Java基础篇——垃圾收集详解

2023-02-24,,

JAVA垃圾收集

1.如何判断对象死亡

说道垃圾回收,那么首要问题就是jvm如何判断一个对象已经死亡呢

1.1 引用计数法

说白了,就是为每个对象设立一个引用计数器,每当有一个引用指向它,计数器加一,引用失效或是转移则减一,很容易想到,当计数器为0时认为该对象死亡。

但就是这样一个原理简单、判定效率高的算法却没有在主流的java虚拟机中得到使用,原因是他存在一个致命的问题——循环引用

即当两个"死亡"的对象互相引用着对方时,出现了类似"死锁"的情况,死亡的对象当然不会对他的指针进行修改,所以这两个对象会一直占用内存,造成内存泄漏。

![img]

1.2可达性分析

可达性分析算法是当前广为接受的算法,该算法采用了一系列称为"GC Roots"的根对象作为起始节点集,从该节点集开始向下搜索,形成一个"存活链",所有在存活链中的对象即为存活对象,否则判定为死亡

在Java中,可作为根对象的对象有以下几种:

虚拟机栈中直接引用的对象(如线程方法栈中的参数、局部变量等)
方法区中类静态属性引用的对象、常量引用的对象
本地方法栈中JNI(native方法)引用的对象
虚拟机内部的引用对象(如基本数据类型包装类等)
被同步锁持有的对象
等等

对象在第一次标记死亡后,并不会立即被回收,而是有一个自救的机会,即为java中的"析构函数"finalize(),可以在重写该方法中重新为该对象加上引用,但一般虚拟机等待的时间有限,即在二次标记时该对象还未完成自救,则立即回收该对象,所以这个自救是有概率的。

1.3 java的四大引用类型

传统的对象引用只单单指地址引用关系,对于绝大多数对象引用足矣,但对于某种"可有可无"对象的引用关系描述则显得乏力,所以在jdk1.2之后有了四大引用类型,分别是:

强引用,最古老的引用方式,像String a = new String("abcd")就是一个强引用例子,对强引用的回收遵从判定死亡算法
软引用,描述一些可以有,但不是必要的对象,用SoftReference类实现。系统在发生OOM异常之前,会首先将软引用的对象纳入回收对象中。
弱引用,描述一些非必须对象,用WeakReference类实现,弱引用的对象会在下一次垃圾回收中被回收
虚引用,最"没用"的引用,虚引用不会对对象的生存时间造成任何影响,也不能通过虚引用调用对象,他只是为了使回收该对象时系统给出一个通知

2.垃圾收集算法

2.1标记清除算法

最简单的回收算法,过程分为"标记"和"清除",顾名思义,先标记死亡的对象,在统一回收

缺点:1.标记清除的效率会随着对象的增多而骤减

​ 2.产生大量内存碎片,如下图

2.2标记复制算法

将内存空间分为两个子块,每次只使用其中一个子块,再一次垃圾回收中,将新分配的对象和上一次GC存活的对象统一移向另一个子块,然后对死亡对象进行回收。

缺点:1.存活对象的复制需要大量开销

​ 2.每次只使用一半空间,造成大量空间浪费![img]

2.3 标记整理算法

一种基于标记清除算法的改进算法,每次标记后,让存活的对象统一向内存的一端移动,然后再进行回收

2.4分代收集算法

一种集成了以上算法的收集理论,基于强分代假说(绝大多数对象都是朝生夕灭的)和弱分代假说(熬过越多次标记的对象就越难消亡)之上

分代收集顾名思义将内存区域划分为多个不同的区域:

2.4.1新生代

绝大多数对象的分配和回收都发生在新生代,所以新生代采用的是标记复制算法,在此基础上,新生代又分为Eden区和两个Survivor区(from区和to区),每次内存分配都发生在eden区,少部分情况也分配在from区.在发生一次内存回收时,将from区存放的对象(上次gc存活下来的对象)和eden区存活下来的对象移向to区。

2.4.2老年代

经历15次移动的对象将会被移动到老年代,老年代基本都是些存活时间比较久的对象,所以在老年代采用的是标记整理算法。

为什么说基本呢,这里存在一个叫做分配担保的问题,如果即将要分配的对象大于eden和from区的剩余空间的话,这些对象便通过分配担保直接分配到老年代。

2.4.3永久代

JDK1.8之后改为元空间,元空间物理上不在JVM堆内存中,而在计算机内存中,方法区便在其中

3.java垃圾回收的算法细节(以HotSpot虚拟机为例)

3.1根节点枚举

随着虚拟机技术的不断发展,查找存活链的过程已经可以和用户线程并发了,但根节点枚举必须保证在一个能保障一致性的快照中进行,即暂时性的暂停用户进程。

但根节点的扫描需要扫描所有的对象引用,并计算他们的类型,这就需要大量的时间,导致用户进程的"Stop The World"。但事实果真如此吗,其实不然,虚拟机自然有方法知道引用中的对象类型。在hotspot虚拟机中,是一组被称之为OopMap的数据结构,一旦类加载完成后,hotspot就会把对象的引用中的数据类型计算出来,并在某些特定的位置记录下哪些位置存放着引用。这样根节点枚举时只需要去扫描这些引用的位置就可以。

3.2 安全点

如果每个需要修改引用关系的指令都要为其维护OopMap,无疑是一个巨大的开销,所以前面说过,虚拟机只是在某些特定的位置记录下引用信息,而这些特定的位置被称之为安全点。同时也规定,只有当所有正在运行的用户线程进行到安全点时,才能进行垃圾回收,这也需要安全点的选取一般是方法调用、循环等这些序列复用的长时间指令。

同时也迎来了一个问题,如何保证进行垃圾回收时,所有线程都运行到了安全点呢,有两种方案:

抢占式中断

由系统控制,在需要垃圾回收时先中断所有进程,如果发现有进程没有到达安全点,则让它继续运行一会,直到安全点。

主动式中断

当系统需要垃圾回收时,将一个标志位置为真。每个用户线程都需要不断地去轮询这个标志位,如果标志位为真,则运行到最近的安全点挂起。

3.3 安全区域

前面讲到,通过线程与虚拟机之间的交互保证到达安全点,那么如果该线程正处于阻塞状态或者睡眠状态呢,在安全点的基础上,虚拟机又针对这种情况设置了一个安全区域(在安全区域内的指令不会修改引用关系)

当一个线程运行安全区域内的代码时,他标注自己进入了安全区,这样即使它被阻塞,依然可以进行垃圾回收。
当一个线程即将离开安全区域时,他先确认是不是完成了根节点枚举,如果没有完成,则等待。

3.4 记忆集与卡表

分代收集理论带来了一个很现实的问题,就是跨代引用(例如一个老年代的对象指向了一个新生代对象),为了考虑这种情况,每次新生代的标记过程都要扫描一边老年代,又是一个大开销,为了解决这个问题,在新生代设立了一种数据结构——记忆集(记录由非收集区指向收集区的引用)。

卡表就是记忆集的一种实现方式,他将老年代分为一个一个的内存块,并标注了哪些内存块存在跨代引用,这样在标记时就只需要扫描这些内存块就可以。

3.5 写屏障

卡表有效地解决了跨代引用的问题,那么卡表应该如何维护的,如何保证在引用关系更新后能立即更新卡表。虚拟机引入了一个机器码层面的控制手段——aop切面,将类型赋值的指令视作切入点,将维护卡表的操作作为aop增量服务即可有效维护卡表。

3.6 并发的可达性分析

为了方便理解,这里引入一个三色标记算法来模拟查找存活链的过程

白色:未被收集器访问过的对象
黑色:自身被扫描且所有子引用被扫描
灰色:自身被扫描,但至少有一个未被扫描的子引用

则很容易想象到该过程是一个以灰色对象为波峰的蔓延,如下图:

但是如果在并发的标记过程中,用户线程修改了某些引用关系,则会出现两种情况:

将原本存活的对象标记为死亡(致命的错误):将对象已经标记为死亡,用户线程加了引用,复活
将原本死亡的对象标记为存活:将对象已经标记为存活(黑色节点),用户线程删除了所以对它的引用,对象死亡

第二种情况对程序难以造成影响,所以一般都选择处理第一种情况,又称为"对象消失",以三色标记算法举例,对象消失当且仅当以下两个情况同时满足时发生:

用户线程插入了一条或多条从黑色对象到白色对象的引用

解决办法:增量更新

黑色对象如果有插入到白色对象的引用,则将其记录下来,等待并发扫描结束后,再重新扫描这些黑色对象

用户线程删除了所有由灰色对象到该白色对象的直接饮用或间接引用

解决办法:原始快照

当灰色对象要删除指向白色对象的引用时,就将该引用关系记录下来,等并发扫描结束后再将这些灰色对象重新扫描一次

4.经典的垃圾收集器

新生代收集器

4.1 Serial收集器

进行垃圾收集时,必须暂停替他所有用户线程,基于标记复制算法
占用额外内存最少,单线程收集效率最高的收集器
对运行在客户端模式下的虚拟机是个非常好的选择

4.2 ParNew收集器

与Serial收集器相似,但是支持多线程的垃圾收集,基于标记复制算法
ParNew和Serial是唯二可以与CMS搭配使用的收集器

4.3 Parallel Scavenge 收集器

支持多线程垃圾收集,基于标记复制算法
提供可控制的吞吐量、自适应吞吐量调节

老年代收集器

4.4 Serial Old收集器

顾名思义,Serial的老年代版本,单线程收集,基于标记整理算法

4.5 Parallel Old收集器

Parallel的老年代版本,多线程并发收集,基于标记整理算法
可与Parallel组成吞吐量优先的收集器组合

4.6 CMS(Concurrent Mark Sweep并发标记清除)收集器

以获取最短回收停顿时间为目标,基于标记清除

步骤:

初始标记:stw(stop the world)标记Gc Roots和其关联对象

并发标记:查找存活链,可与用户线程并发

重新标记:stw,增量更新实现

并发清除:回收死亡对象

与用户线程的并发导致用户线程变慢,为此提供了一个i-CMS变种,使并发变为并行(到jdk9被废弃)

为了提供并发的内存空间,CMS在老年代溢出之前就会进行一次FullGC,一般会有一个阈值,如果预留的空间无法满足并发需要,则会产生一次stw的垃圾回收

标记清除会产生大量内存碎片,可用虚拟机参数调节

整堆收集器

4.7 G1(Garbage First)收集器

一款面向服务端的收集器,开创了后几期面向局部收集和基于Region的内存布局
将内存布局成数个大小相等相互独立的区域(Region),每个region都可以扮演eden、survivor等空间
对于那些大小大于region区的对象,则由几个相连的Humongous Region存储
Garbage First,计算每个Region区的垃圾回收效益,维护一个优先级列表,每次根据用户指定的回收时间(停顿时间模型),选择优先级最高的region区回收
双向卡表解决跨Region引用、原始快照解决对象消失、写前屏障跟踪原始快照
G1的垃圾回收过程涉及存活对象的移动,所以要stw

Java基础篇——垃圾收集详解的相关教程结束。

《Java基础篇——垃圾收集详解.doc》

下载本文的Word格式文档,以方便收藏与打印。