JVM FULL GC 生產(chǎn)問題 II-如何定位內(nèi)存泄露?
情景回顧
我們在上一篇 JVM FULL GC 生產(chǎn)問題筆記 中提出了如何更好的實現(xiàn)一個多線程消費的實現(xiàn)方式。
沒有看過的小伙伴建議看一下。
本來以為一切都可以結(jié)束的,不過又發(fā)生了一點點意外,這里記錄一下,避免自己和小伙伴們踩坑。
生產(chǎn)-消費者模式
簡介
上一節(jié)中我們嘗試了多種多線程方案,總會有各種各樣奇怪的問題。
于是最后決定使用生產(chǎn)-消費者模式去實現(xiàn)。
實現(xiàn)如下:
這里使用 AtomicLong 做了一個簡單的計數(shù)。
userMapper.handle2(Arrays.asList(user)); 這個方法是同事以前的方法,當(dāng)然做了很多簡化。
就沒有修改,入?yún)⑹且粋€列表。這里為了兼容,使用 Arrays.asList() 簡單封裝了一下。
- import com.github.houbb.thread.demo.dal.entity.User;
- import com.github.houbb.thread.demo.dal.mapper.UserMapper;
- import com.github.houbb.thread.demo.service.UserService;
- import java.util.Arrays;
- import java.util.List;
- import java.util.concurrent.*;
- import java.util.concurrent.atomic.AtomicLong;
- /**
- * 分頁查詢
- * @author binbin.hou
- * @since 1.0.0
- */
- public class UserServicePageQueue implements UserService {
- // 分頁大小
- private final int pageSize = 10000;
- private static final int THREAD_NUM = 20;
- private final Executor executor = Executors.newFixedThreadPool(THREAD_NUM);
- private final ArrayBlockingQueue<User> queue = new ArrayBlockingQueue<>(2 * pageSize, true);
- // 模擬注入
- private UserMapper userMapper = new UserMapper();
- /**
- * 計算總數(shù)
- */
- private AtomicLong counter = new AtomicLong(0);
- // 消費線程任務(wù)
- public class ConsumerTask implements Runnable {
- @Override
- public void run() {
- while (true) {
- try {
- // 會阻塞直到獲取到元素
- User user = queue.take();
- userMapper.handle2(Arrays.asList(user));
- long count = counter.incrementAndGet();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
- // 初始化消費者進程
- // 啟動五個進程去處理
- private void startConsumer() {
- for(int i = 0; i < THREAD_NUM; i++) {
- ConsumerTask task = new ConsumerTask();
- executor.execute(task);
- }
- }
- /**
- * 處理所有的用戶
- */
- public void handleAllUser() {
- // 啟動消費者
- startConsumer();
- // 充值計數(shù)器
- counter = new AtomicLong(0);
- // 分頁查詢
- int total = userMapper.count();
- int totalPage = total / pageSize;
- for(int i = 1; i <= totalPage; i++) {
- // 等待消費者處理已有的信息
- awaitQueue(pageSize);
- System.out.println(UserMapper.currentTime() + " 第 " + i + " 頁查詢開始");
- List<User> userList = userMapper.selectList(i, pageSize);
- // 直接往隊列里面扔
- queue.addAll(userList);
- System.out.println(UserMapper.currentTime() + " 第 " + i + " 頁查詢?nèi)客瓿?quot;);
- }
- }
- /**
- * 等待,直到 queue 的小于等于 limit,才進行生產(chǎn)處理
- *
- * 首先判斷隊列的大小,可以調(diào)整為0的時候,才查詢。
- * 不過因為查詢也比較耗時,所以可以調(diào)整為小于 pageSize 的時候就可以準備查詢
- * 從而保障消費者不會等待太久
- * @param limit 限制
- */
- private void awaitQueue(int limit) {
- while (true) {
- // 獲取阻塞隊列的大小
- int size = queue.size();
- if(size >= limit) {
- try {
- // 根據(jù)實際的情況進行調(diào)整
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- } else {
- break;
- }
- }
- }
- }
測試驗證
當(dāng)然這個方法在集成環(huán)境跑沒有任何的問題。
于是就開始直接上生產(chǎn)驗證,結(jié)果開始很快,然后就可以變慢了。
一看 GC 日志,梅開二度,F(xiàn)ULL GC。
可惡,圣斗士竟然會被同一招打敗 2 次嗎?
FULL GC 的產(chǎn)生
一般要發(fā)現(xiàn) full gc,最直觀的感受就是程序很慢。
這時候你就需要添加一下 GC 日志打印,看一下是否有 full gc 即可。
這個最坑的地方就在于,性能問題是測試一般無法驗證的,除非你進行壓測。
壓測還要同時滿足兩個條件:
(1)數(shù)據(jù)量足夠大,或者說 QPS 足夠高。持續(xù)壓
(2)資源足夠少,也就是還想馬兒跑,還想馬兒不吃草。
好巧不巧,我們同時趕上了兩點。
那么問題又來了,如何定位為什么 FULL GC 呢?
內(nèi)存泄露
程序變慢并不是一開始就慢,而是開始很快,然后變慢,接著就是不停的 FULL GC。
這就和自然的想到是內(nèi)存泄露。
如何定位內(nèi)存泄露呢?
你可以分成下面幾步:
(1)看代碼,是否有明顯存在內(nèi)存泄露的地方。然后修改驗證。如果無法解決,則找出可能存在問題的地方,執(zhí)行第二步。
(2)把 FULL GC 時的堆棧信息 dump 下來,分析到底是什么數(shù)據(jù)過大,然后結(jié)合 1 去解決。
接下來,讓我們一起看一下這個過程的簡化版本記錄。
問題定位
看代碼
最基本的生產(chǎn)者-消費者模式確認了即便,感覺沒啥問題。
于是就要看一下消費者模式中調(diào)用其他人的方法問題。
方法的核心目的
(1)遍歷入?yún)⒘斜?,?zhí)行業(yè)務(wù)處理。
(2)把當(dāng)前批次的處理結(jié)果寫入到文件中。
方法實現(xiàn)
簡化版本如下:
- /**
- * 模擬用戶處理
- *
- * @param userList 用戶列表
- */
- public void handle2(List<User> userList) {
- String targetDir = "D:\\data\\";
- // 理論讓每一個線程只讀寫屬于自己的文件
- String fileName = Thread.currentThread().getName()+".txt";
- String fullFileName = targetDir + fileName;
- FileWriter fileWriter = null;
- BufferedWriter bufferedWriter = null;
- User userExample;
- try {
- fileWriter = new FileWriter(fullFileName);
- bufferedWriter = new BufferedWriter(fileWriter);
- StringBuffer stringBuffer = null;
- for(User user : userList) {
- stringBuffer = new StringBuffer();
- // 業(yè)務(wù)邏輯
- userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- List<User> userCountList = queryUserList(userExample);
- if(userCountList != null && userCountList.size() > 0) {
- return;
- }
- // 其他處理邏輯
- // 記錄最后的結(jié)果
- stringBuffer.append("用戶")
- .append(user.getId())
- .append("同步結(jié)果完成");
- bufferedWriter.newLine();
- bufferedWriter.write(stringBuffer.toString());
- }
- // 處理結(jié)果寫入到文件中
- bufferedWriter.newLine();
- bufferedWriter.flush();
- bufferedWriter.close();
- fileWriter.close();
- } catch (Exception exception) {
- exception.printStackTrace();
- } finally {
- try {
- if (null != bufferedWriter) {
- bufferedWriter.close();
- }
- if (null != fileWriter) {
- fileWriter.close();
- }
- } catch (Exception e) {
- }
- }
- }
這種代碼怎么說呢,大概就是祖?zhèn)鞔a吧,不曉得大家有沒有見過,或者寫過呢?
我們可以不看文件部分,核心部分實際上只有:
- User userExample;
- for(User user : userList) {
- // 業(yè)務(wù)邏輯
- userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- List<User> userCountList = queryUserList(userExample);
- if(userCountList != null && userCountList.size() > 0) {
- return;
- }
- // 其他處理邏輯
- }
代碼存在的問題
你覺得上面的代碼有哪些問題?
什么地方可能存在內(nèi)存泄露呢?
有應(yīng)該如何改進呢?
看堆棧
如果你看代碼已經(jīng)確定了疑惑的地方,那么接下來就是去看一下堆棧,驗證下自己的猜想。
堆棧的查看方式
jvm 堆棧查看的方式很多,我們這里以 jmap 命令為例。
(1)找到 java 進程的 pid
你可以執(zhí)行 jps 或者 ps ux 等,選擇一個你喜歡的。
我們 windows 本地測試了下(實際生產(chǎn)一般是 linux 系統(tǒng)):
- D:\Program Files\Java\jdk1.8.0_192\bin>jps
- 11168 Jps
- 3440 RemoteMavenServer36
- 4512
- 11660 Launcher
- 11964 UserServicePageQueue
UserServicePageQueue 是我們執(zhí)行的測試程序,所以 pid 是 11964
(2)執(zhí)行 jmap 獲取堆棧信息
命令:
- jmap -histo 11964
效果如下:
- D:\Program Files\Java\jdk1.8.0_192\bin>jmap -histo 11964
- num #instances #bytes class name
- ----------------------------------------------
- 1: 161031 20851264 [C
- 2: 157949 3790776 java.lang.String
- 3: 1709 3699696 [B
- 4: 3472 3688440 [I
- 5: 139358 3344592 com.github.houbb.thread.demo.dal.entity.User
- 6: 139614 2233824 java.lang.Integer
- 7: 12716 508640 java.io.FileDescriptor
- 8: 12714 406848 java.io.FileOutputStream
- 9: 7122 284880 java.lang.ref.Finalizer
- 10: 12875 206000 java.lang.Object
- ...
當(dāng)然下面還有很多,你可以使用 head 命令過濾。
當(dāng)然,如果服務(wù)器不支持這個命令,你可以把堆棧信息輸出到文件中:
- jmap -histo 11964 >> dump.txt
堆棧分析
我們可以很明顯發(fā)現(xiàn)不合理的地方:
[C 這里指的是 chars,有 161031。
String 是字符串,有 157949。
當(dāng)然還有 User 對象,有 139358。
我們每一次分頁是 1W 個,queue 中最多是 19999 個,這么多對象顯然不合理。
代碼中的問題
chars 和 String 為什么這么多
代碼給人的第一感受,就是和業(yè)務(wù)邏輯沒啥關(guān)系的寫文件了。
很多小伙伴肯定想到了可以使用 TWR 簡化一下代碼,不過這里存在兩個問題:
(1)最后文件中能記錄所有的執(zhí)行結(jié)果嗎?
(2)有沒有更好的方式呢?
對于問題1,答案是不能。雖然我們?yōu)槊恳粋€線程創(chuàng)建一個文件,但是實際測試,發(fā)現(xiàn)文件會被覆蓋。
實際上比起我們自己寫文件,更應(yīng)該使用 log 去記錄結(jié)果,這樣更加優(yōu)雅。
于是,最后把代碼簡化如下:
- //日志
- User userExample;
- for(User user : userList) {
- // 業(yè)務(wù)邏輯
- userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- List<User> userCountList = queryUserList(userExample);
- if(userCountList != null && userCountList.size() > 0) {
- // 日志
- return;
- }
- // 其他處理邏輯
- // 日志記錄結(jié)果
- }
user 對象為什么這里多?
我們看一下核心業(yè)務(wù)代碼:
- User userExample;
- for(User user : userList) {
- // 業(yè)務(wù)邏輯
- userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- List<User> userCountList = queryUserList(userExample);
- if(userCountList != null && userCountList.size() > 0) {
- return;
- }
- // 其他處理邏輯
- }
這里在判斷是否存在的時候構(gòu)建了一個 mybatis 中常用的 User 查詢條件,然后判斷查詢的列表大小。
這里有兩個問題:
(1)判斷是否存在,最好使用 count,而不是判斷列表結(jié)果大小。
(2)User userExample 的作用域盡量小一點。
調(diào)整如下:
- for(User user : userList) {
- // 業(yè)務(wù)邏輯
- User userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- int count = selectCount(userExample);
- if(count > 0) {
- return;
- }
- // 其他業(yè)務(wù)邏輯
- }
調(diào)整之后的代碼
這里的 System.out.println 實際使用時用 log 替代,這里只是為了演示。
- /**
- * 模擬用戶處理
- *
- * @param userList 用戶列表
- */
- public void handle3(List<User> userList) {
- System.out.println("入?yún)ⅲ?quot; + userList);
- for(User user : userList) {
- // 業(yè)務(wù)邏輯
- User userExample = new User();
- userExample.setId(user.getId());
- // 如果查詢到的結(jié)果已存在,則跳過處理
- int count = selectCount(userExample);
- if(count > 0) {
- System.out.println("如果查詢到的結(jié)果已存在,則跳過處理");
- continue;
- }
- // 其他業(yè)務(wù)邏輯
- System.out.println("業(yè)務(wù)邏輯處理結(jié)果");
- }
- }
生產(chǎn)驗證
全部改完之后,重新部署驗證,一切順利。
希望不會有第三篇。:)
小結(jié)
當(dāng)然驗證的過程中還發(fā)生過一點小插曲,比如開發(fā)沒有權(quán)限看堆棧信息,執(zhí)行命令時程序已經(jīng)假死等等。
生產(chǎn) full gc 是一個比較麻煩的問題,一個是難以復(fù)現(xiàn),另一個是如果是偶發(fā)性的,又是實時鏈路,可能也不好執(zhí)行 dump 命令。
所以寫代碼還是寫的盡可能簡單的好,不然會有各種問題。
能復(fù)用已有的工具、中間件盡量復(fù)用。
這樣看來,我們自己寫的生產(chǎn)-消費者模式也不太好,因為復(fù)用性不強,所以建議使用公司已有的 mq 工具,不過如何選擇,還是看具體的業(yè)務(wù)場景。
架構(gòu),就是權(quán)衡。
希望本文對你有所幫助!