釘釘機器人限流應對方案--延遲隊列的實現和內存泄漏思考(下)

沒有被回收的對象

上篇文章介紹了自己寫的延遲隊列工具。我們提到,延遲隊列不需要長久存活,我們使用帶有lru功能的LinkedHashMap來淘汰一些不常用的LimitUtil。但是對象有沒有真的會回收呢?

簡單寫了一個測試類,建了三個對象,Lru容量設爲1

LRU<String, LimitUtil> map = new LRU<>(1, 0.75f);
@Test
    public void test() throws InterruptedException {

        LimitUtilFactory limitUtilFactory = new LimitUtilFactory();

        for (int i = 0; i < 3; i++) {
            int finalI = i;
            LimitUtil instance = limitUtilFactory.getInstance(String.valueOf(finalI), 10, 10, 10, (x) -> {
                log.info("執行邏輯 {}", x);
            });
            instance.put("LimitUtilTest test" + finalI);
        }

        Thread.sleep(1000000000);
    }

我們看下VisualVm的堆快照,第一個對象是被LinkedHashMap所引用
在這裏插入圖片描述
另外兩個對象都沒有被LinkedHashMap引用
在這裏插入圖片描述
但是我們手動點擊GC,堆裏LimitUtil的數量依然是三個,我們的程序已經無法取到LimitUtil的實例,但是堆裏的對象沒有被回收,這裏發生了內存泄漏。
在這裏插入圖片描述

GC Root

我們如果看了垃圾回收的書,我們可以瞭解到一個對象決定回不回收有兩種算法,一種引用計數算法,一種可達性分析算法(GC Root),可達性就是給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器爲0的對象就是不可能再被使用的。這種算法 有一個致命問題,如果兩個對象相互引用,那就沒法判斷它們是否真的需要被回收,所以此算法已被淘汰。

可達性分析算法則換了種思路,就是通過一系列的稱爲“GC Roots”的對象作爲起始點, 從這些節點開始向下搜索, 搜索所走過的路徑稱爲引用鏈( Reference Chain),當一個對象到 GC Roots 沒有任何引用鏈相連時,則證明此對象是不可用的。GC會收集那些不是GC Roots且沒有被GC Roots引用的對象。

那什麼可以作爲“GC Roots”的對象作爲起始點?

  • Class - 由系統類加載器(system class loader)加載的對象,這些類是不能夠被回收的,他們可以以靜態字段的方式保存持有其它對象。我們需要注意的一點就是,通過用戶自定義的類加載器加載的類,除非相應的Java.lang.Class實例以其它的某種(或多種)方式成爲roots,否則它們並不是roots
  • Thread - 活着的線程
  • Stack Local - Java方法的local變量或參數
  • JNI Local - JNI方法的local變量或參數
  • JNI Global - 全局JNI引用
  • Monitor Used - 用於同步的監控對象
  • Held by JVM - 用於JVM特殊目的由GC保留的對象,但實際上這個與JVM的實現是有關的。可能已知的一些類型是:系統類加載器、一些JVM知道的重要的異常類、一些用於處理異常的預分配對象以及一些自定義的類加載器等。然而,JVM並沒有爲這些對象提供其它的信息,因此需要去確定哪些是屬於"JVM持有"的了。

講到這裏,是不是覺得LimitUtil即使沒有被 LinkedHashMap引用,但是,是否會存在GC Root對象而導致自身無法回收? 我們再回顧VisualVm的堆快照引用分析。 我們可以看見VisualVm把GC Root對象都用藍色三角修飾了,再展開看引用分析, 我們的兩個ThreadPool都被虛擬機當作GC Root對象,而不可回收。

在這裏插入圖片描述
在LimitUtil的實現裏,我們都用了ThreadPoolExecutor,而且當沒有元素put進去的時候,線程會掛起等待,貌似符合gc root對象所定義的活的線程,目標有了,我們需要將這個線程池關閉。對於ThreadPoolExecutor的源碼,可以推薦看下面的文章:

【死磕Java併發】—–J.U.C之線程池:ThreadPoolExecutor

shutdownNow方法可以關閉ThreadPoolExecutor內部活躍的線程。我們在LinkedHashMap的removeEldestEntry方法裏面寫一個關閉方法。保證LinkedHashMap丟棄這個對象的時候可以執行對應的清理代碼。

@Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            // 當元素個數大於了緩存的容量, 就移除元素
            if (size() > this.capacity) {
                LimitUtil value = (LimitUtil) eldest.getValue();
                value.close();
            }
            return size() > this.capacity;
        }
    public void close() {
        service.shutdownNow();
        executorService.shutdownNow();
    }

做完之後,我們重新執行程序,在執行gc前,我們看看VisualVm的堆快照。藍色三角形沒有了。這個對象理論上只剩下被回收。
在這裏插入圖片描述
我們手動執行一遍gc,LimitUtil只剩下1個。對象被成功回收!
在這裏插入圖片描述

其它收穫,Finalizer

在VisualVm堆快照中,我發現有些對象會被referent引用。通過查閱資料,瞭解到所有覆寫了finalize方法的對象在創建時 都會被Finalizer包裝並且 在引用不可達時放入ReferenceQueue中,隊列會執行對象的finalize方法,所以以前只知道finalize方法是 對象被gc前執行的方法,現在也知道了前因後果。

Finalizer跟WeakReference(弱引用),SoftReference(軟引用) 同系一脈,適合當緩存。感興趣的同學可以深入瞭解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章