背景:
? ? ? ? 某業務系統中,同一天產生多次excel導出請求,excel數據需要通過查表獲取,由于數據量過大,導致了OutOfMemoryError
? ? ? ? 事先在服務啟動腳本中已設置OOM異常觸發堆快照參數及GC詳情打印參數:-XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -XX:+HeapDumpOnOutOfMemoryError,已知為該服務環境分配的jvm內存為6G,所以在事故發生后,我們可以得到gc.log和heapDump.hprof文件來進行分析
堆快照分析:
通過MAT(eclipse memory analyser)分析堆快照
線程http-bio-8443-exec-2的Retained Heap,即持有資源約為3.5g左右,加上jdbc結果集對象、數據庫連接池對象等,占據6g左右空間,而JVM最大空間為6g,所以OOM也是情理之中
到這里我們得到的線索是:2號線程及數據查詢相關對象均持有非常多的資源,那么這個資源具體對應什么內容,我們查看mat持有樹便可發現
線程中存在著海量的CmcCustomer對象,每個對象占用1到2K空間,mat還為我們提供了OOM時的一些日志信息,我們可以查找一下什么方法創建了這么多對象
CmcCustomer對象為cmc下的一個pojo,那么我們可以初步推斷:exportExcel方法需要返回一個excel,excel所需的數據需要在數據庫中查詢業務所需的CmcCustomer表數據,但負責此請求的2號線程持有的該對象量過大,導致在該方法執行過程中需要分配更多空間時,沒有了足夠空間可以分配,導致了OutOfMemoryError
至此,我們通過堆快照分析,得到了"exportExcel方法引起OOM"的初步結論,那么這個方法就是OOM的罪魁禍首嗎?現場還有一條gc.log的線索,我們可以通過GC日志來分析OOM的形成原因。
GC日志分析:
在實時打印的gc日志中,我們先分析一段事故發生前的gc信息
{Heap before GC invocations=6425 (full 29):
par new generation? total 699072K, used 358594K [0x0000000660000000, 0x00000006a0000000, 0x00000006a0000000)
eden space 349568K, 100% used [0x0000000660000000, 0x0000000675560000, 0x0000000675560000)
from space 349504K,? 2% used [0x0000000675560000, 0x0000000675e30aa0, 0x000000068aab0000)
to? space 349504K,? 0% used [0x000000068aab0000, 0x000000068aab0000, 0x00000006a0000000)
137826.671: [GC137826.671: [ParNew: 358594K->9042K(699072K), 0.0174300 secs] 3006268K->2656717K(5941952K), 0.0176250 secs] [Times: user=0.05 sys=0.01, real=0.02 secs]
Heap after GC invocations=6426 (full 29):
par new generation? total 699072K, used 9042K [0x0000000660000000, 0x00000006a0000000, 0x00000006a0000000)
eden space 349568K,? 0% used [0x0000000660000000, 0x0000000660000000, 0x0000000675560000)
from space 349504K,? 2% used [0x000000068aab0000, 0x000000068b384950, 0x00000006a0000000)
to? space 349504K,? 0% used [0x0000000675560000, 0x0000000675560000, 0x000000068aab0000)
concurrent mark-sweep generation total 5242880K, used 2647675K [0x00000006a0000000, 0x00000007e0000000, 0x00000007e0000000)
concurrent-mark-sweep perm gen total 524288K, used 75286K [0x00000007e0000000, 0x0000000800000000, 0x0000000800000000)
}
? ? ? ? 這是一次gc,下一次gc與其間隔500s,均是新生代并行回收,新生代分配了1G空間,eden區達到350M左右,survivor區空間均充足,回收后新生代僅剩余9M左右空間,效果明顯,老年代分配了5G空間,使用空間保持在2.6G左右,永久代空間更加充裕,500M空間僅使用70M左右。
? ? ? ?接下來我們定位到excel的導出方法
2017-08-02 18:22:40,141 INFO? [jeesite.modules.cmc.web.CmcCustomerController] - 導出指定時間段內的數據到Excel
2017-08-02 18:22:40,142 DEBUG [modules.cmc.dao.CmcCustomerDao.getExcelCmcList] - ==>? Preparing: SELECT 篇幅關系此處省略查詢字段 WHERE a.del_flag = ? AND a.create_date > concat(?," ","00:00:00") AND a.create_date < concat(?," ","23:59:59") ORDER BY a.pid DESC
2017-08-02 18:22:40,142 DEBUG [modules.cmc.dao.CmcCustomerDao.getExcelCmcList] - ==> Parameters: 0(String), 2017-04-17(String), 2017-07-31(String)
=>18:22分,查詢了2017-04-17至2017-07-31的CmcCustomer數據,開始后,gc日志如下
{Heap before GC invocations=1454 (full 192):
par new generation? total 699072K, used 699071K [0x0000000660000000, 0x00000006a0000000, 0x00000006a0000000)
eden space 349568K, 100% used [0x0000000660000000, 0x0000000675560000, 0x0000000675560000)
from space 349504K,? 99% used [0x0000000675560000, 0x000000068aaaffe8, 0x000000068aab0000)
to? space 349504K,? 0% used [0x000000068aab0000, 0x000000068aab0000, 0x00000006a0000000)
202585.700: [Full GC202585.700: [CMS202589.173: [CMS-concurrent-mark: 3.507/3.509 secs] [Times: user=3.56 sys=0.00, real=3.51 secs]
(concurrent mode failure): 5242879K->5242879K(5242880K), 14.3420460 secs] 5941951K->5936395K(5941952K), [CMS Perm : 74280K->74274K(524288K)], 14.3421470 secs] [Times: user=14.35 sys=0.00, real=14.34 secs]
Heap after GC invocations=1455 (full 193):
par new generation? total 699072K, used 693515K [0x0000000660000000, 0x00000006a0000000, 0x00000006a0000000)
eden space 349568K,? 99% used [0x0000000660000000, 0x000000067555ffb8, 0x0000000675560000)
from space 349504K,? 98% used [0x0000000675560000, 0x000000068a542fe8, 0x000000068aab0000)
to? space 349504K,? 0% used [0x000000068aab0000, 0x000000068aab0000, 0x00000006a0000000)
concurrent mark-sweep generation total 5242880K, used 5242879K [0x00000006a0000000, 0x00000007e0000000, 0x00000007e0000000)
concurrent-mark-sweep perm gen total 524288K, used 74274K [0x00000007e0000000, 0x0000000800000000, 0x0000000800000000)
}
202600.077: [Full GC202600.077: [CMS: 5242879K->5242879K(5242880K), 8.9369500 secs] 5941951K->5936519K(5941952K), [CMS Perm : 74277K->74277K(524288K)], 8.9370540 secs] [Times: user=8.95 sys=0.00, real=8.93 secs]
我們可以發現:
1.兩次gc間隔時間僅為5秒鐘
2.gc發生時,eden區、survivor區均爆滿,而且回收后僅回收了1%
3.老年代空間未見減少,停留在5242879K
4.cms收集器發生了concurrent mode failure-并發模式失敗(發生的原因一般是CMS正在進行,但是由于老年代空間內存不足,需要盡快回收老年代里死的java對象,這個時候需要觸發full gc,停止所有的java線程,同時終止CMS)。
篇幅原因在此不列出后續所有gc日志,本次gc是第192次,但是在OutOfMemory發生時,gc執行到了第597次,并且均為"cms回收->被阻斷concurrent mode failure->full gc"的循環,而且老年代始終未見減少,直到最后一次請求分配空間,并且沒有空間可分配,JVM也無法再回收,在這種情況下產生了內存溢出。
到這里,結論慢慢清晰,exportExcel方法產生太多的對象,占滿了新生代、老年代空間,JVM也無法回收更多對象。但是有一點不知道有沒有引起大家的好奇——老年代是什么時候變的這么大了?在之前正常回收的日志中,老年代使用空間只有2.6g左右,即使峰值也不到4g,就被JVM回收掉了,而現在使用空間已經到了閾值。下面這個圖模擬了老年代已使用空間隨gc次數的變動
導出excel動作在事故發生日發生多次,但時間范圍為當天的查詢請求中,數據量不小,但是JVM還是可以支撐,只是新生代gc頻繁,效果如上圖(左)所示;但發生OOM的那次請求中,時間起止范圍在100天左右,數據量非常之大,新生代不斷產生海量對象,survivor區瞬間達到最大值,eden區跳過survivor區進入老年代的對象非常多,即使JVM仍在gc,但是回收的效率低于對象增長的速度,那么就會產生上圖(右)的現象,CMS收集器無法進行,full gc強制進行,系統停頓時間暴增,直至新生代、老年代占用空間均達到峰值,JVM再無可分配空間,游戲結束。
總結:
? ? ? 通過堆快照、GC日志的分析,我們得到了最終的結論:問題確實出在excel導出方法,這個方法在內存中一次性加載的數據量太過龐大,雖然不是立即發生GC,但是新對象的不斷堆積最終還是壓垮JVM發生了內存溢出,這是一種典型的內存溢出問題。
一、查詢開始后,對象的創建過程,
解決:
1.分配更大的JVM內存空間,但不推薦,目前系統流量不大,擴大空間只是緩兵之計,假如后期推廣開始,100個人同時導出excel,總不能擴大100倍的內存,這會造成資源的浪費
2.優化業務流程,每日凌晨定時生成當日excel數據,用戶若有需求,可以異步推送到其用戶空間,但這種方式等于修改了產品層面內容,代價比較大
3.將導出excel改為寫入csv,這樣就通過流輸出解決了創建超大excel的問題,用戶可以通過excel打開csv數據;而由于是流輸出,我們也可以利用游標的思想,進行流式讀取,如利用MyBatisCursorItemReader:
static void testCursor1() throws UnexpectedInputException, ParseException, Exception { ? ? ? ??
? ? ?try {
? ? ? ? ? ? Mapparam = new HashMap();
? ? ? ? ? ? AccsDeviceInfoDAOExample accsDeviceInfoDAOExample = new ? ? ? ?
? ? ? ? ? ? ? ? ? ? AccsDeviceInfoDAOExample();
? ? ? ? ? ? accsDeviceInfoDAOExample.createCriteria().
? ? ? ? ? ? ? ? ? ? andAppKeyEqualTo("12345").andAppVersionEqualTo("5.7.2.4.5"). ? ?
? ? ? ? ? ? ? ? ? ? andPackageNameEqualTo("com.test.zlx");
? ? ? ? ? ? ?param.put("oredCriteria", accsDeviceInfoDAOExample.getOredCriteria()); // 設置參數
? ? ? ? ? ? ?myMyBatisCursorItemReader.setParameterValues(param); // 創建游標
? ? ? ? ? ? ?myMyBatisCursorItemReader.open(new ExecutionContext()); //使用游標迭代獲取每個記錄
? ? ? ? ? ? ?Long count = 0L;
? ? ? ? ? ? ?AccsDeviceInfoDAO accsDeviceInfoDAO;
? ? ? ? ? ? ?while ((accsDeviceInfoDAO = myMyBatisCursorItemReader.read()) != null) {
? ? ? ? ? ? ? ? ? ?System.out.println(JSON.toJSONString(accsDeviceInfoDAO));
? ? ? ? ? ? ? ? ? ?++count;
? ? ? ? ? ? ? ? ? ?System.out.println(count);
? ? ? ? ? ? }
? ? ?} catch (Exception e) {
? ? ? ? ? ? System.out.println("error:" + e.getLocalizedMessage());
? ? ?} finally {? ? ? ? ? ? // do some
? ? ? ? ? ? myMyBatisCursorItemReader.close();
? ? ?}
}
MyBatisCursorItemReader注入:
<bean id="myMyBatisCursorItemReader" ? ? ? ? ?class="org.mybatis.spring.batch.MyBatisCursorItemReader">
<property name="sqlSessionFactory" ref="sqlSessionFactory" />
<property name="queryId"?value="com.taobao.accs.mass.petadata.dal.sqlmap.AccsDeviceInfoDAOMapper.selectByExampleForPetaData" />
</bean>
mapper.xml中再手動為sql加入fetchSize=-2147483648來配置JDBC的流式抓取,對這個配置感興趣的同學可以參考此文章, 這種方式返回了操作ResultSet的游標讓用戶自己迭代獲取數據,我們也可以使用Mybatis的ResultHandler,內部直接操作ResultSet逐條獲取數據并調用回調handler的handleResult方法進行處理
static void testCursor2() {? ??
? ? ? SqlSession session = sqlSessionFactory.openSession();? ? Mapparam = new HashMap();
? ? ? AccsDeviceInfoDAOExample accsDeviceInfoDAOExample = new AccsDeviceInfoDAOExample();
? ? ? accsDeviceInfoDAOExample.createCriteria().andAppKeyEqualTo("12345").
? ? ? andAppVersionEqualTo("1.2.3.4").andPackageNameEqualTo("com.hello.test");
? ? ? param.put("oredCriteria", accsDeviceInfoDAOExample.getOredCriteria());
? ? ? session.select("com.taobao.accs.mass.petadata.dal.sqlmap.
? ? ? AccsDeviceInfoDAOMapper.selectByExampleForPetaData", param, new ? ? ? ? ?
? ? ? ? ? ? ? ResultHandler() {
? ? ? ? ? ? ? ? ? ? ? @Override? ? ? ? ? ? ? ??
? ? ? ? ? ? ? ? ? ? ? ?public void handleResult(ResultContext resultContext) {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? AccsDeviceInfoDAO accsDeviceInfoDAO = (AccsDeviceInfoDAO) ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? resultContext.getResultObject();
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?System.out.println(resultContext.getResultCount());
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?System.out.println(JSON.toJSONString(accsDeviceInfoDAO));
? ? ? ? ? ? ? ? ? ? ? ?}
? ? ? ? ? ? ? ?}
? ? ? ?);
}
本次事故復盤結束,解決方案不盡完善,希望對遇到類似問題的同學有所幫助。