了解大概什麼原因後,如何定位和解決就很簡單了,可以 dump 快照之後通過 JProfiler 或 MAT 觀察 Classes 的 Histogram(直方圖) 即可,或者直接通過命令即可定位, jcmd 打幾次 Histogram 的圖,看一下具體是哪個包下的 Class 增加較多就可以定位了。不過有時候也要結合InstBytes、KlassBytes、Bytecodes、MethodAll 等幾項指標綜合來看下。如下圖便是筆者使用 jcmd 排查到一個 Orika 的問題。
jcmd <PID> GC.class_stats|awk '{print$13}'|sed 's/\(.*\)\.\(.*\)/\1/g'|sort |uniq -c|sort -nrk1
如果無法從整體的角度定位,可以添加 -XX:+TraceClassLoading 和 -XX:+TraceClassUnLoading 參數觀察詳細的類加載和卸載信息。
4.3.4 小結
原理理解比較複雜,但定位和解決問題會比較簡單,經常會出問題的幾個點有 Orika 的 classMap、JSON 的 ASMSerializer、Groovy 動態加載類等,基本都集中在反射、Javasisit 字節碼增強、CGLIB 動態代理、OSGi 自定義類加載器等的技術點上。另外就是及時給 MetaSpace 區的使用率加一個監控,如果指標有波動提前發現並解決問題。
4.4 場景四:過早晉升 *
4.4.1 現象
這種場景主要發生在分代的收集器上面,專業的術語稱為「Premature Promotion」。90% 的對象朝生夕死,只有在 Young 區經歷過幾次 GC 的洗禮後才會晉升到 Old 區,每經歷一次 GC 對象的 GC Age 就會增長 1,最大通過 -XX:MaxTenuringThreshold 來控制。
過早晉升一般不會直接影響 GC,總會伴隨著浮動垃圾、大對象擔保失敗等問題,但這些問題不是立刻發生的,我們可以觀察以下幾種現象來判斷是否發生了過早晉升。
分配速率接近於晉升速率,對象晉升年齡較小。
GC 日誌中出現「Desired survivor size 107347968 bytes, new threshold 1(max 6)」等信息,說明此時經歷過一次 GC 就會放到 Old 區。
Full GC 比較頻繁,且經歷過一次 GC 之後 Old 區的變化比例非常大。
比如說 Old 區觸發的回收閾值是 80%,經歷過一次 GC 之後下降到了 10%,這就說明 Old 區的 70% 的對象存活時間其實很短,如下圖所示,Old 區大小每次 GC 後從 2.1G 回收到 300M,也就是說回收掉了 1.8G 的垃圾,只有 300M 的活躍對象。整個 Heap 目前是 4G,活躍對象只佔了不到十分之一。
過早晉升的危害:
Young GC 頻繁,總的吞吐量下降。
Full GC 頻繁,可能會有較大停頓。
4.4.2 原因
主要的原因有以下兩點:
Young/Eden 區過小:過小的直接後果就是 Eden 被裝滿的時間變短,本應該回收的對象參與了 GC 並晉升,Young GC 採用的是複製算法,由基礎篇我們知道 copying 耗時遠大於 mark,也就是 Young GC 耗時本質上就是 copy 的時間(CMS 掃描 Card Table 或 G1 掃描 Remember Set 出問題的情況另說),沒來及回收的對象增大了回收的代價,所以 Young GC 時間增加,同時又無法快速釋放空間,Young GC 次數也跟著增加。
分配速率過大:可以觀察出問題前後 Mutator 的分配速率,如果有明顯波動可以嘗試觀察網卡流量、存儲類中間件慢查詢日誌等信息,看是否有大量數據被加載到內存中。
同時無法 GC 掉對象還會帶來另外一個問題,引發動態年齡計算:JVM 通過 -XX:MaxTenuringThreshold 參數來控制晉升年齡,每經過一次 GC,年齡就會加一,達到最大年齡就可以進入 Old 區,最大值為 15(因為 JVM 中使用 4 個比特來表示對象的年齡)。設定固定的 MaxTenuringThreshold 值作為晉升條件:
MaxTenuringThreshold 如果設置得過大,原本應該晉升的對象一直停留在 Survivor 區,直到 Survivor 區溢出,一旦溢出發生,Eden + Survivor 中對象將不再依據年齡全部提升到 Old 區,這樣對象老化的機制就失效了。
MaxTenuringThreshold 如果設置得過小,過早晉升即對象不能在 Young 區充分被回收,大量短期對象被晉升到 Old 區,Old 區空間迅速增長,引起頻繁的 Major GC,分代回收失去了意義,嚴重影響 GC 性能。
相同應用在不同時間的表現不同,特殊任務的執行或者流量成分的變化,都會導致對象的生命周期分布發生波動,那麼固定的閾值設定,因為無法動態適應變化,會造成和上面問題,所以 Hotspot 會使用動態計算的方式來調整晉升的閾值。
具體動態計算可以看一下 Hotspot 源碼,具體在 /src/hotspot/share/gc/shared/ageTable.cpp 的 compute_tenuring_threshold 方法中:
compute_tenuring_thresholduint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//TargetSurvivorRatio默認50,意思是:在回收之後希望survivor區的佔用率達到這個比例
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
assert(sizes[0] == 0, "no objects with age zero should be recorded");
while (age < table_size) {//table_size=16
total += sizes[age];
//如果加上這個年齡的所有對象的大小之後,佔用量>期望的大小,就設置age為新的晉升閾值
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
if (PrintTenuringDistribution || UsePerfData) {
//列印期望的survivor的大小以及新計算出來的閾值,和設置的最大閾值
if (PrintTenuringDistribution) {
gclog_or_tty->cr();
gclog_or_tty->print_cr("Desired survivor size " SIZE_FORMAT " bytes, new threshold %u (max %u)",
desired_survivor_size*oopSize, result, (int) MaxTenuringThreshold);
}
total = 0;
age = 1;
while (age < table_size) {
total += sizes[age];
if (sizes[age] > 0) {
if (PrintTenuringDistribution) {
gclog_or_tty->print_cr("- age %3u: " SIZE_FORMAT_W(10) " bytes, " SIZE_FORMAT_W(10) " total",
age, sizes[age]*oopSize, total*oopSize);
}
}
if (UsePerfData) {
_perf_sizes[age]->set_value(sizes[age]*oopSize);
}
age++;
}
if (UsePerfData) {
SharedHeap* sh = SharedHeap::heap();
CollectorPolicy* policy = sh->collector_policy();
GCPolicyCounters* gc_counters = policy->counters();
gc_counters->tenuring_threshold()->set_value(result);
gc_counters->desired_survivor_size()->set_value(
desired_survivor_size*oopSize);
}
}
return result;
}
可以看到 Hotspot 遍歷所有對象時,從所有年齡為 0 的對象佔用的空間開始累加,如果加上年齡等於 n 的所有對象的空間之後,使用 Survivor 區的條件值(TargetSurvivorRatio / 100,TargetSurvivorRatio 默認值為 50)進行判斷,若大於這個值則結束循環,將 n 和 MaxTenuringThreshold 比較,若 n 小,則閾值為 n,若 n 大,則只能去設置最大閾值為 MaxTenuringThreshold。動態年齡觸發後導致更多的對象進入了 Old 區,造成資源浪費。
4.4.3 策略知道問題原因後我們就有解決的方向,如果是 Young/Eden 區過小,我們可以在總的 Heap 內存不變的情況下適當增大 Young 區,具體怎麼增加?一般情況下 Old 的大小應當為活躍對象的 2~3 倍左右,考慮到浮動垃圾問題最好在 3 倍左右,剩下的都可以分給 Young 區。
拿筆者的一次典型過早晉升優化來看,原配置為 Young 1.2G + Old 2.8G,通過觀察 CMS GC 的情況找到存活對象大概為 300~400M,於是調整 Old 1.5G 左右,剩下 2.5G 分給 Young 區。僅僅調了一個 Young 區大小參數(-Xmn),整個 JVM 一分鐘 Young GC 從 26 次降低到了 11 次,單次時間也沒有增加,總的 GC 時間從 1100ms 降低到了 500ms,CMS GC 次數也從 40 分鐘左右一次降低到了 7 小時 30 分鐘一次。
4.4.4 小結
過早晉升問題一般不會特別明顯,但日積月累之後可能會爆發一波收集器退化之類的問題,所以我們還是要提前避免掉的,可以看看自己系統裡面是否有這些現象,如果比較匹配的話,可以嘗試優化一下。一行代碼優化的 ROI 還是很高的。
如果在觀察 Old 區前後比例變化的過程中,發現可以回收的比例非常小,如從 80% 只回收到了 60%,說明我們大部分對象都是存活的,Old 區的空間可以適當調大些。
4.4.5 加餐
關於在調整 Young 與 Old 的比例時,如何選取具體的 NewRatio 值,這裡將問題抽象成為一個蓄水池模型,找到以下關鍵衡量指標,大家可以根據自己場景進行推算。
NewRatio 的值 r 與 va、vp、vyc、voc、rs 等值存在一定函數相關性(rs 越小 r 越大、r 越小 vp 越小,…,之前嘗試使用 NN 來輔助建模,但目前還沒有完全算出具體的公式,有想法的同學可以在評論區給出你的答案 )。
總停頓時間 T 為 Young GC 總時間 Tyc 和 Old GC 總時間 Toc 之和,其中 Tyc 與 vyc 和 vp 相關,Toc 與 voc相關。
忽略掉 GC 時間後,兩次 Young GC 的時間間隔要大於 TP9999 時間,這樣儘量讓對象在 Eden 區就被回收,可以減少很多停頓。
4.5 場景五:CMS Old GC 頻繁 *
4.5.1 現象
Old 區頻繁的做 CMS GC,但是每次耗時不是特別長,整體最大 STW 也在可接受範圍內,但由於 GC 太頻繁導致吞吐下降比較多。
4.5.2 原因
這種情況比較常見,基本都是一次 Young GC 完成後,負責處理 CMS GC 的一個後臺線程 concurrentMarkSweepThread 會不斷地輪詢,使用 shouldConcurrentCollect() 方法做一次檢測,判斷是否達到了回收條件。如果達到條件,使用 collect_in_background() 啟動一次 Background 模式 GC。輪詢的判斷是使用 sleepBeforeNextCycle() 方法,間隔周期為 -XX:CMSWaitDuration 決定,默認為2s。
具體代碼在:src/hotspot/share/gc/cms/concurrentMarkSweepThread.cpp。
void ConcurrentMarkSweepThread::run_service() {
assert(this == cmst(), "just checking");
if (BindCMSThreadToCPU && !os::bind_to_processor(CPUForCMSThread)) {
log_warning(gc)("Couldn't bind CMS thread to processor " UINTX_FORMAT, CPUForCMSThread);
}
while (!should_terminate()) {
sleepBeforeNextCycle();
if (should_terminate()) break;
GCIdMark gc_id_mark;
GCCause::Cause cause = _collector->_full_gc_requested ?
_collector->_full_gc_cause : GCCause::_cms_concurrent_mark;
_collector->collect_in_background(cause);
}
verify_ok_to_terminate();
}
void ConcurrentMarkSweepThread::sleepBeforeNextCycle() {
while (!should_terminate()) {
if(CMSWaitDuration >= 0) {
// Wait until the next synchronous GC, a concurrent full gc
// request or a timeout, whichever is earlier.
wait_on_cms_lock_for_scavenge(CMSWaitDuration);
} else {
// Wait until any cms_lock event or check interval not to call shouldConcurrentCollect permanently
wait_on_cms_lock(CMSCheckInterval);
}
// Check if we should start a CMS collection cycle
if (_collector->shouldConcurrentCollect()) {
return;
}
// .. collection criterion not yet met, let's go back
// and wait some more
}
}
判斷是否進行回收的代碼在:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp。
shouldConcurrentCollect()bool CMSCollector::shouldConcurrentCollect() {
LogTarget(Trace, gc) log;
if (_full_gc_requested) {
log.print("CMSCollector: collect because of explicit gc request (or GCLocker)");
return true;
}
FreelistLocker x(this);
// -
// Print out lots of information which affects the initiation of
// a collection.
if (log.is_enabled() && stats().valid()) {
log.print("CMSCollector shouldConcurrentCollect: ");
LogStream out(log);
stats().print_on(&out);
log.print("time_until_cms_gen_full %3.7f", stats().time_until_cms_gen_full());
log.print("free=" SIZE_FORMAT, _cmsGen->free());
log.print("contiguous_available=" SIZE_FORMAT, _cmsGen->contiguous_available());
log.print("promotion_rate=%g", stats().promotion_rate());
log.print("cms_allocation_rate=%g", stats().cms_allocation_rate());
log.print("occupancy=%3.7f", _cmsGen->occupancy());
log.print("initiatingOccupancy=%3.7f", _cmsGen->initiating_occupancy());
log.print("cms_time_since_begin=%3.7f", stats().cms_time_since_begin());
log.print("cms_time_since_end=%3.7f", stats().cms_time_since_end());
log.print("metadata initialized %d", MetaspaceGC::should_concurrent_collect());
}
// -
if (!UseCMSInitiatingOccupancyOnly) {
if (stats().valid()) {
if (stats().time_until_cms_start() == 0.0) {
return true;
}
} else {
if (_cmsGen->occupancy() >= _bootstrap_occupancy) {
log.print(" CMSCollector: collect for bootstrapping statistics: occupancy = %f, boot occupancy = %f",
_cmsGen->occupancy(), _bootstrap_occupancy);
return true;
}
}
}
if (_cmsGen->should_concurrent_collect()) {
log.print("CMS old gen initiated");
return true;
}
// We start a collection if we believe an incremental collection may fail;
// this is not likely to be productive in practice because it's probably too
// late anyway.
CMSHeap* heap = CMSHeap::heap();
if (heap->incremental_collection_will_fail(true /* consult_young */)) {
log.print("CMSCollector: collect because incremental collection will fail ");
return true;
}
if (MetaspaceGC::should_concurrent_collect()) {
log.print("CMSCollector: collect for metadata allocation ");
return true;
}
// CMSTriggerInterval starts a CMS cycle if enough time has passed.
if (CMSTriggerInterval >= 0) {
if (CMSTriggerInterval == 0) {
// Trigger always
return true;
}
// Check the CMS time since begin (we do not check the stats validity
// as we want to be able to trigger the first CMS cycle as well)
if (stats().cms_time_since_begin() >= (CMSTriggerInterval / ((double) MILLIUNITS))) {
if (stats().valid()) {
log.print("CMSCollector: collect because of trigger interval (time since last begin %3.7f secs)",
stats().cms_time_since_begin());
} else {
log.print("CMSCollector: collect because of trigger interval (first collection)");
}
return true;
}
}
return false;
}
分析其中邏輯判斷是否觸發 GC,分為以下幾種情況:
大家可以看一下源碼中的日誌列印,通過日誌我們就可以比較清楚地知道具體的原因,然後就可以著手分析了。
4.5.3 策略
我們這裡還是拿最常見的達到回收比例這個場景來說,與過早晉升不同的是這些對象確實存活了一段時間,Survival Time 超過了 TP9999 時間,但是又達不到長期存活,如各種資料庫、網絡連結,帶有失效時間的緩存等。
處理這種常規內存洩漏問題基本是一個思路,主要步驟如下:
Dump Diff 和 Leak Suspects 比較直觀就不介紹了,這裡說下其它幾個關鍵點:
內存 Dump:使用 jmap、arthas 等 dump 堆進行快照時記得摘掉流量,同時分別在 CMS GC 的發生前後分別 dump 一次。
分析 Top Component:要記得按照對象、類、類加載器、包等多個維度觀察 Histogram,同時使用 outgoing 和 incoming 分析關聯的對象,另外就是 Soft Reference 和 Weak Reference、Finalizer 等也要看一下。
分析 Unreachable:重點看一下這個,關注下 Shallow 和 Retained 的大小。如下圖所示,筆者之前一次 GC 優化,就根據 Unreachable Objects 發現了 Hystrix 的滑動窗口問題。4.5.4 小結
經過整個流程下來基本就能定位問題了,不過在優化的過程中記得使用控制變量的方法來優化,防止一些會加劇問題的改動被掩蓋。
4.6 場景六:單次 CMS Old GC 耗時長 *
4.6.1 現象
CMS GC 單次 STW 最大超過 1000ms,不會頻繁發生,如下圖所示最長達到了 8000ms。某些場景下會引起「雪崩效應」,這種場景非常危險,我們應該儘量避免出現。
4.6.2 原因
CMS 在回收的過程中,STW 的階段主要是 Init Mark 和 Final Remark 這兩個階段,也是導致 CMS Old GC 最多的原因,另外有些情況就是在 STW 前等待 Mutator 的線程到達 SafePoint 也會導致時間過長,但這種情況較少,我們在此處主要討論前者。發生收集器退化或者碎片壓縮的場景請看場景七。
想要知道這兩個階段為什麼會耗時,我們需要先看一下這兩個階段都會幹什麼。
核心代碼都在 /src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp 中,內部有個線程 ConcurrentMarkSweepThread 輪詢來校驗,Old 區的垃圾回收相關細節被完全封裝在 CMSCollector 中,調用入口就是 ConcurrentMarkSweepThread 調用的 CMSCollector::collect_in_background 和 ConcurrentMarkSweepGeneration 調用的 CMSCollector::collect 方法,此處我們討論大多數場景的 collect_in_background。整個過程中會 STW 的主要是 initial Mark 和 Final Remark,核心代碼在 VM_CMS_Initial_Mark / VM_CMS_Final_Remark 中,執行時需要將執行權交由 VMThread 來執行。
CMSCollector::checkpointRootsInitialWork()void CMSCollector::checkpointRootsInitialWork() {
assert(SafepointSynchronize::is_at_safepoint(), "world should be stopped");
assert(_collectorState == InitialMarking, "just checking");
// Already have locks.
assert_lock_strong(bitMapLock());
assert(_markBitMap.isAllClear(), "was reset at end of previous cycle");
// Setup the verification and class unloading state for this
// CMS collection cycle.
setup_cms_unloading_and_verification_state();
GCTraceTime(Trace, gc, phases) ts("checkpointRootsInitialWork", _gc_timer_cm);
// Reset all the PLAB chunk arrays if necessary.
if (_survivor_plab_array != NULL && !CMSPLABRecordAlways) {
reset_survivor_plab_arrays();
}
ResourceMark rm;
HandleMark hm;
MarkRefsIntoClosure notOlder(_span, &_markBitMap);
CMSHeap* heap = CMSHeap::heap();
verify_work_stacks_empty();
verify_overflow_empty();
heap->ensure_parsability(false); // fill TLABs, but no need to retire them
// Update the saved marks which may affect the root scans.
heap->save_marks();
// weak reference processing has not started yet.
ref_processor()->set_enqueuing_is_done(false);
// Need to remember all newly created CLDs,
// so that we can guarantee that the remark finds them.
ClassLoaderDataGraph::remember_new_clds(true);
// Whenever a CLD is found, it will be claimed before proceeding to mark
// the klasses. The claimed marks need to be cleared before marking starts.
ClassLoaderDataGraph::clear_claimed_marks();
print_eden_and_survivor_chunk_arrays();
{
if (CMSParallelInitialMarkEnabled) {
// The parallel version.
WorkGang* workers = heap->workers();
assert(workers != NULL, "Need parallel worker threads.");
uint n_workers = workers->active_workers();
StrongRootsScope srs(n_workers);
CMSParInitialMarkTask tsk(this, &srs, n_workers);
initialize_sequential_subtasks_for_young_gen_rescan(n_workers);
// If the total workers is greater than 1, then multiple workers
// may be used at some time and the initialization has been set
// such that the single threaded path cannot be used.
if (workers->total_workers() > 1) {
workers->run_task(&tsk);
} else {
tsk.work(0);
}
} else {
// The serial version.
CLDToOopClosure cld_closure(¬Older, true);
heap->rem_set()->prepare_for_younger_refs_iterate(false); // Not parallel.
StrongRootsScope srs(1);
heap->cms_process_roots(&srs,
true, // young gen as roots
GenCollectedHeap::ScanningOption(roots_scanning_options()),
should_unload_classes(),
¬Older,
&cld_closure);
}
}
// Clear mod-union table; it will be dirtied in the prologue of
// CMS generation per each young generation collection.
assert(_modUnionTable.isAllClear(),
"Was cleared in most recent final checkpoint phase"
" or no bits are set in the gc_prologue before the start of the next "
"subsequent marking phase.");
assert(_ct->cld_rem_set()->mod_union_is_clear(), "Must be");
// Save the end of the used_region of the constituent generations
// to be used to limit the extent of sweep in each generation.
save_sweep_limits();
verify_overflow_empty();
}
void CMSParInitialMarkTask::work(uint worker_id) {
elapsedTimer _timer;
ResourceMark rm;
HandleMark hm;
// scan from roots ----
_timer.start();
CMSHeap* heap = CMSHeap::heap();
ParMarkRefsIntoClosure par_mri_cl(_collector->_span, &(_collector->_markBitMap));
// young gen roots ----
{
work_on_young_gen_roots(&par_mri_cl);
_timer.stop();
log_trace(gc, task)("Finished young gen initial mark scan work in %dth thread: %3.3f sec", worker_id, _timer.seconds());
}
// remaining roots ----
_timer.reset();
_timer.start();
CLDToOopClosure cld_closure(&par_mri_cl, true);
heap->cms_process_roots(_strong_roots_scope,
false, // yg was scanned above
GenCollectedHeap::ScanningOption(_collector->CMSCollector::roots_scanning_options()),
_collector->should_unload_classes(),
&par_mri_cl,
&cld_closure,
&_par_state_string);
assert(_collector->should_unload_classes()
|| (_collector->CMSCollector::roots_scanning_options() & GenCollectedHeap::SO_AllCodeCache),
"if we didn't scan the code cache, we have to be ready to drop nmethods with expired weak oops");
_timer.stop();
log_trace(gc, task)("Finished remaining root initial mark scan work in %dth thread: %3.3f sec", worker_id, _timer.seconds());
}
整個過程比較簡單,從 GC Root 出發標記 Old 中的對象,處理完成後藉助 BitMap 處理下 Young 區對 Old 區的引用,整個過程基本都比較快,很少會有較大的停頓。
CMSCollector::checkpointRootsFinalWork()void CMSCollector::checkpointRootsFinalWork() {
GCTraceTime(Trace, gc, phases) tm("checkpointRootsFinalWork", _gc_timer_cm);
assert(haveFreelistLocks(), "must have free list locks");
assert_lock_strong(bitMapLock());
ResourceMark rm;
HandleMark hm;
CMSHeap* heap = CMSHeap::heap();
if (should_unload_classes()) {
CodeCache::gc_prologue();
}
assert(haveFreelistLocks(), "must have free list locks");
assert_lock_strong(bitMapLock());
heap->ensure_parsability(false); // fill TLAB's, but no need to retire them
// Update the saved marks which may affect the root scans.
heap->save_marks();
print_eden_and_survivor_chunk_arrays();
{
if (CMSParallelRemarkEnabled) {
GCTraceTime(Debug, gc, phases) t("Rescan (parallel)", _gc_timer_cm);
do_remark_parallel();
} else {
GCTraceTime(Debug, gc, phases) t("Rescan (non-parallel)", _gc_timer_cm);
do_remark_non_parallel();
}
}
verify_work_stacks_empty();
verify_overflow_empty();
{
GCTraceTime(Trace, gc, phases) ts("refProcessingWork", _gc_timer_cm);
refProcessingWork();
}
verify_work_stacks_empty();
verify_overflow_empty();
if (should_unload_classes()) {
CodeCache::gc_epilogue();
}
JvmtiExport::gc_epilogue();
assert(_markStack.isEmpty(), "No grey objects");
size_t ser_ovflw = _ser_pmc_remark_ovflw + _ser_pmc_preclean_ovflw +
_ser_kac_ovflw + _ser_kac_preclean_ovflw;
if (ser_ovflw > 0) {
log_trace(gc)("Marking stack overflow (benign) (pmc_pc=" SIZE_FORMAT ", pmc_rm=" SIZE_FORMAT ", kac=" SIZE_FORMAT ", kac_preclean=" SIZE_FORMAT ")",
_ser_pmc_preclean_ovflw, _ser_pmc_remark_ovflw, _ser_kac_ovflw, _ser_kac_preclean_ovflw);
_markStack.expand();
_ser_pmc_remark_ovflw = 0;
_ser_pmc_preclean_ovflw = 0;
_ser_kac_preclean_ovflw = 0;
_ser_kac_ovflw = 0;
}
if (_par_pmc_remark_ovflw > 0 || _par_kac_ovflw > 0) {
log_trace(gc)("Work queue overflow (benign) (pmc_rm=" SIZE_FORMAT ", kac=" SIZE_FORMAT ")",
_par_pmc_remark_ovflw, _par_kac_ovflw);
_par_pmc_remark_ovflw = 0;
_par_kac_ovflw = 0;
}
if (_markStack._hit_limit > 0) {
log_trace(gc)(" (benign) Hit max stack size limit (" SIZE_FORMAT ")",
_markStack._hit_limit);
}
if (_markStack._failed_double > 0) {
log_trace(gc)(" (benign) Failed stack doubling (" SIZE_FORMAT "), current capacity " SIZE_FORMAT,
_markStack._failed_double, _markStack.capacity());
}
_markStack._hit_limit = 0;
_markStack._failed_double = 0;
if ((VerifyAfterGC || VerifyDuringGC) &&
CMSHeap::heap()->total_collections() >= VerifyGCStartAt) {
verify_after_remark();
}
_gc_tracer_cm->report_object_count_after_gc(&_is_alive_closure);
// Change under the freelistLocks.
_collectorState = Sweeping;
// Call isAllClear() under bitMapLock
assert(_modUnionTable.isAllClear(),
"Should be clear by end of the final marking");
assert(_ct->cld_rem_set()->mod_union_is_clear(),
"Should be clear by end of the final marking");
}
Final Remark 是最終的第二次標記,這種情況只有在 Background GC 執行了 InitialMarking 步驟的情形下才會執行,如果是 Foreground GC 執行的 InitialMarking 步驟則不需要再次執行 FinalRemark。Final Remark 的開始階段與 Init Mark 處理的流程相同,但是後續多了 Card Table 遍歷、Reference 實例的清理並將其加入到 Reference 維護的 pend_list 中,如果要收集元數據信息,還要清理 SystemDictionary、CodeCache、SymbolTable、StringTable 等組件中不再使用的資源。
4.6.3 策略
知道了兩個 STW 過程執行流程,我們分析解決就比較簡單了,由於大部分問題都出在 Final Remark 過程,這裡我們也拿這個場景來舉例,主要步驟:
2019-02-27T19:55:37.920+0800: 516952.915: [GC (CMS Final Remark) 516952.915: [ParNew516952.939: [SoftReference, 0 refs, 0.0003857 secs]516952.939: [WeakReference, 1362 refs, 0.0002415 secs]516952.940: [FinalReference, 146 refs, 0.0001233 secs]516952.940: [PhantomReference, 0 refs, 57 refs, 0.0002369 secs]516952.940: [JNI Weak Reference, 0.0000662 secs]
[class unloading, 0.1770490 secs]516953.329: [scrub symbol table, 0.0442567 secs]516953.373: [scrub string table, 0.0036072 secs][1 CMS-remark: 1638504K(2048000K)] 1667558K(4352000K), 0.5269311 secs] [Times: user=1.20 sys=0.03, real=0.53 secs]
if (should_unload_classes()) {
{
GCTraceTime(Debug, gc, phases) t("Class Unloading", _gc_timer_cm);
// Unload classes and purge the SystemDictionary.
bool purged_class = SystemDictionary::do_unloading(_gc_timer_cm);
// Unload nmethods.
CodeCache::do_unloading(&_is_alive_closure, purged_class);
// Prune dead klasses from subklass/sibling/implementor lists.
Klass::clean_weak_klass_links(purged_class);
}
{
GCTraceTime(Debug, gc, phases) t("Scrub Symbol Table", _gc_timer_cm);
// Clean up unreferenced symbols in symbol table.
SymbolTable::unlink();
}
{
GCTraceTime(Debug, gc, phases) t("Scrub String Table", _gc_timer_cm);
// Delete entries for dead interned strings.
StringTable::unlink(&_is_alive_closure);
}
}
4.6.4 小結
正常情況進行的 Background CMS GC,出現問題基本都集中在 Reference 和 Class 等元數據處理上,在 Reference 類的問題處理方面,不管是 FinalReference,還是 SoftReference、WeakReference 核心的手段就是找準時機 dump 快照,然後用內存分析工具來分析。Class 處理方面目前除了關閉類卸載開關,沒有太好的方法。
在 G1 中同樣有 Reference 的問題,可以觀察日誌中的 Ref Proc,處理方法與 CMS 類似。
4.7 場景七:內存碎片&收集器退化
4.7.1 現象
並發的 CMS GC 算法,退化為 Foreground 單線程串行 GC 模式,STW 時間超長,有時會長達十幾秒。其中 CMS 收集器退化後單線程串行 GC 算法有兩種:
4.7.2 原因
CMS 發生收集器退化主要有以下幾種情況。
晉升失敗(Promotion Failed)
顧名思義,晉升失敗就是指在進行 Young GC 時,Survivor 放不下,對象只能放入 Old,但此時 Old 也放不下。直覺上乍一看這種情況可能會經常發生,但其實因為有 concurrentMarkSweepThread 和擔保機制的存在,發生的條件是很苛刻的,除非是短時間將 Old 區的剩餘空間迅速填滿,例如上文中說的動態年齡判斷導致的過早晉升(見下文的增量收集擔保失敗)。另外還有一種情況就是內存碎片導致的 Promotion Failed,Young GC 以為 Old 有足夠的空間,結果到分配時,晉級的大對象找不到連續的空間存放。
使用 CMS 作為 GC 收集器時,運行過一段時間的 Old 區如下圖所示,清除算法導致內存出現多段的不連續,出現大量的內存碎片。
碎片帶來了兩個問題:
空間分配效率較低:上文已經提到過,如果是連續的空間 JVM 可以通過使用 pointer bumping 的方式來分配,而對於這種有大量碎片的空閒鍊表則需要逐個訪問 freelist 中的項來訪問,查找可以存放新建對象的地址。
空間利用效率變低:Young 區晉升的對象大小大於了連續空間的大小,那麼將會觸發 Promotion Failed ,即使整個 Old 區的容量是足夠的,但由於其不連續,也無法存放新對象,也就是本文所說的問題。
增量收集擔保失敗
分配內存失敗後,會判斷統計得到的 Young GC 晉升到 Old 的平均大小,以及當前 Young 區已使用的大小也就是最大可能晉升的對象大小,是否大於 Old 區的剩餘空間。只要 CMS 的剩餘空間比前兩者的任意一者大,CMS 就認為晉升還是安全的,反之,則代表不安全,不進行Young GC,直接觸發Full GC。
顯式 GC
這種情況參見場景二。
併發模式失敗(Concurrent Mode Failure)
最後一種情況,也是發生概率較高的一種,在 GC 日誌中經常能看到 Concurrent Mode Failure 關鍵字。這種是由於並發 Background CMS GC 正在執行,同時又有 Young GC 晉升的對象要放入到了 Old 區中,而此時 Old 區空間不足造成的。
為什麼 CMS GC 正在執行還會導致收集器退化呢?主要是由於 CMS 無法處理浮動垃圾(Floating Garbage)引起的。CMS 的並發清理階段,Mutator 還在運行,因此不斷有新的垃圾產生,而這些垃圾不在這次清理標記的範疇裡,無法在本次 GC 被清除掉,這些就是浮動垃圾,除此之外在 Remark 之前那些斷開引用脫離了讀寫屏障控制的對象也算浮動垃圾。所以 Old 區回收的閾值不能太高,否則預留的內存空間很可能不夠,從而導致 Concurrent Mode Failure 發生。
4.7.3 策略
分析到具體原因後,我們就可以針對性解決了,具體思路還是從根因出發,具體解決策略:
內存碎片:通過配置 -XX:UseCMSCompactAtFullCollection=true 來控制 Full GC的過程中是否進行空間的整理(默認開啟,注意是Full GC,不是普通CMS GC),以及 -XX: CMSFullGCsBeforeCompaction=n 來控制多少次 Full GC 後進行一次壓縮。
增量收集:降低觸發 CMS GC 的閾值,即參數 -XX:CMSInitiatingOccupancyFraction 的值,讓 CMS GC 儘早執行,以保證有足夠的連續空間,也減少 Old 區空間的使用大小,另外需要使用 -XX:+UseCMSInitiatingOccupancyOnly 來配合使用,不然 JVM 僅在第一次使用設定值,後續則自動調整。
浮動垃圾:視情況控制每次晉升對象的大小,或者縮短每次 CMS GC 的時間,必要時可調節 NewRatio 的值。另外就是使用 -XX:+CMSScavengeBeforeRemark 在過程中提前觸發一次 Young GC,防止後續晉升過多對象。
4.7.4 小結
正常情況下觸發併發模式的 CMS GC,停頓非常短,對業務影響很小,但 CMS GC 退化後,影響會非常大,建議發現一次後就徹底根治。只要能定位到內存碎片、浮動垃圾、增量收集相關等具體產生原因,還是比較好解決的,關於內存碎片這塊,如果 -XX:CMSFullGCsBeforeCompaction 的值不好選取的話,可以使用 -XX:PrintFLSStatistics 來觀察內存碎片率情況,然後再設置具體的值。
最後就是在編碼的時候也要避免需要連續地址空間的大對象的產生,如過長的字符串,用於存放附件、序列化或反序列化的 byte 數組等,還有就是過早晉升問題儘量在爆發問題前就避免掉。
4.8 場景八:堆外內存 OOM
4.8.1 現象
內存使用率不斷上升,甚至開始使用 SWAP 內存,同時可能出現 GC 時間飆升,線程被 Block 等現象,通過 top 命令發現 Java 進程的 RES 甚至超過了 -Xmx 的大小。出現這些現象時,基本可以確定是出現了堆外內存洩漏。
4.8.2 原因
JVM 的堆外內存洩漏,主要有兩種的原因:
4.8.3 策略
哪種原因造成的堆外內存洩漏?
首先,我們需要確定是哪種原因導致的堆外內存洩漏。這裡可以使用 NMT(NativeMemoryTracking) 進行分析。在項目中添加 -XX:NativeMemoryTracking=detail JVM參數後重啟項目(需要注意的是,打開 NMT 會帶來 5%~10% 的性能損耗)。使用命令 jcmd pid VM.native_memory detail 查看內存分布。重點觀察 total 中的 committed,因為 jcmd 命令顯示的內存包含堆內內存、Code 區域、通過 Unsafe.allocateMemory 和 DirectByteBuffer 申請的內存,但是不包含其他 Native Code(C 代碼)申請的堆外內存。
如果 total 中的 committed 和 top 中的 RES 相差不大,則應為主動申請的堆外內存未釋放造成的,如果相差較大,則基本可以確定是 JNI 調用造成的。
原因一:主動申請未釋放
JVM 使用 -XX:MaxDirectMemorySize=size 參數來控制可申請的堆外內存的最大值。在 Java 8 中,如果未配置該參數,默認和 -Xmx 相等。
NIO 和 Netty 都會取 -XX:MaxDirectMemorySize 配置的值,來限制申請的堆外內存的大小。NIO 和 Netty 中還有一個計數器欄位,用來計算當前已申請的堆外內存大小,NIO 中是 java.nio.Bits#totalCapacity、Netty 中 io.netty.util.internal.PlatformDependent#DIRECT_MEMORY_COUNTER。
當申請堆外內存時,NIO 和 Netty 會比較計數器欄位和最大值的大小,如果計數器的值超過了最大值的限制,會拋出 OOM 的異常。
NIO 中是:OutOfMemoryError: Direct buffer memory。
Netty 中是:OutOfDirectMemoryError: failed to allocate capacity byte(s) of direct memory (used: usedMemory , max: DIRECT_MEMORY_LIMIT )。
我們可以檢查代碼中是如何使用堆外內存的,NIO 或者是 Netty,通過反射,獲取到對應組件中的計數器欄位,並在項目中對該欄位的數值進行打點,即可準確地監控到這部分堆外內存的使用情況。
此時,可以通過 Debug 的方式確定使用堆外內存的地方是否正確執行了釋放內存的代碼。另外,需要檢查 JVM 的參數是否有 -XX:+DisableExplicitGC 選項,如果有就去掉,因為該參數會使 System.gc 失效。(場景二:顯式 GC 的去與留)
原因二:通過 JNI 調用的 Native Code 申請的內存未釋放
這種情況排查起來比較困難,我們可以通過 Google perftools + Btrace 等工具,幫助我們分析出問題的代碼在哪裡。
gperftools 是 Google 開發的一款非常實用的工具集,它的原理是在 Java 應用程式運行時,當調用 malloc 時換用它的 libtcmalloc.so,這樣就能對內存分配情況做一些統計。我們使用 gperftools 來追蹤分配內存的命令。如下圖所示,通過 gperftools 發現 Java_java_util_zip_Inflater_init 比較可疑。
接下來可以使用 Btrace,嘗試定位具體的調用棧。Btrace 是 Sun 推出的一款 Java 追蹤、監控工具,可以在不停機的情況下對線上的 Java 程序進行監控。如下圖所示,通過 Btrace 定位出項目中的 ZipHelper 在頻繁調用 GZIPInputStream ,在堆外內存分配對象。
最終定位到是,項目中對 GIPInputStream 的使用錯誤,沒有正確的 close()。
除了項目本身的原因,還可能有外部依賴導致的洩漏,如 Netty 和 Spring Boot,詳細情況可以學習下這兩篇文章:《疑案追蹤:Spring Boot內存洩露排查記》、《Netty堆外內存洩露排查盛宴》。
4.8.4 小結
首先可以使用 NMT + jcmd 分析洩漏的堆外內存是哪裡申請,確定原因後,使用不同的手段,進行原因定位。
4.9 場景九:JNI 引發的 GC 問題
4.9.1 現象
在 GC 日誌中,出現 GC Cause 為 GCLocker Initiated GC。
2020-09-23T16:49:09.727+0800: 504426.742: [GC (GCLocker Initiated GC) 504426.742: [ParNew (promotion failed): 209716K->6042K(1887488K), 0.0843330 secs] 1449487K->1347626K(3984640K), 0.0848963 secs] [Times: user=0.19 sys=0.00, real=0.09 secs]
2020-09-23T16:49:09.812+0800: 504426.827: [Full GC (GCLocker Initiated GC) 504426.827: [CMS: 1341583K->419699K(2097152K), 1.8482275 secs] 1347626K->419699K(3984640K), [Metaspace: 297780K->297780K(1329152K)], 1.8490564 secs] [Times: user=1.62 sys=0.20, real=1.85 secs]
4.9.2 原因
JNI(Java Native Interface)意為 Java 本地調用,它允許 Java 代碼和其他語言寫的 Native 代碼進行交互。
JNI 如果需要獲取 JVM 中的 String 或者數組,有兩種方式:
由於 Native 代碼直接使用了 JVM 堆區的指針,如果這時發生 GC,就會導致數據錯誤。因此,在發生此類 JNI 調用時,禁止 GC 的發生,同時阻止其他線程進入 JNI 臨界區,直到最後一個線程退出臨界區時觸發一次 GC。
GC Locker 實驗:
public class GCLockerTest {
static final int ITERS = 100;
static final int ARR_SIZE = 10000;
static final int WINDOW = 10000000;
static native void acquire(int[] arr);
static native void release(int[] arr);
static final Object[] window = new Object[WINDOW];
public static void main(String... args) throws Throwable {
System.loadLibrary("GCLockerTest");
int[] arr = new int[ARR_SIZE];
for (int i = 0; i < ITERS; i++) {
acquire(arr);
System.out.println("Acquired");
try {
for (int c = 0; c < WINDOW; c++) {
window[c] = new Object();
}
} catch (Throwable t) {
// omit
} finally {
System.out.println("Releasing");
release(arr);
}
}
}
}
#include <jni.h>
#include "GCLockerTest.h"
static jbyte* sink;
JNIEXPORT void JNICALL Java_GCLockerTest_acquire(JNIEnv* env, jclass klass, jintArray arr) {
sink = (*env)->GetPrimitiveArrayCritical(env, arr, 0);
}
JNIEXPORT void JNICALL Java_GCLockerTest_release(JNIEnv* env, jclass klass, jintArray arr) {
(*env)->ReleasePrimitiveArrayCritical(env, arr, sink, 0);
}
運行該 JNI 程序,可以看到發生的 GC 都是 GCLocker Initiated GC,並且注意在 「Acquired」 和 「Released」 時不可能發生 GC。
GC Locker 可能導致的不良後果有:
如果此時是 Young 區不夠 Allocation Failure 導致的 GC,由於無法進行 Young GC,會將對象直接分配至 Old 區。
如果 Old 區也沒有空間了,則會等待鎖釋放,導致線程阻塞。可能觸發額外不必要的 Young GC,JDK 有一個 Bug,有一定的機率,本來只該觸發一次 GCLocker Initiated GC 的 Young GC,實際發生了一次 Allocation Failure GC 又緊接著一次 GCLocker Initiated GC。是因為 GCLocker Initiated GC 的屬性被設為 full,導致兩次 GC 不能收斂。
4.9.3 策略
添加 -XX+PrintJNIGCStalls 參數,可以列印出發生 JNI 調用時的線程,進一步分析,找到引發問題的 JNI 調用。JNI 調用需要謹慎,不一定可以提升性能,反而可能造成 GC 問題。升級 JDK 版本到 14,避免 JDK-8048556 導致的重複 GC。4.9.4 小結
JNI 產生的 GC 問題較難排查,需要謹慎使用。
5. 總結
在這裡,我們把整個文章內容總結一下,方便大家整體地理解回顧。
5.1 處理流程(SOP)
下圖為整體 GC 問題普適的處理流程,重點的地方下面會單獨標註,其他的基本都是標準處理流程,此處不再贅述,最後在整個問題都處理完之後有條件的話建議做一下復盤。
制定標準:這塊內容其實非常重要,但大部分系統都是缺失的,筆者過往面試的同學中只有不到一成的同學能給出自己的系統 GC 標準到底什麼樣,其他的都是用的統一指標模板,缺少預見性,具體指標制定可以參考 3.1 中的內容,需要結合應用系統的 TP9999 時間和延遲、吞吐量等設定具體的指標,而不是被問題驅動。
保留現場:目前線上服務基本都是分布式服務,某個節點發生問題後,如果條件允許一定不要直接操作重啟、回滾等動作恢復,優先通過摘掉流量的方式來恢復,這樣我們可以將堆、棧、GC 日誌等關鍵信息保留下來,不然錯過了定位根因的時機,後續解決難度將大大增加。當然除了這些,應用日誌、中間件日誌、內核日誌、各種 Metrics 指標等對問題分析也有很大幫助。
因果分析:判斷 GC 異常與其他系統指標異常的因果關係,可以參考筆者在 3.2 中介紹的時序分析、概率分析、實驗分析、反證分析等 4 種因果分析法,避免在排查過程中走入誤區。根因分析:確實是 GC 的問題後,可以藉助上文提到的工具並通過 5 Why 根因分析法以及跟第三節中的九種常見的場景進行逐一匹配,或者直接參考下文的根因魚骨圖,找出問題發生根因,最後再選擇優化手段。
5.2 根因魚骨圖
送上一張問題根因魚骨圖,一般情況下我們在處理一個 GC 問題時,只要能定位到問題的「病灶」,有的放矢,其實就相當於解決了 80%,如果在某些場景下不太好定位,大家可以藉助這種根因分析圖通過排除法去定位。
5.3 調優建議
Trade Off:與 CAP 註定要缺一角一樣,GC 優化要在延遲(Latency)、吞吐量(Throughput)、容量(Capacity)三者之間進行權衡。
最終手段:GC 發生問題不是一定要對 JVM 的 GC 參數進行調優,大部分情況下是通過 GC 的情況找出一些業務問題,切記上來就對 GC 參數進行調整,當然有明確配置錯誤的場景除外。
控制變量:控制變量法是在蒙特卡洛(Monte Carlo)方法中用於減少方差的一種技術方法,我們調優的時候儘量也要使用,每次調優過程儘可能只調整一個變量。
善用搜索:理論上 99.99% 的 GC 問題基本都被遇到了,我們要學會使用搜尋引擎的高級技巧,重點關注 StackOverFlow、Github 上的 Issue、以及各種論壇博客,先看看其他人是怎麼解決的,會讓解決問題事半功倍。能看到這篇文章,你的搜索能力基本過關了~
調優重點:總體上來講,我們開發的過程中遇到的問題類型也基本都符合正態分布,太簡單或太複雜的基本遇到的概率很低,筆者這裡將中間最重要的三個場景添加了「*」標識,希望閱讀完本文之後可以觀察下自己負責的系統,是否存在上述問題。
GC 參數:如果堆、棧確實無法第一時間保留,一定要保留 GC 日誌,這樣我們最起碼可以看到 GC Cause,有一個大概的排查方向。關於 GC 日誌相關參數,最基本的 -XX:+HeapDumpOnOutOfMemoryError 等一些參數就不再提了,筆者建議添加以下參數,可以提高我們分析問題的效率。
6. 寫在最後
最後,再說筆者個人的一些小建議,遇到一些 GC 問題,如果有精力,一定要探本窮源,找出最深層次的原因。另外,在這個信息泛濫的時代,有一些被「奉為圭臬」的經驗可能都是錯誤的,儘量養成看源碼的習慣,有一句話說到「源碼面前,了無秘密」,也就意味著遇到搞不懂的問題,我們可以從源碼中一窺究竟,某些場景下確有奇效。但也不是只靠讀源碼來學習,如果硬啃源碼但不理會其背後可能蘊含的理論基礎,那很容易「撿芝麻丟西瓜」,「只見樹木,不見森林」,讓「了無秘密」變成了一句空話,我們還是要結合一些實際的業務場景去針對性地學習。
你的時間在哪裡,你的成就就會在哪裡。筆者也是在前兩年才開始逐步地在 GC 方向上不斷深入,查問題、看源碼、做總結,每個 Case 形成一個小的閉環,目前初步摸到了 GC 問題處理的一些門道,同時將經驗總結應用於生產環境實踐,慢慢地形成一個良性循環。
本篇文章主要是介紹了 CMS GC 的一些常見場景分析,另外一些,如 CodeCache 問題導致 JIT 失效、SafePoint 就緒時間長、Card Table 掃描耗時等問題不太常見就沒有花太多篇幅去講解。Java GC 是在「分代」的思想下內卷了很多年才突破到了「分區」,目前在美團也已經開始使用 G1 來替換使用了多年的 CMS,雖然在小的堆方面 G1 還略遜色於 CMS,但這是一個趨勢,短時間無法升級到 ZGC,所以未來遇到的 G1 的問題可能會逐漸增多。目前已經收集到 Remember Set 粗化、Humongous 分配、Ergonomics 異常、Mixed GC 中 Evacuation Failure 等問題,除此之外也會給出 CMS 升級到 G1 的一些建議,接下來筆者將繼續完成這部分文章整理,敬請期待。
「防火」永遠要勝於「救火」,不放過任何一個異常的小指標(一般來說,任何不平滑的曲線都是值得懷疑的) ,就有可能避免一次故障的發生。作為 Java 程式設計師基本都會遇到一些 GC 的問題,獨立解決 GC 問題是我們必須邁過的一道坎。開篇中也提到過 GC 作為經典的技術,非常值得我們學習,一些 GC 的學習材料,如《The Garbage Collection Handbook》、《深入理解Java虛擬機》等也是常讀常新,趕緊動起來,苦練 GC 基本功吧。
最後的最後,再多囉嗦一句,目前所有 GC 調優相關的文章,第一句講的就是「不要過早優化」,使得很多同學對 GC 優化望而卻步。在這裡筆者提出不一樣的觀點,熵增定律(在一個孤立系統裡,如果沒有外力做功,其總混亂度(即熵)會不斷增大)在計算機系統同樣適用,如果不主動做功使熵減,系統終究會脫離你的掌控,在我們對業務系統和 GC 原理掌握得足夠深的時候,可以放心大膽地做優化,因為我們基本可以預測到每一個操作的結果,放手一搏吧,少年!
7. 參考資料
[1]《ガベージコレクションのアルゴリズムと実裝》中村 成洋 / 相川 光
[2]《The Garbage Collection Handbook》 Richard Jones/ Antony Hosking / Eliot Moss
[3]《深入理解Java虛擬機(第3版)》 周志明
[4]《Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide》
[5]《Shipilev One Page Blog》 Shipilëv
[6] https://openjdk.java.net/projects/jdk/15/
[7] https://jcp.org/en/home/index
[8]《A Generational Mostly-concurrent Garbage Collector》 Tony Printezis / David Detlefs
[9]《Java Memory Management White Paper》
[10]《Stuff Happens:Understanding Causation in Policy and Strategy》AA Hill
8. 作者簡介
新宇,2015 年加入美團,到店住宿門票業務開發工程師。湘銘,2018 年加入美團,到店客戶平臺開發工程師。祥璞,2018 年加入美團,到店客戶平臺開發工程師。