JVM FULL GC 生產(chǎn)問(wèn)題筆記
故事的開(kāi)始
早晨 8 點(diǎn)多,同事給我發(fā)了一條消息。
“跑批程序很慢,負(fù)載過(guò)高,上午幫忙看一下。”
我一邊走路,一遍回復(fù)好的,整個(gè)人都是懵的,一方面是因?yàn)闆](méi)睡飽,另一方面是因?yàn)閷?duì)同事的程序一無(wú)所知。
而這,就是今天整個(gè)故事的開(kāi)始。
問(wèn)題的定位
到了公司,簡(jiǎn)單了解情況之后,開(kāi)始登陸機(jī)器,查看日志。
一看好家伙,最簡(jiǎn)單的一個(gè)請(qǐng)求 10S+,換做實(shí)時(shí)鏈路估計(jì)直接炸鍋了。
于是想到兩種可能:
(1)數(shù)據(jù)庫(kù)有慢 SQL,歸檔等嚴(yán)重影響性能的操作
(2)應(yīng)用 FULL GC
于是讓 DBA 幫忙定位是否有第一種情況的問(wèn)題,自己登陸機(jī)器看是否有 FULL GC。
初步的解決
十幾分鐘后,DBA 告訴我確實(shí)有慢 SQL,已經(jīng) kill 掉了。
GC 日志
不過(guò)查看 GC 日志的道路卻一點(diǎn)都不順利。
(1)發(fā)現(xiàn)應(yīng)用本身沒(méi)打印 gc log
(2)想使用 jstat 發(fā)現(xiàn) docker 用戶(hù)沒(méi)權(quán)限,醉了。
于是讓配管幫忙重新配置 jvm 參數(shù)加上 gc 日志,幸運(yùn)的是,這個(gè)程序?qū)儆谂芘绦?,可以隨時(shí)發(fā)布。
剩下的就等同事來(lái)了,下午驗(yàn)證一下即可。
FULL-GC 的源頭
慢的源頭
有了 GC 日志之后,很快就定位到慢是因?yàn)橐恢痹诎l(fā)生 full gc 導(dǎo)致的。
那么為什么會(huì)一直有 full gc 呢?
jvm 配置的調(diào)整
一開(kāi)始大家都以為是 jvm 的新生代配置的太小了,于是重新調(diào)整了 jvm 的參數(shù)配置。
結(jié)果很不幸,執(zhí)行不久之后還是會(huì)觸發(fā) full gc。
要定位 full gc 的源頭,只有開(kāi)始看代碼了。
代碼與需求
需求
首先說(shuō)一下應(yīng)用內(nèi)需要解決的問(wèn)題還是比較簡(jiǎn)單的。
把數(shù)據(jù)庫(kù)里的數(shù)據(jù)全部查出來(lái),依次執(zhí)行處理,不過(guò)有兩點(diǎn)需要注意:
(1)數(shù)據(jù)量相對(duì)較大,百萬(wàn)級(jí)
(2)單條數(shù)據(jù)處理比較慢,希望處理的盡可能快。
業(yè)務(wù)簡(jiǎn)化
為了便于大家理解,我們這里簡(jiǎn)化所有的業(yè)務(wù),使用最簡(jiǎn)單的 User 類(lèi)來(lái)模擬業(yè)務(wù)。
- User.java
基本的數(shù)據(jù)庫(kù)實(shí)體。
- /**
- * 用戶(hù)信息
- * @author binbin.hou
- * @since 1.0.0
- */
- public class User {
- private Integer id;
- public Integer getId() {
- return id;
- }
- public void setId(Integer id) {
- this.id = id;
- }
- @Override
- public String toString() {
- return "User{" +
- "id=" + id +
- '}';
- }
- }
- UserMapper.java
模擬數(shù)據(jù)庫(kù)查詢(xún)操作。
- public class UserMapper {
- // 總數(shù),可以根據(jù)實(shí)際調(diào)整為 100W+
- private static final int TOTAL = 100;
- public int count() {
- return TOTAL;
- }
- public List<User> selectAll() {
- return selectList(1, TOTAL);
- }
- public List<User> selectList(int pageNum, int pageSize) {
- List<User> list = new ArrayList<User>(pageSize);
- int start = (pageNum - 1) * pageSize;
- for (int i = start; i < start + pageSize; i++) {
- User user = new User();
- user.setId(i);
- list.add(user);
- }
- return list;
- }
- /**
- * 模擬用戶(hù)處理
- *
- * @param user 用戶(hù)
- */
- public void handle(User user) {
- try {
- // 模擬不同的耗時(shí)
- int id = user.getId();
- if(id % 2 == 0) {
- Thread.sleep(100);
- } else {
- Thread.sleep(200);
- }
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " " + user);
- }
- }
這里提供了幾個(gè)簡(jiǎn)單的方法,這里為了演示方便,將總數(shù)固定為 100。
- UserService.java
定義需要處理所有實(shí)體的一個(gè)接口。
- /**
- * 用戶(hù)服務(wù)接口
- * @author binbin.hou
- * @since 1.0.0
- */
- public interface UserService {
- /**
- * 處理所有的用戶(hù)
- */
- void handleAllUser();
- }
v1-全部加載到內(nèi)存
最簡(jiǎn)單粗暴的方式,就是把所有數(shù)據(jù)直接加載到內(nèi)存。
- public class UserServiceAll implements UserService {
- /**
- * 處理所有的用戶(hù)
- */
- public void handleAllUser() {
- UserMapper userMapper = new UserMapper();
- // 全部加載到內(nèi)存
- List<User> userList = userMapper.selectAll();
- for(User user : userList) {
- // 處理單個(gè)用戶(hù)
- userMapper.handle(user);
- }
- }
- }
這種方式非常的簡(jiǎn)單,容易理解。
不過(guò)缺點(diǎn)也比較大,數(shù)據(jù)量較大的時(shí)候會(huì)直接把內(nèi)存打爆。
我也嘗試了一下這種方式,應(yīng)用直接假死,所以不可行。
v2-分頁(yè)加載到內(nèi)存
既然不能一把加載,那我很自然的就想到分頁(yè)。
- /**
- * 分頁(yè)查詢(xún)
- * @author binbin.hou
- * @since 1.0.0
- */
- public class UserServicePage implements UserService {
- /**
- * 處理所有的用戶(hù)
- */
- public void handleAllUser() {
- UserMapper userMapper = new UserMapper();
- // 分頁(yè)查詢(xún)
- int total = userMapper.count();
- int pageSize = 10;
- int totalPage = total / pageSize;
- for(int i = 1; i <= totalPage; i++) {
- System.out.println("第" + i + " 頁(yè)查詢(xún)開(kāi)始");
- List<User> userList = userMapper.selectList(i, pageSize);
- for(User user : userList) {
- // 處理單個(gè)用戶(hù)
- userMapper.handle(user);
- }
- }
- }
- }
一般這樣處理也就夠了,不過(guò)因?yàn)橄胱非蟾斓奶幚硭俣龋率褂昧硕嗑€(xiàn)程,大概實(shí)現(xiàn)如下。
v3-分頁(yè)多線(xiàn)程
這里使用 Executor 線(xiàn)程池進(jìn)行單個(gè)數(shù)據(jù)的消費(fèi)處理。
主要注意點(diǎn)有兩個(gè)地方:
(1)使用 sublist 控制每一個(gè)線(xiàn)程處理的數(shù)據(jù)范圍
(2)使用 CountDownLatch 保證當(dāng)前頁(yè)處理完成后,才進(jìn)行到下一次分頁(yè)的查詢(xún)和處理。
- 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.List;
- import java.util.concurrent.CountDownLatch;
- import java.util.concurrent.Executor;
- import java.util.concurrent.Executors;
- /**
- * 分頁(yè)查詢(xún)多線(xiàn)程
- * @author binbin.hou
- * @since 1.0.0
- */
- public class UserServicePageExecutor implements UserService {
- private static final int THREAD_NUM = 5;
- private static final Executor EXECUTOR = Executors.newFixedThreadPool(THREAD_NUM);
- /**
- * 處理所有的用戶(hù)
- */
- public void handleAllUser() {
- UserMapper userMapper = new UserMapper();
- // 分頁(yè)查詢(xún)
- int total = userMapper.count();
- int pageSize = 10;
- int totalPage = total / pageSize;
- for(int i = 1; i <= totalPage; i++) {
- System.out.println("第 " + i + " 頁(yè)查詢(xún)開(kāi)始");
- List<User> userList = userMapper.selectList(i, pageSize);
- // 使用多線(xiàn)程處理
- int count = userList.size();
- int countPerThread = count / THREAD_NUM;
- // 通過(guò) CountDownLatch 保證當(dāng)前分頁(yè)執(zhí)行完成,才繼續(xù)下一個(gè)分頁(yè)的處理。
- CountDownLatch countDownLatch = new CountDownLatch(THREAD_NUM);
- for(int j = 0; j < THREAD_NUM; j++) {
- int startIndex = j * countPerThread;
- int endIndex = startIndex + countPerThread;
- // 最后一個(gè)
- if(j == THREAD_NUM - 1) {
- endIndex = count;
- }
- final int finalStartIndex = startIndex;
- final int finalEndIndex = endIndex;
- EXECUTOR.execute(()->{
- List<User> subList = userList.subList(finalStartIndex, finalEndIndex);
- handleList(subList);
- // countdown
- countDownLatch.countDown();
- });
- }
- try {
- countDownLatch.await();
- System.out.println("第 " + i + " 頁(yè)查詢(xún)?nèi)客瓿?quot;);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- private void handleList(List<User> userList) {
- UserMapper userMapper = new UserMapper();
- // 處理
- for(User user : userList) {
- // 處理單個(gè)用戶(hù)
- userMapper.handle(user);
- }
- }
- }
這個(gè)實(shí)現(xiàn)是有一點(diǎn)復(fù)雜,但是第一感覺(jué)還是沒(méi)啥問(wèn)題。
為什么就 full gc 了呢?
sublist 的坑
這里使用了 sublist 方法,性能很好,也達(dá)到了分割范圍的作用。
不過(guò)一開(kāi)始,我卻懷疑這里導(dǎo)致了內(nèi)存泄漏。
SubList 的源碼:
- private class SubList extends AbstractList<E> implements RandomAccess {
- private final AbstractList<E> parent;
- private final int parentOffset;
- private final int offset;
- int size;
- SubList(AbstractList<E> parent,
- int offset, int fromIndex, int toIndex) {
- this.parent = parent;
- this.parentOffset = fromIndex;
- this.offset = offset + fromIndex;
- this.size = toIndex - fromIndex;
- this.modCount = ArrayList.this.modCount;
- }
- }
可以看出SubList原理:
- 保存父ArrayList的引用;
- 通過(guò)計(jì)算offset和size表示subList在原始list的范圍;
由此可知,這種方式的subList保存對(duì)原始list的引用,而且是強(qiáng)引用,導(dǎo)致GC不能回收,故而導(dǎo)致內(nèi)存泄漏,當(dāng)程序運(yùn)行一段時(shí)間后,程序無(wú)法再申請(qǐng)內(nèi)存,拋出內(nèi)存溢出錯(cuò)誤。
解決思路是使用工具類(lèi)替代掉 sublist 方法,缺點(diǎn)是內(nèi)存占用會(huì)變多,比如:
- /**
- * @author binbin.hou
- * @since 1.0.0
- */
- public class ListUtils {
- @SuppressWarnings("all")
- public static List copyList(List list, int start, int end) {
- List results = new ArrayList();
- for(int i = start; i < end; i++) {
- results.add(list.get(i));
- }
- return results;
- }
- }
經(jīng)過(guò)實(shí)測(cè),發(fā)現(xiàn)并不是這個(gè)原因?qū)е碌?。orz
lambda 的坑
因?yàn)槭褂玫?jdk8,所以大家也就習(xí)慣性的使用 lambda 表達(dá)式。
- EXECUTOR.execute(()->{
- //...
- });
這里實(shí)際上是一個(gè)語(yǔ)法糖,會(huì)導(dǎo)致 executor 引用 sublist。
因?yàn)?executor 的生命周期是非常長(zhǎng)的,從而會(huì)讓 sublist 一直得不到釋放。
后來(lái)把代碼調(diào)整了如下,full gc 也確認(rèn)解決了。
v4-分頁(yè)多線(xiàn)程 Task
我們使用 Task,讓 sublist 放在 task 中去處理。
- public class UserServicePageExecutorTask implements UserService {
- private static final int THREAD_NUM = 5;
- private static final Executor EXECUTOR = Executors.newFixedThreadPool(THREAD_NUM);
- /**
- * 處理所有的用戶(hù)
- */
- public void handleAllUser() {
- UserMapper userMapper = new UserMapper();
- // 分頁(yè)查詢(xún)
- int total = userMapper.count();
- int pageSize = 10;
- int totalPage = total / pageSize;
- for(int i = 1; i <= totalPage; i++) {
- System.out.println("第 " + i + " 頁(yè)查詢(xún)開(kāi)始");
- List<User> userList = userMapper.selectList(i, pageSize);
- // 使用多線(xiàn)程處理
- int count = userList.size();
- int countPerThread = count / THREAD_NUM;
- // 通過(guò) CountDownLatch 保證當(dāng)前分頁(yè)執(zhí)行完成,才繼續(xù)下一個(gè)分頁(yè)的處理。
- CountDownLatch countDownLatch = new CountDownLatch(THREAD_NUM);
- for(int j = 0; j < THREAD_NUM; j++) {
- int startIndex = j * countPerThread;
- int endIndex = startIndex + countPerThread;
- // 最后一個(gè)
- if(j == THREAD_NUM - 1) {
- endIndex = count;
- }
- Task task = new Task(countDownLatch, userList, startIndex, endIndex);
- EXECUTOR.execute(task);
- }
- try {
- countDownLatch.await();
- System.out.println("第 " + i + " 頁(yè)查詢(xún)?nèi)客瓿?quot;);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- private void handleList(List<User> userList) {
- UserMapper userMapper = new UserMapper();
- // 處理
- for(User user : userList) {
- // 處理單個(gè)用戶(hù)
- userMapper.handle(user);
- }
- }
- private class Task implements Runnable {
- private final CountDownLatch countDownLatch;
- private final List<User> allList;
- private final int startIndex;
- private final int endIndex;
- private Task(CountDownLatch countDownLatch, List<User> allList, int startIndex, int endIndex) {
- this.countDownLatch = countDownLatch;
- this.allList = allList;
- this.startIndex = startIndex;
- this.endIndex = endIndex;
- }
- @Override
- public void run() {
- try {
- List<User> subList = allList.subList(startIndex, endIndex);
- handleList(subList);
- } catch (Exception exception) {
- exception.printStackTrace();
- } finally {
- countDownLatch.countDown();
- }
- }
- }
- }
我們這里做了一點(diǎn)上面沒(méi)有考慮到的點(diǎn),countDownLatch 可能無(wú)法被執(zhí)行,導(dǎo)致線(xiàn)程被卡主。
于是我們把 countDownLatch.countDown(); 放在 finally 中去執(zhí)行。
辛苦搞了大半天,按理說(shuō)到這里故事應(yīng)該就結(jié)束了,不過(guò)現(xiàn)實(shí)比理論更加夢(mèng)幻。
實(shí)際執(zhí)行的時(shí)候,這個(gè)程序總是會(huì)卡主一段時(shí)間,導(dǎo)致整體的效果很差,還沒(méi)有不適用多線(xiàn)程的效果好。
和其他同事溝通了一下,還是建議使用 生產(chǎn)-消費(fèi)者 模式去實(shí)現(xiàn)比較好,原因如下:
(1)實(shí)現(xiàn)相對(duì)簡(jiǎn)單,不會(huì)產(chǎn)生奇奇怪怪的 BUG
(2)相對(duì)于 countDownLatch 的強(qiáng)制等待,生產(chǎn)-消費(fèi)者模式可以做到基本無(wú)鎖,性能更好。
于是,我晚上就花時(shí)間寫(xiě)了一個(gè)簡(jiǎn)單的 demo。
v5-生產(chǎn)消費(fèi)者模式
這里我們使用 ArrayBlockingQueue 作為阻塞隊(duì)列,也就是消息的存儲(chǔ)媒介。
當(dāng)然,你也可以使用公司的 mq 中間件來(lái)實(shí)現(xiàn)類(lèi)似的效果。
- 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.List;
- import java.util.concurrent.*;
- /**
- * 分頁(yè)查詢(xún)-生產(chǎn)消費(fèi)
- * @author binbin.hou
- * @since 1.0.0
- */
- public class UserServicePageQueue implements UserService {
- // 分頁(yè)大小
- private final int pageSize = 10;
- private static final int THREAD_NUM = 5;
- private final Executor executor = Executors.newFixedThreadPool(THREAD_NUM);
- private final ArrayBlockingQueue<User> queue = new ArrayBlockingQueue<>(2 * pageSize, true);
- // 模擬注入
- private UserMapper userMapper = new UserMapper();
- // 消費(fèi)線(xiàn)程任務(wù)
- public class ConsumerTask implements Runnable {
- @Override
- public void run() {
- while (true) {
- try {
- // 會(huì)阻塞直到獲取到元素
- User user = queue.take();
- userMapper.handle(user);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
- // 初始化消費(fèi)者進(jìn)程
- // 啟動(dòng)五個(gè)進(jìn)程去處理
- private void startConsumer() {
- for(int i = 0; i < THREAD_NUM; i++) {
- ConsumerTask task = new ConsumerTask();
- executor.execute(task);
- }
- }
- /**
- * 處理所有的用戶(hù)
- */
- public void handleAllUser() {
- // 啟動(dòng)消費(fèi)者
- startConsumer();
- // 分頁(yè)查詢(xún)
- int total = userMapper.count();
- int pageSize = 10;
- int totalPage = total / pageSize;
- for(int i = 1; i <= totalPage; i++) {
- // 等待消費(fèi)者處理已有的信息
- awaitQueue(pageSize);
- System.out.println("第 " + i + " 頁(yè)查詢(xún)開(kāi)始");
- List<User> userList = userMapper.selectList(i, pageSize);
- // 直接往隊(duì)列里面扔
- queue.addAll(userList);
- System.out.println("第 " + i + " 頁(yè)查詢(xún)?nèi)客瓿?quot;);
- }
- }
- /**
- * 等待,直到 queue 的小于等于 limit,才進(jìn)行生產(chǎn)處理
- *
- * 首先判斷隊(duì)列的大小,可以調(diào)整為0的時(shí)候,才查詢(xún)。
- * 不過(guò)因?yàn)椴樵?xún)也比較耗時(shí),所以可以調(diào)整為小于 pageSize 的時(shí)候就可以準(zhǔn)備查詢(xún)
- * 從而保障消費(fèi)者不會(huì)等待太久
- * @param limit 限制
- */
- private void awaitQueue(int limit) {
- while (true) {
- // 獲取阻塞隊(duì)列的大小
- int size = queue.size();
- if(size >= limit) {
- try {
- System.out.println("當(dāng)前大?。?quot; + size + ", 限制大小: " + limit);
- // 根據(jù)實(shí)際的情況進(jìn)行調(diào)整
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- } else {
- break;
- }
- }
- }
- }
整體的實(shí)現(xiàn)確實(shí)簡(jiǎn)單很多,因?yàn)椴樵?xún)比處理一般要快,所以往隊(duì)列中添加元素時(shí),這里進(jìn)行了等待。
當(dāng)然可以根據(jù)你的實(shí)際業(yè)務(wù)進(jìn)行調(diào)整等待時(shí)間等。
這里保證小于等于 pageSize 時(shí)才插入新的元素,保證不超過(guò)隊(duì)列的總長(zhǎng)度,同時(shí)盡可能的讓消費(fèi)者不會(huì)進(jìn)入空閑等待狀態(tài)。
小結(jié)
總的來(lái)說(shuō),造成 full gc 的原因一般都是內(nèi)存泄漏。
GC 日志真的很重要,遇到問(wèn)題一定要記得添加上,這樣才能更好的分析解決問(wèn)題。
很多技術(shù)知識(shí),我們以為熟悉了,往往還是存在不少坑。
要永遠(yuǎn)記得如無(wú)必要,勿增實(shí)體。