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