Stories

Detail Return Return

Netty 如何自動探測內存泄露的發生 - Stories Detail

本文基於 Netty 4.1.112.Final 版本進行討論

本文是 Netty 內存管理系列的最後一篇文章,在第一篇文章 《聊一聊 Netty 數據搬運工 ByteBuf 體系的設計與實現》 中,筆者以 UnpooledByteBuf 為例,從整個內存管理的外圍對 ByteBuf 的整個設計體系進行了詳細的拆解剖析,隨後在第二篇文章 《談一談 Netty 的內存管理 —— 且看 Netty 如何實現 Java 版的 Jemalloc》 中,筆者又帶大家深入到 Netty 內存池的內部,對整個池化內存的管理進行了詳細拆解。

不知大家有沒有注意到,無論是非池化內存 —— UnpooledByteBuf 的分配還是池化內存 —— PooledByteBuf 的分配,最後都會被 Netty 包裝成一個 LeakAwareBuffer 返回。

public final class UnpooledByteBufAllocator {
    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        final ByteBuf buf;
        if (PlatformDependent.hasUnsafe()) {
            buf = noCleaner ? new InstrumentedUnpooledUnsafeNoCleanerDirectByteBuf(this, initialCapacity, maxCapacity) :
                    new InstrumentedUnpooledUnsafeDirectByteBuf(this, initialCapacity, maxCapacity);
        } else {
            buf = new InstrumentedUnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
        }
        // 是否啓動內存泄露探測,如果啓動則額外用 LeakAwareByteBuf 進行包裝返回
        return disableLeakDetector ? buf : toLeakAwareBuffer(buf);
    }
}
public class PooledByteBufAllocator {
    // 線程本地緩存
    private final PoolThreadLocalCache threadCache;

    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        // 獲取線程本地緩存,線程第一次申請內存的時候會在這裏與 PoolArena 進行綁定
        PoolThreadCache cache = threadCache.get();
        // 獲取與當前線程綁定的 PoolArena
        PoolArena<ByteBuffer> directArena = cache.directArena;

        final ByteBuf buf;
        if (directArena != null) {
            // 從固定的 PoolArena 中申請內存
            buf = directArena.allocate(cache, initialCapacity, maxCapacity);
        } else {
            // 申請非池化內存
            buf = PlatformDependent.hasUnsafe() ?
                    UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
                    new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
        }
        // 如果內存泄露探測開啓,則用 LeakAwareByteBuf 包裝 PooledByteBuf 返回
        return toLeakAwareBuffer(buf);
    }
}

筆者之前曾提到過,相比於 JDK DirectByteBuffer 需要依賴 GC 機制來釋放其背後引用的 Native Memory , Netty 更傾向於手動及時釋放 DirectByteBuf 。因為 JDK DirectByteBuffer 的釋放需要等到 GC 發生,由於 DirectByteBuffer 的對象實例所佔的 JVM 堆內存太小了,所以一時很難觸發 GC , 這就導致被引用的 Native Memory 的釋放有了一定的延遲,嚴重的情況會越積越多,導致 OOM 。而且也會導致進程中對 DirectByteBuffer 的申請操作有非常大的延遲。

而 Netty 為了避免這些情況的出現,選擇在每次使用完之後手動釋放 Native Memory ,但是不依賴 JVM 的話,總會有內存泄露的情況,比如在使用完了 ByteBuf 卻忘記調用 release() 方法釋放。

手動釋放雖然及時可控,但是卻很容易出現內存泄露。Netty 為了應對內存泄露的發生,從而引入了 LeakAwareBuffer,從命名上就可以看出,LeakAwareBuffer 主要是為了識別出被其包裝的 ByteBuf 是否有內存泄露情況的發生。

現在大家是不是對這個 LeakAwareBuffer 非常的好奇,它究竟擁有怎樣的魔力,居然能夠自動探測內存泄露,但現在我們先把 LeakAwareBuffer 丟在一邊,先不用管它,因為它只是 ByteBuf 一個簡單的套殼,背後真正核心的是與內存泄露相關的一些探測模型設計,所以筆者決定先從最核心的設計原理開始談起~~~

image

1. 內存泄露探測的設計原理

首先我們來看第一個核心的問題,我們究竟該選擇一個什麼樣的時機來對內存泄露進行探測 ?

正在使用的內存肯定不能算是泄露,別管我已經消耗了多麼大的內存,但這些內存確實是正在使用的,你不能説我是內存泄露對吧。當我不需要這些內存了,但仍然繼續持有着不釋放,這種情況,我們才能定義為內存泄露。

所以當內存不再被使用的時候,才是我們進行內存泄露探測的時機,而正在使用的內存,壓根就沒有內存泄露,自然也不需要進行探測,那麼接下來的問題就是,我們如何判斷某一塊內存是正在被使用的 ? 還是已經不在被使用了 ?

那肯定得靠 GC 啊!對吧。當一個 DirectByteBuf 已經沒有任何強引用或者軟引用的時候,那就説明它已經不在被使用了,GC 就會回收它。當它還存在強引用或者軟引用的時候,説明它還在被使用,那麼 GC 就不會回收它。

但是內存泄露探測的功能是在 JVM 之外實現的,JVM 不會意識到我們到底想要幹嘛,它只管無腦回收 DirectByteBuf,對於 DirectByteBuf 背後引用的 Native Memory 是否發生泄露,JVM 壓根就不會 Care 。

看上去靠 GC 是靠不住了,但如果我們能夠在 DirectByteBuf 被 GC 的時候得到一個 JVM 的通知,然後在這個通知中,觸發內存泄露的探測,是不是就可以了 ?那我們如何得到這個通知呢 ?

還記不記得筆者在 《以 ZGC 為例,談一談 JVM 是如何實現 Reference 語義的》 一文中介紹的 WeakReference 和 PhantomReference 以及 FinalReference ? 它們都可以拿到這個通知。

比如 JDK 中的 DirectByteBuffer ,其背後引用的 Native Memory 的回收需要依靠 Cleaner 機制,而 Cleaner 就是一個 PhantomReference 對象。

public class Cleaner extends PhantomReference<Object>

image

Cleaner 虛引用了 DirectByteBuffer,這樣一來當這個 DirectByteBuffer 沒有任何強引用或者軟引用的時候,也就是不會再被使用了,後面就會被 GC 回收掉,與此同時 JVM 會將它的虛引用 Cleaner 放入 JVM 內部一個叫做 _reference_pending_list 的鏈表中。

隨後 JVM 會喚醒 JDK 中的 1 號線程 —— ReferenceHandler。

        Thread handler = new ReferenceHandler(tg, "Reference Handler");
        // 設置 ReferenceHandler 線程的優先級為最高優先級
        handler.setPriority(Thread.MAX_PRIORITY);
        handler.setDaemon(true);

ReferenceHandler 線程會從 JVM 的 _reference_pending_list 中挨個將所有的 Cleaner 摘下,調用它的 clean() 方法,最終在 Deallocator 中釋放 Native Memory 。

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

image

再比如 Netty 內存池中的線程本地緩存 PoolThreadCache,其背後緩存的池化 Native Memory 的回收依賴的是 Finalizer 機制。

    private static final class FreeOnFinalize {
        // 待釋放的 PoolThreadCache
        private volatile PoolThreadCache cache;

        private FreeOnFinalize(PoolThreadCache cache) {
            this.cache = cache;
        }

        @Override
        protected void finalize() throws Throwable {
            try {
                super.finalize();
            } finally {
                PoolThreadCache cache = this.cache;
                this.cache = null;
                // 當 FreeOnFinalize 實例要被回收的時候,觸發 PoolThreadCache 的釋放
                if (cache != null) {
                    cache.free(true);
                }
            }
        }
    }

FreeOnFinalize 的作用主要就是為了回收 PoolThreadCache , 內部重寫了 finalize() 方法,JVM 會為其創建一個 Finalizer 對象(FinalReference 類型),Finalizer 引用了 FreeOnFinalize ,但這種引用關係是一種 FinalReference 類型。

final class Finalizer extends FinalReference<Object> { 

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

    private Finalizer(Object finalizee) {
        // 這裏的 finalizee 就是 FreeOnFinalize 對象,被 FinalReference 引用
        super(finalizee, queue);
              ......
    }
}

image

Finalizer 中有一個全局的 ReferenceQueue,這個 ReferenceQueue 非常的重要,因為 JVM 中的 _reference_pending_list 是屬於 JVM 內部的,除了 ReferenceHandler 線程,其它普通的 Java 線程是訪問不了的,所以我們要想在 JVM 的外部處理這些 Reference(其引用的對象已經被回收),就需要用到一個外部隊列,這個外部隊列就是 Finalizer 中的 ReferenceQueue。

   Reference(T referent, ReferenceQueue<? super T> queue) {
        // FreeOnFinalize 對象
        this.referent = referent;
        //  Finalizer 中的 ReferenceQueue 實例(全局)
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }

當線程終結的時候,那麼 PoolThreadCache 與 FreeOnFinalize 對象將會被 GC 回收,但由於 FreeOnFinalize 被一個 FinalReference(Finalizer) 引用,所以 JVM 會將 FreeOnFinalize 對象再次復活,由於 FreeOnFinalize 對象也引用了 PoolThreadCache,所以 PoolThreadCache 也會被複活。

隨後 JVM 會將這個 Finalizer(FinalReference 對象)放入到內部 _reference_pending_list 中,然後 ReferenceHandler 線程會從 _reference_pending_list 中將 Finalizer 對象挨個摘下,並將其放入到 ReferenceQueue 中。

最後 JDK 中的 2 號線程 —— FinalizerThread 被喚醒,從 ReferenceQueue 中將收集到的 Finalizer 對象挨個摘下,並執行它的 runFinalizer 方法,最終在 FreeOnFinalize 對象的 finalize() 方法中將 PoolThreadCache 釋放。

        Thread finalizer = new FinalizerThread(tg);
        finalizer.setPriority(Thread.MAX_PRIORITY - 2);
        finalizer.setDaemon(true);
        finalizer.start();

image

以上就是針對 Native Memory 回收的一些例子實現,同樣的道理,關於 Native Memory 的泄露探測也是一樣,它們的共同觸發時機都是需要等到 DirectByteBuf 不在被使用的時候,也就是被 GC 的時候。

Netty 這裏使用了 WeakReference 來獲取 DirectByteBuf 被 GC 的通知。

final class DefaultResourceLeak<T> extends WeakReference<Object>

image

前面筆者提過,_reference_pending_list 是一個 JVM 內部的隊列,如果我們想要在 JVM 外部處理 DefaultResourceLeak ,就必須在創建 DefaultResourceLeak 的時候傳入一個全局的 ReferenceQueue,Netty 用於內存泄露探測的 ReferenceQueue 定義在 ResourceLeakDetector 中。

public class ResourceLeakDetector<T> {
    private final ReferenceQueue<Object> refQueue = new ReferenceQueue<Object>();
}

有了這個 ReferenceQueue 之後,當 DirectByteBuf 在系統中沒有任何強引用或者軟引用的時候,那麼就只剩下一個弱引用 DefaultResourceLeak 在引用它了,這時 DirectByteBuf 就會被 GC 回收,後面的 WeakReference 處理流程和前面的 PhantomReference , FinalReference 都是一樣的。

JVM 會將 DefaultResourceLeak 放入到內部的 _reference_pending_list 中,隨後 ReferenceHandler 線程會從 _reference_pending_list 中將 DefaultResourceLeak 摘下,並將它放入到與其關聯的 ReferenceQueue 中,這裏的 ReferenceQueue 就是 ResourceLeakDetector 中定義的全局 refQueue,會在創建 DefaultResourceLeak 對象的時候傳入。

當這個 DefaultResourceLeak 對象被 ReferenceHandler 線程放入到 ReferenceQueue 之後,後面的處理流程就和前面的不一樣了。

Cleaner 是由 ReferenceHandler 線程直接進行處理,Finalizer 是由 FinalizerThread 線程進行處理,那這裏的 DefaultResourceLeak 又該由哪個線程來處理呢 ?這是我們面臨的第二個核心問題。

Cleaner 與 Finalizer 都是 JDK 內部實現的一個機制,所以 JDK 都會配有專門的守護線程來處理它們,而 DefaultResourceLeak 是 Netty 在 JDK 外部實現的內存泄露探測機制,Netty 不可能專門起一個守護線程來處理內存泄露的探測,也沒這個必要。

事實上,Netty 中的任何一個線程都可以處理 DefaultResourceLeak,因為內存分配是一個非常頻繁的操作,在分配內存的時候順帶探測一下是否有內存泄露的情況發生就可以了,沒有必要專門配備一個線程來探測內存泄露。這樣資源消耗不僅少,內存泄露探測的還更快更及時一些。

當某一個線程在調用 ByteBufAllocator 申請內存的時候,Netty 就會觸發對 ReferenceQueue 的檢測,如果隊列中包含 DefaultResourceLeak 就將它拿下來檢查一下是否有內存泄露發生。那麼我們依據什麼來判斷一個 DirectByteBuf 是否發生內存泄露呢 ?這是我們面臨的第三個核心問題。

Netty 為每個 ByteBuf 都維護了一個引用計數 —— refCnt 。

public abstract class AbstractReferenceCountedByteBuf extends AbstractByteBuf {
   // 引用計數
   private volatile int refCnt;
}

我們可以通過 refCnt() 方法來獲取 ByteBuf 當前的引用計數 refCnt。當 ByteBuf 在其他上下文中被引用的時候,我們需要通過 retain() 方法將 ByteBuf 的引用計數加 1。每當我們使用完 ByteBuf 的時候就需要手動調用 release() 方法將 ByteBuf 的引用計數減 1 。當引用計數 refCnt 變成 0 的時候,Netty 就會通過 deallocate 方法來釋放 ByteBuf 所引用的 Native Memory。

public interface ReferenceCounted {
     int refCnt();
     ReferenceCounted retain();
     boolean release();
}

於是我們很容易想到能不能在這個引用計數 refCnt 身上做做文章,當一個 DirectByteBuf 被 GC 的時候,如果它的引用計數為 0 ,表示它引用的 Native Memory 已經及時地被釋放掉了,不存在內存泄露。如果它的引用計數不為 0 ,那就説明它背後引用的 Native Memory 沒有被釋放,內存泄露就發生了。

想法很好,但是非常可惜,我們現在已經拿不到 DirectByteBuf 了,它的引用計數更是無從獲取,因為它已經被 GC 了,而現在我們只能從 ReferenceQueue 中拿到與 DirectByteBuf 弱引用關聯的 DefaultResourceLeak 。那該怎麼辦呢 ?

我們判斷一個 DirectByteBuf 是否存在內存泄露最根本的依據還是要看它的引用計數是否為 0 ,但現在 DirectByteBuf 已經被 GC 了,它的引用計數也獲取不到了,但是我們還可以在另一個維度實現 “引用計數是否為 0” 的這層語義 —— 曲線救國。

如何實現呢 ? 我們還是重新到 Cleaner 和 Finalizer 機制中去找找靈感,在 Cleaner 的內部都會有一個全局的雙向鏈表 —— first 。

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

    private Cleaner next = null, prev = null;
}

image

每當一個 Cleaner 對象被創建出來之後,JDK 就會將新的 Cleaner 對象採用頭插法插入到該雙向鏈表中。

這麼做的目的就是為了讓系統中的這些 Cleaner 對象始終與 GcRoot 關聯,始終保持一條強引用鏈的存在。

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

同理,為了確保這些 Finalizer 在執行 finalizee 對象的 finalize() 方法之前不會被 GC 回收掉。Finalizer 的內部也有一個雙向鏈表 —— unfinalized,用來強引用 JVM 堆中所有的 Finalizer 對象。

final class Finalizer extends FinalReference<Object> { 
    // 雙向鏈表,保存 JVM 堆中所有的 Finalizer 對象,防止 Finalizer 被 GC 掉
    private static Finalizer unfinalized = null;
    private Finalizer next, prev;
}

image

一模一樣的套路,Netty 為了保證在 DirectByteBuf 被 GC 之前,與其弱引用關聯的 DefaultResourceLeak 始終保持活躍不被 GC , 也需要在某一個地方來全局持有 DefaultResourceLeak 的強引用。

但和 Cleaner 與 Finalizer 不同的是,Netty 並沒有採用雙向鏈表的結構來持有 DefaultResourceLeak 的強引用,而是選擇了 Set 結構。

public class ResourceLeakDetector<T> {
    private final Set<DefaultResourceLeak<?>> allLeaks =
            Collections.newSetFromMap(new ConcurrentHashMap<DefaultResourceLeak<?>, Boolean>());
}

image

之所以這裏採用 Set 結構就是為了實現 “引用計數是否為 0” 的這層語義,那麼如何實現呢 ?

Netty 在分配一個 DirectByteBuf 的同時也會創建一個 DefaultResourceLeak 對象來弱引用這個 DirectByteBuf,隨後會將這個 DefaultResourceLeak 對象放入到 allLeaks 集合中。

當我們使用完 DirectByteBuf 並調用 release() 方法釋放其 Native Memory 的時候,如果它的引用計數為 0 ,那麼 Netty 就會將它的 DefaultResourceLeak 對象從 allLeaks 集合中刪除。

如果我們使用完 DirectByteBuf 忘記調用 release() 方法,那麼它的引用計數就會一直大於 0 ,同時也意味着它對應的 DefaultResourceLeak 對象會一直停留在 allLeaks 集合中。

從另一個層面上來説,只要是停留在 allLeaks 集合中的 DefaultResourceLeak 對象,那麼被其弱引用的 DirectByteBuf 的引用計數一定是大於 0 的。

當這個 DirectByteBuf 給 GC 回收之後,JVM 會將其對應的 DefaultResourceLeak 插入到 _reference_pending_list 中,隨後 ReferenceHandler 線程會再一次將 DefaultResourceLeak 對象從 _reference_pending_list 中轉移到 ReferenceQueue 中。

當某一個普通的 Java 線程在向 Netty 申請 DirectByteBuf 的時候,這個申請內存的線程就會順帶到 ReferenceQueue 中查看一下是否有 DefaultResourceLeak 對象,如果有,那麼就證明被其弱引用的 DirectByteBuf 已經被 GC 了。

緊接着,就會查看這個 DefaultResourceLeak 對象是否仍然停留在 allLeaks 集合中 ,如果還在,那麼就説明 DirectByteBuf 背後的 Native Memory 仍然沒有被釋放,這樣一來 Netty 就探測到了內存泄露的發生。

好了,現在我們已經清楚了 Netty 內存泄露探測的核心設計原理,那麼下面的內容就很簡單了,我們把視角在切換一下,從內存泄露探測的內部在轉換到外部,站在應用的角度再來從整體上完整地看一下整個內存泄露探測機制。

2. Netty 的內存泄露探測機制

從總體上來講,觸發內存泄露的探測需要同時滿足以下五個條件:

  1. 應用必須開啓內存泄露探測功能。
  2. 必須要等到 ByteBuf 被 GC 之後,內存泄露才能探測的到,如果 GC 一直沒有觸發,那麼即使是 ByteBuf 沒有任何強引用或者軟引用了,內存泄露的探測也將無從談起。
  3. 當 GC 發生之後,必須是要等到下一次分配內存的時候,才會觸發內存泄露的探測。如果沒有內存申請的行為發生,那麼內存泄露的探測也不會發生。
  4. Netty 並不會探測每一個 ByteBuf 的泄露情況,而是根據一定的採樣間隔,進行採樣探測。所以要想觸發內存泄露的探測,還需要達到一定的採樣間隔。
  5. 應用的日誌級別必須開啓 Error 級別,因為內存泄露的報告,Netty 是以 Error 級別的日誌打印出來的,如果日誌級別在 Error 以下,那麼內存泄露的報告則無法輸出。

除此之外,Netty 還為內存泄露的探測設置了四種級別:

    public enum Level {
        DISABLED,
        SIMPLE,
        ADVANCED,
        PARANOID;
    }

我們可以通過 JVM 參數 -Dio.netty.leakDetection.level 為應用設置不同的探測級別,其中 DISABLED 表示禁用內存泄露探測,因為內存泄露探測開啓之後,應用對於 ByteBuf 的訪問鏈路會變長,而且 Netty 需要記錄 ByteBuf 的創建位置堆棧,以及訪問鏈路堆棧,這樣在內存泄露報告中,我們才可以清楚的知道泄露的 ByteBuf 是在哪裏創建的,又是在哪裏泄露的,它的訪問路徑有哪些。

image

而報告中的每一個堆棧在內存中佔用 2K 大小,所以內存消耗還是非常可觀的,所以筆者一般建議在生產環境中,要將 Netty 的內存泄露探測關閉掉。而在測試環境中,則仍然開啓內存泄露探測。

當內存泄露探測開啓之後,Netty 為我們提供了三種不同的探測級別,級別越高,消耗越大,信息也越詳細。第一種探測級別是 SIMPLE , 這也是 Netty 默認的探測級別。

SIMPLE 級別下,Netty 並不會探測每一個 ByteBuf 的泄露情況,而是選擇進行採樣探測,默認的採樣間隔是 128 。

public class ResourceLeakDetector<T> {
  // 採樣間隔,默認 128
  static final int SAMPLING_INTERVAL;

  private static final String PROP_SAMPLING_INTERVAL = "io.netty.leakDetection.samplingInterval";

  private static final int DEFAULT_SAMPLING_INTERVAL = 128;

  SAMPLING_INTERVAL = SystemPropertyUtil.getInt(PROP_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL);
}

我們可以通過 JVM 參數 -Dio.netty.leakDetection.samplingInterval 來設置內存泄露探測的採樣間隔。那麼 Netty 如何根據這個採樣間隔來決定到底為哪一個具體的 ByteBuf 探測內存泄露呢 ?

事實上,這個探測頻率的實現也很簡單,在每一次內存申請之後,Netty 都會生成 [ 0 , samplingInterval ) 之間的一個隨機數,如果這個隨機數是 0 ,Netty 將會為本次申請到的 ByteBuf 進行內存泄露探測,如果這個隨機數不為 0 ,Netty 將放棄探測。

PlatformDependent.threadLocalRandom().nextInt(samplingInterval) == 0

從效果上來看,就是每申請 samplingInterval 個 ByteBuf , Netty 就會觸發一次內存泄露的探測。

除了受到這個採用頻率的限制之外,SIMPLE 級別下的內存泄露報告信息是最少的,只會包含 ByteBuf 的創建位置,後面針對 ByteBuf 的訪問堆棧信息 Netty 就不會跟蹤了,也就是日誌中的 Recent access records: 信息,在 SIMPLE 級別下是沒有的。

image

ADVANCED 級別和 SIMPLE 級別一樣,在這兩種探測級別下,Netty 都會選擇進行採樣探測,而不是為每一個 ByteBuf 進行探測,同樣都會受到採樣頻率的限制。

那麼 ADVANCED 究竟比 SIMPLE 高級在哪裏呢 ?SIMPLE 級別只會報告泄露的 ByteBuf 是在哪裏創建的, ADVANCED 級別則除了泄露 ByteBuf 的創建位置之外,還會跟蹤 ByteBuf 的每一次訪問堆棧,也就是下面內存泄露報告日誌中的 Recent access records 相關信息。

image

前面筆者也提過,追蹤 ByteBuf 的訪問堆棧是需要消耗非常可觀的內存的,對於 ByteBuf 的每一次訪問堆棧,如果要記錄的話,每個堆棧佔用 2K 的內存,堆棧信息 Netty 會記錄在一個 TraceRecord 結構中。

如果一個 ByteBuf 被訪問了多次,那麼就會對應多個 TraceRecord 結構,ByteBuf 的這些 TraceRecord , 被 Netty 組織在對應 DefaultResourceLeak 裏的一個棧結構中,位於棧底的 TraceRecord 記錄的是 ByteBuf 的創建堆棧,位於棧頂的 TraceRecord 記錄的是 ByteBuf 最近一次被訪問的堆棧。

private static final class DefaultResourceLeak<T> {
    // 棧頂指針
    private volatile TraceRecord head; // 棧結構,存放對應 ByteBuf 的訪問堆棧
}

private static class TraceRecord extends Throwable {
  // 棧底
  private static final TraceRecord BOTTOM = new TraceRecord()
}

由於每個 TraceRecord 中記錄的訪問堆棧信息佔用 2K 的內存,因此無論在什麼探測級別下,Netty 都不可能為 ByteBuf 的每一次訪問都記錄下堆棧信息,所以要對 DefaultResourceLeak 棧中 TraceRecord 的個數進行限制。默認棧中的 TraceRecord 最大個數為 4 , 我們可以通過 -Dio.netty.leakDetection.targetRecords 參數進行調節。

public class ResourceLeakDetector<T> {
    // ByteBuf 訪問堆棧記錄個數限制,默認為 4
    private static final int TARGET_RECORDS;

    private static final String PROP_TARGET_RECORDS = "io.netty.leakDetection.targetRecords";

    private static final int DEFAULT_TARGET_RECORDS = 4;

    TARGET_RECORDS = SystemPropertyUtil.getInt(PROP_TARGET_RECORDS, DEFAULT_TARGET_RECORDS);
}

但更加準確的説,targetRecords 只是對棧中的 TraceRecord 個數進行限制,避免無限的增長,但不會限制死。事實上, 棧中 TraceRecord 個數有一定的概率會超過 targetRecords 的限制。

比如,默認情況下 targetRecords 的值為 4 ,如果我們將棧中 TraceRecord 個數限制成 4 個的話,當一個 ByteBuf 的訪問鏈路很長的話,那麼棧中就只能記錄前三個最遠的 TraceRecord 和一個最近的 TraceRecord。中間的訪問堆棧就丟失了。這樣不利於我們排查 ByteBuf 的完整泄露路徑。

事實上 targetRecords 的真正語義是,當 ByteBuf 的訪問堆棧記錄 TraceRecord 個數達到 targetRecords 的限定時,Netty 會根據一定的概率來丟棄當前棧頂 TraceRecord,並將新的 TraceRecord 作為棧頂。這個丟棄的概率是非常高的,從而避免了 TraceRecord 個數瘋狂地增長。

但如果恰好命中了不丟棄的概率(非常低),那麼原來棧頂的 TraceRecord 將不會被丟棄而是繼續保留在棧中,新的 TraceRecord 作為棧頂加入到棧中,這樣一來棧中 TraceRecord 個數就超過了 targetRecords 的限制。但是可以儘可能多的保留 ByteBuf 中間的訪問堆棧記錄。使得 ByteBuf 的泄露路徑更加完整一些。

PARANOID 是 Netty 內存泄露探測的最高級別,信息最全,消耗也最大,它在 ADVANCED 的基礎之上,繞開了採樣頻率的限制,會對每一個 ByteBuf 進行詳細地泄露探測。一般用於需要在測試環境定位緊急的內存泄露問題才會開啓。

3. 內存泄露探測相關的設計模型

現在我們已經清楚了內存泄露探測的設計原理以及相關應用,那麼在本小節中就該正式介紹實現細節了,Netty 一共設計了 4 種探測模型,不同的模型封裝不同的探測職責。

3.1 ResourceLeakDetector

首先第一個模型是 ResourceLeakDetector 。顧名思義,它主要負責內存泄露的探測,第一小節中介紹的原理實現,就是在這個模型中完成的。

public class ResourceLeakDetector<T> {
    // 探測級別
    private static Level level;
    // 未被釋放的 ByteBuf 對應的弱引用 DefaultResourceLeak 集合
    private final Set<DefaultResourceLeak<?>> allLeaks =
            Collections.newSetFromMap(new ConcurrentHashMap<DefaultResourceLeak<?>, Boolean>());
    // 用於接收 ByteBuf 被回收的通知
    private final ReferenceQueue<Object> refQueue = new ReferenceQueue<Object>();
    // 探測的資源類型,這裏是 ByteBuf
    private final String resourceType;
    // 採樣間隔
    private final int samplingInterval;
    // 內存泄露監聽器,一旦探測到內存泄露,Netty 就會回調 LeakListener
    private volatile LeakListener leakListener;
}

ResourceLeakDetector 中封裝了內存泄露探測所需要的所有信息,其中最重要的就是 allLeaks 和 refQueue 這兩個集合,allLeaks 主要用於保存所有未被釋放的 ByteBuf 對應的弱引用 DefaultResourceLeak,在 ByteBuf 被創建之後,Netty 就會為其創建一個 DefaultResourceLeak 實例來弱引用 ByteBuf,同時這個 DefaultResourceLeak 會被添加到這裏的 allLeaks 中。

如果應用程序及時的釋放了 ByteBuf , 那麼對應的 DefaultResourceLeak 也會從 allLeaks 中刪除,如果 ByteBuf 被 GC 之後,其對應的 DefaultResourceLeak 仍然停留在 allLeaks 中,那麼就説明該 ByteBuf 發生泄露了。

image

refQueue 主要用於收集被 GC 的 ByteBuf 對應的弱引用 DefaultResourceLeak,當一個 ByteBuf 被 GC 之後,那麼其對應的 DefaultResourceLeak 就會被 JVM 放入到一個內部的 _reference_pending_list 中,隨後 ReferenceHandler 線程被喚醒,將 DefaultResourceLeak 從 _reference_pending_list 中轉移到這裏的 refQueue。

image

後續 ResourceLeakDetector 就會從 refQueue 中將 DefaultResourceLeak 摘下,然後檢查這個 DefaultResourceLeak 是否仍然停留在 allLeaks 集合中。如果存在,就説明對應的 ByteBuf 發生了泄露,最後將泄露路徑以 ERROR 級別的日誌打印出來。

除此之外,Netty 還提供了一個內存泄露監聽器,讓我們可以在內存泄露發生之後實現自主的處理邏輯。

    public interface LeakListener {

        /**
         * Will be called once a leak is detected.
         */
        void onLeak(String resourceType, String records);
    }

我們可以通過 ByteBufUtil.setLeakListener 方法來向 ResourceLeakDetector 註冊 LeakListener。

public final class ByteBufUtil {

    public static void setLeakListener(ResourceLeakDetector.LeakListener leakListener) {
        AbstractByteBuf.leakDetector.setLeakListener(leakListener);
    }
}

一旦 ResourceLeakDetector 探測到內存泄露的發生,Netty 就會回調我們註冊的 LeakListener。

Netty 在全局範圍內只會有一個 ResourceLeakDetector 實例,被 AbstractByteBuf 的靜態字段 leakDetector 所引用。

public abstract class AbstractByteBuf extends ByteBuf {
    // 全局 ResourceLeakDetector 實例
    static final ResourceLeakDetector<ByteBuf> leakDetector =
            ResourceLeakDetectorFactory.instance().newResourceLeakDetector(ByteBuf.class);
}

內存泄露探測器的默認實現是 ResourceLeakDetector,但我們也可以自定義實現內存泄露探測器,只需要繼承 ResourceLeakDetector 類,並覆蓋實現相關的核心探測方法,最後通過 JVM 參數 -Dio.netty.customResourceLeakDetector={className} 指定即可。

ResourceLeakDetector 最核心的方法莫過於 track(T obj)reportLeak() 這兩個方法。

public class ResourceLeakDetector<T> {

    public final ResourceLeakTracker<T> track(T obj) {
        return track0(obj, false);
    }

    // 採樣頻率,默認 128
    private final int samplingInterval;
    // 對 obj 進行資源泄露的探測
    // force 表示是否強制探測
    private DefaultResourceLeak track0(T obj, boolean force) {
        Level level = ResourceLeakDetector.level;
        if (force ||
                level == Level.PARANOID ||
                (level != Level.DISABLED && PlatformDependent.threadLocalRandom().nextInt(samplingInterval) == 0)) {
            // 觸發內存泄露探測,如果發生內存泄露,則在日誌中 report
            reportLeak();
            // 創建 ByteBuf (obj) 對應的弱引用 DefaultResourceLeak
            // ResourceLeakDetector 中的全局 refQueue , allLeaks 會在這裏註冊進去
            return new DefaultResourceLeak(obj, refQueue, allLeaks, getInitialHint(resourceType));
        }
        return null;
    }
}

其中 track 方法用於觸發內存泄露的探測,這裏是對第二小節中的內容實現,如果我們設置的內存泄露探測級別為 PARANOID , 那麼 Netty 就會對系統中所有的 ByteBuf 進行全量探測,內存泄露發生之後的報告日誌也會包含詳細的泄露堆棧路徑。

如果內存泄露探測級別為 SIMPLE 或者 ADVANCED , 那麼 Netty 就會對系統中的 ByteBuf 進行採樣探測,採樣間隔 SAMPLING_INTERVAL = 128 , 我們可以通過 JVM 參數 -Dio.netty.leakDetection.samplingInterval 進行設置。

具體的採樣邏輯是,Netty 會生成 [ 0 , samplingInterval ) 之間的一個隨機數,如果這個隨機數是 0 ,那麼就進行內存泄露探測,如果這個隨機數不為 0 ,則放棄探測。從效果上來看,就是每申請 samplingInterval 個 ByteBuf , Netty 就會觸發一次內存泄露的探測。

PlatformDependent.threadLocalRandom().nextInt(samplingInterval) == 0

當符合內存泄露的探測條件之後,Netty 將會在 reportLeak() 方法中進行內存泄露的探測,如果有內存泄露的發生,那麼就將泄露的 ByteBuf 相關訪問路徑以 ERROR 的日誌級別打印出來。

既然內存泄露的日誌級別是 ERROR , 那麼在進行內存泄露探測之前,我們首先必須檢查一下用户是否開啓了 ERROR 日誌級別。

    protected boolean needReport() {
        return logger.isErrorEnabled();
    }

如果用户選擇的日誌級別比較低,那麼即使發生了內存泄露,相關的 ERROR 日誌也不會打印,這種情況下內存泄露的探測也就沒必要進行了。Netty 會調用 clearRefQueue() 方法,將 refQueue 中收集到的所有 DefaultResourceLeak 實例清空,並且將 DefaultResourceLeak 從 allLeaks 集合中刪除。

    private void clearRefQueue() {
        for (;;) {
            // 清空 refQueue
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            if (ref == null) {
                break;
            }
            // 將 DefaultResourceLeak 從 allLeaks 集合中刪除。
            ref.dispose();
        }
    }

如果用户的日誌級別選擇的是 ERROR , Netty 就會繼續後面的內存泄露探測流程,首先一個 ByteBuf 如果被 GC 回收的話,那麼與其弱引用關聯的 DefaultResourceLeak 就會被 ReferenceHandler 線程轉移到 refQueue 中。

也就是説當前 refQueue 中保留的所有 DefaultResourceLeak 其對應的 ByteBuf 已經被 GC 回收了,而內存泄露探測針對地就是這些被回收的 ByteBuf。

Netty 會從 refQueue 中將這些收集到的 DefaultResourceLeak 挨個摘下。

DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();

然後調用 dispose() 方法檢查 DefaultResourceLeak 實例是否仍然停留在 allLeaks 集合中。

        boolean dispose() {
            // 斷開 DefaultResourceLeak 與 ByteBuf 的弱引用關聯
            clear();
            // 檢查 DefaultResourceLeak 實例是否仍然存在於 allLeaks 集合中。
            return allLeaks.remove(this);
        }

如果仍然停留在 allLeaks 中,那麼就説明該 DefaultResourceLeak 實例對應的 ByteBuf 出現內存泄露了。在探測到內存泄露發生之後,調用 getReportAndClearRecords() 方法獲取 ByteBuf 相關的訪問堆棧路徑,然後通過 reportTracedLeak 方法將 ByteBuf 的泄露路徑以 ERROR 級別的日誌打印出來,最後回調內存泄露監聽器 LeakListener。

    // resourceType 為需要探測的資源類型,這裏是 ByteBuf
    // records 是發生內存泄露的 ByteBuf 相關的訪問堆棧
    protected void reportTracedLeak(String resourceType, String records) {
        logger.error(
                "LEAK: {}.release() was not called before it's garbage-collected. " +
                "See https://netty.io/wiki/reference-counted-objects.html for more information.{}",
                resourceType, records);
    }

reportLeak() 方法的實現邏輯正是筆者在第一小節中介紹的所有內容:

    private void reportLeak() {
        // 日誌級別必須是 Error 級別
        if (!needReport()) {
            clearRefQueue();
            return;
        }

        // Detect and report previous leaks.
        for (;;) {
            // 對應的 ByteBuf 必須已經被 GC 回收,才會觸發內存泄露的探測
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            if (ref == null) {
                break;
            }
            // 檢查 ByteBuf 對應的 DefaultResourceLeak 是否仍然停留在 allLeaks 集合中
            if (!ref.dispose()) {
                // 如果不存在,則説明 ByteBuf 已經被及時的釋放了,不存在內存泄露
                continue;
            }
            // 當探測到 ByteBuf 發生內存泄露之後,這裏會獲取 ByteBuf 相關的訪問堆棧 
            String records = ref.getReportAndClearRecords();
            if (reportedLeaks.add(records)) { // 去重泄露日誌
                // 打印泄露的堆棧路徑
                if (records.isEmpty()) {
                    reportUntracedLeak(resourceType);
                } else {
                    reportTracedLeak(resourceType, records);
                }
                // 回調 LeakListener
                LeakListener listener = leakListener;
                if (listener != null) {
                    listener.onLeak(resourceType, records);
                }
            }
        }
    }

3.2 ResourceLeakTracker

上一小節介紹的 ResourceLeakDetector 只是負責內存泄露的探測,但如果探測到了內存泄露,相關的泄露路徑信息從哪裏來的呢 ?Netty 是如何收集的 ?這就引入了第二個探測模型 —— ResourceLeakTracker。

Netty 對 ResourceLeakTracker 的默認實現是 DefaultResourceLeak,它是一個 WeakReference ,被 Netty 用來弱引用關聯 ByteBuf , 目的是接收 ByteBuf 被 GC 回收的通知,從而可以判斷是否有內存泄露的情況發生。

image

除此之外,ResourceLeakTracker 承擔的另一個重要職責就是負責收集 ByteBuf 的訪問鏈路堆棧,一旦 ByteBuf 發生泄露,ResourceLeakDetector 就會從 ResourceLeakTracker 中獲取相關的泄露堆棧 —— getReportAndClearRecords() 方法,並在日誌中打印出來。

每一條 ByteBuf 相關的訪問鏈路堆棧信息,Netty 用一個 TraceRecord 結構來封裝,而一個 ByteBuf 會有多條訪問鏈路,那麼在它的 ResourceLeakTracker 結構中就對應多個 TraceRecords,這些 TraceRecords 被 Netty 組織在一個棧的結構中。

image

    private static final class DefaultResourceLeak<T>
            extends WeakReference<Object> implements ResourceLeakTracker<T>, ResourceLeak {
        // 棧頂指針
        private volatile TraceRecord head;
        // 棧中被丟棄的 TraceRecord 個數
        private volatile int droppedRecords;
        // 指向 ResourceLeakDetector 中的全局 allLeaks
        private final Set<DefaultResourceLeak<?>> allLeaks;
        // 被追蹤探測的 Bytebuf 的 hash 值
        private final int trackedHash;
    }

當 Netty 新分配一個 ByteBuf 之後,如果符合 ResourceLeakDetector.track 中的探測條件,那麼就會創建一個 DefaultResourceLeak 來弱引用這個 ByteBuf。同時將這個 DefaultResourceLeak 加入到 allLeaks 集合中,這裏正是判斷一個 ByteBuf 是否發生內存泄露的關鍵依據。

無論什麼樣的探測級別,DefaultResourceLeak 都會至少保留一個 TraceRecord , 這個 TraceRecord 用於保存 ByteBuf 的創建位置堆棧,在構建 DefaultResourceLeak 的時候會被加入到棧底。

image

        DefaultResourceLeak(
                Object referent,
                ReferenceQueue<Object> refQueue,
                Set<DefaultResourceLeak<?>> allLeaks,
                Object initialHint) {
            // 弱引用關聯 ByteBuf (referent)
            // 註冊 refQueue
            super(referent, refQueue);
            // 保存 Bytebuf 的 hash 值
            trackedHash = System.identityHashCode(referent);
            // 加入到 allLeaks 中,如果 ByteBuf 被回收之後,DefaultResourceLeak 仍然停留在 allLeaks,則表示發生內存泄露。
            allLeaks.add(this);
            // 創建第一個 TraceRecord,記錄 ByteBuf 的創建位置堆棧,保存在棧底
            headUpdater.set(this, initialHint == null ?
                    new TraceRecord(TraceRecord.BOTTOM) : new TraceRecord(TraceRecord.BOTTOM, initialHint));
            this.allLeaks = allLeaks;
        }

另外我們可以通過 record 相關方法,來向 DefaultResourceLeak 添加 ByteBuf 的當前訪問堆棧。

        @Override
        public void record() {
            record0(null);
        }

        @Override
        public void record(Object hint) {
            record0(hint);
        }

通過 record(Object hint) 添加的堆棧,會在泄露日誌中出現我們自定義的提示信息。

image

而通過 record() 添加的堆棧,在泄露日誌中就沒有這個提示信息。

image

向 DefaultResourceLeak 添加新 TraceRecord 的邏輯也很簡單,就是將 ByteBuf 當前最新的訪問堆棧信息 —— TraceRecord 入棧即可。但也不能無限制的向棧中添加 TraceRecord。

第二小節筆者介紹過,每個 TraceRecord 中記錄的訪問堆棧信息佔用 2K 的內存,Netty 不可能為 ByteBuf 的每一次訪問都記錄下堆棧信息,所以 DefaultResourceLeak 棧中的個數會受到 TARGET_RECORDS 的限制,默認為 4 , 我們可以通過 -Dio.netty.leakDetection.targetRecords 參數進行調節。

當 DefaultResourceLeak 棧中記錄的 TraceRecord 個數達到 TARGET_RECORDS 的限定時,Netty 會根據一定的概率(比較高)來丟棄當前棧頂 TraceRecord,並將新的 TraceRecord 作為棧頂。從而避免了 TraceRecord 個數瘋狂地增長。

但如果恰好命中了不丟棄的概率(非常低),那麼原來棧頂的 TraceRecord 將不會丟棄而是繼續保留在棧中,新的 TraceRecord 作為棧頂加入到棧中,這樣一來棧中 TraceRecord 個數就超過了 TARGET_RECORDS 的限制。但是可以儘可能多的保留 ByteBuf 中間的訪問堆棧記錄。使得 ByteBuf 的泄露路徑更加完整一些。

丟棄概率的計算邏輯也很簡單,Netty 仍然是通過計算一個 [ 0 , 1 << backOffFactor ) 區間的隨機數,如果這個隨機數不為 0 ,那麼就將當前的棧頂元素丟棄,這麼看來,當 DefaultResourceLeak 棧中 TraceRecord 個數達到 TARGET_RECORDS 的限定,如果繼續添加 TraceRecord,那麼棧頂元素被丟棄的概率還是非常高的。

// numElements 為當前棧中的 TraceRecord 個數
final int backOffFactor = Math.min(numElements - TARGET_RECORDS, 30)
dropped = PlatformDependent.threadLocalRandom().nextInt(1 << backOffFactor) != 0

TraceRecord 完整的入棧邏輯如下:

        private void record0(Object hint) {
            if (TARGET_RECORDS > 0) {
                TraceRecord oldHead;
                TraceRecord prevHead;
                TraceRecord newHead;
                boolean dropped;
                do {
                    // 獲取棧頂 TraceRecord,也就是 ByteBuf 最近一次的訪問堆棧
                    if ((prevHead = oldHead = headUpdater.get(this)) == null) {
                        // 棧頂為 null ,表示 ByteBuf 已經被釋放,對應的泄露探測已經關閉。
                        return;
                    }
                    // 獲取當前棧中的 TraceRecord 個數
                    final int numElements = oldHead.pos + 1;
                    // 如果達到 TARGET_RECORDS 的限制,就開始概率性的丟棄當前棧頂
                    // 然後用新的 TraceRecord 作為棧頂
                    if (numElements >= TARGET_RECORDS) {
                        final int backOffFactor = Math.min(numElements - TARGET_RECORDS, 30);
                        // numElements 超出 TARGET_RECORDS 的限制越多,當前棧頂就越容易被 drop
                        if (dropped = PlatformDependent.threadLocalRandom().nextInt(1 << backOffFactor) != 0) {
                            // 命中丟棄的概率,則將當前棧頂 TraceRecord 丟棄
                            prevHead = oldHead.next;
                        }
                    } else {
                        // 保留當前棧頂,這樣棧中的 TraceRecord 個數就會超過 TARGET_RECORDS 的限制
                        // 但 ByteBuf 中間的訪問鏈路堆棧就會被概率性的保留下來
                        dropped = false;
                    }
                    // 創建的新的 TraceRecord(記錄 ByteBuf 的當前訪問堆棧)
                    // 並作為新的棧頂元素
                    newHead = hint != null ? new TraceRecord(prevHead, hint) : new TraceRecord(prevHead);
                } while (!headUpdater.compareAndSet(this, oldHead, newHead));

                if (dropped) {
                     // 統計被丟棄的 TraceRecord 個數
                    droppedRecordsUpdater.incrementAndGet(this);
                }
            }
        }

好了,現在我們已經清楚了,Netty 如何通過 DefaultResourceLeak 來收集 ByteBuf 相關的訪問鏈路堆棧信息,那麼當這個 ByteBuf 發生內存泄露之後,Netty 又是如何生成相關的泄露堆棧呢 ?

這就要依靠 DefaultResourceLeak 中的這個 TraceRecord 棧結構,棧頂 TraceRecord 永遠保存的是 ByteBuf 最近一次的訪問堆棧,棧底 TraceRecord 永遠保存的是 ByteBuf 起始創建位置堆棧,中間的 TraceRecord 記錄的是 ByteBuf 的訪問鏈路堆棧。

image

ByteBuf 的泄露堆棧是從棧頂的 TraceRecord 開始打印,一直到棧底 TraceRecord,也就是由近及遠的輸出 ByteBuf 的泄露路徑。

        String getReportAndClearRecords() {
            // 獲取棧頂 TraceRecord
            TraceRecord oldHead = headUpdater.getAndSet(this, null);
            // 由近及遠的輸出 ByteBuf 相關的 TraceRecords
            return generateReport(oldHead);
        }

首先 Netty 會打印一行 Recent access records: , 然後每一個 TraceRecord 在日誌中都有一個 # 字編號,棧頂的 TraceRecord 編號為 #1 , 後面依次遞增,棧底的 TraceRecord 由於記錄的是創建位置堆棧,Netty 在日誌中會提示 Created at:

image

        private String generateReport(TraceRecord oldHead) {
            // 當前 DefaultResourceLeak 棧中一共有多少個 TraceRecord
            int present = oldHead.pos + 1;
            // 每個 TraceRecord 分配 2K 大小的內存
            StringBuilder buf = new StringBuilder(present * 2048).append(NEWLINE);
            buf.append("Recent access records: ").append(NEWLINE);
            int i = 1;
            // 防重集合
            Set<String> seen = new HashSet<String>(present);
            // 從棧頂開始生成泄露堆棧
            for (; oldHead != TraceRecord.BOTTOM; oldHead = oldHead.next) {
                // 獲取 TraceRecord 記錄的堆棧信息
                String s = oldHead.toString();
                if (seen.add(s)) {
                    if (oldHead.next == TraceRecord.BOTTOM) {
                        // 棧底 TraceRecord 記錄了 Buffer 的創建位置
                        buf.append("Created at:").append(NEWLINE).append(s);
                    } else {
                        buf.append('#').append(i++).append(':').append(NEWLINE).append(s);
                    }
                } else {
                    // 重複的 TraceRecord 個數
                    duped++;
                }
            }
            // 生成泄露堆棧,並返回
            buf.setLength(buf.length() - NEWLINE.length());
            return buf.toString();
        }

3.3 TraceRecord

上述內存泄露日誌中出現的每一條訪問堆棧是如何生成的呢 ? 這就引入了第三個模型 —— TraceRecord , 該模型在內存泄露探測中用於記錄 ByteBuf 某次的訪問堆棧。實現起來也很簡單,只需要繼承 Throwable 即可,這樣在每次創建 TraceRecord 的時候,就會自動生成 ByteBuf 當前的訪問堆棧。

由於 TraceRecord 在 DefaultResourceLeak 中是被組織在一個棧結構中,所以它的 next 指針指向棧中下一個 TraceRecord, pos 用於標識當前 TraceRecord 在棧中的位置,整個結構比較簡單明瞭。

    private static class TraceRecord extends Throwable {
        // 空實現,用來標識棧底位置
        private static final TraceRecord BOTTOM = new TraceRecord() {
            @Override
            public Throwable fillInStackTrace() {
                return this;
            }
        };
        // 出現在日誌中的自定義 Hint 提示信息 
        private final String hintString;
        // 棧中下一個 TraceRecord
        private final TraceRecord next;
        // 當前 TraceRecord 在棧中的位置
        private final int pos;
    }

image

TraceRecord 的 toString() 方法用於生成其中記錄的堆棧信息,實現也很簡單,就是直接打印 Throwable 中的堆棧即可。

        @Override
        public String toString() {
            // 每個 TraceRecord 堆棧信息佔用 2K 內存
            StringBuilder buf = new StringBuilder(2048);
            if (hintString != null) {
                // 日誌中顯示我們自定義的提示信息 tHint
                buf.append("\tHint: ").append(hintString).append(NEWLINE);
            }

            // 獲取 TraceRecord 記錄的堆棧信息
            StackTraceElement[] array = getStackTrace();
            // Skip the first three elements.
            out: for (int i = 3; i < array.length; i++) {
                StackTraceElement element = array[i];

                ....... 清理一些沒用的堆棧信息 ......

                // 生成有效的堆棧信息
                buf.append('\t');
                buf.append(element.toString());
                buf.append(NEWLINE);
            }
            return buf.toString();
        }

3.4 LeakAwareByteBuf

關於內存泄露探測所有的核心設計,到這裏筆者就為大家介紹完了,當我們清楚了這些背景之後,在回頭來看筆者在文章開始處提出的疑問,是不是多多少少會有一些感覺了 ?

在 Netty 每次分配內存的時候,都會觸發內存泄露的採樣探測,如果命中採樣概率,則會對本次分配的 ByteBuf 進行後續的內存泄露追蹤。

public final class UnpooledByteBufAllocator {
    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
        final ByteBuf buf;

        ....... 分配 UnpooledByteBuf .....

        // 是否啓動內存泄露探測,如果啓動則額外用 LeakAwareByteBuf 進行包裝返回
        return disableLeakDetector ? buf : toLeakAwareBuffer(buf);
    }
}

public class PooledByteBufAllocator {
    @Override
    protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {

         ....... 分配 PooledByteBuf .....

        // 如果內存泄露探測開啓,則用 LeakAwareByteBuf 包裝 PooledByteBuf 返回
        return toLeakAwareBuffer(buf);
    }
}

Netty 為了實現對 ByteBuf 內存泄露的追蹤,從而引入了第四個模型 —— LeakAwareBuffer,從命名上就可以看出,LeakAwareBuffer 主要是為了識別出被其包裝的 ByteBuf 是否有內存泄露情況的發生。

每當命中採樣概率之後,Netty 都會將普通的 ByteBuf 包裝成一個 LeakAwareBuffer 返回。

    protected static ByteBuf toLeakAwareBuffer(ByteBuf buf) {
        // DefaultResourceLeak 用於追蹤 ByteBuf 的泄露路徑
        ResourceLeakTracker<ByteBuf> leak;
        switch (ResourceLeakDetector.getLevel()) {
            case SIMPLE:
                // 觸發內存泄露採樣探測,如果命中採樣頻率
                // 則為 ByteBuf 創建 DefaultResourceLeak(弱引用)
                leak = AbstractByteBuf.leakDetector.track(buf); // 本文 3.1 小節內容
                if (leak != null) {
                    // SIMPLE 級別對應的是 SimpleLeakAwareByteBuf
                    buf = new SimpleLeakAwareByteBuf(buf, leak);
                }
                break;
            case ADVANCED:
            case PARANOID:
                // 觸發內存泄露採樣探測
                leak = AbstractByteBuf.leakDetector.track(buf); // 本文 3.1 小節內容
                if (leak != null) {
                    // ADVANCED , PARANOID 級別對應的是  AdvancedLeakAwareByteBuf
                    buf = new AdvancedLeakAwareByteBuf(buf, leak);
                }
                break;
            default:
                break;
        }
        // 如果命中採樣頻率,則用 LeakAwareByteBuf 包裝返回
        // 如果沒有命中採樣頻率,則原樣返回
        return buf;
    }

內存泄露探測級別是 SIMPLE 的情況下,Netty 會用 SimpleLeakAwareByteBuf 對 ByteBuf 進行包裝。內存泄露探測級別是 ADVANCED 或者 PARANOID 的情況下,Netty 會用 AdvancedLeakAwareByteBuf 對 ByteBuf 進行包裝。

image

從類的繼承結構圖中我們可以看出,SimpleLeakAwareByteBuf 和 AdvancedLeakAwareByteBuf 均繼承於 WrappedByteBuf,説明它們只是對原始普通 ByteBuf 的一個簡單裝飾(裝飾者設計模型)。

class SimpleLeakAwareByteBuf extends WrappedByteBuf {
   // 需要被探測的普通  ByteBuf
   private final ByteBuf trackedByteBuf;
   // ByteBuf 的弱引用 DefaultResourceLeak
   final ResourceLeakTracker<ByteBuf> leak;

   SimpleLeakAwareByteBuf(ByteBuf wrapped, ResourceLeakTracker<ByteBuf> leak) {
        this(wrapped, wrapped, leak);
    }

   SimpleLeakAwareByteBuf(ByteBuf wrapped, ByteBuf trackedByteBuf, ResourceLeakTracker<ByteBuf> leak) {
        super(wrapped);
        this.trackedByteBuf = ObjectUtil.checkNotNull(trackedByteBuf, "trackedByteBuf");
        this.leak = ObjectUtil.checkNotNull(leak, "leak");
    }
}

LeakAwareByteBuf 中最核心的一個裝飾屬性就是 leak ,它用來指向與 trackedByteBuf 弱引用關聯的 DefaultResourceLeak。在 DefaultResourceLeak 剛被創建出來的時候,它會加入到全局的 allLeaks 集合中。

image

最開始 DefaultResourceLeak 棧中只包含一個 TraceRecord,位於棧底,用於記錄 trackedByteBuf 的創建位置堆棧。在 SIMPLE 探測級別下,內存泄露日誌中也只會出現 trackedByteBuf 的創建位置堆棧。

image

所以 SimpleLeakAwareByteBuf 相關的 read , write 方法並沒有什麼特別之處,都是對 trackedByteBuf 的簡單代理。

class SimpleLeakAwareByteBuf extends WrappedByteBuf {
    @Override
    public byte readByte() {
        return trackedByteBuf.readByte();
    }

    @Override
    public ByteBuf writeByte(int value) {
        trackedByteBuf.writeByte(value);
        return this;
    }
}

值得聊一下的是 SimpleLeakAwareByteBuf 的 release() 方法,當我們使用完 SimpleLeakAwareByteBuf , 就需要及時的手動釋放。如果 SimpleLeakAwareByteBuf 的引用計數為 0 ,就需要額外關閉內存泄露的探測,因為已經及時釋放了,就不會存在內存泄露的情況。

    @Override
    public boolean release() {
        // 引用計數為 0 
        if (super.release()) {
            // 關閉內存泄露的探測
            closeLeak();
            return true;
        }
        return false;
    }

   private void closeLeak() {
        boolean closed = leak.close(trackedByteBuf);
    }

關閉 trackedByteBuf 的內存泄露檢測核心步驟是:

  1. 首先將 DefaultResourceLeak 從 allLeaks 集合中刪除,因為 allLeaks 中保存的全部都是未被釋放的 trackedByteBuf 對應的 DefaultResourceLeak 。
  2. 斷開 DefaultResourceLeak 與 trackedByteBuf 的弱引用關聯,這樣一來,當 trackedByteBuf 被 GC 之後,JVM 將不會把 DefaultResourceLeak 放入到 _reference_pending_list 中,反而會將 DefaultResourceLeak 與 trackedByteBuf 一起回收。這樣一來,refQueue 中自然也不會出現這個 DefaultResourceLeak ,ResourceLeakDetector 也不會錯誤地探測到它了。
    public void clear() {
        this.referent = null;
    }
  1. 將 DefaultResourceLeak 棧中保存的 TraceRecords 清空。
private static final class DefaultResourceLeak<T>
            extends WeakReference<Object> implements ResourceLeakTracker<T>, ResourceLeak {

        @Override
        public boolean close() {
            // 將 DefaultResourceLeak 從 allLeaks 集合中刪除
            if (allLeaks.remove(this)) {
                // 斷開 DefaultResourceLeak 與 trackedByteBuf 的弱引用關聯
                clear();
                // 清空 DefaultResourceLeak 棧
                headUpdater.set(this, null);
                return true;
            }
            return false;
        
}

如果這個 SimpleLeakAwareByteBuf 忘記釋放了,那麼它對應的 DefaultResourceLeak 就會一直停留在 allLeaks 集合中,當 SimpleLeakAwareByteBuf 被 GC 之後,JVM 就會將 DefaultResourceLeak 放入到 _reference_pending_list 中,隨後喚醒 ReferenceHandler 線程將 DefaultResourceLeak 從 _reference_pending_list 中轉移到 refQueue。

image

當下一次內存分配的時候,如果命中內存泄露採樣檢測的概率,那麼 ResourceLeakDetector 就會從 refQueue 中將收集到的所有 DefaultResourceLeak 挨個摘下,並判斷它們是否仍然停留在 allLeaks 中。

如果仍然在 allLeaks 中,就説明該 DefaultResourceLeak 對應的 ByteBuf 發生了內存泄露,而具體的泄露路徑就保存在 DefaultResourceLeak 棧中,最後將泄露路徑以 ERROR 的日誌級別打印出來。

public class ResourceLeakDetector<T> {

    private void reportLeak() {
        // Detect and report previous leaks.
        for (;;) {
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            if (ref == null) {
                break;
            }
            // 檢查 ByteBuf 對應的 DefaultResourceLeak 是否仍然停留在 allLeaks 集合中
            if (!ref.dispose()) {
                // 如果不存在,則説明 ByteBuf 已經被及時的釋放了,不存在內存泄露
                continue;
            }
            // 當探測到 ByteBuf 發生內存泄露之後,這裏會獲取 ByteBuf 相關的訪問堆棧 
            String records = ref.getReportAndClearRecords();
            // 打印泄露的堆棧路徑
            reportTracedLeak(resourceType, records);
        }
    }
}

以上就是內存泄露探測級別 SIMPLE 的實現邏輯,而 ADVANCED , PARANOID 級別的特點在於它們會收集詳細的訪問堆棧,所以 AdvancedLeakAwareByteBuf 是在 SimpleLeakAwareByteBuf 的基礎之上對相關的訪問方法,比如 read , write 等方法進行裝飾,裝飾什麼呢 ?就是每對 AdvancedLeakAwareByteBuf 進行一次訪問,就向 DefaultResourceLeak 棧中添加一次最新的堆棧信息。

final class AdvancedLeakAwareByteBuf extends SimpleLeakAwareByteBuf {

    AdvancedLeakAwareByteBuf(ByteBuf buf, ResourceLeakTracker<ByteBuf> leak) {
        super(buf, leak);
    }

    @Override
    public byte readByte() {
        // 記錄當前訪問的堆棧信息
        recordLeakNonRefCountingOperation(leak);
        return super.readByte();
    }

    @Override
    public ByteBuf writeByte(int value) {
         // 記錄當前訪問的堆棧信息
        recordLeakNonRefCountingOperation(leak);
        return super.writeByte(value);
    }

    static void recordLeakNonRefCountingOperation(ResourceLeakTracker<ByteBuf> leak) {
        if (!ACQUIRE_AND_RELEASE_ONLY) {
            // 向 DefaultResourceLeak 添加新的堆棧
            leak.record();
        }
    }
}

但一個現實的問題是,ByteBuf 中有那麼多的方法,如果對 ByteBuf 每一個方法的訪問都要記錄堆棧的話,那內存消耗就太大了,況且 DefaultResourceLeak 棧中的 TraceRecords 個數,是會受到 -Dio.netty.leakDetection.targetRecords 限制的,不能無限向棧中添加。

因此 Netty 又為我們提供了一個新的 JVM 參數 -Dio.netty.leakDetection.acquireAndReleaseOnly ,默認為 false , 表示默認情況下,對 ByteBuf 的每一個方法的訪問都需要記錄堆棧。

private static final String PROP_ACQUIRE_AND_RELEASE_ONLY = "io.netty.leakDetection.acquireAndReleaseOnly";

ACQUIRE_AND_RELEASE_ONLY = SystemPropertyUtil.getBoolean(PROP_ACQUIRE_AND_RELEASE_ONLY, false);

設置為 true 表示,只對明確要求記錄堆棧的方法進行記錄,比如 touch 相關方法,retain() 方法,還有 release() 方法。其他的方法均不記錄堆棧。

    @Override
    public ByteBuf touch() {
        leak.record();
        return this;
    }

    @Override
    public ByteBuf touch(Object hint) {
        leak.record(hint);
        return this;
    }

    @Override
    public ByteBuf retain() {
        leak.record();
        return super.retain();
    }

    @Override
    public boolean release() {
        leak.record();
        return super.release();
    }

由於在 SIMPLE 探測級別下只會記錄創建堆棧,不會記錄訪問堆棧,所以 SimpleLeakAwareByteBuf 的相關訪問方法均不會調用 leak.record()

class SimpleLeakAwareByteBuf extends WrappedByteBuf {
    @Override
    public ByteBuf touch() {
        return this;
    }

    @Override
    public ByteBuf touch(Object hint) {
        return this;
    }
}

總結

要想觸發 Netty 的內存泄露探測機制需要同時滿足以下五個條件:

  1. 應用必須開啓內存泄露探測功能。
  2. 必須要等到 ByteBuf 被 GC 之後,內存泄露才能探測的到,如果 GC 一直沒有觸發,那麼即使是 ByteBuf 沒有任何強引用或者軟引用了,內存泄露的探測也將無從談起。
  3. 當 GC 發生之後,必須是要等到下一次分配內存的時候,才會觸發內存泄露的探測。如果沒有內存申請的行為發生,那麼內存泄露的探測也不會發生。
  4. Netty 並不會探測每一個 ByteBuf 的泄露情況,而是根據一定的採樣間隔,進行採樣探測。所以要想觸發內存泄露的探測,還需要達到一定的採樣間隔。
  5. 應用的日誌級別必須開啓 Error 級別,因為內存泄露的報告,Netty 是以 Error 級別的日誌輸出出來的,如果日誌級別在 Error 以下,那麼內存泄露的報告則無法輸出。

我們可以通過 JVM 參數 -Dio.netty.leakDetection.level 為應用設置不同的探測級別:

  1. DISABLED 表示禁用內存泄露探測。
  2. SIMPLE 則是進行內存泄露的採樣探測,我們可以通過 JVM 參數 -Dio.netty.leakDetection.samplingInterval 來設置內存泄露探測的採樣頻率。內存泄露報告中只會包含 ByteBuf 的創建位置堆棧信息。
  3. ADVANCED 也是進行採樣探測,但在內存泄露報告中會體現更詳細的信息,比如,ByteBuf 的相關訪問路徑堆棧信息,能夠採集到的泄露堆棧受到 -Dio.netty.leakDetection.targetRecords 參數的限制。
  4. PARANOID 則是在 ADVANCED 的基礎之上,對系統中的所有 ByteBuf 進行全量探測。級別最高,信息最全,消耗也最大。

好了,今天的內容就到這裏,我們下篇文章見 ~

user avatar u_16297326 Avatar zhuchenxi113 Avatar sofastack Avatar u_13529088 Avatar u_16769727 Avatar lenglingx Avatar jiangyi Avatar yizhidanshendetielian Avatar wnhyang Avatar aitibao_shichangyingxiao Avatar lu_lu Avatar java_study Avatar
Favorites 50 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.