Categories
程式開發

Java Reference核心原理分析


帶著問題,看源碼針對性會更強一點、印象會更深刻、並且效果也會更好。所以我先賣個關子,提兩個問題(沒準下次跳槽時就被問到)。

我們可以用ByteBuffer的allocateDirect方法,申請一塊堆外內存創建一個DirectByteBuffer對象,然後利用它去操作堆外內存。這些申請完的堆外內存,我們可以回收嗎?可以的話是通過什麼樣的機制回收的?大家應該都知道WeakHashMap可以用來實現內存相對敏感的本地緩存,為什麼WeakHashMap合適這種業務場景,其內部實現會做什麼特殊處理呢?

GC可到達性與JDK中Reference類型

上面提到的兩個問題,其答案都在JDK的Reference裡面。 JDK早期版本中並沒有Reference相關的類,這導致對像被GC回收後如果想做一些額外的清理工作(比如socket、堆外內存等)是無法實現的,同樣如果想要根據堆內存的實際使用情況決定要不要去清理一些內存敏感的對像也是法實現的。為此JDK1.2中引入的Reference相關的類,即今天要介紹的Reference、SoftReference、WeakReference、PhantomReference,還有與之相關的Cleaner、ReferenceQueue、ReferenceHandler等。與Reference相關核心類基本都在java.lang.ref包下面。其類關係如下:

Java Reference核心原理分析 1

其中,SoftReference代表軟引用對象,垃圾回收器會根據內存需求酌情回收軟引用指向的對象。普通的GC並不會回收軟引用,只有在即將OOM的時候(也就是最後一次Full GC)如果被引用的對像只有SoftReference指向的引用,才會回收。 WeakReference代表弱引用對象,當發生GC時,如果被引用的對像只有WeakReference指向的引用,就會被回收。 PhantomReference代表虛引用對象(也有叫幻象引用的,個人認為還是虛引用更加貼切),其是一種特殊的引用類型,不能通過虛引用獲取到其關聯的對象,但當GC時如果其引用的對象被回收,這個事件程序可以感知,這樣我們可以做相應的處理。最後就是最常見強引用對象,也就是通常我們new出來的對象。在繼續介紹Reference相關類的源碼前,先來簡單的看一下GC如何決定一個對像是否可被回收。其基本思路是從GC Root開始向下搜索,如果對象與GC Root之間存在引用鏈,則對像是可達的,GC會根據是否可到達與可到達性決定對像是否可以被回收。而對象的可達性與引用類型密切相關,對象的可到達性可分為5種。

強可到達,如果從GC Root搜索後,發現對象與GC Root之間存在強引用鏈則為強可到達。強引用鏈即有強引用對象,引用了該對象。

軟可到達,如果從GC Root搜索後,發現對象與GC Root之間不存在強引用鏈,但存在軟引用鏈,則為軟可到達。軟引用鏈即有軟引用對象,引用了該對象。

弱可到達,如果從GC Root搜索後,發現對象與GC Root之間不存在強引用鏈與軟引用鏈,但有弱引用鏈,則為弱可到達。弱引用鏈即有弱引用對象,引用了該對象。

虛可到達,如果從GC Root搜索後,發現對象與GC Root之間只存在虛引用鏈則為虛可到達。虛引用鏈即有虛引用對象,引用了該對象。

不可達,如果從GC Root搜索後,找不到對象與GC Root之間的引用鏈,則為不可到達。

看一個簡單的列子:

Java Reference核心原理分析 2

ObjectA為強可到達,ObjectB也為強可到達,雖然ObjectB對像被SoftReference ObjcetE 引用但由於其還被ObjectA引用所以為強可到達;而ObjectC和ObjectD為弱引用達到,雖然ObjectD對像被PhantomReference ObjcetG引用但由於其還被ObjectC引用,而ObjectC又為弱引用達到,所以ObjectD為弱引用達到;而ObjectH與ObjectI是不可到達。引用鏈的強弱有關係依次是強引用> 軟引用> 弱引用> 虛引用,如果有更強的引用關係存在,那麼引用鏈到達性,將由更強的引用有關係決定。

Reference核心處理流程

JVM在GC時如果當前對像只被Reference對象引用,JVM會根據Reference具體類型與堆內存的使用情況決定是否把對應的Reference對象加入到一個由Reference構成的pending鍊錶上,如果能加入pending鍊錶JVM同時會通知ReferenceHandler線程進行處理。 ReferenceHandler線程是在Reference類被初始化時調用的,其是一個守護進程並且擁有最高的優先級。 Reference類靜態初始化塊代碼如下:

static {
//省略部分代码...
Thread handler = new ReferenceHandler(tg, "Reference Handler");
handler.setPriority(Thread.MAX_PRIORITY);
handler.setDaemon(true);
handler.start();
//省略部分代码...
}

而ReferenceHandler線程內部的run方法會不斷地從Reference構成的pending鍊錶上獲取Reference對象,如果能獲取則根據Reference的具體類型進行不同的處理,不能則調用wait方法等待GC回收對象處理pending鍊錶的通知。 ReferenceHandler線程run方法源碼:

public void run() {
//死循环,线程启动后会一直运行
while (true) {
tryHandlePending(true);
}
}

run內部調用的tryHandlePending源碼:

static boolean tryHandlePending(boolean waitForNotify) {
Reference r;
Cleaner c;
try {
synchronized (lock) {
if (pending != null) {
r = pending;
//instanceof 可能会抛出OOME,所以在将r从pending链上断开前,做这个处理
c = r instanceof Cleaner ? (Cleaner) r : null;
//将将r从pending链上断开
pending = r.discovered;
r.discovered = null;
} else {
//等待CG后的通知
if (waitForNotify) {
lock.wait();
}
//重试
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
//当抛出OOME时,放弃CPU的运行时间,这样有希望收回一些存活的引用并且GC能回收部分空间。同时能避免频繁地自旋重试,导致连续的OOME异常
Thread.yield();
//重试
return true;
} catch (InterruptedException x) {
//重试
return true;
}
//如果是Cleaner类型的Reference调用其clean方法并退出
if (c != null) {
c.clean();
return true;
}
ReferenceQueue q = r.queue;
//如果Reference有注册ReferenceQueue,则处理pending指向的Reference结点将其加入ReferenceQueue中
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}

上面tryHandlePending方法中比较重要的点是c.clean()与q.enqueue®,这个是文章最开始提到的两个问题答案的入口。Cleaner的clean方法用于完成清理工作,而ReferenceQueue是将被回收对象加入到对应的Reference列队中,等待其他线程的后继处理。更具体地关于Cleaner与ReferenceQueue后面会再详细说明。Reference的核心处理流程可总结如下:

Java Reference核心原理分析 3

对Reference的核心处理流程有整体了解后,再来回过头细看一下Reference类的源码。

/* Reference实例有四种内部的状态
* Active: 新创建Reference的实例其状态为Active。当GC检测到Reference引用的referent可达到状态发生改变时,
* 为改变Reference的状态为Pending或Inactive。这个取决于创建Reference实例时是否注册过ReferenceQueue。
* 注册过其状态会转换为Pending,同时GC会将其加入pending-Reference链表中,否则为转换为Inactive状态。
* Pending: 代表Reference是pending-Reference链表的成员,等待ReferenceHandler线程调用Cleaner#clean
* 或ReferenceQueue#enqueue操作。未注册过ReferenceQueue的实例不会达到这个状态
* Enqueued: Reference实例成为其被创建时注册过的ReferenceQueue的成员,代表已入队列。当其从ReferenceQueue
* 中移除后,其状态会变为Inactive。
* Inactive: 什么也不会做,一旦处理该状态,就不可再转换。
* 不同状态时,Reference对应的queue与成员next变量值(next可理解为ReferenceQueue中的下个结点的引用)如下:
* Active: queue为Reference实例被创建时注册的ReferenceQueue,如果没注册为Null。此时,next为null,
* Reference实例与queue真正产生关系。
* Pending: queue为Reference实例被创建时注册的ReferenceQueue。next为当前实例本身。
* Enqueued: queue为ReferenceQueue.ENQUEUED代表当前实例已入队列。next为queue中的下一实列结点,
* 如果是queue尾部则为当前实例本身
* Inactive: queue为ReferenceQueue.NULL,当前实例已从queue中移除与queue无关联。next为当前实例本身。
*/
public abstract class Reference {
// Reference 引用的对象
private T referent;
/* Reference注册的queue用于ReferenceHandler线程入队列处理与用户线程取Reference处理。
* 其取值会根据Reference不同状态发生改变,具体取值见上面的分析
*/
volatile ReferenceQueue queue;
// 可理解为注册的queue中的下一个结点的引用。其取值会根据Reference不同状态发生改变,具体取值见上面的分析
volatile Reference next;
/* 其由VM维护,取值会根据Reference不同状态发生改变,
* 状态为active时,代表由GC维护的discovered-Reference链表的下个节点,如果是尾部则为当前实例本身
* 状态为pending时,代表pending-Reference的下个节点的引用。否则为null
*/
transient private Reference discovered;
/* pending-Reference 链表头指针,GC回收referent后会将Reference加pending-Reference链表。
* 同时ReferenceHandler线程会获取pending指针,不为空时Cleaner.clean()或入列queue。
* pending-Reference会采用discovered引用接链表的下个节点。
*/
private static Reference pending = null;
// 可理解为注册的queue中的下一个结点的引用。其取值会根据Reference不同状态发生改变,具体取值见上面的分析
volatile Reference next;
//用于CG同步Reference成员变量值的对象。
static private class Lock { }
private static Lock lock = new Lock();
//省略部分代码...
}

上面解释了Reference中的主要成员的作用,其中比较重要是Reference内部维护的不同状态,其状态不同成员变量queue、pending、discovered、next的取值都会发生变化。Reference的主要方法如下:

//构造函数,指定引用的对象referent
Reference(T referent) {
this(referent, null);
}
//构造函数,指定引用的对象referent与注册的queue
Reference(T referent, ReferenceQueue queue) {
this.referent = referent;
this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
}
//获取引用的对象referent
public T get() {
return this.referent;
}
//将当前对象加入创建时注册的queue中
public boolean enqueue() {
return this.queue.enqueue(this);
}

ReferenecQueue與Cleaner源碼分析

先來看下ReferenceQueue的主要成員變量的含義。

//代表Reference的queue为null。Null为ReferenceQueue子类
static ReferenceQueue NULL = new Null();
//代表Reference已加入当前ReferenceQueue中。
static ReferenceQueue ENQUEUED = new Null();
//用于同步的对象
private Lock lock = new Lock();
//当前ReferenceQueue中的头节点
private volatile Reference head = null;
//ReferenceQueue的长度
private long queueLength = 0;

ReferenceQueue中比较重要的方法为enqueue、poll、remove方法。

//入列队enqueue方法,只被Reference类调用,也就是上面分析中ReferenceHandler线程为调用
boolean enqueue(Reference r) {
//获取同步对象lock对应的监视器对象
synchronized (lock) {
//获取r关联的ReferenceQueue,如果创建r时未注册ReferenceQueue则为NULL,同样如果r已从ReferenceQueue中移除其也为null
ReferenceQueue queue = r.queue;
//判断queue是否为NULL 或者 r已加入ReferenceQueue中,是的话则入队列失败
if ((queue == NULL) || (queue == ENQUEUED)) {
return false;
}
assert queue == this;
//设置r的queue为已入队列
r.queue = ENQUEUED;
//如果ReferenceQueue头节点为null则r的next节点指向当前节点,否则指向头节点
r.next = (head == null) ? r : head;
//更新ReferenceQueue头节点
head = r;
//列队长度加1
queueLength++;
//为FinalReference类型引用增加FinalRefCount数量
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(1);
}
//通知remove操作队列有节点
lock.notifyAll();
return true;
}
}

poll方法源碼相對簡單,其就是從ReferenceQueue的頭節點獲取Reference。

public Reference poll() {
//头结点为null直接返回,代表Reference还没有加入ReferenceQueue中
if (head == null)
return null;
//获取同步对象lock对应的监视器对象
synchronized (lock) {
return reallyPoll();
}
}
//从队列中真正poll元素的方法
private Reference reallyPoll() {
Reference r = head;
//double check 头节点不为null
if (r != null) {
//保存头节点的下个节点引用
Reference rn = r.next;
//更新queue头节点引用
head = (rn == r) ? null : rn;
//更新Reference的queue值,代表r已从队列中移除
r.queue = NULL;
//更新Reference的next为其本身
r.next = r;
queueLength--;
//为FinalReference节点FinalRefCount数量减1
if (r instanceof FinalReference) {
sun.misc.VM.addFinalRefCount(-1);
}
//返回获取的节点
return r;
}
return null;
}

remove方法的源碼如下:

public Reference remove(long timeout) throws IllegalArgumentException, InterruptedException {
if (timeout < 0) { throw new IllegalArgumentException("Negative timeout value"); } //获取同步对象lock对应的监视器对象 synchronized (lock) { //获取队列头节点指向的Reference Reference r = reallyPoll();
//获取到返回
if (r != null) return r;
long start = (timeout == 0) ? 0 : System.nanoTime();
//在timeout时间内尝试重试获取
for (;;) {
//等待队列上有结点通知
lock.wait(timeout);
//获取队列中的头节点指向的Reference
r = reallyPoll();
//获取到返回
if (r != null) return r;
if (timeout != 0) {
long end = System.nanoTime();
timeout -= (end - start) / 1000_000;
//已超时但还没有获取到队列中的头节点指向的Reference返回null
if (timeout <= 0) return null; start = end; } } } }

簡單的分析完ReferenceQueue的源碼後,再來整體回顧一下Reference的核心處理流程。 JVM在GC時如果當前對像只被Reference對象引用,JVM會根據Reference具體類型與堆內存的使用情況決定是否把對應的Reference對象加入到一個由Reference構成的pending鍊錶上,如果能加入pending鍊錶JVM同時會通知ReferenceHandler線程進行處理。 ReferenceHandler線程收到通知後會調用Cleaner#clean或ReferenceQueue#enqueue方法進行處理。如果引用當前對象的Reference類型為WeakReference且堆內存不足,那麼JVM就會把WeakReference加入到pending-Reference鍊錶上,然後ReferenceHandler線程收到通知後會異步地做入隊列操作。而我們的應用程序中的線程便可以不斷地去拉取ReferenceQueue中的元素來感知JVM的堆內存是否出現了不足的情況,最終達到根據堆內存的情況來做一些處理的操作。實際上WeakHashMap低層便是過通上述過程實現的,只不過實現細節上有所偏差,這個後面再分析。再來看看ReferenceHandler線程收到通知後可能會調用的另外一個類Cleaner的實現。

同樣先看一下Cleaner的成員變量,再看主要的方法實現。

//继承了PhantomReference类也就是虚引用,PhantomReference源码很简单只是重写了get方法返回null
public class Cleaner extends PhantomReference {
/* 虚队列,命名很到位。之前说CG把ReferenceQueue加入pending-Reference链中后,ReferenceHandler线程在处理时
* 是不会将对应的Reference加入列队的,而是调用Cleaner.clean方法。但如果Reference不注册ReferenceQueue,GC处理时
* 又无法把他加入到pending-Reference链中,所以Cleaner里面有了一个dummyQueue成员变量。
*/
private static final ReferenceQueue dummyQueue = new ReferenceQueue();
//Cleaner链表的头结点
private static Cleaner first = null;
//当前Cleaner节点的后续节点
private Cleaner next = null;
//当前Cleaner节点的前续节点
private Cleaner prev = null;
//真正执行清理工作的Runnable对象,实际clean内部调用thunk.run()方法
private final Runnable thunk;
//省略部分代码...
}

从上面的成变量分析知道Cleaner实现了双向链表的结构。先看构造函数与clean方法。

//私有方法,不能直接new
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
//创建Cleaner对象,同时加入Cleaner链中。
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
//头插法将新创意的Cleaner对象加入双向链表,synchronized保证同步
private static synchronized Cleaner add(Cleaner var0) {
if (first != null) {
var0.next = first;
first.prev = var0;
}
//更新头节点引用
first = var0;
return var0;
}

public void clean() {
//从Cleaner链表中先移除当前节点
if (remove(this)) {
try {
//调用thunk.run()方法执行对应清理逻辑
this.thunk.run();
} catch (final Throwable var2) {
//省略部分代码..
}

}
}

可以看到Cleaner的實現還是比較簡單,Cleaner實現為PhantomReference類型的引用。當JVM GC時如果發現當前處理的對像只被PhantomReference類型對象引用,同之前說的一樣其會將該Reference加pending-Reference鏈中上,只是ReferenceHandler線程在處理時如果PhantomReference類型實際類型又是Cleaner的話。其就是調用Cleaner.clean方法做清理邏輯處理。 Cleaner實際是DirectByteBuffer分配的堆外內存收回的實現,具體見下面的分析。

DirectByteBuffer堆外內存回收與WeakHashMap敏感內存回收

繞開了一大圈終於回到了文章最開始提到的兩個問題,先來看一下分配給DirectByteBuffer堆外內存是如何回收的。在創建DirectByteBuffer時我們實際是調用ByteBuffer#allocateDirect方法,而其實現如下:

public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

DirectByteBuffer(int cap) {
//省略部分代码...
try {
//调用unsafe分配内存
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
//省略部分代码...
}
//省略部分代码...
//前面分析中的Cleaner对象创建,持有当前DirectByteBuffer的引用
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

裡面和DirectByteBuffer堆外內存回收相關的代碼便是Cleaner.create(this, new Deallocator(base, size, cap))這部分。還記得之前說實際的清理邏輯是裡面和DirectByteBuffer堆外內存回收相關的代碼便是Cleaner裡面的Runnable#run方法嗎?直接看Deallocator.run方法源碼:

public void run() {
if (address == 0) {
// Paranoia
return;
}
//通过unsafe.freeMemory释放创建的堆外内存
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}

終於找到了分配給DirectByteBuffer堆外內存是如何回收的的答案。再總結一下,創建DirectByteBuffer對象時會創建一個Cleaner對象,Cleaner對象持有了DirectByteBuffer對象的引用。當JVM在GC時,如果發現DirectByteBuffer被地方法沒被引用啦,JVM會將其對應的Cleaner加入到pending-reference鍊錶中,同時通知ReferenceHandler線程處理,ReferenceHandler收到通知後,會調用Cleaner#clean方法,而對於DirectByteBuffer創建的Cleaner對像其clean方法內部會調用unsafe.freeMemory釋放堆外內存。最終達到了DirectByteBuffer對像被GC回收其對應的堆外內存也被回收的目的。

再來看一下文章開始提到的另外一個問題WeakHashMap如何實現敏感內存的回收。實際WeakHashMap實現上其Entry繼承了WeakReference。


//Entry继承了WeakReference, WeakReference引用的是Map的key
private static class Entry extends WeakReference implements Map.Entry {
V value;
final int hash;
Entry next;
/**
* 创建Entry对象,上面分析过的ReferenceQueue,这个queue实际是WeakHashMap的成员变量,
* 创建WeakHashMap时其便被初始化 final ReferenceQueue queue = new ReferenceQueue()
*/
Entry(Object key, V value,
ReferenceQueue queue,
int hash, Entry next) {
super(key, queue);
this.value = value;
this.hash = hash;
this.next = next;
}
//省略部分原码...
}

往WeakHashMap添加元素时,实际都会调用Entry的构造方法,也就是会创建一个WeakReference对象,这个对象的引用的是WeakHashMap刚加入的Key,而所有的WeakReference对象关联在同一个ReferenceQueue上。我们上面说过JVM在GC时,如果发现当前对象只有被WeakReference对象引用,那么会把其对应的WeakReference对象加入到pending-reference链表上,并通知ReferenceHandler线程处理。而ReferenceHandler线程收到通知后,对于WeakReference对象会调用ReferenceQueue#enqueue方法把他加入队列里面。现在我们只要关注queue里面的元素在WeakHashMap里面是在哪里被拿出去啦做了什么样的操作,就能找到文章开始问题的答案啦。最终能定位到WeakHashMap的expungeStaleEntries方法。

private void expungeStaleEntries() {
//不断地从ReferenceQueue中取出,那些只有被WeakReference对象引用的对象的Reference
for (Object x; (x = queue.poll()) != null; ) {
synchronized (queue) {
//转为 entry
Entry e = (Entry) x;
//计算其对应的桶的下标
int i = indexFor(e.hash, table.length);
//取出桶中元素
Entry prev = table[i];
Entry p = prev;
//桶中对应位置有元素,遍历桶链表所有元素
while (p != null) {
Entry next = p.next;
//如果当前元素(也就是entry)与queue取出的一致,将entry从链表中去除
if (p == e) {
if (prev == e)
table[i] = next;
else
prev.next = next;
// Must not null out e.next;
//清空entry对应的value
e.value = null;
size--;
break;
}
prev = p;
p = next;
}
}
}
}

現在只看一下WeakHashMap哪些地方會調用expungeStaleEntries方法就知道什麼時候WeakHashMap裡面的Key變得軟可達時我們就可以將其對應的Entry從WeakHashMap裡面移除。直接調用有三個地方分別是getTable方法、size方法、resize方法。 getTable方法又被很多地方調用如get、containsKey、put、remove、containsValue、replaceAll。最終看下來,只要對WeakHashMap進行操作就行調用expungeStaleEntries方法。所有隻要操作了WeakHashMap,沒WeakHashMap裡面被再用到的Key對應的Entry就會被清除。再來總結一下,為什麼WeakHashMap適合作為內存敏感緩存的實現。當JVM 在GC時,如果發現WeakHashMap裡面某些Key沒地方在被引用啦(WeakReference除外),JVM會將其對應的WeakReference對象加入到pending-reference鍊錶上,並通知ReferenceHandler線程處理。而ReferenceHandler線程收到通知後將對應引用Key的WeakReference對象加入到WeakHashMap內部的ReferenceQueue中,下次再對WeakHashMap做操作時,WeakHashMap內部會清除那些沒有被引用的Key對應的Entry。這樣就達到了每操作WeakHashMap時,自動的檢索並清量沒有被引用的Key對應的Entry的目地。

總結

本文通過兩個問題引出了JDK中Reference相關類的源碼分析,最終給出了問題的答案。但實際上一般開發規範中都會建議禁止重寫Object#finalize方法同樣與Reference類關係密切(具體而言是Finalizer類)。受篇幅的限製本文並未給出分析,有待各位自己看源碼啦。半年沒有寫文章啦,有點對不住關注的小伙伴。希望看完本文各位或多或少能有所收穫。如果覺得本文不錯就幫忙轉發記得標一下出處,謝謝。後面我還會繼續分享一些自己覺得比較重要的東西給大家。由於個人能力有限,文中不足與錯誤還望指正。

看完三件事❤️

如果你覺得這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:

點贊,轉發,有你們的『點贊和評論』,才是我創造的動力。關注公眾號『 java爛豬皮』,不定期分享原創知識。同時可以期待後續文章ing🚀

Java Reference核心原理分析 4

本文作者:葉易出處:https://club.perfma.com/article/125010