二、java虚拟机夯实基础--垃圾收集器(面向堆)与内存分配策略(上)

1.判断对象是否需要被回收(指的没有任何用途的实例对象)

1.1引用计数法

引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,原因是很难解决对象之间相互循环引用的问题。

例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

1.2可达性分析算法

可达性分析(Reachability Analysis)算法:一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,也就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

·在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等

·在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量

·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用

·在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

·Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

·所有被同步锁(synchronized关键字)持有的对象。

·反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

1.3 引用分类

可达性分析算法是根据‘引用chain’,判断对象是否死亡。那么何为引用?

在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。

(1)强引用是指在程序代码之中普遍存在的引用赋值,即类似“Objectobj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

(2)软引用是用来描述一些还有用,但非必须的对象(非必要不回收),只有在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。

(3)弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。

(4)虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系,gc回收必回收虚引用指向对象。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

1.4 回收对象

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡(回收),至少要经历两次标记过程:

(1)如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后Finalizer线程去触发它们的finalize()方法但并不承诺一定会等待它运行结束。

这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可。

譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

1.5回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

(1)该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

(2)加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

2.垃圾收集算法

本节介绍的所有算法均属于追踪式垃圾收集的范畴。

2.1分代收集理论->把堆内存划分成新生代和老年代

现代商用收集器建立在两个分代理论假说上:

1)弱分代假说:绝大多数对象都是朝生夕灭的。

2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。现代商用虚拟机根据这个理论设计至少把堆内存分为新生代和老年代两个区域。

3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。即:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

原因:假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。解决方案:在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。

2.2标记-清除算法->大多数回收算法的基本回收思想

标记-清除(Mark-Sweep)算法分为“标记”和“清除”两个阶段: 1、首先扫描所有对象标记出需要回收的对象, 2、在标记完成后扫描并回收所有被标记的对象,故需要两次扫描

它的主要缺点有两个:

第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;

第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.3标记-复制算法->大多数新生代常用

1.空间利用率为50%的初级形态

标记复制方法(半区复制):它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

带来的好处是:

1、实现简单,运行高效,

2、每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,

存在的弊端是:

1、内存的使用率缩小为原来的一半。

2、内存移动是必须实打实的移动(复制)(STW),所以对应的引用(直接指针)需要调整。

2.空间利用率为90%的优化形态

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,据调查:新生代中的对象有98%熬不过第一轮收集。因此 并不需要按照1∶1的比例来划分新生代的内存空间。

Appel式回收”:把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%。

Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

注意:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间或者只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行Full GC

为什么复制15次(15岁)后,被判定为高龄对象,晋升到老年代呢? 因为每个对象的年龄是存在对象头中的,对象头用4bit存储了这个年龄数,而4bit最大可以表示十进制的15,所以是15岁。

有几种情况,对象会晋升到老年代:

1. 超大对象会直接进入到老年代(受虚拟机参数-XX:PretenureSizeThreshold参数影响,默认值0,即不开启,单位为Byte,例如:3145728=3M,那么超过3M的对象,会直接晋升老年代)

2. 如果to区已满,多出来的对象也会直接晋升老年代;

3. 复制15次(15岁)后,依然存活的对象,也会进入老年代。

2.4 标记-整理算法->大多数老年代常用

又叫标记-压缩算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存 活的极端情况,所以在老年代一般不能直接选用这种算法.

算法逻辑如下:

1、首先标记出所有需要回收的对象,

2、在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,

3、然后直接清理掉端边界以外的内存

然后直接清理掉边界以外的内存。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式。

1.首先标记垃圾对象(红色)

2.内存碎片整理

3.内存清理

是否移动回收后的存活对象是一项优缺点并存的风险决策:

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行==》“Stop The World”.

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的 空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。

HotSpot虚拟机⾥⾯关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的⽽关注延迟的CMS收集器则是基于标记-清除算法的。

3 HotSpot的算法实现细节

STW 收集器在根节点枚举这步都是必须要暂停用户线程的( STW ),如果不这样的话在根节点枚举的过程中由于引用关系在不断变化,分析的结果就不准确。

3.1根节点枚举

我们以可达性分析算法中从GC Roots集合找引⽤链这个操作作为介绍虚拟机⾼效实现的第⼀个例⼦。 在HotSpot的解决⽅案⾥,是使⽤⼀组称为OopMap的数据结构来达到这个⽬的。⼀旦类加载动作完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈⾥和寄存器⾥哪些位置

是引⽤。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正⼀个不漏地从⽅

法区等GC Roots开始查找。

3.2安全点

收集器在工作的时候某些时间是需要暂停正在执行的用户线程的( STW ),这个暂停也并不是说用户线程在执行指令流的任意位置都能停顿下来开始垃圾收集,而是需要等用户线程执行到最近的安全点后才能够暂停。

安全点如何选取呢?,安全点的选取基本是以:”是否具有让程序长时间执行的特征“为标准选定的,而最明显的特征就是指令序列的复用,主要有以下几点:

1、方法调用

2、循环跳转,

3、异常跳转等等

对于安全点另一个问题是:垃圾收集器工作时如何让用户线程都跑到最近的安全点停顿下来?有两种方案:

1、抢先式中断:不需要用户代码主动配合,垃圾收集发生时,系统把用户线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这个线程执行让它执行一会再重新中断。不过现在的虚拟机几乎没有采用这种方式。

2、主动式中断:思想是当垃圾收集器需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程中会不停的去主动轮询这个标志,一旦发现中断标志为真时就自己再最近的安全点上主动挂起

3.3 安全区域

安全点的设计似乎完美的解决了如何停顿用户线程,它能保证用户线程在执行时,不太长时间内就会遇到可进入垃圾回收的安全点,但是如果用户线程本身就没在执行呢?比如用户线程处于 sleep 或者 blocked 状态,这个时候它就无法响应虚拟机的中断请求,办法主动走到安全的地方中断挂起自己,对于这种情况就必须引入安全区域( Safe Regin )来解决。

STW是为了在GC线程工作的时候,防止用户线程改变引用关系,安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这些线程了。 当线程要离开安全区域时,它要检查 JVM 是否已经完成了根节点枚举(或者其他 GC 中需要暂停用户线程的阶段)

1、如果完成了,那线程就当作没事发生过,继续执行。

2、如果没完成,它就必须一直等待, 直到收到可以离开安全区域的信号为止。

3.4 记忆集与卡表

记忆集是⼀种⽤于记录从⾮收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以⽤⾮收集区域中所有含跨代引⽤的对象数组

实现这个数据结构。这种记录全部含跨代引⽤对象的实现⽅案,⽆论是空间占⽤还是维护

成本都相当⾼昂。⽽在垃圾收集的场景中,收集器只需要通过记忆集判断出某⼀块⾮收集

区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。

实现记忆集所选择的记录粒度:

1.字长精度:每个记录精确到一个机器字长,该字包含跨代指针。

2.对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

3.卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是

目前最常用的一种记忆集实现形式。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

3.5 写屏障

在HotSpot虚拟机⾥是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层⾯对“引⽤类型字段赋值”这个动作的AOP切⾯,在引⽤对象赋值时会产⽣⼀个环形(Around)通知,供程序执⾏额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(PostWrite Barrier)。HotSpot虚拟机的许多收集器中都有使⽤到写屏障,但直⾄G1收集器出现之前,其他收集器都只⽤到了写后屏障。

3.6 并发的可达性分析

到目前为止,所有收集器在根节点枚举遍历其直接关联的对象时是要 STW的,并发收集器在继续往下进行可达性标记时是允许用户线程并发执行的,这样有效的减少了整体 STW 时间, 那这个并发标记到底是如何工作的呢?这就是我们要说的三色标记。

算法概述

首先约定好jvm在GC时会对对象进行颜色标记,按照对象是否被访问过这个条件将对象标记成以下三种颜色:

白色:表示该对象尚未被收集器访问过,在可达性分析结束后,仍为白色

的对象表示不可达,即为垃圾。要被回收

灰色:表示该对象已被收集器访问过,但是这个对象至少存在一个引用还未被扫描

黑色:表示该对象已被收集器访问过,并且它的所有引用都已被扫描,黑色对象是安全存活的。

另外:对于黑色对象

1、如果有其他对象的引用指向了黑色对象,无需重新扫描一遍

2、黑色对象不可能绕过灰色对象直接指向白色对象。

三色标记过程:


增量更新与原始快照

解决漏标问题,只要破坏漏标的两个条件之一即可,不同收集器采用的方案也不一样,

增量更新(Incremental Update):

1、主要针对对象新增的引用,利用写屏障将其记录下来,这样破坏了条件1

2、后续重新扫描时还会继续从记录下来的新增引用深度扫描下去

CMS收集器采用的是这种方案。

原始快照(Snapshot At The Beginning,SATB):

1、当某个对象断开其属性的引用时,利用写屏障,将断开之前的引用记录下来,

2、尝试保留开始时的对象引用图,即原始快照,当某个时刻的 GC Roots确定后,当时的对象引用图就已经确定了。

3、后续标记是按照开始时的快照走,比如 E > G ,即使期间发生变化,通过写屏障记录后,保证标记还是按照原本的视图来,

4、SATB破坏的是漏标条件2,主要针对是引用的减少。G1收集器采用的是这种方案。

在HotSpot虚拟机中,增量更新和原始快照这两种解决⽅案都有实际应⽤,譬如,CMS

是基于增量更新来做并发标记的,G1、Shenandoah则是⽤原始快照来实现。

总结

基于可达性分析的 GC 算法,标记过程几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同,比如标记的方式有栈、队列、多色指针等。

对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:

1、 CMS :写屏障 + 增量更新

2、 G1、Shenandoah :写屏障 + 原始快照

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值