目录
3.虚拟机栈(Java Virtual Machine Stacks)
4.程序计数器(Program Counter Register)
java运行时数据区
划分成如下图所示:
1.java堆(Java Heap)
Java堆是java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块区域,在虚拟机启动的时候创建,用于存放对象实例,该区域也是垃圾收集器管理的主要区域;该区域还可以被细分为:新生代和老年代,新生代再细致一点有Eden区,From Survivor区和To Survivor区等,这样划分的目的是为了更好的回收内存,或者更快的分配内存(后面会有单独的博文去说)
java堆可以通过-Xmx和-Xms来指定大小,如果该区域没有内存完成实例分配,将会抛出OOM(OutOfmemoryError)异常。
新生代与老年代的比列默认为1:2( 该值可以通过参数 –XX:NewRatio 来指定 )
新生代又分:Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分,默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 )
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
新生代是垃圾回收的频繁区域,当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。
但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代。
From Survivor区域与To Survivor区域是交替切换空间,在同一时间内两者中只有一个不为空。
JVM运行时堆的大小: -Xms: 堆空间的最小值 -Xmx :堆空间的最大值
新生代堆空间的大小调整:-XX:NewSize 新生代的最小值
-XX:MaxNewSize 新生代的最大值
-XX:NewTatio 设置新生代与老年代的比例
-XX:SurvivorTatio 新生代中各区所占比列
老年代大小调整 : -XX:MaxPermSize
设置将新生代对象转到老年代时需要经过多少次垃圾回收 -XX:MaxTenuringThreshold
2.方法区(Method Area)
也是被所有线程共享的一块区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(即存储每一个类的结构);
该区域也有人称之为“永久代”,但是相对而言,垃圾收集行为在该区域是比较少出现的,这块区域的内存回收目标主要是针对常量池的回收和对类型的卸载(类型的卸载条件非常苛刻),该区域同样也会出现OOM
3.虚拟机栈(Java Virtual Machine Stacks)
线程私有,生命周期与线程相同,虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完毕,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址),局部变量表所需的内存空间大小在编译期间完成分配。
操作数栈:
每个帧包含一个后进先出的栈,用于存储正在执行的jvm指令的操作数,就是都熟知的操作数栈,这个栈的最大深度在编译时就已确定,随着编译后的方法代码一起提供。
当帧被创建时,操作数栈是空的,jvm提供一些指令用于加载常量值,本地变量值,字段值到操作数栈上,另一些jvm指令采用操作数栈上的操作数进行操作,并把结果放回到操作数栈上。
操作数栈也用于准备将要传递给方法调用的参数和接收方法调用返回的结果。
long和double类型的值占用两个单位的栈深度,其它类型的值占用一个单位的栈深度。
动态链接:
每一个帧都包含了对当前方法所属类型的运行时常量池的引用。目的是为了支持方法代码的动态链接。class文件中描述一个方法引用被调用的方法和被访问的变量的代码,是采用符号引用的形式实现的。
符号引用的形式可以粗略的认为是字符串的形式,就是用字符串标明需要调用哪个类的哪个方法或访问哪个字段或变量。就像符号引用这个名字一样,这些仅仅是符号,是拿不到具体值的,所以必须要进行转换。
动态链接就是把这些符号方法引用转换为具体的方法引用,在必要时加载类来解析尚未明确的符号,把符号变量的访问转换为这些变量运行时所在存储结构的适合的偏移量(索引)。这样的方式又称为后期绑定
方法出口:
一个方法调用正常完成(即没有抛异常)时,会根据所返回的值的类型执行一个适合的return指令,当前帧会去恢复调用者的状态,包括它的本地变量和操作数栈,使调用者的程序计数器适合的递增来跳过刚刚的那个方法调用指令。
返回值会被放到调用者帧的操作数栈上,然后继续执行调用者方法的帧。
一个方法在调用时抛出了异常,且这个异常没有在这个方法内被捕获处理,将会导致这个方法调用的突然结束,这种情况下永远不会向方法的调用者返回一个值
该区域会出现OOM和StackOverflowError异常
4.程序计数器(Program Counter Register)
是一块较小的内存空间,是当前线程所执行的字节码的行号指示器。此内存区域事JVM规范中没有规定任何OOM情况的区域。
5.本地方法栈(Native Method Stack)
native方法不是用Java语言写的,为了支持它需要使用传统栈,如C语言栈。不过jvm不能加载native方法,所以也不需要提供native方法需要的栈
垃圾回收
虚拟机栈:描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢,主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息,方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据,入栈出栈的时机很明确,所以这块区域不需要进行 GC。
本地方法栈:与虚拟机栈功能非常类似,主要区别在于虚拟机栈为虚拟机执行 Java 方法时服务,而本地方法栈为虚拟机执行本地方法时服务的。这块区域也不需要进行 GC
程序计数器:记录线程运行时的状态,方便线程被唤醒时能从上一次被挂起时的状态继续执行,需要注意的是,程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域,所以这块区域也不需要进行 GC
本地内存:线程共享区域,Java 8 中,本地内存,也是我们通常说的堆外内存,包含元空间和直接内存,注意到上图中 Java 8 和 Java 8 之前的 JVM 内存区域的区别了吗,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能(发生 GC 会发生 Stop The Word,造成性能受到一定影响),也就不存在由于永久代限制大小而导致的 OOM 异常了(假设总内存2G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。综上所述,在 Java 8 以后这一区域也不需要进行 GC
知道了 GC 主要发生在堆,那么 GC 该怎么判断堆中的对象实例或数据是不是垃圾呢,或者说判断某些数据是否是垃圾的方法有哪些?
引用计数法:
对象被引用一次,在它的对象头中加一次引用次数,如果没有被引用(引用次数为0),则该对象可被回收
如:String s= new String("cjian"); 则引用次数为1, s=null; 则引用次数为0,则可以被回收。
但是引用计数法有一个问题,那就是无法解决循环引用,如下代码:
public class Test {
private Test test;
public Test() {
}
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a = b.test;
b = a.test;
a = null;
b = null;
}
}
虽然 a,b 都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。
可达性算法
现代虚拟机基本都是采用这种算法来判断对象是否存活,可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。。。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。
如图示,如果用可达性算法即可解决上述循环引用的问题,因为从GC Root 出发没有到达 a,b,所以 a,b 可回收
a, b 对象可回收,就一定会被回收吗?并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!
注意: finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!
哪些对象可以作为 GC Root 呢:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
虚拟机栈中引用的对象:
a 是栈帧中的本地变量,当 a = null 时,由于此时 a 充当了 GC Root 的作用,a 与原来指向的实例 new Test() 断开了连接,所以对象会被回收。
public class Test {
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
方法区中类静态属性引用的对象
当栈帧中的本地变量 a = null 时,由于 a 原来指向的对象与 GC Root (变量 a) 断开了连接,所以 a 原来指向的对象会被回收,而由于我们给 s 赋值了变量的引用,s 在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!
public class Test {
public static Test s;
public static void main(String[] args) {
Test a = new Test();
a.s = new Test();
a = null;
}
}
方法区中常量引用的对象
常量 s 指向的对象并不会因为 a 指向的对象被回收而回收
public class Test {
public static final Test s = new Test();
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
本地方法栈中 JNI 引用的对象
所谓本地方法就是一个 java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使 JAVA 可以实现和本地机器的紧密联系,调用系统级的各接口方法
当调用 Java 方法时,虚拟机会创建一个栈桢并压入 Java 栈,而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。
JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {
...
// 缓存String的class
jclass jc = (*env)->FindClass(env, STRING_PATH);
}
当 java 调用以上本地方法时,jc 会被本地方法栈压入栈中, jc 就是我们说的本地方法栈中 JNI 的对象引用,因此只会在此本地方法执行完成后才会被释放。
垃圾回收算法
垃圾是怎么回收的呢?主要有以下几种:
标记清除算法
复制算法
标记整理法
标记清除算法
标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2步,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉。就像上图一样,清理掉的垃圾就变成未使用的内存区域,等待被再次使用。
这逻辑再清晰不过了,并且也很好操作,但它存在一个很大的问题,那就是内存碎片。上图中等方块的假设是 2M,小一些的是 1M,大一些的是 4M。等我们回收完,内存就会切成了很多段。我们知道开辟内存空间时,需要的是连续的内存区域,这时候我们需要一个 2M的内存区域,其中有2个 1M 是没法用的。这样就导致,其实我们本身还有这么多的内存的,但却用不了。
复制算法
复制算法(Copying)是在标记清除算法上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效。
上面的图很清楚,也很明显的暴露了另一个问题,合着我这140平的大三房,只能当70平米的小两房来使?而且每次回收也要把存活对象移动到另一半,效率低代价实在太高。
标记整理法
前面两步和标记清除法一样,不同的是它在标记清除法的基础上添加了一个整理的过程 ,即将所有的存活对象都往一端移动,紧邻排列(如图示),再清理掉另一端的所有区域,这样的话就解决了内存碎片的问题,但是缺点也很明显:每进一次垃圾清除都要频繁地移动存活的对象,在效率上比复制算法要差很多。
分代收集算法
分代收集算法综合了这些算法的优点,最大程度上避免它们的缺点,是现代虚拟机采用的首选算法,与其说它是算法,倒不如说它是一种策略,因为它是把上述几种算法整合在了一起(新生代复制算法,老年代标记-清除或者标记-整理):
Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清理或者标记-整理算法来进行回收
分代收集工作原理:
1、对象在新生代的分配与回收
由以上的分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区
当 Eden 区将满时,触发 Minor GC
由于Eden区的大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间,动图如下
当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。
若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。
YGC是什么时候触发的?
大多数情况下,对象直接在年轻代中的Eden区进行分配,如果Eden区域没有足够的空间,那么就会触发YGC(Minor GC),YGC处理的区域只有新生代。因为大部分对象在短时间内都是可收回掉的,因此YGC后只有极少数的对象能存活下来,而被移动到S0区(采用的是复制算法)。
当触发下一次YGC时,会将Eden区和S0区的存活对象移动到S1区,同时清空Eden区和S0区。当再次触发YGC时,这时候处理的区域就变成了Eden区和S1区(即S0和S1进行角色交换)。每经过一次YGC,存活对象的年龄就会加1。
2、对象何时晋升老年代
- 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代
如图示:年龄阈值设置为 15, 当发生下一次 Minor GC 时,S0 中有个对象年龄达到 15,达到我们的设定阈值,晋升到老年代!
-
大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.
-
还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
3、空间分配担保
在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,那么Minor GC 可以确保是安全的,如果不大于,那么虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则进行 Minor GC,否则可能进行一次 Full GC。
4、Stop The World
如果老年代满了,会触发 Full GC, Full GC 会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World(简称 STW),造成挺大的性能开销。
什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
PS:为啥在垃圾收集期间其他工作线程会被挂起?想象一下,你一边在收垃圾,另外一群人一边丢垃圾,垃圾能收拾干净吗?
一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理整个堆中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。
现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。
由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:
-
循环的末尾
-
方法返回前
-
调用方法的 call 之后
-
抛出异常的位置 ,另外需要注意的是由于新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间)所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收。
FGC又是什么时候触发的?
下面4种情况,对象会进入到老年代中:
YGC时,To Survivor区不足以存放存活的对象,对象会直接进入到老年代。
经过多次YGC后,如果存活对象的年龄达到了设定阈值,则会晋升到老年代中。
动态年龄判定规则,To Survivor区中相同年龄的对象,如果其大小之和占到了 To Survivor区一半以上的空间,那么大于此年龄的对象会直接进入老年代,而不需要达到默认的分代年龄。
大对象:由-XX:PretenureSizeThreshold启动参数控制,若对象大小大于此值,就会绕过新生代, 直接在老年代中分配。
当晋升到老年代的对象大于了老年代的剩余空间时,就会触发FGC(Major GC),FGC处理的区域同时包括新生代和老年代。除此之外,还有以下4种情况也会触发FGC:
老年代的内存使用率达到了一定阈值(可通过参数调整),直接触发FGC。
空间分配担保:在YGC之前,会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果小于,说明YGC是不安全的,则会查看参数 HandlePromotionFailure 是否被设置成了允许担保失败,如果不允许则直接触发Full GC;如果允许,那么会进一步检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于也会触发 Full GC。
Metaspace(元空间)在空间不足时会进行扩容,当扩容到了-XX:MetaspaceSize 参数的指定值时,也会触发FGC。
System.gc() 或者Runtime.gc() 被显式调用时,触发FGC。
垃圾收集器种类
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范并没有规定垃圾收集器应该如何实现,因此一般来说不同厂商,不同版本的虚拟机提供的垃圾收集器实现可能会有差别,一般会给出参数来让用户根据应用的特点来组合各个年代使用的收集器,主要有以下垃圾收集器(连线表示可以可以配合使用)
新生代的垃圾回收器:Serial、ParNew、Parallel Scavenge
老年代的垃圾回收器:CMS、Serial Old、Parallel Old
既可以在新生代也可以在老年代:G1
新生代收集器
Serial 收集器
Serial 收集器是工作在新生代的,单线程的垃圾收集器,单线程意味着它只会使用一个 CPU 或一个收集线程来完成垃圾回收,而且它在进行垃圾收集时,其他用户线程会暂停(STW),直到垃圾收集结束,也就是说在 GC 期间,此时的应用不可用。
看起来单线程垃圾收集器不太实用,不过我们需要知道的任何技术的使用都不能脱离场景,在 Client 模式下,它简单有效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 单线程模式无需与其他线程交互,减少了开销,专心做 GC 能将其单线程的优势发挥到极致,另外在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆(仅是新生代的内存,桌面应用基本不会再大了),STW 时间可以控制在一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的,所以对于运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器
ParNew 收集器
ParNew 收集器是 Serial 收集器的多线程版本,除了使用多线程,其他像收集算法,STW,对象分配规则,回收策略与 Serial 收集器完成一样,在底层上,这两种收集器也共用了相当多的代码,它的垃圾收集过程如下
ParNew 主要工作在 Server 模式,我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了 STW 时间,能提升响应时间,所以是许多运行在 Server 模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作,CMS 是一个划时代的垃圾收集器,是真正意义上的并发收集器,它第一次实现了垃圾收集线程与用户线程(基本上)同时工作,它采用的是传统的 GC 收集器代码框架,与 Serial,ParNew 共用一套代码框架,所以能与这两者一起配合工作,而后文提到的 Parallel Scavenge 与 G1 收集器没有使用传统的 GC 收集器代码框架,而是另起炉灶独立实现的,另外一些收集器则只是共用了部分的框架代码,所以无法与 CMS 收集器一起配合工作。
在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器一样,它有啥特别之处吗
关注点不同,CMS 等垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),也就是说 CMS 等垃圾收集器更适合用到与用户交互的程序,因为停顿时间越短,用户体验越好,而 Parallel Scavenge 收集器关注的是吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的 -XX:MaxGCPauseMillis 参数及直接设置吞吐量大小的 -XX:GCTimeRatio(默认99%)
除了以上两个参数,还可以用 Parallel Scavenge 收集器提供的第三个参数 -XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与 Survivor 比例(SurvivorRatio)等细节,只需要设置好基本的堆大小(-Xmx 设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机就会根据当前系统运行情况收集监控信息,动态调整这些参数以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标。自适应策略也是 Parallel Scavenge 与 ParNew 的重要区别!
老年代收集器
Serial Old 收集器
上文我们知道, Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器,此收集器的主要意义在于给 Client 模式下的虚拟机使用,如果在 Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用(后文讲述),它与 Serial 收集器配合使用示意图如下
Parallel Old 收集器
Parallel Old 是相对于 Parallel Scavenge 收集器的老年代版本,使用多线程和标记整理法,两者组合示意图如下,这两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标
CMS 收集器
CMS 收集器是以实现最短 STW 时间为目标的收集器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择!
我们之前说老年代主要用标记整理法,而 CMS 虽然工作于老年代,但采用的是标记清除法,主要有以下四个步骤
-
初始标记
-
并发标记
-
重新标记
-
并发清除
从图中可以的看到初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记 GC Roots 能关联的对象,速度很快,并发标记是进行 GC Roots Tracing 的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。
整个过程中耗时最长的是并发标记和标记清理,不过这两个阶段用户线程都可工作,所以不影响应用的正常使用,所以总体上看,可以认为 CMS 收集器的内存回收过程是与用户线程一起并发执行的。
但是 CMS 收集器远达不到完美的程度,主要有以下三个缺点
-
CMS 收集器对 CPU 资源非常敏感 原因也可以理解,比如本来我本来可以有 10 个用户线程处理请求,现在却要分出 3 个作为回收线程,吞吐量下降了30%,CMS 默认启动的回收线程数是 (CPU数量+3)/ 4, 如果 CPU 数量只有一两个,那吞吐量就直接下降 50%,显然是不可接受的
-
CMS 无法处理浮动垃圾(Floating Garbage),可能出现 「Concurrent Mode Failure」而导致另一次 Full GC 的产生,由于在并发清理阶段用户线程还在运行,所以清理的同时新的垃圾也在不断出现,这部分垃圾只能在下一次 GC 时再清理掉(即浮云垃圾),同时在垃圾收集阶段用户线程也要继续运行,就需要预留足够多的空间来确保用户线程正常执行,这就意味着 CMS 收集器不能像其他收集器一样等老年代满了再使用,JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
-
CMS 采用的是标记清除法,上文我们已经提到这种方法会产生大量的内存碎片,这样会给大内存分配带来很大的麻烦,如果无法找到足够大的连续空间来分配对象,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
G1(Garbage First) 收集器
G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点
-
像 CMS 收集器一样,能与应用程序线程并发执行。
-
整理空闲空间更快。
-
需要 GC 停顿时间更好预测。
-
不会像 CMS 那样牺牲大量的吞吐性能。
-
不需要更大的 Java Heap
与 CMS 相比,它在以下两个方面表现更出色
-
运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
-
在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。
为什么G1能建立可预测的停顿模型呢,主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配就像我们前文所述,是连续的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下
而 G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址,如图示
除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?
传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。
G1 收集器的工作步骤如下
-
初始标记
-
并发标记
-
最终标记
-
筛选回收
可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。
本文参考:https://mp.weixin.qq.com/s/_AKQs-xXDHlk84HbwKUzOw
https://mp.weixin.qq.com/s/pR7U1OTwsNSg5fRyWafucA
《深入理解 Java 虚拟机》