JVM GC 总结
周志明大大的《深入理解Java虚拟机》出第三版了,早早的买了这本书,却一直没有花时间看。近来抽空温习了一下,感觉又有了新的收获。这里简单总结下。
GC的由来
由于堆的动态性,操作系统将堆交由给了开发者自己管理,手动申请,手动释放。对于C++,则是将这个权限继续交给了开发者,而对于Java,则是将这个过程自动化了。为什么要释放内存呢?最简单的原因就是操作系统一共给你了4G的内存空间,你需要的时候,就去借用。有借有还,再借不难,只借不还,最后4G内存空间被用完了,你就无法再申请新的内存了。内存泄漏,就是只借不还。
JVM在操作系统与开发者之间又封装了一层,间接的接管了内存的划分。同时也将堆统一管理起来,使得开发者只管借用内存,由JVM负责回收,了解JVM的回收机制,明白它的原理,能让开发者在不同的场景下,定制不同的回收规则,提高回收效率。
关于GC的思考
如果让我设计一个能自动回收垃圾的虚拟机,我会怎么设计呢?
什么时候开始回收?
怎么判断这部分内存可以回收?
怎么回收这部分的垃圾?
这3个问题,也是JVM开发者一直在思考的问题。之前简单了解过JVM,就知道JVM会有Stop The World的问题,这对于用户体验来说非常不好,其根本原因便是因为在回收垃圾的时候,用户线程可能会修改这部分内存,如果不暂停用户线程,则可能会导致严重的问题,而如何减少Stop The World的时候,甚至让其消失,是各个垃圾回收器一直追求的目标。
哪些内存可以回收?
对于一个对象来说,当不存在任何一个引用能够访问到这个对象的时候,则说明这个对象可以进行回收。因为没有任何引用指向这个对象,那么这个对象就不能被读或写。
引用计数法
前面说判断一个对象可以被回收的标准就是是否还有引用指向这个对象,所以最容易想到的便是引用计数法,通过判断一个对象的引用数量即可,可是这样无法判断两个循环引用的对象。
可达性分析
可达性分析指的是从目前程序中正在使用的所有引用的对象出发,循环遍历所有能找到的对象。
作为出发的点的这些对象,被称为GC Roots
GC Roots主要包括以下几种:
在虚拟机栈(比如栈帧中的本地遍历表)中引用的对象
静态属性引用的对象
常量池引用对象(比如String Table)
本地方法栈引用的对象
Java虚拟机内部的引用对应的对象
所有被同步锁持有的对象
…
总体来说,就是当前程序中正在被使用的引用所指向的对象会被作为GC Roots
从GC Roots出发,依次查找,就能标记出当前存活的对象。但是标记这个过程,细节上依然存在问题:
STW : 标记是通过引用查找对象的,如果在标记过程中,用户修改了引用的对象,那么会导致不可预估的后果,因此一般标记过程中,是会STW的
跨代标记 : 现在的垃圾回收器,大多数都是分代,或者分区域回收的,也就是说,可能进行垃圾回收的时候,不是标记所有的垃圾,而是标记一部分,比如老年代或者新生代。此时就存在一个问题,跨代引用。比如一个新生代的对象,仅仅被一个老年代对象引用的话,对于Yong GC来说,是不会扫描老年代对象的,这个时候就会造成误判。解决这个误判的方法便是记忆集(Remembered Set),记忆集通过AOP技术生成写屏障来维护。
前面说了从GC Roots开始扫面,那分代收集的,怎么知道哪些对象是新生代的,哪些对象是老年代的呢?因为GC Roots是包含了所有引用的。后面想想,其实对象的分代信息是存放在对象头里面的。在扫描GC Roots的时候,只保留新生代的对象即可。这样基本能保证扫描到的是新生代对象,然后老年代对新生代引用交给记忆集实现就行(自己的猜测,没有证据)
JVM书中说道通过AOP生成的写屏障会使得只要有更新操作,无论更新的是不是老年代对新生代对象的引用,都会使卡表变脏,不过这样的代价相对来说是能接受的。
GC Roots 需要扫描的引用过多 :随着现在Java应用越做越大,Java堆也越来越大,GC Roots的扫描是需要STW的,如果每次GC都逐个扫描,会非常的浪费时间。解决这个问题的办法就是OopMap,使用OopMap记录应用程序所存放的引用,每次需要GC的时候扫描这个OopMap即可生成对应的GC Roots,OopMap通过安全点和安全区域来维护,只有在安全点或安全区域的时候,才更新OopMap和进行垃圾回收。
并发标记过程可能丢失存活的对象 :从CMS到G1,都将从GC Roots出发标记存活对象的过程修改成并发的,这样会需要解决的问题就是标记过程中如果用户修改了对象的引用,可能会导致本应该存活的对象”丢失“(可以通过三色标记分析),相应的解决方案便是破坏存活对象消失的必要条件,分别是增量更新(Incremental Upate)和原始快照(Snapshot At The Begin,SATB),增量更新破坏的是第一个条件,每插入一个引用,就都记录下来,而原始快照破坏的是第二个条件,每删除一个,都将其记录下来。
增量更新和并发快照也是通过前面所说的AOP技术生成写屏障来维护
通过以上分析以及解决方案,基本明白了怎么标记那些内存可以回收,接下来需要分析的就是什么时候开始回收
什么时候开始内存回收?
对于内存回收来说,开始也需要有一定的讲究,理论上来说,随时随地都可以开始内存回收,但是如果回收时使用的内存过多,会导致GC时间过程,进而STW时间也会很长,如果回收过于频繁,又会导致吞吐量下降,毕竟每次扫描GC Roots都回STW的。
同时,前面还说过,对于用户线程来说,需要将用户线程运行到安全点,更新对应的OopMap,才能开始垃圾回收。
因此,对应何时GC,有以下几点分析:
对于新生代来说,一般新生代满了(Eden + Survivor1)就会开始进行(Yong/Minor GC)
对于老年代来说,一般是老年代满了了会开始Full/Major GC
注意:这里的满了,需要根据具体的回收器不同,来衡量真正的满,对于没有并发过程的GC,老年代满一般指的是真正到达100%,已经无法分配内存了,对于有并发过程的GC,则需要预留出来空间给用户线程在并发过程中同时申请内存,如果预留内存过小,则会使用非并发垃圾回收器进行Full GC
CMS: -XX:CMSInitiatingOccupancyFraction 设置,默认92% (JDK 8),表示当老年代垃圾占用到92%就开始老年代回收, JDK 9后便无法使用CMS
G1: -XX:G1ReservePercent设置,默认为10,表示当整个Java堆使用到达90%,就开始回收。同时配合的参数还有-XX:InitiatingHeapOccupancyPercent=n,默认值为45,表示使用率到达45%就启动标记周期。这里的GC是Mixed GC
一般来说,只有CMS才有Major GC,其他老年代GC都会回收整个Java堆,也称为Full GC
统计得到的Minor GC晋升到老年代的平均大小大于老年代剩余的空间。(JDK 6 之后已经删除了担保规则)
GC并发失败(concurrent mode failure): 情况如前面说的,并发标记过程中,又出现了新生代晋升的情况,但是此时老年代剩下的内存不足够放下晋升的对象的时候,会生导致Full GC
这里的Full GC和情况1中说到达预留空间的GC不一样,情况1是正常进行的GC,而这个并发失败却是GC过程中出现了异常,一般需要切换到非并发GC,此时性能会大大下降
方法区区域被使用完毕:JDK 8之后将方法区从Perm Gen替换成了元空间,一般来说元空间大小理论上等于本地内存大小,不过元空间有一个默认初始值,到达默认初始值后,会通过Full GC扩大
注意:G1只有Yong GC和Mixed GC。没有Full GC的概念,也就是说如果需要回收方法区的话,只能退化为Serial GC进行Full GC
CMS可以通过-XX:+CMSClassUnloadingEnabled设置并发回收方法区
最大连续空间装不下大对象:对于CMS,基于标记-清除算法来说,即使空间足够,但是由于内存碎片,装不下分配的大对象时,会进行一次Full GC,对于G1来说,当分配巨型对象的时候,如果在老年代无法找到连续的Humongous的时候,会进行Full GC
用户执行System.gc(),可以通过-XX:+DisableExplicitGC屏蔽
…
怎么回收这些内存
最后一步便是怎么回收这些内存。怎么回收,书中介绍不多,总体来说有以下三种:
标记-清除(Mark-Sweep):最原始的方法,实现简单,不用移动对象,很容易做到不用Stop The Word,但是缺点也很致命,容易产生内存碎片。标记清除的速度一般,Mark阶段与活对象的数量成正比,Sweep阶段与整堆大小成正比。目前只有CMS使用这种回收方案
标记-复制(Mark-Copying):基于标记-清除修改的垃圾回收算法,需要移动对象。 前期标记,然后复制活下来的对象到另一个区域,再总体回收整块区域。标记复制算法对于新生代这种专门放朝生夕死的对象效率非常高,因为存活下来的对象少,所以Mark阶段和Copying阶段花费的时间都会比较少,几乎所有的分代GC新生代都是使用的这种算法
标记-整理(Mark-Compact): 基于标记-清除算法修改的垃圾回收器,需要移动对象。前期标记,然后将所有对象移动到一起,再对剩余的区域进行回收,速度最慢,但是不会产生内存碎片。
对于新生代使用标记-复制算法,是毋庸置疑的。但是对于老年代,使用标记清除还是标记整理,需要有一定的考量。因为使用标记-清除,不用移动对象,速度会相对来说比较快,但是由于存在内存碎片,无法使用指针碰撞的方式分配内存,而不得不使用“分区空闲分配链表”来解决内存分配的问题,这样会对在内存分配带来一定的效率影响,而标记-整理算法需要移动对象,特别是对于老年代这种大对象来说,移动这些对象将是一种极为负重的操作,但是标记-整理不会产生内存碎片。
因此,基于以上考虑,对于CMS这种侧重响应速度,致力于减少STW时间的回收器来说,选择了标记-清除算法,但是由于内存分配是一个非常频繁的操作,使用"分区空闲分配链表”会降低整个垃圾回收器的吞吐量,因此,对于Parllel Scavenge这种注重回收吞吐的垃圾回收器来说,选择了标记-整理算法。当然,对于G1则是吞吐和响应速度都比较注重,权衡之下,选择了标记-整理(全局)算法。
GC的概念,到这里基本总结完毕,但是,如果仅仅是理论,只是让我们记着一些概念性的东西,接下来,我会结合CMS,G1的GC日志以及《深入理解JVM》第四章的内容,聊一聊如何分析以及查看GC过程,简单介绍如果进行GC调优。