高并發(fā)服務(wù)優(yōu)化篇:詳解一次由讀寫鎖引起的內(nèi)存泄漏
JVM相關(guān)的異常,一直是一線研發(fā)比較頭疼的問題。因?yàn)閷τ跇I(yè)務(wù)代碼,JVM的運(yùn)行基本算是黑盒,當(dāng)異常發(fā)生時,較難直觀的看到和找到問題所在,這也是我們一直要研究其內(nèi)部邏輯的原因。
本篇就由一個近期線上JVM內(nèi)存泄漏的例子,帶大家強(qiáng)行分析一波~
Part1 線上服務(wù)器報警了
某天,同事來找我?guī)兔?,原來是某系統(tǒng)毫無征兆的來了一連串報警,一波機(jī)器的老年代內(nèi)存占用率超過閾值~
1.1先看表現(xiàn)
老年代內(nèi)存占用
可以看到,在7月中旬之前,內(nèi)存占用還是比較正常的,每次GC都可以回收掉很大一部分的老年代對象。
而中旬之后,老年代內(nèi)存一直緩慢增長而無法釋放。很明顯,應(yīng)該是對象沒法被正常回收導(dǎo)致。
內(nèi)存泄漏了~
1.2 怎么辦呢
如果是剛上線的項(xiàng)目爆出了此類問題,因?yàn)橛绊懨姹容^小,可以直接先回滾代碼,止血為第一要務(wù)。
不過,這個項(xiàng)目明顯已經(jīng)上線N多天,中間還不知道上過多少需求,而且,既然流量近期有上漲導(dǎo)致問題出現(xiàn),說明,已經(jīng)對客開流量了。
回滾是不可能了,抓緊時間定位問題,上線修復(fù)吧。
Part2 定位問題
一般的步驟:
- 拿到dump文件
- 用MAT等工具,找出內(nèi)存占用過多的異常對象,以及引用關(guān)系
- 分析異常對象關(guān)聯(lián)代碼的可能問題
不過,因?yàn)檫@次dump下來的文件十多G,太大的,MAT基本無能為力,只能打印出來人工分析了
2.1 定位問題代碼
jmap結(jié)果查看
很幸運(yùn),異常對象非常明顯。Point對象和GeoDispLocal對象,居然多達(dá)好幾百萬實(shí)例數(shù),那就先看下代碼中這兩個對象是怎么用的。
- 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中(內(nèi)存泄漏的一個常見原因,就是因?yàn)楸混o態(tài)集合持有,無法回收導(dǎo)致),而dump文件中的CacheMap.Entry也是非常高的。
CacheMap就是我們的第一優(yōu)先懷疑對象了。先看下這個緩存類是怎么回事:
- ublic class CacheMap<K, V> {
- private final long expireMs;
- private LRUMap<K, CacheMap.Entry<V>> valueMap;
- //其他略
- }
內(nèi)部依賴一個帶LRU功能的map,怎么實(shí)現(xiàn)的呢:
- public class LRUMap<K, V> extends LinkedHashMap<K, V> {
- private static final long serialVersionUID = 1L;
- private final int maxCapacity;
- // 這個map不會擴(kuò)容
- 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 略
- }
內(nèi)部是一個依賴LinkedHashMap實(shí)現(xiàn)的LRU緩存。看注釋,目的是要構(gòu)建一個限定容量、且不會進(jìn)行擴(kuò)容的MAP(百度了一波,和網(wǎng)上的實(shí)現(xiàn)一模一樣~)。那么,實(shí)際情況真的和想象中的一樣么?。
2.2 LinkedHashMap實(shí)現(xiàn)的LRUMap好使么
我們來看容量和擴(kuò)容相關(guān)的設(shè)置:為什么設(shè)計者認(rèn)為該LRUMap不會進(jìn)行擴(kuò)容?
- //**把容量和擴(kuò)容相關(guān)的參數(shù)摘出來**
- //用戶期望的最大容量
- private final int maxCapacity;
- //加載系數(shù)
- private static final float LOAD_FACTOR = 0.99f;
- //構(gòu)造函數(shù)中調(diào)用LinkedHashMap進(jìn)行初始化
- super(maxCapacity, LOAD_FACTOR, true);
- @Override //復(fù)寫刪除最久元素條件方法
- protected boolean removeEldestEntry(java.util.Map.Entry<K, V> eldest) {
- //當(dāng)LinkedHashMap.size 比 我們限定容量大時,執(zhí)行刪除
- return size() > maxCapacity;
- }
按我們的實(shí)際使用實(shí)例化一下:
- maxCapacity=6000,是我們希望的最大元素容量。
- load_factor=0.99 加載因子。
- Map內(nèi)部threshold=8192*0.99=8110,是那么下次擴(kuò)容時的容量大小。(map中table容量的真實(shí)大小是離6000最近的2的N次冪,即8192)。
因?yàn)閺?fù)寫了LRU條件函數(shù),當(dāng)size>6000時會進(jìn)行LRU替換。因此,理論上,size永遠(yuǎn)不會達(dá)到8110。
怎么解決并發(fā)下的讀寫沖突呢?
- //讀寫鎖
- 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();
- }
- }
設(shè)計者為了解決并發(fā)下的讀寫沖突,給查詢和修改方法加了鎖,為了兼顧性能,使用了讀寫鎖:在get的時候加讀鎖,在put/remove的時候加寫鎖。
看起來,整個設(shè)計很好的解決了LRUMap的固定容量和并發(fā)操作問題,那么事實(shí)是什么樣的呢?
其實(shí),這個問題很早就有人分析過了[1] ,是因?yàn)長inkedHashMap在get讀操作的時候,會為了維護(hù)LRU從而進(jìn)行元素修改,即將get到的元素轉(zhuǎn)移到鏈表最后。這樣,就導(dǎo)致了讀寫并發(fā)問題,但這個解釋感覺朦朦朧朧,因此,我決定在其基礎(chǔ)上對讀寫并發(fā)問題再講細(xì)致一些。
2.3 LinkedHashMap內(nèi)存泄漏拆解
都加了讀寫鎖為什么不好使呢?
這里我們還是需要先明確,讀寫鎖的概念和適用場景:讀寫鎖,允許多個線程共享讀鎖,適用于讀多寫少的情況。(前提是,讀操作不會改變存儲結(jié)構(gòu))
所以,問題就發(fā)生在get操作上,LinkedHashMap的get操作被重寫,目的是為了實(shí)現(xiàn)LRU功能,在get之后,將當(dāng)前節(jié)點(diǎn)移動到鏈表最后。
移動啊,同志們,這明顯是一個寫操作,所以,加讀鎖還有用么?
即允許多線程進(jìn)入,又進(jìn)行了修改,那還能起什么作用,能沒有并發(fā)問題么?
下面,對照節(jié)點(diǎn)移動的代碼,詳細(xì)拆解一下多線程下的并發(fā)問題:
get之后的節(jié)點(diǎn)移動,將節(jié)點(diǎn)移動到最后
實(shí)際拆解分析如下,為什么在多線程的情況下,會出現(xiàn)內(nèi)存泄漏:
時間片下多線程的get執(zhí)行
我們看到,在線程1執(zhí)行完前兩句,讓出了時間片,當(dāng)線程2執(zhí)行到p.after=null之后又出讓了時間片,這樣,本來a應(yīng)該是后面的<2,B>節(jié)點(diǎn),結(jié)果多線程下變成了null,最終,后面兩個節(jié)點(diǎn)被踢出了鏈表,刪除操作無法觸達(dá),造成內(nèi)存泄漏。
驗(yàn)證的代碼就不貼了,大家有興趣可以自己試一下~
Part3 總結(jié)
話說回來,既然定位到了問題,這個內(nèi)存泄漏怎么修復(fù)呢?
可以把讀寫鎖改成互斥鎖。或者直接用分布式存儲,能慢多少呢,是不是,既方便,簡單,又免得為了節(jié)約機(jī)器內(nèi)存自己構(gòu)造LRUMap。
每一個八股文都不只是為了面試,而是每次線上問題排查的基石。千萬別把八股文的作用定位錯了。。。
本文轉(zhuǎn)載自微信公眾號「Coder的技術(shù)之路 」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系Coder的技術(shù)之路公眾號。