概述
第2章了解了运行时数据区,这一章探讨垃圾收集器与内存分配策略
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内存
Java堆的回收
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”
如何判断对象活着还是死了?
引用计数算法(Java虚拟机不用)
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可
能再被使用的。引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是其要配合大量的额外处理才能保证正确的工作。
单纯的引用计数就很难解决对象之间相互循环引用的问题
可达性分析算法(java用)
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
java中固定可作为GC Roots的对象
·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
·在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
·在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
·Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
·所有被同步锁(synchronized关键字)持有的对象。
·反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
GC Roots是可以有其他内容加入的 ,必须保证GC ROOTS内容完整
正确的定义引用
在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。但现在希望 :当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
强引用是最传统的“引用”的定义。
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
判断为不可达对象之后的处理
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是
否有必要执行finalize()方法。
没有必要执行finalize()方法的情况(直接死掉):对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过
有必要执行finalize()方法的情况(有逃脱机会)
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对
象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。从代码清单3-2中我们可以看到一个
对象的finalize()被执行,但是它仍然可以存活
回收方法区
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似
==假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。==常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,需要同时满足下面三个条件:
1.该类所有的实例都已经被回收
2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
什么是反射:
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
垃圾收集算法
主要重点介绍分代收集理论和几种算法思想及其发展过程
从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(ReferenceCounting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。
java虚拟机中主要涉及**“追踪式垃圾收集”(Tracing GC)**
分代收集理论:
分代收集的前提:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
垃圾收集器的一致的设计原则:
收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;
如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
Java虚拟机中的分代理论
设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域[2]。顾名思义,在新生代中,每次垃圾收集
时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
但是跨代引用带来极大性能负担,所以有第三条假说
3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
收集的分类:
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
■新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
■老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
■混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收
集器会有这种行为。
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
垃圾收集算法举例–标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点: 1.第一个是执行效率不稳定 2.内存空间的碎片化问题
垃圾收集算法举例–标记-复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
**缺点:**少了一半内存
**改进(HotSpot虚拟机):**把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实
际上大多就是老年代)进行分配担保(Handle Promotion)
垃圾收集算法举例–标记-整理算法
标记-整理算法针对老年代对象的存亡特征,在标记后让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
HotSpot的算法细节实现
HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度当。需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了
安全区域(Safe Region)
解决用户线程处于Sleep状态或者Blocked状态让虚拟机进入垃圾回收状态的问题
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的
阶段),如果完成了,那线程就当作没事发生过,继续执行;未完成它就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆集与卡表
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构
记忆集的记录精度
**字长精度:**每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
**对象精度:**每个记录精确到一个对象,该对象里有字段含有跨代指针。
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集
HotSpot默认的卡表标记逻辑
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如
果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0
==解释执行(Interpreted Execution)和编译执行(Compiled Execution)是两种程序代码转换成机器码的方式,它们对代码的处理方式和执行机制不同。
解释执行指的是通过一个解释器直接运行源代码,一边逐行读取代码,一边进行解释和执行。这种方式不需要事先将程序编译成机器码,每次运行都需要解释器实时翻译。解释执行的好处是方便调试,因为它可以立即执行代码,但缺点是执行速度通常较慢,因为每次运行时都需要解释。
编译执行则是指先通过一个编译器将源代码全部编译成机器码(或者是中间码,再由虚拟机转换为机器码),生成可执行文件,然后再运行这个文件。这种方式需要编译这个步骤,但是一旦编译完成,运行时就不再需要源代码,因为已经转换为了机器语言。编译执行的优点是运行速度快,因为编译只做一次,之后直接运行编译好的代码;但缺点是调试起来不如解释执行方便,并且对不同的操作系统或平台,可能需要重新编译。 Java 使用了折中的方法,先将源代码编译成中间码(Java 字节码),然后通过虚拟机(JVM)进行解释执行或进一步编译为机器码执行 ==
写屏障
解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏
HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)
伪共享问题
卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处
理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏(只解决一部分)
并发的可达性分析
从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长
并发可能导致对象消失问题
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
赋值器插入了一条或多条从黑色对象到白色对象的新引用;(黑色)引用了一个可能会被当作垃圾回收的对象(白色),因为在三色标记的过程中,黑色对象不会再被检查,所以这个新的引用不会被发现。如果没有其他的灰色对象引用这个白色对象,GC 可能会错误地回收它。)**
赋值器删除了全部从灰色对象到该白色对象的直接或间接引用**(白色对象被标记为存活的机会没有了)
解决方案:破坏条件,即增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
经典的垃圾收集器
如果两个收集器之间存在连线,就说明它们可以搭配使用,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器
Serial(串行)收集器-老生代
特点:
1.只会使用一个处理器或一条收集线程去完成垃圾收集工作
2.在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束
ParNew收集器-新生代
ParNew收集器实质上是Serial收集器的多线程并行版本
并行与并发
·并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
·并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。
Parallel Scavenge收集器-新生代
一个基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐(Throughput)。
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个
参数之外,Parallel Scavenge收集器还有一个参数XX+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)
Serial Old收集器-老生代
可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用
Parallel Old收集器-老生代
在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
CMS(Concurrent Mark Sweep)收集器
CMS收集器就非常符合关注服务的响应速度,希望系统停顿时间尽可能短的需求
CMS的运作过程
1)初始标记(CMS initial mark)
2)并发标记(CMS concurrent mark)
3)重新标记(CMS remark)
4)并发清除(CMS concurrent sweep)
初始标记、重新标记这两个步骤仍然需要“Stop The World”。在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一
起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
优点:并发、低停顿
缺点:1.CMS收集器对处理器资源非常敏感且会,降低总吞吐量
解决办法:虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,在并发标记、清理的时候让收集器线程、用户线程交替运行,但效果不好
Garbage First(G1)收集器
开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。G1可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合
G1的垃圾处理思路:G1收集器去跟踪各个Region里面的垃圾堆积的**“价值”大小**,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region
Region里面存在的跨Region引用对象如何解决:每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。
缺点: 比卡表实现复杂,消耗很多额外内存
G1收集器的停顿预测模型
是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。
·初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
·最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
·筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧
egion的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
G1与CMS
G1的优点:G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现。G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存
缺点:为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高
内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用
执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
各款收集器的并发情况
低延迟垃圾收集器
衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)
Shenandoah收集器
能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
G1与Shenandoah的区别:
(1)Shenandoah支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发
(2)Shenandoah(目前)是默认不使用分代收集的
(3)Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection
Matrix)的全局数据结构来记录跨Region的引用关系,也降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。
Shenandoah收集器的工作过程
(1)初始标记(Initial Marking) Stop The World:与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍
是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
(2)并发标记(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段
是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
(3)最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停
(4)并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
(5)并发回收(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的
核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之
中**。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进
行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象
进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对
象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为 “Brooks Pointers”的转发指针来解决(讲解完Shenandoah整个工作过程之后笔者还要再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。
(6)初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
(7)并发引用更新(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
(7)最终引用更新(Final Update Reference): 解决了堆中的引用更新后,还要修正存在于GC Roots ?
中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
(8)并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已
再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收
这些Region的内存空间,供以后新对象分配使用。
黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。
Brooks Pointers”的转发指针
在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己.
解决线程竞争问题:
对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。
解决执行频率问题:
计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)[10]的实现,所谓“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写
什么是读屏障(晚上回去看)、写屏障?
读屏障是在读取操作前执行的一系列指令,用于确保得到的数据是最新的,特别是在其他线程或核心可能同时修改数据的环境中。在垃圾回收中,读屏障可以用来确保对象的引用是正确的,防止读取到已经移动或者处理过的对象。
写屏障是在写入操作前后执行的一系列指令,用于维护内存的一致性和正确性。在并发环境中,写屏障确保在修改内存内容后,其他读取操作能够看到这个变化。在垃圾收集的上下文中,写屏障通常用来实现增量或并发标记,以及确保对象的引用关系在对象被移动时仍然保持一致。
ZGC收集器
特点:ZGC收集器是一款基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。
内存布局
ZGC也采用基于Region的堆内存布局,其Region具有动态性——动态创建和销毁,以及动态的区域容量大小。
Region容量分类:
小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置
4MB或以上的大对象。每个大型Region中只会存放一个大对象,最小容量可低至4MB。大型Region在ZGC的实
现中是不会被重分配
### ZGC的并发整理
染色指针技术:染色指针是一种标记实现方案,Serial收集器直接把标记直接记录在对象头上,G1、Shenandoah使用BitMap的结构来记录标记信息。ZGC的染色指针直接把标记信息记在引用对象的指针上。染色指针是一种直接将少量额外的信息存储在指针上的技术
染色指针将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问
到,如图3-20所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致
ZGC能够管理的内存不可以超过4TB
染色指针的优势
1)染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用
掉。
2)染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的
目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些
专门的记录操作。
3)染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以
便日后进一步提高性能。
要顺利应用染色指针有一个必须解决的前置问题
ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了
ZGC的工作流程
ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段
并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的
阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的
短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC
的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志
位。
并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出
本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器
的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增
量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的
维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面
的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对
全堆的。
并发重分配(Concurrent Relocate):这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(ForwardTable),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的”自愈“(Self Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次
是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于
新对象的分配
并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所
有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不
是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第
一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束
后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射
阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所
有对象的,这样合并就节省了一次遍历对象图[9]的开销。一旦所有指针都被修正之后,原来记录新旧
对象关系的转发表就可以释放掉了。
ZGC的弊端
ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(请读者切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这区域进行更频繁、更快的收集。Azul的C4收集器实现了分代收集后,能够应对的对象分配速率就比不分代的PGC收集器提升了十倍之多。
选择合适的垃圾收集器
Epsilon收集器
如果是数据分析、科学计算类的任务,目标是能尽快算出结果,那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是不可忽视的