深入理解 JVM 垃圾回收机制 - 引用类型

发布于: 2020 年 06 月 24 日
深入理解JVM垃圾回收机制 - 引用类型

在方法中,通过new关键字来创建对象时,JVM会在堆中开辟空间存放对象实例数据,这时,定义的局部变量仍存储在栈中,它包含指向堆中对象的指针 ( 即对象在堆内存的起始地址索引 ),而不是对象本身,这个指针被称为引用。

这里可能会造成大家的误解:指针 = 引用,但实际上它们并不完全相同。当我们一提到指针,就很容易把指针与C语言中的指针划等号,实际上,"指针"是一个概念上的东西,不同的语言有不同的实现。从概念上来说:指针就是一个值,而这个值是某块内存的地址,通过这个值,就可以找到这块内存。

C语言中的指针,可以指向内存中的任何地方,也可以参与运算,甚至还可以指向指针。因此,更多时候它只反映了指针的寻址特性。

虽然Java中的引用也是一个值,其包含有对象的内存地址,但它关心的是对象,而不是地址。当对象搬家以后(内存整理),这个引用也会跟着改变。因此,Java引用类型可以认为是一个封装后的指针,它屏蔽了指针的复杂性。

在这个定义下,一个对象只有被引用和没有被引用两种状态,要么有指针指向这个对象,要么没有。当对象没有被引用的时候,就会被JVM回收,但这种设计稍显死板,并不能很好的满足某些应用场景。比如缓存,由于缓存的对象一直被引用,JVM永远不会回收这些对象,极端情况下,缓存可能会耗尽内存。如果在内存紧张时,JVM可以主动回收某些对象就好了,这样就不至于让缓存耗尽内存。

因此,在JDK1.2以后,Java对引用的概念进行扩充,将引用分为:强引用、软引用、弱引用和虚引用四种。通过这四种引用类型来满足个性化的应用场景,比如前面提到的缓存,就可以使用软引用来解决。那如何理解这四种引用类型,它们之间有何区别?具体的应用场景又有哪些?

一、对象的生命周期

1.1 Finalizer机制

在Java中,当对象处于没有被引用的状态下,一段时间后,其内存将被垃圾收集器回收。然而,内存并不是唯一需要清理并回收的资源。比如,当创建一个FileOutputStream对象时,会从操作系统分配一个文件句柄,用于文件操作 (文件句柄属于OS资源)。当这个流不在被引用且即将关闭时,这个文件句柄会发生什么?答案就在finalize()中,这个方法会在垃圾收集器回收对象之前由JVM来调用。

FileOutputStream对象中,finalize()会关闭文件输出流、释放句柄、刷新缓冲区以确保所有的数据被正确的写入磁盘文件。 需要注意的是,在最新的JDK版本中,比如JDK13中,FileOutputStream类的finalize方法已被删除。因为这些释放工作其实应该由开发者在GC之前就处理好,而不是等到GC时由JVM来处理。

protected void finalize() throws IOException {
if (fd != null) {
if (fd == FileDescriptor.out || fd == FileDescriptor.err) {
flush();
} else {
close();
}
}
}

所有的对象都可以有一个finalizer,你仅需要在对象中定义如下的finalize方法:

protected void finalize() throws IOException {
// 清理垃圾、释放资源的代码写在这儿
}

在日常开发中,很多地方都需要做一些清理的工作,finalize()看起来是一个不错的方法,但我们却很少使用它,甚至在业界,finalize()也被证明是一种非常不好的实践,主要是因为:

  • 不能控制finalize()的调用时机,甚至很多时候,JVM根本就不会调用这个方法。虽然可以通过调用System.runFinalization()告诉JVM更积极地执行finalize方法,但调用时机仍是不可预测的。

  • finalize()方法的执行和GC关联在一起,一旦实现了非空的finalize方法,JVM就要对它进行额外的处理,这样它就变成了快速回收的阻碍者,有可能导致对象需要经过多次GC才能被回收。

  • 一般来说,需要执行清理工作的对象都是消耗资源的大户,而finalize拖慢垃圾回收,严重的情况下会导致OOM。

1.2 对象的可达性

一个对象的生命周期可以简单的概括为:对象首先被创建,然后初始化,被使用,接着没有其他对象引用它(不可达),这时候,它可以被垃圾收集器回收,最终被回收掉。其整个生命周期如下图所示,其中,阴影部分便是该对象的强可达 阶段。

从JDK1.2起,Java对对象的生命周期进行了扩充,除了强可达阶段 (如上图所示),另增加了3个新阶段:软可达弱可达虚可达(幻象可达)

当一个对象可以通过普通引用链 (不包括引用对象,即软/弱/虚引用对象) 从根集访问时,则表示该对象强可达,就如下图中的A对象。延伸开来,如果达到对象的唯一路径上涉及至少一个软 ( 或弱 ) 引用对象,则称该对象是软 ( 或弱 ) 可达。最后,虚可达对象就是没有强、软、弱引用关联,并且 finalize 过了,只有幻象引用指向它的这类对象。这个定义稍微有点绕,下面的示意图应该可以帮助你理解。

图中A对象,虽然它被弱引用对象B引用,但由于根集直接引用它,因此根集到A对象强可达,同样地,即使BC本身是弱引用对象,但根集可直接达到,所以,根集到对象B、C也是强可达。而对象D、E直接或间接地仅被弱引用对象引用,因此根集到D、E弱可达。

对象的几种可达性状态可以按照如下示意图进行流转,图中有些地方是双向箭头,这意味着,我们可以人为地改变对象的可达性状态。具体的原因,我们会在下文详细说明。

二、四种引用类型及其应用场景

前面我们已经提到的这4种引用类型之间的区别主要体现在对象的不同可达性级别以及对垃圾收集器的影响。因而,引用可以认为是Java提供的一种人为干预GC的手段。

2.1 强引用 (Strong Reference)

所谓强引用,就是我们最常见的普通对象引用,类似Object obj = new Object()这类的引用,只要还有强引用指向一个对象,垃圾收集器永远不会回收这个对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应的(强)引用赋值为 null,就是可以被JVM回收的,当然具体回收时机还是要看垃圾收集策略。来看一个简单的例子:

Class StrongReferenceObject {
// code here
}
public Class Example {
public static void main(String[] args) {
StrongReferenceObject obj = new StrongReferenceObject();
// obj指向的对象已经可以被GC回收
obj = null;
}
}

代码中obj为强引用,指向堆中一个StrongReferenceObject类型的实例对象,当给obj赋值为null以后,让这个强引用指向null,那么原来堆中的实例对象即可以被GC回收,简单的内存示意图如下所示。

有些优化建议:不使用的对象应手动赋值为null,有利于GC更早回收内存,减少内存占用。现在我们来讨论下,这条优化建议是否合理?

首先,请思考两个问题,内存回收的早晚,对应用的影响很大吗?即使赋值为null,内存就立即被回收了吗?

其次,强引用obj是在栈空间中分配内存,当方法执行完成后,栈帧弹出,所占用的内存被回收,这时候已经没有引用指向堆中的对象。

综上,绝大多数时候方法的执行都耗时很短,因此赋值为null到底又能够把GC回收的时间点提前多少呢?相信大家会有自己的答案,当然,这里并不是说这条建议毫无意义,只是希望大家可以思考其应用场景,更不用把它当做一条普遍适应的优化建议。

2.2 软引用 (Soft Reference)

软引用一般用于描述一些有用但非必需的对象,它相对强引用来说,引用的关系更弱一些。当JVM认为内存不足时,才会去尝试回收软引用指向的对象,如果回收以后,还没有足够的内存,才会抛出内存溢出错误。因此,JVM会确保在抛出内存溢出错误之前,回收软引用指向的对象。

软引用通常用来实现内存敏感的缓存,当有足够内存时,保留缓存,反之则清理掉部分缓存,这样在使用缓存的同时,尽量避免耗尽内存。来看一个简单的示例:

class SoftRefObject {
public void m() {
System.out.println("I'm Soft Reference Object");
}
}
public class Example {
public static void main(String[] args) {
// 强引用
SoftRefObject obj = new SoftRefObject();
obj.m();
// 创建一个软引用指向SoftRefObject类型的实例对象'obj'
SoftReference<SoftRefObject> softRef = new SoftReference<SoftRefObject>(obj);
// 去掉 SoftRefObject 对象上面的强引用,这时,对象可以被回收
obj = null;
// 返回弱引用指向的对象,对象的可达性状态发生改变
obj = softRef.get();
if (obj != null) {
obj.m();
}
}
}

在内存足够的情况下,以上程序将输出:

I'm Soft Reference Object
I'm Soft Reference Object

来看代码,首先创建一个强引用obj指向堆中一个SoftRefObject实例对象,然后我们创建一个软引用,软引用中的referent指向堆中的SoftRefObject实例,其内存结构示意图如下图所示。

当我们去掉强引用时,这时候obj指向的对象是可以被内存回收的,当内存充足时,我们可以通过get()方法,得到堆中的实例对象,其内存示意图如下图所示。

所有引用类型,都是抽象类 java.lang.ref.Reference的子类,它提供了get() 方法,其部分代码如下所示。其中referent指向具体的实例对象,因此,如果referent指向的对象还没有被回收,都可以通过 get 方法获取原有对象。这意味着,利用软引用 (弱引用也类似,下文不再说明),我们可以将访问到的对象,重新指向强引用(obj=softRef.get()),也就是人为的改变了对象的可达性状态。

public abstract class Reference<T> {
private T referent;
public T get() {
return this.referent;
}
}

当软引用对象被JVM标记为可回收状态时,仍然可以通过get方法,让其重新被强引用关联,这时候就需要JVM进行第二次确认,以确保正在使用的对象不会被回收,这也是部分对象真正死亡至少需要经历两次标记的原因 (相关内容可参考 深入理解 JVM 垃圾回收机制 - 何为垃圾?)。

2.3 软引用的内存回收策略

软引用的内存结构示意图如下所示。这种情况下,虽然堆中对应的实例对象已经没有强引用指向它,但softRef作为强引用指向referent,而referent则指向SoftRefObject type object,看起来堆中的对象还是被强引用关联着,如下图所示,JVM到底是如何回收这部分内存的呢?

JVM在进行垃圾回收的时候,首先会遍历引用列表,判断列表中每个软引用中的referent是否存活 (存活的条件是referent指向的对象不为空且被GC Roots可达),比如前面的例子中,如果obj还指向SoftRefObject的话,则说明SoftRefObject还活着,JVM不会对其作任何处理。而如果已经没有其它对象引用SoftRefObject,就如上图所示,表示该对象已死,JVM会尝试回收该对象。

默认情况下,当内存不足时,如果软引用的存活时间到达一定时长就会被回收,这个时间限制可以通过参数 SoftRefLRUPolicyMSPerMB 来设置,具体我们来看下源码:

// 源码来源于openjdk-jdk8u: hotspot/src/share/memory/referenceProcessor.cpp
// 软引用回收策略的选择
// 注意:调用此方法时,always_clear = false,即总是使用默认的策略
ReferencePolicy* setup_policy(bool always_clear) {
_current_soft_ref_policy = always_clear ? _always_clear_soft_ref_policy : _default_soft_ref_policy;
_current_soft_ref_policy->setup();
return _current_soft_ref_policy;
}
// 默认策略的定义:在 LRUMaxHeapPolicy 和 LRUCurrentHeapPolicy 两种策略中任选其一
// openJdk中对Compiler1和Compiler2作了解释,简单的说:
// Compiler1对应Client JVM
// Compiler2对应Server JVM
_default_soft_ref_policy = new COMPILER2_PRESENT(LRUMaxHeapPolicy())
NOT_COMPILER2(LRUCurrentHeapPolicy());

简单说来,在Client VM模式下,使用LRUCurrentHeapPolicy策略,而Server VM模式下使用LRUMaxHeapPolicy策略,这两种策略的区别在其setup函数中:

void LRUMaxHeapPolicy::setup() {
// 设置的最大堆内存
size_t max_heap = MaxHeapSize;
// 最大堆内存 - 上次GC后已使用的堆内存
max_heap -= Universe::get_heap_used_at_last_gc();
// 单位换算 = ?M
max_heap /= M;
// _max_interval = max_heap * (VM参数:-XX:SoftRefLRUPolicyMSPerMB的值)
_max_interval = max_heap * SoftRefLRUPolicyMSPerMB;
assert(_max_interval >= 0,"Sanity check");
}
void LRUCurrentHeapPolicy::setup() {
// 上次GC后空闲堆内存或者说当前可用的堆内存(单位换算) * (VM参数:-XX:SoftRefLRUPolicyMSPerMB的值)
_max_interval = (Universe::get_heap_free_at_last_gc() / M) * SoftRefLRUPolicyMSPerMB;
assert(_max_interval >= 0,"Sanity check");
}

主要的区别也就是计算当前可用堆内存的方式不同,得到这个内存大小后,再乘以SoftRefLRUPolicyMSPerMB后就得到了软引用可存活的最大时间。如果软引用存活时间大于_max_interval则会被回收,反之则不会,其实现如下所示。

should_clear_reference(oop p, jlong timestamp_clock) {
// java_lang_ref_SoftReference返回该引用对象上次执行get方法的时间点,
// 如果没有执行过get方法,就是初始化的时间点
jlong interval = timestamp_clock - java_lang_ref_SoftReference::timestamp(p);
if(interval <= _max_interval) {
return false;
}
return true;
}

代码中 interval 表示该对象从上次GC后存活时间,即用当前时间 - 上次GC时间。而上次GC时间是如何计算出来的?那就得回到 SoftReference 的定义,重点关注clocktimestamp两个属性。

public class SoftReference<T> extends Reference<T> {
// JVM在每次GC时会更新这个值
static private long clock;
private long timestamp;
public T get() {
T o = super.get();
if (o != null && this.timestamp != clock)
this.timestamp = clock;
return o;
}
}

JVM在每次GC时会更新clock,而在调用get方法时也会更新timestamp的值为clock。如果对象的存活时间大于_max_interval,说明这个软引用已经被废弃足够长的时间,认为是可以被回收的,这也跟策略名称中的LRU相吻合。

最后总结下,-XX:SoftRefLRUPolicyMSPerMB可以影响软引用的存活时间,在其他因素不变的情况下,VM参数的值越大,软引用对象存活越久,同样地,如果应用已使用堆内存不变的情况下,设置的堆内存越大,软引用对象也存活的更久。而如果想要在内存不足时回收所有软引用,则把SoftRefLRUPolicyMSPerMB设置为0即可。

2.4 软引用的应用场景

前面我们提到过,可以利用软引用来实现缓存,比如一些图片缓存框架中,均大量使用到软引用。由于软引用和弱引用均可以应用在缓存的实现,因而,具体的实现原理我放在下文弱引用的部分详细说明。这里我们介绍软引用的另外一个应用场景:内存熔断。

熔断机制来源于电力行业,当电流超过规定值时,产生的热量使溶体熔断,断开电路以达到保护电路的目的。在分布式系统中也大量运用熔断机制,以实现快速失败,防止服务间调用的雪崩效应。而这里所讲的内存熔断也类似,当应用大量使用内存时,容易造成内存溢出错误,甚至程序崩溃,这种情况下,可以使用软引用来避免OutOfMemoryError,以实现自我保护的目的。

回想刚开始学习JDBC的时候,你一定写过如下代码,它使用一个通用的方法处理ResultSet并返回一个List<Map>

// 去掉资源关闭,异常处理等细节
public static List<Map<String, Object>> processResults(ResultSet rs) {
List<Map<String, Object>> list = Lists.newArrayList();
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
while (rs.next()) {
Map<String, Object> map = new HashMap<String, Object>();
// 每行数据放入一个map中
for (int i = 0; i < colCount; i++) {
map.put(meta.getColumnName(i), rs.getObject(i));
}
list.add(map);
}
}

这段代码在大部分情况下,都能很好的运行,但它有一个小的缺陷:如果查询返回一百万行而你没有可用内存来存储它们会发生什么?

现在使用软引用在完善上面这段代码:

// 去掉资源关闭,异常处理等细节
public static List<Map<String, Object>> processResults(ResultSet rs) {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
// 软引用指向最终返回的List
SoftReference<List<Map<String, Object>>> ref = new SoftReference<>(new LinkedList<Map<String, Object>>());
while (rs.next()) {
Map<String, Object> map = new HashMap<>();
for (int i = 0; i < colCount; i++) {
map.put(meta.getColumnName(i), rs.getObject(i));
}
// 如果List已经被回收,那么说明内存不足,直接返回自定义的异常通知上层服务
List<Map<String, Object>> result = ref.get();
if (result == null) {
throw new TooManyResultsException();
} else {
result.add(map);
}
}
return ref.get();
}

需要注意的是,ResultSet并不会直接获取所有的查询结果,一般会通过fetchSize方法来设置每次返回的行数。

而整个过程中,内存分配都集中在两个地方:调用next()和将行数据存储在自己的列表中。调用next()时,ResultSet通常会检索包含多行数据库数据的大块二进制数据,以判断数据是否取完。当需要存储数据时,调用getObject()方法提取数据并包装成Java对象,然后在扔到列表中。

当进行这些操作的时候,如果发现内存不足,GC会回收列表占用的内存,这时候再通过软引用获取列表对象,得到的就是null。当上层捕获到自定义异常时,可以进行相关处理:再次检索或者减少获取数据的行数。需要注意的是,这里的列表使用的是LinkedList而不是ArrayList,这是因为ArrayList在扩容的时候会创建新的数组,占用更多的内存。

最后,这仅是一个示例而已,提供另外一个应用场景和解决问题的思路,并不是建议大家在操作JDBC时要使用软引用。

2.5 弱引用 (Weak Reference)

弱引用的强度比软引用更弱一些,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。它一般用于维护一种非强制的映射关系,如果获取的对象还在,就是用它,否则就重新实例化,因此,很多缓存框架均基于它来实现。

弱引用的内存结果示意图与软引用类似,这里就不使用示例代码来说明,具体可以参考前面的内容。

2.6 弱引用的应用场景

弱引用的一个应用场景就是缓存,经常使用的数据结构为WeakHashMap。WeakHashMap与其他Map最主要的区别在于它的Key是弱引用类型,每次GC时,WeakHashMap的Key均会被回收,而后,其Value也会被回收,简单的看下其具体的实现:

// 构造方法
public WeakHashMap(int initialCapacity, float loadFactor) {
table = newTable(capacity);
}
// newTable的实现
private Entry<K,V>[] newTable(int n) {
return (Entry<K,V>[]) new Entry<?,?>[n];
}
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
V value;
final int hash;
Entry<K,V> next;
Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) {
// 调用WeakReference的构造方法,并传入Map的Key和引用队列
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
// other code here ...
}

通过源码可以知道,在构造WeakHashMap的Entry时,会将key关联到一个弱引用上,GC发生时,Map的Key会被清理掉,但Map的Value仍然是强引用,但它随后会在WeakHashMap的expungeStaleEntries()方法中被移除数组(Entry[]),这时Value关联的强应用被干掉,即处于可回收状态。

由于本文不是专门分析WeakHashMap源码的文章,因此,对于WeakHashMap的实现,点到即止,在这儿只需要理解WeakHashMap的Key会在GC时被回收,进而回收其对应的Value。关于WeakHashMap源码的文章,大家可以自行搜索,这里推荐: Java WeakHashMap 源码解析,推荐它,主要是因为文章对为什么要使用引用队列ReferenceQueue讲得很透彻。

WeakHashMap中缓存的数据其实活不了多久,特别是GC非常频繁的场景下,没两下,缓存的数据就没了,又得重新加载,那它作为缓存的意义何在?如果单纯作为缓存的话,SoftHashMap(表示软引用的HashMap,实际并不存在这样一个类)貌似更合适一些,毕竟当内存够用时,并不希望缓存被GC掉。但WeakHashMap可应用在更为复杂的场景,比如下面的代码:

// 代码来自于:org.apache.tomcat.util.collections.ConcurrentCache.java
public final class ConcurrentCache<K,V> {
private final int size;
private final Map<K,V> eden;
private final Map<K,V> longterm;
public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}
// 先从ConcurrentHashMap中取值,取不到就在WeakHashMap中取值
public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
synchronized (longterm) {
v = this.longterm.get(k);
}
// 如果在WeakHashMap取到值以后,在放入ConcurrentHashMap中
if (v != null) {
this.eden.put(k, v);
}
}
return v;
}
// 如果ConcurrentHashMap已满,则把所有的数据放到WeakHashMap中,并清空自己
public void put(K k, V v) {
if (this.eden.size() >= size) {
synchronized (longterm) {
this.longterm.putAll(this.eden);
}
this.eden.clear();
}
// 如果ConcurrentHashMap未满,直接放入ConcurrentHashMap中
this.eden.put(k, v);
}
}

你看懂这段代码了吗?是不是跟JVM堆的划分有一点点相似?这其实是一个热点缓存的实现方案,一段时间以后,经常使用的缓存就会在eden这个Map中,而不常用的缓存就会逐渐被清理。在以前,如果让你来设计热点缓存的实现方案,可能会想很多方案,但不可避免的,会写非常多的代码,但使用 WeakHashMap,就变得非常的简单。

WeakHashMap 的另外一个应用场景就是 ThreadLocal,考虑篇幅这里就不再讲解,大家可以参考:这才是 Thread Local 的正确原理与适用场景。但文中没有考虑到的一个场景:在线程池下的ThreadLocal确实存在内存泄漏。

2.7 虚引用 (Phantom Reference)

虚引用也被称为幽灵引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。也就是说,通过其 get() 方法得到的对象永远是 null。

那虚引用到底有什么作用?其实虚引用主要被用来跟踪对象被垃圾回收的状态,当目标对象被回收之前,它的引用会被放入一个 ReferenceQueue 对象中,通过查看引用队列中是否包含对象所对应的虚引用来判断它是否即将被垃圾回收,从而采取行动。因此,在创建虚引用的时候,它必须传入一个 ReferenceQueue 对象,比如:

public static void main(String[] args) {
ReferenceQueue<String> refQueue = new ReferenceQueue<String>();
PhantomReference<String> referent = new PhantomReference<String>(new String("CSC"), refQueue);
// null
System.out.println(referent.get());
System.gc();
System.runFinalization();
//true
System.out.println(refQueue.poll() == referent);
}

另外值得注意的是,其实 SoftReference, WeakReference 以及 PhantomReference 的构造函数都可以接收一个 ReferenceQueue 对象。当 SoftReference 以及 WeakReference 被清空的同时,也就是JVM准备对它们所指向的对象进行回收时,调用对象的 finalize() 方法之前,它们自身会被加入到这个 ReferenceQueue 对象中,此时可以通过 ReferenceQueue 的 poll() 方法取到它们。而 PhantomReference 只有当 Java 垃圾回收器对其所指向的对象真正进行回收时,会将其加入到这个 ReferenceQueue 对象中,这样就可以追综对象的销毁情况。

最后

限于篇幅和侧重点的原因,本文在介绍应用场景的时候,主要着眼于 SoftReferenceWeakReference ,这也是日常开发应用非常广泛的两种引用类型,而对于虚引用的应用场景并未作过多的说明,如果你感兴趣的话,可以阅读 Java Reference Objects 的最后一个小节,它使用虚引用定制了一个资源收集器用于在释放数据库连接的同时,释放占用的资源。

关于引用,还有一个重要的知识点没有涉及,就是引用队列 ReferenceQueue,网上有大量关于引用队列的内容,可自行查阅。

深入理解JVM系列的第9篇,完整目录请移步:深入理解JVM系列文章目录

封面图:Andrew Pons

参考资料

  1. Java Reference Objects

  2. Types of References in Java

  3. Java软引用究竟什么时候被回收

发布于: 2020 年 06 月 24 日 阅读数: 25
用户头像

CHEN川

关注

Because, I love. 2017.10.16 加入

这里本来应该有简介的,但我还没想好。

评论

发布
暂无评论
深入理解JVM垃圾回收机制 - 引用类型