文章目录
垃圾收集器与内存分配策略
3.1 概述
垃圾收集需要完成的三件事情:哪些内存需要回收、什么时候回收、如何回收
程序计数器、虚拟机栈、本地方法栈3个区域随线程而产生和销毁,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,它们内存分配和回收都具备确定性,不需要过多考虑垃圾回收问题
而Java堆和方法区
这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理
3.2 死去的对象
对象已死:指的不可能再被任何途径使用的对象
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”
引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;
任何时刻计数器为零的对象就是不可能再被使用的
优点:原理简单,判定效率也很高(也有很多应用了这个算法)
缺点:占用额外的内存空间计数;无法解决循环引用的问题:例如A的字段引用了B,B的字段引用了A,这样AB就都无法回收了
Java虚拟机并不是通过引用计数算法来判断对象是否存活的
-
示例:Java GC
虚拟机设置
-XX:+PrintGCDetails //输出GC的详细日志
除此之外,还有其他的虚拟机设置可选
-XX:+PrintGC //输出GC日志 -XX:+PrintGCTimeStamps //输出GC的时间戳(以基准时间的形式) -XX:+PrintGCDateStamps //输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800) -XX:+PrintHeapAtGC //在进行GC的前后打印出堆的信息 -Xloggc:../logs/gc.log //日志文件的输出路径
测试代码
public class Test { public Object instance = null; private static final int _1MB = 1024 * 1024;//占内存,以便能在GC日志中看清楚是否有回收过 private byte[] bigSize = new byte[2 * _1MB]; public static void main(String[] args) { Test objA = new Test(); Test objB = new Test(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; // 假设在这行发生GC,objA和objB是否能被回收? System.gc(); } }
GC结果
分别进行了一次Mirror GC 和 Full GC, 对于每一行:
第一个 “->” 表示:GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)
第二个 “->” 表示:GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)
可以看到即便是循环引用,Java依然进行了垃圾收集,证明使用的不是引用计数算法
可达性分析算法
Java、C#等的内存管理子系统采用的是可达性分析算法来判定对象是否存活
思路:
以GC Roots
集合中的对象作为根节点(多个),根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”
。
从GC Roots开始不可达,则证明此对象是不可能再被使用的
GC Roots并非单独的一块内存区域,它只是那些符号条件的对象的统称
固定可作为GC Roots的对象包括:
参考:https://blog.csdn.net/suming97/article/details/126359521
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
public class Test{ public static void main(String[] args) { Test a = new Test(); a = null; } } //a是栈帧中的本地变量,a就是GC Root,由于a=null,a与new Test()对象断开了链接,所以对象会被回收
-
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
public class Test{ public static Test r; public static void main(String[] args){ Test a=new Test(); a.r=new Test(); //r赋值变量的引用 a=null; //a被回收 } } //栈帧中的本地变量a=null,断开了链接,所以a对象会被回收 //由于给Test的成员变量r赋值了变量的引用,并且r成员变量是静态的,所以r就是一个GC Root对象,r指向的对象不会被回收
-
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
public class Rumenz{ public static final Rumenz r=new Rumenz(); public static void main(String[] args){ Rumenz a=new Rumenz(); a=null; } } //a引用的对象的会被回收,但常量r引用的对象不会因此而被回收
-
在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) { // 缓存String的class jclass jc = (*env)->FindClass(env, STRING_PATH); } //jc就是本地方法栈中JNI的对象引用,它只会在此本地方法执行完成后才会被释放。
-
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
-
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如分代收集和局部回收
目前最新的几款垃圾收集器都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀,它们在实现上也有各种优化处理
引用分类
在早期版本中,对象只有已被引用和未被引用两种状态,对于那些希望 “内存空间足够时保留,不足时可抛弃” 的对象(如缓存)不好区分在JDK1.2版之后,引用被进一步分类
- 强引用:普遍的引用,如 “Objectobj=new Object()” , 只要强引用存在,就不会被GC
- 软引用:描述一些还有用,但非必须的对象。发生内存溢出异常前,会对这些对象进行二次回收,回收后内存还是不够,再抛出异常
提供了SoftReference类来实现软引用 - 弱引用:也是描述那些非必须对象,但强度比软引用更弱。这些对象只能生存到下一次垃圾收集发生为止,而与内存是否足够无关
提供了WeakReference类来实现弱引用 - 虚引用:不对生存时间构成影响,也无法通过虚引用来取得一个对象实例。唯一作用是这个对象被收集器回收时能收到一个系统通知提供了PhantomReference类来实现虚引用
finalize()
即使在可达性分析算法中判定为不可达的对象,也不会被判定为“死亡”
一次标记:
对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记
一次筛选:判断对象是否有必要执行finalize()方法
-
没有必要执行finalize():
(1)对象没有覆盖finalize()方法,(2)或者finalize()方法已经被虚拟机调用过。
虚拟机将这两种情况都视为“没有必要执行”,直接回收 -
有必要执行finalize()
非上述两者情况时,被标记的对象就被放置在一个名为F-Queue的队列之中稍后会由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法
二次标记:
即便对象在F-Queue中被执行了finalize(),它也不一定能被回收
如果某个对象的finalize()方法执行缓慢(甚至死循环),那么对象只要在finalize()期间重新与引用链上的对象建立引用关系,那么它就可以在收集器对F-Queue对象进行第二次标记时,被移出“即将回收”的集合,完成自救
-
示例:对象的自救
/** * 此代码演示了两点: * 1.对象可以在被GC时自我拯救。 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次 */ public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am still alive :)"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 // 调用重写后的finalize()方法,在这个过程中自己又被引用,所以成功逃脱 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } // 下面这段代码与上面的完全相同,但是这次自救却失败了 SAVE_HOOK = null; System.gc(); // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } } }
finalize()只能被调用一次,也就是说最多只能自救一次
finalize()不等同于C/C++的析构函数,在java中十分不建议使用它,因为运行代价高昂,不确定性大,无法保证各个对象的调用顺序
回收方法区
在Java堆中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间
但在方法区中,回收性价比很低,《Java虚拟机规范》并不强制要求实现方法区的GC
方法区回收内容:
-
废弃的常量:
相对简单,和Java堆中的对象回收相似。只要常量池中的常量没有被任何地方引用,就可以被回收。
常量池中的字面值、其他类(接口)、方法、字段的符号引用也都遵循这个原则 -
不再使用的类型
比较困难,需要满足下列三个条件:
(1)该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
(2)加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
满足这三个条件的类可以被虚拟机回收,但不是一定会被回收//一些虚拟机参数 -Xnoclassgc //表示不对方法区进行垃圾回收,谨慎使用 -verbose:class //打印类加载过程,可以在Product版的虚拟机中使用 -XX:+TraceClassLoading //查看类加载,可以在Product版的虚拟机中使用 -XX:+TraceClassUnLoading //查看类卸载,需要FastDebug版的虚拟机支持
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力
3.3 垃圾收集算法
垃圾收集算法可以划分为“引用计数式垃圾收集”(ReferenceCounting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。这里主要介绍追踪式垃圾收集
分代收集理论
两个分代假说:
分代收集建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
这两个奠定了多款常用的垃圾收集器的一致的设计原则
垃圾收集器的设计原则(分代):
收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储
- 如果一个区域中大多数对象都是朝生夕灭:每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间(新生代)
- 如果一个区域中大多数对象都是难以消亡的:虚拟机以较低的频率来回收这个区域(老年代)
这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用
分代收集存在的问题:跨代引用
Java堆至少会被划分为新生代和老年代两个区域。
当要单独对新生代做一次GC时,发现新生代中的对象被老年代所引用,这时不得不遍历整个老年代中所有对象来确保可达性分析结果的正确性,反之亦然。这会为内存回收带来很大的性能负担
分代理论第三条经验法则:
针对跨代引用的问题,提出了跨代引用假说
:跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的
实施:记忆集
(1)在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set)
(2)记忆集将老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用
(3)此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描
(4)缺点是需要在引用关系改变时,维护记忆集中的数据,但比起扫描整个老年代,效益更好
名词释义:
- 部分收集:指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外 “Major GC” 这个说法现在有点混淆,需按上下文区分到底是指老年代的收集还是整堆收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
分代收集理论也有其缺陷,最近很多在实验面向全区域收集设计
通常能单独发生收集行为的只是新生代
标记-清除算法
分为标记和清除两个阶段:
- 标记:标记出所有需要回收的对象(根据前面的对象死亡判定算法来标记)
- 清除:统一回收掉所有被标记的对象
也可以反过来标记存活对象,回收未被标记对象
是最基础的收集算法,其他大多收集算法根据其缺点而改进
缺点:
- 执行效率不稳定:当大部分对象是需要回收时,将产生大量的标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,当对象需要大内存时不得不提前触发另一次垃圾收集动作
标记-复制算法(新生代)
目的:解决面对大量可回收对象时执行效率低的问题
流程:
(1)将可用内存按容量划分
为大小相等的两块,每次只使用其中的一块
(2)当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
优缺点:
优点:对于多数对象都是可回收的区域,仅需复制少数存活对象,
每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可
简单,运行高效
缺点:不适合多数对象都是存活的区域,否则产生大量的内存间复制开销
空间浪费,可用内存缩小为了原来的一半
现在的商用Java虚拟机大多都优先采用了(改进的)标记-复制收集算法去回收新生代
Appel式回收:
IBM研究表明:新生代约98%的对象熬不过第一轮垃圾收集
(1)把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor
发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间
(2)HotSpot虚拟机的Serial、ParNew等新生代收集器均采用Appel式回收策略来设计新生代的内存布局,且默认的Eden和Survivor的大小比例是8∶1,即每次新生代中可用内存空间为整个新生代容量的90%
(3)Appel式回收的“逃生门”安全设计
:当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保
(Handle Promotion)
标记-整理算法(老年代)
标记-复制算法不适合老年代,因为会产生大量的复制操作,而且会导致内存空间的浪费
标记-整理算法流程:
- 标记:同标记-清除算法一样
- 整理:让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
存在的问题:
移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作
这种对象移动操作必须全程暂停用户应用程序才能进行,被称为Stop The World(STW)
但如果不移动的话,则会导致内存碎片问题,对于访问频繁的内存来说,采用类似"分区空闲分配链表"方法对吞吐量的影响更大
吞吐量
指赋值器(使用垃圾收集的用户程序)和收集器的效率总和
不移动会提高收集器的效率,但降低内存访问和分配的效率,而后者频率更高,因此总的吞吐量下降
HotSpot里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的
一种混合解决方案:
让虚拟机平时多数时间都采用标记-清除算法,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。CMS收集器面临空间碎片过多时采用的就是这种处理办法
通常标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已
3.4 HotSpot的算法细节实现
根节点枚举
虚拟机高效实现的第一个例子:可达性分析算法中从GC Roots集合找引用链
根节点枚举存在的问题
固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
(1)对于大型应用,GC Roots集里数量庞大,若要逐个检查以这里为起源的引用耗时很长
(2)目前,可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但迄今为止,所有收集器在根节点枚举(找到所有内存空间中的GC Roots)这一步骤时都是必须暂停用户线程的
(3)根节点枚举
与整理内存碎片
一样会面临STW困扰。这是为了保证一致性,即避免在根节点枚举过程中对象引用关系还不断变化。STW不可避免,目的在于减少STW的时间(通过快速定位)
(4)最糟糕的情况,根节点枚举需要一个不漏地检查完所有执行上下文和全局的引用位置,即符合GC Roots条件的所有对象,来检查是否存在引用关系
HotSpot准确式垃圾收集:OopMap
-
保守式GC:不能直接识别指针和非指针类型数据
-
准确式GC:可以直接识别出是指针还是非指针类型数据(主流Java虚拟机使用的都是准确式垃圾收集,即虚拟机知道内存中某个位置的数据具体是什么类型)
这是一种以空间换时间的方式
HotSpot使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用
在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举
OopMap相当于记录了所有可以作为根节点的对象的位置
参考:https://zhuanlan.zhihu.com/p/265908838,https://zhuanlan.zhihu.com/p/286110609
理解:以栈帧为例,根节点枚举时,它将会去栈上的内存进行扫描,看哪些位置是Reference类型并且记录下来不可被回收。但Reference类型只占一部分,如果对整个栈进行扫描影响效率。而每个栈帧的OopMap将记录哪些位置有着Reference,这样枚举根节点时,只需要遍历栈中每个栈帧的OopMap即可
安全区
问题:
会导致OopMap变化(引用改变)的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间
安全点:
HotSpot只在“特定的位置”记录OopMap,这些位置被称为安全点
这就要求程序执行时不能在任意位置停下来进行垃圾收集,而必须达到安全点后才能暂停
安全点设置标准:
(1)不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷
(2)标准:是否具有让程序长时间执行的特征
(3)“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点
GC的STW问题(线程中断方式):
问题:如何在GC发生时让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来
-
抢先式中断:GC发生时,系统直接中断所有用户线程。如果发现有线程不在安全点上,就恢复它,过段时间再尝试,直到线程在安全点的位置成功中断。已经没有虚拟机采用抢先式中断响应GC事件了
-
主动式中断:系统不直接对线程操作,而是设置一个标志点。各个线程执行过程时会不停地主动去轮询这个标志。
轮询时机:(1)每个线程到达任意安全点时,就去查看一下这个标志,如果为真,就在安全点的位置主动挂起;(2)在所有创建对象和其他需要在Java堆上分配内存的时候去检查标志点。这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象轮询操作非常频繁,HotSpot使用内存保护陷阱的方式把轮询操作精简至只有一条汇编指令的程度,十分高效
安全区域
问题:
安全点保证了程序在执行时可以在不太长的时间内进行一次GC,但对于”不执行“的线程(未分配处理器时机,典型的如线程处于Sleep状态或者Blocked状态),这时候线程无法响应虚拟机的中断请求,也不能到达安全点去挂起自己,虚拟机也不能干等着线程重新激活。因此,引入安全区域的概念
安全区域:
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的
可以看作是被扩展拉伸了的安全点
执行流程:
(1)线程执行到安全区域的代码时,首先标识自己已经进入了安全区域
(2)虚拟机发起GC时,会忽略掉正处于安全区域的线程,正常进行根节点枚举
(3)线程退出安全区域时,要检查虚拟机是否已完成根节点枚举(是否处于STW状态),如果完成了就继续执行;如果没完成,就必须等待,直到收到可以离开安全区域的信号为止
理解:大多处于安全区域的线程本身就处于挂起状态,这样的线程被唤醒离开安全区域时,需要先确定虚拟机是否处于GC中;即检查主动式中断的标志位是否为真,如果为真,就继续挂起等待;为假就正常唤醒执行
记忆集与卡表
记忆集(Remembered Set)是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
问题:
如果记忆集中记录非收集区域中所有含跨代引用的对象,那么空间占用和维护成本都相当高昂
在垃圾收集的场景中,实际只需要记录某一块非收集区域是否存在跨代引用即可,而不需要记录详细细节。因此可以更粗犷的记录粒度
记忆集记录精度:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位),该字包含跨代指针
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
卡表:
卡表就是记忆集利用卡精度的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等
HotSpot利用一个字节数组CARD_TABLE来实现卡表
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)
卡页大小都是以2的N次幂的字节数,从上面的代码可以推测,HotSpot的卡页大小是29,即512字节
如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块
卡表运行原理:
一个卡页的内存中通常包含不止一个对象,只要卡页内有1个及以上的对象存在跨代引用,就将该卡页标识为1,称这个元素变脏(Dirty);没有则标识为0;
GC发生时,筛选出所有变脏的卡页,得到全部的有跨代引用的对象,并将它们加入到GC Roots中一并扫描
写屏障
卡表元素何时变脏:
有其他分代区域中对象引用了本区域对象时,其(其他分代)对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻
问题:如何变脏
如果是解释执行的字节码,虚拟机负责每条字节码指令的执行,有修改的能力,比较方便
但如果是编译执行的场景,例如即时编译后产生的是纯粹的机器指令流,就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中
写屏障:
HotSpot通过写屏障技术维护卡表状态
在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障;直至G1收集器出现之前,其他收集器都只用到了写后屏障,即在赋值语句后,增加一条命令,以供程序执行额外的动作(对卡表更新)
//写后屏障更新卡表
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
写屏障会产生额外的开销,当相比于扫描整个老年代,这个开销是低得多的
“伪共享”问题:
(1)卡表在高并发场景下还面临着“伪共享”(False Sharing)问题
(2)伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
(3)举例:假设缓存行大小为64字节,一个卡表元素占1个字节,那么1个缓存行将存储64个卡表元素,对应64*512B=32KB
相当于一个缓存行标识了32KB的内存是否存在跨代引用,当不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
(4)为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏
//卡表更新
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
虚拟机命令:
-XX:+UseCondCardMark //开启卡表更新的条件判断
开启将避免伪共享问题,但增加一次额外判断的开销
并发的可达性分析
可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行
GC Roots相比起整个Java堆中全部的对象算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)
但GC Roots再继续往下遍历对象图,这一步骤的停顿时间将与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长
“标记”阶段是所有追踪式垃圾收集算法的共同特征,对它的影响将会波及所有的垃圾收集器
三色标记:
白色:表示对象尚未被垃圾收集器访问过(GC后仍未白色说明不可达)
黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
并发引发的问题——对象消失:假设GC不采用STW
(从左到右依次为:原引用关系、情况一、情况二)
- 情况一:图2
正在扫描的是灰色对象,此时引用关系改变,灰色对象的一个引用被切断,而这个被切断的引用对象又与黑色对象建立了引用
结果:导致本应该被保留的对象被回收,程序出错 - 情况二:图3
情况一的升级版,这种切断后被重新引用的对象可能是原有引用链的一部分,这直接导致出现2个被黑色对象引用的对象仍是白色
结果:多个本该被保留的对象被回收,程序出错
当以下两个条件同时满足时,会产生“对象消失”的问题:黑色被误标记为白色
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决方法:
针对上面的两个条件,分别提出了增量更新和原始快照两个方案
-
增量更新:破坏第1个条件
原理:当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次(二次扫描)。简化理解为:黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了 -
原始快照:破坏第2个条件
当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,并假定被删除的引用关系仍存在,对白色对象染色。简化理解:无论引用关系删除与否,都按照未删除的对象图(即原始快照记录的对象图)来进行搜索会产生浮动对象(即错误标记,本该回收但未被回收的对象),但这些对象在下一轮GC中就会被回收
对引用关系记录的插入和删除,都是通过写屏障实现的。HotSpot虚拟机中这两种方案都有使用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现
3.5 经典垃圾收集器
上图表示HotSpot中处于不同分代的7种垃圾收集器,连线表示2者可以搭配使用;连线上的JDK 9表示这种搭配自该版本起被废除
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者一般难以兼得,一款优秀的收集器通常最多可以同时达成其中的两项。其中最重要的指标是延迟
Serial收集器
最基础、历史最悠久的收集器(在JDK 1.3.1之前,只有这一个收集器)
单线程工作:进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束
早期的STW问题比较严重,为此不断创新了新的收集器
Serial收集器的优点:
迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方
(1)简单高效(与其他收集器的单线程相比),在内存资源受限的环境,它是所有收集器里额外内存消耗最小(为保证垃圾收集能够顺利高效地进行而存储的额外信息)的;
(2)对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率
ParNew收集器
实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、STW、对象分配规则、回收策略等都与Serial收集器完全一致
ParNew收集器是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器
其中一个重要原因:除了Serial收集器外,目前只有它能与CMS收集器配合工作
JDK 5推出了CMS,这是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。但在使用CMS收集老年代时,新生代只能选择ParNew和Serial中的一个
但在JDK 9之后,随着CMS被G1取代,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。并且在之后,ParNew合并入CMS,成为它专门处理新生代的组成部分。
ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器
ParNew收集器在单核心处理器的环境中效果不如Serial收集器,它默认开启的收集线程数与处理器核心数量相同
并行:同一时间多条垃圾收集器线程在协同工作,通常默认此时用户线程是处于等待状态
并发:同一时间垃圾收集器线程与用户线程都在运行,但应用程序的处理的吞吐量将受到一定影响
Parallel Scavenge收集器
新生代收集器、标记-复制算法、并行收集的多线程收集器
不同之处:
CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值
吞
吐
量
=
运
行
用
户
代
码
时
间
运
行
用
户
代
码
时
间
+
运
行
垃
圾
收
集
时
间
吞吐量 = \frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}
吞吐量=运行用户代码时间+运行垃圾收集时间运行用户代码时间
减小STW时间:适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验
高吞吐量:高效率地利用处理器资源,适合在后台运算而不需要太多交互的分析任务
两个参数:
-XX:MaxGCPauseMillis //控制最大垃圾收集停顿时间
//参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值
//以牺牲吞吐量和新生代空间为代价换取:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快
//但这导致垃圾收集更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间下降,但吞吐量也下降
-XX:GCTimeRatio //设置吞吐量大小
//参数值在0-100,用来表示垃圾收集时间占总时间的比率 = 1/(1+x)
//默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间
//设置为19, 允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),
-XX:+UseAdaptiveSizePolicy //开启之后,虚拟机根据当前系统的运行情况收集性能监控信息,对新生代大小等参数进行动态调整
自适应调节策略:
Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”
Parallel Scavenge收集器区别于ParNew收集器的一个重要特性:自适应调节策略;
当“-XX:+UseAdaptiveSizePolicy”参数被激活之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略
Serial Old收集器
Serial Old是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法
主要是供客户端模式下的HotSpot虚拟机使用
在服务端模式下,可以有两个功能:
一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用
另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用
Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,多线程并行收集,标记-整理算法
JDK 6时才开始提供,在这之前Parallel Scavenge只能同Serial Old(PS MarkSweep)收集器配合,而Serial Old无法充分利用服务器多处理器的并行处理能力,这一搭配在吞吐量上表现不佳
Parallel Old收集器出现后,它与Parallel Scavenge的组合更符合“吞吐量优先”要求,在注重吞吐量或者处理器资源较为稀缺的场合,可以考虑采用这一搭配
CMS收集器
基于标记-清除
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
适用交互多、关注服务的响应速度的服务器端,如B/S系统
分为四个步骤:
-
初始标记(CMS initial mark)
需要STW,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
-
并发标记(CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对象图,这个过程耗时较长但
不需要停顿用户线程,可以与垃圾收集线程一起并发运行
-
重新标记(CMS remark)
需要STW,修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(例如之前提到的对象消失问题)。STW耗时比初始标记长,但远比并发标记短
-
并发清除(CMS concurrent sweep)
清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
由于在耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
优点:并发收集、低停顿
缺点:
-
对处理器资源非常敏感:虽然不会导致用户线程停顿,但会导致应用程序变慢,降低总吞吐量
CMS默认启动的回收线程数是(处理器核心数量+3)/4,低于4个核心会明显影响速度
为了解决这一问题,提出了增量式并发收集器- 增量式并发收集器(i-CMS)
并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,GC过程会更长,但对用户程序的影响降低。对用户来说直观感受是速度没那么慢了,但慢的时间延长了
(i-CMS效果并不好,在JDK 7开始就被声明为deprecated,JDK 9起被完全废弃)
- 增量式并发收集器(i-CMS)
-
无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败进而导致另一次完全STW的Full GC的产生
与其他收集器相比,CMS需要预留内存给用户并发的程序。但如果留得太多(JDK 5),CMS的GC触发会更频繁;如果留得太少(JDK 6),用户程序不够用则容易触发“并发失败”,从而启动备用方案Serial Old重新GC-XX:CMSInitiatingOccupancyFraction //设置到达多少百分比的内存时,CMS就触发一次GC //例如JDK 5是68%,即内存被使用了68%,就进行GC;而JDK 6默认值已经升到92%
-
基于标记-清除算法,导致大量空间碎片,可能导致提前触发一次Full GC
-XX:+UseCMS-CompactAtFullCollection //CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程 //上述命令默认开启,但在JDK 9被废弃。因为必须移动存活对象,导致STW变长 -XX:CMSFullGCsBefore-Compaction //在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理 //JDK 9起被废弃,默认值为0,表示每次Full GC都整理
CMS在JDK 9及以上版本,已被声明为Deprecate,即不推荐适用
Garbage First收集器(G1)
概述:
G1是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于Region的内存布局形式
G1是一款主要面向服务端应用的垃圾收集器
目标是替代CMS,JDK 9之后,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器
产生原因:
(1)CMS与HotSpot各个子系统都有联系,不符合职责分离的设计原则(就是没达到高内聚低耦合的要求)
(2)JDK10的规划:制定“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离(可以类比一个接口和多个实现类),使CMS以及其他收集器都重构成基于这套接口的一种实现,这样移除和加入收集器都更加方便
(3)希望建立“停顿时间模型”(Pause Prediction Model)的收集器:能够支持在指定的M毫秒时间内,消耗在GC上的时间大概率不超过N毫秒
基于Region的堆内存布局:
仍遵循分代收集理论,但不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的
旧对象都能获取很好的收集效果。
- Humongous区域:Region中的一类特殊区域,专门用于存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象
每个Region的大小可以通过参数-XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待
(G1收集器Region的分区示意图:E代表Eden区域,S代表Survivor区域,H代表Homongous区域)
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合
新对象一般会被分在新生代区域,G1会在Eden区域进行Mirror GC,经过多次Mirror GC仍存活就称为老年代区域
G1的两种GC:
-
Mirror GC:
对新生代Regions进行,采用一般的复制-清除算法 -
Mixed GC:
在G1之前,GC范围都是整个新生代(Minor GC)、整个老年代(Major GC)、整个Java堆(Full GC)
G1提出了Mixed GC:面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大
G1特殊情况下也会发生Full GC
另外,G1从JDK 9才称为默认收集器,JDK 8需要参数指定
G1回收:
以Region作为单次回收的最小单元。G1收集器在后台维护一个优先级列表,里面记录了回收每个Region能够获得的空间大小以及回收所需时间。每次GC时根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,保证了G1收集器在有限的时间内获取尽可能高的收集效率,这也是Garbage First名称的含义
- 期望停顿时间太短:每次回收很少,最终回收速度跟不上分配速度,最终占满堆引发Full GC反而降低性能
一般设置为100-300ms
G1的四个步骤
- 初始标记(Initial Marking)
标记GC Roots能直接关联到的对象
修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象
需要STW,但耗时极短。G1的初始标记是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿 - 并发标记(Concurrent Marking)
从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,标记要回收的对象
扫描完之后,还有重新处理SATB(原始快照)记录的引用变动的对象
耗时较长,但可与用户程序并发执行 - 最终标记(Final Marking)
短暂的STW,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录 - 筛选回收(Live Data Counting and Evacuation)
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后要被回收的Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间
涉及存活对象的移动,是必须STW暂停用户线程,由多条收集器线程并行完成的
除了并发标记外,其他3个阶段都需要STW;可以看出G1并非完全地追求低延迟,它的目标是在延迟可控的情况下获得尽可能高的吞吐
量,这也是”全功能收集器“的要求
Oracle官方有考虑将筛选回收做成并发的,但比较复杂,这个特性被G1之后出现的低延迟垃圾收集器(ZGC)实现
G1从整体来看是基于“标记-整理”算法,局部又是基于“标记-复制”算法,因此不会产生大量碎片
G1应对的一些问题:
- 跨Region引用问题:使用记忆集,但G1的记忆集要更加复杂
每个Region都维护有自己的记忆集,G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号
这种“双向”的卡表结构比原来的卡表实现起来更复杂(卡表是“我指向谁”,这种结构还记录了“谁指向我”)
由于Region数量众多,因此内存占用负担更大,根据实验,至少要耗费约Java堆容量10%至20%的额外内存来维持收集器工作 - 并发标记阶段如何保证收集线程与用户线程互不干扰地运行
(1)用户线程需要改变对象引用关系时:CMS采用增量更新算法,而G1采用原始快照算法
(2)用户线程需要创建新对象时:G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,Region中的一部分空间被划分出来用于并发回收过程中的新对象分配,在并发回收过程中新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围
(3)同样,如果内存回收的速度赶不上内存分配的速度,G1也要STW,进行Full FG - 建立可靠的停顿预测模型
用户通过-XX:MaxGCPauseMillis参数指定停顿时间
G1收集器的停顿预测模型是以衰减均值(?)为理论基础来实现的,衰减均值更容易受到新数据的影响,更准确地代表“最近的”平均状态
Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益
相比于CMS:
G1内存占用更多:CMS仅一份记忆集,G1的记忆集更多
G1的额外执行负载大,例如写屏障更加复杂,不仅需要写后屏障,还需要写前屏障
总结
收集器 | 分代 | 线程 | 适用 | 优点 | 缺点 |
---|---|---|---|---|---|
Serial | 新生代 | 单线程 | 客户端 | 简单高效,内存开销小 在单线程中收集效率最高 | STW问题 |
ParNew | 新生代 | 多线程并行 | 服务端 | 唯一能与CMS收集器配合 | 单核还不如Serial |
Parallel Scavenge | 新生代 | 多线程并行 | 交互少 后台运算量大 | 目标:高吞吐 自适应调节策略 | 不合适交互多 |
Serial Old | 老年代 | 单线程 | 主客户端/服务端 | - | 服务端应用性能表现不佳 |
Parallel Old | 老年代 | 多线程并行 | 服务端 | 多线程提高吞吐量 | - |
CMS | 老年代 | 多线程并发 | 交互多 服务端 | 并发收集、低停顿 | 处理器资源、 浮动垃圾、空间碎片 |
G1 | Region | 并行与并发 | 服务端 | 并行与并发、分代收集、 空间整合、可预测停顿时间 | 内存占用高 格外执行负载大 |
几种搭配
搭配 | 说明 |
---|---|
ParNew + CMS | JDK 5推出了CMS,是JDK7之前服务端的首选,JDK 9之后已被舍弃 |
Parallel Scavenge + Serial Old | JDK 5以及之前,服务端模式被使用过 但Serial Old适用客户端,这种搭配整体上不能实现吞吐量最大化 在老年代上甚至可能不如ParNew+CMS |
Parallel Scavenge + Parallel Old | “吞吐量优先”的最佳搭配组合,JDK 9后G1取代了其在服务端的默认地位 |
3.6 低延迟垃圾收集器
随着计算机硬件的发展、性能的提升,吞吐量会更高,但对延迟会带来负面的效果(主要是内存的扩大导致)
Shenandoah收集器
第一款不由Oracle开发的HotSpot垃圾收集器,OpenJDK包含,但OracleJDK不包含
目标:实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器。意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作
和G1大体相同,不同之处在于:
-
支持并发的整理算法:并发回收阶段
-
默认不使用分代收集:也就是Region不被分为新生代或老年代等
-
用”连接矩阵“代替记忆集:降低了维护消耗和伪共享发生的概率;连接矩阵是一个N*N的二维表格,N表示Region的个数,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记
Brooks Pointer转发指针:
-
在转发指针(Forwarding Pointer)出现之前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。
虽然实现了并发,但需要OS支持,且频繁的用户态与核心态切换代价高昂 -
Brooks Pointer
不需要内存保护陷阱,在原有对象最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自身
这样相当于暂时多了一份对象备份,在并发移动时,只需要修改原对象的引用字段指向整理后的新位置即可
Brooks指针每次对象访问会带来一次额外的转向开销,尽管这个开销被精简到了一条汇编指令的程度,但总量大- 问题一:并发写
并发读没事,但并发写入就要确保写操作只发生在新复制的对象上,而不是写入旧对象的内存,假设按以下顺序执行:
(1)收集器线程复制了新的对象副本
(2)用户线程更新对象的某个字段
(3)收集器线程更新转发指针的引用值为新副本地址
这将导致写入到旧对象里面,因此需要采取同步措施使事件3发生在事件1和2之间 - 问题二:执行效率
Shenandoah要覆盖全部对象访问操作,不得不同时设置读、写屏障去拦截(因为要在读写前更新引用值)
并且为了实现Brooks Pointer,还需要在读写屏障中设置额外的转发处理
读屏障的代价比写屏障更高,因为对象读取的操作远多于写入,这是最高的诟病
改进:引用访问屏障,只拦截对象中数据类型为引用类型的读写操作,而放过原生数据类型
- 问题一:并发写
九个阶段:
- 初始标记(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍需要STW,但停顿时间与堆大小无关,只与GC Roots的数量相关
- 并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿
- 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region
- 并发回收(Concurrent Evacuation):核心差异处。这个阶段需要将存活对象复制到未被使用的Region之中,如果采用STW很简单,要并发进行就有一个难题:在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的
Shenandoah通过读屏障和被称为“Brooks Pointers”的转发指针来解决这一问题
并发回收阶段运行的时间长短取决于回收集的大小 - 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。初始引用阶段并未真正进行引用更新,它只是提供一个线程的集合点,确保所有的垃圾回收线程都完成了复制对象到新 Region 的任务。耗时很短,会产生一个短暂的STW
- 并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可
- 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关
- 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用
ZGC收集器
Oracle公司研发的,在JDK 11中新加入的具有实验性质的低延迟垃圾收集器
ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器
背景:
早在2005年,运行在Azul VM上的PGC就已经实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集,而运行在Zing VM上的C4收集器是PGC继续演进的产物,主要增加了分代收集支持,大幅提升了收集器能够承受的对象分配速度
ZGC与PGC和C4是高度相似的
内存布局——动态Region:
动态创建和销毁,动态的区域容量大小
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象
每个大型Region中只会存放一个大对象,因此实际容量完全有可能小于中型Region
大型Region在ZGC的实现中是不会被重分配,因为复制一个大对象的代价非常高昂
并发整理算法的实现:
Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但实现思路完全不同
ZGC收集器有一个标志性的设计是它采用的染色指针技术
-
产生背景:
过去,如果要存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段
当希望得知该对象的某些信息,又不能访问该对象时(例如想知道该对象是否被移动过),存储在对象头就不方便了,ZGC收集器通过将标记打在指针上,来解决这个问题 -
三种标记方式:
- 标记直接记录在对象头上(如Serial收集器)
- 标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息)
- 标记信息记在引用对象的指针上(ZGC收集器)。此时,可达性分析从遍历对象图,变成遍历”引用图“
-
染色指针:
染色指针是一种直接将少量额外的信息存储在指针上的技术-
对于64位系统,基于需求、性能、成本考虑,AMD64架构只支持到52位的地址总线和48位的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB
-
操作系统进一步增加约束,64位的Linux分别支持47位的进程虚拟地址空间和46位物理地址空间(用于寻址)
64位的Windows系统甚至只支持44位的物理地址空间 -
ZGC的染色指针技术将46位指针宽度中的高4位提取出来,存储四个标志信息,分别是其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,染色指针如下图
ZGC能够管理的内存不可以超过4TB(2的42次幂)
染色指针不支持32位平台,不能支持压缩指针
-
两个Marked标记:每一个GC周期开始时,会交换使用的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记
-
基于染色指针的多重映射
参考:https://cloud.tencent.com/developer/article/2118862
内存多重映射就是使用 mmap 把不同的虚拟内存地址映射到同一个物理内存地址上
ZGC 为了更灵活高效地管理内存,使用了内存多重映射,把同一块物理内存映射为Marked0、Marked1和Remapped三个虚拟内存
当应用程序创建对象时,会在堆上申请一个虚拟地址,这时 ZGC 会为这个对象在 Marked0、Marked1 和 Remapped 这三个视图空间分别申请一个虚拟地址,这三个虚拟地址映射到同一个物理地址
Marked0、Marked1 和 Remapped 这三个虚拟内存作为 ZGC 的三个视图空间,在同一个时间点内只能有一个有效。ZGC 就是通过这三个视图空间的切换,来完成并发的垃圾回收
当标记位变化时,指针的虚拟内存地址会发生变化,此时对象的物理内存地址没有变化
这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大
ZGC的运作过程(不太清楚):
参考:https://cloud.tencent.com/developer/article/2118862
-
并发标记(Concurrent Mark):遍历对象图做可达性分析,前后也要经过类似于G1、Shenandoah的初始标记、最终标记的短暂停顿。不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针的Marked 0、Marked 1标志位
- ZGC 初始化后,整个内存空间的地址视图被设置为Remapped
- 标记过程中Java 应用线程新创建的对象会直接进入 Marked0 视图
- 标记过程中Java 应用线程访问对象时,如果对象的地址视图是 Remapped,就把对象地址视图切换到 Marked0
- GC线程访问对象时,如果对象地址视图是 Remapped,说明该对象未被访问过,对其进行标记等处理后,把对象地址视图切换到 Marked0;如果访问时已经是Marked0试图,说明要么被其他GC线程访问过,要么被用户更新过,于是不对它进行处理(暂时保留)
- 使用2个Marked标志:是为了区分二次标记。如果只用Marked 0,就无法区分一个视图是Marked0的对象,它到底有没有经历过第二次标记
- 此时,(1)Marked0表示上次标记为活跃(需要保留的对象),但未被转移,且本次未被标记为活跃的对象;(2)Marked1表示本次被标记为活跃的对象;(3)Remapped是上次发生转移但本次被标记为不活跃的对象
-
并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)
G1的回收集是为了做收益优先的增量回收,它的标记和回收只针对回收集中的Region
ZGC的重分配集有所不同,ZGC每次回收都会扫描所有的Region,以范围更大的扫描成本换取省去G1中记忆集的维护成本。它的标记是针对全堆的,回收是针对重分配集的在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的
-
并发重分配(Concurrent Relocate):核心阶段。这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。
在染色指针的支持下,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中。如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值(改变虚拟地址,但物理地址没变,即多重映射),使其直接指向新对象。ZGC将这种行为称为指针的“自愈”(Self-Healing)能力-
流程:(1)转移过程中,对象视图会被切回 Remapped
(2)并发转移过程中 Java 应用线程创建的新对象地址视图是 Remapped
(3)并发转移过程中 Java 应用线程访问的对象被标记为活跃并且对象视图是 Marked0,则转移对象,并把对象视图设置成 Remapped。
(4)如果 GC 线程访问对象的视图是 Marked0,则转移对象,并把对象视图设置成Remapped ;如果是 Remapped,说明被其他 GC 线程处理过,跳过不再处理 -
染色标记只有第一次访问旧对象会陷入转发,而Brooks转发指针每次对象访问都必须付出的固定开销,相较负载更低
-
由于转发表和染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放(转发表需要保留)用于新对象的分配。哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的
理解:
Brooks指针:由于引用字段在原有对象最前面,原有对象必须一直存活作为中间跳板,直到所有指向它的对象都完成了读写和引用更新操作
染色指针:一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放。因为即便原对象被回收了,程序也可以通过转发表直接访问到新的对象,并更新这个引用,这个过程不需要原对象的支持 -
-
并发重映射(Concurrent Remap):是修正整个堆中指向重分配集中旧对象的所有引用。
与Shenandoah并发引用更新阶段不同,ZGC的并发重映射并非迫切任务。从并发重分配阶段也可以看出,只要访问对象,就能触发重映射,之所以还要专门进行一次,是为了尽早释放转义表等资源
ZGC将并发重映射合并到了下一次GC的并发标记阶段,节省了一次遍历对象图的开销
优势:
几乎全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标
没有分代,因此不需要记忆集(卡表),也不用写屏障,给用户线程带来的运行负担小很多
- 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量。ZGC未使用任何写屏障,只使用了读屏障(一部分原因是染色指针的功劳,一部分是因为没有分代收集因而没有跨代引用问题)
- 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
劣势:
- 承受的对象分配速率不会太高。归结来说是因为没有分代收集,假设在一次GC进行中,应用又创建了大量对象,这些对象很难进入本次GC的标记范围(没有新生代的划分),就只能全部当作存活对象来看。这种高速分配持续的话,浮动对象越来越多,最终内存不足
- ZGC的前提是要使用染色指针,但是涉及到更改底层指针架构,需要考虑到操作系统和处理器是否支持,及更改的成本问题
3.7 选择合适的垃圾收集器
收集器的权衡
Epsilon收集器
JDK 11引入,反其道而行:Epsilon收集器控制内存分配,但是不执行任何垃圾回收工作。一旦java的堆被耗尽,jvm就直接关闭。
传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化等特点,对短时间、小规模的服务形式就有诸多不适。运行负载极小、没有任何回收行为的Epsilon可能在这样一些工作中更合适
收集器的权衡:
- 关注的重点是什么
注重吞吐量:数据分析、科学计算类的任务,目标是能尽快算出结果
注重延迟:如SLA应用,停顿时间直接影响服务质量,严重的甚至会导致事务超时
注重内存占用:客户端应用或者嵌入式应用 - 运行应用的基础设施是什么
系统架构、处理器数量、内存大小、操作系统类型 - JDK版本
JDK发行商、版本号
需要根据系统实际情况去测试选择合适的收集器
虚拟机及垃圾收集器日志
-
测试代码
/** * 用于测试GC日志的代码 * 创建50M的数组,再设置最大堆为10M,这样在运行时,一定会发生GC * -Xms10m -Xmx10m -XX:+PrintGC */ public class Test { public static void main(String[] args) { byte[] bytes = new byte[50 * 1024 * 1024]; } }
概述
JDK 9及之后,HotSpot所有功能的日志都收归到了“-Xlog”
参数上
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
-
Selector:最关键的参数,标签(Tag)和日志级别(Level)组成
-
Tag:功能模块,例如 gc 表示垃圾收集器的标签名。
支持的Tag非常多,例如add,age,alloc,annotation,aot,arguments,attach,barrier等等 -
Level:从低到高,依次为Trace,Debug,Info,Warning,Error,Off
日志级别决定了输出信息的详细程度,默认级别为Info;HotSpot的日志规则与Log4j、SLF4j这类Java日志框架大体上是
一致的注意这里的日志级别和java.util.logging.Logger中的日志级别设置不同
Logger里面是:FINEST FINER FINE CONFIG INFO WARNING SEVERE
-
-
Decorators:附加信息
time:当前日期和时间 uptime:虚拟机启动到现在经过的时间,以秒为单位。 timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。 uptimemillis:虚拟机启动到现在经过的毫秒数。 timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。 uptimenanos:虚拟机启动到现在经过的纳秒数。 pid:进程ID。 tid:线程ID。 level:日志级别。 tags:日志输出的标签集
如果不指定,默认值是uptime、level、tags这三个,例如
[0.201s][info][gc] GC(3) Pause Full (Allocation Failure) 1M->1M(10M) 4.436ms
JDK 9之前和之后的GC日志
IDEA环境下进行
- (1)查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc
上面分别是JDK 8和JDK 9的gc日志,在JDK 9中已经默认使用G1收集器了,且两者日志输出格式也大不相同
-
查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc*;通配符 * 表示打印全部信息
JDK 9中如果把日志级别调整到Debug或者Trace,可以获得更详细的信息,例如-Xms10m -Xmx10m -Xlog:gc*=debug
-
查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug
-
查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用-XX:+PrintGCApplicationConcurrentTime以及-XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint
-
查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在JDK 9之前使用-XX:+PrintAdaptiveSizePolicy,JDK 9之后使用-Xlog:gc+ergo*=trace
-
查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuringDistribution,JDK 9之后使用-Xlog:gc+age=trace
参数非常多,可能等实际用到时再查阅
3.8 内存分配与回收策略
关于默认收集器
jdk 7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk 8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk 9 默认垃圾收集器G1
查看jvm默认收集器(IDEA):-XX:+PrintCommandLineFlags -version
这里使用更常见的JDK 8做测试
对象优先在Eden上分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
-
参数设置
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 //-verbose:gc和-XX:+PrintGC效果相同 //-Xms 堆内存的初始大小,默认为物理内存的1/64 //-Xmx 堆内存的最大大小,默认为物理内存的1/4 //-Xmn 堆内新生代的大小。通过这个值也可以得到老生代的大小:-Xmx减去-Xmn //-XX:SurvivorRatio=8 决定了新生代中Eden区与一个Survivor区的空间比例是8∶1
-
测试代码
/** * 分配三个2MB大小和一个4MB大小的对象 * 根据参数设置最大堆为20M,其中新生代为10M,老年代为10M * 新生代中Eden区8M,Suvivor区每个1M;则新生代可用空间 = Eden + 1个Suvivor = 9M */ public class Test { private static final int _1MB = 1024 * 1024; public static void main(String[] args) { System.gc();//测试之前,先全面清理一遍 byte[] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC } } /** * 假设是在Serial+Serial Old客户端的理想情况下,会发生以下结果: * allocate1,allocate2,allocate3都被放入Eden区 * allocate4分配空间时,发现新生代不足,会发生一次Minor GC * Minor GC期间,发现之前的3个已分配对象各占2M,但Suvivor只有1M,一个都放不进去,只能将它们转移到老年代 * 最终是Eden占用4MB,老年代占用6MB */
-
结果验证和分析
!!!注意这里采用的是JDK 8的Parallel收集器!!!
- 如果是在命令行下运行,输入以下命令
javac -encoding utf-8 Test.java java -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 Test
-
注意,如果不先
System.gc()
,即便不申请任何堆空间,CMD中新生代初始也被占用了1312K
的内存;而在IDEA中,这个初始被占用内存增加到了2321K
-
以下是在IDEA中测试的结果(运行结果在50K以内可能有些微差距)
System.gc(); //PSYoungGen total 9216K, used 246K //ParOldGen total 10240K, used 619K byte[] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[2 * _1MB]; //PSYoungGen total 9216K, used 2457K //ParOldGen total 10240K, used 619K allocation2 = new byte[2 * _1MB]; //PSYoungGen total 9216K, used 4506K //ParOldGen total 10240K, used 619K allocation3 = new byte[2 * _1MB]; //PSYoungGen total 9216K, used 6554K //ParOldGen total 10240K, used 619K allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC //PSYoungGen total 9216K, used 6554K //ParOldGen total 10240K, used 4715K
在前3次对象分配中,Eden内存充足,在第4次分配时,Eden内存不足,大对象allocation4直接被分配到了老年代;而Survivor因为太小,始终没有用到
最终结果如下图:
大对象直接进入老年代
注意,在Parallel收集中,只要Eden区域空间不足,也是直接将新来的大对象放到老年代的
而Serial则是将Eden已有对象复制给老年代,之后优先将新来的大对象放到Eden区
-
参数设置
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 //PretenureSizeThreshold:指定大于该设置值(这里是3M,但不能写成3M的形式)的对象直接在老年代分配,避免大量的内存复制
-
测试代码
public class Test { private static final int _1MB = 1024 * 1024; public static void main(String[] args) { byte[] allocation; allocation = new byte[4 * _1MB]; //直接分配在老年代中 } }
-
结果
PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,而对Parallel Scavenge并不支持,在JDK 8中运行只能看到它被分配到新生代
长期存活的对象将进入老年代(未完成)
在Eden区存活的对象,如果能被Survivor容纳,会被首先分到Survivor,并且将其对象年龄设为1岁
对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置
-
参数设置
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution //设置为经过3轮Mirror GC就进入老年代
-
测试代码(Parallel Scavenge 收集器)
private static final int _1MB = 1024 * 1024; @SuppressWarnings("unused") public static void testTenuringThreshold() { byte[] allocation1, allocation2, allocation3; allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置 allocation2 = new byte[4 * _1MB]; allocation3 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; }
这里没测试成功
动态对象年龄判定(未完成)
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄
空间分配担保(未完成)
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的
如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure)
- 如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC(发现以前每次复制过来都成功了,哪怕这次空间小于新生代对象总空间,也尝试一次,比较不是所有新生代对象都会晋升)
- 如果不允许冒险,就改为进行一次Full GC。