大白话讲解JVM调优(基础篇)
原创不易,喜欢的话动动发财的小手点个赞,收个藏吧
1.概述
在开始之前先来说下咱们JVM调优主要是调的啥?毫无疑问,调优就是调的咱们JVM运行时内存大小+gc垃圾回收细节,要想调整JVM运行时内存大小,需要我们知道JVM内存划分知识,要想调整gc垃圾回收的一些细节,需要我们知道一些垃圾回收器工作原理,以及它们使用的垃圾回收算法,还有知道垃圾回收的一个流程,这样才能根据自己项目的具体情况来调整,这里有个调优原则就是能在年轻代回收掉的不要留到老年代,减少Full GC 次数,如果这个原则里面的一些概念不懂的话也没关系,下面我们都会有介绍,接下来我们先来介绍下调优前的一些必备知识点,后面的篇章会介绍具体的调优实战。
2.涉及的知识点
2.1 JVM内存划分
我们先来看下下面这个JVM运行时内存划分图
我们来介绍下各个区域都是干啥的
-
堆(heap):这块区域主要存放对象的,比如说我们程序中new User()对象它就会被分配到这块区域,这块区域是共享的,也就是所有线程都可以访问该区域的对象,堆也是垃圾回收主要区域。
-
java虚拟机栈 (vm stack): 这块区域存放我们线程运行时的一些数据,它是每个线程私有的,可以理解为一个线程就对应的着一个java虚拟机栈,它里面就是一个个的栈帧组成的,一个方法就是一个栈帧,栈帧里面有局部变量表,操作数栈,常量池的引用,方法返回地址等,比如下面这段代码:
public static void main(String[] args) {
int a=1;
int b=2;
int c= a+b;
System.out.println(c);
c=0;
}
这段代码就是计算a+b 为c,然后调用println方法打印c的值。
这里面 a,b,c 就在局部变量表中,在算c的时候就是使用的操作数栈算的,然后调用println方法打印c其实就是往java 虚拟机栈 压入一个println 方法的栈帧。
println栈帧中会保存着一个返回地址(也就是方法返回地址),当println方法执行完成后,会进行弹栈,然后程序跳到返回地址位置,也就是c=0 位置继续执行。
- 本地方法栈(native stack):它与java虚拟机栈功能差不多,只不过java虚拟机栈是运行的java方法,而本地方法栈主要是执行的native方法。在hotspot虚拟机中将本地方法栈与java虚拟机栈合二为一。
- 元数据(Metaspace):这个是java8 的新概念,以前叫方法区,主要是存放着我们的class类信息,我们常说的类加载器其实就是将class文件加载到这个区域,这个区域还保存着我们定义的常量,也就是常量池。
- 程序计数器(Program Counter Register):它也叫做pc寄存器,每个线程一个程序计数器,它保存着当前线程执行的指令地址,当执行的是java 方法的时候,程序计数器保存的是当前需要执行的指令地址,当执行的是native 方法的时候,程序计数器保存的就是undefined。
2.2 回收算法
2.2.1 垃圾回收的介绍
在介绍垃圾回收算法之前,我们要解释下什么是垃圾回收,哪些内存需要回收,怎样的算是垃圾这三个问题
1.什么是垃圾回收
先来说下什么是垃圾回收,比如说我们在代码新起的线程中new 了以User对象,看下面这段代码
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
User user = new User();
//do something
}
}).start();
//do something
//.........
}
private static class User{
private String name;
private int age;
}
然后它就会在堆(heap)区中为这个User对象划分一块内存,当这个线程执行完成之后,这个User对象就用不到了,就成了垃圾,你把它占用的这块内存释放掉就是垃圾回收。说白了垃圾回收就是释放那些用不着的对象(这个用不着的对象就是我们平常说的垃圾)占用的内存。
2.哪些内存需要回收(哪些内存会产生垃圾)
我们在2.1小节中介绍了JVM运行时的内存划分,
3.怎样辨别垃圾
最简单的解释,就是一个对象没有地方用着了就算是垃圾。怎样判断一个对象有没有被用着了呢? 其实最早的时候就是给某个对象引用计数器,有一个地方引用它,计数器就+1,不引用了就-1,这样做有个好处就是快,垃圾回收的时候只需要判断一下它这个引用计数器是不是0就可以了,但是有个问题就是那种相互引用的对象没法回收,比如说A对象 里面有个成员是B对象,B对象里面有个成员是A对象,然后A,B对象的引用计数器都是1,但是实际上已经没有地方用到这两个对象,还无法被回收掉,这个引用计数的方法就是引用计数算法。
我们再来了解下另一个辨别垃圾的方法,可达性分析算法,就是从一个点开始,然后顺着往下找,沿途的对象就是有用的,剩下的就是无用的,这个点就是GCRoot。
这个GCRoot都有哪些呢?
- 虚拟机栈方法中引用的对象(栈帧的局部变量表)
- 静态成员引用的对象
- 常量引用的对象
- 本地方法栈中(Native方法)引用的对象
我们举个例子(虚拟机栈方法中引用的对象) :
public class GCTest {
public static void main(String[] args) {
createUser();
try {
TimeUnit.SECONDS.sleep(60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void createUser() {
User user = new User();
}
}
class User{
private String name;
private String des;
}
main方法中,先调用了createUser方法,创建了User对象,我们用图来表示下:
这时候createUser 的user变量就是个GCRoot,顺着它往下找就能找到在堆中的User对象,这时候User对象是个有用的对象,一旦createUser执行完,也就是createUser栈帧弹栈,这个User对象没有GCRoot能够到达它了,他就成了无用的,也就是我们常说的垃圾。
再来个静态成员引用的例子:
public class GCTest {
private static User user =new User();
}
class User{
private String name;
private String des;
}
我们知道,static修饰的成员属于类成员,在这里也就是GCTest.class的成员,那么GCTest.class存在哪呢,我们在介绍2.1JVM运行时内存划分的时候说过元数据区域是存放class对象的,GCTest.class 就是存放在这。
这个静态成员user就是个GCRoot,user引用着User对象,如果User对象里面还引用着对象A,A引用着对象B…
那么这一串都属于有用对象,而且这个静态成员引用的对象一般不会被回收,元数据区这块回收的条件非常苛刻,等着我们在介绍永久代回收的时候讲到。
常量引用的对象已经native栈引用的对象也同理,这里就不展开介绍了。
2.2.2 标记清除
标记清除算法分为先根据GCRoot找出存活的对象,并进行标记,标记完成后,扫描整个空间,然后对未标记的对象进行回收。
由于user 引用着User对象,bytes引用着byte[]数组,所以这两个对象被标记。标记完成后就会扫描整个空间,然后回收未标记的对象
由于直接回收未标记的对象,会导致内存碎片的产生,为什么这么说呢,如果直接回收的对象占空间很小,然后程序新创建的对象占用的空间比较大,就没法重复利用这块小内存,如果这种小内存非常多的话,就会浪费很多内存。
2.2.3 标记整理
标记整理也分两部分,先是标记,根据GCRoot找出活着的对象并标记
然后就是将这些活着的对象靠左边的空闲空间移动,并更新对应的指针,并清除未被标记的对象。
我们可以看到它跟标记清除算法差不多,但是在清除之前多了一步整理的工作。这个整理的工作增加了成本,但是解决了标记清除算法的内存碎片问题
2.2.4 复制算法
复制算法,是将内存分为两块,一块内存存放创建的对象,一块内存空着,回收的时候,根据GCRoot找到活着的对象,然后将它们复制到那块空着的内存上,之后把存放创建对象那块内存清了。之后分配对象在存活对象的那块内存上分配。这样子解决了内存碎片问题,同时效率能够提升,唯一不好的地方就是费内存。
2.2.5 分代回收
我们先来看下这个图
通过这个JVM运行时数据区域图我们可以看到,堆区被分成了年轻代与老年代两部分,通过字面意思可以猜到,年轻代主要就是存放新创建的对象的,然后老年代主要是存放一些老对象的,我们还可以看到这个我们这个元数据区域变成了永久代,这个永久代好像是存放永久存活的对象的。我们再看下下面这个图:
可以看到年轻代被划分成了Eden, From ,to三个区域,这三个区域默认大小比例是8:1:1 , 这三个区域是怎么回事呢,这里其实是个复制算法的优化版,前面我们说了复制算法会浪费内存,然后这里对它进行了优化,当我们在代码中执行new User(创建对象)的时候,JVM会帮我们在Eden申请一块内存存放User对象,当Eden和From满了的时候,就会触发年轻代的垃圾回收,这时候会根据GCRoot找到存活的对象,然后将这些存活的对象复制到to区域,然后清除Eden与From区域,当有新建对象的时候,会被分配到Eden或to区域,等着Eden与to区域满了的时候,还是根据GCRoot找出存活的对象,然后复制到From区域,最后将Eden与to清空。我们可以看到From与to这两块内存总有一块内存会空着,也就是不使用,这样浪费一小块内存,从而达到高效率的收益。
大家有没有想下三个区域默认大小比例为啥是8:1:1呢?
因为,我们创建的对象大多数都是朝生夕死的,所以在每次GC的时候使用一小块内存来存放活着的对象,来个官方一点的说法:
IBM论文里说据他们统计95%的对象朝生夕死一样存活时间极短,为了保险默认实际使用了90%。
关于具体的垃圾回收流程我们在优化篇详细讲解。