Java虚拟机-第3章笔记-垃圾收集器与内存分配策略
2022-01-20 17:16:23 0 举报
AI智能生成
Java虚拟机-第3章笔记-垃圾收集器与内存分配策略
作者其他创作
大纲/内容
3.1 概述
为什么我们还要去了解垃圾收集和内存分配?
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
3.2 对象已死?
1. 引用计数算法
说明:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
单纯的引用计数就很难解决对象之间相互循环引用的问题。
2. 可达性分析算法
说明:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
Java中固定可作为GC Roots的对象的种类
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
所有被同步锁(synchronized关键字)持有的对象。
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
Java中“临时性”加入的GC Roots
分代收集和局部回收(Partial GC)
某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
3. 四种引用类型
强引用:指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
弱引用:用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
虚引用:也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
4. 生存还是死亡?
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段。
真正宣告一个对象死亡,至少要经历两次标记过程
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记。
随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法(只会被系统自动调用一次)。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。
5. 回收方法区
回收废弃的常量
回收条件:没有任何字符串对象引用常量池中的对应常量,且虚拟机中也没有其他地方引用这个字面量。
回收不再使用的类型
回收需要同时满足3个条件
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3.3 垃圾收集算法
1. 分代收集理论
建立在两个分代假说之上
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
强弱分代假说奠定垃圾收集器的设计原则
收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。
如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域。
才有了 Minor GC、Major GC、Full GC 这样的回收类型的划分。
才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。
因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单独收集老年代的行为。
混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。 目前只有G1收集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
2. 标记-清除算法
说明:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点
执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
算法示意图
图片
3. 标记-复制算法
说明:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
特点:如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效;缺点是将可用内存缩小为了原来的一半。
算法示意图
图片
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。
IBM研究新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。
Appel式新生代回收
把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
逃生门安全设计:当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。
4. 标记-整理算法
说明:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
算法示意图
图片
标记-清除算法是一种非移动式的回收算法
标记-整理算法是一种移动式的回收算法
是否移动回收后的存活对象的优缺点
移动存活对象
缺点1:在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World)。
缺点2:移动则内存回收时会更复杂。
优点:不移动对象停顿时间会更短,甚至可以不需要停顿。
不移动存活对象
缺点1:弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
缺点2:不移动则内存分配时会更复杂。
优点:移动对象能提高整个程序的吞吐量。
和稀泥式解决方案
让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
3.4 HotSpot的算法细节实现
1. 根节点枚举
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。
根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。
在HotSpot虚拟机里使用一组称为OopMap的数据结构存放对象引用。并不需要一个不漏地检查完所有执行上下文和全局的引用位置。在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举。
2. 安全点
HotSpot没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。
有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。
方法调用
循环跳转
异常跳转
对于安全点,如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来?
抢先式中断
抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
主动式中断
主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。
3. 安全区域
安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是当程序“不执行”的时候则无法再进入安全点了。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
4. 记忆集与卡表
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
实现记忆集时的记录精度方案
字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
对象精度: 每个记录精确到一个对象,该对象里有字段含有跨代指针。
卡精度(卡表): 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
卡表与卡页对应示意图
图片
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
5. 写屏障
使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题, 例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
在HotSpot虚拟机里是通过写屏障技术维护卡表状态的(如何变脏)。使用在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。
HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。
会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”问题。
伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(CacheLine)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。(开启会增加一次额外判断的开销,但能够避免伪共享问题)
6. 并发的可达性分析
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。
三色标记:把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成三种颜色
白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
用户线程与收集器是并发工作存在的问题
一、把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。
二、把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。
并发出现“对象消失”问题的示意
图片
仅当以下两个条件同时满足时,会产生“对象消失”的问题
赋值器插入了一条或多条从黑色对象到白色对象的新引用
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
要解决并发扫描时的对象消失问题,只需破坏上面两个条件的任意一个即可。两种方案:
增量更新(Incremental Update)
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照(Snapshot At The Beginning,SATB)
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
3.5 经典垃圾收集器
0. 垃圾收集器组合
图片
1. Serial收集器
Serial收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
Serial/Serial Old收集器运行示意图
图片
Serial收集器的特点
依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。
对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
2. ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。
ParNew/Serial Old收集器运行示意图
图片
ParNew收集器的特点
是JDK7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。
ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。
自JDK9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案(被G1所取代)。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。
并发和并行
并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
3. Parallel Scavenge收集器
ParallelScavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器,ParallelScavenge的诸多特性从表面上看和ParNew非常相似。
Parallel Scavenge收集器的特点
ParallelScavenge收集器的目标则是达到一个可控制的吞吐量。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间。
吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
图片
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。
自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量
控制最大垃圾收集停顿时间(-XX:MaxGCPauseMillis)
允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。
垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的,直接导致垃圾收集发生得更频繁。
直接设置吞吐量大小(-XX:GCTimeRatio)
参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。
开启自适应的调节策略参数(-XX:+UseAdaptiveSizePolicy)
当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了。
虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。
4. Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
在服务端模式下,它也可能有两种用途
一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用。
另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Serial/Serial Old收集器运行示意图
图片
5. Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑PS+PO(Parallel Scavenge加Parallel Old收集器)这个组合。
Parallel Scavenge/Parallel Old收集器运行示意图
图片
6. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间(响应速度)为目标的收集器。基于标记-清除算法实现的。
CMS 运作过程分为4个步骤
1. 初始标记
仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
2. 并发标记
从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户 线程,可以与垃圾收集线程一起并发运行。
3. 重新标记
为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
4. 并发清除
清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,这个阶段也是可以与用户线程同时并发运行的。
CMS 收集器运行示意图
图片
CMS最主要的优点:并发收集、低停顿。称之为“并发低停顿收集器”。
CMS的3个明显缺点
CMS收集器对处理器资源非常敏感。
面向并发设计的程序都对处理器资源比较敏感
CMS默认启动的回收线程数是(处理器核心数量+3)/4
当处理器核心数量不足4个时,CMS对用户程序的影响就可能变得很大
CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。
7. G1收集器
G1收集器它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1是一款主要面向服务端应用的垃圾收集器(替换掉CMS收集器)。
G1的目标是能够建立起“停顿时间模型”的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
G1实现“停顿时间模型”的思路
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。
而G1它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。
G1收集器Region分区示意图
图片
G1基于Region的堆内存布局
G1也仍是遵循分代收集理论设计的。
G1不再坚持固定大小以及固定数量的分代区域划分。
把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。
G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
G1收集器能建立可预测的停顿时间模型原因
将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region。
这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
G1收集器运行示意图
图片
G1收集器的运作过程
初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
默认的停顿目标为两百毫秒。
如果把停顿时间调得非常低,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。最终占满堆引发FullGC反而降低性能。
通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
G1相比CMS的优点
指定最大停顿时间
分Region的内存布局
按收益动态确定回收集
G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行。
G1相比CMS的弱项
G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高。
G1的卡表实现更为复杂,占用的内存更多。
G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。
G1对写屏障的复杂操作要比CMS消耗更多的运算资源。
G1的实践经验
在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势
优劣势的Java堆容量平衡点通常在6GB至8GB之间
3.6 低延迟垃圾收集器
0. 概述
垃圾收集器的三项最重要的指标是
内存占用(Footprint)
吞吐量(Throughput)
延迟(Latency)
一款优秀的收集器通常最多可以同时达成其中的两项。
延迟的重要性日益凸显,越发备受关注
随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存。
硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。
对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果。
各款收集器的并发情况
图片
浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。
CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。
CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。
G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。
1. Shenandoah收集器
2. ZGC收集器
JDK 17(长期支持版)已标记为可在生成环境中使用。
ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的特点
1. 亚毫秒级别的时间延迟 (就是不超过1毫秒)
2. 暂停时间不会随着堆的大小、存活集、根集的大小的增加而增加
3. 支持8MB-16TB级别的堆大小
ZGC的内存布局
与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region具有动态性:动态创建和销毁,以及动态的区域容量大小。
ZGC的Region分类
小型Region:容量固定为2MB,用于放置小于256KB的小对象。
中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
大型Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。
每个大型Region中只会存放一个大对象
实际容量完全有可能小于中型Region,最小容量可低至4MB
不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)
ZGC的堆内存布局
图片
ZGC的并发整理算法实现
ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)。
染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?
在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节
实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。
操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间
染色指针示意
图片
ZGC的染色指针的三大优势
染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。
ZGC未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)
染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。
ZGC运作过程
图片
1. 并发标记 (Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
2. 并发预备重分配 (Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。
3. 并发重分配 (Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
4. 并发重映射 (Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。
ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。ZGC的这种选择也限制了它能承受的对象分配速率不会太高。
ZGC支持“NUMA-Aware”的内存分配
NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。
在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。
在ZGC之前的收集器就只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配。
ZGC将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。
ZGC可用的调优参数
启用ZGC
-XX:+UseZGC 启用ZGC
-XX:+UseZGC -Xmx-Xlog:gc
-XX:+UseZGC -Xmx-Xlog:gc* 可以打印更详细的GC日志
ZGC 通用的参数
-XX:MinHeapSize, -Xms 最小堆大小 (default = 8388608 = 8M)
-XX:InitialHeapSize, -Xms 初始化堆大小 (default = 134217728 = 128M )
-XX:MaxHeapSize, -Xmx 最大堆大小 (default = 2134900736 = 2036M)
-XX:SoftMaxHeapSize JVM堆的最大软限制 (default = 2134900736 = 2036M)
-XX:ConcGCThreads 并发GC的线程数量(default -XX:+ConcGCThreads=1 )
-XX:ParallelGCThreads 设置垃圾回收时的并行GC线程数量 (default = 4 )
-XX:UseLargePages 使用大页面内存 (dafault false)
-XX:UseTransparentHugePages 使用Transparent大页面内存
-XX:UseNUMA 使用UNMA内存分配,可以获得更好的性能
-XX:SoftRefLRUPolicyMSPerMB 每MB的空闲内存空间允许软引用对象存活时间(default = 1000)
-XX:AllocateHeapAt = 堆分配参数,可以使用非DRAM 内存,这个参数将指向文件系统的文件并使用内存映射来达到在备用存储设备上进行堆分配。
ZGC 特有的参数
-XX:ZAllocationSpikeTolerance 修正系数,数值越大,越早触发GC (default = 2.000000)
-XX:ZCollectionInterval ZGC发生的最小时间间隔 ,秒 (default = 0.000000)
-XX:ZFragmentationLimit relocation时,当前region碎片化大于此值,则回收region (default = 25.000000) -XX:ZMarkStackSpaceLimit 指定为标记堆栈分配的最大字节数 (default = 8589934592 = 8096M)
-XX:ZProactive 是否启用主动回收 (default true)
-XX:ZUncommit 是否归还不使用的内存给OS(default true)
-XX:ZUncommitDelay 不再使用的内存最多延迟多久会归还给OS (default = 300 s)
ZGC的一些诊断参数
-XX:+UnlockDiagnosticVMOptions 使用诊断模式,下面的参数才会起作用
-XX:ZStatisticsInterval 指定统计数据输出之间的时间间隔(秒)。
-XX:ZVerifyForwarding 检验转发表 -XX:ZVerifyMarking 检验标记集
-XX:ZVerifyObjects 检验对象 -XX:ZVerifyRoots 检验根节点
-XX:ZVerifyViews 检验堆视图访问
3.7 选择合适的垃圾收集器
1. Epsilon收集器
一款以不能够进行垃圾收集为“卖点”的垃圾收集器。
应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。
2. 收集器的权衡
选择一款适合应用的收集器主要受以下三个因素影响
应用程序的主要关注点是什么?
如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点
如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点
如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的
运行应用的基础设施如何?
系统架构是x86-32/64
处理器的数量多少,分配内存的大小
操作系统是Linux、Solaris还是Windows
使用JDK的发行商是什么?版本号是多少?
3. 虚拟机及垃圾收集器日志
图片
图片
4. 垃圾收集器参数总结
图片
图片
3.8 实战:内存分配与回收策略
1. 对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
2. 大对象直接进入老年代
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
3. 长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。
对象通常在Eden区里诞生,如果经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
5. 空间分配担保
在发生MinorGC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次MinorGC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次MinorGC,尽管这次MinorGC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。
如果导图对您有用,请在右上角给点个赞吧
0 条评论
下一页