公众号:爱码叔漫画软件设计(搜:爱码叔)
个人博客站点: icodebook
博主:爱码叔
专注于软件设计与架构、技术管理。擅长用通俗易懂的语言讲解技术。对技术管理工作有自己的一定见解。文章会第一时间首发在个站上,欢迎大家关注访问!
上一篇文章讲述了在 Java 世界中,对象如何产生和运行(直达电梯)。出生一定会伴随着消亡,这是一个亘古不变不变的自然规律。现实世界中,如果生物没有消亡,那么早晚地球要被撑爆。在程序世界中,如果对象不会消亡,那么等来的也将是 OOM 导致的 “世界” 毁灭。当前这轮文明被彻底消亡,重启后进入下一轮文明。(嗯?有点三体的味道)
那么这次咱们就来聊聊 Java 对象是怎么 “没” 的。本文将讲解如下内容。
1、垃圾收集算法介绍
2、按代收集——新生代
3、按代收集——老年代
按Region管理内存的垃圾收集器 G1 和 Z(也是按代的思想), 咱们以后再做分析。
1 垃圾收集算法介绍
1.1 如何整理书柜
咱们可以打个比方,垃圾收集就像整理书柜。我比较喜欢读书,虽然家中有个比较大的书柜,但依然经常被装满。我每隔一段时间就会整理一次书柜。我书柜的布局如下图:
“新购书籍” 区域摆放我最近购入的书籍,一般是新技术、新热点相关书籍。“常用书籍区域” 摆放的是我近期工作会用到的书籍。“经典书籍“ 区域摆放经典著作,我时不时会拿出来翻看,例如《设计模式》、《重构》等书。
大量新购入的书籍会被我摆放到“新购书籍” 区域。一段时间后,该区域将会摆满了书,没有空余空间。这个时候就要对书柜进行整理,整理的过程类似于 JVM 的垃圾收集。
“新购书籍” 区域的整理方式如下:
- 不会再次阅读的书籍, 我会挑出来,装箱后收到储藏室
- 近期还会用到的书籍,会被我摆放到 “常用书籍” 区域
- 我认为值得长期阅读的经典书籍,我会直接放入“经典书籍” 区域
- 留下来的书籍会被整理好,摆放整齐
在清理的第2,3步,很可能相应的区域也已经没有空间,那么也需要相应的清理。
对于 “常用书籍” 区域整理方式如下。
- 属于经典,值得长期阅读的书籍,会被我放入“经典书籍” 区域
- 近期的工作不再需要,也没有长期阅读价值的书, 会被我装箱后放到储藏室
对于“经典书籍” 区域整理方式如下。
- 已经很久没有看过的书籍, 会被我装箱后放到储藏室
以上对书柜的整理体现了两个思想。
- 按书籍被使用的周期对书柜进行区域划分
- 识别出不再需要的书籍,从书柜上清理掉,装箱封存
- 整理留下来的书籍,让书籍整齐的摆放在应该在的区域。
对书柜的一次整理,就好比进行了一次内存的垃圾收集。能够长期留在书柜中的一定是我长期需要的书籍。内存中的对象也是如此 ,如果熬过了一轮轮的垃圾收集, 那么必将成为 “经典”。
如果你理解了整理书柜的过程,那么你已经理解了基于分代的垃圾收集器 80% 的知识点。对于下面 JVM 垃圾收集的理解会轻松很多。
1.2 内存区域划分
垃圾收集在很长一段时间中都是基于分代的思想管理内存,将内存分为新生代和老年代。不过从 G1 开始,到后来出现的 Z,则是使用Region 的区域概念管理内存。
1.2.1 按代划分内存区域
在书柜的例子中,我的书柜被划分为三个区域。
- “新购书籍”区域(新生代-Eden)
- “常用书籍” 区域(新生代-Survivor)
- “经典书籍” 区域(老年代)
这种区域的划分就是一种分代的思想。相对应的内存区域标注在后面。
1.2.2 按Region划分内存区域
G1和Z垃圾收集器将内存划分为若干个Region。这是一种将大块内存分而治之的思想。虽然在名字上已经抛弃了新生代和老年代,但各个Region其实还在扮演的新生代和老年代,此时老年代和新生代不再固定,而是由若干个Region构成的动态集合。
1.3 垃圾收集算法
整理书柜的方式,其实就是整理书柜的算法。主要的垃圾收集算法有如下几种。我们分别来看。
1.3.1 标记清除
这个算法比较简单,分为标记和清除两个阶段。
- 在标记阶段,标记所有存活对象,这就好比整理书柜时,挑出留下的书籍。
- 清除阶段会将所有未被标记的对象清除。这就好比将不保留的书籍从书柜上拿下来,打包封箱后,可以封存、扔掉、卖废品。
经过这两个阶段,内存中可以被清理的对象已经被清除,内存空间得到了释放。虽然释放内存空间的目的已经达到,但是问题也很明显——产生大量不连续的内存碎片。这就像清理书柜,如果只是把不保留的书拿掉,那么书柜上的空余空间并不连续。这会导致大的对象无法放入内存中,再次触发垃圾收集。
1.3.2 标记整理
针对标记清除造成大量不连续内存碎片的问题,标记整理算法提供了解决方案。它与标记清除的不同在于清除阶段的处理。它并没有直接清除掉可回收对象,而是将需要保留的对象向内存空间一端紧凑对齐,避免碎片空间产生。对齐完成后,再将存活对象边界之外的内存全部清理。
这个过程有点像我在整理书柜时,将留下的书全部排列到书柜一端。然后把后面所有的书打包装箱。
标记整理算法很好的解决了内存碎片问题。但是由于要移动对象,有着更高的成本。移动对象意味着所有对被移动对象引用的地方都需要更新引用值。这就好比整理完书柜后,书已经不在原来的位置,我按照形成的印象位置去找某本书,会发现并不在那里。程序更是如此,如果移动了对象在内存中的位置,必须更新所有引用者持有的引用值,否则程序一定会出错。因此,程序需要暂停,更新对象引用。此时,整个程序世界停止运转,这就是著名的 “Stop The World”。
1.3.3 标记复制
标记清除算法还存在一个不足,未被标记的对象,需要逐一清理。如果存活的对象少,需要清理的对象多,这种方式的效率并不高。标记复制算法使用 “空间换时间” 的办法,解决这个问题。标记复制算法的核心思想是,提供一块额外的内存区域,保持干净未被使用。当垃圾收集发生时,先把存活的对象紧凑地放入这块内存区域,然后将原内存区域一次性清理干净。
标记复制算法适用于存活对象占比小的场景,提升回收效率,并且不会产生内存碎片。但它的缺点是需要额外的内存空间用于对象复制。在极端情况下,内存的使用率只能达到50%。
1.3.4 小结
每种算法都有自身独特的优点和缺点,有其适用的场景。因此,针对对象在内存中不同的驻留周期,需要选择适合的算法进行垃圾收集。
2 按代回收-新生代
Java 程序中的方法被频繁调用,同时伴随着频繁的调用结束。这意味着大量的对象被创建出来,但是随着方法执行完成,这些对象又迅速成为了垃圾对象。有研究表明,新生代98%的对象熬不过一轮垃圾收集。
当一个对象被创建,就像新买回来的一本书,会先进入 “新生代-Eden” 区域。Eden 的意思是伊甸园,意为保存出生不久的对象。
当一轮垃圾收集发生时,Eden 区域绝大多数的对象会被清理掉。这就像新书读完后,只有少部分会被留在书柜上。这部分存活对象会进入新生代的 Survivor 区域。由于存活下来的对象非常少,所以Suvivor区域会比Eden小很多。另外,新生代的垃圾收集算法基于标记复制,所以需要两块 Survivor 区域,这两块 Suvivor 轮流用做复制区域。Eden、Survivor 0、Survivor 1 的默认比例为8:1:1。
每次 GC 发生时,Eden区域和正在使用的 Survivor 区域中存活的对象,会被放入另外一块 Survivor 区域,当前的Eden 和 Survivor 区域被清空。
新生代的垃圾收集器主要有如下几种。
1、Serial 垃圾收集器
最早的垃圾收集器。Serial是一个单线程的垃圾收集器,采用标记复制算法。当它进行垃圾收集时,必须要 “Stop The World”。对于内存资源有限制、或者单核处理器,也有其用武之地。
2、ParNew 垃圾收集器
ParNew时Serial的多线程版本。他和Serial的运行机制几乎完全一样,只是使用了多条线程。它的辉煌是因为CMS垃圾收集器的出现。CMS是一款老年代垃圾收集器,它的厉害之处在于可以让垃圾收集线程和用户线程同时工作,从而降低 “Stop The World” 的发生频率。CMS只能和新生代垃圾收集器 Serial 或者 ParNew 搭配使用,因此大部分相对复杂的场景,用户都会选择ParNew+CMS。在JDK9之后,官方只保留了 ParNew+CMS 的垃圾收集器组合。其余的组合全部取消。
3、Parallel Scavenge 垃圾收集器
Parallel Scavenge 同样基于标记复制算法,也是多线程工作。它和ParNew的区别之处在于,它关注点在于吞吐量。吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集的时间)。它的特点是在用户设置了关注的吞吐量后,会根据监控数据,自动调整参数,例如 Eden 和 Survivor 的比例、晋升老年代的对象大小等,以尽量达到设置的目标。
3 按代回收-老年代
新生代-Eden 区域的对象在熬过一轮垃圾收集后,该对象的年龄+1,将会进入Survivor区域。如果对象在 Survivor 区域又熬过了数轮垃圾收集,年龄达到一定阈值,说明这是一个生命周期较长的对象,将会被移入另外一块内存区域——老年代。老年代和新生代默认大小比例为 2:1。
老年代中的对象来源有如下几种。
- 新生代中的对象熬过N轮垃圾收集后进入老年代
N的默认值为15 ,通过 -XX:MaxTenuringThreshold 参数可以进行调节,但是不能超过15,这是因为对象头中用4 bit 记录对象的年龄,只能记录到 15。
- 大对象直接进入老年代
大对象如果存活时间较长,会在新生代中经历多次复制。这样做的效率极低,复制开销极大。因此,比较好的做法是让大对象直接进入老年代,大对象的判定值可以通过参数 -XX:PretenureSizeThreshold 进行设置。
- 动态判断对象年龄
新生代中的对象年龄并不一定必须达到固定的 N,才会进入老年代。假如存在一个年龄 M,如果占据 Survivor 区域中一半空间大小的对象的年龄都小于 M,那么大于、等于年龄 M 的对象直接进入老年代。换句话讲,当Survivor 区域空间使用超过一半时,必定存在满足条件的年龄M。这个规则可以确保 Survivor 区域有半数以上的空间可用。
老年代的垃圾收集器主要有如下几种。
1、Serial Old 垃圾收集器
Serial 垃圾收集器的老年代版本,同样是单线程,但是老年代适合使用标记整理算法。在 JDK 5 之前,它可以搭配Parallel Scavenge 收集器使用。另外,他是 CMS 垃圾收集器失败后的备选收集器。
2、Parallel Old 垃圾收集器
Parallel Scavenge 垃圾收集器的老年版本,支持多线程,同样使用了标记整理算法。JDK 6后,它才出现,搭配Parallel Scavenge 使用。
3、CMS 垃圾收集器
上面讲到的老年代收集器均使用标记整理算法,而 CMS 垃圾收集器基于标记清除算法,以追求更少的停顿为目标。CMS工作分为如下四个阶段
- 初始标记阶段(需要stop the world,时间很短)
- 并发标记阶段(与用户线程并行)
- 重新标记阶段(需要stop the world,稍长,但远远低于并发标记阶段)
- 并发清除(与用户线程并行)
可以看到 CMS 将标记阶段分为了三步。第一步初始标记阶段,仅标记 GC Root 直接关联的对象,因此停顿很短。第二步与用户线程并发执行,遍历对象图,标记所有可回收对象。这一步最为耗时,但是可以与用户线程并发执行。第三 步用于修正在第二步并发执行期间产生的变动,正常情况下,这一步时间虽然稍长,但还是远远小于第二步。
标记阶段拆分为三步后,最为耗时的第二步不会 “Stop The World”,大大降低了停顿时间。
最后,由于采用了标记清除算法,并不需要移动对象,所以在清除阶段也不会因为移动对象而停顿。
CMS 虽然有着低停顿的优势,但是同样也有如下几个问题。
- 由于和用户线程并发执行,在垃圾收集期间,会影响到用户线程的执行,导致系统变慢。尤其是对于不足 4 核的CPU。这是因为 CMS 默认回收线程数是(CPU 核数+3)/4。
- 在并行处理期间,垃圾并没有被彻底清理完,而是伴随着新垃圾的产生。这部分在清理期间产生的垃圾称为浮动垃圾。在并发清理垃圾的时候,需要为浮动垃圾留有足够多的内存空间,否则会面临OOM的风险。这会导致老年代不能接近用满时才进行回收。但即使如此,还是可能在并发清除的过程中,内存无法容纳新产生的对象。这会导致 “并发失败”,JVM 会启用备案 Serial Old 进行回收。老年代使用比例 的参数为-XX:CMSInitiatingOccupancyFraction。这个比例过高会造成留给浮动垃圾的空间过小,导致频繁启用 Serial Old 回收,性能下降。比例过低则导致老年代使用率低,回收频繁。
- CMS基于标记清除算法,不移动对象,达到了不停顿的目的。但是,标记清除算法会产生内存碎片。CMS有两种方式解决这个问题。第一个是被动方式,当老年代的连续内存空间无法容纳一个大对象时,提前触发一次包括碎片整理的 Full GC。第二种是主动的方式,当经历过 N 次不进行碎片整理的 Full GC 后,触发一次进行碎片整理的 Full GC。
4 按代垃圾收集总结
根据对象生命周期的长短,JVM 为对象划分了不同的存储区域,这样 JVM 可以根据对象的特点采用针对性的回收算法。新生代中的对象有着朝生夕灭的特点,JVM在进行垃圾收集时,存活的对象数量少,复制的工作量较小,因此主要采用标记复制的算法进行回收。老年代中的对象生命周期长,反复进行复制的成本高,因此主要采用标记整理算法。但是标记整理需要 Stop The World,会导致用户线程停顿。CMS收集器,采用标记清除的办法,能够大幅度减少停顿。但是由于标记清除会产生内存碎片,需要定期进行碎片整理。
下图是按代回收的垃圾收集器总结,连线代表可以搭配使用的垃圾收集器。