Java从诞生至今,在追求更加智能、更加低延迟的垃圾回收器的道路上一路披荆斩棘,Java7推出了G1收集器,在此之前的所有垃圾收集器接着被冠以“经典”之名,而G1垃圾收集器在Java9才被设置为默认的垃圾收集器,Java13推出的Shemendoah收集器已经是一款成熟的高性能垃圾收集器,垃圾收集领域最新的研究成果ZGC已经在OpenJDK中商用。
Serial是一款比较老的收集器,可以用于新生代和老年代的对象回收,但是因为使用的是复制算法,比较耗费空间资源,所以一般选择为新生代中的垃圾收集器。
· 标记复制算法是一种实现较简单的回收算法,把存活的对象移动到另外一片空间,再清除本空间的所有对象即可。
· 但是需要一片额外的“空”内存区域用来作为对象拷贝使用。这就比较符合新生代的垃圾回收。
· Serial收集器是必须暂停全部用户线程的,并且只有一个GC线程进行垃圾收集工作。
在Serial的基础上改进,多个GC线程去处理垃圾回收,增加回收的效率。
如果但是看收集算法、收集机制,它和ParNew看起来好像是完全一致的。但是他在某些情况下却是最好的选择。
因为它是根据吞吐量来设计的垃圾收集器。
吞吐量 = 用户代码时间 / 用户代码时间+GC时间
高吞吐量可以高效率利用CPU资源,但是新生代空间将被减少,系统会把新生代的空间调小。这样垃圾收集的动作也就越频繁。
只要告诉收集器一个期望的吞吐量值(介于0-100之间的整数),这款收集器将会全自动的进行内存空间的分配和垃圾回收的触发。
-XX:MaxGCPauseMillis最大垃圾收集停顿时间(毫秒)
-XX:GCTimeRatio 直接设置吞吐量(0-100的整数)
-XX:+UseAdaptiveSizePolicy开关打开,不需要指定新生代、Eden与Survivor比例、晋升老年代对象大小等参数,完全交给虚拟机自己计算,虚拟机自己动态自适应调节。
对于不是很了解收集器运作的时候,可以采取这个办法。只要把基本的参数设置好即可。
CMS收集器是一款优秀的垃圾收集器,作为老年代收集器,它能在标记阶段完成和用户线程同步进行,不再需要长时间的Stop the world
采用的是标记清除算法,整个垃圾收集过程可以分为四个阶段:
1、初始标记,暂停用户线程标记与GC Roots直接关联的对象。
2、并发标记,和用户线程一起执行,遍历RG Roots的对象图。
3、重新标记,需要暂停用户线程修正标记变动的部分。
4、并发清除,和用户线程并发执行,清理判断为死亡的对象。
理论的支撑让技术的实现成为可能,在历经风风雨雨之后,在JVM上运行的应用程序,已经从原始的长时间的“自闭状态”,转变成短时间的“楞神”!即时到今天,在根节点标记阶段的暂停用户线程和在重新标记阶段的修正,还是一道无法解决的难题。如果以后有一天,能够成功的解决这两个阶段短暂的“愣神”暂停,JVM就能真正的像一个“幽灵”一样的做到全自动的内存管理和垃圾回收。
虽然CMS是一款成功的垃圾收集器,但是举例碗面总是还差一段很长的路要走。(实际上这段路比想象的还要艰辛,为什么在下文会简单阐述一下)
不完美的三点缺陷:
1、对处理器资源敏感,并发阶段仅仅只占用不到25%的处理器资源,处理的时间对比原来全部线程回收要长的多。
2、由于用户线程的存在,无法处理浮动垃圾,可能导致一次Full GC,因为和用户线程并发,新产生垃圾对象的就只能等待下一次GC,所以在老年代使用了68%之后就会触发CMS收集器开始工作。
-XX:CMSInitiatingOccu-pan-cyFraction来提高这个比值,当新对象没有足够空间分配的时候,会冻结用户线程,临时启用SerialOld收集器来工作。
3、标记清除算法自带一个内存碎片问题。没有足够空间给新对象的时候,会触发一次FullGC进行碎片的整理。
Garbage First
G1收集器的大致流程:
1、初始标记:标记一下从GC Roots能直接关联的对象。耗时短,需要用户线程停止工作。(还是安全点,安全区域那一套)
2、并发标记:从GC Roots进行可达性分析,递归扫描对象图。耗时长,可以和用户线程一起进行
3、最终标记:短时间暂停用户线程。并发处理用户线程改变过的对象引用。
4、筛选回收:更新Region的统计数据,对各个Region进行回收价值和回收成本进行排序,根据用户期望的停顿时间制定回收计划。 把决定回收的Region里面存活的对象放到空的Region中,在清理掉原来的Region空间,这个步骤是必须暂停用户线程,由多个收集器线程一起完成的。
从G1开始,之后的低延迟垃圾收集器都借鉴了这种Region分块的思想。从这开始,最先进的垃圾收集器的设计导向都不约而同的变为追求能够应付应用的内存分配速率,而不再追求一次性把整个堆空间全部清理干净。
2004年Sun公司就发表了G1收集思想这样的论文,但是一直到2012年才有G1收集器的实现。为什么需要这么长的时间?至少有这些问题要解决。
1、Region和Region之间的引用如何解决?
解决的思路还是使用卡表。但是复杂得多,每个Region都有维护自己的记忆集,并且卡表是双向的,记录了我指向谁,谁指向我。G1至少要耗费堆内存的10%-20%来维护这些信息。
2、并发标记如何保证结果不被本地线程干扰?
用户线程改变了引用关系导致标记结果出现错误。G1的采用的解决方案是原始快照,CMS采用的是增量更新。这同样会导致如果内存回收速度跟不上内存分配速度,G1收集器还是会冻结用户线程的执行,开始进行Full GC
3、怎么确保停顿时间达到期望值?这个预测模型怎么建立?
G1收集器是通过衰减均值的理论来实现的,统计每个Region回收的耗时,记忆集里面的脏卡数量,计算出平均值,标准方差,置信度等信息,这样就能确保停顿的时间不超过设置的期望值。
G1和CMS的对比
从G1开始,最先进的垃圾收集器的设计导向都不约而同的追求应付内存的分配速率。而不是一次性把整个Java堆清理干净。
G1对比CMS有很多优点,可以设置停顿时间,按照收益去确定回收集合,Region这样的内存布局等等,整体上复制算法,解决内存碎块问题,其实他本身就是碎块。
但是G1的缺点也很明显,内存的占用这些额外的负载就比CMS要高很多。算法本身也就更加复杂,记忆集卡表的维护。为了实现原始快照的搜索算法,还要使用写前屏障来跟踪指针变化的情况等等。
所以,在小内存的机器上尽量使用CMS,而大内存的应用上使用G1会更好,这个内存的平衡到哪大概是6G-8G左右。不过随着HotSpot的开发者对G1的偏爱,对G1不断的升级会让对比的结果向G1倾斜。
介绍了这么多款垃圾收集器,再结合(二)图解垃圾回收里面的理论,不难发现内存占用、吞吐量、延迟,这三者之间构成了一个不可能的三角。(类似于分布式系统中,CAP理论的不可能三角)。但是计算机技术发展到今天,计算机硬件的发展允许我们摒弃内存占用。64G内存的服务器已经是很常见的了,毕竟能用钱解决的事情都不叫事情,不能用钱解决的事情才会去动脑子。
但是初始标记、最终标记这些阶段是必须要停顿的。上一篇也说过,在类加载时候的OopMap、并发三色标记技术、原始增量和快照更新,以及接下来要讲的(连接矩阵、染色指针)等技术的优化条件下,暂停时间不超过10毫秒已经是计算机科学能够做到的了。
由RedHat公司开发的,在2014年贡献给了OpenJDK,后来成为JEP189,这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒之内的垃圾收集器。
1、G1的回收阶段是支持并发整理的,但是不能做到与用户线程并发。而Shenandoah可以做到。
2、Shenandoah不使用分代收集,不会有专门的新生代Region、老年代Region
3、shenandoah摒弃了G1中耗费大量资源去维护的记忆集,改为“连接矩阵”的全局数据结构去维护跨Region引用的问题。降低了维护记忆集的消耗,也降低了伪共享的发生概率。
连接矩阵 简单可以理解为二维表格,Region N 有引用指向 Region M 在N行M列就会产生标记。
Shenandoah工作流程分为9个阶段:
(1) 初始标记:暂停用户线程。stop the world 停顿时间与堆大小无关,只与GC Roots的数量有关。
(2) 并发标记:遍历对象图。可以和用户线程一起并发。时间长短取决于堆中存活的对象数量和图的复杂程度。
(3) 最终标记:暂停用户线程。stop the world 统计出回收价值高的Region。
(4) 并发清理:清理掉整个区域内一个存活对象都没有的Region。(瞬间完成了应付内存分配的需求)。
(5) 并发回收:把回收集里面的存活对象复制一份到其他没有被使用的Region中。运行时间的长短取决于回收集合的大小。这个阶段是和用户线程一起执行的,要完成的难度相当大,因为用户线程还在不断的移动对象进行读写访问。
(6) 初始引用更新:复制结束之后,还要把堆中指向旧对象的引用改为指向新对象。会产生短暂的停顿。其实这个阶段只是建立了一个线程集合点,确保所有并发回收阶段中进行的收集线程都完成了对象移动的任务而已。
(7) 并发引用更新:和用户线程一起并发执行,只需要把旧的引用更新为新的引用就好。
(8) 最终引用更新:修正存在于GC Roots中的引用,这个阶段还是会短暂停顿。
(9) 并发清理:此时整个回收集中的Region再无存活对象,再次调用并发清理去回收这些内存空间。
Brooks Pointer (转发指针)
在应用程序并发的同时去复制对象。一门面向对象的语言,对象时时刻刻都在发生着改变。在这样的情况下安全的复制对象是很有难度的。
在没有Brooks Point之前,采用的是一种对象的内存保护陷阱来完成的,当访问旧对象的时候,旧对象的内存空间产生自陷中段,进入预设好的异常处理器中,就会把访问转发到新的对象上,能够实现对象移动和线程的并发,但是需要操作系统层面的支持,就需要从用户态切换到核心态,代价很大,不能频繁使用。
而转发指针是采用一种增加一个转发指针的引用字段的形式来完成的,当不处于并发移动的时候,这个指针指向的就是自己,当对象存在副本的时候,只需要修改这个转发指针的指向新副本对象,然后对象的访问转发到新的副本对象上即可,但是这个过程仍然需要考虑线程安全问题:
收集线程建立对象副本 — 用户线程写访问对象的某个字段 — 收集线程更新转发指针指向新副本对象
上面这个过程如果不加CAS控制是会出现线程安全问题的,导致的后果就是,新对象和原对象不一致
ZGC也是是一款基于Region内存布局的,暂时不设置分代,使用了读屏障,染色指针和内存多重映射等技术来实现的可并发的标记-整理算法,以低延迟为首要目标的垃圾收集器。
ZGC设计的 内存布局
小型Region容量固定为2MB,用于存放小于256KB的小对象;
中型Region容量固定为32MB,用于存放256KB-4MB之间的对象;
大型Region容量不固定,可以动态变化,用于放置4MB以上的大对象,每一个大Region中只有一个对象。大对象不会被重新分配。
并发整理的实现
Shenandoah使用的是转发指针和读屏障来实现。ZGC采用读屏障和染色指针。
染色指针:反正我书是没看懂,猜测一下大概是这样:
将少量的标记信息存储在指针上,也就是类似于雪花算法一样,取出几个位置来作为标志位,对指针本身加上一些限制,但是带来的收益确是相当可观的。
分析一下:64位的硬件最大支持256TB内存,这只是理论上,64位的Linux虚拟机支持47位(128TB)的虚拟地址空间和46位的物理地址空间(64TB),64位的windows系统值支持44位(16TB)。
既然Linux下64位指针的高18位不能用来寻址,染色指针就将剩下的46位取出4位用来标记(是否被移动过,是否只能通过finalize方法才能访问,marked0,marked1)这样只有42位能寻址,也就是ZGC能够管理的内存将不会超过4TB
除了这个限制之外,不能支持32位平台,也不能开启指针压缩(-XX:+UseCompressedOops)
ZGC的工作流程
1、并发标记:遍历对选哪个图做可达性分析,与Shenandoah不同,ZGC标记的指针不是在对象上进行的,标记阶段会更新染色指针中的Marked0,Marked1标志位。
2、并发预备重新分配:针对全堆的标记,得到需要清理的Region有哪些。而不是像G1那样我维护记忆集,卡表。
3、并发重新分配:把存活对象分配到新的Region上,维护了一个Forward Table记录了旧对象到新对象的转发关系。
如果用户线程访问了对象,被内存屏障所截获,根据转发表将访问转发到新的对象上去,并且更新这个引用的值,这样下一次就不会被截获,不会去查转发表了。ZGC的这个行为叫做“自愈”。
4、并发重映射:修正整个堆中指向重新分配集合中的所有引用。其实这个步骤是不必要的,因为自愈功能,所有的引用肯定都会被更新。所以ZGC把这个步骤放到了下一次GC进行并发标记的时候去完成。
ZGC的性能极好,是迄今为止垃圾收集器研究领域的最前沿成果。
它出世之时是JDK11时期,正好是Oracle调整许可授权,把商业特性都开源给了OpenJdk。遗憾的是还没有在正式的JDK版本中使用。
最后再介绍一款不会进行垃圾收集的收集器。 这也
收集器的工作除了收集垃圾之外,还负责堆内存的管理布局,对象分配,与解释器、编译器、监控系统协作等。
一个应用只需要运行数秒钟,Java虚拟机能正确分配内存、堆空间耗光时就退出,那Epsilon收集器就是最好的选择。
这也说明了,在JVM中 垃圾回收机制是和内存分配策略离不开的!
JVM写到这里其实就已经写完大概90%以上的面试常考常问的知识了,大致方向上只剩下一些执行引擎和本地方法接口的东西。
其实(二)(三)两篇很像,只是概念的部分太多,还是要多多画图整理,才不至于面试的时候头被打的太肿。
执行引擎、JIT即时编译器、C1,C2编译器、热点代码判定、编译器优化排序、逃逸分析、锁优化、锁消除、TLAB栈上分配等等都是JVM为什么这么强大的原因。
Java语言在不断发展,JVM体系也在不断的进步完善,JVM的世界里需要学的东西还有很多很多。
懂的东西越多,才发现自己不懂的东西越多。加油,xiaosha !