服务治理实战——闲时主动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下的具体情况就留给大家自己去探索啦。

参考资料

在这里插入图片描述

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