深入理解JVM虚拟机读书笔记——自动内存管理(二)

第三章、垃圾收集器与内存分配策略

垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

Java内存运行时 区域的各个部分,其中 程序计数器、虚拟机栈、本地方法栈 三个区域随线程生灭,栈中的栈帧每个分配多少内存基本在类结构确定下来就已知的(虽然在编译期会被优化,但在概念模型的讨论中可以认为编译器已知),因此这几个区域内存分配和回收都确定。
Java堆和方法区 这两个区域有很显著的不确定性:一个接口的多个实现类需要内存不同、一个方法分支执行需要内存不同,这些只有运行期间才能确定,这部分内存的分配和回收都是动态的。垃圾收集器关注的正是这部分内存的管理。

一、对象已死?

堆中存放 Java 中几乎所有对象实例,对堆回收前,要先确认哪些对象还“活着”,哪些已“死去”(即不可能被任何途径使用的对象)。

1、引用计数和可达性分析

  1. 引用计数法:
    在对象中添加一个引用计数器,被引用时加一,引用失效减一,任何时刻计数器为零的对象就是不可能再被引用的。
    虽然它原理简单、效率高,但主流 Java虚拟机 都没用它,因为它需要大量额外处理才能保证正确工作。比如说对象之间循环引用的问题,就无法回收它们。
  2. 可达性分析法:
    可达性分析(Reachability Analysis),通过一系列 “GC Roots” 作为起始点集,根据引用关系向下搜素,搜索路径被称 “引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,则证明此对象不可能再被使用。

在这里插入图片描述
在 Java技术体系中,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机(帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI (即通常所说的 Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的 JMXBean、 JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合外,根据选用的垃圾收集器以及当前回收的内存区域不同,还可以有其它对象“临时性”的加入,共同构成。如 分代收集和局部回收 ,如果只针对 Java堆 中某一区域垃圾收集,必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭,所以某个区域的对象完全有可能被位于堆中其它区域对象所引用,这是就要把哪些也加入 GC Roots 中,才保证可达性分析正确性。

2、再谈引用

上面的两种方法判定对象是否存活都和 “引用” 离不开关系。

在 JDK 1.2 前,Java 中引用是很传统的定义:(如果 reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference数据是代表某块内存、某个对象的引用)
这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃最收集后仍然非常紧张,那就可以抛弃这些对象一一很多 系统的缓存功能 都符合这样的应用场景。

所以在 JDK1.2 版本后,Java对引用的概念进行了扩充,将引用分为 强引用( Strongly Reference)、软引用(Soft Reference)、弱引用( Weak Reference)和 虚引用( PhantomReference) 4种,这4种引用强度依次逐渐减弱。

  1. 强引用是最传统的“引用”的定义,是指在程序代码之中 普遍存在的引用赋值,即类似“ Object obj= new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就 永远不会回收掉被引用的对象
  2. 软引用是用来描述一些 还有用,但非必须 的对象。只被软引用关联着的对象,在系统将 要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了Softreference类来实现软引用。
  3. 弱引用也是用来描述那些 非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象 只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了Weakreference类来实现弱引用。
  4. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得个对象实例。为一个对象设置虚引用关联的唯一目的 只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了 PhantomReference类来实现虚引用。

3、生存还是死亡?

一个对象的死亡,至少经历两次标记:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots相连接的引用链,那它将会被 第一次标记。随后进行一次筛选,假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为没有必要执行 finalize() 方法。
    如果这个对象被判定有必要执行 finalize() 方法,会被放置一个名为 F-Queue 队列中,并在稍后由一条虚拟机自动建立、低调度优先级的 Finalizer 线程去执行他们的 finalize() 方法(为防止某个对象 finalize() 方法慢或产生死循环,虚拟机触发方法但不一定会等它运行结束)。 finalize() 方法是对象逃脱死亡的最后一次机会。
  2. 稍后收集器对 F-Queue 中对象进行 第二次小规模标记,对象在 finalize() 中成功拯救自己——需要与引用链任何一个对象建立关联,就可把它移除“即将回收”的集合;如果对象这时候还没逃脱,基本上就真的要被回收了。

4、回收方法区

方法区“性价比”较低,Java堆 ,尤其是新生代,对常规应用一次垃圾收集通常可回收 70%~99% 内存空间,方法区回收判定条件苛刻,成果远低于此。

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

  • 常量判断是否废弃较简单:没有任何字符串对象和虚拟机其他地方引用在字符串池中的此常量,如果这时发生内存回收,且垃圾收集器判断有必要的话,这个常量就会被清出常量池。常量池中其它类(接口)、方法、字段符号的引用也类似。
  • 判断一个类型是否不再使用,比较苛刻,需要同时满足三个条件。
    1. 该类所有实例被回收,也就是堆中无该类和任何派生子类的实例。
    2. 类加载器已被回收,除非精心设计可替换类加载器,如PSGi、JSP重加载,否则很难达成。
    3. 类对应的 Java.lang.Class 对象没有被任何地方引用,任何地方无法通过反射访问该类方法。

Java虚拟机被允许对满足上述三个条件的无用类回收,是否回收看具体虚拟机设置,在大量使用反射、动态代理、CGLib等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器场景,通常都需要 Java 虚拟机有类型卸载能力,以保证不对方法区造成过大内存压力。

二、垃圾收集算法

从如何判定对象消亡的角度,垃圾收集算法可分为:“引用计数式垃圾收集”、“追踪式垃圾收集” 两大类。

1、分代收集理论

当前商业虚拟机的垃圾收集器,大多都遵循 “分代理论” ,它建立在两个分代假说之上

  • 弱分代假说:绝大多数对象都是招生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个假说共同奠定了多款常用的垃圾收集器一致性的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集过程次数)分配到不同的区域中存储。至少分为新生代和老年代,划分不同区域,垃圾收集器采取不同回收算法。
分代收集并非只是简单划分下内存区域,至少存在一个明确的困难:对象不是孤立的,对象会存在跨代引用。为解决这个,需要添加第三条法则。

  • 跨代引用假说:跨代引用相对于同代引用来说,仅占极少数。

依据这个假说,我们就不应再为少量跨代引用去扫描整个老年代,也不必浪费空间记录每个对象跨代引用,只需在新生代上建立一个全局数据结构(记忆集),把老年代划分多个块,标识哪块存在跨代引用。

部分收集(Minor GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独老年代收集行为。
  • 混合收集(Mixed GC):指目标收集整个新生代以及部分老年代。目前只有G1收集器有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

2、标记—清除算法

分为 “标记” 和 “清除” 两个阶段:首先标记需要 回收/存活 对象,然后收集。标记过程就是判断对象是否存活的过程。
缺点:①执行效率不高 ②内存空间碎片化。

3、标记—复制算法

解决 标记—清除 算法面对大量可回收对象时执行效率低的问题。
“半区复制”: 内存分为大小相同两半每次只用一半,用完了就把这半存活对象复制另一半上,然后一次请理这半。
优点:不用考虑空间碎片化。
缺点:存活对象多就要很大复制开销,而且可用内存少了一半。

因为新生代对象 98% 都熬不过第一轮收集,因此不需要按照1:1分配内存空间。

Appel式回收:(HotSpot 虚拟机的 Serial、ParNew等新生代收集器均用这种策略设计新生代内存布局)

  • 把新生代分为一块较大的 Eden空间 和两块较小的 Survivor空间 ,每次分配内存只用 Eden 和其中一块 Survivor。
  • 垃圾收集时一次将这两块已存活的对象复制到另一个 Survivor空间上,然后请理。HotSpot虚拟机默认 Eden 和 Survivor 为 8:1。

没人可以保证每次存活对象不多 10%,因此有 逃生门 设计。

  • Survivor 空间不足以容纳一次 Minor GC后存活对象时,就需要依赖其它内存区域(老年代)进行分配担保。

4、标记—整理算法

针对老年代的 标记—整理 算法。标记完存活对象后,把它们移向内存空间一端,然后直接清理掉边界以外区域。

移动存活对象并更新所有这些对象的引用方向是极为负重的操作,必须全程暂停用户应用程序(ZGC和Shenandoah通过读屏障实现了整理过程与用户线程并发),这样设计最初被成为 “Stop The World” (标记清楚也需要停顿用户线程,只是时间短)。关注吞吐量的 Parallel Scavenge 收集器基于标记—清除,关注延迟的CMS收集器基于标记—整理。
还有一种方法,让虚拟机多数时间采用标记—清除,内存碎片空间大到影响分配对象时,再用标记整理。不在内存分配和访问上增加太大负担。CMS收集器面临空间碎片过多时就采用这种方法。

三、HotSpot 的算法细节

1、根节点枚举

在可达性分析算法,固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧的本地变量表)中,根节点枚举还是要暂停用户线程,现在可达性引用分析算法耗时最长的查找引用链过程已经可以与用户线程一起并发,但根节点枚举还是要在一个保证一致性快照中才能进行(即根节点引用对象关系没有不断变化),否则无法保证准确性。即使几乎不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点也需要停顿。
HotSpot使用一组称为 OppMap 的数据结构来得到存放对象引用的位置,(类加载完成时,HotSpot就会把对象内什么偏移量上是什么类型数据计算出来,在即时编译过程中,也会在特定位置记录栈和寄存器哪些位置是引用。)所以收集器不需要一个不漏从 GC Roots 开始查找。

2、安全点

在OppMap协助,HotSp可以准确快速完成 GC Roots枚举,但可能导致引用改变(OppMap变化)的指令非常多,只在特定位置记录信息,这些位置被称为安全点。
只有在安全点才能让代码指令流停下来收集垃圾。安全点太少收集时间过长,太多会频繁增加运行时内存符合,安全点基本是以 “是否具有让程序长时间执行的特征” 为标准选定的,“长时间执行” 最明显特征是指令序列的复用,例如:方法调用、循环跳转、异常跳转等,这些才会产生安全点。
对于安全点另一个问题是:如何让所有线程都跑到最近的安全点,然后停顿下来?

  1. 抢先式中断:先把所有线程中断,不在安全点上的,恢复执行,然后一会再中断,直到跑到安全点上。(现已几乎没虚拟机用这种)
  2. 主动中断:不直接对线程操作,仅仅简单设置一个标志位,各个线程会不停主动轮询,一旦中断标志为真,就自己在最近的安全点上主动中断挂起。轮询标准地方和安全点是重合的,另外还要加上所有创建对象和其它需要在堆上分配内存的地方。(为了检查是否即将发生垃圾收集,避免没有内存分配新对象)

3、安全区域

对于不执行的用户线程 (Sleep与Blocked)状态,不能走到安全点中断自己,虚拟机也不能持续等待其被激活垃圾回收,所以对这种情况引入“安全区域”。
安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,因此在这区域任何地方收集垃圾都是安全的。
用户线程执行到安全区域时会标识自己,那样这段时间内虚拟机发起垃圾收集时就不用管这些线程。线程离开安全区域时,检查虚拟机是否完成根节点枚举,没有就一直等到虚拟机发送离开信号为止。

4、记忆集与卡表

解决对象跨代引用问题,为避免把整个老年代加进 GC Roots 扫描范围,所以需要 记忆集 记录 从非收集区域指向收集区域 的指针集合的抽象数据结构。
这种方式空间占用和维护成本高昂,所以只需通过记忆集判断某一块非收集区域是否存在指向收集区域指针就行了,一些可选择的记录精度:

  • 字节精度:记录精确到一个机器字长(处理器寻址位数32、64等),该字包含跨代指针。
  • 对象精度:记录精确到一个对象,该对象包含跨代指针。
  • 卡精度:记录精确到块内存区域,该内存区域包含跨代指针。

第三种 “卡精度” 指的一种 “卡表” 的方式实现记忆集,目前最常用。
HotSpot虚拟机用一个字节数组,其中每个元素都对应其表示的内存区域中一个特定大小的内存块,称为卡页,一般卡页都是 2^n 字节(HotSpot 中 n = 9)。只要卡页中有跨代指针,就把对应数组元素标为1,称为变脏。垃圾收集时,把卡表中标识为1的元素加入GC Roots 一并扫描。

5、写屏障

用记忆集来缩减 GC Roots 扫描范围问题,但还没解决卡表维护的问题。
其它分代区域对象引用本区域对象时,对应卡表就变脏,变脏时间点原则上应发生在 引用类型字段赋值 那一刻。那如何实现变脏?

解释执行阶段,虚拟机容易介入,但编译执行时候,即时编译后代码已经是存粹机器指令流了,所以需要一个机器码层面手段维护。

HotSpot虚拟机中通过读写屏障技术维护卡表状态,可以看作虚拟机层面的一个AOP切面。虚拟机会对所有赋值操作生成相应指令。(虽然每次引用都需要更新卡表,额外开销,但对比扫描老年代低得多)

卡表在高并发场景面临 “伪共享” 问题,CPU缓存是以缓存行为单位,多线程修改相互独立变量时,如果这些变量恰好共享同缓存行,就会影响(写回、无效化、同步)性能变低。
某些卡表元素->CPU同一缓存行->两个线程同时更新卡表这些元素 = 写入同一缓存行
解决伪共享,需要加个检查卡表元素是否变脏再更新元素。JDK7后虚拟机可以参数开启,开启会有额外判断开销,不开启会有伪共享,各有损耗。

6、并发的可达性分析

GC Roots相比堆中全部对象是少数,而且各种优化(如OopMap)下,停顿很少。再从GC Roots 遍历对象图这部分,停顿时间必定会与对堆容量成正比:堆越大、对象越多,图越复杂,优化这部分停顿时间收益是系统性的。
如何在一个快照上实现并发可达性分析,用三色标记来辅助说明。

  • 白色:对象没被垃圾收集器访问。开始都是白,结束时还是白代表要被清理。
  • 黑色:对象已被访问,且对象所有引用都已经被访问过。它是安全存活的,如果其它对象引用指向黑色对象无需再扫描。黑色对象不可能直接指向(不通过灰色对象)白色对象。
  • 灰色:对象已被访问,但对象上还有引用没被扫描。

在垃圾收集器工作时,用户线程同时工作修改引用关系,可能有两种后果:①本来应该消亡对象标记为存活(不管,下次请理就行)②原本应该存活(又被引用了)的标记为消亡,同时满足下列两个条件才会产生这个问题。

  1. 赋值器插入了一条或者多条从黑色对象到白色对象的新引用。
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或者间接引用。

我们只需破坏其中任意一个条件就行,两种方案:“增量更新”和“原始快照”。
增量更新:破坏第一个条件,黑色对象插入到白色对象引用时,记录下来,并发扫描结束时,再把记录过的黑色对象为根,从新扫描一次。(等于黑色对象变灰了)
原始快照:破坏第二个条件,灰色对象删除指向白色对象引用时,记录下来,并发扫描结束后,再以这些记录过灰色对象为根,从新扫描。(可以理解为无论引用关系删除与否,都按照刚开始扫描的对象图快照搜索)

四、经典垃圾收集器

1、CMS

2、G1

3、ZGC

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值