文章目录
深入JVM:详解垃圾判定与垃圾回收算法
一、序言
对于Java工程师而言,深入理解JVM(Java虚拟机)不仅是掌握Java程序运行机制的基础,也是提升系统性能、优化应用和解决复杂问题能力的重要一步,更是Java进阶之路的重中之重。
本文小豪将带大家巩固JVM垃圾回收相关知识,探究Java对象如何被定位为垃圾对象,以及常见的垃圾回收算法,为后续的JVM性能调优打下基础。
二、基础概念
1、回收区域
在上一篇【深入JVM:详解JVM内存模型及其演变过程】中,我们了解到JVM内存模型分为线程私有和线程共享两大类,一般来说线程私有的区域(Java虚拟机栈、本地方法栈和程序计数器),垃圾回收我们不用过多关注,因为其内存随着线程的创建而创建,随着线程的销毁而销毁。
例如方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存
本节我们重点聊一下线程共享的方法区(永久代、元空间)、堆区的垃圾回收。
2、方法区垃圾回收
2.1 永久代
对于Java 7版本以及之前,方法区的实现永久代而言,主要回收的对象即不再使用的类,在【深入JVM:从类加载机制解读类的生命周期】一文中我们介绍到,类的卸载条件相对苛刻,需同时满足三个条件:
- 类的所有实例对象都已经被垃圾回收,堆内存中不存在类对象及其子类对象。
- 加载此类的类加载器也被回收(运行期间基本不太可能)。
- 类对应的
Class
对象没有在任何地方被引用,即无法使用反制机制获取到该类信息。
同时永久代在物理区域上属于堆内存中的一块,具体的垃圾回收与堆内存中的老年代捆绑在一起。
2.2 元空间
对于Java 8版本的元空间而言,其使用操作系统本地内存,由于其突破了内存限制,几乎不太可能发生OOM
,但是元空间仍然会产生垃圾回收。
在元空间中为每个类加载器分配专门的存储空间,不会单独回收某个类,而是当类加载器被垃圾回收器标记为不再存活,其对应的元空间内存会被全部回收。
元空间的内存管理由元空间虚拟机来完成,采用的形式为组块分配(线性链表),元空间虚拟机维护一个全局的空闲组块列表,当类加载器需要元空间内存的时会被分配一块组块,然后类加载器又将其分为多个小块(自己的空闲组块列表),每一块存储一个单元的元信息。
因此由于元空间的内存分配为组块(线性链表)方式,某一组块被分配给类加载器,大小会被固定,当整个类加载器的内存被回收后,后续只能被分配给同等或者小于此类加载器的内存,存在内存碎片问题。
3、堆区垃圾回收
在堆内存中,主要用来存放创建出来的对象实例,而我们创建出来的对象实例在什么条件下认为可以被回收呢?即当一个对象没有任何的引用指向它了,那这个对象就被认为是垃圾对象。
3.1 对象引用类型
在Java中,对象的引用被分为四种,从高到低分别为:强引用 -> 软引用 -> 弱引用 -> 虚引用。
1)强引用
强引用即最常见的引用对象方式,我们平时编写代码时创建的对象基本都是强引用,例如:
public static void main(String[] args) {
UserInfo user = new UserInfo();
}
垃圾回收器不会回收强引用对象,即使内存不足导致OOM异常,强引用对象也不会被回收。
2)软引用
软引用通过SoftReference
类实现,相对于强引用不会被垃圾回收而言,软引用则是当内存不足的时候,如果对象只存在一个软引用,则会回收此对象。
软引用实现:
public static void main(String[] args) {
UserInfo user = new UserInfo();
// 创建软引用对象,通过构造方法传入user对象
SoftReference userReference = new SoftReference(user);
// SoftReference.get()方法,获取软引用对象指向的实际对象
UserInfo userRef = (UserInfo) userReference.get();
// 是否相等
System.out.println(user == userRef);
}
控制台输出:
true
内存不足时,如果软引用对象指向的实际对象被回收,那么创建的软引用对象自身也应该被回收,那我们应该如何回收软引用对象呢,或者在另外一种场景下,我们想要知道哪些软引用对象已经被回收了呢,应该如何处理?
这里我们观察SoftReference
类源码的另一个构造方法,发现其传入了ReferenceQueue
:
/**
* Creates a new soft reference that refers to the given object and is
* registered with the given queue.
*
* @param referent object the new soft reference will refer to
* @param q the queue with which the reference is to be registered,
* or <tt>null</tt> if registration is not required
*
*/
public SoftReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
this.timestamp = clock;
}
ReferenceQueue
即引用队列,实际上软引用正是采用队列机制实现被回收对象的跟踪及管理。
大致使用流程如下:
- 当创建
SoftReference
软引用对象时,通过构造方法传入ReferenceQueue
引用队列 - 当软引用对象指向的实际对象被回收,软引用对象会被自动添加至引用队列
- 遍历引用队列,跟踪到被回收的实际对象,处理后续逻辑
public static void main(String[] args) {
UserInfo user = new UserInfo();
// 创建引用队列,存储被回收的user对象
ReferenceQueue queue = new ReferenceQueue();
// 创建软引用对象,通过构造方法传入user对象和引用队列
SoftReference userSoftRef = new SoftReference(user, queue);
// 释放软引用对象
releaseSoftRef(queue);
}
public static void releaseSoftRef(ReferenceQueue queue) {
SoftReference ref = null;
while ((ref = (SoftReference) queue.poll()) != null) {
//释放软引用对象
}
}
一般来说软引用常用来实现缓存,这里小豪提供一个采用软引用 + 饿汉式单例模式实现的用户缓存代码,一些小型服务大家可以做适当改造后拿去使用:
用户缓存类:
public class UserInfoCache {
// 创建UserInfoCache缓存类对象
private static UserInfoCache cache = new UserInfoCache();
// Map集合用于缓存User对象的软引用对象(key:用户ID)
private static Map<Integer, UserInfoRef> userInfoRefs;
// 引用队列用于存放被回收的User对象
private static ReferenceQueue<UserInfo> garbageQueue;
// 私有构造方法,禁止外部创建对象
private UserInfoCache() {
userInfoRefs = new HashMap<Integer, UserInfoRef>();
garbageQueue = new ReferenceQueue<UserInfo>();
}
// 对外提供静态方法,获取缓存类实例对象
public static UserInfoCache getInstance() {
return cache;
}
// 创建包装user对象的软引用类,继承SoftReference类
// 定义此软引用对象的唯一标识为用户ID
private static class UserInfoRef extends SoftReference<UserInfo> {
private Integer key;
public UserInfoRef(UserInfo user, ReferenceQueue<UserInfo> q) {
super(user, q);
key = user.getId();
}
}
// 获取user对象,根据用户ID
public static UserInfo getUserById(Integer id) {
UserInfo user = null;
// 判断缓存中是否存在user对象的软引用对象
if (userInfoRefs.containsKey(id)) {
UserInfoRef userRef = userInfoRefs.get(id);
user = userRef.get();
}
// 如果缓存中不存在软引用,则查询数据库重新获取user对象并存放至缓存
if (user == null) {
/*
* 查询数据库
* */
addCache(user);
}
return user;
}
// 缓存user对象
public static void addCache(UserInfo user) {
// 释放软引用对象
cleanCache();
// 创建user对象的软引用对象
UserInfoRef userRef = new UserInfoRef(user, garbageQueue);
// 存放至缓存
userInfoRefs.put(user.getId(), userRef);
}
// 释放软引用对象
private static void cleanCache() {
UserInfoRef userRef = null;
while ((userRef = (UserInfoRef) garbageQueue.poll()) != null) {
userInfoRefs.remove(userRef.key);
}
}
}
用户实体类:
public class UserInfo {
// 主键
private int id;
// 姓名
private String name;
// 年龄
private int age;
}
3)弱引用
弱引用通过WeakReference
类实现,其整体实现机制与软引用类似,相对于软引用而言,无论内存空间是否充足,如果对象只存在一个弱引用,它都会被直接回收
具体实现与软引用对象类似:
public static void main(String[] args) {
UserInfo user = new UserInfo();
// 创建引用队列,存储被回收的user对象
ReferenceQueue queue = new ReferenceQueue();
// 创建弱引用对象WeakReference,通过构造方法传入user对象和引用队列
WeakReference userWeakRef = new WeakReference(user, queue);
// 释放弱引用对象
releaseWeakRef(queue);
}
ThreadLocalMap中
key
的底层实现采用的就是弱引用
4)虚引用
虚引用通过PhantomReference
类实现,不同于软引用和弱引用可以通过get()
方法获取到实际对象,虚引用不能获取到实际对象,主要用于跟踪对象被垃圾回收的状态。
同时虚引用必须和引用队列联合使用,当虚引用的实际对象被回收时,放入引用队列,通过监听引用队列,进行必要的干预操作。
MySQL
驱动使用到虚引用,当我们创建了数据库连接对象connection
时,如果我们操作完数据库忘记调用close()
方法关闭网络连接,则当数据库连接对象connection
被垃圾回收时,会自动执行close()
方法。
测试一下虚引用能否获取到实际对象:
public static void main(String[] args) {
UserInfo user = new UserInfo();
// 创建引用队列,存储被回收的user对象
ReferenceQueue queue = new ReferenceQueue();
// 创建虚引用对象PhantomReference,通过构造方法传入user对象和引用队列
// 虚引用必须传入引用队列ReferenceQueue
PhantomReference userPhantomRef = new PhantomReference(user, queue);
// PhantomReference.get()方法,获取虚引用对象指向的实际对象
UserInfo userRef = (UserInfo) userPhantomRef.get();
// 输出结果
System.out.println(userRef);
}
控制台输出null
,我们发现无法通过虚引用对象的get()
方法获取到实际对象:
null
4、STW
STW(Stop The World)机制指的是垃圾回收GC事件发生过程中,Java程序会产生停顿。
停顿产生时整个应用程序线程都会被暂停,没有任何响应,类似于卡死的感觉,这个停顿称为STW。
三、垃圾判定算法
介绍完了四种对象引用,那JVM如何判断堆内存中的对象有没有被引用呢?
JVM实际上采用可达性分析法甄别对象是否存在引用。
当然,在早期版本JVM采用的是引用计数法,不过引用计数法存在明显的缺点,已被淘汰
1、引用计数法
引用计数法就是当一个对象被创建后,系统给它设置一个引用计数器,这个对象每被引用一次,计数器就+1
,每被释放一次,计数器就-1
,当计数器为0
的时候,就认为这个对象没有被使用了,可以进行垃圾回收了。
举个例子:
public class UserInfo {
public UserInfo bestFriend;
public static void main(String[] args) {
// 创建对象A
UserInfo userA = new UserInfo();
// 创建对象A
UserInfo userB = new UserInfo();
// 设置对象A的bestFriend为对象B
userA.bestFriend = userB;
// 设置对象B的bestFriend为对象A
userB.bestFriend = userA;
}
}
此时,在堆内存中,对象A
和对象B
的引用计数器的值均为2
引用计数法判定比较简单,但是存在致命的缺点,在JDK1.2版本之后已被淘汰:
- 由于引用计数法需为对象设置引用计数器,且每次对象被引用和释放都需要更新计数,如果频繁更新,势必会降低运行效率
- 引用计数法没法避免循环引用,比如当对象
A
、B
相互引用对方时,导致引用计数器不会被置为0
,无法被垃圾回收
当userA
和userB
分别置为null
:
userA = null;
userB = null;
此时A
、B
实例对象虽然在栈上已经没有变量引用了,但是由于其自身属性相互引用对方,存在循环引用,引用计数器不为0
,仍无法被回收
2、可达性分析法
可达性分析法将所有对象分为两类:根对象(GC Root)和普通对象。
其核心思想为:GC Root
根对象作为起始根节点,从它开始根据引用关系一直向下搜索,把它搜索过程走过的路段叫作引用链,如果一个普通对象和GC Root
根对象之间没有任何的引用链连接(直接连接或间接连接),就认为它不可达,可以作为垃圾被回收。
同样的,可达性分析法虽解决了对象的循环引用,但也存在缺点:
- 可达性分析法需要遍历对象之间的引用关系,消耗一定的CPU计算资源
那在我们Java程序中,哪些对象可以作为GC Root根对象呢?
- 虚拟机栈中引用的对象,各个线程栈帧中的方法参数、局部变量等
- 元空间(方法区)中类的静态变量引用的对象,类中的静态变量
- 监视器对象,所有被同步锁
synchronized
关键字持有的对象 - 本地方法栈中JNI引用的对象,即本地
Native
方法
四、垃圾回收算法
上面我们了解到JVM如何定义垃圾对象,同时介绍了可达性分析法如何甄别对象是否存在引用。那识别到垃圾对象后,JVM是如何实现垃圾回收的呢。
本节将介绍四种垃圾回收算法,分别是标记清除算法、复制算法、标记整理算法和分代垃圾回收算法。
1、标记清除算法
标记清除算法是实现最简单的收集算法,其核心思想分为两个阶段:标记阶段和清除阶段。
- 标记:标记阶段通过可达性分析法从
GC Root
根节点开始遍历,根据引用链标记所有被引用的存活对象 - 清除:清除阶段从堆内存从头到尾进行线性遍历,识别到某个对象没有被标记为可达(垃圾对象),则将其进行回收
标记清除算法也存在显著的缺点:
- 内存碎片产生:标记清除后内存中会产生大量不连续的内存碎片,当需要分配较大的内存空间时,小内存块可能无法满足需求,从而再次触发垃圾收集,导致内存浪费
- 运行效率一般:当较多对象需被回收时,进行大量的标记和清除过程,耗时较长,同时由于内存碎片被维护于一个空闲链表,在分配较大内存时,需线性的遍历链表,分配速度较慢
2、复制算法
复制算法的核心思想是将原有的内存空间一分为二,每次只用其中的一块。垃圾回收的时候把存活的对象全部复制到另一块,然后把当前这一块清空,再交换这两块的角色。
采用复制算法垃圾回收时,首先将GC Root
根对象移动到另一块空闲内存,根据引用链,将GC Root
关联的普通对象也移动到空闲内存。
复制算法解决了标记清除算法产生的内存碎片问题,同时性能也较好,只需要遍历一次找到被引用的存活对象并将其复制到另一块空内存空间,运行效率比较高效。
但是很明显,复制算法也存在相应缺点:
- 内存使用效率低:复制算法最直观的缺点就是浪费内存,复制算法每次只用其中的一块,会浪费另一半的内存空间,内存空间利用率低
- 存活对象较多时复制效率低:当较多对象存活时,需要复制的次数过多,开销也比较大
3、标记整理算法
标记整理算法是基于标记清除算法的基础上,解决其产生内存碎片的缺陷,其核心思想也分为两个阶段:标记阶段和整理阶段。
- 标记阶段:标记阶段同标记清除算法的标记阶段一致,通过可达性分析法从
GC Root
根节点开始遍历,根据引用链标记所有被引用的存活对象 - 整理阶段:整理阶段将所有被标记的存活对象移动到堆内存的另外一端,然后直接清理存活内存以外的内存空间
标记整理算法相较于复制算法,解决了空间利用率低的问题,也解决了标记清除算法产生的内存碎片问题,但是仍没有解决存活对象较多时复制移动效率低的问题,同时由于其既需要标记又需要整理移动,效率相较于复制算法或标记清除算法较低。
4、分代垃圾回收算法
分代垃圾回收算法针对不同存活时间的对象采用不同的垃圾回收算法,其将整个内存区域划分为年轻代和老年代,管理不同存活时间的对象。
本质上根据对象的存活时间划分为不同的区域,根据各个区域的特点使用不同的回收算法,具体采用哪个算法取决于垃圾回收器的实现
- 年轻代:
- 年轻代又被划分为三部分,Eden伊甸园区和两个大小相同的Survivor幸存者区(
From
和To
),默认比例为8:1:1
- 存放存活时间比较短的对象,回收频繁
- 内存区域较小
- 年轻代又被划分为三部分,Eden伊甸园区和两个大小相同的Survivor幸存者区(
- 老年代:
- 存放存活时间比较长的对象,回收不频繁
- 内存区域较大
分代收集具体过程如下:
- 新创建的对象,都会先分配到伊甸园区
- 当伊甸园内存不足,触发Minor GC,将伊甸园与幸存者
From
区(现阶段没有)的存活对象复制到另一个幸存者To
区(存活的对象在每次Minor GC
后年龄值+1
,当年龄达到某个阈值,部分垃圾回收器为15
,直接晋升至老年代) - 复制完毕后,释放伊甸园和幸存者
From
区内存 - 交换幸存者
From
区和幸存者To
区的角色,此时依然是幸存者To
区内存为空 - 经过一段时间后伊甸园的内存又出现不足,再次重复此过程
- 当幸存者
To
区内存不足以存放伊甸园和幸存者From
区的存活对象时(或大对象),会移动至老年代 - 当老年代内存不足时,触发Full GC,对整个堆进行垃圾回收(包括年轻代和老年代)
- Minor GC:发生在年轻代的垃圾回收,暂停时间短(
STW
短)- Full GC:年轻代+老年代完整垃圾回收,暂停时间长(
STW
长),应尽量避免
显然,分代垃圾回收算法将堆分为年轻代和老年代存在显著优势:
- 由于大部分Java对象存活时间都很短,通过快速的
Minor GC
来回收大部分内存,STW
时间相对减少,提高了程序的性能 - 各区域自主分配内存比例和垃圾回收算法,适应不同类型的程序,提高了程序的灵活性
五、后记
本文从线程共享区域的垃圾回收介绍开始,逐步讲解了两种垃圾判定算法、四种垃圾回收算法的核心思想与优略,其中额外扩展了Java中的四种对象引用类型及应用场景,相信经过本文,大家已经对JVM垃圾回收有了充分认知。
JVM的垃圾回收机制作为重要的内存管理机制,也是Java程序稳定性和性能的关键,本篇介绍的多种垃圾回收算法和策略,其各有优缺点,但本质上也是空间换时间、时间换空间,似乎很难做到平衡,当然评判一个垃圾回收算法的标准不仅仅局限于吞吐量和空间利用率,还应结合实际业务情况综合考虑。
下一篇,小豪将会更新常见JVM垃圾回收器知识体系,如果大家觉得内容还不错,可以先点点关注,共同进步~