首先,我们来观察一条工厂的生产线,该生产线主要用于将自行车各个组件拼装成一辆完整的自行车。通过观察我们发现一辆自行车从车架上生产线开始装配,直到拼装成完整自行车后下线的整个耗时为4小时,如下图所示。
并且,我们还观察到这条生产线上每分钟就会有一辆组装好的自行车下线,该生产线每天24小时不间断运行。如果忽略掉例如生产线维护等时间成本,可以算出,该生产线在一个小时的时间内可以组装60辆自行车下线。
通过上面的观察,我们提炼出该工厂的两个指标:工厂的延迟(Latency)和工厂的吞吐量(Throughput)。
工厂的延迟:4小时
工厂的吞吐量:60辆自行车每小时
对于一个系统来说,其延迟可以是从纳秒到千年的任意值,吞吐量表示一个系统在单位时间内完成的操作。
假设该生产线已经以这里提到的延迟和吞吐量稳定运行了几个月。现在随着市场需求的增长,我们需要让工厂能够生产出两倍的自行车。即原来每天能生产60 * 24 = 1440辆,现在需要每天生产1440 * 2 = 2880辆才能满足市场需求。以该工厂目前的性能来看,是无法满足这一需求的,这就需要我们想办法对生产线进行调优。
可能首先想到的是扩展整个工厂的容量(Capacity),即增加投入资金,新建一条相同的生产线,新生产线的延迟和吞吐量和现有生产线保持一致,这样就能够满足每天生产2880辆自行车的需求了。如下图所示,
工厂容量扩充一倍后,整个工厂的吞吐量也由原来的60辆每小时变成了120辆每小时,而延迟并没有变化。在本次调节过程中,我们只是提高了吞吐量,而没有对延迟进行任何优化。这种调整由于新增了一条生产线,无形中也提高了工厂的硬件成本的投入。
那么,假如我们换一个角度来考虑,如果将每辆自行车的装配时间由4小时缩短到2小时,那么整个工厂的吞吐量也能翻倍。这种性能上的调节比直接投入一条新的生产线更能让人接受。
其实类似于自行车生产线,在软件工程领域如果需要提高一个系统的吞吐量,也是从这两个角度来考虑的,要么投入更多的硬件成本,要么提高系统性能,缩短延迟时间。
从上面的生产线的例子中我们也提炼出了三个重要概念,延迟(Latency),吞吐量(Throughput),系统容量(Capacity)。接下来会从这三个方面进行分析。
正常来说,我们谈到一个系统的延迟时,一般会提出类似于如下的要求:
(1)所有用户的交易必须在10秒内得到响应
(2)90%的订单必须在3秒内处理完成
(3)推荐的商品必须在100ms内展示在用户面前
当遇到如上这种性能调优要求时,我们需要保证整个系统运行时由于GC导致的交易暂停时间不能过长,因为系统不可避免的还会有一些其他开销,比如外部数据源的交互时间,锁竞争以及其他安全验证等操作。这就要求我们尽量压缩GC的暂停时间。
假设某个系统要求90%的交易需要在1秒内完成,并且交易最长处理时间不能超过10秒。由于整个系统的延迟不仅只是由于GC导致的暂停,所以我们需要GC导致的系统延迟不能超过10%。基于这个要求,那么我们需要确保90%的GC暂停时间需要在100毫秒内完成,并且不允许任何GC暂停时间超过1秒钟。为了简单起见,我们不考虑在一次交易过程中发生多次GC的情况。
当确定好上述调优需求后,我们下一步就需要去计算GC暂停时间了。我们可以使用多种工具来获取这些指标,有关这些工具可以参考《六、GC调优工具》。在本节中,我们使用GC日志来获取GC暂停时间。看下面这段日志示例
2015-06-04T13:34:16.974-0200: 2.578: [Full GC (Ergonomics) [PSYoungGen: 93677K-
>70109K(254976K)] [ParOldGen: 499597K->511230K(761856K)] 593275K->581339K(1016832K),
[Metaspace: 2936K->2936K(1056768K)], 0.0713174 secs] [Times: user=0.21 sys=0.02,
real=0.07 secs
上面这一日志片段反应了在2015年6月4日13:34:16,在该JVM启动2578毫秒后触发的一次GC动作。这一GC动作导致了应用中断了0.0713174秒。虽然在多个CPU核上导致了总共210毫秒的时间,但是由于本例是在一台多核服务器上进行的并行GC,所以我们只需要考虑整个应用线程被暂停的总时间,所以实际暂停时间约为70毫秒。满足性能调优要求中提到的100毫秒的要求。
继续分析更多的GC暂停日志,就可以统计出该系统的GC是否满足调优要求。
系统的吞吐量的要求与上面提到的延迟要求是不同的。对于系统的吞吐量,一般会提出类似如下的需求:
(1)系统必须能每天处理100万条交易
(2)系统必须能够支持1000个用户同时在线,在5~10秒的时间内同时进行A,B或C操作
(3)系统必须每周在不超过6个小时的时间内对所有消费者的消费记录进行分析统计,这个时间窗口定在每周日晚上12点到6点
系统延迟关注于单次操作的性能,而系统吞吐量则关注系统的整体性能。所以在调节系统吞吐量时,我们需要关注系统总的GC时间。由于之前提到的相同原因,我们仍然规定GC时间不能超过总耗时的10%。
假设某个系统每分钟需要处理1000次交易,那么按照10%的要求,我们的系统在这一分钟内能够进行GC的总时间应该不能超过6秒钟。
基于上面这一要求,我们下一步就来分析系统的GC时间消耗。我们仍然看以下这一份GC日志:
2015-06-04T13:34:16.974-0200: 2.578: [Full GC (Ergonomics) [PSYoungGen: 93677K-
>70109K(254976K)] [ParOldGen: 499597K->511230K(761856K)] 593275K->581339K(1016832K),
[Metaspace: 2936K->2936K(1056768K)], 0.0713174 secs] [Times: user=0.21 sys=0.02,
real=0.07 secs
这次我们主要关注user和sys时间,而不是real时间。那么我们可以得到,在这次GC暂停过程中这两部分的总耗时为0.23秒。由于系统运行在多核服务器上,这里的0.23秒是所有核上的总暂停时间,整个系统的暂停时间为0.0713174秒,这个数值在后面的计算中会用到。
获得了上面这些信息后,我们接下来需要统计的是在这一分钟内每次GC导致的总暂停时间。看看在这一分钟内的总GC时间是否超过了规定的6秒钟。
系统容量是在上面提到的延迟需求以及吞吐量之外的硬件限制。这些硬件限制可以是如下形式,例如:
(1)系统必须部署在内存不超过512MB的安卓设备上
(2)系统必须部署在Amazon EC2上。并且硬件性能不能超过(8G, 4核)
(3)花费在Amazon EC2上的月开支不能超过$12000
我们总是希望对给定的硬件环境充分利用,在给定的硬件环境基础上实现最优性能。
所以,综合上面这三部分,一般来说,对一个系统提出的完整性能要求如下,下面这一段基本上就是我们日常生活中耳熟能详的了。
(1)系统必须部署在内存不超过512MB的安卓设备上
(2)系统必须部署在Amazon EC2上。并且硬件性能不能超过(8G, 4核)
(3)花费在Amazon EC2上的月开支不能超过$12000
(4)所有用户的交易必须在10秒内得到响应
(5)90%的订单必须在3秒内处理完成
(6)推荐的商品必须在100ms内展示在用户面前
(7)系统必须能每天处理100万条交易
(8)系统必须能够支持1000个用户同时在线,在5~10秒的时间内同时进行A,B或C操作
(9)系统必须每周在不超过6个小时的时间内对所有消费者的消费记录进行分析统计,这个时间窗口定在每周日晚上12点到6点
到这里,我们已经知道了性能调优时的三个衡量维度了。接下来我们以实例形式来实现对系统性能的调节。
示例代码如下:
public class Producer implements Runnable {
private static ScheduledExecutorService executorService =
Executors. newScheduledThreadPool( 2) ;
private Deque< byte []> deque ;
private int objectSize ;
private int queueSize ;
public Producer( int objectSize, int ttl) {
this . deque = new ArrayDeque<byte []>() ;
this. objectSize = objectSize;
this. queueSize = ttl * 1000 ;
}
@Override
public void run () {
for ( int i = 0 ; i < 100 ; i++) {
deque .add( new byte[ objectSize ]);
if ( deque .size() > queueSize ) {
deque.poll() ;
}
}
}
public static void main (String[] args) throws InterruptedException {
executorService.scheduleAtFixedRate (new Producer( 200 * 1024 * 1024 / 1000 , 5 ) , 0 ,
100, TimeUnit. MILLISECONDS) ;
executorService.scheduleAtFixedRate (new Producer( 50 * 1024 * 1024 / 1000, 120 ), 0 ,
100, TimeUnit. MILLISECONDS) ;
TimeUnit. MINUTES .sleep(10 ) ;
executorService.shutdownNow() ;
}
}
上面这段代码每隔100毫秒会提交并运行两个job,每隔job模拟特定的生命周期:创建对象,在一定时间内对该对象进行释放,让GC对这些内存空间进行回收。
运行时设定如下JVM参数
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
运行后可以看到如下格式的日志信息
2017-03-02T23:39:05.852+0800: 0.376: [GC [PSYoungGen: 98618K->15860K(114688K)] 98618K->91364K(375296K), 0.0512823 secs] [Times: user=0.09 sys=0.00, real=0.05 secs]
2017-03-02T23:39:06.251+0800: 0.775: [GC [PSYoungGen: 114650K->15838K(213504K)] 190154K->187102K(474112K), 0.0241185 secs] [Times: user=0.02 sys=0.03, real=0.02 secs]
2017-03-02T23:39:06.275+0800: 0.799: [Full GC [PSYoungGen: 15838K->0K(213504K)] [ParOldGen: 171264K->186865K(420864K)] 187102K->186865K(634368K) [PSPermGen: 3236K->3235K(21504K)], 0.0414808 secs] [Times: user=0.22 sys=0.00, real=0.04 secs]
2017-03-02T23:39:06.955+0800: 1.479: [GC [PSYoungGen: 197560K->15868K(213504K)] 384425K->388821K(634368K), 0.0381355 secs] [Times: user=0.05 sys=0.08, real=0.04 secs]
基于上面这些日志信息,我们可以从以下三个方面来进行优化
(1)确保GC暂停的最长时间不超过设定的某个阈值
(2)确保GC暂停总时间不超过设定的某个阈值
(3)在达到延长和吞吐量目标要求的情况下,尽可能少的使用硬件资源
首先,在不考虑系统延迟和吞吐量的情况下统计应用程序在不同堆内存和不同GC算法情况下运行10分钟的统计信息,统计信息包括可用率,最长暂停时间。
堆大小 | GC算法 | 系统可用率 | 最长暂停时间 |
---|---|---|---|
-Xmx12g | -XX:UseConcMarkSweepGC | 89.8% | 560ms |
-Xmx12g | -XX:UseParallelGC | 91.5% | 1104ms |
-Xmx8g | -XX:UseConcMarkSweepGC | 66.3% | 1610ms |
现在我们给系统设定一个延迟阈值,所有job必须在1000毫秒内运行完成。如果我们知道job实际运行时间只要100毫秒,那么我们的GC暂停时间就不能超过900毫秒了。我们只需要统计每次GC的最长暂停时间就可以得到结论了。
在上表中仅仅只有第一种参数设置情况达到了要求,最长暂停时间为560毫秒。那么,我们在运行Producer应用时,就需要使用如下命令
java -Xmx12g -XX:UseConcMarkSweepGC Producer
现在假设我们需要每小时能处理13000000次操作。还是看一下上面的统计信息。其中第二条,GC的暂停时间占总运行时间的8.5%。基于以下条件我们进行计算,
(1)一个job在一个核上的运行时间为100毫秒
(2)那么一个核在一分钟内就能运行60000次操作(代码中每个job完成100次操作)
(3)一个小时内,一个核就能运行3600000次操作
(4)在有4个core的情况下,每小时就能运行14400000次操作
那么如果按照应用可用时间为91.5%来计算的话,在
java -Xmx12g -XX:+UseParallelGC Producer
情况下,一个小时可用运行13176000操作。
达到了吞吐量调优的要求,又不满足延迟调优的要求了。在这里最大暂停时间为1104毫秒,是最大暂停时间阈值的两倍多。
加入我们需要将应用部署在4核,可用内存不超过10G的机器上,那么我们就只能选择第三种JVM参数情况,使用8GB的内存了。在上表中,第三种情况的运行命令为
java -Xmx8g -XX:UseConcMarkSweepGC Producer
但是在这种情况下,Latency和Throughput的要求都得不到满足了。
(1)在这种情况下CPU只有66.3%的时间可用,这样的话,每小时能进行的操作数就会降到9547200
(2)延迟时间从560毫秒增加到1610毫秒
通过以上分析可以看到,对系统进行调优时是需要同时考虑三个维度的,在实际调优时,需要在这三个维度上选择一个相对好的方案。