今天是母亲节,早上醒来就祝老妈节日快乐。今天也将帮同事带了两周的英短蓝猫送走,还真有点不习惯。之前每次在电脑前写东西看视频,都会在旁边蹭来蹭去,现在隐约只有窗帘在动来动去了。言归正传,上一篇介绍了虚拟机的初试JVM之垃圾收集算法,接下里将看看虚拟机的内存是如何分配的以及垃圾回收的策略。
Java堆 = 新生代 + 老年代
新生代 = Eden区 + Survivor(From) + Survivor(To),它们之间的空间分配为8:1:1。
方法区 = 永久代
先了解它们之间的关系,接下来会介绍它们之间是如何分配内存和垃圾回收的。
说到垃圾回收策略,那就不得不提到垃圾收集器了。如果说上一篇初试JVM之垃圾收集算法介绍的是内存回收的方法论,垃圾收集器就是内存回收的具体实现。下面先来了解HotSpot虚拟机中的7种垃圾收集器,如图:
如果两个收集器之间存在连线,就说明它们可以搭配使用。
在介绍这些收集器各自的特性之前,我们需要明确一个观点:直到现在为止,还没有最好的的收集器出现,更加没有万能的收集器,所以我们选择的只是对具体应用最合适的收集器。如果有一种放之四海而皆准、任何场景下都适用的完美收集器存在,那HotSpot虚拟机就没必要实现这么多不听的收集器了 。
Serial垃圾收集器是最基本、发展历史最悠久的收集器;一个单线程的收集器;更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束。
对于"Stop The World"带给用户的恶劣体验,虚拟机的设计者们表示完全理解,但也表示很委屈:你妈妈在给你打扫房间时,肯定也会让你老老实实的在椅子上或者房间外面待着,如果她一边打扫,你一边丢纸屑,这房间还能打扫完吗?这确实是一个合情合理的矛盾。Serial / Serial Old收集器运行示意图如下:
优点:简单而高效(与其他收集器的单线程比较),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程效率。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。
它其实是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、
-XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都和Serial收集器完全一样,实现这两种收集器也共用了相当多的代码。ParNew(Serial Old)收集器运行示意图如下:
特点:ParNew收集器除了多线程收集外,其他和 Serial收集器没有啥创新之处,但它是目前除了 Serial收集器外,能与CMS收集器配合工作。
ParNew收集器在单CPU的环境中绝对不会有Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中,都不能100%保证能超过Serial收集器。当然随着CPU的数量增加,它对GC时系统资源的利用还是很有好处的。它默认开启的线程数和CPU数量相同,在CPU非常多(譬如32个)的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
Parallel Scavenge收集器也是一个新生代收集器,它使用复制算法,并行的多线程收集器。由于与吞吐量密切相关,也被称为“吞吐量优先”收集器。运行示意图如下:
特点:
它的关注点与其他收集器不同;
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;
而Parallel Scavenge收集器的目标:达到一个可控的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总耗时的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集1分钟,那么吞吐量就是99%。
停顿时间越短就越适合需要和用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供了两个参数用于精准的控制吞吐量:
1、控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis;
MaxGCPauseMillis允许的值是大于0的毫秒数,收集器将尽量保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为把这个参数设置的稍微小点就能使系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间换来的;系统把新生代调小一点,收集300MB新生代肯定比500MB的快吧,这也导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间确实下降, 但吞吐量也降下来了。
2、直接设置吞吐量大小:-XX:GCTimeRatio;
GCTimeRatio的值应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
另一个参数:-XX:+UseAdaptiveSizePolicy;这是一个开关参数,打开后不需要手动指定新生代的大小(-Xmm)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等参数细节了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大吞吐量,这种调节方式称为GC自适应的调节策略。
如果程序猿对收集器运作原理不太了解,可以使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优交给虚拟机去完成。只需要把基本的内存数据设置好(如设置最大堆-Xmx),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或者GCTimeRatio(更关注吞吐量)给虚拟机设立一个优化目标,具体细节就有虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器和ParNew收集器的一个重要区别。
1、Serial收集器的老年代版本;
2、单线程收集器;
3、使用“标记-整理”算法;
4、Client模式下的虚拟机使用;
5、在Server模式下作为CMS收集器的后背预案,在并发收集发生Concurrent Mode Failure的时候使用。
6、运行时示意图同Serial收集器。
1、Parallel Scavenge收集器的老年版本;
2、多线程;
3、使用“标记-整理”算法;
4、在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
运行示意图如下:
以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法实现的,它的运作过程分为4个步骤:
1、初始标记(CMS initial mark):需要“Stop The world”,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
2、并发标记(CMS concurrent mark):并发标记就是进行GC Roots Tracing(上一篇所说的根搜索算法)的过程。
3、重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分的标记记录,停顿时间一般会比初始标记稍微长些,并发标记短。
4、并发清除(CMS concurrent Sweep)
由于整个过程中,耗时最长的并发标记和并发清除中,收集器线程都可以与用户线程一起工作,所以总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。如图运行示意图:
优点:并发收集,低停顿。
缺点:
1、CMS对CPU资源非常敏感;
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。 CMS的默认收集线程数量是=(CPU数量+3)/4;当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
针对这种情况,曾出现了"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS);类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;但效果并不理想,JDK1.6后就官方不再提倡用户使用。
2、无法处理浮动垃圾;
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
也可以认为CMS所需要的空间比其他垃圾收集器大;
“-XX:CMSInitiatingOccupancyFraction”:设置CMS预留内存空间;
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;这样的代价是很大的,所以-XX:+UserCMSInitiatingOccupancyFraction设置的太高很容易导致大量的"Concurrent Mode Failure",性能会降低。
3、由于基于“标记-清除”算法实现的,收集结束时会产生大量空间碎片。
产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。
为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费附送一个碎片整理过程,内存整理的过程是无法并发的。
空间碎片问题解决了,但停顿时间变长了,虚拟机设计者们还提供了一个参数-XX:+CMSFullGCsBeforeCompaction,用于设置在执行了多少次不压缩的Full GC后,跟着来一次带压缩的。
G1收集器是基于“标记-整理”算法实现的,它可以非常精确的控制停顿。
G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于他能够极力的避免全区域的垃圾收集,之前的垃圾回收器进行的都是整个新生代或老年代,而G1将整个Java堆(新生代、老年代)划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收最多的区域(这有是Garbage First名称的来由)。区域划分及优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的效率。
参数 | 描述 |
---|---|
-XX:+UseSerialGC | Jvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收 |
-XX:+UseParNewGC | 打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收 |
-XX:+UseConcMarkSweepGC | 使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用。 |
-XX:+UseParallelGC | Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行回收 |
-XX:+UseParallelOldGC | 使用Parallel Scavenge + Parallel Old的收集器组合进行回收 |
-XX:SurvivorRatio | 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1 |
-XX:PretenureSizeThreshold | 直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
-XX:MaxTenuringThreshold | 晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代 |
-XX:UseAdaptiveSizePolicy | 动态调整java堆中各个区域的大小以及进入老年代的年龄 |
-XX:+HandlePromotionFailure | 是否允许新生代收集担保,进行一次minor gc后, 另一块Survivor空间不足时,将直接会在老年代中保留 |
-XX:ParallelGCThreads | 设置并行GC进行内存回收的线程数 |
-XX:GCTimeRatio | GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效 |
-XX:MaxGCPauseMillis | 设置GC的最大停顿时间,在Parallel Scavenge 收集器下有效 |
-XX:CMSInitiatingOccupancyFraction | 设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70 |
-XX:+UseCMSCompactAtFullCollection | 由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效 |
-XX:+CMSFullGCBeforeCompaction | 设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用 |
-XX:+UseFastAccessorMethods | 原始类型优化 |
-XX:+DisableExplicitGC | 是否关闭手动System.gc |
-XX:+CMSParallelRemarkEnabled | 降低标记停顿 |
-XX:LargePageSizeInBytes | 内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m |
Java所提倡的自动内存管理解决了两个问题:给对象分配内存以及回收分配给对象的内存。说完垃圾收集器,接下里来看看给对象分配内存的那点事儿。
对象的内存分配,往大了讲就是在堆上分配,对象主要分配在新生代的Eden区上,若启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下会直接分配到老年代中,分配规则取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。
接下来介绍的是Serial / Serial Old收集器下的内存分配和回收策略。
大多数情况下对象在新生代中分配。当Eden没有足够的空间进行分配时,虚拟机将会发起一次Minor GC。直接上代码演示如下:
主方法中,尝试分配3个2MB大小和1个4MB大小的对象,
通过-Xms20M -Xmx20M -Xmn10M 这3个参数限制了Java堆的大小为20MB,不可拓展,其中10MB分配给新生代,剩下10MB分配给老年代。
通过-XX:SurvivorRatio=8决定新生代中Eden区与一个Survivor区间大小比为8:1。
public class test {
private static final int _1MB = 1024 * 1024;
/*
* VM启动参数
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public static void main(String[] args) {
byte[] allocation1,allocation2,allocation3,allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
//将出现Minor GC
allocation4 = new byte[4 * _1MB];
}
}
执行后打印结果如下:
[GC (Allocation Failure) [PSYoungGen: 6232K->872K(9216K)] 6232K->4976K(19456K), 0.0022358 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 9216K, used 7503K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 80% used [0x00000000ff600000,0x00000000ffc79e78,0x00000000ffe00000)
from space 1024K, 85% used [0x00000000ffe00000,0x00000000ffeda020,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 4104K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 40% used [0x00000000fec00000,0x00000000ff002020,0x00000000ff600000)
Metaspace used 3134K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 342K, capacity 388K, committed 512K, reserved 1048576K
可以看到执行main方法后分配allocation4对象的时发生了一次Minor GC,GC结果是新生代 6232K变为872K,而总内存占用量几乎没有减少(allocation1、allocation2、allocation3都是活的)。Minor GC的原因是给allocation4分配内存的时候,发现Eden占用了6MB,剩余空间不足以分配allocation4,因此发生Minor GC,Minor GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入Survivor空间(只有1MB),所以通过分配担保机制将Eden中存活的对象提前转移到老年代去。
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
大对象就是指需要大量连续内存的Java对象,最典型的大对象就是那种很长的字符串及数组(例子中的byte[]数组就是典型的大对象)。写代码的时候应该避免“朝生夕死”的“短命大对象”,经常会出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配,目的是避免在Eden区及两个Survivor区之间发生大量的内存拷贝。
直接上代码:
public class test {
private static final int _1MB = 1024 * 1024;
/*
* VM启动参数
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public static void main(String[] args) {
byte[] allocation;
allocation=new byte[4*_1MB];
}
}
这里-XX:PretenureSizeThreshold设置为3MB=3145728B,对象超过3MB就被分配至老年代中。
执行结果如下:
可以看到Eden空间几乎没有被使用,而老年代10MB的空间被使用了40%,也就是4MB的allocation对象直接分配至老年代了。
虚拟机既然采用了分代收集的思想来管理内存,那它是怎样识别哪些对象应当放新生代,哪些对象应当放老年代中的呢。为了做到这点,虚拟机给每个对象定义了一个对象年龄计数器。
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁)时,就会晋升到老年代中。对象晋升老年代的阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
当-XX:MaxTenuringThreshold = 1时,直接上代码:
public class test {
private static final int _1MB = 1024 * 1024;
/*
* VM启动参数
* -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=1
*/
public static void main(String[] args) {
byte[] allocation1,allocation2,allocation3,allocation4;
allocation1=new byte[_1MB / 4];
allocation2=new byte[4*_1MB];
allocation3=new byte[4*_1MB];
allocation3 = null;
allocation4=new byte[4*_1MB];
}
}
执行结果如下:
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 418144 bytes, 418144 total
: 4695K->408K(9216K), 0.0054252 secs] 4695K->4504K(19456K), 0.0054708 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 136 bytes, 136 total
: 4668K->0K(9216K), 0.0013601 secs] 8764K->4504K(19456K), 0.0013867 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4260K [0x32750000, 0x33150000, 0x33150000)
eden space 8192K, 52% used [0x32750000, 0x32b78fe0, 0x32f50000)
from space 1024K, 0% used [0x32f50000, 0x32f50088, 0x33050000)
to space 1024K, 0% used [0x33050000, 0x33050000, 0x33150000)
tenured generation total 10240K, used 4504K [0x33150000, 0x33b50000, 0x33b50000)
the space 10240K, 43% used [0x33150000, 0x335b60a0, 0x335b6200, 0x33b50000)
compacting perm gen total 12288K, used 377K [0x33b50000, 0x34750000, 0x37b50000)
the space 12288K, 3% used [0x33b50000, 0x33bae5c0, 0x33bae600, 0x34750000)
ro space 10240K, 55% used [0x37b50000, 0x380d1140, 0x380d1200, 0x38550000)
rw space 12288K, 55% used [0x38550000, 0x38bf44c8, 0x38bf4600, 0x39150000)
可以看到当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代的内存GC后非常干净的变为0KB。
当-XX:MaxTenuringThreshold = 15时,结果如下:
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 8 (max 8)
- age 1: 418144 bytes, 418144 total
: 4695K->408K(9216K), 0.0036693 secs] 4695K->4504K(19456K), 0.0036983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 8 (max 8)
- age 1: 136 bytes, 136 total
- age 2: 417936 bytes, 418072 total
: 4668K->408K(9216K), 0.0010034 secs] 8764K->4504K(19456K), 0.0010296 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4668K [0x32750000, 0x33150000, 0x33150000)
eden space 8192K, 52% used [0x32750000, 0x32b78fe0, 0x32f50000)
from space 1024K, 39% used [0x32f50000, 0x32fb6118, 0x33050000)
to space 1024K, 0% used [0x33050000, 0x33050000, 0x33150000)
tenured generation total 10240K, used 4096K [0x33150000, 0x33b50000, 0x33b50000)
the space 10240K, 40% used [0x33150000, 0x33550010, 0x33550200, 0x33b50000)
compacting perm gen total 12288K, used 377K [0x33b50000, 0x34750000, 0x37b50000)
the space 12288K, 3% used [0x33b50000, 0x33bae5b8, 0x33bae600, 0x34750000)
ro space 10240K, 55% used [0x37b50000, 0x380d1140, 0x380d1200, 0x38550000)
rw space 12288K, 55% used [0x38550000, 0x38bf44c8, 0x38bf4600, 0x39150000)
当MaxTenuringThreshold=15时,第二次GC发生后,allocation1对象还留在新生代Survivor空间,这时候新生代仍有408k的空间被占用。
为了能更好的适应不同程序的内存情况,虚拟机不一定要求对象年龄到达MaxTenuringThreshold才能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象直接进入老年代,无需等到MaxTenuringThreshold要求的年龄。
在发生Minor GC之前,虚拟机会检测之前每次晋升到老年代的的平均大小是否大于老年代的剩余空间大小。
1、若满足,则改为直接进行一次Full GC。
2、若不满足,则查看HandlePromotionFailure设置是否允许担保失败。若参数为true,允许担保失败,则只会进行Minor GC。若参数为false,则改为进行一次Full GC。
取平均值进行比较仍然是一种动态概率的手段,如果说某次Minor GC后,对象存活突增,远远高于平均值的话,依然会导致担保失败。那就只好在失败后重新发起一次Full GC。虽然出现HandlePromotionFailure失败,但是大部分情况下还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
抬头看向窗外,现在已然是晚上了,不知不觉用了一天的时间看书消化和整理博客,垃圾收集和分配策略到这里基本上就学完。后面还是会继续去学习和使用,不能看过就不管了。接下来,会继续跟着《深入理解Java虚拟机》这本书,去了解内存分析的工具和调优的一些案例。写的很长,能看到这里的都是大佬,各位周末愉快。