垃圾回收相关算法
一、判断阶段:对象存活判断
- 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
- 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
- 判断对象存活一般有两种方式:==引用计数算法==和==可达性分析算法==。
1、引用计数算法
1.1 概念
- 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型 的引用计数器属性。用于记录对象被引用的情况。
- 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
1.2 优点
- 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
1.3 缺点
- 需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一 条致命缺陷,导致==在Java的垃圾回收器中没有使用这类算法==。
1.4 代码测试Java中没有使用引用计数算法
/**
* 代码测试Java中没有使用引用计数算法来判断对象是否为垃圾
* VM参数:-XX:+PrintGCDetails
*/
public class RefCountGC {
//故意占用空间10M
byte data[] = new byte[1024 * 1024 * 10];
private Object ref = null;
public static void main(String[] args) {
RefCountGC refCountGC1 = new RefCountGC();
RefCountGC refCountGC2 = new RefCountGC();
//循环引用
refCountGC1.ref = refCountGC2;
refCountGC2.ref = refCountGC1;
//取消外界对其的引用
refCountGC1 = null;
refCountGC2 = null;
//手动GC
System.gc();
}
}
- 手动GC关闭的时候,未执行GC,新生区占用used 25682K
- 手动执行GC打开,执行GC,新生区占用650K
说明执行GC之后,两个互相引用的对象被回收,也就是互相的循环依赖引用的两个对象会被判定为垃圾被清理,说明Java使用的不是引用计数算法。而是可达性分析算法。
2、可达性分析算法/追踪性垃圾收集
- 相对于引用计数而言,可达性分析算法解决了循环引用的问题。防止了内存泄露的发生。
-
基本思路
- 可达性分析算法是以根对象(GCRoots)为起始点,按照从上至下的方式==搜索被根对象集合所连接的目标对象是否可达。==
- 使用可达性分析算法之后,内存中存活的对象都会被根对象集合直接或者间接连接,搜索走过的路径叫做==引用链==。
- 如果目标对象没有任何引用链相连,则表示不可达,为垃圾。
-
Java语言中,GCRoots链包括以下几类元素
- 各个线程被调用的方法中的参数,==局部变量==
- 本地方法栈内JNT(本地方法)引用的对象
- 方法区中==静态属性==引用的对象
- 比如: Java类中引用类型静态变量
- 方法区中的==常量引用的对象==
- 比如字符串常量池的引用
- 所有==被同步锁持有的对象==
- 虚拟机的内部引用
- 基本数据类型的包装类,常驻的异常对象,系统类加载器
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针 对新生代),
必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,
这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一.并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
- 小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
注意
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
- 这点也是导致GC进行时必须“StopTheWorld”的一个重要原因。
- ➢即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
二、对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
- 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法。
- finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
- 永远不要主动调用某个对象的finalize ()方法,应该交给垃圾回收机制调用。理由包括下面三点:
- ➢在finalize()时可能会导致对象复活。
- ➢finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
- ➢一个糟糕的finalize ()会严重影响GC的性能。
- 从功能上来说,finalize()方法与C++ 中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质,上不同于C++ 中的析构函数。
1.对象是否”死亡
- 由于finalize ()方法的存在,==虚拟机中的对象一般处于三种可能的状态。==
- 如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。==一个无法触及的对象有可能在某一个条件下“复活”自己==,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
- ➢==可触及的==:从根节点开始,可以到达这个对象。
- ➢==可复活的==:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
- ➢==不可触及的==:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize() 只会被调用一一次。
- 以上3种状态中,是由于finalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
2.判定是否可以回收具体过程
如果对象objA到GC Roots没有引用链,则进行第一 次标记。
进行筛选,判断此对象是否有必要执行finalize()方法
- ①如果对 象objA没有重写finalize()方法,或者finalize ()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
- ②如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F一Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
- ③finalize()方法是对象逃脱死亡的最后机会,稍后Gc会对F一Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。
在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
3.代码测试对象复活
/**
* 测试Object类中finalize()方法
*/
public class CanReliveObject {
public static CanReliveObject ref;
@Override
protected void finalize() throws Throwable {
System.out.println("调用当前类重写的finalize()方法");
//当前待回收的对象重新加入引用链
ref = this;
}
public static void main(String[] args) {
ref = new CanReliveObject();
ref = null;
//调用垃圾回收
System.gc();
System.out.println("第一次GC执行完毕");
/**
* 因为finalize优先级较低 主线程暂停2s 等待它
*/
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(ref == null){
System.out.println("对象已死");
}else {
System.out.println("对象未死");
}
ref = null;
System.gc();
System.out.println("第二次GC执行完毕");
if(ref == null){
System.out.println("对象已死");
}else {
System.out.println("对象未死");
}
}
}
引用对象ref刚开始指向一个对象,==此时为可触及状态==然后让他指向null,==此时为可复活状态==手动调用GC,此时处于会回调执行重写的finalize方法,方法中给这个引用重新赋值了,所以此时为==可触及状态==
再次指向NULL,此时为==不可触及状态==(finalize方法只执一次),所以对象此时已经死了。
结果:
第一次GC执行完毕
调用当前类重写的finalize()方法
对象未死
第二次GC执行完毕
对象已死
三、MAT与JProfiler的GCRoots溯源
public class GCRootsTest {
public static void main(String[] args) {
List<Object> numList = new ArrayList<>();
Date birth = new Date();
for (int i = 0; i < 100; i++) {
numList.add(String.valueOf(i));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("数据添加完毕,请操作:");
new Scanner(System.in).next();
numList = null;
birth = null;
System.out.println("numList、birth已置空,请操作:");
new Scanner(System.in).next();
System.out.println("结束");
}
}
使用Jprofiler打开程序
点击左侧
选择
选择一个元素 点击
即可显示该元素的GCGoots链
MAT查看略。
使用Jprofiler查看OOM
// -Xms8m -Xmx8m -XX:+HeopDumpOnOutOfMemoryError
public class HeapOOM {
//占1M
byte [] bytes = new byte[1024 * 1024 * 1];
public static void main(String[] args) {
List list = new ArrayList();
int count = 0;
try {
while (true){
list.add(new HeapOOM());
count++;
}
}catch (Throwable t){
System.out.println(count);
t.printStackTrace();
}
}
}
每个HeapOOM对象中有一个10M的bytes数组。循环创建 直到堆OOM
运行代码 生成dump文件。用Jprofiler打开 发现
确实有一个arrayList对象占用超过90%
四、清除阶段
当成功区分出内存中存活对象和死亡对象之后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的空间。目前比较常用的算法有三种
- 标记清除算法
- 复制算法
- 标记压缩算法
1、标记清除算法(Mark-Sweep)
-
背景
- 标记清除算法是一种非常基础和常见的垃圾收集算法
-
执行过程
- 当堆中的有效内存空间被耗尽时,就会停止程序STW,然后进行标记-清除
- 标记:Collector从引用的根节点开始遍历,标记所有的被引用的对象,在对象的对象头中记录为可达对象
- 清除:将对象头中没有标记为可达对象的对象进行清除
- 当堆中的有效内存空间被耗尽时,就会停止程序STW,然后进行标记-清除
-
优点:
- 常用,简单
-
缺点
- ➢效率不算高(两次O(n))
- ➢在进行GC的时候,需要停止整个应用程序,导致用户体验差
- ➢这种方式清理出来的==空闲内存是不连续的,产生内存碎片==。需要维护一个空闲列表
-
何为清除?
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲
的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲
2、复制算法
-
背景
为了解决标记清除算法效率方面的问题,M.L.Minsky于1963年发表了著名的论文,“ 使用双存储区的Li sp语言垃圾收集器CALISP Garbage Collector Algorithm Using SerialSecondary Storage )”。M.L. Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M. L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
- 核心思想
将活着的内存空间分为两块,每次使用一块,进行垃圾回收的时候,将存活对象复制到另一块未使用的区域,然后将源区域清空,然后交换两个内存的角色
-
优点:
- 没有标记和清除过程,实现简单,==运行高效==
- 复制过去以后保证==空间连续性==,不会出现“碎片”问题。
-
缺点:
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
- 特别的 如果系统中的可用对象很多,复制算法不会很理想,因为要复制大量的对象
在新生代,对常规应用的垃圾回收,一次通常可以回收708一 99的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
3、标记压缩算法
-
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。==标记一压缩(Mark一Compact) 算法由此诞生==。
1970年前后,G. L. Steele 、C. J. Chene和D.S. Wise 等研究者发布标记一压缩算法。在许多现代的垃圾收集器中,人们都使用了标记一压缩算法或其改进版本。
执行过程
- 第一阶段和标记一清除算法一样,从根节点开始标记所有被引用对象.
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
图示
- 标记一压缩算法的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记一清除一压缩(Mark一 Sweep一Compact)算法。
- 二者的本质差异在于==标记清除算法是一种非移动式==的回收算法,==标记压.缩是移动式==的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
- 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
-
指针碰撞(Bump the Pointer )
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer) 。
-
优点
- 消除了标记一清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只 需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
-
缺点
- 从效率.上来说,标记一整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序。即: STW
-
对比
属性\算法 标记清除算法 复制算法 标记压缩算法 时间复杂度 中 快 满 空间复杂度 少 占用2倍 少 内存碎片 有 无 无 移动对象 否 是 是
4、分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,
它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。
因此,==不同生命周期的对象可以采取不同的收集方式,以便提高回收效率==。
一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,
以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,
- 比如Http请求中的Session对象、线程、Socket连接, 这类对象跟业务直接挂钩,因此生命周期比较长
- 但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况==复制算法==的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。·
- 老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记整理的混合实现。
- ➢标记阶段的开销与存活对象的数量成正比。
- ➢清除阶段的开销与所管理区域的大小成正相关。
- ➢压缩阶段的开销与存活对象的数据成正比。
以HotSpot中的CMS回收器为例,CMS是基于标记清除实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于标记压缩算法的Serialold回收器作为补偿措施:当内存回收不佳(碎片导致的执行失败时),将采用Serial 0ld执行Full GC(标记整理算法)以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
5、增量收集算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种stop the World的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting) 算法的诞生。
-
基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
-
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
6、分区算法
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块 大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
五、关于Hotspot中垃圾回收相关算法的实现细节
对于常见的对象存活判定和垃圾收集算法,Java虚拟机在实现时,必须对算法的执行效率有严格的考量,才能保证虚拟机的高效运行。我们以HotSpot虚拟机为例,来了解一下虚拟机对这些算法的优化。
-
根节点枚举
以可达性分析算法中从GCRoots集合找引用链的这个操作为例来介绍虚拟机的高效实现。
首先,我们知道,固定可作为GCRoots的节点主要包括
全局性引用
(常量或者静态属性)以及执行上下文
(栈帧中的局部变量,参数等)中,尽管定义非常明确,但是查找过程中要做到高效并非一件容易的事情,并且现在Java应用越来越大,光是方法区的大小就数百M,里边的类更多,如果虚拟机真的逐个检查每个引用是否能作为GCRoots,需要耗费大量的时间。迄今为止,所有的垃圾回收器在进行GCRoots
根节点枚举的
时候,都必须暂停用户线程
,虽然最耗时的查找引用链以及可以做到和用户线程并发了,但是在根节点枚举的时候,还是必须在一个能保障一致性快照中才可以进行。如果在枚举过程中发生了变化,分析的结果准确性就无法保证。因此,号称是停顿时间可控,或者几乎不会发生停顿的CMS,G1,ZGC等回收器在根节点枚举的时候也是必须停顿的。因此,我们感觉,应该可以让虚拟机从某些地方直接得到存放对象的引用的。HotSpot的解决方案是,使用一组称为
OopMap
的数据结构来达到这个目的。具体做法如下一旦类加载动作完成的时候,HotSpot就会把对象内多少偏移量上是什么类型的数据计算出来,在即时编译的时候,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样收集器在扫描的时候就可以直接知道这些信息了。 并不需要一个不漏的从方法区等 GCRoots开始查找。
-
安全点
在上述中
OopMap
的协助下,HotSpot可以很快的完成GCRoots枚举,但是一个很现实的问题来了,导致OopMap内容变化的指令非常多,如果为每一指令都生成对应的OopMap
,那么将会需要大量的额外存储空间。实际上虚拟机只会在
一些特定的位置
生成OopMap
,这些位置被称为安全点
。只有代码执行到安全点的时候,才可以停下来进行垃圾回收。安全点的选定不能太少以至于让收集器等待时间过长,也不能太多以至于占用运行时的内存负荷。一些比较有代表性的安全点比如:方法调用、循环跳转、异常跳转等地方。
对于一个安全点另外一个需要考虑的问题是,如果让垃圾回收发生时所有的线程都在安全点停下来。主要有两种选择。
- 抢先式中断
在垃圾回收发生时,系统将所有的用户线程全部中断,如果发现某条线程不在安全点,则恢复执行,直到所有的线程都到达安全点。(几乎没有虚拟机这么做)
- 主动式中断
在垃圾回收发生的时候,会将一个标志位设置为真。每个线程在执行的时候都会不断的主动去轮询这个标志位,如果为真,则在最近的安全点挂起。
-
安全区域
使用安全点的设计似乎已经完美解决了如果停顿用户线程。但是,在程序不执行的时候,也就是线程阻塞时,线程无法响应虚拟机的中断请求。不能主动走到安全点挂起自己,虚拟机也不太可能持续等待到线程重新激活然后分配处理器时间,对于这种情况,必须引入安全区域来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域内开始垃圾收集都是安全的。我们可以把安全区域看成被扩展的安全点。
当用户线程执行到安全区域中时,首先会标识自己已经进入了安全区域,那么当这段时间内虚拟机需要发起垃圾收集的时候就不必管这些已经声明自己在安全区域内的线程。当线程要离开安全区域时,他会检查虚拟机是否已经完成了根节点枚举。如果完成,线程会推出安全区域。否则一直等待,直到可以离开为止。
- 抢先式中断
六、小结
这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
暂无评论内容