1.标记阶段:引用计数算法
在堆里存放这几乎所有的的Java对象实例 , 在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象 , 哪些是已经死亡的对象. 只有被标记为已经死亡的对象, GC才会执行垃圾回收时, 释放掉其所占用的内存空间 , 因此这个过程可以称为垃圾标记阶段.
那么在JVM中究竟是如何标记一个死亡对象呢? 简单来讲, 当一个对象已经不被任何的存活对象的继续引用,就可以宣判为死亡
判断对象存活一般有两种方式 : 计数引用法和可达性分析算法.
引用计数算法(Reference Counting) 比较简单 , 对每个对象保存一个整形的引用计数器属性, 用于记录被引用对象的情况
对于一个对象A, 只要有任何一个对象引用了A, 则A的引用计数器就加1 , 当引用失效时, 引用计数器就减1. 只要对象A的引用计数器的值为0 , 即表示对象A不可能再被使用, 进行回收,
优点: 实现简单, 垃圾对象便于识别; 判定效率高, 回收没有延迟性
缺点: 它需要单独的字段存储计数器, 这样的做法增加了存储空间的开销
每次赋值都需要更新计数器 , 帮随着加法和减法操作 , 这增加了时间开销, 引用计数器有一个严重问题 , 即无法处理循环引用的情况, 这是一条致命缺陷, 导致在Java的垃圾回收中没有使用这一类算法.
1. 循环引用
当p的指针断开的时候, 内部的引用形成一个循环 , 这就是引用循环, 从而造成内存泄露
2.举列
我们使用一个案例来测试Java中是否采用的是引用计数算法
/** 引用计数算法测试
* @author anda
* @since 1.0
*/
public class RefCountGC {
private byte[] bigSize = new byte[5 * 1024 * 1024];
Object reference = null;
public static void main(String[] args) {
RefCountGC refCountGC1 = new RefCountGC();
RefCountGC refCountGC2 = new RefCountGC();
refCountGC1.reference = refCountGC2;
refCountGC2.reference = refCountGC1;
refCountGC1 = null;
refCountGC2 = null;
System.gc();
}
}
运行结果
[GC (System.gc()) [PSYoungGen:
, 0.0079727 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[Full GC (System.gc()) [PSYoungGen: 464K->0K(153088K)] [ParOldGen: 8K->385K(349696K)] 472K->385K(502784K), [Metaspace: 3164K->3164K(1056768K)], 0.0211856 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
Heap
PSYoungGen total 153088K, used 1316K [0x0000000715580000, 0x0000000720000000, 0x00000007c0000000)
eden space 131584K, 1% used [0x0000000715580000,0x00000007156c90d0,0x000000071d600000)
from space 21504K, 0% used [0x000000071d600000,0x000000071d600000,0x000000071eb00000)
to space 21504K, 0% used [0x000000071eb00000,0x000000071eb00000,0x0000000720000000)
ParOldGen total 349696K, used 385K [0x00000005c0000000, 0x00000005d5580000, 0x0000000715580000)
object space 349696K, 0% used [0x00000005c0000000,0x00000005c0060528,0x00000005d5580000)
Metaspace used 3171K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 351K, capacity 388K, committed 512K, reserved 1048576K
我们能够看到, 上述进行了GC收集的行为, 将上述的新生代中的两个对象都进行了回收
15503K->464K(153088K)] 15503K->472K(502784K)
如果使用引用计数算法 , 那么这两个对象就无法回收 , 而现在两个对象被回收了 , 说明Java使用的不是引用计数算法来进行标记的
3.小结
引用计数算法, 是很多语言的资源回收选择, 例如python, 它更是同时支持引用计数和垃圾收集机制.
具体那种最优是要看场景的, 业界有大规模实践保留引用计数机制, 以提高吞吐量的尝试
Java 并没有选择引用计数, 是因为其存在一个基本的难题 , 也就是很难处理循环引用关系
手动解除: 很好理解 , 就是在合适的时机, 解除引用关系 , 使用弱引用weakref, weakref是Python提供的标准库, 在解决循环引用
2.标记阶段: 可达性分析算法
1.概念
可达性分析算法: 也可以称之为 跟搜索算法, 追踪性垃圾收集
相对于引用计数算法而言, 可达性分析算法不仅同样具备实现简单和执行高效等特点, 更重要的是该算法可以有效的解决在引用计数算法中循环引用的问题 , 防止内存泄露的发生
相对于引用计数算法, 这里的可达性分析就是Java, C#. 这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)
2.思路
所谓”GCRoots”根集合就是一组必须活跃的引用
基本思路 :
- 可达性分析算法是以根对象集合(GCRoots)为起始点, 按照从上至下的方式, 搜索被根对象集合所连接的目标对象是否可达
- 使用可达性分析算法后, 内存中的存活对象 , 都会被根对象, 集合直接或者间接连接着 , 搜索所走过的路径称之为引用链(Reference Chain)
- 如果目标对象中没有任何引用链, 则是不可达的 , 就意味着该对象已经死亡, 可以标记为垃圾对象
- 在可达性分析算法中, 只有能够被根对象集合直接或者间接连接的对象才是存活对象
3.GC Roots可以是那些
- 虚拟机栈中的引用的对象
- 比如: 各个线程被调用的方法中使用到的参数 , 局部变量等.
- 本地方法栈内JNI(通常说的本地方法)引用的对象方法区中的静态属性引用的对象
- 比如Java类的引用类型静态
- 方法区中常量引用的对象
- 比如: 字符串常量池(string table) 里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用
- 基本数据类型对应的Class对象, 一些常驻的异常对象, (如: NullPointException , OutOfMemoryError) , 系统类加载器
- 放映了Java虚拟机内部的情况JMXBean, JVMI中注册的回调 , 本地代码缓存等等
1.总结
除了堆空间外的一些结构 , 比如: 虚拟机栈 , 本地方法栈 , 方法区 , 字符串常量池 等这些地方对堆空间进行引用 , 都可以作为GC Roots进行可达性分析
除了这些固定的的GC Root外 , 根据用户所选的垃圾集群器以及当前回收的内存区域不同 , 还可以有其他对象”临时性”地加入 , 共同沟工程完整的GC Roots集合, 比如: 分代收集和局部回收(PartialGC)
如果只是针对Java堆中的某一块区域进行垃圾回收(比如: 典型的只针对新生代), 必须考虑到内存区域是虚拟机自己实现的细节 , 更不是孤立封闭的, 这个区域的对象完全有可能被其他区域的对象所引用, 这个时候就需要一并关联的区域对象也加入到GCRoots集合中去考虑 , 才能保证可达性分析的准确性
2.小技巧
由于Root采用栈的方式存放变量, 如果一个指针,它保存了堆内存里面的对象, 但是自己又不存放在堆内存里面, 那它就是一个Root
4.注意
如果要使用可达性分析算法来判断内存是否可回收 , 那么分析工作必须在一个能保障一致性的快照中进行, 这一点不满足的话,分析结果的准确性就无法保证
这也是导致GC进行时必须STW的一个重要原因, 即时是号称(几乎)不会发生停顿的CMS收集器中, 枚举根节点时也是必须要停顿的.
4.对象的finalization机制
Java语言提供了对象终止(finalization)机制来允许开发人员提供对象销毁之前的自定义处理逻辑.
当垃圾回收器发现没有引用指向一个对象 , 即: 垃圾回收此对象之前 , 总会先调用这个对象的finalize()方法
finalize()方法允许在子类中被重写 , 用于在对象被回收时进行资源释放, 通常在这个方法中进行一写资源释放和清理的工作 , 比如关闭文件, 套接字和数据库连接.
1.注意
永远不要主动调用某个对象的finalze()方法应该交给垃圾回收机制调用, 理由包括下面三点:
- 在finalize()时可能会导致对象复活
- finalze()方法执行时间时没有保障的 , 它完全由GC线程决定,极端情况下 , 若不发生GC,则finalize()方法将没有执行机会.
- 因为优先级比较低 , 即时主动调用该方法, 也不会因此就直接进行回收
- 一个糟糕的finalize()会严重影响GC的性能.
从功能上说 , finalize()方法与C++中的析构函数比较相似 , 但是Java采用的是基于垃圾回收器的自动内存管理机制 , 所以finalize()方法在本质不同于C++中的析构函数.
由于 finalize方法的存在 , 虚拟机中的对象一般处于三种可能状态.
2.生存还是死亡
如果从所有的根节点无法访问到某个对象, 说明对象已经不再使用了 , 一般来说 , 此对象需要被回收. 但事实上, 也并非”非死不可”的 , 这个时候他们暂时处于”缓刑”节点 , 一个无法触及的对象有可能在某个条件下”复活”自己 , 那么它的回收就是不合理的, 为此 , 定义虚拟机中的对象可能的三种状态 , 如下:
- 可触及的: 从根节点开始 , 可以到达这个对象
- 可复活的: 对象的所有引用都被释放 , 但是对象有可能在finalize()中复活
- 不可触及的: 对象的finalize()调用,并且没有复活 , 那么就会进入不可触及状态 , 不可触及的对象不可能被复活 , 因为finalize只会被调用一次.
以上3种状态中, 是由于finalize()方法的存在 , 进行的区分 , 只有在对象不可触及才可以回收.
3.具体过程
判定一个对象是否可回收 , 至少要经历两次标记过程
- 如果对象没有到GC Roots没有引用链 , 则进行第一次标记
- 进行筛选, 判断此对象是否有必要执行finalize()方法
- 如果对象没有重写finalize()方法 , 或者finalize()方法已经被虚拟机调用过了, 则虚拟机视为”没有执行的必要”, 对象被判定为不可触及的
- 如果对象重写了finalize()方法 , 且还未执行过 , 那么会被插入到F-Queue队列中, 由一个虚拟机自动创建的, 低优先级的finalizer线程触发其finalize()方法执行
- finalize()方法是对象逃脱的最后机会 , 稍后GC会对F-Queue队列中的对象进行第二次标记, 如果对象在finalize()方法中与引用链上的任何一个对象建立了联系 , 那么在第二次标记时, 对象会被移除”即将回收”集合 , 之后 . 对象会再次出现没引用存在的情况 . 在这个情况下 , finalize方法不会被再次调用, 对象会直接变成不可触及的状态 , 也就是说 , 对象的finalze()方法只会被调用一次.
4.代码演示
import java.util.concurrent.TimeUnit;
/**
* @author anda
* @since 1.0
*/
public class CanReliveObj {
// 类变量 , 属于GC Roots的一部分
private static CanReliveObj canReliveObj;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
canReliveObj = this;
}
public static void main(String[] args) throws Exception {
canReliveObj = new CanReliveObj();
canReliveObj = null;
System.gc();
System.out.println("=======第一次GC操作=======");
// 因为finalizer线程的优先级比较低 , 暂停2秒 , 以等待它
TimeUnit.SECONDS.sleep(2);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("=======第二次GC操作=======");
canReliveObj = null;
System.gc();
// 此处发生GC的时候就不会再次执行finalize了
TimeUnit.SECONDS.sleep(2);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
}
}
=======第一次GC操作=======
调用当前类重写的finalize()方法
obj is still alive
=======第二次GC操作=======
obj is dead
在进行第一次清除的时候 , 我们会执行finalize方法, 然后对象进行了一次自救操作 , 但是因为finalize方法只会被调用一次 , 因此第二次该对象将会被垃圾清除
5.MAT与JProfiler的GC Roots溯源
1.MAT是什么?
MAT是memory analyzer的简称 , 它是一款功能强大的Java堆内存分析器 , 用于查找内存泄露以及查看内存消耗情况.
MAT是基于Eclipse开发的 , 是一款免费的性能分析工具
2.获取dump文件的方式
使用命令行
jmap
$ jmap -dump:format=b,live,file=[filename] [pId]
$ jmap -dump:format=b,live,file=test_1.bin 1234
最后就会生成这个文件使用Java VisualVM工具进行分析
- 1.运行程序之后 , 使用软件抓取到改进程号 , 之后将该进程的数据dump即可
- 2.之后将文件另存为即可
总结: 捕获heap dump 文件是一个临时文件, 关闭JvisualVM后自动删除 , 若要保留 , 需要将其另存为其它文件, 可以通过一下方法捕获heap dump:
- 在左侧”Application”(应用程序)子窗口中右击相应的应用程序, 选择Heap Dump
- 在Monitor(监视)子标签页中点击Heap Dump按钮 , 本地应用程序的Heap dumps作为应用程序标签页的一个子标签页面打开 , 同时 , heap dump 在左侧的Application(应用程序)栏中对应一个时间戳的节点
- 右击这个节点选择 save as (另存为)即可将heap dump 保存到本地
3.使用MAT打开Dump文件
- 打开的样子
- 分析Roots GC
- 主线程运行
4.使用JProfiler的GC Roots溯源
我们在实际的开发中 , 一般不会查找全部的GC Roots , 可能只是查找某个对象的整个链路, 或者称之为GC Roots 溯源 , 这个时候 , 我们就可以使用JProfiler
- 查看整个服务运行情况
- 查看内存所有大小
- 查看当前进程 , 那些对象增长比较快 , 容易造成内存泄露
- 溯源(离线)
- 打开一个dump文件
- 溯源(在线)
5.使用JProfiler进行OOM的溯源
当我们程序出现OOM的时候 , 我们就需要进行排查 , 我们首先使用下面的例子进行说明
/**
* -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
* @author anda
* @since 1.0
*/
public class HeapOOM {
byte[] buffer = new byte[1024 * 1024];
public static void main(String[] args) {
List<HeapOOM> list = new ArrayList<>();
int count = 10;
try {
while (true) {
list.add(new HeapOOM());
count++;
}
} catch (Exception e) {
e.getStackTrace();
System.out.println("count: " + count);
}
}
}
上述代码就是不断的创建一个1M小字节数组 , 然后让内存溢出, 我们需要限制一下内存大小 , 同时使用HeapDumpOnOutOfMemoryError 将出错时候的dump文件输出
-Xmx8m -Xms8m -XX:HeapDumpOnOutOfMemoryError
我们将生成的dump文件打开 , 然后点击Biggest Objects 就能够看到超大对象
然后通过线程 , 还能够定位哪里出现了OOM
6.清除节点: 标记- 清除算法
当成功区分出内存中存活对象和死亡对象后, GC接下来的任务就是执行垃圾回收, 释放掉无用对象所占用的内存空间 , 以便有足够的可用内存空间为新对象重新分配内存 , 目前在JVM中比较常见的三种垃圾收集算法是
- 标记— 清除算法(Mark-Sweep)
- 复制算法(copying)
- 标记-压缩算法(Mark-Compact)
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,
1.执行过程
当堆中的有效内存空间(Available Memory)被耗尽的时候 , 就会停止整个程序(也被称之为stop the world), 然后进行两项工作 , 第一项则是标记 , 第二项则是清除
- 标记: Collector从引用根节点开始遍历 , 标记所有被引用的对象, 一般是在对象的Header中记录为可达对象.
- 标记的是引用的对象 , 不是垃圾
- 清除: Collector对堆内存从头到尾进行线性的遍历 , 如果发现某个对象在其Header中没有标记为可达对象 , 则将其回收
2.什么是清除
这里所谓的清除并不是真的置空 , 而是把需要清除的对象地址保存在空闲的地址列表里, 下次有新的对象需要加载 , 判断垃圾位置空间是否足够 , 如果足够 , 就存放覆盖原有的地址
关于空闲列表是在为对象分配内存的时候
- 如果内存规整
- 采用指针碰撞的方式进行内存分配
如果内存不规整
标记清除算法的效率不算高
- 在进行GC的时候 , 需要停止整个应用程序 , 用户体验比较差
这种方式清理出来的空闲内存是不连续的 , 产生内碎片 , 需要维护一个空闲列表
7.清除阶段: 复制算法
1.核心思想
将活着的内存空间分为两块 , 每次只使用其中的一块 , 在垃圾回收时, 将正在使用的内存中的存活对象复制到未被使用的内存块中 , 之后清除正在使用的内存块中的所有对象 , 交换两个内存的角色 , 最后完成垃圾回收
可达性的对象, 直接复制到另外一个区域中复制完成后, A区就没有用了 , 里面的对象可以直接清除掉 , 其实新生代里面就用到了复制算法.2.优点
没有标记和清除过程 , 实现简单 , 运行高效
-
3.缺点
此算法的缺点也很明显 , 就是需要两倍的内存空间
对于G1这种拆分为大量的region的GC , 复制而不是移动 , 意味着需要维护region之间对象引用关系 , 不管是内存占用或者时间开销也不小
4.注意
如果系统中的垃圾对象很多, 复制算法需要复制的存活对象数量并不会太大 , 或者说非常低 , (老年代大量的对象存活, 那么复制的对象将会有很多 , 效率会很低)
在新生代, 对常规应用的垃圾回收 , 一次通常可以回收70%-99% 的内存空间, 回收性价比很高 , 所以现在商业虚拟机都是用这种收集算法回收新生代
8.清除阶段: 标记-整理算法
1.背景
复制算法的高效性是建立在存活对象少 , 垃圾对象多的前提下的 , 这种情况在新生代经常发生,但是老年代, 更常见的情况是大部分对象都是存活对象 , 如果依然使用复制算法, 由于存活对象比较多 , 复制到成本也会很高 , 因此 , 基于老年代垃圾回收的特性 , 需要使用其他的算法.
2.执行过程
第一阶段和标记清除算法一样 , 都是从根节点, 开始标记所有被引用对象
第二阶段将所有的存活对象压缩到内存的一端, 按顺序排放 , 之后 , 清理边界外所有的空间.
3.标清和标整的区别
标记-压缩算法的最终效果等同于标记-清除算法执行完成后 , 在进行一次内存碎片整理 , 因此 , 也可以把它成为标记-清除-压缩(Mark-Sweep-Compact)算法.
二者的本质差异在于标记-清除算法是一种非移动式的回收算法, 标记-压缩是移动式的 , 是否移动回收的存活对象是一项优缺点并存的风险策略 , 可以看到, 标记的存活对象将会被整理 , 按照内存地址依次排列, 而未被标记的内存会被清理掉 , 如此一来 , 当我们需要给新对象分配内存时 , JVM只需要持有一个内存的起始地址即可 , 这比维护一个空间列表显然少了许多开销4.标整的优缺点
1.优点
消除了标记-清除算法当中 , 内存区域分散的缺点 , 我们需要给新对象分配时, JVM只需要持有一个内存的起始地址即可
-
2.缺点
从效率上来说 , 标记-整理算法要低于复制算法
- 移动对象的同时 , 如果对象被其他对象引用 , 则还需要调整引用的地址
- 移动过程中 , 需要全程暂停用户应用程序 , 即:STW.
9.小结
从效率上来说 , 复制算法是性能最好的, 但是却浪费了大量的内存空间
而为了尽量兼顾上面提到的三个指标 , 标记-整理算法相对来说更平滑一些 , 但是效率上不尽如人意 , 它比复制算法多了一个标记的阶段 , 比标记-清除多了一个整理内存的阶段.
标记清除(Mark - Sweep) | 标记整理(Mark Compact) | 复制(Copying) | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
1.分代收集算法
前面所有这些算法中, 并没有一种算法可以完全可以替代其他算法 , 它们都具有自己独特的优势和特点 , 分代收集算法应用而生
分代收集算法 , 是基于这样一个事实, 不同的对象的生命周期是不一样的 , 因此 , 不同生命周期的对象可以采取不同的收集方式 , 以便提高回收效率 , 一般是把Java堆分为新生代和老年代 , 这样就可以根据各个年代的特点使用不同的回收算法, 以便提高垃圾回收的效率
在Java程序运行的过程中 , 会产生大量的对象 , 其中有一些对象是与业务相关 , 比如http请求中的session对象 , 线程 , Socket连接, 这类对象跟业务直接挂钩 , 因此生命周期比较长 , 但是还是有一些对象 , 主要是程序运行过程中生成的临时变量,这些对象生命周期比较短 ,
目前几乎所有的GC都采用分代收集算法执行垃圾回收的
在Hotspot中, 基于分代的概念 , GC所使用的内存回收算法必须结合年轻代和老年代各自的特点.
- 年轻代(Young Gen)
年轻代特点: 区域相对于老年代较小 , 对象生命周期短 , 存活率低 , 回收频繁
这种情况复制算法的回收整理, 速度是最快的 , 速度是最快的 , 复制算法的效率只和当前存活对象大小有关,因此很适用于年代代的回收 , 而复制算法内存利用率不高的问题 , 通过hotspot中的两个survivor的设计得到缓解
- 老年代(Tenured Gen)
老年代特点: 区域较大 , 对象生命周期长 , 存活率高, 回收不及年轻代频繁.
这种情况存在大量存活率高的对象 , 复制算法明显变得不合适 , 一般是由 标记-清除或者是标记-整理(压缩-清理)的混合实现
- Mark阶段的开销与存活对象的数量成正比
- Sweep阶段的开销与所管理区域的大小成正比相关
- Compact阶段的开销与存活对象的数据成正比.
以Hotspot中的CMS回收器为例 , CMS是基于Mark-Sweep实现的 , 对于对象的回收效率很高,. 而对于碎片问题 , CMS采用基于Mark-Compact算法的Serial Old 回收器作为补偿措施: 当内存回收不佳(碎片导致Concurrent Mode Failure), 将采用serial old执行 FullGC以达到对老年代内存的整理
分代的思想被现有的虚拟机广泛使用, 几乎所有的垃圾回收器都区分新生代和老年代
10.增量收集算法
1.概述
上述现有的算法, 在垃圾回收过程中 , 应用软件将处于一种stop the world 的状态 , stop the world 状态下 , 应用程序所有的线程都会挂起, 暂停一切正常的工作 , 等待垃圾回收的完成 . 如果垃圾回收时间过长, 应用程序会被挂起很久 , 将严重影响用户体验或者系统的稳定性. 为了解决这个问题 , 即堆实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生.
如果一次性将所有的垃圾进行处理, 需要造成系统长时间的停顿 , 那么就可以多让垃圾收集线程和应用程序线程交替执行 , 每次 , 垃圾收集线程只收集一小片区域的内存空间 , 接着切换到应用程序线程, 依次反复 , 知道垃圾收集完成
总的来说 , 增量收集算法的基础仍是创投的标记-清除算法和复制算法, 增量收集算法通过对线程间冲突的妥善处理 , 允许垃圾收集线程以分阶段的方式来完成标记 , 清理 或者复制工作
2.缺点
使用这种方式 , 由于在垃圾回收过程中 , 间断性地执行了应用程序代码 , 所有能减少系统的停顿时间 , 但是线程切换和上下文转换的消耗 , 会似的垃圾回收的总体成本上升 , 造成系统吞吐量的下降.
11.分区算法
一般来说 , 在相同的条件下 , 堆空间越大 , 一次GC时所需要的时间就越长 , 有关GC产生的停顿也越长 , 为了更好地控制GC产生的停顿时间 , 将一块大的内存区域分割成好多小块 , 根据目标的停顿时间 , 每次合理地回收若干个小区间, 而不是整个堆空间 , 从而减少一次GC所产生的停顿.
分代算法将按照对象的生命周期长短划分成两个部分 , 分区算法将整个堆空间划分成连续的不同小区间 , 每个小区间都独立使用, 独立回收 . 这种算法的好处就是可以控制一次回收多少个小区间