以 ZGC 為例,談一談 JVM 是如何實現 Reference 語義的

bin的技术小屋發表於2024-06-13

本文基於 OpenJDK17 進行討論

image

1. Reference 相關概念及其應用場景總覽

Reference(引用)是 JVM 中非常核心且重要的一個概念,垃圾回收器判斷一個物件存活與否都是圍繞著這個 Reference 來的,JVM 將 Reference 又細分為幾種具體的引用型別,它們分別是:StrongReference,SoftReference,WeakReference,PhantomReference,FinalReference。

談到這些 Reference,可謂是既熟悉又陌生,因此筆者在本文中介紹 Reference 的思路也是從它熟悉的一面再到陌生的一面進行展開討論。

我們在 JDK 以及一些中介軟體原始碼中或多或少見過他們的身影,對他們的應用場景以及概念非常熟悉。比如:

1.1 StrongReference

StrongReference:強引用關係不用多說,這個我們最熟悉了,大部分 Java 物件之間的關係都是強引用,只要物件與 GcRoot 之間有強引用關係的存在,那麼這個物件將永遠不會被垃圾回收器回收。

Object gcRoot = new Object();

1.2 SoftReference

SoftReference:如果物件只有一條軟引用關聯,那麼當記憶體充足的時候,軟引用和強引用一樣,在發生 GC 的時候,只被軟引用關聯的物件是不會被回收掉的。當記憶體不足的時候也就是系統將要發生 OOM 之前,此時發生 GC,那麼這些只被軟引用所關聯的物件將會被當做垃圾回收掉。

SoftReference gcRoot = new SoftReference<Object>(new Object());

上面這行程式碼展示的引用關係如下圖所示:

image

gcRoot 強引用了 SoftReference 物件 ,然後 SoftReference 物件軟引用了 Object 物件,那麼此時對於 Object 物件來說就只存在一條軟引用關係 —— SoftReference物件 -> Object物件,當系統將要發生 OOM 之前,GC 就會將 Object 物件回收掉。後面我們透過 SoftReference#get 方法獲取到的引用物件將會是 Null (Object 物件已被回收)。

根據 SoftReference 的這個特性,我們可以用它來引用持有一些 memory-sensitive caches 等有用但是非必須的物件。比如,Guava 中的 CacheBuilder.softValues() 就可以讓 cache 使用 SoftReference 來引用持有 Values,當記憶體不足的時候回收掉就好了。

Cache<Object, Object> softCache = CacheBuilder.newBuilder().softValues().build();

還有在池化技術的實現中,比如物件池,連線池這些,我們也可以在池中用 SoftReference 來引用持有被池化的這些物件。比如一些 RPC 框架中例如 Dubbo,在從 Sokcet 中讀取到網路傳輸進來的二進位制資料時,需要將這些網路二進位制資料序列化成 Java 類,方便後續業務邏輯的處理。

當我們採用 Kryo 序列化框架時,每一次的序列化都需要用到一個叫做 Kryo 類的例項,由於 Kryo 並不是執行緒安全的,再加上建立初始化一個 Kryo 例項代價比較高,所以在多執行緒環境中,我們需要使用 ThreadLocal 來持有 Kryo 例項或者使用 KryoPool 物件池來池化 Kryo 例項。

由於這裡我們介紹的是 SoftReference 的使用場景,所以我們以 KryoPool 為例說明,在 KryoPool 的建立過程中,我們可以指定使用 softReferences 來持有這些 Kryo 例項,當記憶體不足的時候,GC 將會回收這些 Kryo 例項。

public class PooledKryoFactory extends AbstractKryoFactory {

    private KryoPool pool;

    public PooledKryoFactory() {
        pool = new KryoPool.Builder(this).softReferences().build();
    }

    @Override
    public Kryo getKryo() {
        return pool.borrow();
    }

    @Override
    public void returnKryo(Kryo kryo) {
        pool.release(kryo);
    }
}

後續多執行緒在遇到序列化任務時,直接從 KryoPool 中去獲取 Kryo 例項,序列化完成之後再將 Kryo 例項歸還到池中。當然了,Dubbo 使用的是另一種方式,透過 ThreadLocal 來持有 Kryo 例項也可以達到同樣的目的。這裡筆者就不深入寫了。

public class ThreadLocalKryoFactory extends AbstractKryoFactory {

    private final ThreadLocal<Kryo> holder = new ThreadLocal<Kryo>() {
        @Override
        protected Kryo initialValue() {
            return create();
        }
    };

    @Override
    public void returnKryo(Kryo kryo) {
        // do nothing
    }

    @Override
    public Kryo getKryo() {
        return holder.get();
    }
}

1.3 WeakReference

WeakReference:弱引用是比 SoftReference 更弱的一種引用關係,如果被引用物件當前只存在一條弱引用鏈時,那麼發生 GC 的時候,無論記憶體是否足夠,只被弱引用所關聯的物件都會被回收掉。

WeakReference gcRoot = new WeakReference<Object>(new Object());

上面這行程式碼展示的引用關係如下圖所示:

image

gcRoot 強引用了 WeakReference 物件 ,然後 WeakReference 物件弱引用了 Object 物件,那麼此時對於 Object 物件來說就只存在一條弱引用關係 —— WeakReference物件 -> Object物件。當發生 GC 時, Object 物件就會被回收掉。後面我們透過 WeakReference#get 方法獲取到的引用物件將會是 Null (Object 物件已被回收)。

和 SoftReference 一樣,WeakReference 也經常被用在快取框架以及池化技術中,只不過引用強度更弱一些。比如,在 Guava 中,我們可以透過 CacheBuilder.weakKeys()來指定由弱引用來持有快取 Key,當系統中只有一條弱引用(快取框架)來持有快取 Key ,除此之外沒有任何的強引用或者軟引用持有 Key 時,那麼在 GC 的時候,快取 Key 就會被回收掉,隨後 Guava 也會將這個 Key 對應的整個 entry 清理掉。

同樣我們也可以透過 CacheBuilder.weakValues() 來指定由弱引用來持有快取 Value,當系統中沒有任何強引用或者軟引用來持有快取 Value 時,發生 GC 的時候,快取的這條 entry 也是會被回收掉的。

Cache<Object,Object> weakCache = CacheBuilder.newBuilder().weakKeys().weakValues().build();

WeakReference 在池化技術中運用的更加廣泛些,比如,在 Netty 物件池 Recycler 的設計中:

image

每個執行緒都會擁有一個屬於自己的 Stack,Stack 中包含一個用陣列實現的棧結構(圖中綠色部分),這個棧結構正是物件池中真正用於儲存池化物件的地方,我們每次從物件池中獲取物件都會從這個棧結構中彈出棧頂元素。同樣我們每次將使用完的物件歸還到物件池中也是將物件壓入這個棧結構中

每個執行緒擁有一個獨立 Stack,這樣當多個執行緒併發從物件池中獲取物件時,都是從自己執行緒中的 Stack 中獲取,全程無鎖化執行。大大提高了多執行緒從物件池中獲取物件的效率。

執行緒與 Stack 是一對一的結構,我們看到在 Stack 結構中透過 WeakReference 持有了 Thread 的引用。

private static final class Stack<T> {
        final WeakReference<Thread> threadRef;
}

這是因為物件池的設計中存在這樣一個引用關係:池化物件 -> DefaultHandler(池化物件在池中的模型) -> stack -> threadRef。而池化物件是暴露給使用者的,如果使用者在某個地方持有了池化物件的強引用忘記清理,而 Stack 持有 Thread 的強引用的話,當執行緒掛掉的之後,因為這樣一個強引用鏈的存在從而導致 Thread 一直不能被 GC 回收。

另外,為了使多執行緒回收物件的時候也能夠無鎖化的進行,每一個回收執行緒都對應一個 WeakOrderQueue 節點(上圖黃色部分),回收執行緒會將池化物件暫時回收到自己對應的 WeakOrderQueue 結構中,當物件池中的 Stack 沒有物件時,就會由 Stack 弱引用關聯的 Thread 將 WeakOrderQueue 結構中暫存的回收物件轉移到 Stack 中。物件的申請只能從 Stack 中進行,整個申請,回收過程都是無鎖化進行的。

WeakOrderQueue 結構也是由 WeakReference 實現的,由於 WeakOrderQueue 與回收執行緒也是一對一的關係,所以在 WeakOrderQueue 中也是透過弱引用來持有回收執行緒的例項。

private static final class WeakOrderQueue extends WeakReference<Thread> {
}

目的也是為了當回收執行緒掛掉的時候,能夠保證回收執行緒被 GC 及時的回收掉。如果 WeakOrderQueue.get() == null 說明當前 WeakOrderQueue 節點對應的回收執行緒已經掛掉了,此時如果當前節點還有待回收物件,則需要將節點中的所有待回收物件全部轉移至 Stack 的陣列棧中。

轉移完成之後,將該 WeakOrderQueue 節點從 Stack 結構裡的 WeakOrderQueue 連結串列中刪除。保證被清理後的 WeakOrderQueue 節點可以被 GC 回收。

關於 Recycler 物件池相關的實現細節,感興趣的同學可以回看下 《詳解Recycler物件池的精妙設計與實現》

在比如,Netty 中的資源洩露探測工具 ResourceLeakDetector 也是透過 WeakReference 來探測資源是否存在洩露的,預設是開啟的,但我們也可以透過 -Dio.netty.leakDetection.level=DISABLED 來關閉資源洩露探測。

Netty 中的 ByteBuf 是一種記憶體資源,我們可以透過 ResourceLeakDetector 來探測我們的工程是否存在記憶體洩露的狀況,這裡面有一個非常重要的類 DefaultResourceLeak 就是一個弱引用 WeakReference。由它來弱引用 ByteBuf。


private static final class DefaultResourceLeak<T> extends WeakReference<Object> implements ResourceLeakTracker<T>, ResourceLeak {

    private final Set<DefaultResourceLeak<?>> allLeaks;

    DefaultResourceLeak(
                Object referent,
                ReferenceQueue<Object> refQueue,
                Set<DefaultResourceLeak<?>> allLeaks) {
            // 弱引用 ByteBuf
            super(referent, refQueue);    
            // 將弱引用 DefaultResourceLeak 放入全域性 allLeaks 集合中
            allLeaks.add(this);
            this.allLeaks = allLeaks;
    }
}

在每建立一個 ByteBuf 的時候, Netty 都會建立一個 DefaultResourceLeak 例項來弱引用 ByteBuf,並且會將這個 DefaultResourceLeak 例項放入到一個全域性的 allLeaks 集合中。Netty 中的每個 ByteBuf 都會有一個 refCnt 來表示對這塊記憶體的引用情況。

public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
   // 引用計數
   private volatile int refCnt = updater.initialValue();
}

對於 ByteBuf 的每一次引用 —— ByteBuf.retain(),都會增加一次引用計數 refCnt。對於 ByteBuf 的每一次釋放 —— ByteBuf.release(),都會減少一次引用計數 refCnt。當引用計數 refCnt 為 0 時,Netty 就會將與 ByteBuf 弱引用關聯的 DefaultResourceLeak 例項從 allLeaks 中刪除。

由於 DefaultResourceLeak 只是用來追蹤 ByteBuf 的資源洩露情況,它並不能影響 ByteBuf 是否存活,所以 Netty 這裡只是讓 DefaultResourceLeak 來弱引用一下 ByteBuf。當 ByteBuf 在系統中沒有任何強引用或者軟引用時,那麼就只有一個 DefaultResourceLeak 例項在弱引用它了,發生 GC 的時候 ByteBuf 就會被回收掉。

Netty 判斷是否發生記憶體洩露的時機就發生在 ByteBuf 被 GC 的時候,這時 Netty 會拿到被 GC 掉的 ByteBuf 對應的弱引用 DefaultResourceLeak 例項,然後檢查它的 allLeaks 集合是否仍然包含這個 DefaultResourceLeak 例項,如果包含就說明 ByteBuf 有記憶體洩露的情況。

因為如果 ByteBuf 的引用計數 refCnt 為 0 時,Netty 就會將弱引用 ByteBuf 的 DefaultResourceLeak 例項從 allLeaks 中刪除。ByteBuf 現在都被 GC 了,它的 DefaultResourceLeak 例項如果還存在 allLeaks 中,那說明我們就根本沒有呼叫 ByteBuf.release() 去釋放記憶體資源。

在探測到記憶體洩露發生之後,後續 Netty 就會透過 reportLeak() 將記憶體洩露的相關資訊以 error 的日誌級別輸出到日誌中。

除此之外,WeakReference 的應用場景還有很多,比如在無鎖化的設計中頻繁用到的 ThreadLocal :

   ThreadLocal<Object> gcRoot = new ThreadLocal<Object>(){
            @Override
            protected Object initialValue() {
                return new Object();
            }
   };

ThreadLocal 顧名思義是執行緒本地變數,當我們在程式中定義了一個 ThreadLocal 物件之後,那麼在多執行緒環境中,每個執行緒都會擁有一個獨立的 ThreadLocal 物件副本,這就使得多執行緒可以獨立的操作這個 ThreadLocal 變數不需要加鎖。

為了完成執行緒本地變數的語義,JDK 在 Thread 中新增了一個 ThreadLocalMap 物件,用來持有屬於自己本地的 ThreadLocal 變數副本。

public class Thread implements Runnable {
        ThreadLocal.ThreadLocalMap threadLocals = null;
}

由於我們通常在程式中會定義多個 ThreadLocal 變數,所以 ThreadLocalMap 被設計成了一個雜湊表的結構 —— Entry[] table,多個 ThreadLocal 變數的本地副本就儲存在這個 table 中。

static class ThreadLocalMap {
        private Entry[] table;
}

table 中的每一個元素是一個 Entry 結構,Entry 被設計成了一個 WeakReference,由 Entry 來弱引用持有 ThreadLocal 物件(作為 key), 強引用持有 value 。這樣一來,ThreadLocal 物件和它所對應的 value 就被 Entry 關聯起來了。

static class Entry extends WeakReference<ThreadLocal<?>> { 
        Object value;
        Entry(ThreadLocal<?> k, Object v) {       
                // 弱引用     
                super(k);
                // 強引用
                value = v;
        }
}

當某一個執行緒開始呼叫 ThreadLocal 物件的 get 方法時:

        ThreadLocal<Object> gcRoot = new ThreadLocal<Object>(){
            @Override
            protected Object initialValue() {
                return new Object();
            }
        };

        gcRoot.get();

JDK 首先會找到本地執行緒中儲存的 ThreadLocal 變數副本 —— ThreadLocalMap,然後以 ThreadLocal 物件為 key —— 也就是上面 gcRoot 變數引用的 ThreadLocal 物件,到雜湊表 table 中查詢對應的 Entry 結構(WeakReference),近而透過 Entry. value 找到該 ThreadLocal 物件對應的 value 值返回。

public class ThreadLocal<T> {
    public T get() {
        Thread t = Thread.currentThread();
        // 獲取本地執行緒中儲存的 ThreadLocal 變數副本
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            // 以 ThreadLocal 物件為 key,到雜湊表 table 中查詢對應的 Entry 結構
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                // 返回該 threadLocal 物件對應的 value。
                T result = (T)e.value;
                return result;
            }
        }
        // 如果 threadLocal 物件還未設定 value 值的話,則呼叫 initialValue 初始化 threadLocal 物件的值
        return setInitialValue();
    }
}

以上面這段示例程式碼為例,當前系統中的這個 ThreadLocal 物件 —— 也就是由 gcRoot 變數指向的 ThreadLocal 物件,存在以下兩條引用鏈:

image

  • 一條是 Thead物件 -> ThreadLocalMap物件->Entry物件 這條弱引用鏈。

  • 另一條則是有 gcRoot變數 -> ThreadLocal物件 這條強引用鏈。

當我們透過 gcRoot = null 來斷開 gcRoot 變數到 ThreadLocal 物件的強引用之後,ThreadLocal 物件在系統中就只剩下一條弱引用鏈存在了。

image

Entry 被設計成一個 WeakReference,由它來弱引用 ThreadLocal 物件的好處就是,當系統中不存在任何對這個 ThreadLocal 物件的強引用之後,發生 GC 的時候這個 ThreadLocal 物件就會被回收掉。後續我們在透過 Entry.get() 獲取 Key(ThreadLocal 物件)的時候就會得到一個 Null 。

雖然現在 ThreadLocal 物件已經被 GC 掉了,但 JDK 對於 Reference 的處理流程還沒有結束,事實上對於 Reference 的處理是需要 GC 執行緒以及 Java 業務執行緒相互配合完成的,這也是本文我們要重點討論的主題。(嘮叨了這麼久,終於要引入主題了)

GC 執行緒負責回收被 WeakReference 引用的物件,也就是這裡的 ThreadLocal 物件。但別忘了這裡的 Entry 物件本身也是一個 WeakReference 型別的物件。被它弱引用的物件現在已經回收掉了,那麼與其關聯的 Entry 物件以及 value 其實也沒啥用處了。

但如上圖所示,Entry 物件以及 value 物件卻還是存在一條強引用鏈,雖然他們沒什麼用了,但仍然無法被回收,如果 Java 業務執行緒不做任何處理的話就會導致記憶體洩露。

在 ThreadLocal 的設計中 ,Java 業務執行緒清理無用 Entry 的時機有以下三種:

  1. 當我們線上程中透過 ThreadLocal.get() 獲取任意 ThreadLocal 變數值的時候,如果發生雜湊衝突,隨後採用開放定址解決衝突的過程中,如果發現 key 為 null 的 Entry,那麼就將該 Entry以及與其關聯的 vakue 設定為 null。最後以此 Entry 物件為起點遍歷整個 ThreadLocalMap 清理所有無用的 Entry 物件。但這裡需要注意的是如果 ThreadLocal.get() 沒有發生雜湊衝突(直接命中),或者在解決雜湊衝突的過程中沒有發現 key 為 null 的 Entry,那麼就不會觸發無用 Entry 的清理,仍然存在記憶體洩露的風險。
       private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // 將當前遍歷到的 Entry 以及與其關聯的 value 設定為 null 
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;
    
            Entry e;
            int i;
            // 遍歷整個 ThreadLocalMap,清理無用的 Entry
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } 
            }
            return i;
        }
  1. 當我們線上程中透過 ThreadLocal.set(value) 設定任意 ThreadLocal 變數值的時候,如果直接透過 ThreadLocal 變數定位到了 Entry 的位置,那麼直接設定 value 返回,並不會觸發無用 Entry 的清理。如果在定位 Entry 的時候發生雜湊衝突,隨後會透過開放定址在 ThreadLocalMap 中尋找到一個合適的 Entry 位置。並從這個位置開始向後掃描 log2(size) 個 Entry,如果在掃描的過程中發現有一個是無用的 Entry,那麼就會遍歷整個 ThreadLocalMap 清理所有無用的 Entry 物件。但如果恰好這 log2(size) 個 Entry 都是有用的,即使後面存在無用的 Entry 也不會再清理了,這也導致了記憶體洩露的風險。
        // 引數 i 表示開發定址定位到的 Entry 位置
        // n 為當前 ThreadLocalMap 的 size
        private boolean cleanSomeSlots(int i, int n) {
            boolean removed = false;
            Entry[] tab = table;
            int len = tab.length;
            do {
                i = nextIndex(i, len);
                Entry e = tab[i];
                // 如果發現有一個是無用 Entry
                if (e != null && e.refersTo(null)) {
                    // 遍歷整個 ThreadLocalMap 清理所有無用的 Entry 物件
                    i = expungeStaleEntry(i);
                }
            } while ( (n >>>= 1) != 0); // 向後掃描 log2(size) 個 Entry
            return removed;
        }
  1. 前面介紹的 get , set 方法只是順手清理一下 ThreadLocalMap 中無用的 Entry,但並不一定保證能夠觸發到清理動作,所以仍然面臨記憶體洩露的風險。一個更加安全有效的方式是我們需要在使用完 ThreadLocal 物件的時候,手動呼叫它的 remove 方法,及時清理掉 Entry 物件並透過 Entry.clear() 斷開 Entry 到 ThreadLocal 物件之間的弱引用關係,這樣一來,當 ThreadLocal 物件被 GC 的時候,與它相關的 Entry 物件以及 value 也會被一併 GC ,這樣就徹底杜絕了記憶體洩露的風險。
        private void remove(ThreadLocal<?> key) {
            Entry[] tab = table;
            int len = tab.length;
            // 確定 key 在 table 中的起始位置
            int i = key.threadLocalHashCode & (len-1);
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                if (e.refersTo(key)) {
                    // 斷開 Entry  到 ThreadLocal 物件之間的弱引用關係
                    e.clear();
                    // 清理 key 為 null 的 Entry 物件。
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

1.4 PhantomReference

PhantomReference:虛引用其實和 WeakReference 差不多,他們共同的特點都是一旦發生 GC,PhantomReference 和 WeakReference 所引用的物件都會被 GC 掉,當然前提是這個物件沒有其他的強引用或者軟引用存在。不同的是 PhantomReference 無法像其他引用型別一樣能夠透過 get 方法獲取到被引用的物件。

public class PhantomReference<T> extends Reference<T> {
    public T get() {
        return null;
    }
}

看上去這個 PhantomReference 好像是沒啥用處,因為它既不能影響被引用物件的生命週期,也無法透過它來訪問被引用物件。但其實不然,我們用 PhantomReference 來虛引用一個物件的目的其實為了能夠在這個物件被 GC 之後,我們在 Java 業務執行緒中能夠感知到,在感知到物件被 GC 之後,可以利用這個 PhantomReference 做一些資源清理的動作。

比如與 DirectByteBuffer 關聯的 Cleaner 物件,其實就是一個 PhantomReference 的實現,當 DirectByteBuffer 被 GC 掉之後,由於其與 Cleaner 虛引用關聯,所以我們可以在 Java 業務執行緒中感知到,隨後可以利用 Cleaner 來釋放 DirectByteBuffer 背後引用的 Native Memory。

1.5 FinalReference

FinalReference 只是和 Java 類中的 finalize 方法(OpenJDK9 中已被廢棄)的執行機制有關,但筆者建議大家忘掉 JDK 中還有這麼個玩意存在,因為 FinalReference 會使 Java 物件的回收過程變得磨磨唧唧,拖拖拉拉。可能需要等上好幾輪 GC,物件才可以被回收掉。所以我們基本不會見到 FinalReference 的應用場景,因為這玩意磨磨唧唧的沒啥卵用。不過筆者還是會在本文的最後介紹它,目的不是為了讓大家使用它,而是在末尾利用它再次給大家串聯一下 JVM 關於 Reference 的整個處理流程。

2. Reference —— 最熟悉的陌生人

以上介紹的都是我們熟悉的 Reference,之所以說它熟悉,是因為上述 Reference 相關的處理邏輯都發生在 Java 的業務執行緒中,如果我們站在 JVM 全域性視角上看,Reference 的完整處理流程是需要 Java 執行緒和 GC 執行緒一起相互配合完成的。

之所以說它陌生,是因為 GC 執行緒處理的過程對我們來說還是一個黑盒,這使得我們無法觸達 Reference 的本質,如果我們只是從概念上去理解 Reference 的話,一定會有很多模稜兩可,似是而非的感覺,相信大家在閱讀第一小節的過程中都會伴隨著這種感覺,比如:

  1. 當記憶體不足的時候,那些只被 SoftReference 引用的物件將被 GC 回收掉,那麼這個 “記憶體不足” 的概念就很模糊,如何定義記憶體不足 ?能不能量化出一個具體的回收時機 ?到底什麼情況下會觸發回收 ?

  2. 當發生 GC 的時候,無論記憶體是否足夠,那些只被 WeakReference 所引用的物件都會被回收掉。但大家別忘了這些 WeakReference 本身其實也是一個普通的 Java 物件,GC 在判斷一個物件是否存活的依據,就是沿著 GcRoot 的引用關係鏈開始逐個遍歷,遍歷到的物件就標記為存活,沒有遍歷到的物件就不標記。後續將被標記的物件統一轉移到新的記憶體區域,然後一股腦的清除沒有被標記的物件。如果一個 GcRoot 引用了 WeakReference 物件。而 WeakReference 物件又引用了一個 Object 物件。那麼按道理來說這個 Object 物件也在引用鏈上啊,應該也是能夠被 GC 標記到的啊,那為什麼就被 GC 給回收了呢 ? JVM 到底是如何實現 WeakReference 弱引用語義的呢 ?

  3. PhantomReference 既不能影響被引用物件的生命週期,也無法透過它來訪問被引用物件,那我們要它幹嘛 ? 用 PhantomReference 來虛引用一個物件的目的就是為了跟蹤被引用物件是否被 GC 回收了,當虛引用的物件被 GC 之後,我們在 Java 執行緒中可以感知到,近而做一些相關資源清理的動作。那麼請問 Java 執行緒是如何感知到的 ? GC 執行緒如何通知 ? Java 執行緒如何收到這個通知,它們兩者是如何配合的 ?

  4. FinalReference 到底是如何讓 Java 物件的回收過程變得磨磨唧唧,拖拖拉拉的呢 ?

  5. PhantomReference 和 WeakReference 看起來好像都差不多,如果 PhantomReference 能夠跟蹤物件的回收狀態,那麼 WeakReference 可不可以 ?它們之間究竟有何不同 ?

  6. 上面聊到的這些 Reference 型別:SoftReference,WeakReference,PhantomReference,FinalReference 其實本質上都是一個普通的 Java 類,由他們定義的物件也只是一個普通的 Java 物件。而當這些 Reference 物件引用的 Object 物件被 GC 回收掉之後,其實這些 Reference 物件也就沒用了,但由於 GcRoot 還能關聯到這些 Reference 物件,從而導致這些無用的 Reference 物件無法被 GC 回收。那麼我們在 Java 業務執行緒中該如何處理這些無用的 Reference 物件 ?這些 Reference 物件又是在什麼時候被回收的呢 ?

image

筆者將會在本文中為大家解答上述這六個問題,如果螢幕前的你是一個 Java 小白,那麼恭喜你,接下來你可以無障礙地閱讀本文的內容,因為你心中沒有雜念,在探尋 Reference 本質的過程中不會被既定的思維模式所影響和誤導。你只需要始終牢記一點,就是這些 Reference 只是一個普通的 Java 類,它們的物件也只是一個普通的 Java 物件。 筆者不斷強調的這個概念看起來很傻,但是真的非常重要,因為它是我們探尋 Reference 本質的起點。

public abstract class Reference<T> {
}

public class SoftReference<T> extends Reference<T> {
}
public class WeakReference<T> extends Reference<T> {
}
public class PhantomReference<T> extends Reference<T> {
}

如果螢幕前的你是一個 Java 老司機,那麼請你!現在!立刻!馬上!忘掉所有關於 Reference 的既有概念和印象。就是把它當做一個普通的 Java 類來看待,重新以這個為起點,跟隨筆者的思路一步一步探尋 Reference 的本質。

image

下面我們就從一個具體的問題開始,來正式開啟本文的內容~~~

3. JVM 如何回收 DirectBuffer 背後的 NativeMemory

NIO 中的 DirectByteBuffer 究其本質而言,其實只是 OS 中的 Native Memory 在 JVM 中的一種封裝形式,DirectByteBuffer 本身非常具有迷惑性,因為 JVM 能夠察覺到的只是 DirectByteBuffer 這個 Java 例項在 JVM 堆中佔用的那麼一點點的記憶體,而 DirectByteBuffer 背後所引用的大片 OS 中的 Native Memory,JVM 是察覺不到的。

所以人們將 DirectByteBuffer 例項形象的比喻為 “冰山物件”,DirectByteBuffer 例項就是冰山上的那一角,位於 JVM 堆中,記憶體佔用非常小,它和普通的 Java 物件一樣,當沒有任何強引用或者軟引用的時候,將會被 GC 回收掉。

image

但位於冰山下面的這一大片 Native Memory,GC 是管不到的。也就是說 GC 只能回收 DirectByteBuffer 例項佔用的這一小部分 JVM 堆記憶體,但 GC 無法回收 DirectByteBuffer 背後引用的這一大片 Native Memory。

難道就只能讓 JVM 程序拿著這一大片 Native Memory 不放直到 OOM 嗎?這樣肯定是不行的,那 JVM 是如何回收 DirectByteBuffer 背後引用的這片 Native Memory 呢 ?接下來我們能不能試著看從 DirectByteBuffer 的建立過程中找到一些蛛絲馬跡。

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
    private final Cleaner cleaner;

    DirectByteBuffer(int cap) {                   // package-private

        ...... 省略 .....   
        // 檢查堆外記憶體整體用量是否超過了 -XX:MaxDirectMemorySize
        // 如果超過則嘗試等待一下 JVM 回收堆外記憶體,回收之後還不夠的話則丟擲 OutOfMemoryError
        Bits.reserveMemory(size, cap);   
        // 底層呼叫 malloc 申請虛擬記憶體
        base = UNSAFE.allocateMemory(size);

        ...... 省略 .....   

        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    }
}

DirectByteBuffer 的建立主要由下面的三大核心步驟來完成,JDK 會首先透過 Bits.reserveMemory 來檢查當前 JVM 程序的堆外記憶體用量是否超過了 -XX:MaxDirectMemorySize 指定的最大堆外記憶體限制。

如果已經超過了,則會在這裡進行一下補救,檢查一下當前是否有其他的 DirectByteBuffer 被 GC 掉,如果有則等待一下 JDK 去釋放這些被引用的 native memory。

如果釋放之後堆外記憶體容量還是不夠,那麼就觸發 System.gc() 嘗試看能不能再儘量回收一些沒用的 DirectByteBuffer,如果又回收了一些 DirectByteBuffer,則再次等待一下 JDK 去釋放這些被引用的 native memory。在這一系列的補救措施施行完之後如果堆外記憶體容量還是不夠,則觸發 OOM。

這裡只需要瞭解一下 Bits.reserveMemory 的核心邏輯即可,相關的 native memory 回收細節恰巧是本小節的主題,筆者後面會對這些細節進行介紹。

如果堆外記憶體容量足夠,則透過 UNSAFE.allocateMemory 向 OS 申請 native memory 。

最後一步非常關鍵,我們看到 DirectByteBuffer 內部關聯了一個 Cleaner 物件。這難道是巧合嗎 ?我們恰巧正在討論 native memory 回收的場景,從 Cleaner 的命名上來推測,顧名思義,這裡難道就是釋放 native memory 的地方嗎 ?

下面我們來透過 Cleaner.create 方法進入到 Cleaner 的內部試著看尋找一下答案:

3.1 Cleaner 機制的設計與實現

public class Cleaner extends PhantomReference<Object>
{      
        public static Cleaner create(Object ob, Runnable thunk) {
              if (thunk == null)
                  return null;
              return add(new Cleaner(ob, thunk));
        }   
        // Deallocator
        private final Runnable thunk;
        // 忽略這個 dummyQueue,Cleaner 並不會用到它
        private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue<>();

        private Cleaner(Object referent, Runnable thunk) {
            // 呼叫 PhantomReference 類的建構函式
            super(referent, dummyQueue);
            // Deallocator
            this.thunk = thunk;
        }
}

關鍵的資訊出現了,Cleaner 類原來繼承自 PhantomReference 型別,這裡要建立的 Cleaner 物件本質上其實就是一個 PhantomReference 物件。

JDK 首先會透過 Cleaner 類的私有構造方法構造出一個 PhantomReference 物件。構建引數 referent 就是我們建立好的 DirectByteBuffer,thunk 就是在 DirectByteBuffer 建構函式中傳入的 Deallocator,Deallocator 我們先不用管,這裡大家只需要記得 Cleaner 類中的 thunk 欄位其實指向的是 Deallocator 物件就可以了。

在 Cleaner 的父類建構函式構建 PhantomReference 物件的時候又傳入了一個 ReferenceQueue 型別的 dummyQueue,這個 ReferenceQueue 在 Reference 機制中是一個非常重要的概念,但本小節中我們不會用到它,不需要管。但大家需要記住這個 ReferenceQueue 很重要,筆者後面還會再次提起。

Reference 類中有一個很重要的欄位 referent,用來關聯 Reference 所引用的物件,這裡 referent 指向的是 DirectByteBuffer 物件。這裡大家先不用管什麼虛引用,弱引用,軟引用的語義,只需要知道 PhantomReference 就是一個普通的物件,物件中有一個普通的欄位 referent 現在指向了 DirectByteBuffer 物件。

public abstract class Reference<T> {
    // PhantomReference 虛引用的物件
    private T referent;
    // 暫時先不要管這個 ReferenceQueue,但它是非常重要的一個概念
    volatile ReferenceQueue<? super T> queue;

    Reference(T referent, ReferenceQueue<? super T> queue) {
        //  PhantomReference 虛引用了 DirectByteBuffer 物件
        this.referent = referent;
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }
}

當 Cleaner 被建立出來之後,DirectByteBuffer 就與 PhantomReference(Cleaner)發生了關聯。

image

除此之外,在 Cleaner 的內部還有一個雙向連結串列,由 first 指標指向雙向連結串列的頭結點。

public class Cleaner extends PhantomReference<Object>
{
    private static Cleaner first = null;

    private Cleaner next = null, prev = null;
}

每當一個 Cleaner 物件被建立出來之後,JDK 都會透過下面的 add 方法,將新的 Cleaner 物件採用頭插法插入到雙向連結串列中。

    private static synchronized Cleaner add(Cleaner cl) {
        if (first != null) {
            cl.next = first;
            first.prev = cl;
        }
        first = cl;
        return cl;
    }

image

JDK 這裡使用一個雙向連結串列來始終持有系統中所有 Cleaner 物件的目的其實就是為了讓這些 Cleaner 物件始終與 GcRoot 關聯,始終保持一條強引用鏈的存在。

這樣一來就可以保證被 Cleaner 物件虛引用的這個 DirectByteBuffer 物件,無論在它被 GC 回收之前還是回收之後,與它關聯的這個 Cleaner 物件始終保持活躍不會被 GC 回收掉,因為我們最終要依靠這個 Cleaner 物件來釋放 native memory 。

那麼這個 Cleaner 是如何完成釋放 native memory 的工作呢 ?我們來看它的 clean() 方法:

    // 具體資源的釋放邏輯封裝在 thunk 中
    // Deallocator 物件
    private final Runnable thunk;

    public void clean() {
        // 將該 Cleaner 物件從雙向連結串列中刪除,目的是斷開 Cleaner 物件的強引用鏈
        // 等到下一次 GC 的時候就可以回收這個 Cleaner 物件了
        if (!remove(this))
            return;
        try {
            // 進行資源清理
            thunk.run();
        } catch (final Throwable x) {
           .... 省略 ....
        }
    }

在執行 clean 方法的開始,JDK 首先會呼叫 remove 方法將當前 Cleaner 物件從其內部持有的這個全域性雙向連結串列中刪除。目的是斷開 Cleaner 物件的這個唯一的強引用鏈。

因為我們這裡執行的是 Cleaner 的資源清理操作,當 Cleaner 清理完資源之後,它就沒用了,所以要斷開它的強引用鏈,等到下一次 GC 的時候這個 Cleaner 物件就可以被回收了。

資源清理的核心操作封裝在 thunk 中,它是一個 Runnable 型別的物件,在 DirectByteBuffer 的建構函式中,我們傳入的是 Deallocator,Deallocator 中封裝了 native memory 的相關資訊,在它的 run 方法中透過 UNSAFE.freeMemory 將 native memory 進行釋放。

   private static class Deallocator implements Runnable
   {
        // native memory 的起始記憶體地址
        private long address;
        // OS 實際申請的 native memory 大小
        private long size;
        // DirectByteBuffer 的容量
        private int capacity;

        public void run() {
            if (address == 0) {
                // Paranoia
                return;
            }
            // 底層呼叫 free 來釋放 native memory
            UNSAFE.freeMemory(address);
            address = 0;
            // 更新 direct memory 的用量統計資訊
            Bits.unreserveMemory(size, capacity);
        }
    }

到這裡我們可以看出,Cleaner 為 JDK 提供了一種資源釋放的機制,所有引用 OS 資源的 Java 類其實都可以用這裡的 Cleaner 機制去完成 OS 資源的釋放,不光光是 DirectByteBuffer 。

我們只需要將 OS 相關資源的釋放動作封裝到一個 Runnable 物件中,然後在需要釋放資源的 Java 類的建構函式中透過 Cleaner.create 將自己的 this 指標與負責資源回收的 Runnable 物件傳遞進去構造一個 Cleaner 物件即可。

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
    private final Cleaner cleaner;

    DirectByteBuffer(int cap) {                   // package-private

        ...... 省略 .....   

        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    }
}

類似的用法還有很多,比如,我們透過 FileChannel#map 將檔案對映到 MappedByteBuffer 中的時候,JDK 會將記憶體對映區的相關資訊封裝在 Unmapper 類中,Unmapper 類中的資訊包括:

  • mmap 系統呼叫在程序地址空間真實對映出來的虛擬記憶體區域起始地址 addr 。

  • 虛擬記憶體區域真實的對映長度 mapSize 。

  • MappedByteBuffer 的容量大小。

  • 對映檔案的 file descriptor

  • MappedByteBuffer 的起始記憶體地址,等等。

public class FileChannelImpl extends FileChannel
{
   public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
  
        Unmapper unmapper = mapInternal(mode, position, size, prot, isSync);
   
        return Util.newMappedByteBuffer((int)unmapper.cap,
                    unmapper.address + unmapper.pagePosition,
                    unmapper.fd,
                    unmapper, isSync);    
    }
}

隨後會透過 Util.newMappedByteBuffer 利用 Unmapper 中封裝的檔案對映區資訊來建立 MappedByteBuffer,這裡我們可以看到其實底層建立的是一個 DirectByteBuffer 例項,因為 MappedByteBuffer 也是 DirectByteBuffer 的一種,只不過它位於程序虛擬記憶體空間中的檔案與匿名對映區域罷了。

image

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
   protected DirectByteBuffer(int cap, long addr,
                                     FileDescriptor fd,
                                     Runnable unmapper,
                                     boolean isSync, MemorySegmentProxy segment)
    {
        super(-1, 0, cap, cap, fd, isSync, segment);
        address = addr;
        cleaner = Cleaner.create(this, unmapper);
        att = null;
    }
}

在 MappedByteBuffer 的建立過程中,也用到了 Cleaner,只不過這裡傳入的 thunk (Runnable)不是 Deallocator 而是 Unmapper。

 private static abstract class Unmapper implements Runnable, UnmapperProxy {

    @Override
    public void run() {
            unmap();
    }

    public void unmap() {
            if (address == 0)
                return;
            // 底層呼叫 unmmap 系統呼叫,釋放由 mmap 對映出來的虛擬記憶體以及實體記憶體
            unmap0(address, size);
            address = 0;

            // if this mapping has a valid file descriptor then we close it
            if (fd.valid()) {
                try {
                    nd.close(fd);
                } catch (IOException ignore) {
                    // nothing we can do
                }
            }
        }
  }

當與 MappedByteBuffer 相關聯的 Cleaner 物件的 clean() 方法被呼叫的時候,就會觸發 Unmapper 中的 unmap() 方法來釋放由 mmap 對映出來的虛擬記憶體以及實體記憶體。

對 MappedByteBuffer 相關實現細節感興趣的同學,可以回看下 《從 Linux 核心角度探秘 JDK MappedByteBuffer》

好了,現在整個 Cleaner 機制的設計筆者就為大家介紹完了,但最關鍵的問題是 Cleaner 中的這個 clean() 方法到底是在什麼時候,又是被誰呼叫的呢 ?

我們還是先回歸到 Cleaner 本身,Cleaner 其實是一個 PhantomReference,在 JVM 中有一個 system thread 負責專門處理這些 Reference 物件,這個 system thread 就是 ReferenceHandler 執行緒,它是一個後臺守護執行緒,擁有最高的排程優先順序。那麼它在後臺究竟默默地幹了哪些事情呢 ?接下來我們就聊一聊這個 ReferenceHandler 執行緒。

3.2 ReferenceHandler 執行緒

ReferenceHandler 執行緒是在 Reference 類被載入的時候建立的,從下面的建立過程我們可以看到,ReferenceHandler 執行緒被建立成一個 system thread,擁有最高的排程優先順序 —— Thread.MAX_PRIORITY,這樣可以盡最大可能保證 ReferenceHandler 執行緒被及時的排程到,目的是可以讓它及時地去處理那些需要被處理的 Reference 物件。

public abstract class Reference<T> {

    static {
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        // 獲取 system thread group
        for (ThreadGroup tgn = tg;
             tgn != null;
             tg = tgn, tgn = tg.getParent());
        // 建立 system thread : ReferenceHandler
        Thread handler = new ReferenceHandler(tg, "Reference Handler");
        // 設定 ReferenceHandler 執行緒的優先順序為最高優先順序
        handler.setPriority(Thread.MAX_PRIORITY);
        handler.setDaemon(true);
        handler.start();  
    }
}

那麼問題是 ReferenceHandler 執行緒怎麼知道哪些 Reference 物件需要被它處理,哪些 Reference 物件暫時不需要它處理呢 ?也就是說我們平常在程式碼中定義的這些 Reference 物件,比如:PhantomReference,WeakReference,SoftReference,它們被 ReferenceHandler 執行緒處理的時機是什麼 ?

筆者繼續以 Cleaner 這個 PhantomReference 為例進行說明,Cleaner 被設計出來是幹嗎的 ?當然是用來釋放被虛引用的 Java 物件背後所持有的 OS 資源,比如,本小節中介紹的 DirectByteBuffer 背後引用的大片 native memory。

image

那麼什麼時候需要這個 Cleaner 去清理資源呢 ? 當然是這個 DirectByteBuffer 沒有任何強引用或者軟引用的時候,也就是 DirectByteBuffer 不會再被使用的時候。如果此時發生 GC,那麼 JVM 將不會把這個 DirectByteBuffer 物件標記為 alive,不會被標記為 alive 的物件將會被 GC 清理。

在 GC 的標記階段結束的時候,如果一個 Java 物件沒有被標記為 alive,那麼與其相關聯的 Reference 物件,比如:PhantomReference ,WeakReference。就會被 JVM 採用頭插法放入到一個叫做 _reference_pending_list 的內部連結串列中。

// zReferenceProcessor.cpp 檔案
OopHandle Universe::_reference_pending_list;

// Create a handle for reference_pending_list
 _reference_pending_list = OopHandle(vm_global(), NULL);

_reference_pending_list 連結串列中的 Reference 物件透過 Reference 類中的 discovered 欄位相互連線。

public abstract class Reference<T> {
     private transient Reference<?> discovered;
}

當一個 Reference 物件被 JVM 放入 _reference_pending_list 連結串列之後,JVM 就會將 Reference 物件中的 referent 欄位設定為 null,清除它的引用關係(虛引用 or 弱引用 or 軟引用)。

public abstract class Reference<T> {
     private T referent; 
}

對於 WeakReference , SoftReference 物件來說,此時如果我們呼叫它們的 get 方法將會得到一個 null 。

 public T get() {
        return this.referent;
 }

對於 Cleaner 這個 PhantomReference 來說,此時 Cleaner 與 DirectByteBuffer 的虛引用關係就被 JVM 清除了。 GC 標記階段結束之後,DirectByteBuffer 物件就會被清理掉。

image

也就是說,當 GC 結束之後,_reference_pending_list 連結串列中儲存的這些 Cleaner 物件,它們虛引用的 DirectByteBuffer 就已經被回收掉了,此時我們就需要從這個 _reference_pending_list 中把這些 Cleaner 物件一個一個的拿下來,然後呼叫它的 clean 方法,在 Deallocator 中將這些已經被回收掉的 DirectByteBuffer 背後引用的 native memory 釋放掉就可以了。

這個正是 ReferenceHandler 執行緒所要乾的事情,在它的 run 方法中會不停的呼叫 processPendingReferences,從 JVM 的 _reference_pending_list 中不斷地將 Cleaner 物件摘下來,呼叫它的 clean 方法釋放 native memory。

當然了 _reference_pending_list 連結串列中儲存的不僅僅是 Cleaner 這個 PhantomReference,還有 WeakReference,SoftReference 物件,但它們共同的特點是這些 Reference 物件所引用的 Java 物件都已經被回收了。

    private static class ReferenceHandler extends Thread {
        public void run() {
            while (true) {
                processPendingReferences();
            }
        }
    }

但是這個 _reference_pending_list 它是 JVM 內部維護的一個連結串列,它只能在 JVM 內被 GC 執行緒操作,我們在外部是無法訪問到的。

從 Reference 類中我們就可以看到,Reference 類的內部並沒有一個叫做 pending_list 的欄位去指向 JVM 內部的 _reference_pending_list,在 JDK 中,所有針對 _reference_pending_list 的訪問都是透過 native 方法進行的。

public abstract class Reference<T> {
  /*
     * Atomically get and clear (set to null) the VM's pending-Reference list.
     */
    private static native Reference<?> getAndClearReferencePendingList();

    /*
     * Test whether the VM's pending-Reference list contains any entries.
     */
    private static native boolean hasReferencePendingList();

    /*
     * Wait until the VM's pending-Reference list may be non-null.
     */
    private static native void waitForReferencePendingList();
}

當 _reference_pending_list 中沒有 Reference 物件要處理的時候,ReferenceHandler 執行緒會透過 native 方法 —— waitForReferencePendingList ,在 _reference_pending_list 上阻塞等待。

// Reference.c 檔案
JNIEXPORT void JNICALL
Java_java_lang_ref_Reference_waitForReferencePendingList(JNIEnv *env, jclass ignore)
{
    JVM_WaitForReferencePendingList(env);
}

// jvm.cpp 檔案
JVM_ENTRY(void, JVM_WaitForReferencePendingList(JNIEnv* env))
  MonitorLocker ml(Heap_lock);
  while (!Universe::has_reference_pending_list()) {
    // 如果 _reference_pending_list 還沒有 Reference 物件,那麼當前執行緒在 Heap_lock 上 wait
    ml.wait();
  }
JVM_END

// universe.cpp 檔案
bool Universe::has_reference_pending_list() {
  assert_pll_ownership();
  // 檢查 _reference_pending_list 是否為空
  return _reference_pending_list.peek() != NULL;
}

隨後 GC 執行緒在標記階段結束之後,會將那些需要被處理的 Reference 物件放到 _reference_pending_list 中,然後喚醒 ReferenceHandler 執行緒去處理。

當 ReferenceHandler 執行緒被 JVM 喚醒之後,就會呼叫 native 方法 —— getAndClearReferencePendingList ,從 JVM 中獲取 _reference_pending_list,並儲存到一個型別為 Reference 的區域性變數 pendingList 中,最後將 JVM 中的 _reference_pending_list 置為 null,方便 JVM 等到下一輪 GC 的時候處理其他 Reference 物件。

// Reference.c 檔案
JNIEXPORT jobject JNICALL
Java_java_lang_ref_Reference_getAndClearReferencePendingList(JNIEnv *env, jclass ignore)
{
    return JVM_GetAndClearReferencePendingList(env);
}

// jvm.cpp 檔案
JVM_ENTRY(jobject, JVM_GetAndClearReferencePendingList(JNIEnv* env))
  MonitorLocker ml(Heap_lock);
  // 從 JVM 中獲取 _reference_pending_list
  oop ref = Universe::reference_pending_list();
  if (ref != NULL) {
    // 將 JVM 中的 _reference_pending_list 置為 null
    // 方便下一輪 GC 處理其他 reference 物件 
    Universe::clear_reference_pending_list();
  }
  // 將 _reference_pending_list 返回給 ReferenceHandler 執行緒
  return JNIHandles::make_local(THREAD, ref);
JVM_END

// universe.cpp 檔案
oop Universe::reference_pending_list() {
  if (Thread::current()->is_VM_thread()) {
    assert_pll_locked(is_locked);
  } else {
    assert_pll_ownership();
  }
  // 返回 _reference_pending_list
  return _reference_pending_list.resolve();
}

void Universe::clear_reference_pending_list() {
  assert_pll_ownership();
  // 將 _reference_pending_list 設定為 null
  _reference_pending_list.replace(NULL);
}

當我們熟悉了 ReferenceHandler 執行緒如何與 JVM 中的 _reference_pending_list 互動之後,再回過頭來看 processPendingReferences() 方法就很清晰了,這裡正是 ReferenceHandler 執行緒的核心所在。

image

    private static final Object processPendingLock = new Object();
    private static boolean processPendingActive = false;

    private static void processPendingReferences() {
        // ReferenceHandler 執行緒等待 JVM 向 _reference_pending_list 填充 Reference 物件
        // GC 之後,如果有 Reference 物件需要處理,JVM 則將 ReferenceHandler 執行緒 喚醒
        waitForReferencePendingList();
        // 用於指向 JVM 的 _reference_pending_list
        Reference<?> pendingList;
        synchronized (processPendingLock) {
            // 獲取 _reference_pending_list,隨後將 _reference_pending_list 置為 null
            // 方便 JVM 在下一輪 GC 處理其他 Reference 物件
            pendingList = getAndClearReferencePendingList();
            // true 表示 ReferenceHandler 執行緒正在處理 pendingList
            processPendingActive = true;
        }
        // 將 pendingList 中的 Reference 物件挨個從連結串列中摘下處理
        while (pendingList != null) {
            // 從 pendingList 中摘下 Reference 物件
            Reference<?> ref = pendingList;
            pendingList = ref.discovered;
            ref.discovered = null;
            
            // 如果該 Reference 物件是 Cleaner 型別,那麼在這裡就會呼叫它的 clean 方法
            if (ref instanceof Cleaner) {
                 // Cleaner 的 clean 方法就是在這裡呼叫的
                ((Cleaner)ref).clean();
          
                synchronized (processPendingLock) {
                    // 將等待在 processPendingLock 上的其他 Java 業務執行緒喚醒
                    processPendingLock.notifyAll();
                }
            } else {
                // 這裡處理除 Cleaner 之外的其他 Reference 物件
                // 比如,其他 PhantomReference,WeakReference,SoftReference,FinalReference
                // 將他們新增到對應的 ReferenceQueue 中
                ref.enqueueFromPending();
            }
        }
        // 本次 GC 收集到的 Reference 物件到這裡就全部處理完了
        synchronized (processPendingLock) {
            // false 表示 ReferenceHandler 執行緒已經處理完畢 pendingList
            processPendingActive = false;
            // 喚醒其他等待在 processPendingLock 上 Java 執行緒。
            processPendingLock.notifyAll();
        }
    }

這裡我們先聊一聊 processPendingLock 和 processPendingActive 的作用,它倆的目的是為了維護 ReferenceHandler 執行緒和 Java 業務執行緒對於 Reference 處理進度檢視的一致性。

什麼意思呢 ?我們還是拿 Cleaner 來說,對於 Cleaner 而言,ReferenceHandler 執行緒扮演的其實是一個資源回收的角色,Java 業務執行緒扮演的其實是一個資源申請的角色。

當 Java 執行緒申請資源的時候,如果資源不夠了怎麼辦呢 ?首先應該想到的是先去看看當前系統中有沒有正在被回收的資源,對吧。

  • 如果 processPendingActive 為 true,表示 ReferenceHandler 執行緒正在處理 Cleaner 釋放資源

  • 如果 JVM 中的 _reference_pending_list 不為空,說明當前系統中有正在等待回收的資源。

如果以上兩個條件成立的話,那麼 Java 執行緒就在 processPendingLock 上等一等,等待 ReferenceHandler 執行緒把該回收的資源回收完。當本次 GC 所收集到的所有 Cleaner 都已經被 ReferenceHandler 執行緒處理完之後,ReferenceHandler 執行緒就會 notify 在 processPendingLock 上等待的 Java 執行緒,隨後 Java 執行緒再去嘗試申請資源。

如果當前系統中並沒有正在被回收的資源,比如下面兩個條件:

  • processPendingActive 為 false,表示 ReferenceHandler 執行緒已經將本次 GC 收集到的 Cleaner 全部處理完了。

  • 如果 JVM 中的 _reference_pending_list 為空,說明當前系統中沒有可回收的資源。

這種情況下 Java 執行緒就不需要在 processPendingLock 上等待了,要麼就直接觸發 OOM,要麼就呼叫 System.gc 嘗試讓 GC 在去收集一些需要被處理的 Cleaner。

明白了這些,我們接著來看 ReferenceHandler 執行緒處理 Reference 的主線流程。

首先 ReferenceHandler 執行緒會將 pendingList 中的 Reference 依次從連結串列上摘下,隨後會判斷一下這個 Reference 物件的具體型別,如果是 Cleaner 型別的話,ReferenceHandler 執行緒就會在這裡呼叫它的 clean 方法。

    public void clean() {
        if (!remove(this))
            return;
        try {
            // 進行資源清理
            thunk.run();
        } catch (final Throwable x) {
           .... 省略 ....
        }
    }

在 clean 方法中,會執行 thunk 的 run 方法,在建立 DiretByteBuffer 的時候會將 thunk 指向一個 Deallocator 例項。

   private static class Deallocator implements Runnable
   {
        public void run() {    
            // 底層呼叫 free 來釋放 native memory
            UNSAFE.freeMemory(address);
        }
    }

當執行完 clean 方法之後,其實 Cleaner 的歷史使命就完成了,但別忘了,Cleaner 內部有一個全域性的雙向連結串列,裡邊強引用著所有的 Cleaner 物件。

public class Cleaner extends PhantomReference<Object>
{
    private static Cleaner first = null;

    private Cleaner next = null, prev = null;
}

image

所以為了讓 Cleaner 可以在下一輪 GC 中被回收掉,需要呼叫 remove 方法將這個 Cleaner 物件從雙向連結串列中摘下,斷開這個唯一的強引用。等到下次 GC 的時候,這個 Cleaner 物件就可以被回收了。

好了,現在關於 Cleaner 處理的完整生命週期,筆者就為大家介紹完了,但在 pendingList 中除了 Cleaner 之外還有其他型別的 Reference 物件,比如,其他 PhantomReference,WeakReference,SoftReference,FinalReference。

當 ReferenceHandler 執行緒發現 pendingList 中的 Reference 物件不是 Cleaner,那麼就會呼叫 enqueueFromPending 方法,將這個 Reference 物件新增到與其對應的 ReferenceQueue 中。

public abstract class Reference<T> {

   volatile ReferenceQueue<? super T> queue;

   private void enqueueFromPending() {
        var q = queue;
        if (q != ReferenceQueue.NULL) q.enqueue(this);
    }
}

從這裡我們可以看到,JVM 中的 _reference_pending_list 連結串列中收集到的 Reference 物件最終是會被 ReferenceHandler 執行緒轉移到這個 ReferenceQueue 中的。位於 ReferenceQueue 中的 Reference 物件會透過它的 next 欄位串聯起來。

public abstract class Reference<T> {
    volatile Reference next;
}

而這個 ReferenceQueue 我們是可以在 Java 業務執行緒中直接訪問的,它是一個全域性的佇列。當被 Reference 物件引用的普通 Java 物件被回收之後,如果我們想要在 Java 業務執行緒中處理這個 Reference 物件,那麼就必須在建立 Reference 物件的時候,傳入一個全域性的 ReferenceQueue

    Reference(T referent, ReferenceQueue<? super T> queue) {
        // 被引用的普通 Java 物件
        this.referent = referent;
        // 全域性 ReferenceQueue 例項
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }

當 referent 物件被回收之後,那麼與它關聯的 Reference 物件隨後就會被 ReferenceHandler 執行緒轉移到這個 ReferenceQueue 中。JDK 提供了兩種方式讓我們能夠在 Java 業務執行緒中從 ReferenceQueue 獲取 Reference 物件。

一個是 poll() 函式,它是一種非阻塞的方式,當 ReferenceQueue 中沒有任何 Reference 物件的時候,呼叫 poll() 就會返回 null 。

public Reference<? extends T> poll() 

另一個是 remove(long timeout) 函式,它是一種阻塞的方式,當 ReferenceQueue 中沒有任何 Reference 物件的時候,呼叫 remove 的時候 Java 業務執行緒就會一直阻塞,直到 ReferenceQueue 中有 Reference 物件被新增進來。我們也可以傳入 timeout (單位為:ms) 來設定阻塞的超時時間。

 public Reference<? extends T> remove(long timeout)
        throws IllegalArgumentException, InterruptedException

下面是 ReferenceQueue 的簡單使用示例:

 public static void main(String[] args) throws Exception{

        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();

        Object phantomObject = new Object();
        Reference phantomReference = new  PhantomReference(phantomObject,referenceQueue);

        Object weakObject = new Object();
        Reference weakReference = new WeakReference(weakObject, referenceQueue);

        Object softObject = new Object();
        Reference softReference = new SoftReference(softObject, referenceQueue);

        phantomObject = null;
        weakObject = null;
        // 當記憶體不足的時候,softObject 才會被回收
        softObject = null;
        
        System.gc();

        Reference getReferenceByPoll = referenceQueue.poll();

        long timeout = 1000;
        Reference getReferenceByRemove = referenceQueue.remove(timeout);
    }

當 phantomReference 虛引用的 phantomObject 以及 weakReference 弱引用的 weakObject,softReference 軟引用的 softObject 被 GC 回收掉的時候,ReferenceHandler 執行緒就會將這裡的 phantomReference,weakReference,softReference 轉移到 referenceQueue 中。

我們在 Java 業務執行緒透過呼叫 referenceQueue 的 poll() 方法或者 remove(timeout) 方法就可以將這些 reference 物件拿出來進行相關處理。

下面筆者繼續以 WeakHashMap 為例,來說明一下 ReferenceQueue 的使用場景,首先 WeakHashMap 的內部包含了一個 ReferenceQueue 的例項 —— queue,WeakHashMap 的底層資料結構是一個雜湊表 —— table,table 中的元素型別為 Entry 結構。

public class WeakHashMap<K,V> extends AbstractMap<K,V>  implements Map<K,V> {
    Entry<K,V>[] table;
    private final ReferenceQueue<Object> queue = new ReferenceQueue<>();
}

Entry 是一個 WeakReference,弱引用了 key,強引用了 value,在構造 Entry 例項的時候需要傳入一個 ReferenceQueue,當 key 被 GC 回收的時候,這個 Entry 例項就會被 ReferenceHandler 執行緒從 JVM 中的 _reference_pending_list 轉移到這裡的 ReferenceQueue 中。

    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;

        /**
         * Creates new entry.
         */
        Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
}

從 WeakHashMap 的 put 方法實現中我們可以看到,構建 Entry 例項的時候傳入的這個 ReferenceQueue 正是 WeakHashMap 內部的 queue 例項。

    public V put(K key, V value) {
        Object k = maskNull(key);
        int h = hash(k);
        Entry<K,V>[] tab = getTable();
        int i = indexFor(h, tab.length);

        ...... 省略 ......

        Entry<K,V> e = tab[i];
        // 建立 Entry 的時候會傳入 ReferenceQueue
        tab[i] = new Entry<>(k, value, queue, h, e);

        return null;
    }

image

當 Entry 物件中的 key 在 WeakHashMap 之外存在強引用的時候,那麼 key 是不會被 GC 回收的。當這個強引用被斷開之後,發生 GC 的時候,這個 key 就會被 GC 回收掉,以此同時,與 key 關聯的這個 Entry 物件(WeakReference)就會被 JVM 放入 _reference_pending_list 中。

隨後 ReferenceHandler 執行緒會將 Entry 物件從 _reference_pending_list 中轉移到 WeakHashMap 內部的這個 ReferenceQueue 中。

image

從這裡我們也可以看到,ReferenceQueue 中儲存的正是 WeakHashMap 所有已經被 GC 回收的 key 對應的 Entry 物件。key 都已經被回收了,那麼這個 Entry 物件以及其中的 value 也沒什麼用了。

呼叫 WeakHashMap 的任意方法都會觸發對 ReferenceQueue 的檢測,遍歷 ReferenceQueue,將佇列中所有的 Entry 物件以及其中的 value 清除掉,當下一次 GC 的時候,這些 Entry 物件以及 value 就可以被回收了,防止記憶體洩露的發生。

    private void expungeStaleEntries() {
        for (Object x; (x = queue.poll()) != null; ) {
            synchronized (queue) {

             ... 將 ReferenceQueue 中的 Entry 全部從 WeakHashMap 中刪除 ...

            }
        }
    }

到現在,我們已經清楚了 Reference 物件的整個生命週期,並且也明白了 ReferenceHandler 執行緒的核心邏輯,最後我們在回過頭來看一下在建立 DirectByteBuffer 的時候,Bits.reserveMemory 函式到底做了哪些事情 ?

3.3 Bits.reserveMemory

當我們使用 ByteBuffer#allocateDirect 來向 JVM 申請 direct memory 的時候,direct memory 的容量是受到 -XX:MaxDirectMemorySize 引數限制的,在 ZGC 中 -XX:MaxDirectMemorySize 預設為堆的最大容量(-Xmx)。

class DirectByteBuffer extends MappedByteBuffer implements DirectBuffer
{
    private final Cleaner cleaner;

    DirectByteBuffer(int cap) {                   // package-private

        ...... 省略 .....   
        // 檢查堆外記憶體整體用量是否超過了 -XX:MaxDirectMemorySize
        // 如果超過則嘗試等待一下 JVM 回收堆外記憶體,回收之後還不夠的話則丟擲 OutOfMemoryError
        Bits.reserveMemory(size, cap);   
        // 底層呼叫 malloc 申請虛擬記憶體
        base = UNSAFE.allocateMemory(size);

        ...... 省略 .....   

        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    }
}

所以在建立 DirectByteBuffer 之前,需要透過 Bits.reserveMemory 來檢查一下當前 direct memory 的使用量是否已經超過了 -XX:MaxDirectMemorySize 的限制,如果超過了就需要進行一些補救的措施,嘗試去回收一部分 direct memory 用以滿足本次申請的容量需求。

檢查當前 direct memory 使用量是否超過限制的邏輯在 tryReserveMemory 函式中完成:

    // -XX:MaxDirectMemorySize 最大允許使用的 direct memory 容量
    private static volatile long MAX_MEMORY = VM.maxDirectMemory();
    // 向 OS 實際申請的記憶體,考慮到記憶體對齊的情況,實際向 OS 申請的記憶體會比指定的 cap 要多
    private static final AtomicLong RESERVED_MEMORY = new AtomicLong();
    // 已經使用的 direct memory 總量 
    private static final AtomicLong TOTAL_CAPACITY = new AtomicLong();

    private static boolean tryReserveMemory(long size, long cap) {

        long totalCap;
        while (cap <= MAX_MEMORY - (totalCap = TOTAL_CAPACITY.get())) {
            if (TOTAL_CAPACITY.compareAndSet(totalCap, totalCap + cap)) {
                RESERVED_MEMORY.addAndGet(size);
                COUNT.incrementAndGet();
                return true;
            }
        }
        // 已經超過了最大 direct memory 容量的限制則返回 false
        return false;
    }

如果 tryReserveMemory 返回 false 表示當前系統中 direct memory 的使用量已經超過了 -XX:MaxDirectMemorySize 的限制,隨後就會呼叫 waitForReferenceProcessing 檢查一下當前系統中是否還有待處理的 Reference 物件(Cleaner)沒有處理。

如果有的話,就讓當前 Java 業務執行緒在 processPendingLock 上等待一下,目的是等待 ReferenceHandler 執行緒去呼叫 Cleaner 釋放 direct memory。等到 ReferenceHandler 執行緒處理完這些 Cleaner 就會將當前業務執行緒從 processPendingLock 上喚醒。

隨後 waitForReferenceProcessing 方法返回 true ,表示 _reference_pending_list 中的的這些 Cleaner 已經被 ReferenceHandler 執行緒處理完了,又釋放了一些 direct memory。

如果當前系統中沒有待處理的 Cleaner , 那麼就返回 false ,說明系統中已經沒有任何可回收的 direct memory 了。

public abstract class Reference<T> {

    private static boolean waitForReferenceProcessing()
        throws InterruptedException
    {
        synchronized (processPendingLock) {
            // processPendingActive = true 表示 ReferenceHandler 執行緒正在處理 PendingList 中的 Cleaner,那麼就等待 ReferenceHandler 處理完
            // hasReferencePendingList 檢查 JVM 中的 _reference_pending_list 是否包含待處理的 Reference 物件
            // 如果還有待處理的 Reference,那麼也等待一下
            if (processPendingActive || hasReferencePendingList()) {
                // 等待 ReferenceHandler 執行緒處理 Cleaner 釋放 direct memory
                processPendingLock.wait();
                return true;
            } else {
                // 當前系統中沒有待處理的 Reference,直接返回 false
                return false;
            }
        }
    }
}

當 Java 業務執行緒從 waitForReferenceProcessing 上喚醒之後,如果 ReferenceHandler 執行緒已經回收了一些 direct memory(返回 true),那麼就嘗試再次呼叫 tryReserveMemory 檢查一下當前系統中剩餘的 direct memory 容量是否滿足本次申請的需要。

如果還是不滿足,那麼就迴圈呼叫 waitForReferenceProcessing 持續檢視當前系統是否有可回收的 direct memory,如果確實沒有任何 direct memory 可以被回收了(返回 false)那麼就退出迴圈。

退出迴圈之後,那麼就說明當前系統中已經沒有可回收的 direct memory 了,這種情況下 JDK 就會呼叫 System.gc() 來立即觸發一次 Full GC,嘗試讓 JVM 在去回收一些沒有任何強引用的 directByteBuffer。

如果當前系統中確實存在一些沒有任何強引用的 directByteBuffer,那麼本輪 GC 就可以把它們回收掉,於此同時,與這些 directByteBuffer 關聯的 Cleaner 也會被 JVM 放入 _reference_pending_list 中。

那麼 JDK 就會再次呼叫 waitForReferenceProcessing 去等待 ReferenceHandler 執行緒處理這些 Cleaner 釋放 direct memory。等到 ReferenceHandler 執行緒處理完之後,再去呼叫 tryReserveMemory 檢視當前 direct memory 的容量是否滿足本次申請的需要。

如果還是不滿足,但本次 GC 回收的 Cleaner 已經全部被執行完了,系統中已經沒有可回收的 direct memory 了,那該怎麼辦呢 ?

此時 JDK 再去呼叫 waitForReferenceProcessing 就會返回 false,最後的一個補救措施就是讓當前 Java 業務執行緒在一個 while (true) 迴圈中睡眠 —— Thread.sleep(sleepTime), 最多睡眠 9 次,每次睡眠時間按照 1, 2, 4, 8, 16, 32, 64, 128, 256 ms 依次遞增,目的是等待其他執行緒觸發 GC,嘗試看看後面幾次的 GC 是否能回收到一些 direct memory。

這裡不讓當前執行緒繼續觸發 System.gc 的目的是,我們剛剛已經觸發一輪 GC 了,仍然沒有回收到足夠的 direct memory,那如果再次立即觸發 GC ,收效依然不會很大,所以這裡選擇等待其他執行緒去觸發。

如果在睡眠了 9 次之後,也就是嘗試等待 511 ms 之後,依然沒有足夠的 direct memory ,那麼就丟擲 OOM 異常。

JDK 這裡選擇連續睡眠的應對場景還有另外一種,如果 System.gc() 觸發的是一次 Concurrent Full GC,那麼 Java 業務執行緒是可以與 GC 執行緒一起併發執行的。

此時 JDK 去呼叫 waitForReferenceProcessing 有很大可能會返回 false,因為 GC 執行緒可能還沒有遍歷標記到 Cleaner 物件,自然 JVM 中的 _reference_pending_list 啥也沒有。

連續睡眠應對的就是這種併發執行的情況,每次睡眠時間由短逐漸變長,儘可能及時的感知到 _reference_pending_list 中的變化。

以上就是 Bits.reserveMemory 函式的核心邏輯,明白這些之後,在看原始碼的實現就很清晰了。

image

    static void reserveMemory(long size, long cap) {
         // 首先檢查一下 direct memory 的使用量是否已經超過了 -XX:MaxDirectMemorySize 的限制
        if (tryReserveMemory(size, cap)) {
            return;
        }

        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
        boolean interrupted = false;
        try {      
            boolean refprocActive;
            do {
                try {
                    // refprocActive = true 表示 ReferenceHandler 執行緒又釋放了一些 direct memory
                    // refprocActive = false 表示當前系統中沒有待處理的 Cleaner,系統中已經沒有任何可回收的 direct memory 了
                    refprocActive = jlra.waitForReferenceProcessing();
                } catch (InterruptedException e) {
                    // Defer interrupts and keep trying.
                    interrupted = true;
                    refprocActive = true;
                }
                // 再次檢查 direct memory 的容量是否能夠滿足本次分配請求
                if (tryReserveMemory(size, cap)) {
                    return;
                }
            } while (refprocActive);

            // 此時系統中已經沒有任何可回收的 direct memory 了
            // 只能觸發 gc,嘗試讓 JVM 再去回收一些沒有任何強引用的 directByteBuffer
            System.gc();
             
            // 下面開始睡眠等待 ReferenceHandler 執行緒呼叫 Cleaner 釋放 direct memory
            // 初始睡眠時間, 單位 ms
            long sleepTime = 1;
            // 睡眠次數,最多睡眠 9 次
            int sleeps = 0;
            while (true) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
                // MAX_SLEEPS = 9
                if (sleeps >= MAX_SLEEPS) {
                    break;
                }
                try {
                    // 等待 ReferenceHandler 執行緒處理 Cleaner 釋放 direct memory (返回 true)
                    // 當前系統中沒有任何可回收的 direct memory,則 Thread.sleep 睡眠 (返回 false)
                    if (!jlra.waitForReferenceProcessing()) {
                        // 睡眠等待其他執行緒觸發 gc,嘗試看看後面幾輪 gc 是否能夠回收到一點 direct memory
                        // 最多睡眠 9 次,每次睡眠時間按照 1, 2, 4, 8, 16, 32, 64, 128, 256 ms 依次遞增
                        Thread.sleep(sleepTime);
                        sleepTime <<= 1;
                        sleeps++;
                    }
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }

            // 在嘗試回收 direct memory 511 ms 後觸發 OOM
            throw new OutOfMemoryError
                ("Cannot reserve "
                 + size + " bytes of direct buffer memory (allocated: "
                 + RESERVED_MEMORY.get() + ", limit: " + MAX_MEMORY +")");

        } finally {
        }
    }

從上面 Bits.reserveMemory 的原始碼實現中我們可以體會到,監控當前 JVM 程序 direct memory 的使用量是非常重要的,如果 direct memory 的使用量達到了 -XX:MaxDirectMemorySize 的限制,那麼此時我們再去透過 ByteBuffer#allocateDirect 來向 JVM 申請 direct memory 的話,就會引起很大的阻塞延遲。

首先當前執行緒會阻塞在 processPendingLock 上去等待 ReferenceHandler 執行緒去處理 Cleaner 釋放 direct memory。

如果當前系統中沒有可回收的 direct memory,當前執行緒又會觸發一次 Full GC,如果 Full GC 之後也沒有回收足夠的 direct memory 的話,當前執行緒還會去睡眠等待其他執行緒觸發 GC,極端的情況下需要睡眠 9 次,也就是說在 511 ms 之後才會去觸發 OOM。所以監控系統中 direct memory 的用量是非常非常重要的。

4. JVM 如何實現 Reference 語義

在前面的幾個小節中,筆者為大家全面且詳細地介紹了 Reference 在各中介軟體以及 JDK 中的應用場景,相信大家現在對於在什麼情況下該使用哪種具體的 Reference, JDK 又如何處理這些 Reference 的流程已經非常清晰了。

但這還遠遠不夠,因為我們一直還沒觸達到 Reference 的本質,而在經過前面三個小節的內容鋪墊之後,筆者將會在本小節中,帶著大家深入到 JVM 內部,去看看 JVM 到底是如何實現 PhantomReference,WeakReference,SoftReference 以及 FinalReference 相關語義的。

在這個過程中,筆者會把之前在概念層面介紹的各種 Reference 語義,一一對映到原始碼級別的實現上,讓大家從抽象層面再到具象層面徹底理解 Reference 的本質。大家對各種 Reference 的模糊理解,在後面的內容中都會得到清晰的解答。

哈哈,鋪墊了這麼久,終於和標題開始掛上鉤了

但在本小節的內容開始之前,筆者想和大家明確兩個概念,因為後面筆者不想在用大段的語言重複解釋他們。JDK 對於引用類的設計層級有兩層,PhantomReference,WeakReference,SoftReference 以及 FinalReference 都繼承於 Reference 類中。

image

後續筆者將會用 Reference 這個概念來統稱以上四種引用型別,除非遇到不同 Reference 的語義實現時,筆者才會特殊指明具體的 Reference 型別。而 Reference 所引用的普通 Java 物件,存放在 referent 欄位中,後面我們將會用 referent 來統一指代被引用的 Java 物件。

public abstract class Reference<T> {
  private T referent;
}

好了,在我們明確了 Reference 和 referent 的概念之後,筆者先向大家拋一個問題出來,大家可以先自己思考下。我們都知道,只要一個 Java 物件存在從 GcRoot 到它的強引用鏈,那麼這個 Java 物件就會被 JVM 標記為 alive,本輪 GC 就不會回收它。

這個強引用鏈是什麼呢 ?其實就是 GC 根物件的所有非靜態成員變數,而這些非靜態的成員變數也會引用到其他 Java 物件,這些 Java 物件也會有自己的非靜態成員變數,這些成員變數又會引用到其他 Java 物件,這就慢慢形成了從 GC 根物件出發的有向引用關係圖,這個引用關係圖就是強引用鏈。

image

如果按照這種思路的話,那麼從本質上來說 Reference 類也是一個普通的 Java 類,它的例項也是一個普通的物件例項,referent 也是它的一個成員變數,按理說,JVM 也可以從 GcRoot 開始遍歷到 Reference 物件,近而透過它的成員變數 referent 遍歷到被它引用的普通 Java 物件。

這裡我們先不用考慮什麼軟引用,弱引用,虛引用的概念,我們只從本質上來說,JVM 是不是也可以透過這條引用鏈將 referent 標記為 alive 呢 ?那為什麼在 GC 的時候,這個 referent 就被當做垃圾回收了呢 ?

image

這裡筆者先以 WeakReference 為例說明,事實上 JVM 對於 PhantomReference,WeakReference,SoftReference 以及 FinalReference 的處理總體上都是一樣的,只不過對於 PhantomReference,SoftReference,FinalReference 會進行一些小小的特殊處理,這個筆者後面會放到單獨的小節中討論。我們先以 WeakReference 來說明 JVM 對於 Reference 的總體處理流程。

要明白這個問題,我們就需要弄明白 JVM 在 GC 的時候是如何遍歷物件的引用關係圖的,在處理普通 Java 物件的引用關係時和處理 Reference 物件的引用關係時有何不同 ?

4.1 JVM 如何遍歷物件的引用關係圖

這裡筆者要再次提醒大家,現在請你立刻,馬上忘掉腦海中關於軟引用,弱引用,虛引用的所有概念,讓我們迴歸 Reference 類的本質,它其實就是一個普通的 Java 類,Reference 相關的例項就是一個普通的 Java 物件。

image

看透了這一層,剩下的就好辦了,現在這些所有的問題最終匯結成了 —— JVM 如何遍歷普通 Java 物件的引用關係圖。我們先從一個簡單的例子開始~~

JVM 從 GcRoot 開始遍歷,期間遇到的每一個物件,在 JVM 看來就是活躍的,當 GC 執行緒遍歷到一個物件時,就會將這個物件標記為 alive,然後在看這個物件的非靜態成員變數引用了哪些物件,順藤摸瓜,沿著這些成員變數所引用的物件繼續標記 alive,直到所有的引用關係圖被遍歷完。

現在問題的關鍵就是 JVM 如何找到物件中的這些成員變數,而物件的本質其實就是一段記憶體,當我們透過 new 關鍵字分配物件的時候,JVM 首先會為這個物件分配一段記憶體,然後根據 Java 的物件模型初始化這段記憶體。下圖中展示的就是 Java 物件的記憶體模型,其中存放了物件的 MarkWord , 型別資訊,以及例項資料(下圖中的藍色區域)。

image

物件記憶體模型中的例項資料區中包含了物件中的基本型別欄位還有引用型別欄位,當 JVM 遍歷到一個物件例項時,這個例項所佔用的記憶體地址是不是就知道了,知道了物件所佔記憶體的地址,那麼例項資料區的地址也就知道了。

JVM 近而可以遍歷這段例項資料記憶體區域,如果發現是基本型別的欄位就跳過,如果是引用型別的欄位,那麼這就是我們要找的成員變數,該成員變數引用的物件地址是不是就知道了,最後根據引用型別的成員變數指向的物件地址,找到被引用的物件,然後標記為 alive,最後再次從這個物件出發,迴圈上述邏輯,慢慢的就找到了所有存活的物件。

當然了這只是一種可行的方案,實際上 JVM 並不會這麼做,因為這樣效率太低了,系統中有成千上萬的物件例項,JVM 不可能每遍歷一個物件,就到物件記憶體中的例項資料區去挨個尋找引用型別的成員變數。

那麼有沒有一種索引結構來提前記錄物件中究竟有哪些引用型別的成員變數,並且這些成員變數在物件記憶體中的位置偏移呢 ?這個索引結構就是 JVM 中的 OopMapBlock 。

// Describes where oops are located in instances of this klass.
class OopMapBlock {
 public:
  // Byte offset of the first oop mapped by this block.
  int offset() const          { return _offset; }
  void set_offset(int offset) { _offset = offset; }

  // Number of oops in this block.
  uint count() const         { return _count; }
  void set_count(uint count) { _count = count; }

 private:
  int  _offset;
  uint _count;
};

OopMapBlock 結構用於描述物件中定義的那些引用型別的非靜態成員變數在物件記憶體中的偏移位置,每個 OopMapBlock 中包含多個非靜態成員變數的地址偏移索引,而且這些非靜態成員變數的地址必須是連續的。

什麼意思呢 ? 比如我們在寫程式碼的時候,在一個類中連續的定義了多個非靜態成員變數(引用型別),那麼這些成員變數的地址偏移就被封裝到了一個 OopMapBlock 中。

但如果我們在類中定義成員變數的時候,中間插入了一個基本型別的成員變數,這麼原本連續的引用型別的成員變數就不連續了,被分割成了兩段。JVM 就會用兩個 OopMapBlock 來索引他們的地址偏移。

除了由於被基本型別的成員變數分割而導致產生多個 OopMapBlock 之外,在物件型別的父類中也可能會定義非靜態成員變數,父類中也會有多個 OopMapBlock。

image

OopMapBlock 結構中的 _offset 用來指定第一個非靜態引用型別的成員變數在物件記憶體地址中的偏移。_count 表示 OopMapBlock 中地址連續的非靜態成員變數個數。

另外還有欄位與欄位之間的位元組填充,由於位元組填充造成欄位之間的地址不連續,也會產生多個 OopMapBlock。但是每個 OopMapBlock 中封裝的非靜態成員變數地址一定是連續的。

這樣一來,一個 Java 型別在 JVM 中就會擁有多個 OopMapBlock,這些 OopMapBlock 被組織在一個叫做 nonstatic_oop_maps 的陣列中,當 JVM 遍歷到一個物件例項時,如果能找到這個 nonstatic_oop_maps,近而透過陣列中的這些 OopMapBlock 是不是就能立馬將物件例項中的所有非靜態成員變數都找出來了。

好了,現在又有一個新的問題擺在我們面前了,這個 nonstatic_oop_maps 陣列存放在哪裡 ?JVM 如何找到這個 nonstatic_oop_maps ?

從 JVM 對於 Java 型別的設計層面來講,nonstatic_oop_maps 屬於 Java 類的元資訊,我們比較熟悉的是,在 JDK 層面每一個 Java 類都會對應一個 Class 物件來描述 Java 類的元資訊,而 JDK 層面上的這個 Class 物件,對應於 JVM 層面來說就是 InstanceKlass 例項。

JVM 在載入 Java 類的時候會為 Java 類構建 nonstatic_oop_maps,類載入完成之後,JVM 會為 Java 類建立一個 InstanceKlass 例項,nonstatic_oop_maps 就放在這個 InstanceKlass 例項裡。

從 InstanceKlass 例項的記憶體佈局中我們可以看出,nonstatic_oop_maps 是緊挨在 vtable,itable 之後的。

InstanceKlass* InstanceKlass::allocate_instance_klass(const ClassFileParser& parser, TRAPS) {
  // InstanceKlass 例項的記憶體佈局
  const int size = InstanceKlass::size(parser.vtable_size(),
                                       parser.itable_size(),
                                       nonstatic_oop_map_size(parser.total_oop_map_count()),
                                       parser.is_interface());

  const Symbol* const class_name = parser.class_name();
  assert(class_name != NULL, "invariant");
  ClassLoaderData* loader_data = parser.loader_data();
  assert(loader_data != NULL, "invariant");

  InstanceKlass* ik;

  // Allocation
  if (REF_NONE == parser.reference_type()) {
    if (class_name == vmSymbols::java_lang_Class()) {
          ...... 省略 .... 
    }
    else if (is_class_loader(class_name, parser)) {
          ...... 省略 .... 
    } else {
      // 對於普通的 Java 類來說,這裡建立的是 InstanceKlass 例項
      ik = new (loader_data, size, THREAD) InstanceKlass(parser, InstanceKlass::_kind_other);
    }
  } else {
    // 對於 Reference 類來說,這裡建立的是 InstanceRefKlass 例項
    ik = new (loader_data, size, THREAD) InstanceRefKlass(parser);
  }
  return ik;
}

對於普通的 Java 型別來說,這裡建立的是 InstanceKlass 例項,對於 Reference 型別來說,這裡建立的是 InstanceRefKlass 例項,大家要牢記這一點。

瞭解了 InstanceKlass 例項的記憶體佈局之後,獲取 nonstatic_oop_maps 陣列就很簡單了,我們只需要跳過 vtable 和 itable 就得到了 nonstatic_oop_maps 的起始記憶體地址。

inline OopMapBlock* InstanceKlass::start_of_nonstatic_oop_maps() const {
  return (OopMapBlock*)(start_of_itable() + itable_length());
}

inline intptr_t* InstanceKlass::start_of_itable()   const { return (intptr_t*)start_of_vtable() + vtable_length(); }

但 JVM 遍歷的是 Java 物件,那如何透過 Java 物件獲取到其對應在 JVM 中的 InstanceKlass 呢 ? 這就用到了前面筆者提到的 Java 物件的記憶體模型,在 JVM 中用 oopDesc 結構來描述 Java 物件的記憶體模型。

class oopDesc {
 private:
  volatile markWord _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;
}

我們在 Java 層面見到的物件,對應於 JVM 層面就是一個 oopDesc 例項,JVM 直接處理的就是這個 oopDesc 例項,在 oopDesc 中有一個 _klass 型別指標,指向的就是 Java 物件所屬的 Java 類在 JVM 層面上對應的 InstanceKlass 例項,

我們可以透過 klass() 函式來獲取 oopDesc 中的 _klass:

Klass* oopDesc::klass() const {
  if (UseCompressedClassPointers) {
    // 開啟壓縮指標的情況
    return CompressedKlassPointers::decode_not_null(_metadata._compressed_klass);
  } else {
    return _metadata._klass;
  }
}

當 JVM 遍歷到一個 oop (JVM 層面的 Java 物件)時,首先會將它標記位 alive,隨後透過 klass() 函式獲取它對應的 InstanceKlass 例項。

template <typename OopClosureType>
void oopDesc::oop_iterate(OopClosureType* cl) {
  // 遍歷物件的所有非靜態成員變數
  OopIteratorClosureDispatch::oop_oop_iterate(cl, this, klass());
}

JVM 遍歷物件引用關係圖的核心邏輯就封裝在 InstanceKlass 中的 oop_oop_iterate_oop_maps 方法中:

template <typename T, class OopClosureType>
ALWAYSINLINE void InstanceKlass::oop_oop_iterate_oop_maps(oop obj, OopClosureType* closure) {
  // InstanceKlass 中有多個 OopMapBlock,它們在 InstanceKlass 例項記憶體中會放在一起
  // 獲取首個 OopMapBlock 地址
  OopMapBlock* map           = start_of_nonstatic_oop_maps();
  // 獲取 InstanceKlass 中包含的 OopMapBlock 個數,這些都是在類載入的時候決定的
  // class 檔案中有欄位表,在類載入的時候可以根據欄位表建立 OopMapBlock
  OopMapBlock* const end_map = map + nonstatic_oop_map_count();
  // 挨個遍歷 InstanceKlass 中所有的 OopMapBlock
  for (; map < end_map; ++map) {
    // OopMapBlock 中包含的是 java 類中非靜態成員變數在物件地址中的偏移
    // 透過它直接可以獲取到成員變數的指標
    oop_oop_iterate_oop_map<T>(map, obj, closure);
  }
}

在這裡首先會透過 start_of_nonstatic_oop_maps 在 InstanceKlass 例項中獲取 nonstatic_oop_maps 陣列的起始地址 map ,然後根據 nonstatic_oop_maps 中包含的 OopMapBlock 個數,獲取最後一個 OopMapBlock 的地址 end_map

根據這些 OopMapBlock 索引好的非靜態成員變數地址偏移,挨個獲取這些成員變數的地址,並透過 do_oop 逐個進行標記。

template <typename T, class OopClosureType>
ALWAYSINLINE void InstanceKlass::oop_oop_iterate_oop_map(OopMapBlock* map, oop obj, OopClosureType* closure) {
  // 透過成員變數在 obj 物件記憶體中的偏移獲取成員變數指標
  T* p         = (T*)obj->obj_field_addr<T>(map->offset());
  // 獲取該 OopMapBlock 所對映的成員變數個數
  T* const end = p + map->count();
  // 遍歷成員變數挨個標記
  for (; p < end; ++p) {
    // 標記成員變數
    Devirtualizer::do_oop(closure, p);
  }
}

以上就是 JVM 如何遍歷物件引用關係圖的所有核心邏輯,主要依靠的就是這個 OopMapBlock,那麼它是在什麼時候被 JVM 構建出來的呢 ?

4.2 OopMapBlock 在何時 ?又是如何 ?被 JVM 構建出來

OopMapBlock 構建了 Java 類中定義的非靜態成員變數在物件例項中的偏移地址索引,這些都屬於類的元資訊,在類載入的時候都可以確定下來。所以 nonstatic_oop_maps 自然也是在類載入的時候被構建出來的。

Java 類的元資訊存放在 .class 檔案中,.class 檔案是由 Java 編譯器從 .java 檔案中編譯而來。.class 檔案中存放的是位元組碼,本質上是一個具有特定二進位制格式的二進位制流。裡面將 Java 類的元資訊按照特定格式組織在一起。

Java 類載入的過程就是 JVM 將 .class 檔案中的二進位制流載入到記憶體,並對位元組流中的資料進行校驗,轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別。

.class 檔案中的二進位制位元組流可以來自於 JAR 包,也可以從資料庫中讀取,也可以透過動態代理在程式執行時生成。不管 .class 檔案中的二進位制位元組流是從哪裡獲取的,最終都是透過 ClassLoader 的 native 方法 defineClass1 載入這些二進位制位元組流到記憶體中並生成代表該類的 java.lang.Class 物件,作為這個類在 Metaspace 中的資料訪問入口。

public abstract class ClassLoader {
    static native Class<?> defineClass1(ClassLoader loader, String name, byte[] b, int off, int len,
                                        ProtectionDomain pd, String source);
}

在 defineClass1 的 native 實現中,會呼叫到一個 SystemDictionary::resolve_from_stream 方法,在這裡會完成類的載入,驗證,解析等操作,在完成類的解析之後就會構建 nonstatic_oop_maps,建立 InstanceKlass 例項,最後將 nonstatic_oop_maps 填充到 InstanceKlass 例項中。

static jclass jvm_define_class_common(const char *name,
                                      jobject loader, const jbyte *buf,
                                      jsize len, jobject pd, const char *source,
                                      TRAPS) {
  // 將 class 檔案的二進位制位元組流轉換為 ClassFileStream
  ClassFileStream st((u1*)buf, len, source, ClassFileStream::verify);
  // 進行類的載入,驗證,解析,隨後為建立 InstanceKlass 例項
  Klass* k = SystemDictionary::resolve_from_stream(&st, class_name,
                                                   class_loader,
                                                   cl_info,
                                                   CHECK_NULL);
}

InstanceKlass* SystemDictionary::resolve_class_from_stream
  if (k == NULL) {
    k = KlassFactory::create_from_stream(st, class_name, loader_data, cl_info, CHECK_NULL);
  }
}

類的載入邏輯主要在 KlassFactory::create_from_stream 中進行:

InstanceKlass* KlassFactory::create_from_stream(ClassFileStream* stream,
                                                Symbol* name,
                                                ClassLoaderData* loader_data,
                                                const ClassLoadInfo& cl_info,
                                                TRAPS) {
  // 這裡完成類的載入,驗證,解析 以及 nonstatic_oop_maps 的構建
  ClassFileParser parser(stream,
                         name,
                         loader_data,
                         &cl_info,
                         ClassFileParser::BROADCAST, // publicity level
                         CHECK_NULL);
  // 分配 InstanceKlass 例項,並將構建好的 nonstatic_oop_maps 填充到 InstanceKlass 例項中
  InstanceKlass* result = parser.create_instance_klass(old_stream != stream, *cl_inst_info, CHECK_NULL);
  return result;
}

nonstatic_oop_maps 的構建主要是在類解析階段之後,由 post_process_parsed_stream 函式負責觸發構建。

ClassFileParser::ClassFileParser(ClassFileStream* stream,
                                 Symbol* name,
                                 ClassLoaderData* loader_data,
                                 const ClassLoadInfo* cl_info,
                                 Publicity pub_level,
                                 TRAPS) :

  ........ 省略 載入,驗證,解析邏輯 ......

  // 這裡會構建 nonstatic_oop_maps
  post_process_parsed_stream(stream, _cp, CHECK);
}

在 post_process_parsed_stream 函式中會對 Java 類中定義的所有欄位進行佈局:

void ClassFileParser::post_process_parsed_stream(
  ..... 省略 .....

  // 對 Java 類中的欄位資訊進行佈局
  _field_info = new FieldLayoutInfo();
  FieldLayoutBuilder lb(class_name(), super_klass(), _cp, _fields,
                        _parsed_annotations->is_contended(), _field_info);
  // 構建 nonstatic_oop_maps
  lb.build_layout();
}

void FieldLayoutBuilder::build_layout() {
  // 在對類中的欄位完成佈局之後,會呼叫一個 epilogue() 函式
  compute_regular_layout();
}

// nonstatic_oop_maps的構建邏輯就在這裡
void FieldLayoutBuilder::epilogue() {

在欄位佈局完成之後,會在 epilogue() 函式中按照欄位的佈局資訊,構建 nonstatic_oop_maps。

  1. 首先繼承來自其父類的 nonstatic_oop_maps。

  2. _root_group->oop_fields() 中獲取類中的所有非靜態成員變數,並將相鄰的成員變數構建在同一個 OopMapBlock 中

  3. 處理被 @Contended 標註過的非靜態成員變數,屬於同一個 content group 的成員變數在物件例項記憶體中必須連續存放,獨佔 CPU 快取行。所以同一個 content group 下的成員變數會被構建在同一個 OopMapBlock 中。

void FieldLayoutBuilder::epilogue() {
  // 開始構建 nonstatic_oop_maps
  int super_oop_map_count = (_super_klass == NULL) ? 0 :_super_klass->nonstatic_oop_map_count();
  int max_oop_map_count = super_oop_map_count + _nonstatic_oopmap_count;

  OopMapBlocksBuilder* nonstatic_oop_maps =
      new OopMapBlocksBuilder(max_oop_map_count);
  // 繼承父類的 nonstatic_oop_maps
  if (super_oop_map_count > 0) {
    nonstatic_oop_maps->initialize_inherited_blocks(_super_klass->start_of_nonstatic_oop_maps(),
    _super_klass->nonstatic_oop_map_count());
  }

  // 為非靜態成員變數構建 nonstatic_oop_maps
  if (_root_group->oop_fields() != NULL) {
    for (int i = 0; i < _root_group->oop_fields()->length(); i++) {
      LayoutRawBlock* b = _root_group->oop_fields()->at(i);
      // 構建 OopMapBlock,相鄰的欄位構建在一個 OopMapBlock 中
      // 不相鄰的欄位分別構建在不同的 OopMapBlock 中
      nonstatic_oop_maps->add(b->offset(), 1);
    }
  }

  // 為 @Contended 標註的非靜態成員變數構建 nonstatic_oop_maps
  // 在靜態成員變數上標註 @Contended 將會被忽略
  if (!_contended_groups.is_empty()) {
    for (int i = 0; i < _contended_groups.length(); i++) {
      FieldGroup* cg = _contended_groups.at(i);
      if (cg->oop_count() > 0) {
        assert(cg->oop_fields() != NULL && cg->oop_fields()->at(0) != NULL, "oop_count > 0 but no oop fields found");
        // 構建 OopMapBlock,屬於同一個 contended_groups 的成員變數在記憶體中要放在一起
        nonstatic_oop_maps->add(cg->oop_fields()->at(0)->offset(), cg->oop_count());
      }
    }
  }
  // 對相鄰的  OopMapBlock 進行排序整理
  // 確保在記憶體中相鄰排列的非靜態成員變數被構建在一個 OopMapBlock 中
  nonstatic_oop_maps->compact();

  int nonstatic_field_end = align_up(_layout->last_block()->offset(), heapOopSize);

  // Pass back information needed for InstanceKlass creation
  _info->oop_map_blocks = nonstatic_oop_maps;
  _info->_nonstatic_field_size = (nonstatic_field_end - instanceOopDesc::base_offset_in_bytes()) / heapOopSize;
  _info->_has_nonstatic_fields = _has_nonstatic_fields;
}

JVM 在完成類的載入,解析,以及構建完 nonstatic_oop_maps 之後,就會為 Java 類在 JVM 中分配一個 InstanceKlass 例項。

InstanceKlass* ClassFileParser::create_instance_klass(bool changed_by_loadhook,
                                                      const ClassInstanceInfo& cl_inst_info,
                                                      TRAPS) {
  if (_klass != NULL) {
    return _klass;
  }
  // 分配 InstanceKlass 例項
  InstanceKlass* const ik =
    InstanceKlass::allocate_instance_klass(*this, CHECK_NULL);
  // 填充 InstanceKlass 例項
  fill_instance_klass(ik, changed_by_loadhook, cl_inst_info, CHECK_NULL);

  return ik;
}

InstanceKlass::allocate_instance_klass 只是分配了一個空的 InstanceKlass 例項 ,所以需要 fill_instance_klass 函式來填充 InstanceKlass 例項。在這裡會將剛剛構建好的 nonstatic_oop_maps 填充到 InstanceKlass 例項中。

void ClassFileParser::fill_instance_klass(InstanceKlass* ik,
                                          bool changed_by_loadhook,
                                          const ClassInstanceInfo& cl_inst_info,
                                          TRAPS) {

  ik->set_nonstatic_field_size(_field_info->_nonstatic_field_size);
  ik->set_has_nonstatic_fields(_field_info->_has_nonstatic_fields);
  assert(_fac != NULL, "invariant");
  ik->set_static_oop_field_count(_fac->count[STATIC_OOP]);

  // 將構建好的 nonstatic_oop_maps 填充到 InstanceKlass 例項中
  OopMapBlocksBuilder* oop_map_blocks = _field_info->oop_map_blocks;
  if (oop_map_blocks->_nonstatic_oop_map_count > 0) {
    oop_map_blocks->copy(ik->start_of_nonstatic_oop_maps());
  }
}

好了,現在我們已經清楚了 JVM 如何利用這個 nonstatic_oop_maps 來高效的遍歷物件的引用關係圖,並且也知道了 JVM 在何時 ?又是如何 ? 將 nonstatic_oop_maps 建立出來並填充到 InstanceKlass 例項中。

有了這些背景知識的鋪墊之後,我們再來看 Reference 語義的實現邏輯就很簡單了。

4.3 Reference 型別的 OopMapBlock 有何不同

前面我們提到,當 JVM 遍歷到一個物件並將其標記為 alive 之後,隨後就會從這個普通 Java 物件的記憶體模型中將 _klass 指標取出來,對於普通的 Java 型別來說,它的 _klass 指標指向的是一個 InstanceKlass 例項,而對於 Reference 型別來說,它的 _klass 指標指向的是一個 InstanceRefKlass 例項。

隨後會從 InstanceKlass 例項中將 nonstatic_oop_maps 陣列取出來,這個 nonstatic_oop_maps 是在類載入的時候被建立並填充到 InstanceKlass 例項中的。

根據 nonstatic_oop_maps 中構建的類中所有非靜態成員變數在物件記憶體中的地址偏移,JVM 可以輕鬆的獲取到物件中成員變數的地址,順藤摸瓜,再將這些非靜態成員變數引用到的物件全部標記為 alive,反覆迴圈這個邏輯,最終會將整個引用關係圖遍歷標記完畢。

image

但是別忘了 Reference 型別本質上也是一個 Java 類,referent 也是 Reference 類中定義的一個非靜態成員變數。

public abstract class Reference<T> {
  private T referent;
  volatile ReferenceQueue<? super T> queue;
  volatile Reference next;
  private transient Reference<?> discovered;
}

如果按照這個邏輯,JVM 是不是也可以透過 nonstatic_oop_maps 獲取到 referent 的記憶體地址 ,近而將 Reference 引用的物件標記為 alive 呢 ?但是現實是,這個 referent 並沒有被 JVM 標記到。

image

這就有點奇怪了是吧,JVM 是怎麼做到的呢 ?Reference 的語義是如何實現的呢 ?

我們從頭來捋一捋,現在的現象是什麼 ? 是 Reference 物件的非靜態成員變數 referent 沒有被標記到對吧。那麼查詢一個物件的非靜態成員變數靠什麼 ? 靠的是不是就是我們前面花了大量篇幅介紹的 OopMapBlock ?那這個 OopMapBlock 從哪裡來的 ?對於普通物件是不是在它的 InstanceKlass 例項中,對於 Reference 型別的物件是不是在它的 InstanceRefKlass 例項 中 ?

那為什麼對於普通物件來說,可以透過 OopMapBlock 遍歷到它的非靜態成員變數,而對於 Reference 物件來說,就無法透過 OopMapBlock 遍歷到它的 referent 呢 ?

難道是 JVM 對於 InstanceRefKlass 的 nonstatic_oop_maps 進行了一系列的魔改,壓根就沒有為 referent 在 OopMapBlock 中建立索引 ?這樣自然就不會遍歷到 referent,也無法將它標記為 alive 了。

事實上,JVM 就是這麼幹的,那麼在哪裡,又是如何對 InstanceRefKlass 進行魔改的呢 ?

在 JVM 啟動的時候會對 SystemDictionary 進行初始化,SystemDictionary 在 JVM 中的角色是用於管理系統中已經載入的所有 class 類,在 SystemDictionary 初始化的時候會呼叫到一個重要的函式 InstanceRefKlass::update_nonstatic_oop_maps

void SystemDictionary::initialize(TRAPS) {
  // Resolve basic classes
  vmClasses::resolve_all(CHECK);
}

void vmClasses::resolve_all(TRAPS) {
    InstanceRefKlass::update_nonstatic_oop_maps(vmClasses::Reference_klass());
}

從函式命名上,我們就可以看出來,這裡就是對 InstanceRefKlass 進行魔改的地方了。

void InstanceRefKlass::update_nonstatic_oop_maps(Klass* k) {

  // Reference 類中的 referent 欄位和 discovered 欄位的索引偏移從 OopMapBlock 中清除掉
  // 在後面透過 Reference 遍歷標記成員變數的時候不需要遍歷標記這兩個欄位
  InstanceKlass* ik = InstanceKlass::cast(k);

  OopMapBlock* map = ik->start_of_nonstatic_oop_maps();

  // Updated map starts at "queue", covers "queue" and "next".
  const int new_offset = java_lang_ref_Reference::queue_offset();
  const unsigned int new_count = 2; // queue and next

   assert(map->offset() == referent_offset, "just checking");
   assert(map->count() == count, "just checking");
   map->set_offset(new_offset);
   map->set_count(new_count);
}

在 JVM 啟動的時候會對所有基礎類進行載入當然也包含 Reference 類,和普通的 Java 型別一樣,Reference 類被載入之後,JVM 也會為它構建一個全量的 nonstatic_oop_maps,裡面確實也包含了所有的非靜態成員變數(referent 欄位也包括在內)。

隨後就會在 update_nonstatic_oop_maps 中對 InstanceRefKlass 進行魔改。

public abstract class Reference<T> {
  private T referent;
  volatile ReferenceQueue<? super T> queue;
  volatile Reference next;
  private transient Reference<?> discovered;
}

首先會透過 java_lang_ref_Reference::queue_offset() 將成員變數 queue 的地址偏移取出來 —— new_offset,然後將原來 OopMapBlock 的 _count 設定為 2 ,用新的 new_offset,new_count 重新構建 OopMapBlock。

這裡 new_count 設定為 2 的意思就是,只將 Reference 類中的非靜態成員變數 queue 和 next 構建到 OopMapBlock 中。

也就是說,當 JVM 遍歷到一個 Reference 物件時,只能透過它的 OopMapBlock 遍歷到 queue 和 next,無法遍歷到 referent 和 discovered。

經過這樣的魔改之後,JVM 就巧妙地實現了 Reference 的語義。大家這裡可以停下來回想回想 WeakReference 的語義,是不是就實現了當一個 Java 物件只存在一條弱引用鏈的時候,發生 GC 的時候,只被弱引用所關聯的物件就會被回收掉。本質原因就是這個被 JVM 魔改之後的 OopMapBlock 產生了作用。

有同學可能會問了,你說的只是 WeakReference 的語義啊,Reference 又不只是 WeakReference 這一種,還有 SoftReference,PhantomReference,FinalReference 這些 Reference 型別,好像在這一小節中並沒有看到他們的語義實現。

事實上,筆者在這一小節中只是為大家揭露 Reference 最為本質的面貌,SoftReference,PhantomReference,FinalReference 這些具體的語義都是在 WeakReference 語義的基礎上進行了小小的魔改而已,等筆者把該鋪墊的背景知識全部鋪墊好,後面會有單獨的小節專門為大家解釋清楚其他 Reference 型別的語義實現。

5. JVM 在 GC 的時候如何處理 Reference

在本文的第三小節中,我們主要在 JVM 的外圍來討論 JDK 如何透過 ReferenceHandler 執行緒來處理 Reference 物件,其中提到 JVM 內部有一個非常重要的 _reference_pending_list 連結串列,當 Reference 的 referent 物件沒有任何強引用鏈或者軟引用鏈可達時,GC 執行緒就會回收這個 referent 物件。那麼與之對應的 Reference 物件就會被 JVM 採用頭插法的方式插入到這個 _reference_pending_list 中。

// zReferenceProcessor.cpp 檔案
OopHandle Universe::_reference_pending_list;

// Create a handle for reference_pending_list
 _reference_pending_list = OopHandle(vm_global(), NULL);

如果 _reference_pending_list 中沒有任何需要被處理的 Reference 物件時,ReferenceHandler 執行緒就會在一個 native 方法 —— waitForReferencePendingList() 上阻塞等待。

當發生 GC 的時候,JVM 就會從 GcRoot 開始挨個遍歷整個引用關係圖中的物件,並將遍歷到的物件標記為 alive,沒有被標記到的物件就會被 JVM 當做垃圾回收掉。

當 referent 物件沒有被標記到,需要被 GC 執行緒回收的時候,JVM 就會將與它關聯的 Reference 插入到 _reference_pending_list 中,並喚醒 ReferenceHandler 執行緒去處理,後面的內容我們在第三小節中已經詳細的討論過了。

image

本小節中,筆者將帶著大家深入到 JVM 內部,看看發生 GC 的時候,JVM 如何處理這些 Reference 物件 ? 如何判斷哪些 Reference 需要被插入到 _reference_pending_list 中? 和我們前面第三小節中的內容遙相呼應起來,這樣一來我們就從 JDK 層面再到 JVM 層面將整個 Reference 的處理鏈路打通了。

下面筆者就以 ZGC 為例,帶著大家看一看 JVM 內部到底是如何處理 Reference 的 :

void ZDriver::gc(const ZDriverRequest& request) {
  ZDriverGCScope scope(request);

  // Phase 1: Pause Mark Start
  // 初始化 gc 相關的統計資訊,清空 object alocator 的快取頁,切換地址檢視,設定標記條帶個數
  pause_mark_start();

  // Phase 2: Concurrent Mark
  // 標記 gc root, 標記普通物件,以及 Reference 物件
  // 經過主動重新整理,被動重新整理之後,如果標記棧中還有物件,也不會再進行標記了
  // 剩下的物件標記任務放到 pause_mark_end 中 STW 階段執行
  concurrent(mark);

  // Phase 3: Pause Mark End 再標記階段,標記上一階段剩下的物件
  // zgc 低延遲的精髓,如果 1ms 內結束不了 STW 標記,那麼就在發起一輪 concurrent 標記
  // 目的是降低應用執行緒的停頓控制在 1ms 以內
  while (!pause_mark_end()) {
    // 1ms 內沒有標記完應用執行緒本地標記棧的內容,那麼就重新開始一輪併發標記。
    // Phase 3.5: Concurrent Mark Continue
    concurrent(mark_continue);
  }

  // Phase 4: Concurrent Mark Free
  // 釋放標記棧資源
  concurrent(mark_free);

  // Phase 5: Concurrent Process Non-Strong References
  // 這裡就是本小節討論的重點
  concurrent(process_non_strong_references);

  ....... 省略 .......
}

ZGC 整個 GC 過程分為 10 個階段,其中只有四個階段需要非常短暫的 STW,剩下的六個階段全部是與 Java 應用執行緒併發執行的,階段雖然比較多,整個 GC 過程也非常的複雜,但與本小節相關的階段只有兩個,分別是第二階段的併發標記階段 —— Phase 2: Concurrent Mark,與第五階段的併發處理非強引用 Reference 階段 —— Phase 5: Concurrent Process Non-Strong References

其中 Concurrent Mark 主要的任務就是從 GcRoot 開始併發標記根物件,並沿著根物件遍歷整個堆中的引用關係,在整個遍歷的過程中會逐漸發現那些需要被 ReferenceHandler 執行緒處理的 Reference 物件,隨後會將這些 Reference 物件插入到 _discovered_list 中。

這裡大家可能會有疑問,你剛才不是說 JVM 會將需要被處理的 Reference 物件插入到 _reference_pending_list 中嗎 ?怎麼現在又變成 _discovered_list 了 ?

事實上,大家可以將 _discovered_list 理解為一個臨時的 _reference_pending_list,在 ZGC 的整個過程中會用到兩個臨時的 _reference_pending_list,它們分別是 _discovered_list,_pending_list。

class ZReferenceProcessor : public ReferenceDiscoverer {
  ZPerWorker<oop>      _discovered_list;
  ZContended<oop>      _pending_list;
}

ZGC 有多個 GC 執行緒負責併發執行垃圾回收任務,_discovered_list 是 ZPerWorker 型別的,每一個 GC 執行緒都有一個 _discovered_list,負責臨時儲存由該 GC 執行緒在併發標記過程中發現的 Reference 物件。

在併發標記結束之後,這些 GC 執行緒就會將各自在 _discovered_list 中收集到的 Reference 物件統一轉移到 _pending_list 中,_pending_list 在所有 GC 執行緒中是共享的,負責彙總 ZGC 執行緒收集到的所有 Reference 物件。

Concurrent Process Non-Strong References 階段的最後,JVM 會將 _pending_list 中彙總的 Reference 物件再次統一轉移到 _reference_pending_list 中,_reference_pending_list 是最終對外的釋出形態,ReferenceHandler 執行緒只會和 _reference_pending_list 打交道。

理解了這個背景,下面我們就來一起看下 Concurrent Mark 階段是如何發現 Reference 物件的

5.1 Concurrent Mark

當 ZGC 遍歷到一個物件 —— oop obj 並將其標記為 alive 之後,就會呼叫 follow_object 方法,來遍歷 obj 的所有非靜態成員變數,然後將這些成員變數所引用的 obj 標記為 alive,然後再次呼叫 follow_object 繼續遍歷引用關係圖,這樣迴圈往復。 ZGC 就是靠著這個 follow_object 方法驅動著所有 GC 執行緒去遍歷整個堆的引用關係圖。

void ZMark::follow_object(oop obj, bool finalizable) {
  if (finalizable) {
    ZMarkBarrierOopClosure<true /* finalizable */> cl;
    obj->oop_iterate(&cl);
  } else {
    // 最終的標記邏輯是在這個閉包中完成的
    ZMarkBarrierOopClosure<false /* finalizable */> cl;
    // 遍歷標記 obj 的所有非靜態成員變數
    obj->oop_iterate(&cl);
  }
}

這裡就來到了筆者在第四小節中介紹的內容,這個函式熟悉嗎 ?沒印象的話再去回顧下第四小節。

template <typename OopClosureType>
void oopDesc::oop_iterate(OopClosureType* cl) {
  OopIteratorClosureDispatch::oop_oop_iterate(cl, this, klass());
}

首先會透過 klass() 函式去獲取 obj 中的 _klass 指標,對於普通型別的 Java 物件來說,_klass 指向的是 InstanceKlass 例項,對於 Reference 型別的物件來說,_klass 指向的是 InstanceRefKlass 例項。

最終的遍歷動作是在對應 Klass 中的 oop_oop_iterate 方法中進行的,本小節我們重點關注 InstanceRefKlass。

template <typename T, class OopClosureType>
void InstanceRefKlass::oop_oop_iterate(oop obj, OopClosureType* closure) {
  // 遍歷 Reference 物件的非靜態成員變數,注意這裡 referent 欄位和 discovered 欄位是不會被遍歷到的
  InstanceKlass::oop_oop_iterate<T>(obj, closure);
  // 判斷該 Reference 物件是否需要加入到 _discovered_list 中
  oop_oop_iterate_ref_processing<T>(obj, closure);
}

首先會呼叫 InstanceKlass::oop_oop_iterate 函式,這個函式熟悉嗎 ?我們在第四小節中重點介紹的就是這個函式。

在這個函式中獲取 InstanceRefKlass 例項中的 nonstatic_oop_maps,透過 OopMapBlock 去遍歷標記 Reference 物件非靜態成員變數。

template <typename T, class OopClosureType>
ALWAYSINLINE void InstanceKlass::oop_oop_iterate_oop_maps(oop obj, OopClosureType* closure) {
  OopMapBlock* map           = start_of_nonstatic_oop_maps();
  OopMapBlock* const end_map = map + nonstatic_oop_map_count();

  for (; map < end_map; ++map) {
    oop_oop_iterate_oop_map<T>(map, obj, closure);
  }
}

但筆者前面介紹過,InstanceRefKlass 中的 nonstatic_oop_maps 是被 JVM 經過特殊魔改的,這裡並不會遍歷到 Reference 物件的 referent 欄位和 discovered 欄位。

public abstract class Reference<T> {
  private T referent;
  volatile ReferenceQueue<? super T> queue;
  volatile Reference next;
  private transient Reference<?> discovered;
}

在遍歷標記完 Reference 物件的非靜態成員變數之後,JVM 會呼叫
oop_oop_iterate_ref_processing 來判斷該 Reference 物件是否應該插入到 _discovered_list 中。

template <typename T, class OopClosureType, class Contains>
void InstanceRefKlass::oop_oop_iterate_ref_processing(oop obj, OopClosureType* closure, Contains& contains) {
  switch (closure->reference_iteration_mode()) {
    case OopIterateClosure::DO_DISCOVERY:
      // 執行這裡的 discovery 邏輯,發現需要被處理的 Reference 物件
      oop_oop_iterate_discovery<T>(obj, reference_type(), closure, contains);
      break;

     ...... 省略 .....
  }
}
template <typename T, class OopClosureType, class Contains>
void InstanceRefKlass::oop_oop_iterate_discovery(oop obj, ReferenceType type, OopClosureType* closure, Contains& contains) {
  // Try to discover reference and return if it succeeds.
  if (try_discover<T>(obj, type, closure)) {
    // 走到這裡說明 Reference 物件已經被加入到 _discovered_list 中了
    // 加入到 _discovered_list 的條件是:
    // 1. referent 沒有被標記,說明不活躍
    // 2. Reference 物件之前沒有被新增到 _discovered_list(第一次新增)
    return;
  }

}

在 try_discover 中,JVM 首先會透過 load_referent 從堆中載入 Reference 引用的 referent 物件。這裡會判斷 referent 物件是否已經被 GC 執行緒標記過了,如果已經被標記了,說明 referent 是 alive 的,那麼這個 Reference 物件就不需要被放入 _discovered_list 中,直接 return 掉。

如果 referent 沒有被標記,則進入 ZReferenceProcessor->discover_reference 函式中作進一步的 discover 邏輯判斷。

template <typename T, class OopClosureType>
bool InstanceRefKlass::try_discover(oop obj, ReferenceType type, OopClosureType* closure) {

  //  ZReferenceProcessor
  ReferenceDiscoverer* rd = closure->ref_discoverer();
  if (rd != NULL) {
      // 從堆中載入 Reference 物件的 referent
    oop referent = load_referent(obj, type);
    if (referent != NULL) {
      if (!referent->is_gc_marked()) {
        // Only try to discover if not yet marked.
        // true 表示 reference 被加入到 discover-list 中了
        return rd->discover_reference(obj, type);
      }
    }
  }
  return false;
}

discover_reference 的邏輯很簡單,主要分為兩步:

  1. 透過 should_discover 判斷該 Reference 物件是否需要被 ReferenceHandler 執行緒處理

  2. 如果 Reference 物件需要被處理的話就透過 discover 方法,將其插入到 _discovered_list 中。

bool ZReferenceProcessor::discover_reference(oop reference, ReferenceType type) {

  // true : 表示 referent 還存活(被強引用或者軟引用關聯),那麼就不能放到 _discovered_list
  // false : 表示 referent 不在存活,那麼就需要把 reference 放入 _discovered_list
  if (!should_discover(reference, type)) {
    // Not discovered
    return false;
  }
  // 將 reference 插入到  _discovered_list 中(頭插法)
  discover(reference, type);

  // Discovered
  return true;
}

should_discover 判斷是否將 Reference 新增到 _discovered_list 中的邏輯依據主要有三個方面:

如果 Reference 物件的狀態是 inactive,那麼 JVM 就不會將它放入 _discovered_list 中。那麼什麼時候 Reference 物件會變為 inactive 呢 ?

比如,應用執行緒自己呼叫 Reference.enqueue() 方法,自己親自將 Reference 物件新增到與其關聯的 ReferenceQueue 中等待進一步的處理。那麼這裡 JVM 就不需要將 Reference 新增到 _discovered_list 中了。

因為最終 ReferenceHandler 執行緒還是會從 _reference_pending_list 中將 Reference 新增到 ReferenceQueue 中,這樣一來就重複了。應用執行緒在呼叫 Reference.enqueue() 方法之後,Reference 的狀態就變為了 inactive

還有一種變為 inactive 的情況就是應用執行緒直接呼叫 Reference.clear() 方法,表示應用執行緒自己已經處理過 Reference 物件了,JVM 就別管了,此時 Reference 的狀態變為 inactive , 那麼在下一輪 GC 的時候該 Reference 物件就會被回收,並且不會再次被新增到 _discovered_list 中。

這也就解釋了為什麼 Reference 狀態變為 inactive 之後,JVM 將不會再次將其放入 _discovered_list 的原因了,因為它已經被處理過了。處於 inactive 狀態的 Reference 有一個共同的特點就是它的 referent = null

第二個條件是如果它的 referent 仍然存在強引用鏈,那麼這個 Reference 將不會被放入 _discovered_list。

第三個條件是如果它的 referent 仍然存在軟引用鏈,也就是還被軟引用所關聯,如果此時記憶體充足,軟引用不會被回收的話,那麼這個 Reference 也不會被放入 _discovered_list。

bool ZReferenceProcessor::should_discover(oop reference, ReferenceType type) const {
  // 獲取 referent 物件的地址檢視
  volatile oop* const referent_addr = reference_referent_addr(reference);
  // 調整 referent 物件的地址檢視為 remapped + mark0 也就是 weakgood 檢視
  // 表示該 referent 物件目前只能透過弱引用鏈訪問到,而不能透過強引用鏈訪問到
  // 注意這裡是調整 referent 的檢視而不是調整 Reference 的檢視
  const oop referent = ZBarrier::weak_load_barrier_on_oop_field(referent_addr);

  // 此時 Reference 的狀態就是 inactive,那麼這裡將不會重複將 Reference 新增到 _discovered_list 重複處理
  if (is_inactive(reference, referent, type)) {
    return false;
  }
  // referent 還被強引用關聯,那麼 return false 也就是說不能被加入到 discover list 中
  if (is_strongly_live(referent)) {
    return false;
  }
  // referent 還被軟引用有效關聯,那麼 return false 也就是說不能被加入到 discover list 中
  if (is_softly_live(reference, type)) {
    return false;
  }

  return true;
}

如果 Reference 物件的 referent 在當前堆中已經沒有任何強引用或者軟引用了,並且該 Reference 物件不是 inactive 狀態的,那麼 JVM 就會將該 Reference 物件透過下面的 discover 方法插入到 _discovered_list 中(頭插法)。

void ZReferenceProcessor::discover(oop reference, ReferenceType type) {
  // Add reference to discovered list
  // 確保 reference 不在 _discovered_list 中,不能重複新增
  assert(reference_discovered(reference) == NULL, "Already discovered");
  oop* const list = _discovered_list.addr();
  // 頭插法,reference->discovered = *list
  reference_set_discovered(reference, *list);
  // reference 變為 _discovered_list 的頭部
  *list = reference;
}

從以上過程我們可以看出,在 ZGC 的 Concurrent Mark 階段, Reference 物件被 JVM 新增到 _discovered_list 中需要同時符合下面四個條件:

  1. Reference 物件引用的 referent 沒有被 GC 標記過。
  2. Reference 物件的狀態不能是 inactive, 也就是說這個 Reference 還沒有被應用執行緒處理過,Reference 之前沒有加入過 _discovered_list。
  3. referent 不存在任何強引用鏈。
  4. referent 不存在任何軟引用鏈。

image

好了,現在 Reference 在 Concurrent Mark 階段的處理過程,筆者就為大家介紹完了,這裡需要注意的是,目前 _discovered_list 中收集到的 Reference 都只是臨時的,因為當前所處的階段為併發標記階段,應用執行緒和 GC 執行緒是併發執行的,再加上標記階段還沒有結束,所以 Reference 加入到 _discovered_list 的條件可能隨時會被應用執行緒和 GC 執行緒再次改變。

_discovered_list 終態的確定需要等到併發標記階段完全結束,在 ZGC 的第五階段 —— Concurrent Process Non-Strong References 進行最終的處理。

5.2 Concurrent Process Non-Strong References

void ZHeap::process_non_strong_references() {
  // Process Soft/Weak/Final/PhantomReferences
  _reference_processor.process_references();
  // Enqueue Soft/Weak/Final/PhantomReferences
  _reference_processor.enqueue_references();
}

ZGC 在 Concurrent Process Non-Strong References 階段對於 Reference 的最終處理是在 ZReferenceProcessor 中完成的,其中主要包括兩個核心步驟:

首先在 process_references() 函式中,判斷 ZGC 在 Concurrent Mark 階段的 _discovered_list 中收集到的臨時 Reference 物件所引用的 referent 是否存活,如果這些 referent 仍然存活,那麼就需要將對應的 Reference 物件從 _discovered_list 中移除。

如果這些 referent 不再存活,那麼就將與其關聯的 Reference 物件繼續保留在 _discovered_list,最後將 _discovered_list 中依然保留的 Reference 物件新增到 _pending_list 中,然後清空 _discovered_list。

第二個步驟就是在 enqueue_references() 函式中,將最終確定下來的 _pending_list 再次新增到 _reference_pending_list 中,隨後喚醒 ReferenceHandler 執行緒去處理 _reference_pending_list 中的 Reference 物件,最後清空 _pending_list,為下一輪 GC 做準備。

以上就是 ZGC 對於 Non-Strong References 的總體處理流程,下面我們就來看下這兩個核心步驟中的具體處理細節:

    void ZReferenceProcessor::process_references() {
        // Process discovered lists
        ZReferenceProcessorTask task(this);
        _workers->run(&task);
    }

process_references() 對於 _discovered_list 的處理邏輯被封裝在一個 ZReferenceProcessorTask 中,由所有 GC 執行緒來一起併發執行這個 Task。

void ZReferenceProcessor::work() {
  // Process discovered references
  oop* const list = _discovered_list.addr();
  oop* p = list;

  // 迴圈遍歷 _discovered_list,檢查之前收集到的 Reference 物件的 referent 是否存活
  while (*p != NULL) {
    const oop reference = *p;
    const ReferenceType type = reference_type(reference);
    // 如果該 reference 已經被應用程式處理過了 -> referent == NULL, 那麼就不需要再被處理了,直接丟棄
    // 如果 referent 依然存活,那麼也要丟棄,不能放入 _discovered_list 中
    if (should_drop(reference, type)) {
      // 如果 referent 是 alive 的或者在上一輪 GC 中已經被處理過,則將 reference 從 _discovered_list 中刪除
      *p = drop(reference, type);
    } else {
      // 如果 referent 不是 alive 的,在併發標記階段沒有被標記,那麼就讓它繼續留在 _discovered_list 中
      // 這裡會呼叫 reference 的 clear 方法 -> referent 置為 null
      // 返回 reference 在 _discovered_list 中的下一個物件,繼續 while 迴圈
      p = keep(reference, type);
    }
  }

  // 將 _discovered_list 原子地新增到 _pending_list 中
  if (*list != NULL) {
    *p = Atomic::xchg(_pending_list.addr(), *list);
    // 清空 _discovered_list
    *list = NULL;
  }
}

首先透過 _discovered_list.addr() 獲取 GC 執行緒的本地 _discovered_list,前面我們提到 _discovered_list 是一個 ZPerWorker 型別的,每一個 GC 執行緒對應一個,用於在 Concurrent Mark 階段併發 discover Reference。

迴圈遍歷 _discovered_list,挨個獲取連結串列中收集到的臨時 Reference,透過 should_drop 方法判斷是否需要將 Reference 物件從 _discovered_list 中移除。移除條件有兩個:

  1. 如果 Reference 物件的 referent 被置為 null , 那麼就需要將這裡的 Reference 物件移除掉。因為在 Reference 被放入到 _pending_list 之前,JVM 會主動呼叫 Reference 物件的 clear 方法,將 referent 置空。referent = null 代表的語義是這個 Reference 之前已經被新增到 _discovered_list 中了,比如在上一輪 GC 中就已經被處理了,本輪 GC 直接將 Reference 物件回收掉就好了,不需要再重複新增到 _discovered_list。

  2. 如果 Reference 物件在前幾輪 GC 沒有被處理過,是在本輪 GC 中新發現的,那麼就繼續判斷它的 referent 是否還存活,如果仍然存活的話,就將 Reference 物件移除,因為 referent 還活著,自然也不需要被 ReferenceHandler 執行緒處理

bool ZReferenceProcessor::should_drop(oop reference, ReferenceType type) const {
  // 獲取 Reference 所引用的 referent
  const oop referent = reference_referent(reference);
  // 如果 Reference 物件在上一輪 GC 中被處理過或者已經被應用執行緒自己處理了,那麼本輪 GC 直接回收掉
  // 不會再將 Reference 物件重複新增到 _discovered_list
  if (referent == NULL) {
    return true;
  }
  
  // 如果 referent 仍然存活,那麼也會將 Reference 物件移除,不需要被 ReferenceHandler 執行緒處理
  if (type == REF_PHANTOM) {
    // 針對 PhantomReference 物件的特殊處理,後面在專門的小節中講解,這裡先忽略
    return ZBarrier::is_alive_barrier_on_phantom_oop(referent);
  } else {
    // 本小節我們重點關注這個分支,主要就是判斷這個 referent 是否被標記為 alive
    return ZBarrier::is_alive_barrier_on_weak_oop(referent);
  }
}

這裡大家可能就有點懵了,因為筆者前面介紹過,在 ZGC 的 Concurrent Mark 階段, Reference 物件被 JVM 新增到 _discovered_list 中的條件就是這個 Reference 物件的 referent 沒有被標記過。那為什麼這裡又要判斷一下呢 ?我們來看一個這樣的場景:

image

上圖中展示的這個場景是,一個 object 物件在 JVM 堆中同時被一個 StrongReference 物件和一個 WeakReference 物件所引用。

假設在 ZGC 的 Concurrent Mark 階段,GC 執行緒先遍歷到 WeakReference 物件,注意此時還沒有遍歷到 StrongReference 物件。由於還沒有遍歷到 StrongReference ,所以這個 object 物件還沒有被標記為 alive。

而對於 WeakReference 物件來說,GC 執行緒並不會遍歷標記它的 referent,對吧,這是我們第四小節中的內容了。這時這個 WeakReference 物件就會被 JVM 新增到 _discovered_list 中。

好的,我們繼續 Concurrent Mark,後面 GC 執行緒最終是要遍歷到 StrongReference 物件的,對吧。當 GC 執行緒遍歷到 StrongReference 物件的時候首先會標記這個 StrongReference 物件為 alive,隨後開始遍歷它的所有非靜態成員變數,逐個進行標記 alive,在這個過程中 object 物件最終也會被標記為 alive。

當 Concurrent Mark 結束之後,我們來到了本小節的 Concurrent Process Non-Strong References 階段,那麼對於此時被新增到 _discovered_list 中的這個 WeakReference 物件是不是就不對了,因為它的 referent 後面又被標記為 alive 了,所以在 should_drop 函式的最後還是要透過 is_alive_barrier_on_weak_oop 判斷一下 referent 是否被標記,如果被標記過了,那麼就需要將這個 WeakReference 物件從 _discovered_list 中移除。

瞭解了這個背景,我們再來看 ZReferenceProcessor::work 中的處理邏輯就很清晰了。首先 GC 執行緒會在 while (*p != NULL) 迴圈中不停的遍歷 _discovered_list 中臨時存放的這些 Reference 物件。

然後透過 should_drop 判斷這個 Reference 物件是否應該從 _discovered_list 中移除,如果 should_drop 返回 true ,那麼 JVM 就會透過 drop 方法將 Reference 物件移除。很簡單的連結串列操作,這裡筆者就不展開了。

如果 should_drop 返回 false, JVM 就會讓這個 Reference 物件繼續保留在 _discovered_list 中,並呼叫 keep 方法獲取該 Reference 物件在 _discovered_list 中的下一個元素,繼續進行 while 迴圈重複上述的判斷邏輯。

oop* ZReferenceProcessor::keep(oop reference, ReferenceType type) {

  // 入隊計數加 1
  _enqueued_count.get()[type]++;

  // 將 referent 置為 null ,此後 Reference 就變為了 inactive
  make_inactive(reference, type);

  // 從 _discovered_list 中獲取下一個 Reference 繼續迴圈
  return reference_discovered_addr(reference);
}

keep 方法中會呼叫一個 make_inactive 方法,JVM 在這裡會呼叫 Reference 物件的 clear 方法將 referent 置為 null 。

void ZReferenceProcessor::make_inactive(oop reference, ReferenceType type) const {
  if (type == REF_FINAL) {
      ..... 省略 FinalReference 的處理 ....
  } else {
    // 這裡呼叫 Reference 物件的 clear 方法將,referent 置為 null
    reference_clear_referent(reference);
  }
}

那麼此時如果我們在應用執行緒中呼叫這個 Reference 物件的 get() 方法的時候就會得到一個 null 值,referent 物件被 JVM 置為 null 的時機就是這個 Reference 物件確定要被新增到 _pending_list 的時候。

WeakReference weakReference = new WeakReference<Object>(new Object());
weakReference.get();

當 _discovered_list 中的那些所有需要被移除的 Reference 物件都已經被移除之後,JVM 就會將終態的 _discovered_list 原子地新增到 _pending_list 中。

Concurrent Process Non-Strong References 階段的最後,ZGC 就會呼叫 enqueue_references 方法將 _pending_list 中的 Reference 物件轉移到 _reference_pending_list 中。最後重置 pending list,為下一輪 GC 做準備。

image

    void ZReferenceProcessor::enqueue_references() {

        if (_pending_list.get() == NULL) {
            // Nothing to enqueue
            return;
        }

        {
            // Heap_lock protects external pending list
            MonitorLocker ml(Heap_lock);

            // 將 _pending_list 新增到 _reference_pending_list 中
            *_pending_list_tail = Universe::swap_reference_pending_list(_pending_list.get());

            // 喚醒 ReferenceHandler 執行緒
            ml.notify_all();
        }

        // 重置 pending list,為下一輪 GC 做準備
        _pending_list.set(NULL);
        _pending_list_tail = _pending_list.addr();
    }

這裡我們看到 ZGC 在更新完 _reference_pending_list 之後,會呼叫一個 ml.notify_all(),那麼這個操作是要喚醒誰呢 ?或者說誰會在 Heap_lock 上等待呢 ?

還記不記得筆者在第三小節中為大家介紹的 native 方法 —— waitForReferencePendingList() :

// Reference.c 檔案
JNIEXPORT void JNICALL
Java_java_lang_ref_Reference_waitForReferencePendingList(JNIEnv *env, jclass ignore)
{
    JVM_WaitForReferencePendingList(env);
}

// jvm.cpp 檔案
JVM_ENTRY(void, JVM_WaitForReferencePendingList(JNIEnv* env))
  MonitorLocker ml(Heap_lock);
  while (!Universe::has_reference_pending_list()) {
    // 如果 _reference_pending_list 還沒有 Reference 物件,那麼當前執行緒在 Heap_lock 上 wait
    ml.wait();
  }
JVM_END

那麼誰會在這個方法上阻塞等待呢 ?答案就是 —— ReferenceHandler 執行緒。

private static class ReferenceHandler extends Thread {

  private static void processPendingReferences() {

        waitForReferencePendingList();

        ........ 省略 .....
  }
}

至於 ReferenceHandler 執行緒被喚醒之後幹了什麼 ? 這不就是筆者在第三小節中詳細為大家介紹的內容麼,這樣一來是不是就和前面的內容遙相呼應起來了~~~

image

6. SoftReference 具體在什麼時候被回收 ? 如何量化記憶體不足 ?

大家在網上或者在其他講解 JVM 的書籍中多多少少會看到這樣一段關於 SoftReference 的描述 —— “當 SoftReference 所引用的 referent 物件在整個堆中沒有其他強引用的時候,發生 GC 的時候,如果此時記憶體充足,那麼這個 referent 物件就和其他強引用一樣,不會被 GC 掉,如果此時記憶體不足,系統即將 OOM 之前,那麼這個 referent 物件就會被當做垃圾回收掉”。

image

當然了,如果僅從概念上理解的話,這樣描述就夠了,但是如果我們從 JVM 的實現角度上來說,那這樣的描述至少是不準確的,為什麼呢 ? 筆者先提兩個問題出來,大家可以先思考下:

  1. 記憶體充足的情況下,SoftReference 所引用的 referent 物件就一定不會被回收嗎 ?

  2. 什麼是記憶體不足 ?這個概念如何量化,SoftReference 所引用的 referent 物件到底什麼時候被回收 ?

下面筆者繼續以 ZGC 為例,帶大家深入到 JVM 內部去探尋下這兩個問題的精確答案~~

6.1 JVM 無條件回收 SoftReference 的場景

經過前面第五小節的介紹,我們知道 ZGC 在 Concurrent Mark 以及 Concurrent Process Non-Strong References 階段中處理 Reference 物件的關鍵邏輯都封裝在 ZReferenceProcessor 中。

在 ZReferenceProcessor 中有一個關鍵的屬性 —— _soft_reference_policy,在 ZGC 的過程中,處理 SoftReference 的策略就封裝在這裡,本小節開頭提出的那兩個問題的答案就隱藏在 _soft_reference_policy 中。

class ZReferenceProcessor : public ReferenceDiscoverer {
  // 關於 SoftReference 的處理策略
  ReferencePolicy*     _soft_reference_policy;
}

那下面的問題就是如果我們能夠知道 _soft_reference_policy 的初始化邏輯,那是不是關於 SoftReference 的一切疑惑就迎刃而解了 ?我們來一起看下 _soft_reference_policy 的初始化過程。

在 ZGC 開始的時候,首先會建立一個 ZDriverGCScope 物件,這裡主要進行一些 GC 的準備工作,比如更新 GC 的相關統計資訊,設定並行 GC 執行緒個數,以及本小節的重點,初始化 SoftReference 的處理策略 —— _soft_reference_policy。

void ZDriver::gc(const ZDriverRequest& request) {
  ZDriverGCScope scope(request);
  ..... 省略 ......
}
class ZDriverGCScope : public StackObj {
private:
  GCCause::Cause             _gc_cause;
public:
  ZDriverGCScope(const ZDriverRequest& request) :
      _gc_cause(request.cause()),
 {
    // Set up soft reference policy
    const bool clear = should_clear_soft_references(request);
    ZHeap::heap()->set_soft_reference_policy(clear);
  }

在 JVM 開始初始化 _soft_reference_policy 之前,會呼叫一個重要的方法 —— should_clear_soft_references,本小節的答案就在這裡,該方法就是用來判斷,ZGC 是否需要無條件清理 SoftReference 所引用的 referent 物件。

  • 返回 true 表示,在 GC 的過程中只要遇到 SoftReference 物件,那麼它引用的 referent 物件就會被當做垃圾清理,SoftReference 物件也會被 JVM 加入到 _reference_pending_list 中等待 ReferenceHandler 執行緒去處理。這裡就和 WeakReference 的語義一樣了。

  • 返回 false 表示,記憶體充足的時候,JVM 就會把 SoftReference 當做普通的強引用一樣處理,它所引用的 referent 物件不會被回收,但記憶體不足的時候,被 SoftReference 所引用的 referent 物件就會被回收,SoftReference 也會被加入到 _reference_pending_list 中。

static bool should_clear_soft_references(const ZDriverRequest& request) {
  // Clear soft references if implied by the GC cause
  if (request.cause() == GCCause::_wb_full_gc ||
      request.cause() == GCCause::_metadata_GC_clear_soft_refs ||
      request.cause() == GCCause::_z_allocation_stall) {
    // 無條件清理 SoftReference
    return true;
  }

  // Don't clear
  return false;
}

這裡我們看到,在 ZGC 的過程中,只要滿足以下三種情況中的任意一種,那麼在 GC 過程中就會無條件地清理 SoftReference 。

  1. 引起 GC 的原因是 —— _wb_full_gc ,也就是由 WhiteBox 相關 API 觸發的 Full GC,就會無條件清理 SoftReference。

  2. 引起 GC 的原因是 —— _metadata_GC_clear_soft_refs,也就是在後設資料分配失敗的時候觸發的 Full GC,元空間記憶體不足,情況就很嚴重了,所以要無條件清理 SoftReference。

  3. 引起 GC 的原因是 —— _z_allocation_stall,在 ZGC 採用阻塞模式分配 Zpage 頁面的時候,如果記憶體不足無法分配,那麼就會觸發一次 GC,這時 GC 的觸發原因就是 _z_allocation_stall,這種情況下就會無條件清理 SoftReference。

ZGC 非阻塞模式分配 Zpage 的時候如果記憶體不足、就直接丟擲 OutOfMemoryError,不會啟動 GC 。

ZPage* ZPageAllocator::alloc_page(uint8_t type, size_t size, ZAllocationFlags flags) {
  EventZPageAllocation event;

retry:
  ZPageAllocation allocation(type, size, flags);
  // 判斷是否進行阻塞分配 ZPage
  if (!alloc_page_or_stall(&allocation)) {
    // 如果非阻塞分配  ZPage 失敗,直接 Out of memory
    return NULL;
  }
}

在我們瞭解了這個背景之後,在回頭來看下 _soft_reference_policy 的初始化過程 :

引數 clear 就是 should_clear_soft_references 函式的返回值

void ZReferenceProcessor::set_soft_reference_policy(bool clear) {
  static AlwaysClearPolicy always_clear_policy;
  static LRUMaxHeapPolicy lru_max_heap_policy;

  if (clear) {
    log_info(gc, ref)("Clearing All SoftReferences");
    _soft_reference_policy = &always_clear_policy;
  } else {
    _soft_reference_policy = &lru_max_heap_policy;
  }

  _soft_reference_policy->setup();
}

ZGC 採用了兩種策略來處理 SoftReference :

  1. always_clear_policy : 當 clear 為 true 的時候,ZGC 就會採用這種策略,在 GC 的過程中只要遇到 SoftReference,就會無條件回收其引用的 referent 物件,SoftReference 物件也會被 JVM 加入到 _reference_pending_list 中等待 ReferenceHandler 執行緒去處理。

  2. lru_max_heap_policy :當 clear 為 false 的時候,ZGC 就會採用這種策略,這種情況下 SoftReference 的存活時間取決於 JVM 堆中剩餘可用記憶體的總大小,也是我們下一小節中討論的重點。

下面我們就來看一下 lru_max_heap_policy 的初始化過程,看看 JVM 是如何量化記憶體不足的 ~~

6.2 JVM 如何量化記憶體不足

LRUMaxHeapPolicy 的 setup() 方法主要用來確定被 SoftReference 所引用的 referent 物件最大的存活時間,這個存活時間是和堆的剩餘空間大小有關係的,也就是堆的剩餘空間越大 SoftReference 的存活時間就越長,堆的剩餘空間越小 SoftReference 的存活時間就越短。

void LRUMaxHeapPolicy::setup() {
  size_t max_heap = MaxHeapSize;
  // 獲取最近一次 gc 之後,JVM 堆的最大剩餘空間
  max_heap -= Universe::heap()->used_at_last_gc();
  // 轉換為 MB
  max_heap /= M;
  //  -XX:SoftRefLRUPolicyMSPerMB 預設為 1000 ,單位毫秒
  // 表示每 MB 的剩餘記憶體空間中允許 SoftReference 存活的最大時間
  _max_interval = max_heap * SoftRefLRUPolicyMSPerMB;
  assert(_max_interval >= 0,"Sanity check");
}

JVM 首先會獲取我們透過 -Xmx 引數指定的最大堆 —— MaxHeapSize,然後在透過 Universe::heap()->used_at_last_gc() 獲取上一次 GC 之後 JVM 堆佔用的空間,兩者相減,就得到了當前 JVM 堆的最大剩餘記憶體空間,並將單位轉換為 MB

現在 JVM 堆的剩餘空間我們計算出來了,那如何根據這個 max_heap 計算 SoftReference 的最大存活時間呢 ?

這裡就用到了一個 JVM 引數 —— SoftRefLRUPolicyMSPerMB,我們可以透過 -XX:SoftRefLRUPolicyMSPerMB 來指定,預設為 1000 , 單位為毫秒。

它表達的意思是每 MB 的堆剩餘記憶體空間允許 SoftReference 存活的最大時長,比如當前堆中只剩餘 1MB 的記憶體空間,那麼 SoftReference 的最大存活時間就是 1000 ms,如果剩餘記憶體空間為 2MB,那麼 SoftReference 的最大存活時間就是 2000 ms 。

現在我們剩餘 max_heap 的空間,那麼在本輪 GC 中,SoftReference 的最大存活時間就是 —— _max_interval = max_heap * SoftRefLRUPolicyMSPerMB

從這裡我們可以看出 SoftReference 的最大存活時間 _max_interval,取決於兩個因素:

  1. 當前 JVM 堆的最大剩餘空間。

  2. 我們指定的 -XX:SoftRefLRUPolicyMSPerMB 引數值,這個值越大 SoftReference 存活的時間就越久,這個值越小,SoftReference 存活的時間就越短。

在我們得到了這個 _max_interval 之後,那麼 JVM 是如何量化記憶體不足呢 ?被 SoftReference 引用的這個 referent 物件到底什麼被回收 ?讓我們再次回到 JDK 中,來看一下 SoftReference 的實現:

public class SoftReference<T> extends Reference<T> {
    // 由 JVM 來設定,每次 GC 發生的時候,JVM 都會記錄一個時間戳到這個 clock 欄位中
    private static long clock;
    // 表示應用執行緒最近一次訪問這個 SoftReference 的時間戳(當前的 clock 值)
    // 在 SoftReference 的 get 方法中設定
    private long timestamp;

    public SoftReference(T referent) {
        super(referent);
        this.timestamp = clock;
    }

    public T get() {
        T o = super.get();
        if (o != null && this.timestamp != clock)
            // 將最近一次的 gc 發生時間設定到 timestamp 中
            // 用這個表示當前 SoftReference 最近被訪問的時間戳
            // 注意這裡的時間戳語義是 最近一次的 gc 時間
            this.timestamp = clock;
        return o;
    }
}

SoftReference 中有兩個非常重要的欄位,一個是 clock ,另一個是 timestamp。clock 欄位是由 JVM 來設定的,在每一次發生 GC 的時候,JVM 都會去更新這個時間戳。具體一點的話,就是在 ZGC 的 Concurrent Process Non-Strong References 階段處理完所有 Reference 物件之後,JVM 就會來更新這個 clock 欄位。

void ZReferenceProcessor::process_references() {
  ZStatTimer timer(ZSubPhaseConcurrentReferencesProcess);

  // Process discovered lists
  ZReferenceProcessorTask task(this);
  // gc _workers 一起執行 ZReferenceProcessorTask
  _workers->run(&task);

  // Update SoftReference clock
  soft_reference_update_clock();
}

soft_reference_update_clock() 中 ,JVM 會將 SoftReference 類中的 clock 欄位更新為當前時間戳,單位為毫秒。

static void soft_reference_update_clock() {
  const jlong now = os::javaTimeNanos() / NANOSECS_PER_MILLISEC;
  java_lang_ref_SoftReference::set_clock(now);
}

而 timestamp 欄位用來表示這個 SoftReference 物件有多久沒有被訪問到了,應用執行緒越久沒有訪問 SoftReference,JVM 就越傾向於回收它的 referent 物件。這也是 LRUMaxHeapPolicy 策略中 LRU 的語義體現。

應用執行緒在每次呼叫 SoftReference 的 get 方法時候,都會將最近一次的 GC 時間戳 clock 更新到 timestamp 中,這樣一來,如果一個 SoftReference 被頻繁的訪問,那麼 clock 和 timestamp 的值一直是相等的。

image

如果一個 SoftReference 已經很久沒有被訪問了,timestamp 就會遠遠落後於 clock,因為在沒有被訪問的這段時間內可能已經發生好幾次 GC 了。

image

在我們瞭解了這些背景之後,再來看一下 JVM 對於 SoftReference 的回收過程,在本文 5.1 小節中介紹的 ZGC Concurrent Mark 階段中,當 GC 遍歷到一個 Reference 型別的物件的時候,會在 should_discover 方法中判斷一下這個 Reference 物件所引用的 referent 是否被標記過。如果 referent 沒有被標記為 alive , 那麼接下來就會將這個 Reference 物件放入 _discovered_list 中,等待後續被 ReferenHandler 處理,referent 也會在本輪 GC 中被回收掉。

bool ZReferenceProcessor::should_discover(oop reference, ReferenceType type) const {

  // 此時 Reference 的狀態就是 inactive,那麼這裡將不會重複將 Reference 新增到 _discovered_list 重複處理
  if (is_inactive(reference, referent, type)) {
    return false;
  }
  // referent 還被強引用關聯,那麼 return false 也就是說不能被加入到 discover list 中
  if (is_strongly_live(referent)) {
    return false;
  }
  // referent 現在只被軟引用關聯,那麼就需要透過 LRUMaxHeapPolicy
  // 來判斷這個 SoftReference 所引用的 referent 是否應該存活
  if (is_softly_live(reference, type)) {
    return false;
  }

  return true;
}

如果當前遍歷到的 Reference 物件是 SoftReference 型別的,那麼就需要在 is_softly_live 方法中根據前面介紹的 LRUMaxHeapPolicy 來判斷這個 SoftReference 引用的 referent 物件是否滿足存活的條件。

bool ZReferenceProcessor::is_softly_live(oop reference, ReferenceType type) const {
  if (type != REF_SOFT) {
    // Not a SoftReference
    return false;
  }

  // Ask SoftReference policy
  // 獲取 SoftReference 中的 clock 欄位,這裡存放的是上一次 gc 的時間戳
  const jlong clock = java_lang_ref_SoftReference::clock();
  // 判斷是否應該清除這個 SoftReference
  return !_soft_reference_policy->should_clear_reference(reference, clock);
}

透過 java_lang_ref_SoftReference::clock() 獲取到的就是前面介紹的 SoftReference.clock 欄位 —— timestamp_clock。

透過 java_lang_ref_SoftReference::timestamp(p) 獲取到的就是前面介紹的 SoftReference.timestamp 欄位。

如果 SoftReference.clock 與 SoftReference.timestamp 的差值 —— interval,小於等於前面介紹的 SoftReference 最大存活時間 —— _max_interval,那麼這個 SoftReference 所引用的 referent 物件在本輪 GC 中就不會被回收,SoftReference 物件也不會被放到 _reference_pending_list 中被 ReferenceHandler 執行緒處理。

// The oop passed in is the SoftReference object, and not
// the object the SoftReference points to.
bool LRUMaxHeapPolicy::should_clear_reference(oop p,
                                             jlong timestamp_clock) {
  // 相當於 SoftReference.clock - SoftReference.timestamp
  jlong interval = timestamp_clock - java_lang_ref_SoftReference::timestamp(p);


  // The interval will be zero if the ref was accessed since the last scavenge/gc.
  // 如果 clock 與 timestamp 的差值小於等於 _max_interval (SoftReference 的最大存活時間)
  if(interval <= _max_interval) {
    // SoftReference 所引用的 referent 物件在本輪 GC 中就不會被回收
    return false;
  }
  // interval 大於 _max_interval,這個 SoftReference 所引用的 referent 物件就會被回收
  // SoftReference 也會被放到 _reference_pending_list 中等待 ReferenceHandler 執行緒去處理
  return true;
}

如果 interval 大於 _max_interval,那麼這個 SoftReference 所引用的 referent 物件在本輪 GC 中就會被回收,SoftReference 物件也會被 JVM 放到 _reference_pending_list 中等待 ReferenceHandler 執行緒處理。

從以上過程中我們可以看出,SoftReference 被 ZGC 回收的精確時機是,當一個 SoftReference 物件已經很久很久沒有被應用執行緒訪問到了,那麼發生 GC 的時候這個 SoftReference 就會被回收掉。

具體多久呢 ? 就是 _max_interval 指定的 SoftReference 最大存活時間,這個時間由當前 JVM 堆的最大剩餘空間和 -XX:SoftRefLRUPolicyMSPerMB 共同決定。

比如,發生 GC 的時候,當前堆的最大剩餘空間為 1MB,SoftRefLRUPolicyMSPerMB 指定的是 1000 ms ,那麼當一個 SoftReference 物件超過 1000 ms 沒有被應用執行緒訪問的時候,就會被 ZGC 回收掉。

7. FinalReference 如何使 GC 過程變得磨磨唧唧

FinalReference 對於我們來說是一種比較陌生的 Reference 型別,因為我們好像在各大中介軟體以及 JDK 中並沒有見過它的應用場景,事實上,FinalReference 被設計出來的目的也不是給我們用的,而是給 JVM 用的,它和 Java 物件的 finalize() 方法執行機制有關。

public class Object {
    @Deprecated(since="9")
    protected void finalize() throws Throwable { }
}

我們看到 finalize() 方法在 OpenJDK9 中已經被標記為 @Deprecated 了,並不推薦使用。筆者其實一開始也並不想提及它,但是思來想去,本文是主要介紹各類 Refernce 語義實現的,前面筆者已經非常詳細的介紹了 SoftReference,WeakReference,PhantomReference 在 JVM 中的實現。

在文章的最後何不利用這個 FinalReference 將前面介紹的內容再次為大家串聯一遍,加深一下大家對 Reference 整個處理鏈路的理解,基於這個目的,才有了本小節的內容。但筆者的本意並不是為了讓大家使用它。

下面我們還是按照老規矩,繼續從 JDK 以及 JVM 這兩個視角全方位的介紹一下 FinalReference 的實現機制,併為大家解釋一下這個 FinalReference 如何使整個 GC 過程變得拖拖拉拉,磨磨唧唧~~~

7.1 從 JDK 視角看 FinalReference

image

FinalReference 本質上來說它也是一個 Reference,所以它的基本語義和 WeakReference 保持一致,JVM 在 GC 階段對它的整體處理流程和 WeakReference 也是大致一樣的。

唯一一點不同的是,由於 FinalReference 是和被它引用的 referent 物件的 finalize() 執行有關,當一個普通的 Java 物件在整個 JVM 堆中只有 FinalReference 引用它的時候,按照 WeakReference 的基礎語義來講,這個 Java 物件就要被回收了。

但是在這個 Java 物件被回收之前,JVM 需要保證它的 finalize()被執行到,所以 FinalReference 會再次將這個 Java 物件重新標記為 alive,也就是在 GC 階段重新復活這個 Java 物件。

後面的流程就和其他 Reference 一樣了,FinalReference 也會被 JVM 加入到 _reference_pending_list 連結串列中,ReferenceHandler 執行緒被喚醒,隨後將這個 FinalReference 從 _reference_pending_list 上摘下,並加入到與其關聯的 ReferenceQueue 中,這個流程就是我們第三小節主要討論的內容,大家還記得嗎 ?

image

和 Cleaner 不同的是,對於 FinalReference 來說,在 JDK 中還有一個叫做 FinalizerThread 執行緒來專門處理它,FinalizerThread 執行緒會不斷的從與 FinalReference 關聯的 ReferenceQueue 中,將所有需要被處理的 FinalReference 摘下,然後挨個執行被它所引用的 referent 物件的 finalize() 方法。

隨後在下一輪的 GC 中,FinalReference 物件以及它引用的 referent 物件才會被 GC 回收掉。

以上就是 FinalReference 被 JVM 處理的整個生命週期,下面讓我們先回到最初的起點,這個 FinalReference 是怎麼和一個 Java 物件關聯起來的呢 ?

我們知道 FinalReference 是和 Java 物件的 finalize() 方法執行有關的,如果一個 Java 類沒有重寫 finalize() 方法,那麼在建立這個 Java 類的例項物件的時候將不會和這個 FinalReference 有任何的瓜葛,它就是一個普通的 Java 物件。

但是如何一個 Java 類重寫了 finalize() 方法 ,那麼在建立這個 Java 類的例項物件的時候, JVM 就會將一個 FinalReference 例項和這個 Java 物件關聯起來。

instanceOop InstanceKlass::allocate_instance(TRAPS) {
  // 判斷這個類是否重寫了 finalize() 方法
  bool has_finalizer_flag = has_finalizer(); 
  instanceOop i;
  // 建立例項物件
  i = (instanceOop)Universe::heap()->obj_allocate(this, size, CHECK_NULL);
  // 如果該物件重寫了  finalize() 方法
  if (has_finalizer_flag && !RegisterFinalizersAtInit) {
    // JVM 這裡就會呼叫 Finalizer 類的靜態方法 register
    // 將這個 Java 物件與 FinalReference 關聯起來
    i = register_finalizer(i, CHECK_NULL);
  }
  return i;
}

我們看到,在 JVM 建立物件例項的時候,會首先透過 has_finalizer() 方法判斷這個 Java 類有沒有重寫 finalize() 方法,如果重寫了就會呼叫 register_finalizer 方法,JVM 最終會呼叫 JDK 中的 Finalizer 類的靜態方法 register。

final class Finalizer extends FinalReference<Object> {
    static void register(Object finalizee) {
        new Finalizer(finalizee);
    }
}

在這裡 JVM 會將剛剛建立出來的普通 Java 物件 —— finalizee,與一個 Finalizer 物件關聯起來, Finalizer 物件的型別正是 FinalReference 。這裡我們可以看到,當一個 Java 類重寫了 finalize() 方法的時候,每當建立一個該類的例項物件,JVM 就會自動建立一個對應的 Finalizer 物件

Finalizer 的整體設計和之前介紹的 Cleaner 非常相似,不同的是 Cleaner 是一個 PhantomReference,而 Finalizer 是一個 FinalReference。

它們都有一個 ReferenceQueue,只不過 Cleaner 中的那個基本沒啥用,但是 Finalizer 中的這個 ReferenceQueue 卻有非常重要的作用。

它們內部都有一個雙向連結串列,裡面包含了 JVM 堆中所有的 Finalizer 物件,用來確保這些 Finalizer 在執行 finalizee 物件的 finalize() 方法之前不會被 GC 回收掉。

final class Finalizer extends FinalReference<Object> { 

    private static ReferenceQueue<Object> queue = new ReferenceQueue<>();

    // 雙向連結串列,儲存 JVM 堆中所有的 Finalizer 物件,防止 Finalizer 被 GC 掉
    private static Finalizer unfinalized = null;

    private Finalizer next, prev;

    private Finalizer(Object finalizee) {
        super(finalizee, queue);
        // push onto unfinalized
        synchronized (lock) {
            if (unfinalized != null) {
                this.next = unfinalized;
                unfinalized.prev = this;
            }
            unfinalized = this;
        }
    }
}

在建立 Finalizer 物件的時候,首先會呼叫父類方法,將被引用的 Java 物件以及 ReferenceQueue 關聯註冊到 FinalReference 中。

    Reference(T referent, ReferenceQueue<? super T> queue) {
        // 被引用的普通 Java 物件
        this.referent = referent;
        //  Finalizer 中的 ReferenceQueue 例項(全域性)
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }

最後將這個 Finalizer 物件插入到雙向連結串列 —— unfinalized 中。

image

這個結構是不是和第三小節中我們介紹的 Cleaner 非常相似。

image

而 Cleaner 最後是被 ReferenceHandler 執行緒執行的,那這個 Finalizer 最後是被哪個執行緒執行的呢 ?

這裡就要引入另一個 system thread 了,在 Finalizer 類初始化的時候會建立一個叫做 FinalizerThread 的執行緒。

final class Finalizer extends FinalReference<Object> { 
    static {
        ThreadGroup tg = Thread.currentThread().getThreadGroup();
        // 獲取 system thread group
        for (ThreadGroup tgn = tg;
             tgn != null;
             tg = tgn, tgn = tg.getParent());
        // 建立 system thread : FinalizerThread
        Thread finalizer = new FinalizerThread(tg);
        finalizer.setPriority(Thread.MAX_PRIORITY - 2);
        finalizer.setDaemon(true);
        finalizer.start();
    }
}

FinalizerThread 的優先順序被設定為 Thread.MAX_PRIORITY - 2,還記得 ReferenceHandler 執行緒的優先順序嗎 ?

public abstract class Reference<T> {

    static {
        Thread handler = new ReferenceHandler(tg, "Reference Handler");
        // 設定 ReferenceHandler 執行緒的優先順序為最高優先順序
        handler.setPriority(Thread.MAX_PRIORITY);
        handler.setDaemon(true);
        handler.start();  
    }
}

而一個普通的 Java 執行緒,它的預設優先順序是多少呢 ?

    /**
     * The default priority that is assigned to a thread.
     */
    public static final int NORM_PRIORITY = 5;

我們可以看出這三類執行緒的排程優先順序為:ReferenceHandler > FinalizerThread > Java 業務 Thead

FinalizerThread 執行緒在執行起來之後,會不停的從一個 queue 中獲取 Finalizer 物件,然後執行 Finalizer 中的 runFinalizer 方法,這個邏輯是不是和 ReferenceHandler 執行緒不停的從 _reference_pending_list 中獲取 Cleaner 物件,然後執行 Cleaner 的 clean 方法非常相似。

    private static class FinalizerThread extends Thread {

        public void run() {
            for (;;) {
                try {
                    Finalizer f = (Finalizer)queue.remove();
                    f.runFinalizer(jla);
                } catch (InterruptedException x) {
                    // ignore and continue
                }
            }
        }
    }

這個 queue 就是 Finalizer 中定義的 ReferenceQueue,在 JVM 建立 Finalizer 物件的時候,會將重寫了 finalize() 方法的 Java 物件與這個 ReferenceQueue 一起註冊到 FinalReference 中。

final class Finalizer extends FinalReference<Object> { 
    private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
    private Finalizer(Object finalizee) {
        super(finalizee, queue);
    }
}

那這個 ReferenceQueue 中的 Finalizer 物件是從哪裡新增進來的呢 ?這就又和我們第三小節中介紹的內容遙相呼應起來了,就是 ReferenceHandler 執行緒新增進來的。

private static class ReferenceHandler extends Thread {
    private static void processPendingReferences() {
        // ReferenceHandler 執行緒等待 JVM 向 _reference_pending_list 填充 Reference 物件
        waitForReferencePendingList();
        // 用於指向 JVM 的 _reference_pending_list
        Reference<?> pendingList;
        synchronized (processPendingLock) {
            // 獲取 _reference_pending_list,隨後將 _reference_pending_list 置為 null
            // 方便 JVM 在下一輪 GC 處理其他 Reference 物件
            pendingList = getAndClearReferencePendingList();
        }
        // 將 pendingList 中的 Reference 物件挨個從連結串列中摘下處理
        while (pendingList != null) {
            // 從 pendingList 中摘下 Reference 物件
            Reference<?> ref = pendingList;
            pendingList = ref.discovered;
            ref.discovered = null;
            
            // 如果該 Reference 物件是 Cleaner 型別,那麼在這裡就會呼叫它的 clean 方法
            if (ref instanceof Cleaner) {
                 // Cleaner 的 clean 方法就是在這裡呼叫的
                ((Cleaner)ref).clean();
            } else {
                // 這裡處理除 Cleaner 之外的其他 Reference 物件
                // 比如,其他 PhantomReference,WeakReference,SoftReference,FinalReference
                // 將他們新增到各自注冊的 ReferenceQueue 中
                ref.enqueueFromPending();
            }
        }
    }
}

當一個 Java 物件在 JVM 堆中只有 Finalizer 物件引用,除此之外沒有任何強引用或者軟引用之後,JVM 首先會將這個 Java 物件復活,在本次 GC 中並不會回收它,隨後會將這個 Finalizer 物件插入到 JVM 內部的 _reference_pending_list 中,然後從 waitForReferencePendingList() 方法上喚醒 ReferenceHandler 執行緒。

ReferenceHandler 執行緒將 _reference_pending_list 中的 Reference 物件挨個摘下,注意 _reference_pending_list 中儲存的既有 Cleaner,也有其他的 PhantomReference,WeakReference,SoftReference,當然也有本小節的 Finalizer 物件。

如果摘下的是 Cleaner 物件那麼就執行它的 clean 方法,如果是其他 Reference 物件,比如這裡的 Finalizer,那麼就透過 ref.enqueueFromPending(),將這個 Finalizer 物件插入到它的 ReferenceQueue 中。

當這個 ReferenceQueue 有了 Finalizer 物件之後,FinalizerThread 執行緒就會被喚醒,然後執行 Finalizer 物件的 runFinalizer 方法。

image

Finalizer 的內部有一個雙向連結串列 —— unfinalized,它儲存了當前 JVM 堆中所有的 Finalizer 物件,目的是為了避免在執行其引用的 referent 物件的 finalize() 方法之前被 GC 掉。

在 runFinalizer 方法中首先要做的就是將這個 Finalizer 物件從雙向連結串列 unfinalized 上摘下,然後執行 referent 物件的 finalize() 方法。這裡我們可以看到,大家在 Java 類中重寫的 finalize() 方法就是在這裡被執行的。

    private void runFinalizer(JavaLangAccess jla) {
        synchronized (lock) {
            if (this.next == this)      // already finalized
                return;
            // 將 Finalizer 物件從雙向連結串列 unfinalized 上摘下
            if (unfinalized == this)
                unfinalized = this.next;
            else
                this.prev.next = this.next;
            if (this.next != null)
                this.next.prev = this.prev;
            this.prev = null;
            this.next = this;           // mark as finalized
        }

        try {
            // 獲取 Finalizer 引用的 Java 物件
            Object finalizee = this.get();

            if (!(finalizee instanceof java.lang.Enum)) {
                // 執行 java 物件的 finalize() 方法
                jla.invokeFinalize(finalizee);
            }
        } catch (Throwable x) { }
        // 呼叫 FinalReference 的 clear 方法,將其引用的 referent 物件置為 null
        // 下一輪 gc 的時候這個  FinalReference 以及它的 referent 物件就會被回收掉了。
        super.clear();
    }

最後呼叫 Finalizer 物件(FinalReference型別)的 clear 方法,將其引用的 referent 物件置為 null , 在下一輪 GC 的時候, 這個 Finalizer 物件以及它的 referent 物件就會被 GC 掉。

7.2 從 JVM 視角看 FinalReference

現在我們已經從 JVM 的外圍熟悉了 JDK 處理 FinalReference 的整個流程,本小節,筆者將繼續帶著大家深入到 JVM 的內部,看看在 GC 的時候,JVM 是如何處理 FinalReference 的。

在本文 5.1 小節中,筆者為大家介紹了 ZGC 在 Concurrent Mark 階段如何處理 Reference 的整個流程,只不過當時我們偏重於 Reference 基礎語義的實現,還未涉及到 FinalReference 的處理。

但我們在明白了 Reference 基礎語義的基礎之上,再來看 FinalReference 的語義實現就很簡單了,總體流程是一樣的,只不過在一些地方做了些特殊的處理。

image

在 ZGC 的 Concurrent Mark 階段,當 GC 執行緒遍歷標記到一個 FinalReference 物件的時候,首先會透過 should_discover 方法來判斷是否應該將這個 FinalReference 物件插入到 _discovered_list 中。判斷邏輯如下:

bool ZReferenceProcessor::should_discover(oop reference, ReferenceType type) const {
  // 獲取 referent 物件的地址檢視
  volatile oop* const referent_addr = reference_referent_addr(reference);
  // 調整 referent 物件的檢視為 remapped + mark0 也就是 weakgood 檢視
  // 獲取 FinalReference 引用的 referent 物件
  const oop referent = ZBarrier::weak_load_barrier_on_oop_field(referent_addr);

  // 如果 Reference 的狀態就是 inactive,那麼這裡將不會重複將 Reference 新增到 _discovered_list 重複處理
  if (is_inactive(reference, referent, type)) {
    return false;
  }
  // referent 還被強引用關聯,那麼 return false 也就是說不能被加入到 discover list 中
  if (is_strongly_live(referent)) {
    return false;
  }
  // referent 還被軟引用有效關聯,那麼 return false 也就是說不能被加入到 discover list 中
  if (is_softly_live(reference, type)) {
    return false;
  }

  return true;
}

首先獲取這個 FinalReference 物件所引用的 referent 物件,如果這個 referent 物件在 JVM 堆中已經沒有任何強引用或者軟引用了,那麼就會將 FinalReference 物件插入到 _discovered_list 中。

但是在插入之前還要透過 is_inactive 方法判斷一下這個 FinalReference 物件是否在上一輪 GC 中被處理過了,

bool ZReferenceProcessor::is_inactive(oop reference, oop referent, ReferenceType type) const {
  if (type == REF_FINAL) {
    return reference_next(reference) != NULL;
  } else {
    return referent == NULL;
  }
}

對於 FinalReference 來說,inactive 的標誌是它的 next 欄位不為空。

public abstract class Reference<T> {
   volatile Reference next;
}

這裡的 next 欄位是幹嘛的呢 ?比如說,這個 FinalReference 物件在上一輪的 GC 中已經被處理過了,那麼在發生本輪 GC 之前,ReferenceHandler 執行緒就已經將這個 FinalReference 插入到一個 ReferenceQueue 中,這個 ReferenceQueue 是哪來的呢 ?

正是上小節中我們介紹的,JVM 建立 Finalizer 物件的時候傳入的這個 queue。

final class Finalizer extends FinalReference<Object> { 
    private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
    private Finalizer(Object finalizee) {
        super(finalizee, queue);
    }
}

而 ReferenceQueue 中的 FinalReference 物件就是透過它的 next 欄位連結起來的,當一個 FinalReference 物件被 ReferenceHandler 執行緒插入到 ReferenceQueue 中之後,它的 next 欄位就不為空了,也就是說一個 FinalReference 物件一旦進入 ReferenceQueue,它的狀態就變為 inactive 了。

那麼在下一輪的 GC 中如果一個 FinalReference 物件的狀態是 inactive,表示它已經被處理過了,那麼就不在重複新增到 _discovered_list 中了。

如果一個 FinalReference 物件之前沒有被處理過,並且它引用的 referent 物件當前也沒有任何強引用或者軟引用關聯,那麼是不是說明這個 referent 就該被回收了 ?想想 FinalReference 的語義是什麼 ? 是不是就是在 referent 物件被回收之前還要呼叫它的 finalize() 方法 。

所以為了保證 referent 物件的 finalize() 方法得到呼叫,JVM 就會在 discover 方法中將其復活。隨後會將 FinalReference 物件插入到 _discovered_list 中,這樣在 GC 之後 ,FinalizerThread 就會呼叫 referent 物件的 finalize() 方法了,這裡是不是和上一小節的內容呼應起來了。

void ZReferenceProcessor::discover(oop reference, ReferenceType type) {
  // 復活 referent 物件
  if (type == REF_FINAL) {
    // 獲取 referent 地址檢視
    volatile oop* const referent_addr = reference_referent_addr(reference);
    // 如果是 FinalReference 那麼就需要對 referent 進行標記,檢視改為 finalizable 表示只能透過 finalize 方法才能訪問到 referent 物件
    // 因為 referent 後續需要透過 finalize 方法被訪問,所以這裡需要對它進行標記,不能回收
    ZBarrier::mark_barrier_on_oop_field(referent_addr, true /* finalizable */);
  }

  // Add reference to discovered list
  // 確保 reference 不在 _discovered_list 中,不能重複新增
  assert(reference_discovered(reference) == NULL, "Already discovered");
  oop* const list = _discovered_list.addr();
  // 頭插法,reference->discovered = *list
  reference_set_discovered(reference, *list);
  // reference 變為 _discovered_list 的頭部
  *list = reference;
}

那麼 JVM 如何將一個被 FinalReference 引用的 referent 物件復活呢 ?

uintptr_t ZBarrier::mark_barrier_on_finalizable_oop_slow_path(uintptr_t addr) {
  // Mark,這裡的 Finalizable = true
  return mark<GCThread, Follow, Finalizable, Overflow>(addr);
}
template <bool gc_thread, bool follow, bool finalizable, bool publish>
uintptr_t ZBarrier::mark(uintptr_t addr) {
  uintptr_t good_addr;

  // Mark,在 _livemap 標記點陣圖中將 referent 對應的 bit 位標記為 1
  if (should_mark_through<finalizable>(addr)) {
    ZHeap::heap()->mark_object<gc_thread, follow, finalizable, publish>(good_addr);
  }

  if (finalizable) {
    // 調整 referent 物件的檢視為 finalizable
    return ZAddress::finalizable_good(good_addr);
  }

  return good_addr;
}

其實很簡單,首先透過 ZPage::mark_object 將 referent 對應在標記點陣圖 _livemap 的 bit 位標記為 1。其次調整 referent 物件的地址檢視為 finalizable,表示該物件在回收階段被 FinalReference 復活。

inline bool ZPage::mark_object(uintptr_t addr, bool finalizable, bool& inc_live) {
  // Set mark bit, 獲取 referent 物件在標記點陣圖的索引 index 
  const size_t index = ((ZAddress::offset(addr) - start()) >> object_alignment_shift()) * 2;
  // 將 referent 對應的 bit 位標記為 1
  return _livemap.set(index, finalizable, inc_live);
}

到現在 FinalReference 物件已經被加入到 _discovered_list 中了,referent 物件也被複活了,隨後在 ZGC 的 Concurrent Process Non-Strong References 階段,JVM 就會將 _discovered_list 中的所有 Reference 物件(包括這裡的 FinalReference)統統轉移到 _reference_pending_list 中,並喚醒 ReferenceHandler 執行緒去處理。

隨後 ReferenceHandler 執行緒將 _reference_pending_list 中的 FinalReference 物件在新增到 Finalizer 中的 ReferenceQueue 中。隨即 FinalizerThread 執行緒就會被喚醒,然後執行 Finalizer 物件的 runFinalizer 方法,最終就會執行到 referent 物件的 finalize() 方法。這是不是就和上一小節中的內容串起來了。

image

當 referent 物件的 finalize() 方法被 FinalizerThread 執行完之後,下一輪 GC 的這時候,這個 referent 物件以及與它關聯的 FinalReference 物件就會一起被 GC 回收了。

從整個 JVM 對於 FinalReference 的處理過程可以看出,只要我們在一個 Java 類中重寫了 finalize() 方法,那麼當這個 Java 類對應的例項可以被回收的時候,它的 finalize() 方法是一定會被呼叫的。

呼叫的時機取決於 FinalizerThread 執行緒什麼時候被 OS 排程到,但是從另外一個側面也可以看出,由於 FinalReference 的影響,一個原本該被回收的物件,在 GC 的過程又會被 JVM 復活。而只有當這個物件的 finalize() 方法被呼叫之後,該物件以及與它關聯的 FinalReference 只能等到下一輪 GC 的時候才能被回收。

如果 finalize() 方法執行的很久又或者是 FinalizerThread 沒有被 OS 排程到,這中間可能已經發生好幾輪 GC 了,那麼在這幾輪 GC 中,FinalReference 和他的 referent 物件就一直不會被回收,表現的現象就是 JVM 堆中存在大量的 Finalizer 物件。

8. PhantomReference 和 WeakReference 究竟有何不同

PhantomReference 和 WeakReference 如果僅僅從概念上來說其實很難區別出他們之間究竟有何不同,比如, PhantomReference 是用來跟蹤物件是否被垃圾回收的,如果物件被 GC ,那麼其對應的 PhantomReference 就會被加入到一個 ReferenceQueue 中,這個 ReferenceQueue 是在建立 PhantomReference 物件的時候註冊進去的。

我們在應用程式中可以透過檢查這個 ReferenceQueue 中的 PhantomReference 物件,從而可以判斷出其引用的 referent 物件已經被回收,隨即可以做一些釋放資源的工作。

public class PhantomReference<T> extends Reference<T> {
 public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

而 WeakReference 的概念是,如果一個物件在 JVM 堆中已經沒有任何強引用鏈或者軟引用鏈了,在只有一個 WeakReference 引用它的情況下,那麼這個物件就會被 GC,與其對應的 WeakReference 也會被加入到其註冊的 ReferenceQueue 中。後面的套路和 PhantomReference 一模一樣。

既然兩者在概念上都差不多,JVM 處理的過程也差不多,那麼 PhantomReference 可以用來跟蹤物件是否被垃圾回收,WeakReference 可不可以跟蹤呢 ?

事實上,在大部分情況下 WeakReference 也是可以的,但是在一種特殊的情況下 WeakReference 就不可以了,只能由 PhantomReference 來跟蹤物件的回收狀態。

image

上圖中,object1 物件在 JVM 堆中被一個 WeakReference 物件和 FinalReference 物件同時引用,除此之外沒有任何強引用鏈和軟引用鏈,根據 FinalReference 的語義,這個 object1 是不是就要被回收了,但為了執行它的 finalize() 方法所以 JVM 會將 object1 復活。

根據 WeakReference 的語義,此時發生了 GC,並且 object1 沒有任何強引用鏈和軟引用鏈,那麼此時 JVM 是不是就會將 WeakReference 加入到 _reference_pending_list 中,後面再由 ReferenceHandler 執行緒轉移到 ReferenceQueue 中,等待應用程式的處理。

也就是說在這種情況下,FinalReference 和 WeakReference 在本輪 GC 中,都會被 JVM 處理,但是 object1 卻是存活狀態,所以 WeakReference 不能跟蹤物件的垃圾回收狀態。

image

object2 物件在 JVM 堆中被一個 PhantomReference 物件和 FinalReference 物件同時引用,除此之外沒有任何強引用鏈和軟引用鏈,根據 FinalReference 的語義, JVM 會將 object2 復活。

但根據 PhantomReference 的語義,只有在 object2 要被垃圾回收的時候,JVM 才會將 PhantomReference 加入到 _reference_pending_list 中,但是此時 object2 已經復活了,所以 PhantomReference 這裡就不會被加入到 _reference_pending_list 中了。

也就是說在這種情況下,只有 FinalReference 在本輪 GC 中才會被 JVM 處理,隨後 FinalizerThread 會呼叫 Finalizer 物件(FinalReference型別)的 runFinalizer 方法,最終就會執行到 object2 物件的 finalize() 方法。

當 object2 物件的 finalize() 方法被執行完之後,在下一輪 GC 中就會回收 object2 物件,那麼根據 PhantomReference 的語義,PhantomReference 物件只有在下一輪 GC 中才會被 JVM 加入到 _reference_pending_list 中,隨後被 ReferenceHandler 執行緒處理。

所以在這種特殊的情況就只有 PhantomReference 才能用於跟蹤物件的垃圾回收狀態,而 WeakReference 卻不可以。

那 JVM 是如何實現 PhantomReference 和 WeakReference 的這兩種語義的呢

image

首先在 ZGC 的 Concurrent Mark 階段,GC 執行緒會將 JVM 堆中所有需要被處理的 Reference 物件加入到一個臨時的 _discovered_list 中。

隨後在 Concurrent Process Non-Strong References 階段,GC 會透過 should_drop 方法再次判斷 _discovered_list 中存放的這些臨時 Reference 物件所引用的 referent 是否存活 ?

如果這些 referent 仍然存活,那麼就需要將對應的 Reference 物件從 _discovered_list 中移除。

如果這些 referent 不再存活,那麼就將對應的 Reference 物件繼續保留在 _discovered_list,最後將 _discovered_list 中的 Reference 物件全部轉移到 _reference_pending_list 中,隨後喚醒 ReferenceHandler 執行緒去處理。

PhantomReference 和 WeakReference 的核心區別就在這個 should_drop 方法中:

bool ZReferenceProcessor::should_drop(oop reference, ReferenceType type) const {
  // 獲取 Reference 所引用的 referent
  const oop referent = reference_referent(reference);
  
  // 如果 referent 仍然存活,那麼就會將 Reference 物件移除,不需要被 ReferenceHandler 執行緒處理
  if (type == REF_PHANTOM) {
    // 針對 PhantomReference 物件的特殊處理
    return ZBarrier::is_alive_barrier_on_phantom_oop(referent);
  } else {
    // 針對 WeakReference 物件的處理
    return ZBarrier::is_alive_barrier_on_weak_oop(referent);
  }
}

should_drop 方法主要是用來判斷一個被 Reference 引用的 referent 物件是否存活,但是根據 Reference 型別的不同,比如這裡的 PhantomReference 和 WeakReference,具體的判斷邏輯是不一樣的。

根據前面幾個小節的內容,我們知道 ZGC 是透過一個 _livemap 標記點陣圖,來標記一個物件的存活狀態的,ZGC 會將整個 JVM 堆劃分成一個一個的 page,然後從 page 中一個一個的分配物件。每一個 page 結構中有一個 _livemap,用來標記該 page 中所有物件的存活狀態。

class ZPage : public CHeapObj<mtGC> {
private:
  ZLiveMap           _livemap;
}

在 ZGC 中 ZPage 共分為三種型別:

// Page types
const uint8_t     ZPageTypeSmall                = 0;
const uint8_t     ZPageTypeMedium               = 1;
const uint8_t     ZPageTypeLarge                = 2;
  • ZPageTypeSmall 尺寸為 2M , SmallZPage 中的物件尺寸按照 8 位元組對齊,最大允許的物件尺寸為 256K。

  • ZPageTypeMedium 尺寸和 MaxHeapSize 有關,一般會設定為 32 M,MediumZPage 中的物件尺寸按照 4K 對齊,最大允許的物件尺寸為 4M。

  • ZPageTypeLarge 尺寸不定,但需要按照 2M 對齊。如果一個物件的尺寸超過 4M 就需要在 LargeZPage 中分配。

uintptr_t ZObjectAllocator::alloc_object(size_t size, ZAllocationFlags flags) {
  if (size <= ZObjectSizeLimitSmall) {
    // 物件 size 小於等於 256K ,在 SmallZPage 中分配
    return alloc_small_object(size, flags);
  } else if (size <= ZObjectSizeLimitMedium) {
    // 物件 size 大於 256K 但小於等於 4M ,在 MediumZPage 中分配
    return alloc_medium_object(size, flags);
  } else {
    // 物件 size 超過 4M ,在 LargeZPage 中分配
    return alloc_large_object(size, flags);
  }
}

那麼 ZPage 中的這個 _livemap 中的 bit 位個數,是不是就應該和一個 ZPage 所能容納的最大物件個數保持一致,因為一個物件是否存活按理說是不是用一個 bit 就可以表示了 ?

  • ZPageTypeSmall 中最大能容納的物件個數為 2M / 8B = 262144,那麼對應的 _livemap 中是不是隻要 262144 個 bit 就可以了。

  • ZPageTypeMedium 中最大能容納的物件個數為 32M / 4K = 8192,那麼對應的 _livemap 中是不是隻要 8192 個 bit 就可以了。

  • ZPageTypeLarge 只會容納一個大物件。在 ZGC 中超過 4M 的就是大物件。

inline uint32_t ZPage::object_max_count() const {
  switch (type()) {
  case ZPageTypeLarge:
    // A large page can only contain a single
    // object aligned to the start of the page.
    return 1;

  default:
    return (uint32_t)(size() >> object_alignment_shift());
  }
}

但實際上 ZGC 中的 _livemap 所包含的 bit 個數是在此基礎上再乘以 2,也就是說一個物件需要用兩個 bit 位來標記。

static size_t bitmap_size(uint32_t size, size_t nsegments) {
  return MAX2<size_t>(size, nsegments) * 2;
}

那 ZGC 為什麼要用兩個 bit 來標記物件的存活狀態呢 ?答案就是為了區分本小節中介紹的這種特殊情況,一個物件是否存活分為兩種情況:

  1. 物件被 FinalReference 復活,這樣 ZGC 會標記第一個低位 bit —— 1

  2. 物件存在強引用鏈,人家原本就應該存活,這樣 ZGC 會將兩個 bit 位全部標記 —— 11

而在本小節中我們討論的就是物件在被 FinalReference 復活的情況下,PhantomReference 和 WeakReference 的處理有何不同,瞭解了這些背景知識之後,那麼我們再回頭來看 should_drop 方法的判斷邏輯:

首先對於 PhantomReference 來說,在 ZGC 的 Concurrent Process Non-Strong References 階段是透過 ZBarrier::is_alive_barrier_on_phantom_oop 來判斷其引用的 referent 物件是否存活的。

inline bool ZHeap::is_object_live(uintptr_t addr) const {
  ZPage* page = _page_table.get(addr);
  // PhantomReference 判斷的是第一個低位 bit 是否被標記
  // 而 FinalReference 復活 referent 物件標記的也是第一個 bit 位
  return page->is_object_live(addr);
}

inline bool ZPage::is_object_marked(uintptr_t addr) const {
  //  獲取第一個 bit 位 index
  const size_t index = ((ZAddress::offset(addr) - start()) >> object_alignment_shift()) * 2;
  // 檢視是否被 FinalReference 標記過
  return _livemap.get(index);
}

我們看到 PhantomReference 判斷的是第一個 bit 位是否被標記過,而在 FinalReference 復活 referent 物件的時候標記的就是第一個 bit 位。所以 should_drop 方法返回 true,PhantomReference 從 _discovered_list 中移除。

而對於 WeakReference 來說,卻是透過 Barrier::is_alive_barrier_on_weak_oop 來判斷其引用的 referent 物件是否存活的。

inline bool ZHeap::is_object_strongly_live(uintptr_t addr) const {
  ZPage* page = _page_table.get(addr);
  // WeakReference 判斷的是第二個高位 bit 是否被標記
  return page->is_object_strongly_live(addr);
}

inline bool ZPage::is_object_strongly_marked(uintptr_t addr) const {

  const size_t index = ((ZAddress::offset(addr) - start()) >> object_alignment_shift()) * 2;
  //  獲取第二個 bit 位 index
  return _livemap.get(index + 1);
}

我們看到 WeakReference 判斷的是第二個高位 bit 是否被標記過,所以這種情況下,無論 referent 物件是否被 FinalReference 復活,should_drop 方法都會返回 false 。WeakReference 仍然會保留在 _discovered_list 中,隨後和 FinalReference 一起被 ReferenceHandler 執行緒處理。

所以總結一下他們的核心區別就是:

  1. PhantomReference 物件只有在物件被回收的時候,才會被 ReferenceHandler 執行緒處理,它會被 FinalReference 影響。

  2. WeakReference 物件只要是發生 GC , 並且它引用的 referent 物件沒有任何強引用鏈或者軟引用鏈的時候,都會被 ReferenceHandler 執行緒處理,不會被 FinalReference 影響。

總結

本文我們首先從中介軟體的角度,介紹了 SoftReference,WeakReference,PhantomReference,FinalReference 這些 Java 中定義的 Reference 的相關概念及其應用場景。

後面我們從中介軟體的視角轉入到 JDK 中,介紹了 Cleaner,Finalizer,ReferenceHandler 執行緒,FinalizerThread 執行緒,ReferenceQueue 等在 JDK 層面處理 Reference 物件的重要設計。

最後我們又從 JDK 的視角轉入到 JVM 內部,詳細的介紹了 SoftReference,WeakReference,PhantomReference,FinalReference 在 JVM 中的實現,透過分析 JVM 的原始碼,我們清楚了 SoftReference 的準確回收時機,FinalReference 如何拖慢整個 GC 過程,以及 PhantomReference 與 WeakReference 的根本區別在哪裡。

在看完本文的全部內容之後,筆者在第二小節中準備的那六個問題,大家現在可以回答了嗎 ?

相關文章