又一個頻繁FullGC的案例
將使用者已安裝APP資料從MySQL中遷移到MongoDB中。MySQL中儲存方式比較簡單,每個使用者每個已安裝的APP一行記錄,且資料模型對應AppFromMySQL。遷移到MongoDB中,我們想更好的利用MongoDB的優勢,所以其對應的資料模型為UserAppMongo,如果用JSON表示則如下所示:
{ "id": "201811040001", "userId": "12", "appMongoList": [{ "appName": "支付寶", "packageName": "com.alipay", "iconUrl": "http://s3.domain.com/12/12/com.alipay.jpg" }, { "appName": "淘寶", "packageName": "com.alibaba.taobao", "iconUrl": "http://s3.domain.com/12/12/com.alibaba.taobao.jpg" } ] }
問題重現
按照慣例,為了方便重現問題,將程式碼濃縮一下:
class AppMongo { private String appName; private String packageName; private int versionCode; private Date installTime; private String iconUrl; private String downloadUrl; private String remark; private Long size; private String developer; } // 需要儲存到MongoDB中的使用者已安裝app資訊,這樣儲存的好處就是MongoDB中installed_apps這張表的user_id能設定唯一鍵約束,查詢效能相比RDBMS中資料平鋪要高不少 class UserAppMongo { private String id; private Long userId; private List<AppMongo> appMongoList; } // 關係型資料庫中使用者已安裝app class AppFromMySQL { private int id; private Long userId; private String packageName; private int versionCode; private Date installTime; private String appName; private String iconUrl; private String downloadUrl; private String remark; private Long size; private String developer; } public class FullGCSample { public static void main(String[] args) throws Exception{ for (int pageNo = 0; pageNo < 10000; pageNo++) { List<Long> userList = getUserIdByPage(pageNo); List<UserAppMongo> userAppMongoList = new ArrayList<>(userList.size()); for (Long userId:userList){ List<AppFromMySQL> appFromMySQLList = getUserInstalledAppList(userId); UserAppMongo userAppMongo = new UserAppMongo(); userAppMongo.setId(System.nanoTime()+"");//測試程式碼任意模擬一個偽唯一ID userAppMongo.setUserId(userId); userAppMongo.setAppMongoList(appFromMySQL2AppMongo(appFromMySQLList)); userAppMongoList.add(userAppMongo); } // save List<UserAppMongo> to mongodb save2MongoDB(userAppMongoList); } } private static void save2MongoDB(List<UserAppMongo> userAppMongoList) throws Exception { // 模擬儲存一次資料到mongodb中要5ms Thread.sleep(5); } private static List<AppMongo> appFromMySQL2AppMongo(List<AppFromMySQL> list){ List<AppMongo> appMongoList = new ArrayList<>(); for (AppFromMySQL app:list){ AppMongo appMongo = new AppMongo(); //TODO bean copy appMongoList.add(appMongo); } return appMongoList; } private static List<AppFromMySQL> getUserInstalledAppList(Long useId){ List<AppFromMySQL> appFromMySQLList = new ArrayList<>(); // 假設使用者手機上安裝的app數量在50~200之間 int size = 50 + new Random().nextInt(150); for (int i = 0; i < size; i++) { AppFromMySQL appFromMySQL = new AppFromMySQL(i, (long)i, "com.afei.android"+i, i, new Date(), "appName"+i); appFromMySQL.setIconUrl(String.valueOf(i)); appFromMySQL.setDownloadUrl(String.valueOf(i)); appFromMySQL.setRemark(String.valueOf(i)); appFromMySQL.setSize((long)i); appFromMySQL.setDeveloper(String.valueOf(i)); appFromMySQLList.add(appFromMySQL); } return appFromMySQLList; } private static List<Long> getUserIdByPage(int pageNo){ List<Long> userList = new ArrayList<>(); // 取資料時每一頁1000個使用者 for (int i = 0; i < 2000; i++) { userList.add((long)i); } return userList; } }
配套的JVM引數如下(由於是遷移程式,沒必要配置CMS甚至G1,預設的PS垃圾回收即可):
-Xmx400m -Xms400m -Xmn150m -verbose:gc -XX:+PrintGCDetails
執行後jstat -gcutil 57408 2s
的結果如下:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 29.81 82.88 100.00 39.35 61.05 61.52 40 16.274 7 6.756 23.030 91.43 21.01 100.00 39.26 61.05 61.52 45 17.791 8 7.327 25.118 0.00 90.53 0.00 88.47 61.05 61.52 47 18.694 9 7.327 26.021 23.00 0.00 100.00 19.10 61.05 61.52 52 19.655 10 9.227 28.882 93.29 0.00 0.00 90.25 61.05 61.52 56 21.326 11 9.227 30.553 94.21 0.00 0.00 82.39 61.05 61.52 60 22.435 12 10.253 32.688 93.23 93.23 100.00 71.09 61.05 61.52 64 23.223 12 11.027 34.250
這裡有兩個比較嚴重的問題:
-
Old區漲的過快;
-
FGC太頻繁;
事實上第二個問題就是第一個問題引起的。
分析問題
這個案例比較特殊,雖然FGC頻繁,但是每次FGC後,Old都能降下去。這種情況下,我們不好通過jmap -dump得到dump檔案,或者通過jmap -histo得到Java物件柱狀圖,因為極大可能是Old區的使用率很低的時候生成的結果,這種結果沒多大參考價值:
[[email protected] ~]# jstat -gcutil 121165 100
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 40.00 15.71 58.25 51.76 287 7.891 63 2.921 10.812
96.58 0.00 18.00 34.05 58.25 51.76 289 7.937 63 2.921 10.858
96.84 0.00 0.00 70.73 58.25 51.76 291 8.001 63 2.921 10.923
0.00 0.00 0.00 27.31 58.25 51.76 291 8.033 64 2.978 11.010
0.00 99.47 0.00 45.80 58.25 51.76 293 8.077 64 2.978 11.055
0.00 96.84 0.00 83.17 58.25 51.76 295 8.144 65 2.978 11.121
96.91 0.00 0.00 21.68 58.25 51.76 296 8.157 65 3.026 11.183
那麼我們有其他辦法在Old區使用率很大,甚至發生FGC前生成dump檔案嗎?當然有,這裡介紹兩個引數:-XX:+HeapDumpAfterFullGC
和-XX:+HeapDumpBeforeFullGC
。看命名就知道,這兩個引數是在FGC前後生成dump檔案。需要注意的是,一定是發生FGC,而不是CMS GC或者G1這種併發GC。加上-XX:+HeapDumpBeforeFullGC
這個引數後,再次執行,我們看到如下這樣的GC日誌,即在FGC之前生成dump檔案:
[GC (Allocation Failure) [PSYoungGen: 94016K->42816K(102400K)] 236438K->227942K(358400K), 0.0661795 secs] [Times: user=0.62 sys=0.88, real=0.07 secs]
[GC (Allocation Failure) [PSYoungGen: 94016K->42752K(102400K)] 279142K->270606K(358400K), 0.0711319 secs] [Times: user=0.60 sys=1.01, real=0.07 secs]
[Heap Dump (before full gc): Dumping heap to java_pid121598.hprof ...
Heap dump file created [366886452 bytes in 1.878 secs]
, 1.8782650 secs][Full GC (Ergonomics) [PSYoungGen: 42752K->0K(102400K)] [ParOldGen: 227854K->41341K(256000K)] 270606K->41341K(358400K), [Metaspace: 2828K->2828K(1056768K)], 0.1720676 secs] [Times: user=3.72 sys=0.07, real=0.17 secs]
對dump檔案進行分析,結果如下,兩個比較靠前的物件是UserAppMongo和AppMongo:
headp dump
而通過TOP1的物件UserAppMongo的"List Objects"->"with outgoing references",得到如下圖所示,由圖可知,UserAppMongo這個物件屬性裡包含了List<AppMongo>
物件(appMongoList),其本質是Object陣列,每個AppMongo物件又是由appName,packageName,installTime等屬性組成,所以Histogram檢視中排名前幾位的UserAppMongo,Object[],ArrayList,AppMongo事實上都是UserAppMongo這一個物件:
outgoing references
遷移程式比較簡單,核心程式碼就那麼幾行,通過問題物件UserAppMongo,review程式碼的過程中,我們很快就懷疑到了下面這段程式碼:
List<Long> userList = getUserIdByPage(pageNo);
List<UserAppMongo> userAppMongoList = new ArrayList<>(userList.size());
for (Long userId:userList){
List<AppFromMySQL> appFromMySQLList = getUserInstalledAppList(userId);
UserAppMongo userAppMongo = new UserAppMongo();
userAppMongo.setId(System.nanoTime()+"");
userAppMongo.setUserId(userId);
userAppMongo.setAppMongoList(appFromMySQL2AppMongo(appFromMySQLList));
userAppMongoList.add(userAppMongo);
}
// save List<UserAppMongo> to mongodb
save2MongoDB(userAppMongoList);
這段程式碼的邏輯是:
-
得到一批使用者ID;
-
然後遍歷這些使用者ID,取得每個使用者已安裝APP集合轉換成MongoDB需要的資料模型;
-
批量儲存到MongoDB中;
我們仔細分析一下這段程式碼就會發現,遍歷每一頁的過程中,總計有pageSize*n*2個物件直到儲存到MongoDB後,遍歷下一頁時這些物件才會得到釋放,其中pageSize是每一頁的使用者數量(方法getUserIdByPage中),n是使用者平均安裝APP的數量,之所以乘以2是因為有一半是MySQL資料模型物件,另一半是MongoDB資料模型物件。假設每一頁1000個使用者,使用者平均安裝的APP數量為100個。那麼處理每一頁時總計有20w個物件一直常駐,且無法被GC掉。
如何解決
瞭解了問題的本質後,就比較好解決了,而且有很多種方法可以解決。
-
方法1-增大Young區
方法1就是增大Young區大小,準確的說是增大Eden區大小,大到能容忍20w個物件。那如果遷移程式將pageSize改為2000,那麼就需要增大Eden區直到能容下40w個物件。
-
方法2-優化程式碼
方法1優化辦法的JVM引數還得跟pageSize引數值耦合,有點約束。我們能否優化成無論pageSize多大。每次記憶體中最大常駐物件數量是一定的呢?當然可以,請看下面這段優化後的程式碼:
List<Long> userList = getUserIdByPage(pageNo);
List<UserAppMongo> userAppMongoList = new ArrayList<>(userList.size());
for (Long userId:userList){
List<AppFromMySQL> appFromMySQLList = getUserInstalledAppList(userId);
UserAppMongo userAppMongo = new UserAppMongo();
userAppMongo.setId(System.nanoTime()+"");
userAppMongo.setUserId(userId);
userAppMongo.setAppMongoList(appFromMySQL2AppMongo(appFromMySQLList));
userAppMongoList.add(userAppMongo);
// 核心優化程式碼
if (userAppMongoList.size()>=threshold){
save2MongoDB(userAppMongoList);
userAppMongoList.clear();
}
}
// save List<UserAppMongo> to mongodb
save2MongoDB(userAppMongoList);
說明:
核心優化程式碼的threshold的值,取一個合理的值即可。這樣的話,無論getUserIdByPage()時pageSize多大,整個堆中不可GC的駐留物件只會多幾個userId而已。
假設threshold設定為500,那麼在遍歷到下一頁之前整個堆中不可GC的駐留物件個數為:500*100*2=10000,其中100是平均每個使用者安裝APP的數量。
這樣優化以後,無論getUserIdByPage()中批量取使用者時pageSize為1000,還是5000,還是20000。JVM引數都不需要調整,且非常穩定。jstat -gcutil 56436 2s
結果如下所示,執行一段時間都沒有FGC,並且Old漲幅基本可以接受:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
35.87 0.00 54.00 3.64 61.16 61.52 52 3.894 0 0.000 3.894
0.00 50.37 48.00 3.89 61.16 61.52 67 4.392 0 0.000 4.392
12.41 0.00 46.00 4.14 61.16 61.52 80 4.990 0 0.000 4.990
1.66 14.04 100.00 4.38 61.16 61.52 89 5.636 0 0.000 5.636
0.00 27.05 24.00 4.63 61.16 61.52 103 6.146 0 0.000 6.146
-END-