以線上實例來看 內存泄漏的解決方案

面試官:有沒有線上內存泄漏的解決經驗,解決流程是什麼呢?

下面,我就以一個真實線上接口得內存泄漏導致內存溢出案例,走一遍分析內存泄漏的大致流程~

情景

項目上線了一個接口,先灰度一臺機器觀察調用情況;
接口不斷的調用,過了一段時間,發現機器上的接口調用開始報OOM異常
當天就是上線deadline了,刺激。。

最近,我看“面試官”有點忙,我也來蹭蹭熱度~

發現問題

第一步,使用jps命令獲取出問題jvm進程的進程ID

使用jps -l -m獲取到當前jvm進程的pid,通過上述命令獲取到了服務的進程號:427726 (此處假設爲這個)
在這裏插入圖片描述
jps命令

jps(JVM Process Status Tool):顯示指定系統內所有的HotSpot虛擬機進程
jps -l -m : 參數-l列出機器上所有jvm進程,-m顯示出JVM啓動時傳遞給main()的參數

第二步,使用jstat觀察jvm狀態,發現問題

因爲是OOM異常,所以我們首先重啓機器觀察了JVM的運行情況;

我們使用jstat -gc pid time命令觀察GC,發現GC在YGC後,GC掉的內存並不多,每次YGC後都有一部分內存未回收,導致在多次YGC後回收不掉的內存被挪到堆的old區,old滿了之後FGC發現也是回收不掉;
這裏基本可以確定是內存泄漏的問題了,下面我們有簡單看了下機器的cpu、內存、磁盤狀態

jstat命令:

jstat(JVM statistics Monitoring)是用於監視虛擬機運行時狀態信息的命令,它可以顯示出虛擬機進程中的類裝載、內存、垃圾收集、JIT編譯等運行數據。
jstat -gc pid time : -gc 監控jvm的gc信息,pid 監控的jvm進程id,time每個多少毫秒刷新一次
jstat -gccause pid time : -gccause 監控gc信息並顯示上次gc原因,pid 監控的jvm進程id,time每個多少毫秒刷新一次
jstat -class pid time: -class 監控jvm的類加載信息,pid 監控的jvm進程id,time每個多少毫秒刷新一次

在這裏先簡單說一下,堆的GC:

在GC開始的時候,對象只會存在於Eden區和名爲“From”的Survivor區,Survivor區“To”是空的。緊接着進行GC,Eden區中所有存活的對象都會被複制到“To”,而在“From”區中,仍存活的對象會根據他們的年齡值來決定去向。

年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設置)的對象會被移動到年老代中,沒有達到閾值的對象會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名爲To的Survivor區域是空的,minor GC會一直重複這樣的過程。

第三步,觀察機器狀態,確認問題

使用top -p pid獲取進程的cpu和內存使用率;查看RES 和 %CPU %MEM三個指標:
在這裏插入圖片描述

在這裏先簡單說一下,top命令展示的內容:

VIRT:virtual memory usage 虛擬內存
1、進程“需要的”虛擬內存大小,包括進程使用的庫、代碼、數據等
2、假如進程申請100m的內存,但實際只使用了10m,那麼它會增長100m,而不是實際的使用量

RES:resident memory usage 常駐內存
1、進程當前使用的內存大小,但不包括swap out
2、包含其他進程的共享
3、如果申請100m的內存,實際使用10m,它只增長10m,與VIRT相反
4、關於庫佔用內存的情況,它只統計加載的庫文件所佔內存大小

SHR:shared memory 共享內存
1、除了自身進程的共享內存,也包括其他進程的共享內存
2、雖然進程只使用了幾個共享庫的函數,但它包含了整個共享庫的大小
3、計算某個進程所佔的物理內存大小公式:RES – SHR
4、swap out後,它將會降下來

DATA
1、數據佔用的內存。如果top沒有顯示,按f鍵可以顯示出來。
2、真正的該程序要求的數據空間,是真正在運行中要使用的。

ps : 如果程序佔用實存比較多,說明程序申請內存多,實際使用的空間也多。
如果程序佔用虛存比較多,說明程序申請來很多空間,但是沒有使用。

發現機器的自身狀態不存在問題, so毋庸置疑,發現問題了,典型的內存泄漏。。

第四步,使用jmap獲取jvm進程dump文件

我們使用jmap -dump:format=b,file=dump_file_name pid 命令,將當前機器的jvm的狀態dump下來或缺的一份dump文件,用做下面的分析

jmap命令:

jmap(JVM Memory Map)命令用於生成heap dump文件,還可以查詢finalize執行隊列、Java堆和永久代的詳細信息,如當前使用率、當前使用的是哪種收集器等。
jmap -dump:format=b,file=dump_file_name pid : file=指定輸出數據文件名, pid jvm進程號

接下來,回滾灰度的機器,開始解決問題=.=

解決問題

第一步,dump文件分析

在這裏,我們分析dump文件,使用的Jprofiler軟件,就是下面這個東東:
在這裏插入圖片描述

具體的使用方法,在這就不再贅述了,下面將dump文件導入到Jprofiler中:
選擇Heap Walker 中的Current Object Set,這裏面顯示的是當前的類的佔用資源,從佔用空間從大到小排序;
在這裏插入圖片描述
從上圖中,沒有觀察出什麼問題,我們點擊Biggest Objects,查看哪個對象的佔用的內存高:
在這裏插入圖片描述
從上圖中,我們發現org.janusgraph.graphdb.database.StandardJanusGraph這個對象居然佔用了高達724M的內存! 看來內存泄漏八九不離十就是這個對象的問題了!
再點開看看 ,如下圖,可以發現是一個openTransactions的類型爲ConcurrentHashMap的數據結構:
在這裏插入圖片描述

第二步,源碼查找定位代碼

這到底是什麼對象呢,去項目中查找一下,打開idea-打開項目-雙擊shift鍵-打開全局類查找-輸入StandardJanusGraph,如下圖:
在這裏插入圖片描述
發現是我們項目使用的圖數據庫janusgraph的一個類,找到對應的數據結構:
類型定義:

private Set<StandardJanusGraphTx> openTransactions;

初始化爲一個ConcurrentHashMap:

openTransactions = Collections.newSetFromMap(new 
ConcurrentHashMap<StandardJanusGraphTx, Boolean>(100, 
0.75f, 1));

觀察上述代碼,我們可以看到,裏面的存儲的StandardJanusGraphTx從字面意義上理解是janusgraph框架中的事務對象,下面往上追一下代碼,看看什麼時候會往這個Map中賦值:
// 1. 找到執行openTransactions.add()的方法

    public StandardJanusGraphTx newTransaction(final TransactionConfiguration configuration) {
        if (!isOpen) ExceptionFactory.graphShutdown();
        try {
            StandardJanusGraphTx tx = new StandardJanusGraphTx(this, configuration);
            tx.setBackendTransaction(openBackendTransaction(tx));
            openTransactions.add(tx);  // 注意! 此處對上述的map對象進行了add
            return tx;
        } catch (BackendException e) {
            throw new JanusGraphException("Could not start new transaction", e);
        }
    }

// 2. 上述發現,是一個newTransaction,創建事務的一個方法,爲確保起見,再往上跟找到調用上述方法的類:

  public JanusGraphTransaction start() {
       TransactionConfiguration immutable = new ImmutableTxCfg(isReadOnly, hasEnabledBatchLoading,
               assignIDsImmediately, preloadedData, forceIndexUsage, verifyExternalVertexExistence,
               verifyInternalVertexExistence, acquireLocks, verifyUniqueness,
               propertyPrefetching, singleThreaded, threadBound, getTimestampProvider(), userCommitTime,
               indexCacheWeight, getVertexCacheSize(), getDirtyVertexSize(),
               logIdentifier, restrictedPartitions, groupName,
               defaultSchemaMaker, customOptions);
       return graph.newTransaction(immutable);  // 注意!此處調用了上述的newTransaction方法
   }

// 3. 接着找上層調用,發現了最上層的方法

   public JanusGraphTransaction newTransaction() {
       return buildTransaction().start();  // 此處調用了上述的start方法
   } 

在我們對圖數據庫中圖數據操作的過程中,採用的是手動創建事務的方式,在每次查詢圖數據庫之前,我們都會調用類似於dataDao.begin()代碼,
其中就是調用的public JanusGraphTransaction newTransaction()這個方法;

最後,我們簡單的看下源碼可以發現,從上述內存泄漏的map中去除數據的邏輯就是commit事務的接口,調用鏈如下:
// 1. 從openTransactions這個map中刪除事務對象的方法

    public void closeTransaction(StandardJanusGraphTx tx) {
        openTransactions.remove(tx); // 從map中刪除StandardJanusGraphTx對象
    }
    
    private void releaseTransaction() {
        isOpen = false;
        graph.closeTransaction(this); // 調用上述closeTransaction方法
        vertexCache.close();
    }

// 2. 最上層找到了commit方法,提交事務後就會將對應的事務對象從map中移除

   public synchronized void commit() {
        Preconditions.checkArgument(isOpen(), "The transaction has already been closed");
        boolean success = false;
        if (null != config.getGroupName()) {
            MetricManager.INSTANCE.getCounter(config.getGroupName(), "tx", "commit").inc();
        }
        try {
            if (hasModifications()) {
                graph.commit(addedRelations.getAll(), deletedRelations.values(), this);
            } else {
                txHandle.commit();  // 這個commit方法中釋放事務也是調用releaseTransaction
            }
            success = true;
        } catch (Exception e) {
            try {
                txHandle.rollback();
            } catch (BackendException e1) {
                throw new JanusGraphException("Could not rollback after a failed commit", e);
            }
            throw new JanusGraphException("Could not commit transaction due to exception during persistence", e);
        } finally {
            releaseTransaction();  // // 調用releaseTransaction
            if (null != config.getGroupName() && !success) {
                MetricManager.INSTANCE.getCounter(config.getGroupName(), "tx", "commit.exceptions").inc();
            }
        }
    }
   

終於,我們找到了內存泄漏的根源所在:項目代碼中存在調用了事務begin但是沒有commit的代碼!

第三步,修復問題驗證

解決問題: 找到內存泄漏接口的代碼,並發現了沒有commit()的位置,try-catch-finally中添加上了commit()代碼;

提交-部署-發佈-灰度一臺機器後觀察內存泄漏的現象消失,GC回收正常;

內存泄漏問題解決,項目如期上線~

最後

對於內存泄漏導致的內存溢出問題,排查步驟大致如上述,總的步驟:找出問題(GC、CPU、磁盤、內存、網絡),定位問題(使用第三方工具分析dump文件等),解決問題;

另外,內存泄漏的分析方法有好多種,但是大致原理和流程都是相似的;

大家,有沒有遇到過內存泄漏的情況,歡迎在評論區說出你的故事=.=

原創不易,如果大家有所收穫,希望大家可以點贊評論支持一下~

也歡迎大家關注我的CSDN支持一下作者,作者定期分享工作中的所見所得~

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