垃圾标记阶段:
判断对象是否存活
- 已经死亡的对象, 就会被垃圾回收器进行回收
堆里
存放着几乎所有的Java对象实例
,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。垃圾标记阶段。
已经不再被任何的存活对象继续引用
时,就可以宣判为已经死亡。引用计数算法
和可达性分析算法
。方式一:引用计数算法
- 有多少个对象引用着A, 此时A的引用计数器就有多少
- A的引用计数器为0的时候, 此时A就不在使用了, A就可以被回收
- 缺点: 无法解决循环引用的问题, 所以Java没有采用这种方式
引用计数算法(Reference Counting)
比较简单,对每个对象保存一个整型的引用计数器属性
。用于记录对象被引用的情况。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
引用计数器有一个严重的问题,即无法处理循环引用的情况
。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。循环引用举例
内存泄漏
代码示例
我们使用一个案例来测试Java中是否采用的是引用计数算法
代码
/**
* -XX:+PrintGCDetails
* 证明:java使用的不是引用计数算法
*
* @author shkstart
* @create 2020 下午 2:38
*/
public class RefCountGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
obj1.reference = obj2;
obj2.reference = obj1;
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为,这里发生GC,obj1和obj2能否被回收?
// System.gc();
}
}
-XX:+PrintGCDetails
Eden 区占用率为 25%
Heap
PSYoungGen total 76288K, used 16794K [0x000000076b380000, 0x0000000770880000, 0x00000007c0000000)
eden space 65536K, 25% used [0x000000076b380000,0x000000076c3e6850,0x000000076f380000)
from space 10752K, 0% used [0x000000076fe00000,0x000000076fe00000,0x0000000770880000)
to space 10752K, 0% used [0x000000076f380000,0x000000076f380000,0x000000076fe00000)
ParOldGen total 175104K, used 0K [0x00000006c1a00000, 0x00000006cc500000, 0x000000076b380000)
object space 175104K, 0% used [0x00000006c1a00000,0x00000006c1a00000,0x00000006cc500000)
Metaspace used 3469K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
进行了垃圾回收,使用反证法证明了Java中使用的不是引用计数算法
[GC (System.gc()) [PSYoungGen: 15482K->776K(76288K)] 15482K->784K(251392K), 0.0008828 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 776K->0K(76288K)] [ParOldGen: 8K->667K(175104K)] 784K->667K(251392K), [Metaspace: 3461K->3461K(1056768K)], 0.0039408 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 76288K, used 655K [0x000000076b380000, 0x0000000770880000, 0x00000007c0000000)
eden space 65536K, 1% used [0x000000076b380000,0x000000076b423ee8,0x000000076f380000)
from space 10752K, 0% used [0x000000076f380000,0x000000076f380000,0x000000076fe00000)
to space 10752K, 0% used [0x000000076fe00000,0x000000076fe00000,0x0000000770880000)
ParOldGen total 175104K, used 667K [0x00000006c1a00000, 0x00000006cc500000, 0x000000076b380000)
object space 175104K, 0% used [0x00000006c1a00000,0x00000006c1aa6d10,0x00000006cc500000)
Metaspace used 3468K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
Java使用的不是引用计数算法来进行标记
的。引用计数算法小结
Java并没有选择引用计数
,是因为其存在一个基本的难题,也就是很难处理循环引用关系
。Python如何解决循环引用?
标记阶段:可达性分析算法
(重点
)方式二:
可达性分析(或根搜索算法、追踪性垃圾收集)
可达性分析算法:也可以称为根搜索算法、追踪性垃圾收集
该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
达性分析
就是Java、C#选择的。这种类型的垃圾收集
通常也叫作追踪性垃圾收集(Tracing Garbage Collection)
可达性分析算法基本思路
所谓"GCRoots”根集合就是一组必须活跃的引用
,其基本思路如下:
可达性分析算法
是以根对象集合(GCRoots)为起始点
,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
存活对象
都会被根对象集合直接或间接连接着
,搜索所走过的路径称为引用链
(Reference Chain)可达性分析算法
中,只有能够被根对象集合直接或者间接连接的对象
才是存活对象
。可达性分析在官场上的裙带关系上的应用
GC Roots可以是哪些元素?
- 虚拟机栈中被变量引用的对象
- 字符串常量池中的对象
- JNI(本地方法)引用的对象
- 方法区中类静态变量引用的对象
- synchronized持有的对象
- JVM内部的引用
- 基本数据类型对应的Class对象
- 一些常驻的异常对象: NullPointerException、OutofMemoryError等
虚拟机栈中引用的对象
,比如:各个线程被调用的方法中使用到的参数、局部变量等。Java类的引用类型静态变量
方法区中常量引用的对象
,比如:字符串常量池(StringTable)里的引用被同步锁synchronized持有的对象
基本数据类型对应的Class对象,一些常驻的异常对象
(如:NullPointerException、OutofMemoryError),系统类加载器。GC Roots 的总结
虚拟机栈、本地方法栈、方法区、字符串常量池
等地方对堆空间进行引用
的,都可以作为GC Roots进行可达性分析分代收集和局部回收(PartialGC)。
小技巧
Root采用栈方式存放变量和指针
,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。可达性分析算法的注意事项
可达性分析算法来判断内存是否可回收
,那么分析工作必须在一个能保障一致性的快照中进行 (因为不在快照中进行,可能分析期间,又有一些被gcroot引用的对象变成垃圾了)。这点不满足的话分析结果的准确性
就无法保证。“Stop The World”
的一个重要原因。即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。对象销毁前的回调函数:
finalize()
对象终止(finalization)机制
来允许开发人员提供对象被销毁之前的自定义处理逻辑。
finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作
,比如关闭文件、套接字和数据库连接等。Object 类中 finalize() 源码
// 等待被重写
protected void finalize() throws Throwable { }
finalize() 方法使用的注意事项
在finalize()时可能会导致对象复活。
由GC线程
决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。 (可以调用System.gc()来触发GC)优先级比较低
,即使主动调用该方法,也不会因此就直接进行回收虚拟机对象中三种可能的状态
finalize()方法的存在
,虚拟机中的对象一般处于三种可能的状态
。GCRoot根节点
都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。可触及的
:从根节点开始,可以到达这个对象。可复活的
:对象的所有引用都被释放,但是对象有可能在finalize()中复活。不可触及的
:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次。
只有在对象不可触及时才可以被回收。
要经历两次标记过程
finalize() 的具体过程
- 一个对象只能够调用一次该方法(复活只能一次)
判定一个对象objA是否可回收,至少要经历两次标记过程:
objA到GC Roots没有引用链
,则进行第一次标记。
此对象
是否有必要执行finalize()
方法
没有重写finalize()
方法,或者finalize()方法已经被虚拟机调用过
,则虚拟机视为“没有必要执行”,ObjA被判定为不可触及的
。objA重写了finalize()
方法,且还未执行过
,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法
执行。finalize()方法是对象逃脱死亡的最后机会
,稍后GC会对F-Queue队列中的对象进行第二次标记
。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。(复活了)直接变成不可触及的状态
,也就是说,一个对象的finalize()方法只会被调用一次
。通过 JVisual VM 查看 Finalizer 线程
代码演示 finalize() 方法
将 obj 指向当前类对象 this
类变量
, 是属于GC Root
/**
* 测试Object类中finalize()方法,即对象的finalization机制。
*
* @author shkstart
* @create 2020 下午 2:57
*/
public class CanReliveObj {
public static CanReliveObj obj;//类变量,属于 GC Root
//此方法只能被调用一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
}
public static void main(String[] args) {
try {
obj = new CanReliveObj();
// 对象第一次成功拯救自己
obj = null;
System.gc();//调用垃圾回收器
System.out.println("第1次 gc");
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("第2次 gc");
// 下面这段代码与上面的完全相同,但是这次自救却失败了
obj = null;
System.gc();
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
第一次自救成功,但由于 finalize() 方法只会执行一次,所以第二次自救失败
调用当前类重写的finalize()方法
第1次 gc
obj is still alive
第2次 gc
obj is dead
Process finished with exit code 0
(重点)
垃圾清除算法
存活对象
和死亡对象
后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是
标记-清除算法(Mark-Sweep)
复制算法(Copying)
标记-压缩算法(Mark-Compact)
标记-清除(Mark-Sweep)算法
- 标记: 遍历GCRoot所关联的对象, 将被引用的
对象的对象头
都打上标记- 清除: 垃圾收集器对堆空间,进行线性遍历, 遍历到的对象,如果其对象头中没有被标记为
可达对象
, 此时就会被回收
执行过程
有效内存空间(available memory)被耗尽
的时候,就会停止整个程序
(也被称为stop the world
),然后进行两项工作,第一项则是标记,第二项则是清除
标记:
Collector从引用根节点
开始遍历,标记所有被引用的对象。
对象的对象头
中记录为可达对象。清除:
Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收何为清除?
清除并不是真的置空
,而是把需要清除的对象地址
保存在空闲的地址列表
里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。关于空闲列表
是在为对象分配内存
的时候提过:
内存规整
指针碰撞的方式进行内存分配
内存不规整
空闲列表
空闲列表分配内存
标记-清除算法的缺点
效率不算高
, 因为标记
, 清除
都需要进行大量运算清理出来的空闲内存是不连续的
,产生内存碎片
,还需要额外维护一个空闲列表
(重点)
复制(Copying)算法
背景
为了解决标记-清除算法在垃圾收集效率方面的缺陷
,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。复制(Copying)算法
,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。核心思想
能用的内存空间分为两块
,每次只使用其中一块内存
,在垃圾回收时将正在使用的内存中
的存活对象
复制到未被使用的内存块
中,之后清除正在使用的内存块中的所有对象
,交换两个内存的角色,最后完成垃圾回收把可达的对象,直接复制到另外一个区域中复制完成后
,from区里面的对象就没有用了, 直接清除即可,新生代里面就用到了复制算法 (幸存者0, 幸存者1)
复制算法的优缺点
优点
没有标记和清除过程,实现简单,运行高效
复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点
需要两倍的内存空间
。内存空间
直接要保持引用关系
G1
这种分拆成为大量region
的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小综上
复制算法的应用场景
- 适用于垃圾对象多的情况, 此时移动到另一块空间的可达对象就少, 效率就高
系统中的垃圾对象很多
,复制算法需要复制的存活对象数量并不会太大,效率较高 (因为垃圾对象多的话, 复制到另一个内存块中的可达对象就少, 效率自然就高)
老年代中有大量的对象存活
,那么复制的对象将会有很多,效率会很低新生代
,对常规应用的垃圾回收,一次通常可以回收70% - 99%
的内存空间。回收性价比很高。所以 现在的商业虚拟机都是用这种收集算法回收新生代
。(重点)
标记-压缩(或标记-整理、Mark - Compact)算法
复制算法的高效性
是建立在存活对象少、垃圾对象多
的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。由于存活对象较多,复制的成本也将很高
。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记-清除算法
的确可以应用在老年代
中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。 标记-压缩(Mark-Compact)算法由此诞生。
标记-压缩算法的执行流程
和标记清除算法一样,从根节点开始标记所有被引用对象 (在对象的对象头标记可达标记)
将所有的存活对象压缩到内存的一端
,按顺序排放
。之后,清理边界外所有的空间
。
标记-压缩算法
与标记-清除算法
的比较
- 相同点: 会他们第一阶段都是根据GCRoot寻找可达对象并标记
标记-清除算法:
会产生内存碎片, 因为该算法是一种非移动式
的回收算法, 不会对存活对象进行移动, 所以造成内存碎片. 当再次有新对象产生的时候, 需要根据空闲列表
来为新对象分配内存标记-压缩算法:
不会产生垃圾碎片, 因为该算法是一种移动式
的回收算法, 会对清除完垃圾对象剩下的存活对象进行移动. 所以不会造成内存碎片。此时根据指针碰撞
的方式为新对象分配内存
标记-压缩算法
的最终效果等同于标记-清除算法
执行完成后,再进行一次内存碎片整理
,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法
。标记-清除算法是一种非移动式的回收算法
; 标记-压缩是移动式的
。是否移动回收后的存活对象是一项优缺点并存的风险决策。标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉
。如此一来,标记压缩算法: 当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销
。指针碰撞的说明
内存空间以规整和有序的方式
分布,即已用
和未用的内存
都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针
,当为新对象分配内存时只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)
。标记-压缩算法的优缺点
优点
标记-清除算法当中,(内存碎片)内存区域分散的缺点
,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。复制算法当中,内存减半的高额代价。
缺点
效率
上来说,标记-压缩算法要低于复制算法。
移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
对比三种清除阶段的算法
复制算法
是当之无愧的老大,但是却浪费了太多内存。标记-压缩算法
相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。
标记清除 | 标记整理 | 复制 | |
---|---|---|---|
速率 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍空间(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
(重点)
为什么要使用分代收集算法
- 不同的对象生命周期是不一样的, 因此, 不同生命周期的对象, 可以
采用不同的收集方式, 以便提高回收效率
- 分为
年轻代、老年代
, 对这两种分代收集
并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点
。分代收集算法应运而生。不同生命周期的对象
可以采取不同的收集方式,以便提高回收效率。
Java堆
分为新生代
和老年代
,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。分代收集算法的分代依据
分代收集算法
执行垃圾回收
的分代
的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
(根据年轻代、老年代的特点, 使用不同的内存回收算法)年轻代(Young Gen)
内存区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
年轻代内存区域小
, 即使使用复制算法, 浪费一般的内存不使用也还可以接受
复制算法
的回收整理,速度是最快的
。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor
的设计得到缓解。老年代(Tenured Gen)
区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
标记-清除
或者是标记-清除与标记-整理
的混合实现。
标记阶段
的开销与存活对象的数量成正比。清除阶段
的开销与所管理区域的大小成正相关。压缩阶段
的开销与存活对象的数据成正比。Hotspot CMS 回收器 : 基于
标记清除
实现的
CMS回收器
为例,CMS是基于Mark-Sweep(标记清除)
实现的,对于对象的回收效率很高。碎片问题
,CMS采用基于Mark-Compact(标记压缩)
算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。几乎所有的垃圾回收器都区分新生代和老年代
(重点)
标记清除
和复制
算法增量收集算法
- 为了解决上述三个回收算法, 垃圾回收期间,万一回收时间过长, 导致
STW
的情况, 严重影响用户体验和系统稳定增量收集算法
: 在执行垃圾回收
的时候, 还很小间断的执行应用线程
, 减少了系统停顿
的时间, 降低STW, 但是不停的进行上下文切换
,垃圾线程
和用户线程
之间切换频率高, 消耗一定的资源.吞吐量
就降低了
Stop the World (停止用户线程)
的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。将严重影响用户体验或者系统的稳定性
。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法
的诞生。增量收集算法的基本思想
让垃圾收集线程
和应用程序线程
交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
标记-清除和复制算法
。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作
增量收集算法的缺点
在垃圾回收过程中
,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。
因为线程切换和上下文转换的消耗
,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
- 将一整块堆空间, 分割成很多小区间(region). 每次只回收
若干小区域
, 此时造成的垃圾回收的时间就减少了
更好地控制GC产生的停顿时间
,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间
,而不是整个堆空间,从而减少一次GC所产生的停顿
。分代算法
将按照对象的生命周期长短划分成两个部分
分区算法
将整个堆空间划分成连续的不同小区间
。每一个小区间都独立使用,独立回收。 这种算法的好处是可以控制一次回收多少个小区间
。MAT 介绍
获取 dump 文件
方式一:命令行使用 jmap
方式二:使用JVisualVM
使用JVisualVM捕捉 heap dump
/**
* @author shkstart [email protected]
* @create 2020 16:28
*/
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("结束");
}
}
如何捕捉堆内存快照
如何使用 MAT 查看堆内存快照
使用 JProfiler 进行 GC Roots 溯源
可点击【Run GC】手动进行垃圾回收
使用 JProfiler 分析 OOM
/**
* -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
*
* @author shkstart [email protected]
* @create 2020 15:29
*/
public class HeapOOM {
byte[] buffer = new byte[1 * 1024 * 1024];//1MB
public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();
int count = 0;
try{
while(true){
list.add(new HeapOOM());
count++;
}
}catch (Throwable e){
System.out.println("count = " + count);
e.printStackTrace();
}
}
}
-XX:+HeapDumpOnOutOfMemoryError 表示发生 OOM 时抓取内存快照
-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid14608.hprof ...
java.lang.OutOfMemoryError: Java heap space
at com.atguigu.java.HeapOOM.<init>(HeapOOM.java:12)
at com.atguigu.java.HeapOOM.main(HeapOOM.java:20)
Heap dump file created [7797849 bytes in 0.010 secs]
count = 6
Process finished with exit code 0