前言
首先 java 語言的特性是不需像 C 和 C++ 那樣自己手動釋放內(nèi)存,因?yàn)?java 本身有垃圾回收機(jī)制(垃圾回收稱為 GC),顧名思義就是釋放垃圾占用的空間,防止內(nèi)存泄露。JVM 運(yùn)行時(shí)占用內(nèi)存最大的空間就是堆內(nèi)存,另外棧區(qū)和方法區(qū)也會占用空間但是占用有限本章就不探究了。
那么堆中的空間又分為年輕代和老年代,所以我們粗略的把垃圾回收分為兩種:年輕代的垃圾回收稱為 Young GC,老年代的垃圾回收稱為 Full GC,實(shí)際上此處的 Full GC 也包含了新生代,老年代,元空間等的回收。 因?yàn)?Full GC 的回收過程會使系統(tǒng)的所有線程 STW(Stop The World),那么我們一定希望讓系統(tǒng)盡量不要進(jìn)行 Full GC,或者必須要進(jìn)行 FullGC 的時(shí)候執(zhí)行的時(shí)間越短越好。
下面我們主要探究 Full GC 的角度出發(fā)分析我在開發(fā)運(yùn)營后臺的時(shí)候遇到的頻繁 Full GC 過程。
事件背景
項(xiàng)目介紹: 我們團(tuán)隊(duì)做的是一個(gè)后臺管理系統(tǒng),因?yàn)獒槍Σ煌脩糌?fù)責(zé)的功能不同那么需要的權(quán)限也就不一樣,所以引入了主流的 shiro 框架做權(quán)限控制,該框架可以控制菜單欄,按鈕,操作框等。在引入這個(gè)框架時(shí)一并引入了輔助組件shiro-redis,該組件是一個(gè)緩存層方便管理用戶登錄信息,內(nèi)存泄漏的問題也是就現(xiàn)在這個(gè)輔助組件上。
事件還原: 在周五的中午 11:30 分收到了監(jiān)控的報(bào)警信息提示系統(tǒng)在頻繁 Full GC,此時(shí)我們立刻做兩件事情: 第一:登錄公司的 UMP 監(jiān)控平臺(開源監(jiān)控可以參考:【Prometheus+grafana 監(jiān)控】)查看該機(jī)器的系統(tǒng)指標(biāo),發(fā)現(xiàn)確實(shí)在頻繁 FullGC 從 11 點(diǎn)持續(xù)到了 11 點(diǎn)半
第二:保留一臺機(jī)器作為證據(jù)收集,其他機(jī)器進(jìn)行重啟保障業(yè)務(wù)能正常訪問,重啟后 full gc 正常
第三:堆棧信息操作指令./jmap -F -dump:live,format=b,file=/jmapfile.hprof 18362(-F 操作是強(qiáng)制導(dǎo)出堆棧信息,18362 是應(yīng)用 pid,通過 top -c 指令獲取)
第四:因?yàn)閭€(gè)人無權(quán)限導(dǎo)出堆棧信息,馬上電話聯(lián)系運(yùn)維通過上面指令導(dǎo)出該機(jī)器上的堆棧文件,就是抓取現(xiàn)場證據(jù),因?yàn)檫^了這個(gè)時(shí)間堆內(nèi)存可能就正常了 根據(jù) JVM 知識分析,常見 Full GC 時(shí)的五種情況如下:
1. 老年代內(nèi)存不足(大對象過多或內(nèi)存泄漏) 2. Metaspace 空間不足 3. 代碼主動觸發(fā) System.gc() 4. YGC 時(shí)的悲觀策略 5. dump live 的內(nèi)存信息時(shí),比如 jmap -dump:live
分析原因
1、查看公司 SGM 監(jiān)控平臺(開源監(jiān)控可以參考:【Prometheus+grafana 監(jiān)控】),元空間最大內(nèi)存 256M,F(xiàn)ullGC 發(fā)生前后為 117M,排除 Metaspace 不足造成的原因
2、在系統(tǒng)中搜索第三方 jar 包,沒有主動執(zhí)行 System.gc () 操作的代碼 3、查看 JVM 啟動參數(shù)中有下面兩個(gè)參數(shù),所以排除了 YGC 時(shí)候的悲觀策略原因
-XX:CMSInitiatingOccupancyFraction=70 # 堆內(nèi)存達(dá)到 70%進(jìn)行 FullGC -XX:+UseCMSInitiatingOccupancyOnly # 禁止 YGC 時(shí)的悲觀策略(YGC 前后判斷是否需要 FullGC),只有達(dá)到閾值才進(jìn)行 FullGc4、通過和運(yùn)維、研發(fā)組溝通沒有人主動執(zhí)行 dump 操作,查看系統(tǒng)的歷史執(zhí)行指令也沒有 dump 操作,主動 dump 的原因排除 初步分析結(jié)果: 通過上面依靠監(jiān)控平臺、JVM 啟動參數(shù)、代碼排除、指令分析,最終嫌疑最大的就是老年代內(nèi)存空間不足造成頻繁 Full GC,但是作為技術(shù)者,排除法顯然不能作為原因定位的依據(jù),我們還需要繼續(xù)確定我們的猜想,下面會結(jié)合 JVM 啟動參數(shù),Tomcat 啟動參數(shù),堆棧文件三大關(guān)鍵要素做具體分析。
下圖是進(jìn)行 FullGC 時(shí)候的老年代內(nèi)存情況,把下面的 72%、1794Mb、2496Mb、448Mb 先記住,下面會跟這些值做對比
指標(biāo)信息: JVM 核心參數(shù):
-Xms2048M # 系統(tǒng)啟動初始化堆空間 -Xmx4096M # 系統(tǒng)最大堆空間 -Xmn1600M # 年輕代空間(包括 From 區(qū)和 To),F(xiàn)rom 和 To 默認(rèn)占年輕代 20% -XX:MaxPermSize=256M # 最大非堆內(nèi)存,按需分配 -XX:MetaspaceSize=256M # 元空間大小,JDK1.8 取消了永久代(PermGen)新增元空間,元空間并不在虛擬機(jī)中,而是使用本地內(nèi)存。因此,默認(rèn)情況下,元空間的大小僅受本地內(nèi)存限制,存儲類和類加載器的元數(shù)據(jù)信息 -XX:CMSInitiatingOccupancyFraction=70 # 堆內(nèi)存達(dá)到 70%進(jìn)行 FullGC -XX:+UseCMSInitiatingOccupancyOnly # 禁止 YGC 時(shí)的悲觀策略(YGC 前后判斷是否需要 FullGC),只有達(dá)到閾值才進(jìn)行 FullGc -XX:+UseConcMarkSweepGC # 使用 CMS 作為垃圾收集器Tomcat 核心參數(shù):
maxThreads=750# Tomcat 線程池最多能起的線程數(shù) minSpareThreads=50# Tomcat 初始化的線程池大小或者說 Tomcat 線程池最少會有這么多線程 acceptCount=1000# Tomcat 維護(hù)最大的隊(duì)列數(shù)
導(dǎo)出堆棧指令:jmap -dump:live,format=b,file=jmapfile.hprof [pid]。導(dǎo)出的文件需要使用 MAT 軟件分析,全稱 MemoryAnalyzer,主要分析堆內(nèi)存。
從堆棧文件分析結(jié)果中發(fā)現(xiàn)有 50 個(gè) org.apache.tomcat.util.threads.TaskThread 占用空間很大。共占用空間 96.16%
每個(gè) TaskThread 實(shí)例占用空間 36M 左右
查看內(nèi)存詳情保存最大最多的對象是 ThreadLocal 中存儲的 SessionInMemory 對象
最終原因: 通過分析上面的 JVM 參數(shù)、Tomcat 參數(shù)、堆棧文件,內(nèi)存泄漏的原因是每個(gè)線程中有一個(gè) ThreadLocal 存儲大量 SessionInMemory,因?yàn)?Tomcat 的啟動核心線程數(shù)是 50 個(gè),每個(gè)線程的內(nèi)存占用 36M 左右,共占用 1.8G,老年代內(nèi)存達(dá)到 70% 也就是 2496 * 0.7 = 1747.2M 就會進(jìn)行垃圾回收,1.8G 剛好比 1747.2M 稍微大一些。但是線程中的對象又沒辦法被回收,所以就會看到系統(tǒng)再頻繁 FullGC。
定位問題
通過上面內(nèi)存分析已經(jīng)定位到內(nèi)存泄漏的原因是每個(gè)線程中有大量 SessionInMemory,下面步驟就認(rèn)真分析代碼找到其中創(chuàng)建如此多對象還不銷毀的原因。
經(jīng)過初步分析發(fā)現(xiàn) SessionInMemory 是引用 shiro-redis 的工具包里面的對象,主要封裝 Session 信息和創(chuàng)建時(shí)間。主要作用是在當(dāng)前線程的 jvm 中做一層緩存當(dāng)系統(tǒng)頻繁獲取 Session 時(shí)不用去 redis 獲取了。SessionInMemary 對象是 shiro 判斷用戶登錄成功時(shí)候存儲的數(shù)據(jù),主要包括用戶信息,認(rèn)證信息,權(quán)限信息等,因?yàn)橛脩舻卿洺晒蟛粫貜?fù)認(rèn)證,shiro 會對不同用戶做權(quán)限判斷
分析代碼發(fā)現(xiàn)處理本地緩存 Session 的流程有明顯問題,我畫了一個(gè)簡易的流程圖,在介紹流程圖前我先描述一下 Session 和用戶登錄操作如何聯(lián)系起來
我們都知道運(yùn)營后臺需要用戶登錄,登錄成功后會生成一個(gè) cookie 保存到瀏覽器中,cookie 存儲一個(gè)關(guān)鍵字段 sessionId 用來標(biāo)識用戶的狀態(tài)和信息,當(dāng)用戶訪問頁面調(diào)用接口的時(shí)候 shiro 會從請求 Request 中獲取 cookie 中的 sessionId,根據(jù)這個(gè)唯一標(biāo)識生成 Session 來存儲用戶的登錄態(tài)和登錄信息等,這些信息會保存到 redis 中。shiro-redis 組件負(fù)責(zé)從 redis 中獲取的 Session 信息通過 ThreadLoca 做到線程隔離。
上圖流程概括就是:用戶訪問頁面先從本地緩存獲取 Session,如果存在且沒有超過一秒就返回結(jié)果,如果沒有 Session 或者過期了就把現(xiàn)在的 Session 刪除并新建一個(gè)返回結(jié)果。整體看思路清晰,先獲取 Session,如果沒有就新建返回,如果過期了就刪除再新建返回。
流程圖隱藏的問題(核心問題)
1、多個(gè)線程會復(fù)制多份相同 Session 使內(nèi)存成倍增加(Session 一樣線程不同) 舉個(gè)例子:用戶登錄后臺生成一個(gè) Session,假設(shè)請求都到一臺機(jī)器上,第一次請求到線程 1,第二個(gè)請求到線程 2,因?yàn)?Session 一樣但是線程之間是隔離的,所以線程 1 和線程 2 都會創(chuàng)建一份相同 Session 存儲到 ThreaLocal 中,Tomcat 最小空閑線程數(shù)越多復(fù)制的 Session 份數(shù)也越多。因?yàn)?Tomcat 的核心線程數(shù)不會關(guān)閉,所以里面的資源也不會釋放。此處有個(gè)疑問 ThreadLocad 的 key 是弱引用但是為什么沒回收呢?下面統(tǒng)統(tǒng)解答
2、舊 Session 無法清除(線程一樣 Session 不同)
舉個(gè)例子 1:假設(shè)所有請求都到一臺機(jī)器的同一個(gè)線程,用戶第一次登錄后臺生成 Session1,第一次請求到線程 1,1 秒內(nèi)所有請求都執(zhí)行完了,此時(shí) Session 沒有移除(因?yàn)?Session 移除策略是懶刪除,需要等下次同一個(gè) Session 訪問時(shí)判斷過期條件再刪除),用戶重新登錄,生成了 Session2,因?yàn)?Session2 在線程 1 中還沒有就會重新創(chuàng)建,導(dǎo)致第一次登錄時(shí)候用到的 Session1 就一直保存到該線程中了
舉個(gè)例子 2:參考例子 1 的思路,如果用戶用 Session1 沒有在 1 秒內(nèi)把所有請求執(zhí)行完,就會執(zhí)行懶刪除操作,但是刪除后又新建了一個(gè),那么用戶重新登錄后剛才新建的那個(gè) Session 還是沒有被刪除,所以總結(jié)出來只要用戶重新登錄必定有一個(gè)舊的 Session 會保留到線程中
代碼分析
1、在 RedisSessionDAO.java 文件中定義了一個(gè) ThreadLocal 變量作為線程隔離
2、用戶訪問接口、js 文件、css 文件等資源的時(shí)候會進(jìn)入 shiro 的攔截機(jī)制。在攔截過程中會頻繁調(diào)用 doReasSession () 方法獲取用戶的 Session 信息,主要是獲取信息校驗(yàn)用戶的權(quán)限控制等。
下面的方法主要整合了獲取 Session 操作和設(shè)置 Session 操作,如果從 ThreadLocal 中沒有獲取到或者本地緩存超過 1 秒了就返回 null,判斷為 null 之后就會從 redis 中獲取并新建一個(gè) Session 存儲到 ThreadLoca 中
3、從 ThreadLocal 中取出 sessionMap,根據(jù) sessionId 在 sessionMap 中尋找 Session,如果沒找到直接返回 null,如果找到了再判斷時(shí)間是否超過了 1 秒,如果沒超過返回 Session,如果超過了移除返回 null
4、從 ThreadLocal 中獲取 sessionMap,如果為 null 就新建一個(gè)保存起來,因?yàn)橛脩舻谝淮卧L問的時(shí)候線程中的 sessionMap 還沒有呢所以要新建。然后向 sessionMap 中存儲 Session 對象
所以代碼的完成流程總結(jié):獲取 Session 的操作是調(diào)用 getSessionFromThreadLocal () 方法,如果沒有獲取到 Session 就返回 null,調(diào)用 setSessionToThreadLocal () 方法會重新設(shè)置一個(gè) Session。如果 Session 在當(dāng)前線程的保存時(shí)間超過 1 秒就 remove。
通過上面分析 JVM、Tomat、堆棧、代碼已經(jīng)把問題定位了,因?yàn)?shiro-redis 中存儲的 SessionInMemory 對象處理不當(dāng)導(dǎo)致線程間存儲越來越多,最終使內(nèi)存泄漏進(jìn)而導(dǎo)致了頻繁 FullGC。因?yàn)槲覀円玫?shiro-redis 版本是 3.2.2 版本,所以存在這個(gè)漏洞,作者已于 2019 年 3 月升級 jar 包到 3.2.3 版本把該問題解決。備注:3.2.2 及以下版本存在該問題
解決問題
解決問題的方案目前有四種。針對我們系統(tǒng)使用的是方案 1 + 方案 4
序號 | 方案描述 | 優(yōu)點(diǎn) | 缺點(diǎn) |
---|---|---|---|
方案 1 | 每次設(shè)置 session 時(shí)遍歷刪除以前過期或者為 null 的 session | 主動刪除,刪除頻次依賴用戶的訪問頻次 | 如果在 1 秒內(nèi)有大量用戶訪問,總 session 很多無效 session 很少,遍歷所有 session 做了很多無用功導(dǎo)致訪問變慢 |
方案 2 | 取消 threadLocal 策略,所有請求直接查詢緩存(redis) | 減少本地內(nèi)存使用 | 訪問緩存耗時(shí)比本地長,經(jīng)過測試發(fā)現(xiàn)一個(gè)接口會調(diào)用 16 次左右的獲取 session 操作,一個(gè)頁面幾十個(gè)接口,直接查詢緩存性能存在問題 |
方案 3 | 使用本地緩存(guavaCache 或者 EhCache 等),并對緩存做移除策略 | 多個(gè)線程共用一份內(nèi)存,節(jié)省內(nèi)存空間,提升系統(tǒng)性能 | 對框架有深入了解,接入需要開發(fā)成本 |
方案 4 | 把 tomcat 的核心線程數(shù)減小,比如把原來的 50 改成 5 | 減少系統(tǒng)資源,減少相同 Session 的復(fù)制份數(shù),大于 5 的線程銷毀資源也一起回收 | 處理并發(fā)能力略低 |
疑問解答
Q:在 RedisSessionDAO 里面只定義了一個(gè) ThreadLocal 的變量 sessionsInThread,怎么就會是 50 個(gè)線程把相同的 Session 復(fù)制 50 份呢?
A:首先我們先理解 ThreadLocal 的結(jié)構(gòu),ThreadLocal 有一個(gè)靜態(tài)類 ThreadLocalMap,ThreadLocalMap 里面還有一個(gè) Entry,我們的 key 和 value 就是保存在 Entry 的,key 是一個(gè)弱引用的 ThreadLocal 類型,,這個(gè) key 在所有的線程中都是一樣的,實(shí)際上就是我們定義的靜態(tài) sessionsInThread。那又是怎么做到線程隔離的呢?
這就講到 Thread 中的一個(gè)成員變量 threadLocals,這個(gè)對象就是 ThreadLocal.ThreadLocalMap 類型,也就是每次創(chuàng)建一個(gè)線程都會 new 一個(gè) ThreadLocalMap,所以每個(gè)線程中的 ThreadLocalMap 都是不同的,但是里面 Entry 存儲的 key 都是一樣的,也就是我們前面定義的 sessionsInThread 靜態(tài)變量。
當(dāng)一個(gè)線程需要獲取 Entry 中存儲的 value 時(shí)候,調(diào)用 sessionsInThread.get () 方法,這個(gè)方法做了三件事情,一是獲取當(dāng)前線程的實(shí)例,二是從線程實(shí)例中獲取 ThreadLocalMap,三是從 ThreadLocalMap 中根據(jù) ThreadLocal 這個(gè) key 獲取指定的 value
獲取 Thread 中的 ThreadLocalMap
從 ThreadLocalMap 中獲取指定的 value,又有個(gè)疑問,獲取 Entry 為什么還要從一個(gè) table 數(shù)組中拿呢?這個(gè)很好理解一個(gè)線程不一定只有一個(gè) ThreadLocal 變量吧,多個(gè) ThreadLocal 變量就是有多個(gè) key,所以就放到 table 數(shù)組里面了
Q:都說 ThreadLocal 的 key 是一個(gè)弱引用,如果內(nèi)存不足了會被垃圾回收,咱們的 key 從堆棧看并沒有回收呀?
A:這是個(gè)好問題,首先我們的 RedisSessionDAO 是 Spring 注入的單例模式,ThreadLocal 被定義成一個(gè)靜態(tài)變量,靜態(tài)變量在內(nèi)存中是不會回收的。
補(bǔ)充:一般我們在使用 ThreadLocal 的時(shí)候都會定義成靜態(tài)變量,如果定義成非靜態(tài)變量創(chuàng)建一個(gè)對象就會 new 一個(gè) ThreadLocal,那么 ThreadLocal 就沒有存在的意義了。
Q:已經(jīng)結(jié)束的線程,為什么還會存活,里面的對象也不會消失?
A:因?yàn)樵O(shè)置的最小空閑線程數(shù)是 50,業(yè)務(wù)量不大并發(fā)數(shù)沒有超過 50,tomcat 會保留最小的線程數(shù)量不會新建也不用回收,ThreadLocalMap 是線程中的成員變量所以不會回收
Q:訪問一次接口就會生成一個(gè) sessionId 嗎?
A:訪問接口先判斷用戶信息是否有效,無效才會重新登錄獲取新的 sessionId
Q:shiro-redis 在本地保存 Session 為什么設(shè)置 1 秒過期時(shí)間?
A:因?yàn)檫\(yùn)營后臺不同于業(yè)務(wù)接口會持續(xù)調(diào)用,后臺接口大部分的場景是用戶訪問一個(gè)頁面并停留在頁面上做一些操作,訪問一個(gè)頁面的時(shí)候?yàn)g覽器會加載多個(gè)資源,包括靜態(tài)資源 html,css,js 等,和接口的動態(tài)數(shù)據(jù),整個(gè)資源加載過程盡量保持在一秒內(nèi)完成,如果超過一秒的話系統(tǒng)體驗(yàn)性能較差,所以本地緩存一秒足夠了。
收獲總結(jié)
報(bào)警前:
1. 熟悉第三方 jar 包的工作原理,尤其是個(gè)人開發(fā)工具包,因?yàn)闆]有經(jīng)過市場檢驗(yàn)使用前要格外小心
2. 可以使用 jvisualvm 進(jìn)行本地壓測觀察 jvm 情況
3. 關(guān)注監(jiān)控報(bào)警,掌握監(jiān)控平臺操作,能夠從監(jiān)控中查詢系統(tǒng)各項(xiàng)指標(biāo)信息
4. 根據(jù)業(yè)務(wù)合理配置 JVM 參數(shù)和 Tomcat 參數(shù)
報(bào)警后:
1. 能夠第一時(shí)間抓取系統(tǒng)的 JVM 信息,比如堆棧,GC 信息,線程棧等
2. 通過使用 MAT 內(nèi)存輔助軟件幫助自己分析問題原因
審核編輯:劉清
-
存儲器
+關(guān)注
關(guān)注
38文章
7493瀏覽量
163873 -
JAVA語言
+關(guān)注
關(guān)注
0文章
138瀏覽量
20095 -
cms
+關(guān)注
關(guān)注
0文章
60瀏覽量
10981 -
緩存器
+關(guān)注
關(guān)注
0文章
63瀏覽量
11666 -
JVM
+關(guān)注
關(guān)注
0文章
158瀏覽量
12234
原文標(biāo)題:頻繁FullGC的原因竟然是 “開源代碼”?
文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論