垃圾回收相关算法
标记阶段 概述
- 在堆里存放几乎所有的java对象实例,在GC执行之前,首先需要区分内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会执行垃圾回收,释放其占用的内存空间,因此这个过程我们成为垃圾标记阶段
- 判断对象存活一般有两种方式:引用计数算法 和 可达性分析算法
引用计数算法 Reference Counting
- 比较简单,堆每个对象保存一个整型的引用计数器属性.用于记录对象被引用的情况
- 对于一个对象A,只要有任何一个对象引用了A,那么A的引用计数器+1,当引用失效时,计数器-1.只要对象A的引用计数器值为0时,则表示对象A不可能再被引用,即可被回收
- 优点: 实现简单,垃圾对象便于标识,判断效率高,回收没有延迟性
- 缺点
- 需要单独添加字段存储计数器 增加了存储空间开销
- 每次复制都需要更新计数器,增加了时间开销
- 最致命的问题是无法处理循环引用的问题,直接导致java的GC没有使用这个标记算法
- 循环引用 例如 一个变量指针P指向A 其中A引用B B引用C C引用A 内部形成了一个引用链,当一个P指针不再指向A 本质上A已经不会再被使用,但因为循环依赖的关系,导致A的计数器永远不能被减到0,导致永远不能被回收产生内存泄漏
证明HotSpot并没有使用引用计数算法
public class ProveJavaNotUseReferenceCounting {
private byte[] data = new byte[1024 * 1024 * 5];
Object ref = null;
public static void main(String[] args) {
ProveJavaNotUseReferenceCounting obj1 = new ProveJavaNotUseReferenceCounting();
ProveJavaNotUseReferenceCounting obj2 = new ProveJavaNotUseReferenceCounting();
obj1.ref = obj2;
obj2.ref = obj1;
obj1 = null;
obj2 = null;
System.gc();
}
}
[GC (System.gc()) [PSYoungGen: 13578K->744K(38400K)] 13578K->752K(125952K), 0.1038107 secs] [Times: user=0.00 sys=0.00, real=0.11 secs]
[Full GC (System.gc()) [PSYoungGen: 744K->0K(38400K)] [ParOldGen: 8K->642K(87552K)] 752K->642K(125952K), [Metaspace: 3277K->3277K(1056768K)], 0.0081173 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 333K [0x00000000d5900000, 0x00000000d8380000, 0x0000000100000000)
eden space 33280K, 1% used [0x00000000d5900000,0x00000000d59534a8,0x00000000d7980000)
from space 5120K, 0% used [0x00000000d7980000,0x00000000d7980000,0x00000000d7e80000)
to space 5120K, 0% used [0x00000000d7e80000,0x00000000d7e80000,0x00000000d8380000)
ParOldGen total 87552K, used 642K [0x0000000080a00000, 0x0000000085f80000, 0x00000000d5900000)
object space 87552K, 0% used [0x0000000080a00000,0x0000000080aa0bb0,0x0000000085f80000)
Metaspace used 3283K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
- 通过-XX:+PrintGCDetails 查看发现即是obj1 和 obj2有循环引用 当指针obj1和obj2置空的时候GC还是回收了内存,说明HotSpot并没有使用引用计数器作为GC的标记算法
小结
- 引用计数算法,是很多语言资源回收的选择,如Python支持引用计数和垃圾收集机制.具体哪种更优需要结合场景,业界有大规模的实践中仅保留了引用计数机制,已提高吞吐量
- java没有选择引用计数,根本原因是因为循环依赖问题
- Python解决循环引用的方法主要有两种
- 手动解除 如 obj.ref = None
- 使用弱引用weakref模块 弱引用在GC一定会被回收.
- 在计算机程序设计中,弱引用,与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则可能在任何时刻被回收。弱引用的主要作用就是减少循环引用,减少内存中不必要的对象存在的数量。
可达性分析算法
概念
- 可达性分析算法 也称为根搜索算法,追踪性垃圾收集
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是算法可以有效的解决在引用数据类型循环引用的问题,防止内存泄漏
- 相较于引用计数算法,这里可达性分析就是java C#的选择.这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)
思路
GCRoots根集合就是一组必须活跃的引用
基本思路
- 可达性分析是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的对象是否可达
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接,搜索所走过的路径成为引用链(Reference Chain)
- 可达性分析算法,只有能够被根对象集合直接或间接连接的对象才是存活对象
GC Roots可以是哪些
- 虚拟机栈引用的对象
- 比如:各个线程被调用的方法中使用到的参数 局部变量
- 本地方法栈内JNI(通常说的本地方法)引用的对象方法区中类经常属性引用的对象
- 比如: java类的引用类型静态常量
- 方法区中常量引用的对象
- 比如: 字符串常量池(String Table)的引用
- 所有被同步锁synchronized持有的对象
- java虚拟机内部的引用
- 基本数据类型对应的Class对象,一些常驻的异常对象 如(NullPointException,OutOfMemroyError),系统类加载器
- 反应java虚拟机内部情况的JMXBean JVMTI中的注册回调,本地代码缓存
总结
- 总结一句话就是,除了堆空间外的一些结构,比如虚拟机栈,本地方法栈,方法区,字符串常量池等地方对堆空间进行引用,都可以作为GC Roots进行可达性分析
- 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收内存区域不同,还可以有其他对象临时性加入,共同构成完成的GC Roots集合.比如 分代收集和局部回收(Partial GC)
- 如果只针对java堆中的某一个区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是鼓励封闭的,这个区域的对象完全有可能被其他区域对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性
小技巧
由于Root采用栈方式存放变量和指针,所以如果一个指针,他保存了堆里面的对象,但是自己又不存放在堆中内存里面,那么他就是一个Root
注意
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行,这点不满足的话分析结果准确性就无法保证
- 这点直接导致了进行GC必须Stop The World的重要原因
- 即是号称几乎不发生停顿的CMS收集器中,枚举根节点的时候也必须要停顿
对象的finalization机制
- java提供了对象终止(finalization)机制允许开发人员提供对象被销毁之前自定义处理逻辑
- 当GC发现没有引用指向一个对象的时候,总会先调用这个对象的finalize()方法
- finalize()方法允许在子类中被重写,用于在对象被回收进行资源释放.通常在这个方法中进行一些资源释放和清理工作.比如关闭文件 关闭套接字 关闭数据库连接等
- 但GC对一个对象的finalize方法只会调用一次
注意
永远不要主动调用某对象的finalize()方法!应该交给GC去调用
- 在finalize()方法可能会导致对象复活
- finalize()方法执行的时间没有保障,他完全由GC线程决定,极端情况下,若不发生GC,则finalize方法永远不会执行
- 因为优先级比较低,即是主动调用,也不会因此就直接进行回收
- 一个糟糕的finalize方法会严重影响GC性能
- 功能上说finalize方法跟C++的析构函数类似,但是java采用的基于垃圾回收器的自动内存管理机制,所以finalize方法在本质上不同于C++的析构函数
虚拟机对象三种状态
由于finalize方法的存在,虚拟机对象可能存在三种状态
如果所有根节点都无法访问某对象,说明对象已经不在被使用.一般来说,此时对象需要被回收.但事实上,也并非是"非死不可".一个无法触及的对象有可能在某一个条件下复活自己,如果这样,那么对他的回收就是不合理的.
- 可触及状态: 从GC Roots集合中可以到达的对象 不能会GC标记回收
- 可复活状态: 对象所有引用被释放,但是对象finalize方法还没被调用,可能在finalize中复活
- 不可触及状态: 对象的finalize被调用之后并没有复活.进入不可触及状态,不可触及状态对象不能被复活,因为finalize方法只会调用一次,即对象只能被复活一次
finalization的过程
-
判断一个对象obj是否可以被回收,至少经历两次标记过程
- 如果对象obj到GC Roots没有引用链,则进行第一次标记
- 进行筛选,判断此对象是否有必要执行finalize方法
- 如果对象obj没有重写finalize()方法,或者finalize方法已经被虚拟机调用过,则虚拟机视为没有必要执行,obj被判断为不可触及
- 如果obj重写了finalize方法,且未被执行,obj插入到F-Queue队列中,由虚拟机自动创建的低优先级Finalizer线程触发其finalize方法执行
- finalize方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue的对象进行二次标记
- 如果obj在finalize方法中与引用链上任何一个对象建立了联系,那么二次标记,obj会被移除即将回收集合,之后如果再出现对象没有被任何引用指向的情况,finalize方法将不会再被调用,对象直接进入不可触及状态. finalize只会被调用一次
代码演示在finazlie复活的情况
public class ReviveTest {
// 类成员 是GC Roots的集合一员
public static ReviveTest data;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize 方法调用");
// 这句话使得对象在finalize中被复活
data = this;
}
public static void main(String[] args) throws InterruptedException {
data = new ReviveTest();
data = null;
System.out.println("第一次执行GC============");
System.gc();
TimeUnit.SECONDS.sleep(2);
if (data == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is alive");
}
System.out.println("第二次执行GC============");
data = null;
System.gc();
TimeUnit.SECONDS.sleep(2);
if (data == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is alive");
}
}
}
第一次执行GC============
finalize 方法调用
obj is alive
第二次执行GC============
obj is dead
在进行第一次清除的时候,我们会执行finalize方法,然后 对象 进行了一次自救操作,但是因为finalize()方法只会被调用一次,因此第二次该对象将会被垃圾清除。
MAT与JProfiler的GC Roots溯源
MAT 是Memory Anlayzer简称 由Ecplise开发免费的强大的java堆内存分析器,用于查找内存泄漏以及查看内存消耗情况
-
使用JVisualVM捕获一个heap dump文件,JVisualVM捕获的是临时文件,关闭JVisualVM之后会自动删除,需要将其另存为文件.
- Application 右击相应的应用程序 选择Heap Dump
- 在Monitor 子标签页中点击 Heap Dump按钮. 本地应用程序的Heap Dumps作为应用程序标签页的一个子标签页打开. 同时 Heap Dump在左侧Application栏中对应一个含有时间戳的节点
- save as另存为
-
使用MAT打开Dump文件查看GC Roots
-
使用JProfiler的GC Roots溯源
- 选择JProfiler打开dump heap文件在class 或者 Biggest Objects选择一个类
- 进入reference视图 选择 incoming references 查看某一个对象的整个链路
如何判断什么原因造成OOM
模拟造成OOM代码
// -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {
// 创建1M的文件
byte [] buffer = new byte[1 * 1024 * 1024];
public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();
int count = 0;
try {
while (true) {
list.add(new HeapOOM());
count++;
}
} catch (Exception e) {
e.getStackTrace();
System.out.println("count:" + count);
}
}
}
上述代码,不断往list添加一个 1m的数据,JVM参数中-Xms8m -Xmx8m限定最多8M内存
-XX:+HeapDumpOnOutOfMemeoryError 在出现OOM的时候生成一个hprof文件
使用JProfiler定位内存溢出代码
-
将hprof使用JProfiler打开,点击Biggest Object 定位大对象
-
通过线程 Dump文件 定位哪一行出现OOM
清除节点-标记清除算法
当进行完成标记阶段明确哪些对象是垃圾之后,GC将执行垃圾回收,释放无用对象占用的内存空间,以便有足够的内存空间为新对象分配内存.主流三种垃圾收集算法如下
- 标记-清除算法 Mark-Sweep
- 复制算法 Copying
- 标记压缩算法 Mark Compact
标记清除算法
当堆中有效内存(available memory)被耗尽的时候,就会停止整个程序(Stop The World),GC进行标记和清除工作
- 标记: Collector 从引用根节点GC Roots开始遍历,标记所有被引用的对象.一般在对象的Header对象头中记录为可达对象. 注意!!!标记的是被引用对象而不是垃圾!!
- 清除: Collector对堆内存从头到尾进行线性遍历,如果发现对象在Header对象头中没有被标记为可达对象则将其回收
什么是清除?
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲地址列表中.下次有新对象需要加载,判断空闲列表中的空间是否够,如果够则覆盖原有的地址
- 空闲列表的概念在对象分配内存时作用
- 如果内存规整 采用指针碰撞的方式进行内存分配 内存规整说明已用内存是连续的.指针碰撞则表示一个指针永远指向已用内存的最后一个位置,当需要新分配内存的之后指针下移就是空闲内存的开始位置
- 如果内存不规整 虚拟机需要维护一个空闲列表,来判断内存空间哪些位置是空的
标记清除算法的优缺点
- 优点在于实现简单,容易理解
- 标记清除算法效率不算高
- 在进行GC的时候,需要STW,用户体验较差
- 清理出来的空闲空间是不连续的,产生内存碎片,需要维护一个空闲列表
复制算法
- 将内存空间一分为二,每次只使用其中的一块,在垃圾回收时将正在使用的内存存活对象复制到未使用的那块内存块中,之后清除正在使用内存块的所有对象,交换角色,完成垃圾回收.JVM堆内存中的新生代S0 S1幸存者区使用的就是复制算法
复制算法优缺点
- 优点
- 没有标记清除过程,实现简单,运行高效
- 复制过去保证空间连续性,不会出现内存碎片
- 缺点
- 需要两倍的内存空间
- 对于G1这种分拆成为大量的 region的GC,复制而不是移动,意味着GC需要维护region之间对象的引用关系,不管是内存占用或者时间开销都不小
注意事项
- 如果系统中存活对象较多,不适合使用复制算法
- 复制算法适合系统存活对象比较低的时候(比如新生代中的朝生夕死的对象 幸存者区也确实使用的是复制算法,老年代中的大量生命周期很长的对象如果使用复制算法效率就会很低,因为每次GC都需要将对象重新复制到另一块内存中)
- 在新生代,对于常规应用的垃圾回收,一次性通过可以回收70%-99%的内存空间,回收性价比很高.所以很多商业虚拟机都是用这种算法回收新生代
标记-压缩算法
- 因为复制算法不适合处理存活对象较多的场景,而标记清除算法是线性遍历时间复杂度高而产生了内存碎片,所以后来提出了标记压缩算法 Mark-Compact结合了之前两种算法的优点
执行过程
- 第一阶段与标记清除算法一样,从GC Roots开始遍历标记所有为引用的对象
- 第二阶段将所有存活的对象压缩到内存的一端,按顺序排放,之后清理边界之外的所有空间
标记清理算法和标记压缩算法的区别
- 本质差异在于标记清除算法是一种非移动式的回收算法,标记压缩算法是移动式的.
- 是否移动回收存活对象是一项优缺点并存的风险策略,移动带来的各种关联属性地址修改的时间成本,带来的收益就是保证了内存的连续性
- 标记存活的对象将被整理,按内存地址依次排列,未被标记的对象将被清理,这种情况下JVM只需要维护一个可用内存起始地址即可,不需要像标记清除算法一样维护一个空闲列表
标记压缩算法的优缺点
- 优点
- 解决了标记清除算法中内存不连续的问题,JVM只需维护一个可用内存的起始地址即可
- 解决了复制算法中可用内存减半的问题
- 缺点:
- 效率上来说标记整理算法低于复制算法
- 移动对象的同时,如果对象被其他对象引用,还需要调整引用地址
- 移动过程中会导致STW
小结
标记清除 | 复制 | 标记压缩 | |
---|---|---|---|
效率 | 低 | 高 | 中 |
空间开销 | 低 | 高(1/2的空间) | 低(原始大小) |
移动对象 | 否 | 是 | 是 |
不同场景选择不同的算法
分代收集算法
- 上述算法中,没有一个算法可以完全替代其他算法,每种算法具体自己的优势和缺点,分代收集算法应运而生
- 分代收集算法,基于不同对象的声明周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。一般是把java堆分为新生代和老年代,这样就可以根据各年代的特点选择使用不同的回收算法,从而提高垃圾回收的效率
- 在实际生产中会产生大量的对象,其中一些对象是与业务信息相关的,比如Http请求中的Session对象,线程,Socket连接,这类对象跟业务直接挂钩,因此生命周期长.但是有些对象,比如一些临时变量,这些对象生命周期就会比较短,如String,但由于String的不变性,系统会产生大量的这些对象,有些对象设置只使用了一次即可被回收
- 目前几乎所有的GC都采用了分代收集算法进行垃圾回收
- 在HotSpot中,基于分代思想,GC所使用的内存回收算法必须结合新生代和老年代各自的特点
- 新生代 young Gen
- 区域相对老年代小,对象生命周期短,存活率低,回收频繁
- 这种情况使用复制算法,速度最快.新生代中的对象生命周期大部分较短,且空间占比较小,更进一步的HotSpot使用了两个Survivor进行缓解,所以在新生代就适合使用复制算法
- 老年代 Tenured Gen
- 区域大,对象的周期长,存活率高,回收不及新生代频繁
- 因为老年代存在大量存活率高的对象,复制算法显然不合适.一般是采用标记清除算法或者标记压缩算法混合实现
- Mark阶段的时间复杂度与存活对象正相关
- Sweep阶段时间复杂度与内存区域大小正相关
- Compact阶段时间复杂度与存活对象正相关
- 新生代 young Gen
- 以HotSpot中的CMS回收器为例,CMS基于Mark-Sweep实现,对于对象的回收效率很高.对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施,当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用serial old执行 FullGC以达到对老年代内存整理
- 分代的思想被现在的虚拟机广泛采用,几乎所有的垃圾回收器都区分新生代和老年代
增量收集算法
使用上述现有算法,在执行垃圾回收的时候会造成STW状态,所有用户线程被挂起,暂停一切正常工作,等待垃圾回收完成,如果垃圾回收时间过长,严重影响用户的体验和系统稳定性.为解决这个问题,即是堆实时垃圾收集的研究导致产生了增量收集(Incremental Collecting)算法的诞生
如果一次性将所有垃圾进行处理,需要造成较长的STW,那么就可以让垃圾收集线程和应用程序线程交替执行.每次垃圾收集线程只收集一小片区域的内存,接着切换到应用程序线程.依次反复.直到垃圾收集完成
增量收集算法基础仍是传统的标记-清除和复制算法.只是增量收集算法通过对线程间的冲突妥善的处理,允许垃圾收集线程以分阶段的方式完成标记,清除,复制操作
缺点在于使用这种方式,因为在垃圾回收的过程中,间断性的执行了应用程序代码,所以减少了STW时间,但是因为线程切换和上下文转换的消耗,使得垃圾回收总体成本上升,造成了系统吞吐量的下降
分区算法
一般来说,在相同堆空间越大,一次GC所需要的时间越长,有关GC的产生的停顿时间也长,为了更好的控制GC产生的停顿时间,将一块大内存区域分割成多个小块,根据目标停顿的事件,每次合理的回收若干个小区间,而不是真个堆空间,从而减少一次GC产生的停顿
分代算法将按照对象的声明周期长短分为两个部分,分区算法将整个堆空间划分为连续不同的小区间,每个小区间独立使用独立回收,这种算法的好处是可以控制一次回收多少个小区间