垃圾回收篇
什么是垃圾
垃圾是指在运行程序中没有任何指针指向的对象
垃圾回收重点区域
GC 只会存在与方法区与堆中,重点是堆,频繁 GC 的是堆区的新生代
垃圾判别阶段算法
引用计数算法
原理
对于一个对象 A ,只有有任何一个对象引用了 A ,则引用计数器加一,否则减一,当这个值为 0 的时候,这个对象就可以被回收
优点
实现简单,垃圾对象便于标识;判定效率高,回收没有延迟
缺点
引用计数器有一个严重的问题,无法处理循环引用的情况,这是致命缺陷。因此 JVM 没有选择这种算法。Python
支持引用计数算法,采用手动解除和弱引用的方式解决循环引用问题
可达性分析算法
原理
Java / C# 选择可达性分析算法,以根对象集合 (GC Roots) 作为起始点,按照从上到下的方式搜索被根对象集合所链接的目标对象是否可达,如果目标对象不可达,则目标对象可以回收
优点
简单高效,解决循环引用问题
GC Roots 有哪些呢?
虚拟机栈中引用的对象,比如各个线程被调用的方法中使用到的参数、局部变量等
本地方法栈(本地方法)引用的一些对象
方法区中常量引用的对象,比如字符串常量池里的引用
所有被同步锁
synchronized
持有的引用JVM 内部的引用,比如基本数据类型对应的
Class
对象,一些常驻的异常对象 (NullPointerException),系统类加载器反映 JVM 内部情况的 JMXBean 、 本地代码缓存等
除了以上固定的 GC Roots 以外,根据用户选择的垃圾收集器以及回收区域的不同,有其他对象“临时性”地加入这个集合中
注意
分析工作必须在一个能保障一致性地快照中进行,才能保证分析结果的准确性,因此必须进行 "Stop the world" ,枚举根节点时必须停顿
垃圾清除阶段算法
标记清除算法
将与根节点连接的对象标记出来,将未标记的对象回收。注意标记的是可达的对象,而不是垃圾!
缺点
- 效率比较低,递归全堆对象需要遍历两次
- 在进行 GC 的时候,需要停止整个应用程序,导致用户体验差
- 这种方法清理出来的内存空间不是连续的,产生内存碎片
复制算法
将活着的对象全部复制到另一个区域,然后将之前的所有对象进行回收
优点
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,避免内存碎片
缺点
- 需要两倍的内存空间
- 对于 G1 这种分拆未大量
region
的 GC ,复制而不是移动,意味着 GC 需要维护他们之间的对象引用关系,增加开销 - 特别是,如果系统中的存活对象很多,复制算法的效率也不会很理想
标记-压缩算法
优点
- 没有标记清除算法中的内存碎片化问题
- 没有复制算法中的两倍内存问题
缺点
- 效率低,比复制算法效率低,不仅要标记所有存活对象,还要整理所有存活对象的引用地址
- 移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址
- 移动过程中,需要全程暂停用户应用程序
分代收集算法
每种算法有不同的优缺点,可以根据不同生命周期对象采用不同的收集算法,以便提高回收效率。
目前几乎所有的 GC 都是采用分代收集算法执行垃圾回收的。
- 新生代 : 区域相对老年代较小,对象生命周期短,存活率低,回收频繁,使用复制算法速度最快
- 老年代 : 区域较大,对象生命周期长,存活率高,一般采用标记-清除或标记-清除与标记-压缩的混合实现
System.gc()
System.gc()
方法并不是一个强提示,JVM 不会立刻执行垃圾回收,而是将请求委托给垃圾回收器,由垃圾回收器决定何时执行垃圾回收。
内存泄露与内存溢出
内存溢出
内存不够的原因
- JVM 的堆内存设置不够
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集
OOM 前必有 GC ?
在正常情况下,在抛出 OutOfMemoryError
之前,垃圾收集器都会被触发,尽其所能去清理空间;但极端情况下,比如创建一个超大数组,超出了堆的内存空间最大值,就不会GC,直接抛出异常。
内存泄露
内存泄露就是指,本来应该回收的内存空间,但却一直无法被回收,有8种情况:
1、静态集合类
静态集合类的生命周期与 JVM 程序一致,则容器中的对象在程序结束之前将不能被释放,造成内存泄露。
简而言之,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而不能被回收。
public class MemoryLeak {
static List list = new ArrayList();
public void oomTests() {
Object obj = new Object(); //局部变量
list.add(obj);
}
}
2、单例模式
单例模式和静态集合导致内存泄露的原因类型,因为单例的静态特性,它的生命周期也和JVM程序一致,所以如果单例对象持有外部对象的引用,这个外部对象将不能被回收
3、内部类持有外部类
内部类持有外部类的引用,外部类对象将不能被回收,从而导致内存泄露。
4、各种连接未及时关闭
数据库连接、网络连接或IO连接等,只有连接被关闭后, GC 才会回收相应地对象,如果没有显性关闭连接,会造成大量对象无法被回收,导致内存泄露
5、变量不合理的作用域
public class UsingRandom {
private String msg; //不合理的作用域
public void receiveMsg() {
//private String msg; //合理的作用域
readFromNet(); //从网络中读取数据
saveDB(); //保存到数据库
//msg = null; //由于 msg 不合理的作用域,这里需要显性置空,否则 msg 将一直被引用,无法被回收
}
}
6、改变哈希值
public class ChangeHashCode {
public static void main(String[] args) {
HashSet<Person> set = new HashSet<>();
Person aa = new Person(1001, "AA");
Person bb = new Person(1002, "BB");
set.add(aa);
set.add(bb);
aa.name = "CC";
set.remove(aa); //重写了 HashCode,再改变移除不掉 aa
set.forEach(System.out::println); // aa bb
}
@AllArgsConstructor
@NoArgsConstructor
@Data
static class Person {
int id;
String name;
}
}
7、缓存泄露
一旦你把对象引用放到缓存中,遗忘了清理,那么这个对象就永远不能被回收。
可以使用 WeakHashMap
代表缓存,它的特点是,当除了自身有对 key
的引用外,此 key
没有其他引用,那么此 map
会自动丢弃此值
8、监听器和回调
内存泄露的另一个常见来源是监听器和其他回调,如果客户端在你实现的 API 中注册回调,却没有显式地取消,那么就会积聚。
需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为 WeakHashMap
的键
- 强引用:不回收
Object obj = new Object(); //声明强引用
- 软引用:内存不足时回收;软引用通常用来实现内存敏感的缓存,比如:高速缓存
//声明软引用
SoftReference<Object> softReference = new SoftReference<>(new Object());
- 弱引用:发现即回收
//使用 WeakHashMap 来实现弱引用
WeakHashMap weakHashMap = new WeakHashMap();
- 虚引用:对象回收跟踪
是所有引用类型中最弱的一个,一个对象是否有虚引用的存在,完全不会决定对象的生命周期。
- 终结器引用
GC 评估指标
- 吞吐量: 运行用户代码时间/(运行用户代码时间 + GC 时间)
- 暂停时间: 执行垃圾收集时,程序的工作线程被暂停的时间
- 内存占用: Java 堆区所占的内存大小
以上三项构成一个 “不可能三角”,这三个指标最多只能尽可能的取舍,最多同时保证其中两项指标尽可能优秀。
随着硬件发展,内存占用成了这三者中最能容忍的指标。尽可能减少 GC 停顿,提高吞吐量,从而提高程序性能。
垃圾回收器都有哪些?
Parallel GC
在 JDK6 之后称为 HotSpot 默认 GC- JDK9 中 G1 成为默认的垃圾收集器,以替代 CMS
- JDK11 中,引入 ZGC,可伸缩的低延迟垃圾回收器
如何查看默认 GC ?
-XX:+PrintCommandLineFlags:
查看命令行相关参数(包含使用的垃圾收集器)- 使用命令行指令 :
jinfo -flag 相关垃圾回收器参数 进程ID
SerialGC
采用复制算法、串行回收和 “Stop-the-World” 的机制执行 GC。简单高效,适用于单核 CPU 场景下,桌面小内存应用选择。
Serial Old GC
和 Serial GC
采取的算法有所不同,它采用标记-压缩算法
ParNew GC
ParNew
是 Serial GC
的改进版本,它采用并行复制算法,并行收集垃圾,从而提高吞吐量。是许多 JVM 运行在 Server
模式下新生代的默认垃圾收集器。
Parallel GC
Parallel GC
主打吞吐量,采用复制算法,并行回收和 “Stop-the-World” 的机制执行 GC。
Parallel Old GC
采用标记-压缩算法,并行回收,用来替代 CMS GC
CMS GC
CMS GC
主要关注低延迟,它采用标记-清除算法,采用 “Stop-the-World” 的机制执行 GC。
- 初始标记(STW):暂停时间非常短,仅仅标记
GC Roots
直接关联的对象 - 并发标记: 最耗时,从
GC Roots
开始遍历整个对象图的过程,不会停顿用户线程 - 重新标记(STW):修复并发标记环节,因为用户线程执行导致的数据不一致性问题
优点
- 并发收集
- 低延迟
缺点
- 会产生内存碎片,并发清除后,用户线程的可用空间不足,无法分配大对象的情况下,不得不提前触发 Full GC
- CMS 收集器对 CPU 资源非常敏感,在并发阶段虽然不会导致用户停顿,但会降低吞吐量
- CMS 收集器无法处理浮动垃圾,可能出现
Concurrent Mode Failure
而导致另一次 Full GC
G1 GC
G1 (Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高吞吐两的性能特征。
jdk9 之后的默认垃圾回收器,在 jdk8 中需要使用 -XX:+UseG1GC
来启用。
G1 将内存划分为一个个的 region
。内存的回收是以 region
为基本单位的,region
之间是复制算法,但整体上实际可看作是标记-压缩算法。
G1 回收过程
- Eden 区空间耗尽时,进行新生代回收,回收过程包括
- 扫描根:根指
static
变量指向的对象,正在执行的方法调用链上的局部变量等 - 更新
Rset
:保证Rset
可用准确的反映老年代对所在的内存分段中对象的引用 - 处理
Rset
:识别被老年代对象指向的Eden
中的对象,这些对象被认为是存活的对象 - 复制对象
- 处理引用
- 扫描根:根指
并发标记过程
- 初始标记阶段:标记从根节点直接可达的对象,这个阶段是 STW 的,并且会触发一次年轻代 GC
- 根区域扫描:G1 扫描
Survivor
区直接可达的老年代区域对象,并标记被引用的对象 - 并发标记:在整个堆中进行并发标记,此过程可能被
young GC
中断。 - 再次标记:由于应用程序持续进行,需要修正上一次的标记结果,是 STW 的
- 独占清理 (STW)
- 并发清理
混合回收 : 当越来越多的对象晋升到老年代
old region
时,为了避免堆内存被耗尽,虚拟机会触发Mixed GC
,会回收整个Young Region
,还会回收一部分Old Region
GC 日志分析
-XX:+PrintGC
打印 GC 日志,注意在 jdk21 中这个参数已经过时,需要使用-Xlog:gc
-XX:+PrintGCDetails
打印详细 GC 日志,注意在 jdk21 中这个参数已经过时,需要使用-Xlog:gc*
-Xlog:gc=info // 指定以 info 级别记录 GC 日志
:file=gc.log // 指定日志文件的路径
:time,uptime,pid,tid,level // 添加时间、进程ID、线程ID、日志级别等装饰器信息
:filecount=10,filesize=10M // 日志文件最大数量为 10个,单个日志文件最大大小为 10M
GC 日志分析工具
推荐使用 GC easy
: https://gceasy.io/