netty中的內(nèi)存泄漏檢測機(jī)制ResourceLeakDetector

前言

接上文,好久沒寫文,一寫就停不了。在上文講解HashedWheelTimer的過程中,我看到了一個東西ResourceLeakDetector,這個東西由于當(dāng)時沒有影響主流程,所以我就略過了。不過我后來有時間看了下,發(fā)現(xiàn)有點意思,它也算是netty中的核心組件了。所以這篇文章我就在它的基礎(chǔ)上給大家講講netty中的內(nèi)存泄漏檢測機(jī)制。

背景知識

以下內(nèi)容有大部分翻譯自netty官方文檔,有需求的同學(xué)可以移步。我們都知道
netty中大量使用了池化技術(shù)來減緩IO buffer的創(chuàng)建銷毀開銷。對于這些內(nèi)存池管理的對象,從netty 4之后使用了引用計數(shù)來對它們進(jìn)行管理。以ByteBuf為例:

  • 初始化一個對象的時候它的引用計數(shù)為1
ByteBuf buf = ctx.alloc().directBuffer();
assert buf.refCnt() == 1;
  • 當(dāng)你釋放它的時候,它的引用計數(shù)會減1,如果引用計數(shù)到0了,這個對象就會釋放并且它們的資源就能回到內(nèi)存池中
assert buf.refCnt() == 1;
// release() returns true only if the reference count becomes 0.
boolean destroyed = buf.release();
assert destroyed;
assert buf.refCnt() == 0;

這個機(jī)制固然簡單,但是有一個弊端,一時間存在了兩個回收系統(tǒng),JVM GC并不知曉netty的存在。那么,如果一個ByteBuf沒有執(zhí)行完自己該做的release,它就已經(jīng)不可達(dá)了,JVM就有可能對他們執(zhí)行GC。我們知道一旦一個對象被GC了,那就不可能再去調(diào)用它的release方法了。那這個ByteBuf就所占用的內(nèi)存池資源就沒法還回去,內(nèi)存池上可用資源就會越來越少,換言之。這時候我們就產(chǎn)生了內(nèi)存泄漏。
為了應(yīng)對這個問題,netty就提供了一個內(nèi)存泄漏檢查機(jī)制,使用ResourceLeakDetector,也就是我們今天的主角。

關(guān)鍵技術(shù)

經(jīng)過上面的描述,我們可以明確,netty的內(nèi)存泄漏檢測機(jī)制需要完成兩個任務(wù):

  • 在泄漏發(fā)生的時候,我們需要有個通知機(jī)制來知曉被GC的對象沒有調(diào)用自己該有的release方法來釋放池化資源
  • 需要有個記錄機(jī)制來記錄這些泄漏的對象使用的地方,方便溯源

對于這兩個問題,netty巧妙地應(yīng)用了兩個java的特性,在介紹這個之前,我們還是先來看看ResourceLeakDetector整個類圖結(jié)構(gòu)。
類圖結(jié)構(gòu)

從這個類圖中,我們看到整個ResourceLeakDetector框架組成,里面包含如下成分:

  • ResourceLeakDetector 是整個框架的api入口,針對每一個類型的資源會有一個實例對這個類型下的所有池化資源進(jìn)行監(jiān)控
  • ResourceLeakTracker 資源監(jiān)控的跟蹤接口,每個ResourceLeakTracker的實例都負(fù)責(zé)跟蹤一個具體的資源,比方說一個ByteBuf。它定義了一些跟蹤過程中的公共方法
  • DefaultResourceLeak ResourceLeakTracker的實現(xiàn)類,他實現(xiàn)了具體的資源跟蹤邏輯,值得注意的是,它繼承了WeakReference,這個我后面會講。
  • Record 代表的是一次使用記錄,記載了所跟蹤資源的使用地點。它有指針來維護(hù)一個單向鏈表,如果有多個記錄的調(diào)用就會用鏈表串起來,同樣值得注意的是,他繼承自Throwable,這個我后面也會講。
    講完了這個,我再來說說netty是如何完成之前我們說的兩個任務(wù)的。

泄漏通知

我們知道java中存在幾種引用,WeakReference是弱引用,當(dāng)一個對象僅僅被WeakReference指向, 而沒有任何其他強(qiáng)引用指向的時候, 這個對象就有可能會被GC回收,不論當(dāng)前的內(nèi)存空間是否足夠。WeakReference有兩種構(gòu)造函數(shù):

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

這兩個方法中,referent就是引用所指向的具體對象,而ReferenceQueue<? super T> 可能就很少見了。這個隊列的作用是:在弱引用對象所引用的真實對象被回收后,會把弱引用對象,也就是WeakReference對象或者其子類的對象,放入隊列ReferenceQueue中。說到這里大家可能就有感覺了,這不就是自然形成了一種對象被GC之后的通知回調(diào)機(jī)制么?這其實也就是DefaultResourceLeak繼承自WeakReference的原因。對象是否在GC之前已經(jīng)完成了release操作放在了DefaultResourceLeak里面,而通知就依賴于創(chuàng)建DefaultResourceLeak時傳入的ReferenceQueue。

使用記錄

再來看看怎么記錄使用記錄,我們對于使用記錄的場景無非就是關(guān)注資源在何處創(chuàng)建與使用。這里的何處其實就是指的是記錄當(dāng)前的調(diào)用棧信息。在java中,獲取調(diào)用棧有兩個途徑:

  • new Exception().getStackTrace()
  • Thread.getStackTrace() 這個最常見的使用場景就是Thread.currentThread().getStackTrace()
    對于第一種方案,實際上是依托于Exception的父類Throwable,在創(chuàng)建的時候,就把當(dāng)前線程的調(diào)用棧存放了,而對于第二種方案,稍微就復(fù)雜了一點。大家可以看看這篇bug報告,這里面描述了為什么Thread.getStackTrace()會比Throwable.getStackTrace()效率慢很多的原因了(在currentThread的情況下已經(jīng)做了優(yōu)化)

Throwable.fillInstackTrace() knows it always works with the current thread's stack and so it can walk the stack with no effort. On the other hand the Thread getStackTrace() method makes no assumptions about whether or not the target thread is the current thread, and it simply requests a thread dump for a single thread ('this') which in this case happens to be the current thread (creating a Thread[] and a StackTraceElement[][] in the process). Obtaining the stack trace of an arbitrary thread requires a safepoint so we take a large performance hit compared to the fillInStackTrace approach.

這里我給大家做下翻譯

Throwable.fillInstackTrace() 明確的知道它肯定工作在當(dāng)前線程棧中,所以它可以沒有任何花銷的瀏覽當(dāng)前線程的調(diào)用棧。另外一方面Thread.getStackTrace()方法不能對目標(biāo)線程是否是當(dāng)前線程做任何假設(shè)和保證,所以它只能直接簡單的請求dump當(dāng)前進(jìn)程中的某些線程(在當(dāng)前線程場景下,僅僅dump 'this' 線程)。這就需要一個安全檢查點來保證這次獲取,這就導(dǎo)致了Thread方案比Throwable方案有個嚴(yán)重的性能缺陷

不過這jdk 1.5之后已經(jīng)做了優(yōu)化了,具體可以看現(xiàn)在的Thread.getStackTrace()實現(xiàn):

  public StackTraceElement[] getStackTrace() {
        if (this != Thread.currentThread()) {
            // check for getStackTrace permission
            SecurityManager security = System.getSecurityManager();
            if (security != null) {
                security.checkPermission(
                    SecurityConstants.GET_STACK_TRACE_PERMISSION);
            }
            // optimization so we do not call into the vm for threads that
            // have not yet started or have terminated
            if (!isAlive()) {
                return EMPTY_STACK_TRACE;
            }
            StackTraceElement[][] stackTraceArray = dumpThreads(new Thread[] {this});
            StackTraceElement[] stackTrace = stackTraceArray[0];
            // a thread that was alive during the previous isAlive call may have
            // since terminated, therefore not having a stacktrace.
            if (stackTrace == null) {
                stackTrace = EMPTY_STACK_TRACE;
            }
            return stackTrace;
        } else {
            // Don't need JVM help for current thread
            return (new Exception()).getStackTrace();
        }
    }

可以看到一進(jìn)來就會對目標(biāo)線程做了判斷,如果是當(dāng)前線程就直接短路到Throwable.getStackTrace(),不然就直接到dump Thread的方案。
好了,題外話說完,我們就能確定了,如果需要記錄一個某個點的調(diào)用棧,我們就可以創(chuàng)建一個Throwable的子類,這樣調(diào)用棧就自動保存好了,這也就是Record類需要繼承Throwable的原因。

回到代碼

交代了那么多,我們還是來擼碼吧,我們會略過一些環(huán)境設(shè)置和靜態(tài)變量以及不必要的方法。

ResourceLeakDetector

首先來看ResourceLeakDetector的跟蹤資源邏輯


    /**
     * 創(chuàng)建一個ResourceLeakTracker示例來跟蹤一個池化資源
     * 它需要在這個資源被釋放的時候調(diào)用close方法
     */
    public final ResourceLeakTracker<T> track(T obj) {
        return track0(obj);
    }

    @SuppressWarnings("unchecked")
    /**
     * 具體開創(chuàng)建ResourceLeakTracker邏輯
     */
    private DefaultResourceLeak<T> track0(T obj) {
        Level level = ResourceLeakDetector.level;
        // 如果跟蹤登記是Disable,直接返回null
        if (level == Level.DISABLED) {
            return null;
        }
        // 如果等級小于Paranoid
        if (level.ordinal() < Level.PARANOID.ordinal()) {
            // 如果這次隨機(jī)觸發(fā)了采樣間隔
            // 就報告現(xiàn)有的泄漏
            // 并返回一個DefaultResourceLeak示例來跟蹤當(dāng)前資源
            // 注意為了性能,這里使用了ThreadLocalRandom
            if ((PlatformDependent.threadLocalRandom().nextInt(samplingInterval)) == 0) {
                reportLeak();
                return new DefaultResourceLeak(obj, refQueue, allLeaks);
            }
            // 否則如果沒觸發(fā)采樣間隔
            // 則直接返回null 表示不用跟蹤這次資源
            return null;
        }
        // 走到這里說明每次資源創(chuàng)建都需要跟蹤
        reportLeak();
        return new DefaultResourceLeak(obj, refQueue, allLeaks);
    }

跟之前的文章一樣,大部分描述都放在我寫的注釋上面,從這里面看,關(guān)鍵邏輯就在reportLeak方法和DefaultResourceLeak的創(chuàng)建中,我們先來看看reportLeak方法:

    /**
     * 無腦循環(huán)ReferenceQueue,清空之
     */
    private void clearRefQueue() {
        for (;;) {
            @SuppressWarnings("unchecked")
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            if (ref == null) {
                break;
            }
            ref.dispose();
        }
    }

    /**
     * 這個方法用來判斷ReferenceQueue中是否存在需要報告的泄漏
     */
    private void reportLeak() {
        // 如果沒有啟用error日志
        // 僅僅清空當(dāng)前ReferenceQueue即可
        if (!logger.isErrorEnabled()) {
            clearRefQueue();
            return;
        }

        // 檢查和報告之前所有的泄漏
        for (;;) {
            // 從ReferenceQueue中poll一個對象
            DefaultResourceLeak ref = (DefaultResourceLeak) refQueue.poll();
            // 為空說明已經(jīng)清空了
            if (ref == null) {
                break;
            }
            // 如果這個DefaultResourceLeak對象的dispose方法返回false
            // 說明它所跟蹤監(jiān)控的資源已經(jīng)被正確釋放,不存在泄露
            if (!ref.dispose()) {
                continue;
            }
            // 到這里說明已經(jīng)產(chǎn)生泄露了
            // 獲取這個泄露的相關(guān)記錄的字符串
            String records = ref.toString();
            // 看看這個泄漏有沒有出現(xiàn)過
            if (reportedLeaks.putIfAbsent(records, Boolean.TRUE) == null) {
                if (records.isEmpty()) {
                    // 如果字符串為空說明沒有任何記錄
                    // 就需要報告為untracked的泄漏
                    // 這個方法就直接記錄日志,沒什么可看的
                    reportUntracedLeak(resourceType);
                } else {
                    // 否則就是報告為tracked的泄漏
                    // 這個方法就直接記錄日志就好,沒什么可看的
                    reportTracedLeak(resourceType, records);
                }
            }
        }
    }

這里面我們能確定一件事情,DefaultResourceLeak.dispose()方法很重要。
以上就是ResourceLeakDetector的全部需要講的內(nèi)容。我們再來看看下一個重點DefaultResourceLeak

DefaultResourceLeak

我們先來看看構(gòu)造函數(shù)

        /**
         * DefaultResourceLeak構(gòu)造方法
         * @param referent
         * @param refQueue
         * @param allLeaks
         */
        DefaultResourceLeak(
                Object referent,
                ReferenceQueue<Object> refQueue,
                Set<DefaultResourceLeak<?>> allLeaks) {
            // 調(diào)用WeakReference的調(diào)用方法
            // 注意傳入了ReferenceQueue, 完成GC的通知
            super(referent, refQueue);

            assert referent != null;

            // 這里生成了我們引用指向的資源的hashCode
            // 注意這里我們存儲了hashCode而非資源對象本身
            // 因為如果存儲資源對象本身的話我們就形成了強(qiáng)引用,導(dǎo)致資源不可能被GC
            trackedHash = System.identityHashCode(referent);
            // 將當(dāng)前的DefaultResourceLeak示例加入到allLeaks集合里面
            // 這個集合是由它跟蹤的資源所屬的ResourceLeakDetector管理
            // 這個集合在后面判斷資源是否正確釋放扮演重要角色
            allLeaks.add(this);
            // 初始化設(shè)置當(dāng)前DefaultResourceLeak所關(guān)聯(lián)的record
            headUpdater.set(this, new Record(Record.BOTTOM));
            this.allLeaks = allLeaks;
        }

再來看看記錄調(diào)用點的方法:

        /**
         * 單純記錄一個調(diào)用點,沒有任何額外提示信息
         */
        @Override
        public void record() {
            record0(null);
        }

        /**
         * 記錄一個調(diào)用點,并附上額外信息
         */
        @Override
        public void record(Object hint) {
            record0(hint);
        }

         /**
         * 這個函數(shù)非常有意思
         * 有一個預(yù)設(shè)的TARGET_RECORDS字段
         * 這里有個問題,如果這個資源會在很多地方被記錄,
         * 那么這個跟蹤這個資源的DefaultResourceLeak的Record就會有很多
         * 但并不是每個記錄都需要被記錄,否則就會對內(nèi)存和運行都會造成壓力
         * 因為每個Record都會記錄整個調(diào)用棧
         * 因此需要對記錄做取舍
         * 這里有幾個原則
         * 1. 所有record都會用一根單向鏈表來保存
         * 2. 最新的record永遠(yuǎn)都會被記錄
         * 3. 小于TARGET_RECORDS數(shù)目的record也會被記錄
         * 4. 當(dāng)數(shù)目大于等于TARGET_RECORDS的時候,就會根據(jù)概率選擇是用最新的record替換掉
         *    當(dāng)前鏈表中頭上的record(保證鏈表長度不會增加),還是僅僅添加到頭上的record之前
         *    (也就是增加鏈表長度),當(dāng)鏈表長度越大時,替換的概率也越大
         * @param hint
         */
        private void record0(Object hint) {
            // 如果TARGET_RECORDS小于等于0 表示不記錄
            if (TARGET_RECORDS > 0) {
                Record oldHead;
                Record prevHead;
                Record newHead;
                boolean dropped;
                do {
                    // 如果鏈表頭為null,說明已經(jīng)close了
                    if ((prevHead = oldHead = headUpdater.get(this)) == null) {
                        // already closed.
                        return;
                    }
                    // 獲取當(dāng)前鏈表長度
                    final int numElements = oldHead.pos + 1;
                    // 如果當(dāng)前鏈表長度大于等于TARGET_RECORDS
                    if (numElements >= TARGET_RECORDS) {
                        // 獲取是否替換的概率,先獲取一個因子n
                        // 這個n最多為30,最小為鏈表長度 - TARGET_RECORDS
                        final int backOffFactor = Math.min(numElements - TARGET_RECORDS, 30);
                        // 這里有 1 / 2^n的概率來添加這個record而不丟棄原有的鏈表頭record,也就是不替換
                        if (dropped = PlatformDependent.threadLocalRandom().nextInt(1 << backOffFactor) != 0) {
                            prevHead = oldHead.next;
                        }
                    } else {
                        dropped = false;
                    }
                    newHead = hint != null ? new Record(prevHead, hint) : new Record(prevHead);
                    // cas 更新record鏈表
                } while (!headUpdater.compareAndSet(this, oldHead, newHead));
                // 增加丟棄的record數(shù)量
                if (dropped) {
                    droppedRecordsUpdater.incrementAndGet(this);
                }
            }
        }

以上就是記錄的相關(guān)代碼,這里主要的點就是如果調(diào)用次數(shù)過多就會通過隨機(jī)概率來舍棄某些記錄,保證記錄鏈條不會太長。
接下來我們來看看close方法,這個方法會在ByteBuf.release()將引用計數(shù)減為0的時候被調(diào)用,保證DefaultResourceLeak監(jiān)控對象正常關(guān)閉。

        @Override
        public boolean close() {
            // 從allLeaks 集合中去除自己
            // allLeaks中是否包含自己就作為是否正確release和GC的標(biāo)準(zhǔn)
            if (allLeaks.remove(this)) {
                // 如果成功去除自己,說明是正常流程
                // 清除掉對資源對象的引用
                clear();
                // 設(shè)置鏈表頭record到null
                headUpdater.set(this, null);
                // 返回關(guān)閉成功
                return true;
            }
            // 說明自己已經(jīng)被去除了,可能是重復(fù)close,或者是存在泄露,返回關(guān)閉失敗
            return false;
        }

        @Override
        public boolean close(T trackedObject) {
            // 保證釋放和跟蹤的是同一個對象
            assert trackedHash == System.identityHashCode(trackedObject);

            try {
                // 調(diào)用真正close的邏輯
                return close();
            } finally {
                // 保證在這個方法調(diào)用之前跟蹤的資源對象不會被GC
                // 具體原因可參見這個方法的注釋,這里只需要注意
                // 如果在這個方法之前對象就被GC,就不能保證close是否正常
                // 因為如果GC之后再close,就有可能導(dǎo)致泄漏的誤判
                reachabilityFence0(trackedObject);
            }
        }

        /**
         * 這個方法看上去很莫名,只在跟蹤對象上調(diào)用了一個空synchronized塊
         * 這里其實引申出來一個很奇葩的問題,就是JVM GC有可能會在一個對象的方法正在執(zhí)行的時候
         * 就判定這個對象已經(jīng)不可達(dá),并把它給回收了,具體可以看這個帖子
         * https://stackoverflow.com/questions/26642153/finalize-called-on-strongly-reachable-object-in-java-8
         * 針對這個問題,Java 9 提供了Reference.reachabilityFence這個方法作為解決方案
         * 出于兼容性考慮,這里實現(xiàn)了一個netty版本的reachabilityFence方法,在ref上調(diào)用了空synchronized塊
         * 來保證在這個方法調(diào)用前,JVM是不會對這個對象進(jìn)行GC,(synchronized保證了不會出現(xiàn)指令重排)
         * 當(dāng)然引入synchronized塊就有可能會引入死鎖,這個需要調(diào)用者來避免這個事情
         * 還有一個注意的就是這個方法一定要在finally塊中使用,保證這個方法的調(diào)用會在整個流程的最后,從而保證GC不會執(zhí)行
         * @param ref
         */
        private static void reachabilityFence0(Object ref) {
            if (ref != null) {
                synchronized (ref) {
                    // 空synchronized塊沒問題,參見: https://stackoverflow.com/a/31933260/1151521
                }
            }
        }

具體描述都在注釋當(dāng)中,重點如下

  • 一般來說外部調(diào)用的都是帶參數(shù)的close方法
  • 在close方法的finally塊中調(diào)用了reachabilityFence方法,保證close調(diào)用結(jié)束之前JVM都不會對資源對象進(jìn)行GC,否則就會造成泄漏的誤判或者邏輯錯誤
  • 關(guān)閉是否成功或者是否存在泄露的關(guān)鍵點就是allLeaks集合中是否還存在this

record和close咱們都看了,還有一個重點函數(shù)dispose,不知道大家還記不記得,dispose是ResourceLeakDetector.reportLeak中用來判斷泄露是否發(fā)生的關(guān)鍵,我們來看看代碼:

        /**
         * 判斷是否存在泄漏的關(guān)鍵
         * @return false 代表已經(jīng)正確close
         *         true 代表并未正確close
         */
        boolean dispose() {
            // 清理對資源對象的引用
            clear();
            // 直接使用allLeaks.remove(this) 的結(jié)果來
            // 如果remove成功就說明之前close沒有調(diào)用成功
            // 也就說明了這個監(jiān)控對象并沒有調(diào)用足夠的release來完成資源釋放
            // 如果remove失敗說明之前已經(jīng)完成了close的調(diào)用,一切正常
            return allLeaks.remove(this);
        }

大概的邏輯其實在注釋當(dāng)中已經(jīng)清楚了,整個判斷的核心就是allLeaks集合是否還存在this。到了這里我有個疑問,就是為何要用一個脫胎于ConcurrentHashMap的set來作為判斷的依據(jù)呢?只用一個AtomicBoolean也可以,性能上應(yīng)該還會更好。這里我唯一想到的原因就是需要這個set來保證對DefaultResourceLeak的強(qiáng)引用,保證這個對象會在資源對象GC之后才能釋放。
PS:我后來給netty提了個issue,作者也給了我同樣的答復(fù)
其他的函數(shù)都沒什么看點了,toString()就是委托給了record鏈表里面的每個record對象的toString()。那我們就來看看Record類。

Record

Record類中除了toString可以看看,其他的都是一些單鏈表的操作。


        @Override
        public String toString() {
            StringBuilder buf = new StringBuilder(2048);
            if (hintString != null) {
                buf.append("\tHint: ").append(hintString).append(NEWLINE);
            }

            // 依托于Throwable的getStackTrace方法,獲取創(chuàng)建時候的調(diào)用棧
            StackTraceElement[] array = getStackTrace();
            // 跳過最開始的三個棧元素,因為它們就是record方法的那些棧信息,沒必要顯示了
            out: for (int i = 3; i < array.length; i++) {
                StackTraceElement element = array[i];
                // 去除一些不必要的方法信息
                String[] exclusions = excludedMethods.get();
                for (int k = 0; k < exclusions.length; k += 2) {
                    if (exclusions[k].equals(element.getClassName())
                            && exclusions[k + 1].equals(element.getMethodName())) {
                        continue out;
                    }
                }
                // 格式化,就不用說了
                buf.append('\t');
                buf.append(element.toString());
                buf.append(NEWLINE);
            }
            return buf.toString();
        }

通過注釋大家基本就能理解了。

結(jié)語

這篇文章對netty中的內(nèi)存泄漏檢測機(jī)制做了一個深入淺出的講解。如果大家有什么疑問歡迎留言討論!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容