《深入理解Java虚拟机》(二) GC 垃圾回收机制
@
一、概述
线程独有的程序计数器、JVM栈、本地方法栈,他们的生命周期与线程一致,实现了内存自动清理,不需要GC进行内存管理;线程共享的方法区和堆区,其中方法区内存回收的条件非常苛刻,所以GC内存回收主要发生在堆上,程序运行期间这部分共享区域的内存分配和使用都是动态的;
二、判断对象是否需要被回收方式
1.引用计数法:
每个对象使用一个引用计数属性,每当对象被引用/释放 自动改变引用计数,当引用计数为0时即代表该对象可以被回收;但是该方法存在致命缺陷,那就是循环引用:
如果存在上述循环引用,那么这些对象的引用计数都不会变成0,所以都不会被回收;
2.可达性分析法:
从GC Roots 开始向下搜索引用,搜索的路径称为引用链,如果一个对象不能通过任意引用链到达GC Roots,那么这个对象就是可以被回收的
如图所示,X、Y、Z存在循环引用,但是ZYZ不能与GC Roots的引用链相连,它们都是不可达的,所以他们都会被回收;而对象A、B、C...等等则不会被回收。
GC Root包括:
- 虚拟机栈中引用的对象
- 方法区中类静态属性实体引用的对象(Class对象)
- 方法区中常量引用的对象
- 本地方法栈 中JNI引用的对象
三、垃圾收集算法
1.分代收集理论基础
分代收集理论建立在如下分代假说上:
- (a)弱分代假说:绝大多数对象存活时间都很短,朝闻道夕可死;
- (b)强分代假说:对象熬过越多次垃圾回收,那么它在每次垃圾回收过程中存活概率就越高;
- (c)跨代引用假说:跨代引用相对于同代引用来说占极少数;
以此为依据,Java堆被分为不同的区域,根据对象熬过收集次数决定其所属区域。Java堆一般会分为新生代(Young Generation)和老年代(Old Generation)两个区域,新生代绝大多数对象符合弱分代假说,每次垃圾回收新生代都会有大量对象消亡,每次回收少量存活对象,当满足一定条件这些新生代存活对象将会进入老年代中;所以老年代中对象则对应强分代假说的产物,老年代对象都是不容易消亡的对象。
根据不同区域对象存活特点,那么他们也适用于不同的垃圾回收算法,先做个概述,后续将会一一详细介绍它们:
- 标记-清除算法:遍历,清理所有被标记的对象;当被标记的需要清除的对象,数量较大时开销很大,所以对象朝生夕灭的新生代不适用该算法;
- 标记-复制算法:复制本次垃圾回收不需要清理的对象,转移到其它内存区域中;当存活对象较多时,那么需要复制的代价就会变得很高,所以该算法不适用于对象存活率高的老年代,(该算法需要担保空间);
- 标记-整理算法:将本次垃圾回收不需要被收回的对象往内存区域的一个方向集中移动;
同样垃圾回收的动作,也因此有了不同的行为:
Partial GC 堆部分收集
- Minor GC/Young GC--目标是新生代的垃圾收集
- Major GC/Old GC--目标是老年代的垃圾收集
- Mixed GC --目标是整个新生代和部分老年代的垃圾收集(只有G1垃圾收集器存在该行为)。
Full GC 整堆收集
- Full GC:整堆收集,收集目标是整个Java堆和方法区。
根据如上述的垃圾回收动作类型,那么产生了新的问题--跨代引用(新生代对象和老年代对象存在相互引用关系),当进行部分收集时,例如:Young GC,此时固定的GC Roots并不包含老年代,但是因为跨代问题,就需要在GC Roots之外,再遍历老年代,以确保新生代中被老年代引用的对象不会被回收。
根据假说(a)、(b)得到推论:一般情况下,存在互相引用关系的对象拥有相同的生命周期,那么当一个对象被另一个对象引用(生命周期同);如果一个对象难以消亡那么它引用的对象也难以消亡,最终会一起进入到老年代;根据跨代引用假说(c),那么当发生Young GC时,不再有必要去遍历老年代。
但是,有的引用并非一开始就能确定的,有的引用会在运行的过程中产生,所以上述的一般情况并不能涵盖所有的情形。
可行的处理方法是:
- 把老年代划分为若干内存块
- 在新生代上建立一个数据集--"记忆集"用以标识存在跨代引用的老年代内存块
- 在进行Young GC时就可以只扫描被标记在记忆集上的老年代内存块,不再需要遍历老年代。
这么需要的开销是:在对象改变引用关系时,需要更新记忆集维护的记录的正确性,更新它的开销比Young GC扫描整个老年代的代价要小得多。
2.标记-清除算法
该算法包含两个步骤:标记和清除,GC时先标记需要回收的对象,然后再统一进行垃圾回收,它是最基础的收集算法,后续的收集算法都是在它的基础上进行改进得到的;
收集前,标记红色对象存活,绿色的需要被清除
清除绿色对象后:
标记清除算法的缺点如下:
- 效率问题,标记和清除过程的效率都不高;
- 空间问题,产生大量不连续的碎片化内存块,对后续分配较大内存块造成困扰,如果分配一个大对象找不到足够的连续内存空间则会触发另一次垃圾回收,直到得到足够的内存空间,显然这样是不合理的。
3.复制-收集算法
该算法将可用的内存空间划分为两块,只使用其中一块,当其中一块即将使用完时(1:连续使用完;2:大对象不能分配到足够的连续内存),那么将当前使用的内存块(A)上存活的对象,拷贝到未使用的内存块(B)上的连续内存上,然后清理内存块A,内存块B成为正在使用的内存块,并继续在B上分配内存,以此直至B将满....
左边区域不能满足分配大对象的需求
左边区域存活对象移动至右边区域,并给新的对象分配内存空间
优点
- 这样做回避了很多标记清除算法的问题,大对象分配内存时不再受碎片化的内存影响;
缺点
- 但是这样做牺牲也是很明显的,代价是可用内存直接缩小为原来的一半。
- 当内存中对象存活率较高时(假定碎片化内存块较少),当活动中的内存块将满时,那么就会进行多次复制操作,降低效率。
关于[3.复制-收集]算法关键的是,如果不想浪费50%的空间,就需要有额外的空间进行内存分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,老年代一般不能直接选用这种算法。
4.标记-压缩(整理)算法
面对 [3.标记-复制] 算法的问题,标记压缩算法的处理是:标记过程与 [2.标记-清除] 算法相同,但是它不是直接清理被标记的对象,而是把存活的对象往一端移动,完成移动后,清理掉保存活动的对象端边界以外的区域。
移动前对象位置(假定红色存货、绿色回收):
往一端移动存活对象后
标记-整理,与标记-复制算法看似相同,但是需要强调的是:标记-复制算法是使用的两块大小等同的内存空间,标记-整理算法使用的是一块空间。(例如:假设当前端,大量空闲碎片存在,且使用率达到了90%,但是往另一端整理、移动后占用可能就变成50%)。
5.分代收集
分代收集算法并非特指某个收集算法,而是结合不同区域(Old Generation、Young Generation)和不同收集算法(标记-清除、标记-复制、标记-整理)的特点后进行的分配:
- 对于新生代,大部分对象存活时间段,那么采用--标记-复制算法,每次垃圾回收只需要复制少量存活对象即可;
- 对于老年代,对象存活率高,且没有额外的空间对它进行分配担保,那么就需要使用[2.标记--清除] (常规)或者[4.标记-整理] (内存碎片化,需要为大对象分配内存空间时)算法对它进行垃圾回收。(针对内存碎片化的问题,[3.标记-复制]和[4.标记-整理]都能解决,但是老年代没有额外的担保内存空间,所以只能使用[4.标记-整理]算法,向其中一端移动所有存活对象,从而得到连续的完整的内存块为大对象分配内存空间)。
四、垃圾收集器
注释
- GC线程值进行垃圾回收操作的线程
- 用户线程,指保证程序运行的线程
在某些收集器中,GC线程运行时必须停掉用户线程,GC线程与用户线程串行;在一些收集器中,GC线程和用户线程也可以并发执行。
讲垃圾收集器前先说几个概念:
- 串行:任意线程之间的关系,表示按序执行,不能同时执行。
- 并行:是指多个GC线程之间的关系,例如多线程版本的收集器在GC时就是多个GC线程同时工作。
- 并发:指任意用户程序线程和GC线程的关系,用户程序线程与GC线程同时执行;由于GC线程占用了一部分系统资源,那么GC线程运行时会在一定程度上影响系统的吞吐量。
- 吞吐量:吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 运行垃圾收集器时间 )
1.Serial 收集器
串行收集器是古老、稳定、效率高的收集器,它只使用一个GC线程进行垃圾回收,收集时会产生停顿。新生代、老年代串行(GC线程执行时,用户程序线程需要停止)回收;新生代GC算法使用[3.标记-复制]算法、老年代使用[4.标记-压缩(整理)]算法;由于它是单线程回收,那么在进行回收时用户程序会完全停止。
2.ParNew 收集器
ParNew收集器是Serial收集器的多线程版本(多个GC线程,依然需要停止用户线程),新生代并行收集(多个GC线程同时工作),老年代串行(同Serial,使用单个GC线程收集);使用的垃圾收集算法同Serial一致。
3.Parallel Scavenge 收集器
吞吐量优先的新生代收集器,类似ParNew收集器,使用[3.标记-复制]算法实现,是多线程收集器,可以有多个GC线程同时收集。相较于ParNew收集器,它可以通过参数控制吞吐量。
4.Parallel Old 收集器
它是Parallel Scavenge的老年代版本,支持多线程并行收集,基于[4.标记-整理]算法实现,可通过参数控制GC线程从而达到控制吞吐量的效果。
5.CMS 收集器
CMS ( concurrent Mark Sweep)收集器,基于标记-清除算法实现,它的目标是完成收集的同时做到最短的回收停顿,以最小程度的影响程序响应,它是吞吐量优先的垃圾收集器;由于算法本身的局限,决定了CMS只是一款老年代收集器。
CMS回收的过程包括4个阶段:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
没错是并发,GC线程与用户线程并发,而不是串行;其中初始标记和重新标,与用户线程是串行的,需要暂停用户程序,他们都很短暂;
其中初始标记只是简单的记录GC Roots能直接关联到的对象,这个过程非常短暂。
并发标记阶段是从GC Roots开始遍历整个对象图,它与用户程序线程是并发执行的,"并发标记" 阶段耗时较长。
并发标记之后是重新标记,它的目的是为了修正 “并发标记” 阶段,因程序运行导致的标记变动。
最后是并发清除,清理掉标记阶段已经判断为需要清除的对象,并发清除与用户程序线程并发的执行。
缺点:
- 由于使用标记清除算法,所以他会造成内存空间碎片化,解决方法是,通过参数控制每次CMS收集后触发Full GC(或者规定CMS收集几次后执行一次Full GC,进行碎片化内存的整理) 参数可以自行百度;
- 同样,由于耗时长的并发-标记、并发-清除阶段,GC线程与用户线程同步执行,这样会影响系统推吞吐量;
- CMS 默认:回收线程数量 = (处理器核心数量 + 3) /4
那么当核心数越多时,GC对吞吐量造成的影响就越小,同样当核心数小于4时CMS将对用户程序产生很大的影响。 - 浮动垃圾问题:当重新标记完成,进行并发清理前产生了新的垃圾,那么这些垃圾就只能在下一次GC时被回收,它们被称为浮动垃圾,因为GC线程与并发清除线程并发运行,所以需要个用户线程提供足够的运行内存空间,所以CMS不能等到老年代即将充满时才进行收集(JDK5:CMS默认老年代空间占用了68%时进行GC回收;在JDK6中这个阀值为92%,但是可能造成预留内存空间不足导"并发失败",这是JVM会启动后备预案:冻结所有线程,启用串行的垃圾回收器Serial Old 进行收集)
6. G1 收集器
G1(Garbage First) 与CMS有一定的相同之处,也有很多新的特性;G1是面向服务端的收集器,作为CMS的后继者,它是可以建立“时间停顿模型”(可以指定M毫秒的时间片段内,消耗在垃圾收集上的时间概率上不超过N毫秒)的收集器 ;相较于前代垃圾收集器收集范围只能是新生代或者老年代的特点,G1不再遵循此规则,它可以面向堆内任意一个部分进行垃圾收集,它回收的原则是哪一块(Region)内存上回收带来的效益最佳即优先回收谁。
G1从整体上看是,基于[4.标记-整理]算实现的;从局部看(两个Region之间)又是基于[3.标记-复制]算法实现的。(先知道有region,后续会介绍它)
很好理解
-
从整体上看:发生GC时,存活对象都往另一个方向的Region上移动,概念类似标记整理算法
-
从 局 部 看:发生GC时,一个Region上存活的对象,被复制到另一个新的Region上,所以类似标记-复制算法。
提到内存区块(Region),接下来就引出了G1 的一项重大变更,G1设计上堆不再从物理内存上严格的区分老年代和新生代;G1诞生的关键基于Regin的堆内存布局,该布局把Java堆划分为多个大小相等独立区域(Region),每个Region既可以是老年代,也可以是新生代,因此G1中老年代和新生代便只剩下逻辑上的界限(新生代满足界定条件即标记为老年代);
Region中有一类特殊的Humongous用于存储大对象(G1认为如果对象超过了Region的一半即可认定为大对象);
G1 的实现面临如下问题:
(1)、对象跨Region引用问题
- G1 将堆划分为了多个Region,为了解决跨Region的对象之间的引用,在每一个Regin上都维护一个记忆集,记录:我引用谁、谁引用我......(记忆集的本事是哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表达索引号,这些卡表是双向卡表,它记录的内容就是前边提到的我指向谁、谁指向我,根据经验G1会占用10%~20%的堆内存维持收集工作)。
(2)、"对象消失"问题:并发标记期间如何保证GC线程与用户线程互不干扰?
根据《深入理解Java虚拟机》一书,关于可达性分析(标记过程)引入的 "三色标记"规则,把对内所有对象标记为:黑色、白色、灰色,定义规则如下:
- 黑色对象:初始从GC Roots开始扫描,每扫描完一个对象就把它标记为黑色,这时它持有的所有对象引用都已被扫描清楚; (例如:某人所有的财产已登记,不会再有任何新的财产是直接属于他的,这时他增加财产的唯一方式是来自于他人的赠送/给与)。
- 灰色对象:可以认为它当前正在被扫描,它还持有了其它对象的引用,但是还未扫描完它具体引用了哪些对象;(正在登记其财产,有的财产已登记,有的未登记)
- 白色对象:从未被扫描过(从未被扫描过、从未被扫描过),如果直至扫描结束都未被扫描,即可认定该对象不可达不可达,需要被回收。
以下讨论对象都为临界情况下(瞬时)
初始,黑色A持有B、G引用,B、G同时持有白色F 的引用,如下:
继续扫描,下一刻经由A对B/G的引用关系,白色F 的引用传递到了 A,并且B、G同时失去白色对象F 的引用,如下:
B、G失去F的引用后继续扫描,B、G完成扫描标记黑色,除F外其余的白色后续子节点(H、C)标记为灰色,后续无论发生什么都与 F白 无关了,如下:
以下代码模拟 类A B G F 的对象 a b g f 的关系:
*注:
FakeIoc : 模拟容器类,类静态变量为单例的,可以保证 b 和 g 引用的是同一个 f 对象
public class A {
B b = new B(); G g = new G(); F f = null;
// 通过Spring Ioc容器获取对象,持有对象 b g 的引用,f域空 《初始》
public static void main(String[] args){
new A().getF().doF_AnyThing();
//最终 对象a 持有b g f ,其中 b g 已被GC Roots扫描,f 为被GC Roots扫描过
}
public F getF(){
f = b.getF();
if (f == null)
f = g.getF();
b.setF_NLL(); g.setF_NLL(); //除 a 外所有对象注销 对 f 的引用
// 对象a 从 对象b 或 对象g 得到 对象f,并把他们(b g FakeIoc)的 f 域置空 《中间过程》
//此时 b g 对 f 的引用都还未被扫描到
return f;
}
}
class F { public void doF_AnyThing(){ System.out.println(" Run doF_AnyThing !"); }}
class FakeIoc{
static F f = new F();
public static F getF(){return f;}
public static void setF_NULL(){ if ( f != null) f = null;}
}
class B{
F f = FakeIoc.getF(); // 获取容器类的类变量
public F getF(){return f;}
//当前对象 b ,以及容器类FakeIoc,同时注销 f的引用;(一次性对象,用过即毁)
public void setF_NLL(){ f = null; FakeIoc.setF_NULL();}
}
class G{
F f = FakeIoc.getF();// 获取容器类的类变量
public F getF(){return f;}
//当前对象 g,以及容器类FakeIoc,同时注销 f的引用;(一次性对象,用过即毁)
public void setF_NLL(){ f = null; FakeIoc.setF_NULL();}
}
根据上述定义可以轻易得到一个结论:由于黑色对象引用(直接)的对象已经全部扫描清楚,所以被标记为黑色的对象,不可能凭空产生一个到白色对象的引用(不能凭空得到财产)。如果它产生了类似引用,那么究其根本原因,必定是经由某个正在被扫描的灰色对象得来。 (本人(黑)财产登记完成,只能通过别人(灰/非黑)主动的赠送或给予增加财富) 。
根据上述定义可知:对象消失必须同时满足如下的两个条件:
- 赋值器插入了从黑色对象到白色对象的新引用( 由灰色对象,将该白色对象给予黑色对象,持有该白色对象的灰色对象,关于该持有关系未被扫描过,白色还未被扫描到引用,就一直为白色);
- 灰色对象将该白色对象给与了黑色对象,且给予后灰色对象自动放弃了该对象的持有关系(例如赠送(比喻)一家公司的所有股份,该公司不再与本人有任何关系);
此时就来问题了 “消失的--白”:接收到该赠送的黑,他的财产已经登记,且不会被再次检查。灰在赠送前还未被检查出他有财产:白。那么此时对于检查的人来说:白消失了。。。。
由于对象的消失必须同时满足两个条件,那么解决问题的方法就是破坏两个条件之一:
- CMS - - 增量更新 破坏第一个条件:当黑色对象得到一个白色对象的引用,那么就将这个新插入的引用记录下来,在并发扫描结束后,再将这些新插入的记录以黑色对象为根节点重新扫描一次,即为:黑色对象一旦插入(得到)了白色对象引用就变为灰色对象。
- G1 - - - - 原始快照 破坏第二个条件:每当灰色对象要删除指向白色对象的引用时,在并发扫描结束后以记录的灰色对象为根节点,再次扫描;可以理解为:无论该引用是否删除,都会被重新扫描。
(3).如何建立起可靠的时间停顿模型?
G1 在垃圾收集的过程中会记录每个Region 的回收耗时、每个Region记忆集里的脏卡数量等各个步骤的可测量回收成本,并根据记录的值计算出平均值,标准偏差、置信度等等统计信息;Region的统计状态将会决定它的回收价值,最后通过这些统计信息可以得到由哪些Region组成的回收集合,在回收时可以不超过期望停顿时间N,的时间约束条件下完成收集,从而得到最高的效益。
最终,G1收集的过程大致划分为如下几个步骤:
- 初始标记 -- 仅标记GC Roots能到直接关联的对象,需要停顿所有用户线程,但是耗时很短。
- 并发标记 -- 从GC Roots开始,对堆中的所有对象进行可达性分析,递归扫描堆里的对象图定位需要回收的对象,这个过程耗时较久,与用户线程并发执行。<唯一与用户线程并发环节>
- 最终标记 -- 并发标记结束后需要根据SATB(原始快照)的记录,更新并发标记阶段发生了变化的引用关系。(问题 2 应用)
- 筛选回收 -- 更新每个Region统计数据,对每个Region回收成本排序,并根据计算得到合适的需要回收Region集合,并且要符合设定时间停顿模型中关于时长M内,停顿时间不超过N的要求。(问题3 应用)
* 本文注释
*内存分配担保机制
内存分配担保是在JVM在内存分配的时候,新生代内存不足时,把新生代的存活的对象搬到老年代,然后新生代腾出来的空间用于为分配给最新的对象。这里老年代是担保人。在不同的GC机制下,也就是不同垃圾回收器组合下,担保机制也略有不同。在Serial+Serial Old的情况下,发现放不下就直接启动担保机制;在Parallel Scavenge+Serial Old的情况下,却是先要去判断一下要分配的内存是不是>=Eden区大小的一半,如果是那么直接把该对象放入老生代,否则才会启动担保机制。