服務治理實戰——閒時主動GC

服務治理實戰——閒時主動GC

前言

看到這個標題可能有些同學會質疑:什麼?Java的GC還能主動去做?——您別說,還真的可以。我來給大家分享一下在之前公司做的這個有意思的功能:在夜半無人私語時、業務流量低谷中主動去做一個高效的Full GC。

緣由

就算可以主動執行GC,可是爲什麼要這麼做呢?
對象固有一死,GC這玩意是早晚都要發生的。如果不巧在業務流量高峯的時候old gen不夠用了給你來一個Full GC,就會影響系統的響應和吞吐量。尤其是當機器物理內存不太夠的時候,JVM的old gen部分很容易就被切換到swap中去。然後當需要做Full GC的時候,還得把所有old gen都swap in回內存,這樣就會把GC弄得很慢,引發業務超時。因此,我們纔有了這樣一個主意:既然GC如同生老病死一樣是無法避免的,發生的時候又會STW,何不在流量低谷的時候主動去做呢?而且,定期主動清理old gen,還便於觀察是否存在內存泄漏(歷次GC後old gen佔用圖線中的底部逐漸擡升)。

適用場景

接入閒時主動GC這個功能的唯一要求就是:你不能設置-XX:+DisableExplicitGC。如果你沒有盲從一些網上的建議打開了這個設置,那麼只要再加上幾個JVM啓動參數設置(下文會介紹),就可以愉快的使用這個功能了。而且,這個功能是CMS/G1通用的。
ps,用CMS還是G1?在Java 7/8中,R大建議以8G爲界,8G以下用CMS。下面的介紹我們將以CMS爲例。
現在的電商公司,早十晚八有專場,週週有活動,月月有大促,流量都是週期性明顯的洪峯與波谷。在有主動GC這個功能之前,爲了迎接大促,業務域的負責人都要約好運維的同事提前重啓一些old gen較滿的機器,然後再執行必要的預熱措施,以求順利過大促不出P0。有了這個功能以後,你的Java應用就能定時主動地清理內存,無需勞煩運維的同學起夜重啓機器了 😃

代碼實現

Talk is cheap,讓我們來看看具體的代碼實現:

package com.vip.vjstar.gc;

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.vip.vjtools.vjkit.number.UnitConverter;

/**
 * Detect old gen usage of current jvm periodically and trigger a cms gc if necessary.<br/>
 * In order to enable this feature, add these options to your target jvm:<br/>
 * -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+ExplicitGCInvokesConcurrent<br/>
 * You can alter this class to work on a remote jvm using jmx.
 */
public class ProactiveGcTask implements Runnable {
	private static Logger logger = LoggerFactory.getLogger(ProactiveGcTask.class);

	protected CleanUpScheduler scheduler;
	protected int oldGenOccupancyFraction;

	protected MemoryPoolMXBean oldGenMemoryPool;
	protected long maxOldGenBytes;
	protected boolean valid;

	public ProactiveGcTask(CleanUpScheduler scheduler, int oldGenOccupancyFraction) {
		this.scheduler = scheduler;
		this.oldGenOccupancyFraction = oldGenOccupancyFraction;
		this.oldGenMemoryPool = getOldGenMemoryPool();

		if (oldGenMemoryPool != null && oldGenMemoryPool.isValid()) {
			this.maxOldGenBytes = getMemoryPoolMaxOrCommitted(oldGenMemoryPool);
			this.valid = true;
		} else {
			this.valid = false;
		}
	}

	public void run() {
		if (!valid) {
			logger.warn("OldMemoryPool is not valid, task stop.");
			return;
		}

		try {
			long usedOldGenBytes = logOldGenStatus("checking oldgen status");

			if (needTriggerGc(maxOldGenBytes, usedOldGenBytes, oldGenOccupancyFraction)) {
				preGc();
				doGc();
				postGc();
			}
		} catch (Exception e) {
			logger.error(e.getMessage(), e);
		} finally {
			scheduler.reschedule(this);
		}
	}

	/**
	 * Determine whether or not to trigger gc.
	 */
	private boolean needTriggerGc(long capacityBytes, long usedBytes, int occupancyFraction) {
		return (occupancyFraction * capacityBytes / 100) < usedBytes;
	}

	/**
	 * Suggests gc.
	 */
	protected void doGc() {
		System.gc(); // NOSONAR
	}

	/**
	 * Stuff before gc. You can override this method to do your own stuff, for example, cache clean up, deregister from register center.
	 */
	protected void preGc() {
		logger.warn("old gen is occupied larger than occupancy fraction[{}], trying to trigger gc...",
				oldGenOccupancyFraction);
	}

	/**
	 * Stuff after gc. You can override this method to do your own stuff, for example, cache warmup, reregister to register center.
	 */
	protected void postGc() {
		logOldGenStatus("post gc");
	}

	protected long logOldGenStatus(String hints) {
		long usedOldBytes = oldGenMemoryPool.getUsage().getUsed();
		logger.info(String.format("%s, max old gen:%s, used old gen:%s, current fraction: %.2f%%, gc fraction: %d%%",
				hints, UnitConverter.toSizeUnit(maxOldGenBytes, 2), UnitConverter.toSizeUnit(usedOldBytes, 2),
				usedOldBytes * 100d / maxOldGenBytes, oldGenOccupancyFraction));
		return usedOldBytes;
	}

	private MemoryPoolMXBean getOldGenMemoryPool() {
		String OLD = "old";
		String TENURED = "tenured";

		MemoryPoolMXBean oldGenMemoryPool = null;
		List<MemoryPoolMXBean> memoryPoolMXBeans = ManagementFactory.getPlatformMXBeans(MemoryPoolMXBean.class);
		for (MemoryPoolMXBean memoryPool : memoryPoolMXBeans) {
			String name = memoryPool.getName().trim().toLowerCase();
			if (name.contains(OLD) || name.contains(TENURED)) {
				oldGenMemoryPool = memoryPool;
				break;
			}
		}

		return oldGenMemoryPool;
	}

	private long getMemoryPoolMaxOrCommitted(MemoryPoolMXBean memoryPool) {
		MemoryUsage usage = memoryPool.getUsage();
		long max = usage.getMax();
		return max < 0 ? usage.getCommitted() : max;
	}
}

先看註釋,-XX:+UseConcMarkSweepGC是啓用CMS GC,-XX:CMSInitiatingOccupancyFraction=75設定了old gen佔用達到75%的時候觸發CMS GC,-XX:+ExplicitGCInvokesConcurrent這個參數很關鍵,這裏先賣個關子,下文會詳細介紹。另外,還建議加上-XX:+UseCMSInitiatingOccupancyOnly這個參數,否則75%只被用來做開始的參考值,後面還是JVM自己算。

ProactiveGcTask本質上是一個Runnable,構造器有兩個入參:scheduler(CleanUpScheduler)和oldGenOccupancyFraction(int)。scheduler用於設置閒時時間段並定期調度自身,oldGenOccupancyFraction則是說在本任務執行中如果發現old gen佔用百分比達到這個閾值就執行主動GC。

實例變量oldGenMemoryPool是代表old gen的MemoryPoolMXBeanMemoryPoolMXBean是JMX提供的內存池的管理接口,getOldGenMemoryPool()方法展示瞭如何獲取的過程。獲取到old gen以後,傳給getMemoryPoolMaxOrCommitted(MemoryPoolMXBean memoryPool)方法計算得到old gen的設定值大小maxOldGenByteslogOldGenStatus(String hints)方法獲取當前old gen的佔用大小並記錄日誌。

preGc()方法在觸發GC之前執行,可以做一些緩存清理、從註冊中心主動摘流等操作。postGc()方法在GC之後執行,可以做一些緩存預熱、重新註冊到註冊中心等操作。用戶可以繼承擴展本類重寫這兩個方法,執行一些定製化的操作。doGc()方法觸發GC,裏面只有一句:System.gc()。很簡單,很魔幻是不是?等等,不是說System.gc()只能“建議”JVM並不保證會執行GC麼?不,在我們這裏是一定會執行,而且觸發的還是高效的Full GC,具體原因請看下文的原理解析。

經過上面的解釋,ProactiveGcTask作爲一個Runnable本身的run()方法所做的事情就很清晰了:首先檢測獲取old gen的行爲成功了沒有,如果成功則檢測當前old gen的使用量,然後除以maxOldGenBytes得到當前old gen佔用的百分比,如果比設定的oldGenOccupancyFraction大,則依次執行preGc()doGc()postGc(),最後在finally塊中reschedule自身實現循環定期。

CleanUpScheduler類的代碼在這裏,使用內置的ScheduledExecutorService實現定時功能,同時提供了一個getDelayMillsList(String schedulePlans)方法支持靈活的定時配置,eg. 03:00-05:00,13:00-14:00表示在凌晨3點到5點以及下午1點到2點這兩個時間段內分別選取一個隨機的時間去執行主動GC(一天兩個洪峯,需要做兩次清理)。

如何使用

在需要使用主動GC的應用引入如上代碼,根據自身需要可以繼承擴展ProactiveGcTask然後重寫preGc()postGc()方法執行一些定製化的行爲,同時結合自身應用的實際情況調整修改oldGenOccupancyFraction參數和CleanUpScheduler的定時配置參數以達到最優效果。

效果如何

主動GC功能從18年初開始規劃、設計、實現,經過不斷的測試和打磨,成爲當年推動(忽悠)用戶升級版本的亮點功能之一納入到前公司的服務治理框架,並最終釋放到明星開源項目vjtools(6k+ star)中。在當時的同類服務治理框架中,此功能屬於業內首創。開源後前老大江南白衣還親自操刀重構了一遍,其在今年QCon的服務治理專題演講中也專門提到了這個功能。主動GC這個功能在生產中被部署到數千臺服務治理side car(OSP Proxy)機器上,歷經多次店慶、雙十一等百億級別流量洪峯的考驗,業已證明其自身的實用價值。
在生產機器上運行的效果圖例如下所示。
單臺機器:
在這裏插入圖片描述
小規模機器集羣(錯開執行時間點避免某個瞬間服務整體不可用):
在這裏插入圖片描述

會有問題嗎

這功能聽起來似乎還真的不錯,那我們是不是就能拍拍腦袋就直接上生產呢?會不會有什麼坑呢?
對於基礎組件來說,引入一個新的功能,需要經過方案評審,代碼review,功能測試,性能測試等層層關卡。新上線的功能點還要求必須要有開關,相關參數設置必須可配置化,而且所有配置都必須有默認值。這樣就算萬一漏了bug到線上還能通過配置中心動態關掉,把影響儘可能降到最小。
對於主動GC這個功能來說,默認的啓動時間段是凌晨三點前後。這個時間段對大部分業務系統來說算是“閒時”,可是有少部分系統也是半夜纔開始忙活的。比如,跑批處理的作業系統,基線壓測等。這個功能上線後就有測試同學反饋說怎麼有時候基線測試報告有點波動?經排查才發現是主動GC的隨機時間段剛好跟基線測試運行時間段重疊了,所以造成了基線測試的波動(old gen達到50%就GC畢竟和原來的75%才GC有差別)。
解決的方法不止一種,這裏簡單提供下思路:

  • 執行基線壓測前通過配置中心關掉主動GC的功能,測試完成後再打開;
  • 優化代碼,在執行前判斷當前系統負載(譬如業務線程池,CPU等)如果在忙,就不執行主動GC;
  • 添加代碼,使用機器學習等方式去學習和推算出一個最優執行時間段和觸發閾值去執行主動GC——所謂的AIOPS;

怎麼測試

集成測試和功能測試用例沒有放到開源庫中,這裏簡單提供下IT怎麼寫的思路:

  • 寫一個擴展FilterLitterFilter專門用於“搞大”內存(eg. 不停地生成隨機串放到List中hold住不釋放,直至old gen佔用達到閾值以上),將此filter動態插入到服務治理框架原有的Filter鏈中;

  • 修改默認配置,調低oldGenOccupancyFraction,縮短scheduler的調度執行時間(或者使用閉鎖,阻塞等到內存被搞大了才放行);

  • 擴展ProactiveGcTask,重寫preGcpostGc方法,preGc中clear掉LitterFilter中的List(不然GC時候因爲仍然有強引用而無法清理),postGc中添加斷言檢測old gen確實被清理乾淨並且佔用比在設定的閾值之下;

背後的原理

在本節,我們來回答前面拋出的關鍵問題:爲何一定會執行GC?而且還是個高效的Full GC?問題的答案,藏在JDK的源代碼之中。
首先,我們來看看System.gc()的源代碼:

    /**
     * Runs the garbage collector.
     * <p>
     * Calling the <code>gc</code> method suggests that the Java Virtual
     * Machine expend effort toward recycling unused objects in order to
     * make the memory they currently occupy available for quick reuse.
     * When control returns from the method call, the Java Virtual
     * Machine has made a best effort to reclaim space from all discarded
     * objects.
     * <p>
     * The call <code>System.gc()</code> is effectively equivalent to the
     * call:
     * <blockquote><pre>
     * Runtime.getRuntime().gc()
     * </pre></blockquote>
     *
     * @see     java.lang.Runtime#gc()
     */
    public static void gc() {
        Runtime.getRuntime().gc();
    }

這個方法其實是調用Runtime.getRuntime().gc()

    /**
     * Runs the garbage collector.
     * Calling this method suggests that the Java virtual machine expend
     * effort toward recycling unused objects in order to make the memory
     * they currently occupy available for quick reuse. When control
     * returns from the method call, the virtual machine has made
     * its best effort to recycle all discarded objects.
     * <p>
     * The name <code>gc</code> stands for "garbage
     * collector". The virtual machine performs this recycling
     * process automatically as needed, in a separate thread, even if the
     * <code>gc</code> method is not invoked explicitly.
     * <p>
     * The method {@link System#gc()} is the conventional and convenient
     * means of invoking this method.
     */
    public native void gc();

哎呀,這是一個native方法,這對於一個普通的Java程序員來說,就意味着已經到頭了,再往下就是C/C++的禁忌領域了。怎麼辦?設想一下,難道女神在你面前說聲不要你就收手放棄了嗎?當然不!我們應該更加勇敢地越過道德的邊境,走進愛的禁區!

調整一下呼吸,我們繼續前進。首先要下載到對應的JDK源代碼,鏈接在這裏。然後參考這裏的建議,稍微花點時間,你就能定位到native方法Runtime.getRuntime().gc()對應的實現(位於openjdk8/jdk/src/share/native/java/lang/Runtime.c):

JNIEXPORT void JNICALL
Java_java_lang_Runtime_gc(JNIEnv *env, jobject this)
{
    JVM_GC();
}

原來是調用了JVM_GC()方法,我們繼續找找JVM_GC()的實現(openjdk8//hotspot/src/share/vm/prims/jvm.cpp):

JVM_ENTRY_NO_ENV(void, JVM_GC(void))
  JVMWrapper("JVM_GC");
  if (!DisableExplicitGC) {
    Universe::heap()->collect(GCCause::_java_lang_system_gc);
  }
JVM_END

看到這裏,我們可以看出來,只要這個變量爲false,調用System.gc()都一定會觸發GC。這個變量的默認值在這裏(openjdk8/hotspot/src/share/vm/runtime/globals.hpp):

product(bool, DisableExplicitGC, false,                                   
          "Ignore calls to System.gc()")

默認值爲false,所以只要沒有顯式設置-XX:+DisableExplicitGC,都一定能夠觸發GC

接下來我們詳細看看-XX:+ExplicitGCInvokesConcurrent到底意味着什麼。在上面的代碼中,我們看到主要是調用了heapcollect方法,對應的代碼(openjdk8/hotspot/src/share/vm/memory/genCollectedHeap.cpp):

void GenCollectedHeap::collect(GCCause::Cause cause) {
  if (should_do_concurrent_full_gc(cause)) {
#if INCLUDE_ALL_GCS
    // mostly concurrent full collection
    collect_mostly_concurrent(cause);
#else  // INCLUDE_ALL_GCS
    ShouldNotReachHere();
#endif // INCLUDE_ALL_GCS
  }
  ...
}

should_do_concurrent_full_gc方法就在同一個文件中:

bool GenCollectedHeap::should_do_concurrent_full_gc(GCCause::Cause cause) {
  return UseConcMarkSweepGC &&
         ((cause == GCCause::_gc_locker && GCLockerInvokesConcurrent) ||
          (cause == GCCause::_java_lang_system_gc && ExplicitGCInvokesConcurrent));
}

可以看到,如果使用了CMS,並且打開了-XX:+ExplicitGCInvokesConcurrent選項,調用System.gc()就會觸發JVM去執行一個名爲collect_mostly_concurrent(cause)的方法,其實就是一個background模式的CMS GC。CMS GC是走的background的,整個暫停的過程主要是YGC+CMS_initMark+CMS_remark幾個階段。所謂的高效的Full GC就體現在這裏。具體算法過程就不繼續展開了,有興趣的同學可以自行繼續深挖 😃

另外,不是還說過CMS/G1通用的麼?其實G1只是分散版的CMS,大部分option是通用的。不信請看下圖:
在這裏插入圖片描述

繪畫需留白,撰文也同樣如此,G1下的具體情況就留給大家自己去探索啦。

參考資料

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章