高併發服務優化篇:詳解一次由讀寫鎖引起的內存泄漏

JVM 相關的異常,一直是一線研發比較頭疼的問題。因爲對於業務代碼,JVM 的運行基本算是黑盒,當異常發生時,較難直觀的看到和找到問題所在,這也是我們一直要研究其內部邏輯的原因。

本篇就由一個近期線上 JVM 內存泄漏的例子,帶大家強行分析一波~

Part1 線上服務器報警了

某天,同事來找我幫忙,原來是某系統毫無徵兆的來了一連串報警,一波機器的老年代內存佔用率超過閾值~

1.1 先看錶現

老年代內存佔用

可以看到,在 7 月中旬之前,內存佔用還是比較正常的,每次 GC 都可以回收掉很大一部分的老年代對象。

而中旬之後,老年代內存一直緩慢增長而無法釋放。很明顯,應該是對象沒法被正常回收導致。

內存泄漏了~

1.2 怎麼辦呢

如果是剛上線的項目爆出了此類問題,因爲影響面比較小,可以直接先回滾代碼,止血爲第一要務。

不過,這個項目明顯已經上線 N 多天,中間還不知道上過多少需求,而且,既然流量近期有上漲導致問題出現,說明,已經對客開流量了。

回滾是不可能了,抓緊時間定位問題,上線修復吧。

Part2 定位問題

一般的步驟:

不過,因爲這次 dump 下來的文件十多 G,太大的,MAT 基本無能爲力,只能打印出來人工分析了

2.1 定位問題代碼

jmap 結果查看

很幸運,異常對象非常明顯。Point 對象和 GeoDispLocal 對象,居然多達好幾百萬實例數,那就先看下代碼中這兩個對象是怎麼用的。

private static final CacheMap<String, List<GeoDispLocal>> NEAR_DISTRICT_CACHE = new CacheMap<String, List<GeoDispLocal>>(3600 * 1000, 1000);

private static final CacheMap<Integer, Point> LOCAL_POINT_CACHE = new CacheMap<Integer, Point>(3600 * 1000, 6000);

都是被存放在本次緩存 CacheMap 中 (內存泄漏的一個常見原因,就是因爲被靜態集合持有,無法回收導致),而 dump 文件中的 CacheMap.Entry 也是非常高的。

CacheMap 就是我們的第一優先懷疑對象了。先看下這個緩存類是怎麼回事:

public class CacheMap<K, V> {
    private final long expireMs;
    private LRUMap<K, CacheMap.Entry<V>> valueMap;
    //其他略
}

內部依賴一個帶 LRU 功能的 map,怎麼實現的呢:

public class LRUMap<K, V> extends LinkedHashMap<K, V> {
    private static final long serialVersionUID = 1L;
    private final int maxCapacity;
    // 這個map不會擴容
    private static final float LOAD_FACTOR = 0.99f;
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public LRUMap(int maxCapacity) {
        super(maxCapacity, LOAD_FACTOR, true);
        this.maxCapacity = maxCapacity;
    }

    @Override
    protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
        return size() > maxCapacity;
    }

    @Override
    public V get(Object key) {
        try {
            lock.readLock().lock();
            return super.get(key);
        } finally {
            lock.readLock().unlock();
        }
    }

    @Override
    public V put(K key, V value) {
        try {
            lock.writeLock().lock();
            return super.put(key, value);
        } finally {
            lock.writeLock().unlock();
        }
    }
    //remove clear 略
}

內部是一個依賴 LinkedHashMap 實現的 LRU 緩存。看註釋,目的是要構建一個限定容量、且不會進行擴容的 MAP(百度了一波,和網上的實現一模一樣~)。那麼,實際情況真的和想象中的一樣麼?。

2.2LinkedHashMap 實現的 LRUMap 好使麼

我們來看容量和擴容相關的設置:爲什麼設計者認爲該 LRUMap 不會進行擴容?

//**把容量和擴容相關的參數摘出來**
//用戶期望的最大容量
private final int maxCapacity;
//加載係數
private static final float LOAD_FACTOR = 0.99f;
//構造函數中調用LinkedHashMap進行初始化
super(maxCapacity, LOAD_FACTOR, true);

@Override  //複寫刪除最久元素條件方法
protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
   //當LinkedHashMap.size 比 我們限定容量大時,執行刪除
   return size() > maxCapacity;
}

按我們的實際使用實例化一下:

因爲複寫了 LRU 條件函數,當 size>6000 時會進行 LRU 替換。因此,理論上,size 永遠不會達到 8110。

怎麼解決併發下的讀寫衝突呢?

//讀寫鎖
private final ReadWriteLock lock = new ReentrantReadWriteLock();
 
public V get(Object key) {
   try {
       lock.readLock().lock();
       return super.get(key);
   } finally {
       lock.readLock().unlock();
   }
}

public V put(K key, V value) {
   try {
      lock.writeLock().lock();
      return super.put(key, value);
   } finally {
      lock.writeLock().unlock();
   }
}

設計者爲了解決併發下的讀寫衝突,給查詢和修改方法加了鎖,爲了兼顧性能,使用了讀寫鎖:在 get 的時候加讀鎖,在 put/remove 的時候加寫鎖。

看起來,整個設計很好的解決了 LRUMap 的固定容量和併發操作問題,那麼事實是什麼樣的呢?

其實,這個問題很早就有人分析過了 [1] ,是因爲 LinkedHashMap 在 get 讀操作的時候,會爲了維護 LRU 從而進行元素修改,即將 get 到的元素轉移到鏈表最後。這樣,就導致了讀寫併發問題,但這個解釋感覺朦朦朧朧,因此,我決定在其基礎上對讀寫併發問題再講細緻一些。

2.3LinkedHashMap 內存泄漏拆解

都加了讀寫鎖爲什麼不好使呢?

這裏我們還是需要先明確,讀寫鎖的概念和適用場景:讀寫鎖,允許多個線程共享讀鎖,適用於讀多寫少的情況。(前提是,讀操作不會改變存儲結構)

所以,問題就發生在 get 操作上,LinkedHashMap 的 get 操作被重寫,目的是爲了實現 LRU 功能,在 get 之後,將當前節點_移動_到鏈表最後。

移動啊,同志們,這明顯是一個寫操作,所以,加讀鎖還有用麼?

即允許多線程進入,又進行了修改,那還能起什麼作用,能沒有併發問題麼?

下面,對照節點移動的代碼,詳細拆解一下多線程下的併發問題:

get 之後的節點移動,將節點移動到最後

實際拆解分析如下,爲什麼在多線程的情況下,會出現內存泄漏:

時間片下多線程的 get 執行

我們看到,在線程 1 執行完前兩句,讓出了時間片,當線程 2 執行到 p.after=null 之後又出讓了時間片,這樣,本來 a 應該是後面的 <2,B> 節點,結果多線程下變成了 null,最終,後面兩個節點被踢出了鏈表,刪除操作無法觸達,造成內存泄漏。

驗證的代碼就不貼了,大家有興趣可以自己試一下~

Part3 總結

話說回來,既然定位到了問題,這個內存泄漏怎麼修復呢?

可以把讀寫鎖改成互斥鎖。或者直接用分佈式存儲,能慢多少呢,是不是,既方便,簡單,又免得爲了節約機器內存自己構造 LRUMap。

每一個八股文都不只是爲了面試,而是每次線上問題排查的基石。千萬別把八股文的作用定位錯了。。。

參考資料

[1]

LinkedHashMap 引發的內存泄漏: "https://blog.csdn.net/yejingtao703/article/details/108062262"

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UPCIgL0_SLyOF5SNFgL27w