
“解剖”JVM之垃圾收集器
JVM就是Java虚拟机,说白了就是为了屏蔽底层操作系统的不一致而设计出来的一个虚拟机,让用户更加专注上层,而不用在乎下层的一个产品。这就是JVM的跨平台,一次编译,到处运行。而JVM中的核心功能其实就是自动化的垃圾回收机制。在Java中你使用对象,使用内存,不用担心回收,只管new对象就行了,不用管垃圾的回收。因为Java当中是自动化的垃圾回收机制。JVM有专门的垃圾回收器,把垃圾回收这件事给干
文章目录
前言
JVM就是Java虚拟机,说白了就是为了屏蔽底层操作系统的不一致而设计出来的一个虚拟机,让用户更加专注上层,而不用在乎下层的一个产品。这就是JVM的跨平台,一次编译,到处运行。
而JVM中的核心功能其实就是自动化的垃圾回收机制。
一、分代收集理论
当前虚拟机的垃圾收集都采用分代收集算法,根据对象存活周期的不同将内存分为几块。一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
新生代:每次收集都会有大量对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。就比如新生代中的 S0 和 S1 区域。
老年代:对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
PS:标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。
二、垃圾收集算法
1.标记-复制算法
标记-复制算法是把内存分为大小相等的两块,每次使用其中的一块,当这块内存使用完以后,就会把还存活的对象放到另外一块内存中去,清理刚才使用到的那块空间。分为标记和复制两个阶段:
第一阶段:标记;就是根据Gc Roots标记存活的对象。
第二阶段:复制;把存活的对象复制到另外一块内存中。
缺点:会浪费多余的存储空间。
2.标记-清除算法
标记-清除算法分为标记和清除两个阶段:
第一阶段:标记;就是根据Gc Roots标记存活的对象。
第二阶段:清除;统一回收未被标记的对象。
缺点:
1. 效率问题 (如果需要标记的对象太多,效率不高) ;
3.标记-整理算法
标记-整理算法是根据老年代的特点设计出的一种标记算法。他的标记过程和标记清除算法一样的,后续不同的是,它会先把所有存活的对象规整的移动到一起,然后把边界外的对象统一回收。
三、垃圾回收中的STW
对于Java项目来说,JVM进行垃圾收集会有一个很大的问题,就是STW(Stop The World)。
就是在垃圾回收的时候会把应用线程(项目中运行的线程)给停止,然后进行垃圾回收,如果STW的时间过程,用户就会感觉到明显的卡顿现象。
1.STW带来的问题
(1)安卓手机系统的卡顿
(2)证券交易系统的实时性
证券交易系统的实时性要求很高,一般都是需要低卖高卖,差之毫厘谬以千里,如果STW的时间很长,到达几秒钟的级别,这几秒钟后的市场完全不同了。所以用Java来做证券系统,STW的时间越短越好。
2.垃圾收集器的发展
为了满足不同的业务场景,Java的GC算法和垃圾收集器都在不同的迭代,对于这些延迟敏感的应用来说,GC停顿已经成为了阻碍Java发展的一大顽疾。
近些年来,服务器的性能越来越好,各种应用可以使用的堆内存也越来越大,其实垃圾收集器的发展都是为了解决STW的停顿时间而一直迭代。为了让STW的停顿时间也跟随着堆大小的增长而不呈现指数级的增长而努力,推出了一系列垃圾收集器。
四、垃圾收集器
如果说垃圾回收算法是内存收回的理论,那么垃圾收集器就是内存回收的具体实现。
1.单线程垃圾收集器
(1)Serial
Serial(串行)垃圾收集器,一看名字就知道它是一个单线程收集器。它的单线程的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾手机工作,更重要的是它在进行垃圾手机工作的时候还必须暂停其他的所有工作线程(STW),直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
Serial 虽然是单线程,STW的时间有点长,但是它简单且高效(与其它垃圾收集器相比),它没有线程交互的开销,自然而然的可以获得很高的单线程收集效率。
参数设置:-XX:+UseSerialGC(年轻代) -XX:+UseSerialOldGC(老年代)
(2)Serial Old
Serial Old收集器是Serial收集器的老年代版本,它同样是单线程收集器。
两大用途:
-
一种用途是在JDK1.5 以及以前的版本中与Parallel Scavenge收集器搭配使用。
-
一种用途是作为CMS收集器的后备方案。
2.多线程垃圾收集器
(1)Parallel Scavenge
Parallel收集器是Serial收集器的多线程版本,除了使用多线程垃圾收集以外,其余和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。
新生代采用标记-复制算法,老年代采用标记-整理算法。
参数设置:-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)
(2)Parallel Old
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。
使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
(3)ParNew
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
新生代采用标记-复制算法,老年代采用标记-整理算法。
参数设置:-XX:+UseParNewGC
3.多线程+并发收集器
(1)CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体 验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从它的名字中可以看出它是一种 “标记-清除” 算法的实现。
GC步骤:
- 初始标记(STW):暂停所有的其它线程,期间会STW,并记录GC Roots直接能引用的对象,速度很快。
- 并发标记:并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但 是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
- 重新标记(STW):重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法做重新标记。
- 并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理。
- 并发重置:重置本次GC过程中的标记数据。
优点:并发收集、低停顿。
缺点:
- 对CPU资源敏感(会和服务抢资源);
- 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然通过参数(- XX:+UseCMSCompactAtFullCollection)可以让jvm在执行完标记清除后再做整理 。
- 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并 发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入STW,用serial old垃圾收集器来回收。
核心参数设置:
参数 | 含义 |
---|---|
-XX:+UseConcMarkSweepGC | 启用CMS |
-XX:ConcGCThreads | 并发的GC线程数 |
-XX:+UseCMSCompactAtFullCollection | FullGC之后做压缩整理(减少碎片) |
-XX:CMSFullGCsBeforeCompaction | 多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次 |
-XX:CMSInitiatingOccupancyFraction | 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比) |
-XX:+UseCMSInitiatingOccupancyOnly | 只使用设定的回收阈值 (-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整 |
-XX:+CMSScavengeBeforeRemark | 在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引 用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段 |
-XX:+CMSParallellnitialMarkEnabled | 表示在初始标记的时候多线程执行,缩短STW |
-XX:+CMSParallelRemarkEnabled | 在重新标记的时候多线程执行,缩短STW |
(2)G1
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC 停顿时间要求的同时,还具备高吞吐量性能特征。
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。
每个Region大小等于堆除以Region的个数,当然也可以用参数(-XX:G1HeapRegionSize)手动指定,但是不推荐。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。
默认年轻代对堆内存的占比是5%,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
G1对于大对象有特殊的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
GC流程:
初始标记(STW):暂停所有的其它线程,并记录下GC Roots直接能引用的对象,速度很快;
并发标记:同CMS的并发标记;
最终标记(STW):同CMS的并发标记;
筛选回收(STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期 望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划。
:比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200ms,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回 收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
- Yong GC:YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时 间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
- Mixed GC:不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的 Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够 的空region能够承载拷贝对象就会触发一次Full GC
- Full GC:停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这 个过程是非常耗时的。
- 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop- The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式 让java程序继续执行。
- 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
- 空间整合:与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器;从局部 上来看是基于“复制”算法实现的。
- 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了 追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"- XX:MaxGCPauseMillis"指定)内完成垃圾收集。默认的停顿目标为200ms。
核心参数设置:
参数 | 含义 |
---|---|
-XX:+UseG1GC | 使用G1收集器 |
-XX:ParallelGCThreads | 指定GC工作的线程数量 |
-XX:G1HeapRegionSize | 指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区 |
-XX:MaxGCPauseMillis | 目标暂停时间(默认200ms) -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%) |
-XX:G1MaxNewSizePercent | 新生代内存最大空间 |
-XX:TargetSurvivorRatio | Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个 年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代 |
-XX:MaxTenuringThreshold | 最大年龄阈值(默认15) |
-XX:InitiatingHeapOccupancyPercent | 老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合 收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了 |
-XX:G1MixedGCLiveThresholdPercent | (默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这 个值,存活对象过多,回收的的意义不大。 |
-XX:G1MixedGCCountTarget | 在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一 会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。 |
-XX:G1HeapWastePercent | (默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都 是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清 理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立 即停止混合回收,意味着本次混合回收就结束了。 |
- 50%以上的堆被存活对象占用。
- 对象分配和晋升的速度变化非常大。
- 垃圾回收时间特别长,超过1秒。
- 8GB以上的堆内存(建议值) 。
- 停顿时间是500ms以内。
(3)ZGC
ZGC(The Z Garbage Collector)是JDK 11中推出的一款追求极致低延迟的垃圾收集器,它曾经设计目标包括:
- 停顿时间不超过10ms(JDK16已经达到不超过1ms);
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆,JDK15后已经可以支持16TB。
如果使用ZGC来做Java项目,像对STW敏感的证券系统,游戏的系统都可以去用Java来做(以前都是C或者C++的 市场),所以ZGC的出现就是为了抢占其他语言的市场(卷!)。
ZGC的内存布局
ZGC收集器是一款基于Region内存布局的, 暂时不设分代的, 使用了读屏障、 颜色指针等技术来实现可并发的标记-整理算法的, 以低延迟为首要目标的一款垃圾收集器。
ZGC的Region可以具有如图所示的大、 中、 小三类容量:
- 小型Region(Small Region): 容量固定为2MB, 用于放置小于256KB的小对象。
- 中型Region(Medium Region): 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region): 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作, 用于复制对象的收集器阶段, 稍后会介绍到)的, 因为复制一个大对象的代价非常高昂。
为什么要这么设计呢?
标准大页(huge page)是Linux Kernel 2.6引入的,目的是通过使用大页内存来取代传统的4KB内存页面,以适应越来越大的系统内存,让操作系统可以支持现代硬件架构的大页面容量功能。
Huge pages 有两种格式大小: 2MB 和 1GB , 2MB 页块大小适合用于 GB 大小的内存, 1GB 页块大小适合用于 TB 级别的 内存; 2MB 是默认的页大小。所以ZGC这么设置也是为了适应现代硬件架构的发展,提升性能。
ZGC的核心概念
指针着色技术
- 颜色指针可以说是ZGC的核心概念。因为他在指针中借了几个位出来做事情,所以它必须要求在64位的机器上 才可以工作。并且因为要求64位的指针,也就不能支持压缩指针。
- ZGC中低42位表示使用中的堆空间。
- ZGC借几位高位来做GC相关的事情(快速实现垃圾回收中的并发标记、转移和重定位等) 。
为什么会有两个Mark标志位(M0和M1)?
- 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指 向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
- 颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
- 颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
读屏障:之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。 在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。
ZGC的GC流程
分为两个阶段:标记阶段和转移阶段。
标记阶段:
- 初始标记(STW):初始标记只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,停顿时间不会随着 堆的大小或者活跃对象的大小而增加。
- 并发标记:扫描剩余的所有对象,这个处理时间比较长,所以走并发,业务线程与GC线程同时运行。 但是这个阶段会产生漏标问题。
- 再标记(STW):主要处理漏标对象,通过SATB算法解决(G1中的解决漏标的方案)。
转移阶段:
- 并发转移准备:分析最有价值GC分页。
- 初始转移(STW):转移初始标记的存活对象同时做对象重定位。
- 并发转移:对转移并发标记的存活对象做转移。
ZGC存在的问题:ZGC最大的问题是浮动垃圾。ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC 全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只 能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
解决方案:目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根 本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频 繁、更快的收集。
ZGC触发时机:
- 定时触发,默认为不使用,可通过ZCollectionInterval参数配置。
- 预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要时统计GC时间,为其他GC机制使用。
- 分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点, 在耗尽之前触发GC(耗尽时间 - 一次GC最大持续时间 - 一次GC检测周期时间)。
- 主动触发,(默认开启,可通过ZProactive参数配置) 距上次GC堆内存增长10%,或超过5分钟时,对比距上 次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发。
总结
本章主要讲了JVM中垃圾收集器的发展,从单线程到多线程,再到GC线程和用户线程并发收集的垃圾收集器。
如何选择垃圾收集器:
更多推荐
所有评论(0)