深入理解Java虚拟机|JVM03-垃圾收集器与内存分配策略02
深入理解Java虚拟机
- 3.4 HotSpot的算法实现
- 3.4.1 枚举根节点
- 3.4.2 安全点
- 3.4.3 安全区域
- 3.4.4 记忆集与卡表
- 3.4.5 写屏障
- 3.4.6 并发的可达性分析
- 3.5 经典垃圾收集器
- 较简单的收集器
- CMS收集器
- Garbage First收集器
- ZGC收集器
- 3.7 如何选择垃圾收集器
- GC日志查看
- 3.8、内存分配与回收策略
- 3.9 衡量垃圾收集器的三项最重要的指标
- 参考
3.4 HotSpot的算法实现
3.4.1 枚举根节点
可达性分析枚举GC Roots时 ,必须stop the world
目前JVM使用准确式GC,停顿时并不需要一个个检查,而是从预先存放的地方直接取。(HotSpot保存在OopMap数据结构中,OopMap可以邦族HotSpot快速准确地完成GC Roots枚举)
3.4.2 安全点
基于效率考虑,生成OopMap只会在特定的地方,称为安全点,目的在于解决停顿用户线程。
如何保证垃圾收集时所有线程到达安全点并停顿:
- 抢先式中断:现代JVM不采用
- 主动式中断:线程轮询安全点标识,然后挂起
3.4.3 安全区域
对于没有分配cpu的线程(sleep),安全点无法处理,由安全区域解决
安全区域指一段代码中引用关系不会发生变化
线程进入安全区域时,JVM发起GC就不用管这些线程,离开时需要检查GC是否完成,未完成就需要等待。
3.4.4 记忆集与卡表
前面分代收集理论提到为解决跨代引用问题,垃圾收集器在新生代建立了一个叫记忆集的数据结构来避免把整个老年代加进 GC Roots扫描范围,这里的记忆集是一种记录从非收集区域指向收集区域的指针的集合的抽象数据结构,根据记忆集的记录粒度的粗狂程度(定位精度),可以有几种实现,常用的是卡精度——指每个记录精确到一块内存区域,该内存区域内有对象含有跨代指针。
这种“卡精度”使用一种“卡表方式实现,相当于一个map,key是非收集区域的一个内存区域——“卡页”,value是0或1,分别代表没有跨代指针和有跨代指针,但是由于内存区域大小相同,所以直接采用一个字节数组就可实现,每个索引都对应一个内存区域的起始地址,值则是0或1。有跨代指针称为这个元素变脏(dirty),实际垃圾收集时,赛选出表中变dirty的元素,就可以知道那些卡页内存块存在跨代指针,然后把它们加入GC Roots中一起扫描。
3.4.5 写屏障
解决卡表如何维护。
3.4.6 并发的可达性分析
解决或降低用户线程停顿。
对象消失问题的产生条件:
复制器插入了一条或多条从黑色对象到白色对象的新引用。
复制器删除全部从灰色到该白色对象的直接或间接引用。
解决对象消失问题即破坏这两个条件中任意一个,分别两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning ,SATB)。
3.5 经典垃圾收集器
这里的经典是指 JDK7 Update 4之后到JDK11正式发布前,Oracle JDK的HotSpot虚拟机中包含的全部垃圾收集器。
较简单的收集器
Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
注意:JDK8默认的是Parallel Scavenge收集器 (复制算法): 新生代并行收集器+Serial Old收集器 (标记-整理算法): 老年代单线程收集器
CMS收集器
CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。特点的是并发收集,低停顿。
1)初始标记:只是标记一下GC Roots能够直接关联到的对象,速度很快。
2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
3)重新标记:为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录。因为并发标记是与垃圾收集线程一起并发运行的,所以会导致一部分对象有变动
4)并发清除:清理删除掉标记阶段已经标记为可回收的对象,由于不需要移动存活对象,所以这个阶段也是可用和用户线程同时并发的
在初始标记和重新标记这两个步骤仍然是需要stop the world 的。
缺点:
1)在并发阶段,虽然不会导致用户线程停顿,但是他会占用一部份线程而导致程序变慢,降低了吞吐量。
2)在并发标记和并发清理步骤中,用户线程还在继续运行,程序就会产生新的可回收的垃圾对象,这一部分垃圾对象是出现在标记重新标记后的,那么在并发清除步骤是就不会将这新产生的垃圾对象进行回收,只能留在下次垃圾收集的时候再清理。这段时间产生的垃圾对象也称为浮动垃圾。
3)因为CMS是基于标记-清除算法实现的收集器,就意味着收集结束后会出现大量的空间碎片,空间碎片过多时,以后对大对象的分配就带来很大困难,当连续空间不够来分配这个大对象时,则会触发一次Full GC。JDK9之前可以通过-XX:CMSFullGCsBeforeCompaction来整理内存空间。
Garbage First收集器
G1(Garbage First)收集器 (宏观上标记-整理算法,微观上标记-清楚算法)与之前介绍的收集器有很大不同。是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征.
是基于Region的堆内存布局是实现这个收集器的关键。G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都要根据需要,扮演新生代的Eden、Survivor空间或者老年代空间。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。只要是对象大小超过Region容量的一半就认为是大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB-32MB,且为2的N次幂。
G1收集器也是可以根据-XX:MaxGCPauseMillis 参数来设定垃圾收集而stop the world 的时间。
整个过程分为4个步骤:
1)初始标记:暂停所有的其他线程,只是标记一下GC Roots能够直接关联到的对象,速度很快
2)并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
3)最终标记:为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录。因为并发标记是与垃圾收集线程一起并发运行的,所以会导致一部分对象有变动
4)筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
G1垃圾收集分类
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
MixedGC 不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC 停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。
ZGC收集器
启用参数:-XX:+UseZGC
是在JDK11中新加入的低延迟的垃圾收集器。
内存布局与G1一样,采用Region的堆内存布局,分为大中小三类容量:
1)小型Region:容量固定为2MB,用于存放小于256KB的小对象
2)中型Region:固定容量是32MB,用于放至大于等于256KB但小于4MB的对象
3)大型Region:容量不固定,可以动态变化,但必须是2MB的整数倍,用于放至4MB及以上的大对象。
运作过程分为4个大阶段:
1)并发标记:与G1相同
2)并发预备重分配:根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集。
3)并发重分配:是ZGC的核心阶段,要把重分配集中的存活对象复制到新的Region上,并重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。
4)并发重映射:重映射所做的就是修正整个堆中指向重新分配集中旧对象的所以引用。
3.7 如何选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
- 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
GC日志查看
1)GC基本信息:jdk9之前-XX:+PrintGC jdk9之后-Xlog
2)GC详情信息:jdk9之前-XX:+PrintGCDetails jdk9之后-Xlog:gc*
3)查看GC前后堆、方法区可用容量变化:jdk9之前-XX:+PrintHeapAtGC jdk9之后-Xlog:gc+heap=debug:
4)查看GC过程中用户线程并发时间及停顿时间:jdk9之前-XX:+PrintGCApplicationConcurrentTime 以及-XX:+PrintGCApplicationStopTime jdk9之后-Xlog
5)查看收集器个分代区域大小,自动调节的相关信息:jdk9之前-XX:+PrintAdaptiveSizePolicy jdk9之后-Xlog:gc+ergo*=trace:
6)查看熬过收集后剩余对象的年龄分布信息:jdk9之前-XX:+PrintTenuringDistribution jdk9之后-Xlog:gc+age=trace:
3.8、内存分配与回收策略
1、对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配对象时,虚拟机将发起一次Minor GC
2、大对象直接进入老年代
大对象就是指需要大量连续的内存空间的Java对象,最典型的大对象就是很长的那种字符串(图片转base64后),或者元素数量很庞大的数组。
-XX:PretenureSizeThreshold参数(只对Serial和ParNew收集器有效),指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Surivor区之间来回复制,产生大量内存复制操作。
3、长期存活的对象将进入老年代
虚拟机中多数收集器都是采用分代(新生代,老年代)收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活的对象放在老年代中。
对象通常在Eden区中诞生,如果经过第一次Monir GC后仍然存活,并且能被Survivor容纳的话,该对象会被移到另一个Survivor区中,并且起对象年龄设置为1.对象在Survivor区中每熬过一次Monir GC,年龄就会加1,当他的年龄到达一定的程度(默认15),就会将该对象放至老年代中。这个升级值老年代的阈值,可以通过-XX:MaxTenuringThreshold参数设置。
4、动态对象年龄判断
如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可用直接进入老年代。
5、空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么这一次的Minor GC可用确保时安全的。防止所有对象都进入老年代。
3.9 衡量垃圾收集器的三项最重要的指标
衡量垃圾收集器的三项最重要的指标:内存占用。吞吐量,延迟。其中前者由于硬件进步,更能容忍内存占用多一些。硬件进步给吞吐量带来提升,但是对延迟带来了负面效果,因为需要回收完整的1TB堆内存肯定比回收1GB堆内存耗时。
于是延迟成了越来越重视的指标。
阿里的GC收集器就是基于G1修改的,所以要仔细看这个。
高吞吐量最好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。 直觉上,吞吐量越高程序运行越快。 低暂停时间最好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。 这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。 因此,具有低的最大暂停时间是非常重要的,特别是对于一个交互式应用程序。
不幸的是**”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)**。这样想想看,为了清晰起见简化一下:GC需要一定的前提条件以便安全地运行。 例如,必须保证应用程序线程在GC线程试图确定哪些对象仍然被引用和哪些没有被引用的时候不修改对象的状态。 为此,应用程序在GC期间必须停止(或者仅在GC的特定阶段,这取决于所使用的算法)。 然而这会增加额外的线程调度开销:直接开销是上下文切换,间接开销是因为缓存的影响。 加上JVM内部安全措施的开销,这意味着GC及随之而来的不可忽略的开销,将增加GC线程执行实际工作的时间。 因此我们可以通过尽可能少运行GC来最大化吞吐量,例如,只有在不可避免的时候进行GC,来节省所有与它相关的开销。
然而,仅仅偶尔运行GC意味着每当GC运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。 单个GC需要花更多时间来完成, 从而导致更高的平均和最大暂停时间。 因此,考虑到低暂停时间,最好频繁地运行GC以便更快速地完成。 这反过来又增加了开销并导致吞吐量下降,我们又回到了起点。
综上所述,在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于最大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
本章学习结束,细节需要重要关注运行时数据区分别存储的是什么,以及几种垃圾收集算法,重要的垃圾收集器CMS和G1.有时间再补充学习地停顿的两个先进收集器。我认为最重要的收集器应该是G1.
看的时候配合宋红康的JVM视频或者PPT看。
参考
笔记主要内容来自《深入理解Java虚拟机 第3版》,里面的一些插图来自尚硅谷的宋红康老师的ppt。
还没有评论,来说两句吧...