Java 线程池实现原理及其在美团业务中的实践
from 2020美团技术年货合集
随着计算机行业的飞速发展,摩尔定律逐渐失效,多核CPU成为主流。使用多线程并行计算逐渐成为开发人员提升服务器性能的基本武器。J.U.C提供的线程池:ThreadPoolExecutor类,帮助开发人员管理线程并方便地执行并行任务。了解并合理使用线程池,是一个开发人员必修的基本功。
本文开篇简述线程池概念和用途,接着结合线程池的源码,帮助读者领略线程池的设计思路,最后回归实践,通过案例讲述使用线程池遇到的问题,并给出了一种动态化线程池解决方案。
一、写在前面
1.1线程池是什么
线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。
线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。
而本文描述线程池是JDK中提供的ThreadPoolExecutor类。
当然,使用线程池可以带来一系列好处:
降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
提高响应速度:任务到达时,无需等待线程创建即可立即执行。
提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用
线程池可以进行统一的分配、调优和监控。
提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任
务延期执行或定期执行。
1.2线程池解决的问题是什么
线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:
频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
系统无法合理管理内部的资源分布,会降低系统的稳定性。
为解决资源分配这个问题,线程池采用了“池化”(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。
Pooling is the grouping together of resources (assets, equipment, personnel,effort, etc.) for the purposes of maximizing advantage or minimizing risk tothe users. The term is used in finance, computing and equipment manage-ment.——wikipedia
“池化”思想不仅仅能应用在计算机领域,在金融、设备、人员管理、工作管理等领域也有相关的应用。
在计算机领域中的表现为:统一管理IT资源,包括服务器、存储、和网络资源等等。通过共享资源,使用户在低投入中获益。除去线程池,还有其他比较典型的几种使用策略包括:
内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。
在了解完“是什么”和“为什么”之后,下面我们来一起深入一下线程池的内部实现原理。
二、线程池核心设计与实现
在前文中,我们了解到:线程池是一种通过“池化”思想,帮助我们管理线程而获取并发性的工具,在Java中的体现是ThreadPoolExecutor类。那么它的的详细设计与实现是什么样的呢?我们会在本章进行详细介绍。
2.1总体设计
Java中的线程池核心实现类是ThreadPoolExecutor,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。
图1ThreadPoolExecutor UML类图
ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。ExecutorService接口增加了一些能力:(1)扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;(2)提供了管控线程池的方法,比如停止线程池的运行。AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。
ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?其运行机制如下图所示:
图2ThreadPoolExecutor运行流程
线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(1)直接申请线程执行该任务;(2)缓冲到队列中等待线程执行;(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
接下来,我们会按照以下三个部分去详细讲解线程池运行机制:
线程池如何维护自身状态。
线程池如何管理任务。
线程池如何管理线程。
2.2生命周期管理
线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个变量维护两个值:运行状态(runState)和线程数量 (work-erCount)。在具体实现中,线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起,如下代码所示:
private final AtomicInteger ctl =new AtomicInteger(ctlOf(RUNNING,0));
ctl这个AtomicInteger类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段, 它同时包含两部分的信息:线程池的运行状态 (runState) 和
线程池内有效线程的数量 (workerCount),高3位保存runState,低29位保存workerCount,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。
关于内部封装的获取生命周期状态、获取线程池线程数量的计算方法如以下代码所示:
private static int runStateOf(int c) { return c & ~CAPACITY; } // 计算当前运行状态
private static int workerCountOf(int c) { return c & CAPACITY; } //计算当前线程数量
private static int ctlOf(int rs, int wc) { return rs | wc; } // 通过状态和线程数生成 ctl
ThreadPoolExecutor的运行状态有5种,分别为:
其生命周期转换如下入所示:
图 3 线程池生命周期
2.3任务执行机制
2.3.1任务调度
任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。
首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:
首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
如果workerCount >= corePoolSize && workerCount < maximum-PoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满,则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
其执行流程如下图所示:
图 4 任务调度流程
2.3.2任务缓冲
任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,
而消费者也只从容器里拿元素。
下图中展示了线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素:
图 5 阻塞队列
使用不同的队列可以实现不一样的任务存取策略。在这里,我们可以再介绍下阻塞队列的成员:
2.3.3任务申请
由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。
线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。这部分策略由getTask方法实现,其执行流程如下图所示:
图6获取任务流程图
getTask这部分进行了多次判断,为的是控制线程的数量,使其符合线程池的状态。如果线程池现在不应该持有那么多线程,则会返回null值。工作线程Worker会不断接收新任务去执行,而当工作线程Worker接收不到任务的时候,就会开始被回收。
2.3.4任务拒绝
任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该
任务,采取任务拒绝策略,保护线程池。
拒绝策略是一个接口,其设计如下:
public interface RejectedExecutionHandler {
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:
2.4Worker线程管理
2.4.1Worker线程
线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker。我们来看一下它的部分代码:
private final class Worker extends AbstractQueuedSynchronizer
implements Runnable{
final Thread thread;//Worker 持有的线程
Runnable firstTask;// 初始化的任务,可以为 null
}
Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。
Worker执行任务的模型如下图所示:
图7Worker执行任务
线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。
Worker是通过继承AQS,使用AQS来实现独占锁这个功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。
lock方法一旦获取了独占锁,表示当前线程正在执行任务中。
如果正在执行任务,则不应该中断线程。
如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。
线程池在执行shutdown方法或tryTerminate方法时会调用interruptI-dleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。
在线程回收过程中就使用到了这种特性,回收过程如下图所示:
图 8 线程池回收过程
2.4.2Worker线程增加
增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask、core。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:
图 9 申请线程执行流程图
2.4.3Worker线程回收
线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。
线程回收的工作是在processWorkerExit方法完成的。
图10线程销毁流程
事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。
2.4.4Worker线程执行任务
在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:
while循环不断地通过getTask()方法获取任务。
getTask()方法从阻塞队列中取任务。
如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。
执行任务。
如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。
执行流程如下图所示:
图 11 执行任务流程
三、线程池在业务中的实践
3.1业务背景
在当今的互联网业界,为了最大程度利用CPU的多核性能,并行运算的能力是不可或缺的。通过线程池管理线程获取并发性是一个非常基础的操作,让我们来看两个典型的使用线程池获取并发性的场景。
场景1:快速响应用户请求
描述:用户发起的实时请求,服务追求响应时间。比如说用户要查看一个商品的信息,那么我们需要将商品维度的一系列信息如商品的价格、优惠、库存、图片等等聚
合起来,展示给用户。
分析:从用户体验角度看,这个结果响应的越快越好,如果一个页面半天都刷不出,用户可能就放弃查看这个商品了。而面向用户的功能聚合通常非常复杂,伴随着调用
与调用之间的级联、多级级联等情况,业务开发同学往往会选择使用线程池这种简单的方式,将调用封装成任务并行的执行,缩短总体响应时间。另外,使用线程池也是有考量的,这种场景最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。
图 12 并行执行任务提升任务响应速度
场景2:快速处理批量任务
描述:离线的大量计算任务,需要快速执行。比如说,统计某个报表,需要计算出全国各个门店中有哪些商品有某种属性,用于后续营销策略的分析,那么我们需要查询
全国所有门店中的所有商品,并且记录具有某属性的商品,然后快速生成报表。
分析:这种场景需要执行大量的任务,我们也会希望任务执行的越快越好。这种情况下,也应该使用多线程策略,并行计算。但与响应速度优先的场景区别在于,这类
场景任务量巨大,并不需要瞬时的完成,而是关注如何使用有限的资源,尽可能在单位时间内处理更多的任务,也就是吞吐量优先的问题。所以应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。在这里,设置的线程数过多可能还会引发线程上下文切换频繁的问题,也会降低处理任务的速度,降低吞吐量。
图 13 并行执行任务提升批量任务执行速度
3.2实际问题及方案思考
线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。
关于线程池配置不合理引发的故障,公司内部有较多记录,下面举一些例子:
Case1:2018年XX页面展示接口大量调用降级:
事故描述:XX页面展示接口产生大量调用降级,数量级在几十到上百。
事故原因:该服务展示接口内部逻辑使用线程池做并行计算,由于没有预估好调用的流量,导致最大核心数设置偏小,大量抛出RejectedExecutionException,触发接
口降级条件,示意图如下:
图 14 线程数核心设置过小引发 RejectExecutionException
Case2:2018年XX业务服务不可用S2级故障
事故描述:XX业务提供的服务执行时间过长,作为上游服务整体超时,大量下游服务调用失败。
事故原因:该服务处理请求内部逻辑使用线程池做资源隔离,由于队列设置过长,最大线程数设置失效,导致请求数量增加时,大量任务堆积在队列中,任务执行时间过
长,最终导致下游服务的大量调用超时失败。示意图如下:
图 15 线程池队列长度设置过长、corePoolSize 设置过小导致任务执行速度低
业务中要使用线程池,而使用不当又会导致故障,那么我们怎样才能更好地使用线程池呢?针对这个问题,我们下面延展几个方向:
能否不用线程池?
回到最初的问题,业务使用线程池是为了获取并发性,对于获取并发性,是否可以有什么其他的方案呢替代?我们尝试进行了一些其他方案的调研:
综合考虑,这些新的方案都能在某种情况下提升并行任务的性能,然而本次重点解决的问题是如何更简易、更安全地获得的并发性。另外,Actor模型的应用实际上甚少,只在Scala中使用广泛,协程框架在Java中维护的也不成熟。这三者现阶段都不是足够的易用,也并不能解决业务上现阶段的问题。
追求参数设置合理性?
有没有一种计算公式,能够让开发同学很简易地计算出某种场景中的线程池应该是什么参数呢?
带着这样的疑问,我们调研了业界的一些线程池参数配置方案:
调研了以上业界方案后,我们并没有得出通用的线程池计算方式。并发任务的执行情况和任务类型相关,IO密集型和CPU密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。
线程池参数动态化?
尽管经过谨慎的评估,仍然不能够保证一次计算出来合适的参数,那么我们是否可以将修改线程池参数的成本降下来,这样至少可以发生故障的时候可以快速调整从而缩短故障恢复的时间呢?基于这个思考,我们是否可以将线程池的参数从代码中迁移到分布式配置中心上,实现线程池参数可动态配置和即时生效,线程池参数动态化前后的参数修改流程对比如下:
图16动态修改线程池参数新旧流程对比
基于以上三个方向对比,我们可以看出参数动态化方向简单有效。
3.3动态化线程池
3.3.1整体设计
动态化线程池的核心设计包括以下三个方面:
简化线程池配置:线程池构造参数有8个,但是最核心的是3个:corePool-Size、maximumPoolSize,workQueue,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:(1)并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。
参数可动态修改:为了解决参数不好配,修改参数成本高等问题。在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。
增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。
图 17 动态化线程池整体设计
3.3.2 功能架构
动态化线程池提供如下功能:
动态调参:支持线程池参数动态调整、界面化操作;包括修改线程池核心大小、最大核心大小、队列长度等;参数修改后及时生效。任务监控:支持应用粒度、线程池粒度、任务粒度的Transaction监控;可以看到线程池的任务执行情况、最大任务执行
时间、平均任务执行时间、95/99线等。负载告警:线程池队列任务积压到一定值的时候会通过大象(美团内部通讯工具)告知应用开发负责人;当线程池负载数达到一定阈值的时候会通过大象告知应用开发负责人。操作监控:创建/修改和删除线程池都会通知到应用的开发负责人。操作日志:可以查看线程池参数的修改记录,谁在什么时候修改了线程池参数、修改前的参数值是什么。权限校验:只有应用开发负责人才能够修改应用的线程池参数。
图 18 动态化线程池功能架构
参数动态化
JDK原生线程池ThreadPoolExecutor提供了如下几个public的setter方法,如下图所示:
图19JDK 线程池参数设置接口
JDK允许线程池使用方通过ThreadPoolExecutor的实例来动态设置线程池的核心策略,以setCorePoolSize为方法例,在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idel的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务,setCorePoolSize具体流程如下:
图20setCorePoolSize方法执行流程
线程池内部会处理好当前状态做到平滑修改,其他几个方法限于篇幅,这里不一一介绍。重点是基于这几个public方法,我们只需要维护ThreadPoolExecutor的实例,并且在需要修改的时候拿到实例修改其参数即可。基于以上的思路,我们实现了线程池参数的动态化、线程池参数在管理平台可配置可修改,其效果图如下图所示:
用户可以在管理平台上通过线程池的名字找到指定的线程池,然后对其参数进行修改,保存后会实时生效。目前支持的动态参数包括核心数、最大值、队列长度等。除此之外,在界面中,我们还能看到用户可以配置是否开启告警、队列等待任务告警阈值、活跃度告警等等。关于监控和告警,我们下面一节会对齐进行介绍。
线程池监控
除了参数动态化之外,为了更好地使用线程池,我们需要对线程池的运行状况有感知,比如当前线程池的负载是怎么样的?分配的资源够不够用?任务的执行情况是怎么样的?是长任务还是短任务?基于对这些问题的思考,动态化线程池提供了多个维度的监控和告警能力,包括:线程池活跃度、任务的执行Transaction(频率、耗时)、Reject异常、线程池内部统计信息等等,既能帮助用户从多个维度分析线程池的使用情况,又能在出现问题第一时间通知到用户,从而避免故障或加速故障恢复。
负载监控和告警
线程池负载关注的核心问题是:基于当前线程池参数分配的资源够不够。对于这个问题,我们可以从事前和事中两个角度来看。事前,线程池定义了“活跃度”这个概念,来让用户在发生Reject异常之前能够感知线程池负载问题,线程池活跃度计算公式为:线程池活跃度 = activeCount/maximumPoolSize。这个公式代表当活跃线程数趋向于maximumPoolSize的时候,代表线程负载趋高。事中,也可以从两方面来看线程池的过载判定条件,一个是发生了Reject异常,一个是队列中有等待任务(支持定制阈值)。以上两种情况发生了都会触发告警,告警信息会通过大象推送给服务所关联的负责人。
图 22 大象告警通知
2. 任务级精细化监控
在传统的线程池应用场景中,线程池中的任务执行情况对于用户来说是透明的。比如在一个具体的业务场景中,业务开发申请了一个线程池同时用于执行两种任务,一个是发消息任务、一个是发短信任务,这两类任务实际执行的频率和时长对于用户来说没有一个直观的感受,很可能这两类任务不适合共享一个线程池,但是由于用户无法感知,因此也无从优化。动态化线程池内部实现了任务级别的埋点,且允许为不同的业务任务指定具有业务含义的名称,线程池内部基于这个名称做Transaction打点,基于这个功能,用户可以看到线程池内部任务级别的执行情况,且区分业务,任务监控示意图如下图所示:
图 23 线程池任务执行监控
3. 运行时状态实时查看
用户基于JDK原生线程池ThreadPoolExecutor提供的几个public的getter方法,可以读取到当前线程池的运行状态以及参数,如下图所示:
图 24 线程池实时运行情况
动态化线程池基于这几个接口封装了运行时状态实时查看的功能,用户基于这个功能可以了解线程池的实时状态,比如当前有多少个工作线程,执行了多少个任务,队列中等待的任务数等等。效果如下图所示:
图 25 线程池实时运行情况
3.4实践总结
面对业务中使用线程池遇到的实际问题,我们曾回到支持并发性问题本身来思考有没有取代线程池的方案,也曾尝试着去追求线程池参数设置的合理性,但面对业界方案具体落地的复杂性、可维护性以及真实运行环境的不确定性,我们在前两个方向上可谓“举步维艰”。最终,我们回到线程池参数动态化方向上探索,得出一个且可以解决业务问题的方案,虽然本质上还是没有逃离使用线程池的范畴,但是在成本和收益之间,算是取得了一个很好的平衡。成本在于实现动态化以及监控成本不高,收益在于:在不颠覆原有线程池使用方式的基础之上,从降低线程池参数修改的成本以及多维度监控这两个方面降低了故障发生的概率。希望本文提供的动态化线程池思路能对大家有帮助。
Java 中 9 种常见的 CMS GC 问题分析与解决
1. 写在前面
| 本文主要针对 Hotspot VM 中“CMS + ParNew”组合的一些使用场景进行总结。重点通过部分源码对根因进行分析以及对排查方法进行总结,排查过程会省略较多,另外本文专业术语较多,有一定的阅读门槛,如未介绍清楚,还请自行查阅相关材料。
| 总字数 2 万左右(不包含代码片段),整体阅读时间约 30min ,文章较长,可以选择你感兴趣的场景进行研究。
1.1引言
自 Sun 发布 Java 语言以来,开始使用 GC 技术来进行内存自动管理,避免了手动管理带来的悬挂指针(Dangling Pointer)问题,很大程度上提升了开发效率,从此GC 技术也一举成名。GC 有着非常悠久的历史,1960 年有着“Lisp 之父”和“人工智能之父”之称的 John McCarthy 就在论文中发布了 GC 算法,60 年以来, GC技术的发展也突飞猛进,但不管是多么前沿的收集器也都是基于三种基本算法的组合或应用,也就是说 GC 要解决的根本问题这么多年一直都没有变过。笔者认为,在不太远的将来, GC 技术依然不会过时,比起日新月异的新技术,GC 这门古典技术更值得我们学习。
目前,互联网上 Java 的 GC 资料要么是主要讲解理论,要么就是针对单一场景的GC 问题进行了剖析,对整个体系总结的资料少之又少。前车之鉴,后事之师,美团的几位工程师搜集了内部各种 GC 问题的分析文章,并结合个人的理解做了一些总结,希望能起到“抛砖引玉”的作用,文中若有错误之处,还请大家不吝指正。
GC 问题处理能力能不能系统性掌握?一些影响因素都是互为因果的问题该怎么分析?比如一个服务 RT 突然上涨,有 GC 耗时增大、线程 Block 增多、慢查询增多、
CPU 负载高四个表象,到底哪个是诱因?如何判断 GC 有没有问题?使用 CMS 有哪些常见问题?如何判断根因是什么?如何解决或避免这些问题?阅读完本文,相信你将会对 CMS GC 的问题处理有一个系统性的认知,更能游刃有余地解决这些问题,下面就让我们开始吧!
1.2概览
想要系统性地掌握 GC 问题处理,笔者这里给出一个学习路径,整体文章的框架也是按照这个结构展开,主要分四大步。
建立知识体系:从 JVM 的内存结构到垃圾收集的算法和收集器,学习 GC 的基础知识,掌握一些常用的 GC 问题分析工具。
确定评价指标:了解基本 GC 的评价方法,摸清如何设定独立系统的指标,以及在业务场景中判断 GC 是否存在问题的手段。
场景调优实践:运用掌握的知识和系统评价指标,分析与解决九种 CMS 中常见 GC 问题场景。
总结优化经验:对整体过程做总结并提出笔者的几点建议,同时将总结到的经验完善到知识体系之中。
GC 基础
在正式开始前,先做些简要铺垫,介绍下 JVM 内存划分、收集算法、收集器等常用概念介绍,基础比较好的同学可以直接跳过这部分。
2.1基础概念
GC:GC 本身有三种语义,下文需要根据具体场景带入不同的语义:
GarbageCollection:垃圾收集技术,名词。
GarbageCollector:垃圾收集器,名词。
GarbageCollecting:垃圾收集动作,动词。
Mutator:生产垃圾的角色,也就是我们的应用程序,垃圾制造者,通过Allocator 进行 allocate 和 free。
TLAB:Thread Local Allocation Buffer 的简写,基于 CAS 的独享线程(Mutator Threads)可以优先将对象分配在 Eden 中的一块内存,因为是
Java 线程独享的内存区没有锁竞争,所以分配速度更快,每个 TLAB 都是一
个线程独享的。
CardTable:中文翻译为卡表,主要是用来标记卡页的状态,每个卡表项对应一个卡页。当卡页中一个对象引用有写操作时,写屏障将会标记对象所在的卡
表状态改为 dirty,卡表的本质是用来解决跨代引用的问题。具体怎么解决的可以参考 StackOverflow 上的这个问题how-actually-card-table-and-writ-er-barrier-works,或者研读一下 cardTableRS.app 中的源码。
2.2JVM内存划分
从 JCP(Java Community Process)的官网中可以看到,目前 Java 版本最新已经到了 Java 16,未来的 Java 17 以及现在的 Java 11 和 Java 8 是 LTS 版本,JVM规范也在随着迭代在变更,由于本文主要讨论 CMS,此处还是放 Java 8 的内存结构。
GC 主要工作在 Heap 区和 MetaSpace 区(上图蓝色部分),在 Direct Memory 中,如果使用的是 DirectByteBuffer,那么在分配内存不够时则是 GC 通过Cleaner#-clean间接管理。
任何自动内存管理系统都会面临的步骤:为新对象分配空间,然后收集垃圾对象空间,下面我们就展开介绍一下这些基础知识。
2.3分配对象
Java 中对象地址操作主要使用 Unsafe 调用了 C 的 allocate 和 free 两个方法,分配方法有两种:
空闲链表(freelist):通过额外的存储记录空闲的地址,将随机 IO 变为顺序IO,但带来了额外的空间消耗。
碰撞指针(bumppointer):通过一个指针作为分界点,需要分配内存时,仅需把指针往空闲的一端移动与对象大小相等的距离,分配效率较高,但使用场
景有限。
2.4收集对象
2.4.1识别垃圾
●引用计数法(ReferenceCounting):对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大
于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
●可达性分析,又称引用链法(TracingGC):从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,
需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃
圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。
备注:引用计数法是可以处理循环引用问题的,下次面试时不要再这么说啦~ ~
2.4.2收集算法
自从有自动内存管理出现之时就有的一些收集算法,不同的收集器也是在不同场景下进行组合。
Mark-Sweep(标记-清除):回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的
每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。
Mark-Compact(标记-整理):这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep
类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。主要实现有双指针(Two-Finger)回收算法、滑动回收(Lisp2)算法和引线整理(Threaded Compaction)算法等。
Copying(复制):将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式
转移到另一个半区。有递归(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)算法,以及解决了前两者递归栈、缓存行等问题的近似优先搜索算法。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高的缺点,另外就是存活对象比较大时复制的成本比较高。
三种算法在是否移动对象、空间和时间方面的一些对比,假设存活对象数量为 *L*、堆空间大小为 *H*,则:
把 mark、sweep、compaction、copying 这几种动作的耗时放在一起看,大致有这样的关系:
虽然 compaction 与 copying 都涉及移动对象,但取决于具体算法,compaction 可能要先计算一次对象的目标地址,然后修正指针,最后再移动对象。copying 则可以把这几件事情合为一体来做,所以可以快一些。另外,还需要留意 GC 带来的开销不能只看 Collector 的耗时,还得看 Allocator 。如果能保证内存没碎片,分配就可以用 pointer bumping 方式,只需要挪一个指针就完成了分配,非常快。而如果内存有碎片就得用 freelist 之类的方式管理,分配速度通常会慢一些。
2.5收集器
目前在 Hotspot VM 中主要有分代收集和分区收集两大类,具体可以看下面的这个图,不过未来会逐渐向分区收集发展。在美团内部,有部分业务尝试用了 ZGC(感兴趣的同学可以学习下这篇文章新一代垃圾回收器ZGC的探索与实践),其余基本都
停留在 CMS 和 G1 上。另外在 JDK11 后提供了一个不执行任何垃圾回收动作的回收器 Epsilon(A No-Op Garbage Collector)用作性能分析。另外一个就是 Azul的 Zing JVM,其 C4(Concurrent Continuously Compacting Collector)收集器也在业内有一定的影响力。
备注:值得一提的是,早些年国内 GC 技术的布道者 RednaxelaFX( 江湖人称 R大)也曾就职于 Azul,本文的一部分材料也参考了他的一些文章。
2.5.1分代收集器
●ParNew:一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过-XX:ParallelGCThreads参数来控制收集的线程数,整个过程都是
STW 的,常与 CMS 组合使用。
●CMS:以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW ,多数应用于互联网站
或者 B/S 系统的服务器端上,JDK9 被标记弃用,JDK14 被删除,详情可见
JEP 363。
2.5.2分区收集器
G1:一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。
ZGC:JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿
时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
Shenandoah:由 Red Hat 的一个团队负责开发,与 G1 类似,基于Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来
记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC
接近,下图为与 CMS 和 G1 等收集器的 benchmark。
2.5.3常用收集器
目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要内存结构如下:
2.5.4其他收集器
以上仅列出常见收集器,除此之外还有很多,如 Metronome、Stopless、Stac-cato、Chicken、Clover 等实时回收器,Sapphire、Compressor、Pauseless 等并发复制/整理回收器,Doligez-Leroy-Conthier 等标记整理回收器,由于篇幅原因,不在此一一介绍。
2.6常用工具
工欲善其事,必先利其器,此处列出一些笔者常用的工具,具体情况大家可以自由选择,本文的问题都是使用这些工具来定位和分析的。
2.6.1命令行终端
标准终端类:jps、jinfo、jstat、jstack、jmap
功能整合类:jcmd、vjtools、arthas、greys
2.6.2可视化界面
简易:JConsole、JVisualvm、HA、GCHisto、GCViewer
进阶:MAT、JProfiler
命令行推荐 arthas ,可视化界面推荐 JProfiler,此外还有一些在线的平台gceasy、heaphero、fastthread,美团内部的 Scalpel(一款自研的 JVM 问题诊断工具,暂时未开源)也比较好用。
GC 问题判断
在做 GC 问题排查和优化之前,我们需要先来明确下到底是不是 GC 直接导致的问题,或者应用代码导致的 GC 异常,最终出现问题。
3.1判断GC有没有问题?
3.1.1设定评价标准
评判 GC 的两个核心指标:
延迟(Latency):也可以理解为最大停顿时间,即垃圾收集过程中一次 STW的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发
展方向。
吞吐量(Throughput):应用系统的生命周期内,由于 GC 线程会占用Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%,吞吐量优先的收集器可以接受较长的停顿。
目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,衡量指标需要结合一下应用服务的 SLA,主要如下两点来判断:
简而言之,即为一次停顿的时间不超过应用服务的TP9999,GC的吞吐量不小于99.99%。举个例子,假设某个服务 A 的 TP9999 为 80 ms,平均 GC 停顿为 30
ms,那么该服务的最大停顿时间最好不要超过 80 ms,GC 频次控制在 5 min 以上一次。如果满足不了,那就需要调优或者通过更多资源来进行并联冗余。(大家可以先停下来,看看监控平台上面的 gc.meantime 分钟级别指标,如果超过了 6 ms 那单机 GC 吞吐量就达不到 4 个 9 了。)
备注:除了这两个指标之外还有 Footprint(资源量大小测量)、反应速度等指标,互联网这种实时系统追求低延迟,而很多嵌入式系统则追求 Footprint。
3.1.2读懂GCCause
拿到 GC 日志,我们就可以简单分析 GC 情况了,通过一些工具,我们可以比较直观地看到 Cause 的分布情况,如下图就是使用 gceasy 绘制的图表:
如上图所示,我们很清晰的就能知道是什么原因引起的 GC,以及每次的时间花费情况,但是要分析 GC 的问题,先要读懂 GC Cause,即 JVM 什么样的条件下选择进行 GC 操作,具体 Cause 的分类可以看一下 Hotspot 源码:src/share/vm/gc/shared/gcCause.hpp 和 src/share/vm/gc/shared/gcCause.cpp 中。
const char* GCCause::to_string(GCCause::Cause cause) { switch (cause) {
case _java_lang_system_gc: return "System.gc()";
case _full_gc_alot: return "FullGCAlot";
case _scavenge_alot: return "ScavengeAlot";
case _allocation_profiler: return "Allocation Profiler";
case _jvmti_force_gc:
return "JvmtiEnv ForceGarbageCollection";
case _gc_locker:
return "GCLocker Initiated GC";
case _heap_inspection:
return "Heap Inspection Initiated GC";
case _heap_dump:
return "Heap Dump Initiated GC";
case _wb_young_gc:
return "WhiteBox Initiated Young GC";
case _wb_conc_mark:
return "WhiteBox Initiated Concurrent Mark";
case _wb_full_gc:
return "WhiteBox Initiated Full GC";
case _no_gc: return "No GC";
case _allocation_failure: return "Allocation Failure";
case _tenured_generation_full: return "Tenured Generation Full";
case _metadata_GC_threshold: return "Metadata GC Threshold";
case _metadata_GC_clear_soft_refs:
return "Metadata GC Clear Soft References";
case _cms_generation_full: return "CMS Generation Full";
case _cms_initial_mark: return "CMS Initial Mark";
case _cms_final_remark: return "CMS Final Remark";
case _cms_concurrent_mark: return "CMS Concurrent Mark";
case _old_generation_expanded_on_last_scavenge: return "Old Generation Expanded On Last Scavenge";
case _old_generation_too_full_to_scavenge: return "Old Generation Too Full To Scavenge";
case _adaptive_size_policy: return "Ergonomics";
case _g1_inc_collection_pause: return "G1 Evacuation Pause";
case _g1_humongous_allocation: return "G1 Humongous Allocation";
case _dcmd_gc_run:
return "Diagnostic Command";
case _last_gc_cause:
return "ILLEGAL VALUE - last gc cause - ILLEGAL VALUE";
default:
return "unknown GCCause";
}
ShouldNotReachHere();
}
重点需要关注的几个GC Cause:
System.gc():手动触发GC操作。
CMS:CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和CMS Final Remark 两个 STW 阶段。
PromotionFailure:Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大)。
ConcurrentModeFailure:CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能,下面的一个
案例即为这种场景。
GCLockerInitiatedGC:如果线程执行在 JNI 临界区时,刚好需要进行GC,此时 GC Locker 将会阻止 GC 的发生,同时阻止其他线程进入 JNI 临
界区,直到最后一个线程退出临界区时触发一次 GC。
什么时机使用这些 Cause 触发回收,大家可以看一下 CMS 的代码,这里就不讨论了,具体在 /src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp 中。
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);
3.2判断是不是GC引发的问题?
到底是结果(现象)还是原因,在一次 GC 问题处理的过程中,如何判断是 GC 导致的故障,还是系统本身引发 GC 问题。这里继续拿在本文开头提到的一个 Case:“GC 耗时增大、线程 Block 增多、慢查询增多、CPU 负载高等四个表象,如何判断哪个是根因?”,笔者这里根据自己的经验大致整理了四种判断方法供参考:
时序分析:先发生的事件是根因的概率更大,通过监控手段分析各个指标的异常时间点,还原事件时间线,如先观察到 CPU 负载高(要有足够的时间Gap),那么整个问题影响链就可能是:CPU 负载高 -> 慢查询增多 -> GC耗时增大 -> 线程Block增多 -> RT 上涨。
●概率分析:使用统计概率学,结合历史问题的经验进行推断,由近到远按类型分析,如过往慢查的问题比较多,那么整个问题影响链就可能是:慢查询增多
-> GC 耗时增大 -> CPU 负载高 -> 线程 Block 增多 -> RT上涨。
●实验分析:通过故障演练等方式对问题现场进行模拟,触发其中部分条件(一个或多个),观察是否会发生问题,如只触发线程 Block 就会发生问题,那么
整个问题影响链就可能是:线程Block增多 -> CPU 负载高 -> 慢查询增多
-> GC 耗时增大 -> RT 上涨。
●反证分析:对其中某一表象进行反证分析,即判断表象的发不发生跟结果是否有相关性,例如我们从整个集群的角度观察到某些节点慢查和 CPU 都正常,
但也出了问题,那么整个问题影响链就可能是:GC 耗时增大 -> 线程 Block
增多 -> RT 上涨。
不同的根因,后续的分析方法是完全不同的。如果是 CPU 负载高那可能需要用火焰图看下热点、如果是慢查询增多那可能需要看下 DB 情况、如果是线程 Block 引起那可能需要看下锁竞争的情况,最后如果各个表象证明都没有问题,那可能 GC 确实存在问题,可以继续分析 GC 问题了。
3.3问题分类导读
3.3.1Mutator类型
Mutator 的类型根据对象存活时间比例图来看主要分为两种,在弱分代假说中也提到类似的说法,如下图所示“ Survival Time” 表示对象存活时间,“Rate” 表示对象分配比例:
●IO交互型:互联网上目前大部分的服务都属于该类型,例如分布式 RPC、MQ、HTTP 网关服务等,对内存要求并不大,大部分对象在 TP9999 的时间
内都会死亡, Young 区越大越好。
●MEM计算型:主要是分布式数据计算 Hadoop,分布式存储 HBase、Cassandra,自建的分布式缓存等,对内存要求高,对象存活时间长,Old 区
越大越好。
当然,除了二者之外还有介于两者之间的场景,本篇文章主要讨论第一种情况。对象Survival Time 分布图,对我们设置 GC 参数有着非常重要的指导意义,如下图就可以简单推算分代的边界。
3.3.2GC问题分类
笔者选取了九种不同类型的 GC 问题,覆盖了大部分场景,如果有更好的场景,欢迎在评论区给出。
UnexpectedGC:意外发生的 GC,实际上不需要发生,我们可以通过一些手段去避免。
SpaceShock:空间震荡问题,参见“场景一:动态扩容引起的空间震荡”。
ExplicitGC:显示执行 GC 问题,参见“场景二:显式 GC 的去与留”。
PartialGC:部分收集操作的 GC,只对某些分代/分区进行回收。
YoungGC:分代收集里面的 Young 区收集动作,也可以叫做 Minor GC。
ParNew:Young GC 频繁,参见“场景四:过早晋升”。
OldGC:分代收集里面的 Old 区收集动作,也可以叫做 Major GC,有些也会叫做 Full GC,但其实这种叫法是不规范的,在 CMS 发生 Fore-
ground GC 时才是 Full GC,CMSScavengeBeforeRemark 参数也只是在 Remark 前触发一次Young GC。
CMS:Old GC 频繁,参见“场景五:CMS Old GC 频繁”。
CMS:Old GC 不频繁但单次耗时大,参见“场景六:单次 CMS OldGC 耗时长”。
FullGC:全量收集的 GC,对整个堆进行回收,STW 时间会比较长,一旦发生,影响较大,也可以叫做 Major GC,参见“场景七:内存碎片&收集
器退化”。
MetaSpace:元空间回收引发问题,参见“场景三:MetaSpace 区 OOM”。
DirectMemory:直接内存(也可以称作为堆外内存)回收引发问题,参见“场景八:堆外内存 OOM”。
JNI:本地 Native 方法引发问题,参见“场景九:JNI 引发的 GC 问题”。
3.3.3排查难度
一个问题的解决难度跟它的常见程度成反比,大部分我们都可以通过各种搜索引擎找到类似的问题,然后用同样的手段尝试去解决。当一个问题在各种网站上都找不到相
似的问题时,那么可能会有两种情况,一种这不是一个问题,另一种就是遇到一个隐藏比较深的问题,遇到这种问题可能就要深入到源码级别去调试了。以下 GC 问题场景,排查难度从上到下依次递增。
常见场景分析与解决
4.1场景一:动态扩容引起的空间震荡
4.1.1现象
服务刚刚启动时GC次数较多,最大空间剩余很多但是依然发生 GC,这种情况我们可以通过观察 GC 日志或者通过监控工具来观察堆的空间变化情况即可。GC Cause
一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间
的大小会被调整,如下图所示:
4.1.2原因
在 JVM 的参数中-Xms和-Xmx设置的不一致,在初始化时只会初始-Xms大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次GC。具体是通过ConcurrentMarkSweepGeneration::compute_new_size()方法计算新的空间大小:
另外,如果空间剩余很多时也会进行缩容操作,JVM 通过-XX:MinHeapFreeRa-tio和-XX:MaxHeapFreeRatio来控制扩容和缩容的比例,调节这两个值也可以
控制伸缩的时机,例如扩容便是使用GenCollectedHeap::expand_heap_and_allocate()来完成的,代码如下:
整个伸缩的模型理解可以看这个图,当 committed 的空间大小超过了低水位/高水位的大小,capacity 也会随之调整:
4.1.3策略
定位:观察 CMS GC 触发时间点 Old/MetaSpace 区的 committed 占比是不是一个固定的值,或者像上文提到的观察总的内存使用率也可以。
解决:尽量将成对出现的空间大小配置参数设置成固定的,如-Xms和-Xmx,-XX:-MaxNewSize和-XX:NewSize,-XX:MetaSpaceSize和-XX:MaxMetaSpace-
Size等。
4.1.4小结
一般来说,我们需要保证 Java 虚拟机的堆是稳定的,确保-Xms和-Xmx设置的是一个值(即初始值和最大值一致),获得一个稳定的堆,同理在 MetaSpace 区也有类似的问题。不过在不追求停顿时间的情况下震荡的空间也是有利的,可以动态地伸缩以节省空间,例如作为富客户端的 Java 应用。
这个问题虽然初级,但是发生的概率还真不小,尤其是在一些规范不太健全的情况下。
4.2场景二:显式GC的去与留
4.2.1现象
除了扩容缩容会触发 CMS GC 之外,还有 Old 区达到回收阈值、MetaSpace 空间不足、Young 区晋升失败、大对象担保失败等几种触发条件,如果这些情况都没有发生却触发了 GC ?这种情况有可能是代码中手动调用了 System.gc 方法,此时可以找到 GC 日志中的 GC Cause 确认下。那么这种 GC 到底有没有问题,翻看网上的一些资料,有人说可以添加-XX:+DisableExplicitGC参数来避免这种 GC,也有人说不能加这个参数,加了就会影响 Native Memory 的回收。先说结论,笔者这里建议保留 System.gc,那为什么要保留?我们一起来分析下。
4.2.2原因
找到 System.gc 在 Hotspot 中的源码,可以发现增加-XX:+DisableEx-plicitGC参数后,这个方法变成了一个空方法,如果没有加的话便会调用
Universe::heap()::collect方法,继续跟进到这个方法中,发现 System.gc
会引发一次 STW 的 Full GC,对整个堆做收集。
保留System.gc
此处补充一个知识点,CMSGC共分为Background和Foreground两种模式,前者就是我们常规理解中的并发收集,可以不影响正常的业务线程运行,但
Foreground Collector 却有很大的差异,他会进行一次压缩式 GC。此压缩式 GC使用的是跟 Serial Old GC 一样的 Lisp2 算法,其使用 Mark-Compact 来做 FullGC,一般称之为 MSC(Mark-Sweep-Compact),它收集的范围是 Java 堆的Young 区和 Old 区以及 MetaSpace。由上面的算法章节中我们知道 compact 的代价是巨大的,那么使用 Foreground Collector 时将会带来非常长的 STW。如果在应用程序中 System.gc 被频繁调用,那就非常危险了。
去掉System.gc
如果禁用掉的话就会带来另外一个内存泄漏问题,此时就需要说一下 Direct-ByteBuffer,它有着零拷贝等特点,被 Netty 等各种 NIO 框架使用,会使用到堆外内存。堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer 没有Finalizer,它的 Native Memory 的清理工作是通过sun.misc.Cleaner自动完成的,是一种基于 PhantomReference 的清理工具,比普通的 Finalizer 轻量些。
为 DirectByteBuffer 分配空间过程中会显式调用 System.gc ,希望通过 Full GC来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 Native Memory,下面为代码实现:
HotSpot VM 只会在 Old GC 的时候才会对 Old 中的对象做 Reference Pro-cessing,而在 Young GC 时只会对 Young 里的对象做 Reference Processing。Young 中的 DirectByteBuffer 对象会在 Young GC 时被处理,也就是说,做 CMSGC 的话会对 Old 做 Reference Processing,进而能触发 Cleaner 对已死的DirectByteBuffer 对象做清理工作。但如果很长一段时间里没做过 GC 或者只做了Young GC 的话则不会在 Old 触发 Cleaner 的工作,那么就可能让本来已经死亡,但已经晋升到 Old 的 DirectByteBuffer 关联的 Native Memory 得不到及时释放。这几个实现特征使得依赖于 System.gc 触发 GC 来保证 DirectByteMemory 的清理工作能及时完成。如果打开了-XX:+DisableExplicitGC,清理工作就可能得不到及时完成,于是就有发生 Direct Memory 的 OOM。
4.2.3策略
通过上面的分析看到,无论是保留还是去掉都会有一定的风险点,不过目前互联网中的 RPC 通信会大量使用 NIO,所以笔者在这里建议保留。此外 JVM 还提供了
-XX:+ExplicitGCInvokesConcurrent和-XX:+ExplicitGCInvokesCon-currentAndUnloadsClasses参数来将 System.gc 的触发类型从 Foreground改为 Background,同时 Background 也会做 Reference Processing,这样的话就能大幅降低了 STW 开销,同时也不会发生 NIO Direct Memory OOM。
4.2.4小结
不止 CMS,在 G1 或 ZGC中开启ExplicitGCInvokesConcurrent模式,都会采用高性能的并发收集方式进行收集,不过还是建议在代码规范方面也要做好约束,规范好 System.gc 的使用。
P.S. HotSpot 对 System.gc 有特别处理,最主要的地方体现在一次 System.gc是否与普通 GC 一样会触发 GC 的统计/阈值数据的更新,HotSpot 里的许多 GC算法都带有自适应的功能,会根据先前收集的效率来决定接下来的 GC 中使用的参数,但 System.gc 默认不更新这些统计数据,避免用户强行 GC 对这些自适应功能的干扰(可以参考 -XX:+UseAdaptiveSizePolicyWithSystemGC 参数,默认是false)。
4.3场景三:MetaSpace区OOM
4.3.1现象
JVM 在启动后或者某个时间点开始,MetaSpace的已使用大小在持续增长,同时每次GC也无法释放,调大MetaSpace空间也无法彻底解决。
4.3.2原因
在讨论为什么会 OOM 之前,我们先来看一下这个区里面会存什么数据,Java7 之前字符串常量池被放到了 Perm 区,所有被 intern 的 String 都会被存在这里,由于 String.intern 是不受控的,所以-XX:MaxPermSize的值也不太好设置,经常会出现java.lang.OutOfMemoryError: PermGen space异常,所以在 Java7之后常量池等字面量(Literal)、类静态变量(Class Static)、符号引用(SymbolsReference)等几项被移到 Heap 中。而 Java8 之后 PermGen 也被移除,取而代之的是 MetaSpace。
在最底层,JVM 通过 mmap 接口向操作系统申请内存映射,每次申请 2MB 空间,这里是虚拟内存映射,不是真的就消耗了主存的 2MB,只有之后在使用的时候才会真的消耗内存。申请的这些内存放到一个链表中 VirtualSpaceList,作为其中的一个Node。
在上层,MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 两大部分组成。
●KlassMetaSpace:就是用来存 Klass 的,就是 Class 文件在 JVM 里的运行时数据结构,这部分默认放在 Compressed Class Pointer Space 中,
是一块连续的内存区域,紧接着 Heap。Compressed Class Pointer Space不是必须有的,如果设置了-XX:-UseCompressedClassPointers,或者-Xmx设置大于 32 G,就不会有这块内存,这种情况下 Klass 都会存在NoKlass Metaspace 里。
●NoKlassMetaSpace:专门来存 Klass 相关的其他的内容,比如 Method,ConstantPool 等,可以由多块不连续的内存组成。虽然叫做 NoKlass
Metaspace,但是也其实可以存 Klass 的内容,上面已经提到了对应场景。
具体的定义都可以在源码 shared/vm/memory/metaspace.hpp 中找到:
MetaSpace 的对象为什么无法释放,我们看下面两点:
●MetaSpace内存管理:类和其元数据的生命周期与其对应的类加载器相同,只要类的类加载器是存活的,在 Metaspace 中的类元数据也是存活的,不能
被回收。每个加载器有单独的存储空间,通过 ClassLoaderMetaspace 来进
行管理 SpaceManager* 的指针,相互隔离的。
●MetaSpace弹性伸缩:由于 MetaSpace 空间和 Heap 并不在一起,所以这块的空间可以不用设置或者单独设置,一般情况下避免 MetaSpace 耗尽
VM 内存都会设置一个 MaxMetaSpaceSize,在运行过程中,如果实际大小小于这个值,JVM 就会通过-XX:MinMetaspaceFreeRatio和-XX:-MaxMetaspaceFreeRatio两个参数动态控制整个 MetaSpace 的大小,具
体使用可以看MetaSpaceGC::compute_new_size()方法(下方代码),这个方法会在 CMSCollector 和 G1CollectorHeap 等几个收集器执行 GC时调用。这个里面会根据used_after_gc,MinMetaspaceFreeRatio和MaxMetaspaceFreeRatio这三个值计算出来一个新的_capacity_
until_GC值(水位线)。然后根据实际的_capacity_until_GC值使用MetaspaceGC::inc_capacity_until_GC()和MetaspaceGC::dec_capacity_until_GC()进行 expand 或 shrink,这个过程也可以参照场景
一中的伸缩模型进行理解。
由场景一可知,为了避免弹性伸缩带来的额外 GC 消耗,我们会将-XX-:MetaSpaceSize和-XX:MaxMetaSpaceSize两个值设置为固定的,但是这样也
会导致在空间不够的时候无法扩容,然后频繁地触发 GC,最终 OOM。所以关键原因就是 ClassLoader 不停地在内存中 load 了新的 Class ,一般这种问题都发生在动态类加载等情况上。
4.3.3策略
了解大概什么原因后,如何定位和解决就很简单了,可以 dump 快照之后通过JProfiler 或 MAT 观察 Classes 的 Histogram(直方图) 即可,或者直接通过命令即可定位, jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以定位了。不过有时候也要结合InstBytes、KlassBytes、Bytecodes、MethodAll 等几项指标综合来看下。如下图便是笔者使用 jcmd 排查到一个 Orika 的问题。
-:+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:MaxTenuring-
Threshold来控制。
过早晋升一般不会直接影响 GC,总会伴随着浮动垃圾、大对象担保失败等问题,但这些问题不是立刻发生的,我们可以观察以下几种现象来判断是否发生了过早晋升。
分配速率接近于晋升速率,对象晋升年龄较小。
GC 日志中出现“Desired survivor size 107347968 bytes,newthreshold1(max6)”等信息,说明此时经历过一次 GC 就会放到 Old 区。
FullGC比较频繁,且经历过一次 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方法中:
可以看到 Hotspot 遍历所有对象时,从所有年龄为 0 的对象占用的空间开始累加,如果加上年龄等于 n 的所有对象的空间之后,使用 Survivor 区的条件值(Target-SurvivorRatio / 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 分钟一次。
如果是分配速率过大:
偶发较大:通过内存分析工具找到问题代码,从业务逻辑上做一些优化。
一直较大:当前的 Collector 已经不满足 Mutator 的期望了,这种情况要么扩容 Mutator 的 VM,要么调整 GC 收集器类型或加大空间。
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场景五:CMSOldGC频繁*
4.5.1现象
Old 区频繁的做 CMS GC,但是每次耗时不是特别长,整体最大 STW 也在可接受范围内,但由于 GC 太频繁导致吞吐下降比较多。
4.5.2原因
这种情况比较常见,基本都是一次 Young GC 完成后,负责处理 CMS GC 的一个后台线程 concurrentMarkSweepThread 会不断地轮询,使用shouldConcur-
rentCollect()方法做一次检测,判断是否达到了回收条件。如果达到条件,使用collect_in_background()启动一次 Background 模式 GC。轮询的判断是使用sleepBeforeNextCycle()方法,间隔周期为-XX:CMSWaitDuration决定,默认为2s。
具体代码在: src/hotspot/share/gc/cms/concurrentMarkSweepThread.cpp。
判断是否进行回收的代码在:/src/hotspot/share/gc/cms/concurrentMarkSweep-Generation.cpp。
分析其中逻辑判断是否触发 GC,分为以下几种情况:
触发CMSGC:通过调用_collector->collect_in_background()进行触发 Background GC 。
CMS 默认采用 JVM 运行时的统计数据判断是否需要触发 CMS GC,如果需要根据-XX:CMSInitiatingOccupancyFraction的值进行判断,需要设置参数-XX:+UseCMSInitiatingOccupancyOnly。
如果开启了-XX:UseCMSInitiatingOccupancyOnly参数,判断当前 Old 区使用率是否大于阈值,则触发 CMS GC,该阈值可以通过参数
-XX:CMSInitiatingOccupancyFraction进行设置,如果没有设置,
默认为 92%。
如果之前的 Young GC 失败过,或者下次 Young 区执行 Young GC 可能失败,这两种情况下都需要触发 CMS GC。
CMS 默认不会对 MetaSpace 或 Perm 进行垃圾收集,如果希望对这些区域进行垃圾收集,需要设置参数-XX:+CMSClassUnloadingEnabled。
触发FullGC:直接进行 Full GC,这种情况到场景七中展开说明。
如果_full_gc_requested为真,说明有明确的需求要进行 GC,比如调用 System.gc。
在 Eden 区为对象或 TLAB 分配内存失败,导致一次 Young GC,在GenCollectorPolicy类的satisfy_failed_allocation()方法中
进行判断。
大家可以看一下源码中的日志打印,通过日志我们就可以比较清楚地知道具体的原因,然后就可以着手分析了。
4.5.3策略
我们这里还是拿最常见的达到回收比例这个场景来说,与过早晋升不同的是这些对象确实存活了一段时间,Survival Time 超过了 TP9999 时间,但是又达不到长期存活,如各种数据库、网络链接,带有失效时间的缓存等。
处理这种常规内存泄漏问题基本是一个思路,主要步骤如下:
Dump Diff 和 Leak Suspects 比较直观就不介绍了,这里说下其它几个关键点:
内存Dump:使用 jmap、arthas 等 dump 堆进行快照时记得摘掉流量,同时分别在CMSGC的发生前后分别dump一次。
分析TopComponent:要记得按照对象、类、类加载器、包等多个维度观察 Histogram,同时使用 outgoing 和 incoming 分析关联的对象,另外就是
Soft Reference 和 Weak Reference、Finalizer 等也要看一下。
分析Unreachable:重点看一下这个,关注下 Shallow 和 Retained 的大小。如下图所示,笔者之前一次 GC 优化,就根据 Unreachable Objects 发
现了 Hystrix 的滑动窗口问题。
4.5.4小结
经过整个流程下来基本就能定位问题了,不过在优化的过程中记得使用控制变量的方法来优化,防止一些会加剧问题的改动被掩盖。
4.6场景六:单次CMSOldGC耗时长*
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中,调用入口就是 Concurrent-MarkSweepThread 调用的CMSCollector::collect_in_background和ConcurrentMarkSweepGeneration调用的CMSCollector::collect方法,此处我们讨论大多数场景的collect_in_background。整个过程中会 STW 的
主要是 initial Mark 和 Final Remark,核心代码在VM_CMS_Initial_Mark/VM_
CMS_Final_Remark中,执行时需要将执行权交由 VMThread 来执行。
●CMS Init Mark执行步骤,实现在CMSCollector::checkpointRoot-sInitialWork()和CMSParInitialMarkTask::work中,整体步骤和
代码如下:
整个过程比较简单,从 GC Root 出发标记 Old 中的对象,处理完成后借助 BitMap
处理下 Young 区对 Old 区的引用,整个过程基本都比较快,很少会有较大的停顿。
● CMS Final Remark 执 行 步 骤, 实 现 在 CMSCollector::checkpointRootsFinalWork() 中,整体代码和步骤如下:
Final Remark 是最终的第二次标记,这种情况只有在 Background GC 执行了InitialMarking 步骤的情形下才会执行,如果是 Foreground GC 执行的 Initial-Marking 步骤则不需要再次执行 FinalRemark。Final Remark 的开始阶段与 InitMark 处理的流程相同,但是后续多了 Card Table 遍历、Reference 实例的清理并将其加入到 Reference 维护的pend_list中,如果要收集元数据信息,还要清理SystemDictionary、CodeCache、SymbolTable、StringTable 等组件中不再使用的资源。
4.6.3策略
知道了两个 STW 过程执行流程,我们分析解决就比较简单了,由于大部分问题都出在 Final Remark 过程,这里我们也拿这个场景来举例,主要步骤:
●【方向】观察详细 GC 日志,找到出问题时 Final Remark 日志,分析下 Reference 处理和元数据处理 real 耗时是否正常,详细信息需要通过-XX:+PrintReferenceGC参数开启。基本在日志里面就能定位到大概是哪
个方向出了问题,耗时超过10%的就需要关注。
●【根因】有了具体的方向我们就可以进行深入的分析,一般来说最容易出问题的地方就是 Reference 中的 FinalReference 和元数据信息处理中的 scrub
symbol table 两个阶段,想要找到具体问题代码就需要内存分析工具 MAT 或JProfiler 了,注意要 dump 即将开始 CMS GC 的堆。在用 MAT 等工具前也可以先用命令行看下对象 Histogram,有可能直接就能定位问题。
○对 FinalReference 的分析主要观察java.lang.ref.Finalizer对象的 dominator tree,找到泄漏的来源。经常会出现问题的几个点有 Socket的SocksSocketImpl、Jersey 的ClientRuntime、MySQL 的
ConnectionImpl等等。
○scrub symbol table 表示清理元数据符号引用耗时,符号引用是 Java 代码被编译成字节码时,方法在 JVM 中的表现形式,生命周期一般与 Class一致,当_should_unload_classes被设置为 true 时在CMSCollec-
tor::refProcessingWork()中与 Class Unload、String Table 一起
被处理。
●【策略】知道 GC 耗时的根因就比较好处理了,这种问题不会大面积同时爆发,不过有很多时候单台 STW 的时间会比较长,如果业务影响比较大,及时摘掉
流量,具体后续优化策略如下:
○FinalReference:找到内存来源后通过优化代码的方式来解决,如果短时间无法定位可以增加-XX:+ParallelRefProcEnabled对 Reference 进行并行处理。
○symbol table:观察 MetaSpace 区的历史使用峰值,以及每次 GC 前后的回收情况,一般没有使用动态类加载或者 DSL 处理等,MetaSpace的使用率上不会有什么变化,这种情况可以通过-XX:-CMSClassUn-
loadingEnabled来避免 MetaSpace 的处理,JDK8 会默认开启CMSClassUnloadingEnabled,这会使得 CMS 在 CMS-Remark 阶段尝试进行类的卸载。
4.6.4小结
正常情况进行的 Background CMS GC,出现问题基本都集中在 Reference 和Class 等元数据处理上,在 Reference 类的问题处理方面,不管是 FinalRefer-ence,还是 SoftReference、WeakReference 核心的手段就是找准时机 dump 快照,然后用内存分析工具来分析。Class 处理方面目前除了关闭类卸载开关,没有太好的方法。
在 G1 中同样有 Reference 的问题,可以观察日志中的 Ref Proc,处理方法与CMS 类似。
4.7场景七:内存碎片&收集器退化
4.7.1现象
并发的 CMS GC 算法,退化为 Foreground 单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。其中 CMS 收集器退化后单线程串行 GC 算法有两种:
带压缩动作的算法,称为 MSC,上面我们介绍过,使用标记-清理-压缩,单线程全暂停的方式,对整个堆进行垃圾收集,也就是真正意义上的 FullGC,暂停时间要长于普通 CMS。
不带压缩动作的算法,收集 Old 区,和普通的 CMS 算法比较相似,暂停时间相对 MSC 算法短一些。
4.7.2原因
CMS 发生收集器退化主要有以下几种情况:
晋升失败(PromotionFailed)
顾名思义,晋升失败就是指在进行 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
这种情况参见场景二。
并发模式失败(ConcurrentModeFailure)
最后一种情况,也是发生概率较高的一种,在 GC 日志中经常能看到 ConcurrentMode 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:CMSInitiatingOccu-pancyFraction的值,让 CMS GC 尽早执行,以保证有足够的连续空间,
也减少 Old 区空间的使用大小,另外需要使用-XX:+UseCMSInitiatingO-ccupancyOnly来配合使用,不然 JVM 仅在第一次使用设定值,后续则自动调整。
●浮动垃圾:视情况控制每次晋升对象的大小,或者缩短每次 CMS GC 的时间,必要时可调节 NewRatio 的值。另外就是使用-XX:+CMSScavengeBefore-
Remark在过程中提前触发一次 Young GC,防止后续晋升过多对象。
4.7.4小结
正常情况下触发并发模式的 CMS GC,停顿非常短,对业务影响很小,但 CMSGC 退化后,影响会非常大,建议发现一次后就彻底根治。只要能定位到内存碎片、浮动垃圾、增量收集相关等具体产生原因,还是比较好解决的,关于内存碎片这块,如果-XX:CMSFullGCsBeforeCompaction的值不好选取的话,可以使用
-XX:PrintFLSStatistics来观察内存碎片率情况,然后再设置具体的值。
最后就是在编码的时候也要避免需要连续地址空间的大对象的产生,如过长的字符串,用于存放附件、序列化或反序列化的 byte 数组等,还有就是过早晋升问题尽量在爆发问题前就避免掉。
4.8场景八:堆外内存OOM
4.8.1现象
内存使用率不断上升,甚至开始使用 SWAP 内存,同时可能出现 GC 时间飙升,线程被 Block 等现象,通过top命令发现Java进程的RES甚至超过了-Xmx的大
小。出现这些现象时,基本可以确定是出现了堆外内存泄漏。
4.8.2原因
JVM 的堆外内存泄漏,主要有两种的原因:
通过UnSafe#allocateMemory,ByteBuffer#allocateDirect主动申请了堆外内存而没有释放,常见于 NIO、Netty 等相关组件。
代码中有通过 JNI 调用 Native Code 申请的内存没有释放。
4.8.3策略
哪种原因造成的堆外内存泄漏?
首先,我们需要确定是哪种原因导致的堆外内存泄漏。这里可以使用 NMT(Native-MemoryTracking) 进行分析。在项目中添加-XX:NativeMemoryTracking=de-
tailJVM参数后重启项目(需要注意的是,打开 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参数来控制可申请的堆外内存的最大值。在 Java8 中,如果未配置该参数,默认和-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 capacitybyte(s) of direct memory(used: usedMemory , max: DIRECT_MEMORY_LIMIT )。
我们可以检查代码中是如何使用堆外内存的,NIO 或者是 Netty,通过反射,获取到对应组件中的计数器字段,并在项目中对该字段的数值进行打点,即可准确地监控到这部分堆外内存的使用情况。
此时,可以通过 Debug 的方式确定使用堆外内存的地方是否正确执行了释放内存的代码。另外,需要检查 JVM 的参数是否有-XX:+DisableExplicitGC选项,如果有就去掉,因为该参数会使 System.gc 失效。(场景二:显式 GC 的去与留)
原因二:通过JNI调用的NativeCode申请的内存未释放
这种情况排查起来比较困难,我们可以通过 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。
4.9.2原因
JNI(Java Native Interface)意为 Java 本地调用,它允许 Java 代码和其他语言写的 Native 代码进行交互。
JNI 如果需要获取 JVM 中的 String 或者数组,有两种方式:
拷贝传递。
共享引用(指针),性能更高。
由于 Native 代码直接使用了 JVM 堆区的指针,如果这时发生 GC,就会导致数据错误。因此,在发生此类 JNI 调用时,禁止 GC 的发生,同时阻止其他线程进入 JNI临界区,直到最后一个线程退出临界区时触发一次 GC。
GC Locker 实验:
运行该 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.1处理流程(SOP)
下图为整体 GC 问题普适的处理流程,重点的地方下面会单独标注,其他的基本都是标准处理流程,此处不再赘述,最后在整个问题都处理完之后有条件的话建议做一下复盘。
制定标准:这块内容其实非常重要,但大部分系统都是缺失的,笔者过往面试的同学中只有不到一成的同学能给出自己的系统 GC 标准到底什么样,其他的
都是用的统一指标模板,缺少预见性,具体指标制定可以参考 3.1 中的内容,需要结合应用系统的 TP9999 时间和延迟、吞吐量等设定具体的指标,而不是被问题驱动。
●保留现场:目前线上服务基本都是分布式服务,某个节点发生问题后,如果条件允许一定不要直接操作重启、回滚等动作恢复,优先通过摘掉流量的方式来
恢复,这样我们可以将堆、栈、GC 日志等关键信息保留下来,不然错过了定位根因的时机,后续解决难度将大大增加。当然除了这些,应用日志、中间件日志、内核日志、各种 Metrics 指标等对问题分析也有很大帮助。
●因果分析:判断 GC 异常与其他系统指标异常的因果关系,可以参考笔者在3.2 中介绍的时序分析、概率分析、实验分析、反证分析等 4 种因果分析法,
避免在排查过程中走入误区。
●根因分析:确实是 GC 的问题后,可以借助上文提到的工具并通过 5 why 根因分析法以及跟第三节中的九种常见的场景进行逐一匹配,或者直接参考下文的
根因鱼骨图,找出问题发生根因,最后再选择优化手段。
5.2根因鱼骨图
送上一张问题根因鱼骨图,一般情况下我们在处理一个 GC 问题时,只要能定位到问题的“病灶”,有的放矢,其实就相当于解决了 80%,如果在某些场景下不太好定位,大家可以借助这种根因分析图通过排除法去定位。
5.3调优建议
TradeOff:与 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等一些参数就不再
提了,笔者建议添加以下参数,可以提高我们分析问题的效率。
其他建议:上文场景中没有提到,但是对 GC 性能也有提升的一些建议。
○主动式GC:也有另开生面的做法,通过监控手段监控观测 Old 区的使用情况,即将到达阈值时将应用服务摘掉流量,手动触发一次 Major GC,减少
CMS GC 带来的停顿,但随之系统的健壮性也会减少,如非必要不建议引入。
○禁用偏向锁:偏向锁在只有一个线程使用到该锁的时候效率很高,但是在竞争激烈情况会升级成轻量级锁,此时就需要先消除偏向锁,这个过程是
STW的。如果每个同步资源都走这个升级过程,开销会非常大,所以在已知并发激烈的前提下,一般会禁用偏向锁-XX:-UseBiasedLocking来提高性能。
○虚拟内存:启动初期有些操作系统(例如 Linux)并没有真正分配物理内存给JVM ,而是在虚拟内存中分配,使用的时候才会在物理内存中分配内存页,
这样也会导致 GC 时间较长。这种情况可以添加-XX:+AlwaysPreTouch参数,让 VM 在 commit 内存时跑个循环来强制保证申请的内存真的commit,避免运行时触发缺页异常。在一些大内存的场景下,有时候能将前几次的 GC 时间降一个数量级,但是添加这个参数后,启动的过程可能会变慢。
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 的学习材料,如《TheGarbage Collection Handbook》《深入理解Java虚拟机》等也是常读常新,赶紧动起来,苦练 GC 基本功吧。
最后的最后,再多啰嗦一句,目前所有 GC 调优相关的文章,第一句讲的就是“不要过早优化”,使得很多同学对 GC 优化望而却步。在这里笔者提出不一样的观点,熵增定律(在一个孤立系统里,如果没有外力做功,其总混乱度(即熵)会不断增大)在计算机系统同样适用,如果不主动做功使熵减,系统终究会脱离你的掌控,在我们对
业务系统和 GC 原理掌握得足够深的时候,可以放心大胆地做优化,因为我们基本可以预测到每一个操作的结果,放手一搏吧,少年!
新一代垃圾回收器ZGC的探索与实践
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
停顿时间不超过10ms;
停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
支持8MB~4TB级别的堆(未来支持16TB)。
从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:
GC之痛:介绍实际业务中遇到的GC痛点,并分析CMS收集器和G1收集器停顿时间瓶颈;
ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因,以及背后的技术原理;
ZGC调优实践:重点分享对ZGC调优的理解,并分析若干个实际调优案例;
升级ZGC效果:展示在生产环境应用ZGC取得的效果。
GC之痛
很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。
CMS与G1停顿时间瓶颈
在介绍ZGC之前,首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
标记阶段,即从GC Roots集合开始,标记活跃对象;
转移阶段,即把活跃对象复制到新的内存地址上;
重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一
般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC关键技术
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针
着色指针是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一
般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC关键技术
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针
着色指针是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA <Load barrier> |
// 从堆中读取引用,需要加入屏障 |
Object p = o | // 无需加入屏障,因为不是从堆中读取引用 |
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用 | |
int i = obj.FieldB | // 无需加入屏障,因为不是对象引用 |
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC并发处理演示
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入
标记阶段。
并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
●并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象
的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
ZGC调优实践
ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性。
调优基础知识
理解ZGC重要配置参数
以我们服务在生产环境中ZGC参数配置为例,说明各个参数的作用:
重要参数配置样例:
-Xms10G -Xmx10G
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-
%t.log:time,tid,tags:filecount=5,filesize=50m
-Xms-Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。-XX:ReservedCodeCacheSize-XX:InitialCodeCa-
cheSize:设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。-XX:+UnlockExperimentalVMOptions-XX:+UseZGC:启用ZGC的配置。-XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU
资源,吞吐会受到影响。-XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。-XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。-XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。-XX:+UnlockDiagnosticVMOptions-XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。-Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
理解ZGC触发时机
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“AllocationStall”。
基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。 日志中关键字是“Proactive”。
预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“War-mup”。
外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。
理解ZGC日志
一次完整的GC过程,需要注意的点已在图中标出。
注意:该日志过滤了进入安全点的信息。正常情况,在一次GC过程中还穿插着进入安全点的操作。
GC日志中每一行都注明了GC过程中的信息,关键信息如下:
Start:开始GC,并标明的GC触发的原因。上图中触发原因是自适应算法。
Phase-PauseMarkStart:初始标记,会STW。
Phase-PauseMarkEnd:再次标记,会STW。
Phase-PauseRelocateStart:初始转移,会STW。
Heap信息:记录了GC过程中Mark、Relocate前后的堆大小变化状况。High和Low记录了其中的最大值和最小值,我们一般关注High中Used的
值,如果达到100%,在GC过程中一定存在内存分配不足的情况,需要调整
GC的触发时机,更早或者更快地进行GC。
GC信息统计:可以定时的打印垃圾收集信息,观察10秒内、10分钟内、10个小时内,从启动到现在的所有统计信息。利用这些统计信息,可以排查定位
一些异常点。
日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。
理解ZGC停顿原因
我们在实战过程中共发现了6种使程序停顿的场景,分别如下:
GC时,初始标记:日志中Pause Mark Start。
GC时,再标记:日志中Pause Mark End。
GC时,初始转移:日志中Pause Relocate Start。
内存分配阻塞:当内存不足时线程会阻塞等待GC完成,关键字是”AllocationStall”。
安全点:所有线程进入到安全点后才能进行GC,ZGC定期进入安全点判断是否需要GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程
挂起。
dump线程、内存:比如jstack、jmap命令。
调优案例
我们维护的服务名叫Zeus,它是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎Aviator。Aviator内部将每一条表达式转化成
Java的一个类,通过调用该类的接口实现表达式逻辑。
Zeus服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致Aviator生成的类和方法会产生很多的ClassLoader和CodeCache,这些在使用ZGC时都成为过GC的性能瓶颈。接下来介绍两类调优案例。
内存分配阻塞,系统停顿可达到秒级
案例一:秒杀活动中流量突增,出现性能毛刺
日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。
分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。
解决方法:
(1)开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
(2)增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。
案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺
日志信息:平均1秒GC一次,两次GC之间几乎没有间隔。
分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。
解决方法:增大-XX:ConcGCThreads, 加快并发标记和回收速度。ConcGC-Threads默认值是核数的1/8,8核机器,默认值是1。该参数影响系统吞吐,如果
GC间隔时间大于GC周期,不建议调整该参数。
GCRoots数量大,单次GC停顿时间长
案例三:单次GC停顿时间30ms,与预期停顿10ms左右有较大差距
日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。
分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道Class-Loader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC
Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些
ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。
解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。
案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复
日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。
分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+PrintCodeCacheOnCompilation参
数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。
解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了
大量Aviator方法进入CodeCache中。
值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。
升级ZGC效果
延迟降低
TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。
在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:
TP999:下降12~142ms,下降幅度18%~74%。
TP99:下降5~28ms,下降幅度10%~47%。
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。
吞吐下降
对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
总结
ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。
Zeus在升级JDK 11+ZGC中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC停顿也几乎不再影响系统可用性。
最后推荐大家升级ZGC,Zeus系统因为业务特点,遇到了较多问题,而风控其他团队在升级时都非常顺利。欢迎大家加入“ZGC使用交流”群。
附录
如何使用新技术
在生产环境升级JDK 11,使用ZGC,大家最关心的可能不是效果怎么样,而是这个新版本用的人少,网上实践也少,靠不靠谱,稳不稳定。其次是升级成本会不会很大,万一不成功岂不是白白浪费时间。所以,在使用新技术前,首先要做的是评估收益、成本和风险。
评估收益
对于JDK这种世界关注的程序,大版本升级所引入的新技术一般已经在理论上经过验证。我们要做的事情就是确定当前系统的瓶颈是否是新版本JDK可解决的问题,切忌问题未诊断清楚就采取措施。评估完收益之后再评估成本和风险,收益过大或者过小,其他两项影响权重就会小很多。
以本文开头提到的案例为例,假设GC次数不变(10次/分钟),且单次GC时间从40ms降低10ms。通过计算,一分钟内有100/60000 = 0.17%的时间在进行GC,且期间所有请求仅停顿10ms,GC期间影响的请求数和因GC增加的延迟都有所减少。
评估成本
这里主要指升级所需要的人力成本。此项相对比较成熟,根据新技术的使用手册判断改动点。跟做其他项目区别不大,不再具体细说。
在我们的实践中,两周时间完成线上部署,达到安全稳定运行的状态。后续持续迭代3个月,根据业务场景对ZGC进行了更契合的优化适配。
评估风险
升级JDK的风险可以分为三类:
兼容性风险:Java程序JAR包依赖很多,升级JDK版本后程序是否能运行起来。例如我们的服务是从JDK 7升级到JDK 11,需要解决较多JAR包不兼容的问题。
功能风险:运行起来后,是否会有一些组件逻辑变更,影响现有功能的逻辑。
性能风险:功能如果没有问题,性能是否稳定,能稳定的在线上运行。
经过分类后,每类风险的应对转化成了常见的测试问题,不再属于未知风险。风险是指不确定的事情,如果不确定的事情都能转化成可确定的事情,意味着风险已消除。
升级JDK11
选择JDK 11,是因为在JDK 11中首次支持ZGC,而且JDK 11属于长期支持(Long Term Support,LTS)版本,至少会被维护三年,普通版本(如JDK 12、JDK 13和JDK 14)只有6个月的维护周期,不建议使用。
本地测试环境安装
从两个源OpenJDK和OracleJDK下载JDK 11,二个版本的JDK主要区别是长时期的免费和付费,短期内都免费。注意JDK 11版本中的ZGC不支持Mac OS系
统,在Mac OS系统上使用JDK 11只能用其他垃圾回收器,如G1。
生产环境安装
升级JDK 11不仅仅是升级自己项目的JDK版本,还需要编译、发布部署、运行、监控、性能内存分析工具等项目支持。美团内部的实践:
编译打包:美团发布系统支持选择JDK 11进行编译打包。线上运行&全量部署:要求线上机器已安装JDK11,有3种方式:
新申请默认安装JDK 11的虚拟机:试用JDK 11时可用这种方式;全量部署时,如果新申请机器数量过多,可能没有足够机器资源。
通过手写脚本给存量虚拟机安装JDK 11:不推荐,业务同学过多参与到运维当中。
使用容器提供的镜像部署功能,在打包镜像时安装JDK 11:推荐方式,不需要新申请资源。
监控指标:主要是GC的时间和频率,我们通过美团的CAT监控系统支持ZGC数据的收集(CAT已开源)。性能内存分析:线上遇到性能问题时,还需要借助Profiling工具,美团的性能诊断优化平台Scalpel已支持JDK 11的性能内存分析。
如果你的公司没有相关工具,推荐使用JProfier。
解决组件兼容性
我们的项目包含二十多万行代码,需要从JDK 7升级到JDK 11,依赖组件众多。虽然看起来升级会比较复杂,但实际只花了两天时间即解决了兼容性问题。具体过程如下:
1.编译,需要修改pom文件中的build配置,根据报错作修改,主要有两类:
a.一些类被删除:比如“sun.misc.BASE64Encoder”,找到替换类java.util.Base64即可。
b.组件依赖版本不兼容JDK 11问题:找到对应依赖组件,搜索最新版本,一般都支持JDK 11。
2.编译成功后,启动运行,此时仍有可能组件依赖版本问题,按照编译时的方式处理即可。
升级所修改的依赖:
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.4</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-parent</artifactId>
<version>6.0.16.Final</version>
</dependency>
<dependency>
<groupId>com.sankuai.inf</groupId>
<artifactId>patriot-sdk</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.39.Final</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
JDK 11已经出来两年,常见的依赖组件都有兼容性版本。但是,如果是公司内部提供的公司级组件,可能会不兼容JDK 11,需要推动相关组件进行升级。如果对方升级较为困难,可以考虑拆分功能,将依赖这些组件的功能单独部署,继续使用低版本JDK。随着JDK11的卓越性能被大家悉知,相信会有更多团队会用JDK 11解决GC问题,使用者越多,各个组件升级的动力也会越大。
验证功能正确性
通过完备的单测、集成和回归测试,保证功能正确性。
设计模式在外卖营销业务中的实践
一、前言
随着美团外卖业务的不断迭代与发展,外卖用户数量也在高速地增长。在这个过程中,外卖营销发挥了“中流砥柱”的作用,因为用户的快速增长离不开高效的营销策略。而由于市场环境和业务环境的多变,营销策略往往是复杂多变的,营销技术团队作为营销业务的支持部门,就需要快速高效地响应营销策略变更带来的需求变动。因此,设计并实现易于扩展和维护的营销系统,是美团外卖营销技术团队不懈追求的目标和必修的基本功。
本文通过自顶向下的方式,来介绍设计模式如何帮助我们构建一套易扩展、易维护的营销系统。本文会首先介绍设计模式与领域驱动设计(Domain-Driven Design,以下简称为DDD)之间的关系,然后再阐述外卖营销业务引入业务中用到的设计模式以及其具体实践案例。
二、设计模式与领域驱动设计
设计一个营销系统,我们通常的做法是采用自顶向下的方式来解构业务,为此我们引入了DDD。从战略层面上讲,DDD能够指导我们完成从问题空间到解决方案的剖析,将业务需求映射为领域上下文以及上下文间的映射关系。从战术层面上,DDD能够细化领域上下文,并形成有效的、细化的领域模型来指导工程实践。建立领域模型的一个关键意义在于,能够确保不断扩展和变化的需求在领域模型内不断地演进和发展,而不至于出现模型的腐化和领域逻辑的外溢。关于DDD的实践,大家可以参考此前美团技术团队推出的《领域驱动设计在互联网业务开发中的实践》一文。
同时,我们也需要在代码工程中贯彻和实现领域模型。因为代码工程是领域模型在工程实践中的直观体现,也是领域模型在技术层面的直接表述。而设计模式,可以说是连接领域模型与代码工程的一座桥梁,它能有效地解决从领域模型到代码工程的转化。
为什么说设计模式天然具备成为领域模型到代码工程之间桥梁的作用呢?其实,2003年出版的《领域驱动设计》一书的作者Eric Evans在这部开山之作中就已经给出了解释。他认为,立场不同会影响人们如何看待什么是“模式”。因此,无论是领域驱动模式还是设计模式,本质上都是“模式”,只是解决的问题不一样。站在业务建模的立场上,DDD的模式解决的是如何进行领域建模。而站在代码实践的立场上,设计模式主要关注于代码的设计与实现。既然本质都是模式,那么它们天然就具有一定的共通之处。
所谓“模式”,就是一套反复被人使用或验证过的方法论。从抽象或者更宏观的角度上看,只要符合使用场景并且能解决实际问题,模式应该既可以应用在DDD中,也可以应用在设计模式中。事实上,Evans也是这么做的。他在著作中阐述了Strategy和Composite这两个传统的GOF设计模式是如何来解决领域模型建设的。因此,当领域模型需要转化为代码工程时,同构的模式,天然能够将领域模型翻译成代码模型。
三、设计模式在外卖营销业务中的具体案例
3.1为什么需要设计模式
营销业务的特点
如前文所述,营销业务与交易等其他模式相对稳定的业务的区别在于,营销需求会随着市场、用户、环境的不断变化而进行调整。也正是因此,外卖营销技术团队选择了DDD进行领域建模,并在适用的场景下,用设计模式在代码工程的层面上实践和反映了领域模型。以此来做到在支持业务变化的同时,让领域和代码模型健康演进,避免模型腐化。
理解设计模式
软件设计模式(Design pattern),又称设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码,让代码更容易被他人理解,保证代码可靠性,程序的重用性。可以理解为:“世上本来没有设计模式,用的人多了,便总结出了一套设计模式。”
设计模式原则
面向对象的设计模式有七大基本原则:
开闭原则(Open Closed Principle,OCP)
单一职责原则(Single Responsibility Principle, SRP)
里氏代换原则(Liskov Substitution Principle,LSP)
依赖倒转原则(Dependency Inversion Principle,DIP)
接口隔离原则(Interface Segregation Principle,ISP)
合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP)
最少知识原则(Least Knowledge Principle,LKP)或者迪米特法则(Law ofDemeter,LOD)
简单理解就是:开闭原则是总纲,它指导我们要对扩展开放,对修改关闭;单一职责原则指导我们实现类要职责单一;里氏替换原则指导我们不要破坏继承体系;依赖倒置原则指导我们要面向接口编程;接口隔离原则指导我们在设计接口的时候要精简单一;迪米特法则指导我们要降低耦合。
设计模式就是通过这七个原则,来指导我们如何做一个好的设计。但是设计模式不是一套“奇技淫巧”,它是一套方法论,一种高内聚、低耦合的设计思想。我们可以在此基础上自由的发挥,甚至设计出自己的一套设计模式。
当然,学习设计模式或者是在工程中实践设计模式,必须深入到某一个特定的业务场景中去,再结合对业务场景的理解和领域模型的建立,才能体会到设计模式思想的精髓。如果脱离具体的业务逻辑去学习或者使用设计模式,那是极其空洞的。接下来我们将通过外卖营销业务的实践,来探讨如何用设计模式来实现可重用、易维护的代码。
3.2“邀请下单”业务中设计模式的实践
3.2.1业务简介
“邀请下单”是美团外卖用户邀请其他用户下单后给予奖励的平台。即用户A邀请用户B,并且用户B在美团下单后,给予用户A一定的现金奖励(以下简称返奖)。同时为了协调成本与收益的关系,返奖会有多个计算策略。邀请下单后台主要涉及两个技术要点:
返奖金额的计算,涉及到不同的计算规则。
从邀请开始到返奖结束的整个流程。
3.2.2返奖规则与设计模式实践
业务建模
如图是返奖规则计算的业务逻辑视图:
从这份业务逻辑图中可以看到返奖金额计算的规则。首先要根据用户状态确定用户是否满足返奖条件。如果满足返奖条件,则继续判断当前用户属于新用户还是老用户,从而给予不同的奖励方案。一共涉及以下几种不同的奖励方案:
新用户
普通奖励(给予固定金额的奖励)
梯度奖(根据用户邀请的人数给予不同的奖励金额,邀请的人越多,奖励金额越多)
老用户
根据老用户的用户属性来计算返奖金额。为了评估不同的邀新效果,老用户返奖会存在多种返奖机制。
计算完奖励金额以后,还需要更新用户的奖金信息,以及通知结算服务对用户的金额进行结算。这两个模块对于所有的奖励来说都是一样的。
可以看到,无论是何种用户,对于整体返奖流程是不变的,唯一变化的是返奖规则。此处,我们可参考开闭原则,对于返奖流程保持封闭,对于可能扩展的返奖规则进行
开放。我们将返奖规则抽象为返奖策略,即针对不同用户类型的不同返奖方案,我们视为不同的返奖策略,不同的返奖策略会产生不同的返奖金额结果。
在我们的领域模型里,返奖策略是一个值对象,我们通过工厂的方式生产针对不同用户的奖励策略值对象。下文我们将介绍以上领域模型的工程实现,即工厂模式和策略模式的实际应用。
模式:工厂模式
工厂模式又细分为工厂方法模式和抽象工厂模式,本文主要介绍工厂方法模式。
模式定义:定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法是一个类的实例化延迟到其子类。
工厂模式通用类图如下:
我们通过一段较为通用的代码来解释如何使用工厂模式:
// 抽象的产品
public abstract class Product {
public abstract void method();
}
// 定义一个具体的产品 ( 可以定义多个具体的产品 )
class ProductA extends Product {
@Override
public void method() {} // 具体的执行逻辑
}
// 抽象的工厂
abstract class Factory<T> {
模式:策略模式
模式定义:定义一系列算法,将每个算法都封装起来,并且它们可以互换。策略模式是一种对象行为模式。
策略模式通用类图如下:
我们通过一段比较通用的代码来解释怎么使用策略模式:
工程实践
通过上文介绍的返奖业务模型,我们可以看到返奖的主流程就是选择不同的返奖策略的过程,每个返奖策略都包括返奖金额计算、更新用户奖金信息、以及结算这三个步骤。 我们可以使用工厂模式生产出不同的策略,同时使用策略模式来进行不同的策略执行。首先确定我们需要生成出n种不同的返奖策略,其编码如下:
通过工厂模式生产出具体的策略之后,根据我们之前的介绍,很容易就可以想到使用策略模式来执行我们的策略。具体代码如下:
接下来我们将工厂模式和策略模式结合在一起,就完成了整个返奖的过程:
工厂方法模式帮助我们直接产生一个具体的策略对象,策略模式帮助我们保证这些策略对象可以自由地切换而不需要改动其他逻辑,从而达到解耦的目的。通过这两个模式的组合,当我们系统需要增加一种返奖策略时,只需要实现RewardStrategy接口即可,无需考虑其他的改动。当我们需要改变策略时,只要修改策略的类名即可。不仅增强了系统的可扩展性,避免了大量的条件判断,而且从真正意义上达到了高内聚、低耦合的目的。
3.2.3返奖流程与设计模式实践
业务建模
当受邀人在接受邀请人的邀请并且下单后,返奖后台接收到受邀人的下单记录,此时邀请人也进入返奖流程。首先我们订阅用户订单消息并对订单进行返奖规则校验。例如,是否使用红包下单,是否在红包有效期内下单,订单是否满足一定的优惠金额等等条件。当满足这些条件以后,我们将订单信息放入延迟队列中进行后续处理。经过T+N天之后处理该延迟消息,判断用户是否对该订单进行了退款,如果未退款,对用户进行返奖。若返奖失败,后台还有返奖补偿流程,再次进行返奖。其流程如下图所示:
我们对上述业务流程进行领域建模:
在接收到订单消息后,用户进入待校验状态;
在校验后,若校验通过,用户进入预返奖状态,并放入延迟队列。若校验未通过,用户进入不返奖状态,结束流程;
T+N天后,处理延迟消息,若用户未退款,进入待返奖状态。若用户退款,进入失败状态,结束流程;
执行返奖,若返奖成功,进入完成状态,结束流程。若返奖不成功,进入待补偿状态;
待补偿状态的用户会由任务定期触发补偿机制,直至返奖成功,进入完成状态,保障流程结束。
可以看到,我们通过建模将返奖流程的多个步骤映射为系统的状态。对于系统状态的表述,DDD中常用到的概念是领域事件,另外也提及过事件溯源的实践方案。当然,在设计模式中,也有一种能够表述系统状态的代码模型,那就是状态模式。在邀请下单系统中,我们的主要流程是返奖。对于返奖,每一个状态要进行的动作和操作都是不同的。因此,使用状态模式,能够帮助我们对系统状态以及状态间的流转进行统一的管理和扩展。
模式:状态模式
模式定义:当一个对象内在状态改变时允许其改变行为,这个对象看起来像改变了其类。
对比策略模式的类型会发现和状态模式的类图很类似,但实际上有很大的区别,具体体现在concrete class上。策略模式通过Context产生唯一一个ConcreteStrate-gy作用于代码中,而状态模式则是通过context组织多个ConcreteState形成一个状态转换图来实现业务逻辑。接下来,我们通过一段通用代码来解释怎么使用状态模式:
工程实践
通过前文对状态模式的简介,我们可以看到当状态之间的转换在不是非常复杂的情况下,通用的状态模式存在大量的与状态无关的动作从而产生大量的无用代码。在我们
的实践中,一个状态的下游不会涉及特别多的状态装换,所以我们简化了状态模式。当前的状态只负责当前状态要处理的事情,状态的流转则由第三方类负责。其实践代码如下:
状态模式的核心是封装,将状态以及状态转换逻辑封装到类的内部来实现,也很好的体现了“开闭原则”和“单一职责原则”。每一个状态都是一个子类,不管是修改还是增加状态,只需要修改或者增加一个子类即可。在我们的应用场景中,状态数量以及状态转换远比上述例子复杂,通过“状态模式”避免了大量的if-else代码,让我们的逻辑变得更加清晰。同时由于状态模式的良好的封装性以及遵循的设计原则,让我们在复杂的业务场景中,能够游刃有余地管理各个状态。
3.3点评外卖投放系统中设计模式的实践
3.3.1业务简介
继续举例,点评App的外卖频道中会预留多个资源位为营销使用,向用户展示一些比较精品美味的外卖食品,为了增加用户点外卖的意向。当用户点击点评首页的“美团外卖”入口时,资源位开始加载,会通过一些规则来筛选出合适的展示Banner。
3.3.2设计模式实践
业务建模
对于投放业务,就是要在这些资源位中展示符合当前用户的资源。其流程如下图所示:
从流程中我们可以看到,首先运营人员会配置需要展示的资源,以及对资源进行过滤的规则。我们资源的过滤规则相对灵活多变,这里体现为三点:
过滤规则大部分可重用,但也会有扩展和变更。
不同资源位的过滤规则和过滤顺序是不同的。
同一个资源位由于业务所处的不同阶段,过滤规则可能不同。
过滤规则本身是一个个的值对象,我们通过领域服务的方式,操作这些规则值对象完成资源位的过滤逻辑。下图介绍了资源位在进行用户特征相关规则过滤时的过程:
为了实现过滤规则的解耦,对单个规则值对象的修改封闭,并对规则集合组成的过滤链条开放,我们在资源位过滤的领域服务中引入了责任链模式。
模式:责任链模式
模式定义:使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有对象处理它为止。
责任链模式通用类图如下:
我们通过一段比较通用的代码来解释如何使用责任链模式:
工程实践
下面通过代码向大家展示如何实现这一套流程:
责任链模式最重要的优点就是解耦,将客户端与处理者分开,客户端不需要了解是哪个处理者对事件进行处理,处理者也不需要知道处理的整个流程。在我们的系统中,后台的过滤规则会经常变动,规则和规则之间可能也会存在传递关系,通过责任链模式,我们将规则与规则分开,将规则与规则之间的传递关系通过Spring注入到List中,形成一个链的关系。当增加一个规则时,只需要实现BasicRule接口,然后将新增的规则按照顺序加入Spring中即可。当删除时,只需删除相关规则即可,不需要考虑代码的其他逻辑。从而显著地提高了代码的灵活性,提高了代码的开发效率,同时也保证了系统的稳定性。
四、总结
本文从营销业务出发,介绍了领域模型到代码工程之间的转化,从DDD引出了设计模式,详细介绍了工厂方法模式、策略模式、责任链模式以及状态模式这四种模式在营销业务中的具体实现。除了这四种模式以外,我们的代码工程中还大量使用了代理模式、单例模式、适配器模式等等,例如在我们对DDD防腐层的实现就使用了适配器模式,通过适配器模式屏蔽了业务逻辑与第三方服务的交互。因篇幅原因不再进行过多的阐述。
对于营销业务来说,业务策略多变导致需求多变是我们面临的主要问题。如何应对复杂多变的需求,是我们提炼领域模型和实现代码模型时必须要考虑的内容。DDD以及设计模式提供了一套相对完整的方法论帮助我们完成了领域建模及工程实现。其实,设计模式就像一面镜子,将领域模型映射到代码模型中,切实地提高代码的复用性、可扩展性,也提高了系统的可维护性。
当然,设计模式只是软件开发领域内多年来的经验总结,任何一个或简单或复杂的设计模式都会遵循上述的七大设计原则,只要大家真正理解了七大设计原则,设计模式对我们来说应该就不再是一件难事。但是,使用设计模式也不是要求我们循规蹈矩,只要我们的代码模型设计遵循了上述的七大原则,我们会发现原来我们的设计中就已经使用了某种设计模式。