内存动态分配和垃圾收集技术是JAVA和C++之间最大的区别之一
垃圾收集(Garbage Collection,GC)只办三件事:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
对于对象回收的方法
- 引用计数法:
每处引用时+1,引用失效时-1,但是主流的Java虚拟机里面都没有选用引用计数算法来管理内存。
比如很难解决对象之间相互循环引用的问题
objA.instance=objB ;objB.instance=objA
objA = null; objB = null
后,objA和objB未被回收
- 可达性分析算法
主流的商用语言(Java\C#\Lisp)通过可达性分析(Reachability Analysis)进行判断。
基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
可以作为GC Roots的对象:
- 虚拟机栈引用的对象,如各个线程被调用的方法栈中的
参数、局部变量、临时变量等
- 方法区中类静态属性引用的对象,如JAVA类的
引用类型静态变量
- Native方法使用的对象
- JAva虚拟机内部的引用,如
基本数据类型对应的Class对象
,一些常驻的异常对象,系统类加载器
- 所有被同步锁(synchronized)持有的对象
- 反应Java虚拟机内部情况的JMXBean\JVMTI中注册的回调、本地代码缓存等
引用概念的修改
判定对象是否存活都和“引用”离不开关系,但是过去的只有被引用和未被引用放在当今过于狭隘了。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空
间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应
用场景。
JDK1.2后队引用进行了补充
- 强引用 Strong:如
Obejct obj = new Object()
的引用赋值,GC永远不会回收 - 软引用 Soft:述一些还有用,但非必须的对象。将溢出时,列入回收内存中进行二次回收
- 弱引用 Weak:那些非必须对象,但是它的强度比软引用更弱一些。生存到下一次GC发生的时候
- 虚引用 Phantom:为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
对于对象的消亡判断
在可达性判断中,若判断为不可达
的对象时,那么是处于缓刑
阶段。宣告一个对象死亡需要两次标记过程
- 在可达性分析后,发现没有与GC ROOT相连的引用链,则会被第一次标记
- 对象是否有必要执行finalize()方法。若没有覆盖该方法或该方法已经被虚拟机调用过,则视为不需要执行。若判断为需要执行,则将对象放置到名为
F-Queue
的队列中,然后由优先级低的Finalizer线程去执行。如果在finalize()中重新与引用链上的任何一个对象建立关联则不会被回收
,否则被回收。
回收方法区
主要回收两部分的内容:废弃的常量
和不再使用的类型
判断废弃的常量:假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”
判断一个类型是否属于“不再被使用的类“:
·该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
·加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
·该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
垃圾收集算法
大致可分为 引用计数式垃圾收集(Reference Counting GC)和 追踪式垃圾收集(Tracing GC)
以下介绍的均为主流Java虚拟机中使用的追踪式垃圾收集
的范畴
分代收集理论
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
由上诉的前两条理论
产生的设计原则::收集器应该将Java堆划分出不同的区域
,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
若一个区域的大多数对象都是朝生夕灭的,则只关注如何保留少量存货。若剩下的都是难以消亡的,则集中放在一起,使用较低的频率来回收这个区域,兼顾了
时间开销
和内存的空间
。
如今,设计者将JAVA堆划分为新生代
和老年代
的两个区域。但是,对象不是孤立的,对象之间会存在跨代引用,由此,引出了跨代引用假说
,即存在相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
由此,只需在新生代上建立一个全局的数据结构(记忆集,Remebered Set)
,该结构把老年代划分为若干小块,标识出哪块存在跨代引用。因此发生Minor GC时,只将这些小块放入GC Root中进行扫描。
不同分代的名词:
·部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
■新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
。
■老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集
。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,需按上下文区分到底是指老年代的收集还是整堆收集。
■混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代
的垃圾收集。目前只有G1收集器会有这种行为。
·整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
标记-清除算法(存活率低时较好)
首先标记出所有需要回收的对象
,在标记完成后,统一回收
掉所有被
标记的对象
也可以反过来,标记存活的对象
,统一回收
所有未被
标记的对象。
主要缺点有两个:
- 执行效率不稳定。若被回收的过多,则需要进行大量的标记和清除工作,导致执行效率随数量变化
- 内存空间碎片化。在标记清楚后,出现不连续的内存空间,在分配大对象时候,需提前出发GC操作
标记-复制算法(存活率低时较好)
将可用内存按容量划分
为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着
的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
。当下,多数采用此方法回收新生代
主要缺点:
将可用的内存缩小了一半,浪费空间
改进后的Appel式回收:
把新生代
分为一块较大的Eden空间
和两块较小的Survivor空间
,每次分配内存只使用Eden和其中一块Survivor
。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象
一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间
。
标记-整理算法(存活率高时较好)
标记过程与“标记-清除”一直,但后续让所有存活
的对象都向内存空间一端移动
,然后直接清理掉边界以外
的内存,是移动式的,而清除是非移动式的
移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而清除会产生碎片化空间。
因此。是否移动对象都存在弊端,移动
则内存回收时
会更复杂,不移动
则内存分配时
会更复杂,但是因内存分配和访问相比垃圾收集频率要高
得多,这部分的耗时增加,总吞吐量仍然是下降的,因此移动相对划算。
也有一种“和稀泥的方法”:平时多数时间都采用标记-清除算法,的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间
HotSpot算法细节实现
根节点枚举
在OopMap帮助下,可以快速准确的完成GC Roots枚举
在根节点枚举时,必须暂停用户线程
,但枚举必须在一个保障一致性的快照
中进行,即不会在分析过程中,引用关系还在变化。即使在号称停顿时间可控/几乎不停顿的CMS\G1\ZGC
中,根节点枚举也是必须要停顿的。
但,目前JAVA虚拟机中采用的都是准确式垃圾收集``,故可以在停顿下来后
,检查所有的上下文/全局的引用位置,可以直接
得到对象引用(使用一组为OopMap
的数据结构存放)。
在类加载完成后
,会把对象中的偏移量对应的类型数据计算出来,在即时编译过程
中,在特定位置
会记录下栈里的寄存器哪些位置是引用。
故收集器在扫描时,可以直接查到引用信息,不需要从方法区/GC ROOT查找
安全点
解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题,安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点
HotSpot并没有为每条指令生成OopMap,只是在“特定位置”记录这些信息,成为安全点(Safepoint)
。
GC强制要求必须执行到安全点后才能执行
- 安全点的选取,取决于“是否具有让程序长时间执行的特征”。如方法调用、循环跳转、异常跳转等指令序列复用
- GC发生时,让所有线程跑到
最近的安全点
,然后停顿:抢先式中断(Preemptive Suspension)
和主动式中断(Voluntary Suspension)
。
抢先式(没人用了):在GC发生时中断所有用户线程,若线程不在安全点上,则恢复执行,重新中断知道跑到安全点上
主动式:GC需要中断线程时,不直接对线程操作,设置一个标志位
,线程执行时主动地轮询该标志,为真
时,到附近的安全点挂起。标志位置与安全点是重合的。
安全区域
对于处于Sleep/Blocked状态的线程,解决无法响应虚拟机的中断请求。由此引入安全区域(Safe Region)
来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化
,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点
。
执行到安全区域的代码时
- 标识自己已经进入了安全区域,故GC发生时,不再管理这些线程
- 离开安全区域时,检查是否虚拟机完成了根节点枚举,若完成则继续执行,若没完成,则等待直到接收到信号
记忆集与卡表
记忆集(Remember Set):解决对象跨代引用的问题,记录从非收集区域
指向收集区域
的指针集合
的抽象数据结构
最简单的实现可以用
非收集区域
中所有含跨代引用
的对象数组来实现
但是对此方案,维护与空间成本很高,但收集器只需要通过记忆集判断是否存在某指针,所以可以使用更粗的粒度进行记录。
- 字长精度:每个记录精确到一个
机器字长
(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。- 对象精度:每个记录精确到一个
对象
,该对象里有字段含有跨代指针。- 卡精度:每个记录精确到一块
内存区域
,该区域内有对象含有跨代指针。
第三种的卡精度也成为“卡表”
,是最常用的记忆集实现形式。卡表定义了记忆集的记录精度、与堆内存的映射关系等。其中的每个元素对应着一块特定大小内存块,成为卡页(Card Page)
,(大小通常为
2
N
2^N
2N的字节数)
在卡页中不只一个对象,只要有一个存在跨代指针
,则将对应的元素值标记为1,称为元素变脏(Dirty)
,没有则表示为0。
GC发生->筛选脏元素->得出存在跨代指针的内存块->放入GC Root中一并扫描
写屏障
为了解决卡表元素如何维护的问题,如:何时变脏、谁把他们变脏
- 有其他分代区域中对象引用了本区域的对象时变脏
- 在
机器码层面
中,使用写屏障
,把维护卡表的动作放到每个赋值操作之中
解释执行的字节码
,VM执行每条字节码指令,而在编译执行
中,代码是纯粹的机器指令流了
写屏障可以看作在虚拟机层面对“引用类型字段赋值”
这个动作的AOP切面
,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内
。
这边我认为AOP切面,就是与Spring中的权限验证功能类似,在执行时判断是否可以执行。将我们原本一条线执行的程序在中间切开加入了一些其他操作一样。
在赋值前
的部分为:前屏障(Pre-Write Barrier)
,后的为
:后屏障(Post-Write Barrier)
引用写屏障后->为所有赋值操作
生成指令->写屏障增加更新卡表
操作
存在伪共享(False Sharing)问题:
因为CPU
的缓存系统是以缓存行(Cache Line)
为单位的,当多线程修改互相独立的变量
,且变量共享同一行
,会彼此影响(写回、无效化、同步)->降低了性能
解决方法:1.先检查未被标记过才标记为脏 2.JDK7新增了参数,但是增加了一次判断的开销
并发的可达性分析
包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,因此并行收益是极大的
三色标记法:
- 白色:尚未被垃圾收集器访问过
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
- 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
–>由此总结出了产生对象消失问题的两条结论:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
解决方案:
- 增量更新(Incremental Update,破坏第一条):
黑色
对象一旦新插入
了指向白色对象的引用之后,它就变回灰色
对象了。【新增白色引用时,记录该引用
,扫描结束后,以该引用的黑色
为根扫描】 - 原始快照(Snap At The Begining,SATB,破坏第二条):无论引用关系删除与否,都会按照刚刚
开始扫描那一刻的对象图快照
来进行搜索。【删除白色引用时,记录该引用
,扫描结束后,以该引用的灰色
为根重新扫描】
以上记录操作都是通过写屏障
来实现的
经典垃圾收集器
链接的线指代两个收集器可以搭配使用。
== 不存在“万能”的收集器,只有对具体应用场景更合适的收集器==
Serial收集器(标记-复制算法)
该收集器是一个单线程工作的收集器,在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比)
1.是所有收集器里额外内存消耗(Memory Footprint)最小的
2. 对于单核/处理器较少的环境,因为没有线程交互
的开销,所以可以获得最高的单线程效率
在部分微服务中,内存一般不会特别大,所以垃圾收集的停顿时间也很短
ParNew收集器(标记-复制算法)
是Serial收集器的并行版本,除了能并行其他与Serial收集器完全一致。
Serial/Serial Old收集过程:
目前的用处:在JDK7之前遗留的系统中,只有他能与CMS(过去可以实现GC线程与用户线程同时工作的收集器)搭配使用,但是CMS作为老年代
的无法与新生代的Parallel Scavenge
搭配使用了,只有ParNew能搭配使用。后续也被G1收集器所代替。
·并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认
此时用户线程是处于等待状态
。
·并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间
垃圾收集器线程与用户线程都在运行
,但不一定是并行的,可能会交替运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
Parallel Scavenge收集器(标记-复制算法)
达到一个可控制的吞吐量(Throughput)
吞吐量
=
运行用户代码时间
运行用户代码时间
+
运行垃圾收集时间
吞吐量 = \frac { 运行用户代码时间 } { 运行用户代码时间+运行垃圾收集时间 }
吞吐量=运行用户代码时间+运行垃圾收集时间运行用户代码时间
停顿时间越短,越适合交互频繁的程序,高吞吐量可以最高效率地利用处理器资源,适合在后台运算而不需要太多交互的分析任务
用于控制吞吐量的参数:
- -XX:MaxGCPauseMillis:最大垃圾收集停顿时间
- -XX:GCTimeRatio:直接设置吞吐量大小
- -XX:UseAdaptiveSizePolicy:激活后不需要人工指定细节参数,通过运行情况动态调整,称为
自适应的调节策略(GC Ergonomics)
自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
以上皆为新生代收集器
Serial Old收集器(标记-整理算法)
单线程收集器,主要提供给客户端模式
下的HotSpot虚拟机使用,在服务端模式下
,作为CMS的后备方案
Parallel Old收集器(标记-整理算法)
Parallel Scavenge的老年代版本,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合
CMS收集器(标记-清除算法)
CMS(Concurrent Mark Sweep)一种以获取最短回收停顿时间
为目标的收集器,集中在B/S系统的服务端上,较为关注服务的响应速度,带来良好的交互体验。
整个过程分为四个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
初始标记
只是标记GC Roots直接关联
的对象,速度很快
并发标记
从直接关联的对象遍历对象图
,不需要停顿
用户线程,并发运行。
重新标记
为了修正
并发标记期间,用户线程导致标记有变动
的对象标记记录,时间停顿
比初试标记稍长
,远比
并发标记短
清除阶段
为了删除
在标记阶段判断的已死亡的对象,不需要移动存活对象,所以与用户线程并发
因此耗时最长的并发标记和并发清除都是可以与用户线程一起工作的
并发收集、停顿,但也有如下的缺点:
- 对
处理器
的资源非常敏感
:【面向并发设计的程序都对处理器资源比较敏感】,并发时,占用了一部分线程、导致程序变慢、CMS中,在4核的情况下,GC线程只占用不超过25%,随着核心变多而下降。但是不足4个
时,分出一半了。由此,产生了 "增量式并发收集器(Incremental Concurrent Mark Sweep/i-CMS)"的变种,模仿OS的抢占式多任务。在``并发标记、清除`时,让GC与用户讲题运行->时间变慢,下降幅度不明显 - 无法处理
“浮动垃圾”(Floating Garbage)
,因为并发
,所以在GC时需要给用户线程留足够内存空间,所以不能等老年代快被填满时收集,JDK 5默认下是68%(偏保守了)
浮动垃圾:在CMS中,有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在运行自然就还会伴随有新的垃圾对象不断产生,但是出现在标记过程结束以后。CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。
- 因为采用的
标记-清除
,因此有大量空间碎片
产生。所以需要内存碎片合并过程
。但是在移动存活对象,是无法并行的,所以在默认情况,每次进入Full GC先进行整理【-XX:CMSFullGCsBefore-Compaction负责,要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理】
Garbage First收集器(整体标记-整理,局部标记-复制)
简称G1,里程碑式的成果,号称全功能的垃圾收集器(Fully-Featured Garbage Collector),开创了收集器
面向局部收集
的设计思路,和基于Region
的内存布局形式,它是主要面向服务端应用
的垃圾收集器。
核心思想:回收的衡量不再是属于哪个分代,而是取决于哪块的内存中的垃圾数量最多,回收收益最大
,称为G1的Mixed GC模式
关键点:基于Region
的堆内存布局【G1将连续的JAVA堆划分为多个大小相同的独立区域(Region)
,每个Region可以根据需要扮演
新生代的Eden、Survivor、老年代空间】。Region中的HuMongous
区域专门存放大对象【大小超过Region的一半】
Region分区示意图:
在应用G1收集器所解决的问题:
- Region中存在
跨Region引用对象
时:使用记忆集
可以避免从全栈中搜索GC Root,但是Region都维护包含自己的记忆集,即G1的记忆集是双向卡表结构
,【哈希表,Key:Region的起始地址,Value:卡表的索引号】,因此占用更高的内存,相当于JAVA堆的10-20% - 如何保证GC与用户
互不干扰
:1. 解决用户改变引用时,不打破原对象图结构:CMS使用了增量更新,而G1使用原始快照
方式(SATB)。2. 收回过程中的新对象的分配:G1为每个Region设计两个TAMS(Top at Mark Start)
指针,把Region一部分空间专门用于分配新对象。默认在这个地址的对象都是被隐式标记过
,即默认存活,不纳入回收范围 - 如何建立可靠的
停顿预测模型
:以衰减均值(Decaying Average)
为理论基础,在GC过程中,G1回记录Reion的Region回收成本、记忆集脏卡数等可测量的成本
,得出平均值、标准偏差、置信度
等信息。衰减平均能够能准确的代表“最近的”平均状态
。即Region的统计状态越新
,越能决定回收价值
。
步骤:
- 初始标记(Initial Marking):标记GC Root直接关联的对象,并修改TAMS指针的值,与Minor GC同步完成
- 并发标记(Concurrent Marking):从GC Root开始对堆进行可达性分析,耗时长可与用户并发进行,扫描后进行SATB处理
- 最终标记(Final Marking):对用户进行短暂的暂停,处理并发后遗留的SATB记录
- 筛选回收(Live Data Counting And Evacuation):更新Region的统计数据,对其回收价值/成本进行排序,根据用户的需求制定回收计划。在这使用标记-复制算法后清除整个旧Region,设计对象移动,暂停用户,多条GC线程并行完成。
达到延迟可控的情况下,尽可能提高吞吐量的目的。需求从一次把整个JAVA堆清干净,变为能够应付应用的内存分配速率(Allocation Rate)即可。与CMS的“标记-清除”算法不同,G1从整体
来看是基于“标记-整理”
,但从局部
(两个Region之间)基于“标记-复制”
,因此G1运作期间不会产生内存空间碎片。但G1在GC时的内存占用(Footprint)
和程序运行的负载(Overload)
都比CMS高
- 内存占用角度:G1的卡表更复杂,每个Region都要有卡表,而CMS只有一份只处理老年代对新生代的引用
- 执行负载:CMS使用
写后屏障
,G1使用写后
维护卡表的同时,因使用SATB,所以使用写前屏障
跟踪并发时的并发情况。G1把写前和写后要做的事放入类似消息队列
的结构中,进行异步处理
。
因此小内存选CMS,大内存选G1,平衡点在6-8GB
之间
低延迟垃圾收集器
GC的衡量标准:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency),随着计算机硬件的发展,延迟的重要性日益凸显,越发备受关注。
各收集器的并发情况:
浅色为必须挂起用户线程,深色表为GC与用户并发工作。
在CMS/G1之前都要“Stop The World”停顿,在CMS/G1分别使用增量更新/原始快照,实现了标记阶段的并发
。但是CMS中整理碎片空间也要“Stop The World”、G1可以按Region来回收,但是也是需要在筛选回收时暂停的。
目前Shenandoah/ZGC都是实验阶段的GC
Shenandoah收集器
在商用被ban了
像是G1的下一代继承整,有着类似的堆内存布局。在管理内存的领域改进如下:
- 支持
并发
的整理算法 - 默认
不适用分代
收集 摒弃了
记忆集,改用链接矩阵(Connection Matrix)
:N有对M的引用->标记matrix[N][M]。
步骤:
- 初始标记(Initial Marking):标记GC Root直接相关联的对象,短停
- 并发标记(Concurrent Marking):标记对象图中可达对象,与用户并发
- 最终标记(Final Marking):处理剩余的SATB扫描,统计出回收价值最高的Region,组成回收集,小暂停
---------以上与G1相同----------
- 并发清理(Concurrent Cleanup):清除
没有存活对象
的Region【称为Immediate Garbage Region
】 - 并发回收(Concurrent Evacuation):核心差异,先复制
回收集
中存活的对象到空Region中,使用读屏障和“Brooks Pointers转发指针
解决,时间取决于回收集大小 - 初始引用更新(Initial Update Reference):并未操作,只是为了确保GC完成了对象移动工作,有短暂停顿
- 并发引用更新(Concurrent Update Reference):开始
更新引用
,但是是按照内存物理地址
的顺序搜索引用类型后修改 - 最终引用更新(Final Update Reference):修改
堆
中的引用,要修改GC Root
中的引用,最后一次停顿,与GC Root有关 - 并发清除(Concurrent Cleanup):回收集中的所有Region都没有存活对象了,直接全回收掉
黄色:被选入回收集的Region
绿色:还存活的对象
蓝色:用户可以用来分配对象的Region
支持并行整理的核心概念:Brooks Pointer
在原有对象结构前添加一个在不处于并发移动
时,引用指向对象自己
的字段。【像句柄定位】
存在的问题:
- 执行效率的问题:保证并发时的
访问一致性
,需要设置读、写屏障拦截,与其他GC模型相比,加入了额外的转发处理
,故而读代价很大,由此改进为基于引用访问屏障(Load Reference Barrier)
,只拦截引用类型的读写操作 - 性能表现:未实现最大停顿在10毫秒内,
高运行负担
导致吞吐量下降
,但是低延迟
ZGC收集器
基于Region内存布局,不设分代,使用读屏障、染色指针和内存多重映射等技术的标记-整理算法
ZGC的Region具有动态性:动态创建、销毁、容量大小
- 小型Region(Small Region):2MB,存放小于256kb的对象
- 中型Region(Medium Region):32MB,存放 256kb< 对象 < 4MB
- 大型Region(Large Region):不固定,是2MB的整数倍,存放4MB以上的对象,不会被重分配,下文详细说
并发整理算法的实现:
标志性的设计染色指针技术(Colored Pointer)
,直接把标记信息记在对象的指针
上,遍历引用图
来标记引用。
64位的linux举例:
染色指针的三大优势:
- 对象被
移走后
,region可以立即
被释放和重用,不需要等待更新引用 - 可以大幅
减少
内存屏障的使用数量。只需要读屏障【写屏障通常是为了记录引用的变动情况】,一部分是因为染色指针
,一部分是因为没有分代收集
【没有跨代引用】 - 存储结构
可扩展
,来记录更多的对象标记、重定位过程相关的数据。若开发出linux前64中未使用的18位【这些不能用来寻址】
虚拟内存映射技术:
JVM重新定义指针中某几位的技术;
在x86系统中,进程共用内存,不隔离。使用分页管理机制,实现线性地址到物理地址空间的映射。故而,linux/x86-64的ZGC使用了多重映射(Multi-Mapping)
实现多个虚拟地址映射到同一个内存地址上【n-1】===》ZGC在虚拟地址识别的空间大于物理上的。
染色指针中的标志位
看作分段符
,将这些不同的地址段
映射到同一个内存空间,就可以正常寻址了。【原本是一个整体,现在切开了】
运行过程(四大阶段皆可并发)
- 并发标记(Concurrent Mark):遍历对象图做可达性分析,但是在
指针上标记
,更新染色指针的Marked 0、Marked 1
- 并发预备分配(Concurrent Prepare for Relocate):根据查询得出
清理哪些Region
。每次GC扫描所有
Region,扫描成本换记忆集维护成本。故重分配集
只是决定
存活的对象复制到别的Region中。 - 并发重分配(Concurrent Relocate):把
重分配集
存活的对象复制到新的Region中,并为每个Region维护一个转发表(Forward Table)
,记录旧->新的引用。并且可以只从染色指针的引用
上明确得知一个对象是否处于重分配集中,若用户线程访问
当前对象,可以被预置的内存屏障所拦截
,根据转发表转发
到新的对象上,并修正
引用值,称为指针的“自愈”(Self-Healing)能力
。好处:1. 只有第一次会转发,比之前的每次的开销低。2. Region中的存活对象都复制后可以立即用于新对象的分配【转发表要留着】 - 并发重映射(Concurrent Remap):修正整个堆中指向重分配集中就对象的所有引用。但
不是迫切任务
。因为引用是可以自愈的,故而合并到了下一次GC的并发标记中完成【因为都要遍历所有对象】
ZGC是迄今为止最前沿的成果,几乎所有收集过程可并发,短暂停留只与GC Roots大小相关,在任何堆上都小于10ms
但是:
因为没有分代,所以能承受的对象分配率不会太高
。【对一个大堆并发收集时,因为新对象的分配率高,所以有大量的新对象,ZGC只能全都当作存活对象,但是其中大多数是很快就死的===》产生了大量的浮动垃圾】,解决这个问题只能引入分代收集。
性能方面:处于实验阶段
下图:左:吞吐量测试,右:ZGC停顿时间测试
PS:他也支持"NUMA-Aware"内存分配【专为多CPU/多核处理器】
选择合适的垃圾收集器
Epsilon收集器
一款不能够进行垃圾收集为卖点的垃圾收集器。但是还是有”自动内存管理子系统“的功能,这是GC收集器除了GC之外的工作。
如果应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。
收集器的权衡
应用程序:
- 数据分析、科学计算===》吞吐量
- SLA应用===》停顿时间、延迟
- 客户端应用、嵌入式应用===》GC的内存占用
B/S系统==》延迟时间
钱多==》商用的Vega、Zing VM
钱不够要延迟低能用新版本==》ZGC
要稳定并在Window系统==》Shenandoah
遗留系统==》CMS,大内存G1
虚拟机及垃圾收集器日记
-Xlog参数:
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
其中最关键的selector是由tag【某个功能块的名字,如gc】与level【日记级别】共同组成。
日志级别:Trace,Debug,Info,Warning,Error,Off,决定了详细程度
HotSpot的日志规则与Log4j、SLF4j框架一致。如果不置顶,默认值是uptime\level\tags:
[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s
内存分配与回收策略
- 对象优先Eden分配,大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
- 大对象直接进入老年代【大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组】。
- 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
- 在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则查看HandlePromotionFailure设置是否允许失败,若可以那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。