4 Java单机系统运行和观测
为了满足分布式系统的延迟或者吞吐量要求,需要将分布系统拆分逐个调优,最重要的就是单机系统性能调优和基础设施调优。本章目标为单机性能调优,相对于分布式系统性能调优,它要求程序员具备更为广泛和深入的Java知识。
在第一篇《质量驱动架构》中,提到单机性能瓶颈的根源包括是“组成计算机核心的的CPU,内存,磁盘的访问延迟不是一个数量级。性能较低的组件会降低计算机整体性能”,以及“JVM、工具、系统基础设施默认设置并非最优化”等。本章将会介绍一系列实战方法来解决这些根源问题。阅读完本章,读者不仅仅能成为一个高性能系统的建设者,也能进一步掌握Java系统高级知识。
4.1 JMH
衡量Java接口或者方法的性能最直接的办法是运行多次后取得平均响应时间,因此经常有程序员是main方法写一段测试代码,比如测试foo方法性能
public static void main(String[] args){
int max = 100000;
long start,end;
start = System.nanoTime();
for(int i=0;i=ma-;i++){
foo();
}
end = System.nanoTime();
//计算平均每次执行的纳秒数
System.out.println("foo "+(end-time)/max)
}这段程序非常简单,但不幸的是,这种方式不能统计出foo方法的性能。试着改写程序
public static void main(String[] args){
int max = 100000;
long start,end;
start = System.nanoTime();
for(int i=0;i=ma-;i++){
foo();
}
end = System.nanoTime();
//计算平均每次执行的纳秒数
System.out.println("foo "+(end-time)/max);
start = System.nanoTime();
for(int i=0;i=ma-;i++){
wrapFoo();
}
end = System.nanoTime();
//计算平均每次执行的纳秒数
System.out.println("wrapFoo "+(end-time)/max);
}
public static wrapFoo(){
foo();
}会发现wrapFoo的性能比foo方法性能更好,这是为什么,wrapFoo还调用了foo方法?这是因为通过手工编写一个性能压测程序有较多的问题
- 虚拟机在执行代码过程中,会加载类,解释执行,以及有可能的优化编译(JIT)。需要确保虚拟机进行了一定预热运行,以保证测试的公平性,在压测foo方法的时候,此时执行的还较慢。当执行wrapFoo的时候,虚拟机预热完毕,因此显得wrapFoo方法性能更好。
- 如果要评测那个方法性能更好,如果在同一个虚拟机里调用,有可能会互相影响, 比如第一个方法执行完毕,JVM做垃圾回收会影响第二个方法。 最好的办法是分成俩个独立的进程运行,确保俩个对比方法不相互影响。
- 为了避免环境影响造成的对结果统计不准,我们需要运行多次,取出平均成绩
- 需要从多个纬度统计方法的性能,比如统计冷启动需要消耗的时间,统计吞吐量,每次消耗时间,或者TP95等功能。
OPS来表示吞吐量,OPS,Opeartion Per Second,是衡量性能的重要指标,指得是每秒操作量。数值越大,性能越好。类似的概念还有TPS,表示每秒的事务完成量;QPS,每秒的查询量。 如果对每次执行时间进行升序排序,取出总数的95%的最大执行时间作为TP95的值(Top Percentile ),TP95通常是衡量系统性能重要指标,他表示95%的请求的响应时间不超过某个值。比TP95更严格的是TP99,要求99%的请求不超过某个值
本章所有性能测试使用JMH工具,JMH(Java Microbenchmark Harness)是由 OpenJDK 团队官方开发的一款 Java 微基准测试框架,用于测试JDK性能。很多开源类库和工具库也广泛使用JMH。
开始是使用JMH,可以在工程里添加对JMH的依赖,添加如下
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>${jmh.version} 为jmh最新版本,目前为1.37
我们编写一个JMH测试类,用来测试生成随机数的性能,使用传统的Random类生成和使用ThreadLocalRandom生成
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class MyBenchmark {
@Benchmark
public int testRandom(){
//Random是非线程安全类
Random random = new Random();
return random.nextInt(1000);
}
@Benchmark
public int testNewRandom(){
return ThreadLocalRandom.current().nextInt(1000);
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName())
.build();
new Runner(opt).run();
}
}MyBenchmark 有俩个需要比较的方法,都用 @Benchmark注解标识,MyBenchmark用了一系列注解,解释如下
| 注解 | 说明 |
|---|---|
| BenchmarkMode | 使用模式,默认是Mode.Throughput,表示吞吐量,其他参数还有AverageTime,表示每次执行时间,SampleTime表示采样时间,采样会输出TP50,TP99等统计数据。SingleShotTime表示只运行一次,用于测试冷启动消耗时间,All表示统计前面的所有指标。本书使用Throughput和AverageTime较多。 |
| Warmup | 配置预热次数,默认是每次运行1秒,运行10次,我们的例子是运行3次 |
| Measurement | 配置执行次数,本例是一次运行5秒,总共运行3次。在性能对比时候,采用默认1秒即可,如果我们用jvisualvm做性能监控,我们可以指定一个较长时间运行 |
| Threads | 配置同时起多少个线程执行,默认值是 Runtime.getRuntime().availableProcessors(),本例启动1个线程同时执行 |
| Fork | 代表启动新的JVM分别测试每个方法。如果配置Fork(0),则表示使用当前JVM运行目标测试方法,此时可以Debug程序,建议性能测试使用非0参数,启动多个进程测试。Fork注解还允许配置JVM参数 |
| OutputTimeUnit | 统计结果的时间单元,这个例子TimeUnit.MILLISECONDS,我们在运行后会看到输出结果是统计每毫秒的吞吐量 |
运行如上程序,最后的日志会得到大概如下输出
Benchmark Mode Cnt Score Error Units
MyBenchmark.testNewRandom thrpt 3 398335.875 ± 25224.264 ops/ms
MyBenchmark.testRandom thrpt 3 19192.690 ± 1541.974 ops/ms输出包含了俩个被@Benchmark 标注的方法testFoo和testWrapFoo,输出解释如下表格
| 列名字 | 说明 |
|---|---|
| Mode | 对应BenchmarkMode |
| Cnt | 运行次数,同@Measurement的iterations设置 |
| Units | 输出单元,同OutputTimeUnit的配置,本例子表示每毫秒的的操作次数(ops) |
| Score | 表示运行结果,与Units对应。 本例子表示403 ,表示每毫秒运行403次 |
| Error | 表示测试结果的置信区间误差,误差值过大,表示当时测试环境可能不稳定,需要增加测试次数或者保证测试环境无干扰 |
很多开源框架都内置了JMH用于验证其服务的性能,或者内部函数的性能,比如FastJSON,Netty,Fory等,作者的Beetl和BeetlSQL也使用JMH验证工具的性能。本章后面的高性能战术都会使用JMH验证.
建议在工程里专门建立一个jmh模块,用来测试性能。
4.2 记录JMH测试结果
业务系统的性能优化也可能因为代码调整,JDK升级等原因性能会发生变化。因此可以使用JMH来性能测试并记录其结果,在业务代码调整后可以复测并对比之前的记录结果。
JMH测试结果可以导出*TEXT,*JSON,CSV等格式用于保存最近一次性能测试结果,通过调用
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName()).resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}也可以直接将测试结果粘贴到性能测试到源码方便观察,比如
@Fork(0)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
/**
* Benchmark Mode Cnt Score Error Units
* MyBenchmark.testNewRandom thrpt 3 322815.641 ± 36424.597 ops/ms
* MyBenchmark.testRandom thrpt 3 19331.700 ± 1159.759 ops/ms
*/
public class MyBenchmark {
}4.3 JMH和Spring
通常在测试和优化的业务系统,是采用Spring框架。因此第一节的MyBenchmark需要改动。增加初始化方法setUp用于初始化Spring
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(1)
@Fork(1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark) /* JMHTest的局部变量context,creditService被所有测试现成共享*/
public class JMHSpringTest {
ConfigurableApplicationContext context;
CreditService creditService;
@Benchmark
public int call(){
return creditService.queryCredit(15L);
}
/**
* 启动前初始化,Trial表示只运行一次
*/
@Setup(Level.Trial)
public void setUp() {
//Main是此工程的Spring初始化类
context = SpringApplication.run(Main.class);
creditService = context.getBean(CreditService.class);
}
/**
* 结束前运行,Trial表示只运行一次
*/
@TearDown(Level.Trial)
public void tearDown() {
if (context != null) {
context.close();
}
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(JMHTest.class.getSimpleName())
.build();
new Runner(opt).run();
}
}这个例子多了3个注解,说明如下
| 注解 | 说明 |
|---|---|
| @Setup | 被此注解标注的方法,将在测试前运行,Level.Trial表示整个测试期间,无论运行多少次,此方法只运行一次 |
| @TearDown | 被此注解标注的方法,将在测试结束后运行 |
| @State | 用于指示JMHSpringTest的局部变量在多线程运行下的状态,Scope.Benchmark意味着整个测试中,共享变量。 |
在通过JMH测试和优化实际业务系统的性能时候,通常采用Spring框架,且需要Mock第三方依赖系统,因此建议实际项目里,再Junit环境中使用JMH以方便的集成Spring和Mock功能,读者可以参考junit-jmh-extension开源实现此功能,限于篇幅。不再具体说明
4.4 观察单机系统运行
当具体单机能运行业务系统,且能使用JMH进行性能测试的时候,我们就可以利用JDK提供的jvisualvm工具来观测系统的运行和寻找性能优化点。
通过jvisualvm,你可以连接到本地或者远程虚拟机,监控以下数据
- 每个线程运行情况,如状态是运行,还是休眠,还是等待,监视,线程当时运行在的代码位置和线程栈
- 通过CPU抽样器,可以获取到每个类的每个方法执行的总时间,分为自身执行时间,以及调用其他方法的时间,你可以保存一份快照,供事后进行性能分析,找到可能得性能改善点。
- 通过内存抽样器,你可以获取到内存使用情况,内存有多少对象,你关心的类占据了多少内存,你可以保存一份快照,供事后通过OQL或者商业可视化工具进一步分析内存。
我们可以运行任何JMH程序(需要设置运行时间足够长,即注解Measurement的time足够长时间),然后打开jvisualvm,观测被我们@Benchmark标注的方法运行数据以及JVM的内存使用情况.
调整JMHSpringTest例子,做如下调整
| 调整 | 描述 |
|---|---|
| fork(0) | fork会启动新的JVM运行JMHSpringTest,这里调整为0方便使用jvisualvm 连接上一个JVM就够了。如果填入一个非0,则会启动一个叫ForkedMain进程运行性能测试。 |
| time=60 | 设置运行较长时间以使得性能分析更为准确。 |
| sleep | CreditService.queryCredit方法里,增加一个sleep(10L), 模拟一个性能瓶颈 |
在控制台启用jvisualvm,并在左侧选择MyBenchmark进程,右侧选择Sampler(采用),并在sample中选择CPU采样
可以从http://visualvm.github.io/ 下载最新版本 jvisualvm

上图CPU采样,列出了JVM的所有线程,点击com.ibeetl.jmh.spring.jmh.JMHSpringTest.call-jmh-worker-1 显示了此线程的调用栈,可以看到,在JMHSpringTest.call是JMH的测试方法,queryCredit方法占用了较长时间,进一步分析发现是有一个sleep调用占用了较长时间。
较为快的找到执行瓶颈是通过工具栏,点击Hot Spot按钮,会直接列出CPU耗时,如下表格

表格的含义如下
| 列名 | |
|---|---|
| Name | 目标方法名,可以点击右键,选择Find in forward call显示调用栈,即如图一所示 |
| Self Time(CPU) | 方法自身执行的CPU耗时, |
| Total Time(CPU) | 方法执行的总CPU耗时,即自身耗时+此方法调用其他方法的耗时 |
| Total Time | 显示在调用栈中的方法执行总的耗时,包括了CPU耗时,以及休眠或者是锁等待耗时 |
有了Cpu采样,可以找到目标方法的调用耗时,分析此方法下所有方法调用耗时,以及锁等待(比如为了获取连接池等待时间)耗时,找到性能瓶颈并进行优化。
限于篇幅,本书简单介绍了 jvisualvm工具的CPU采样,可以通过Help菜单了解 jvisualvm所有用法。在后面的章节中,会陆续使用jvisualvm其他特性。除了jvisualvm外,还有JProfiler,YourKit等商业工具
jvisualvm是一个适合开发时期使用的Java性能观察调优工具,它也可以用于生产环境中,临时连接到生产JVM上,它的CPU 和Memory采样对目标JVM造成的影响极低,我的使用经验是对目标系统,降低10%的吞吐量。 在使用任何性能观测调优工具时候,都需要注意此工具对目标系统的影响,以判断是否能在生产上临时使用。
4.5 过时的优化策略
由于Java 语言和JVM不断演进,有些Java 性能优化策略回过时,有的一直是错误的但业界尚未去纠正。举一个例子,有种说法,循环中捕捉异常 的性能损耗较大,建议在循环体外捕捉异常.
可以用编写一个JMH用来验证,比较循环100万次的性能
@Benchmark
public int test(Blackhole hole) {
int i = 0;
for (; i < 100_000_00; i++) {
try {
if (i == -1) {
throw new RuntimeException();
}
hole.consume(i);
} catch (Exception exception) {
continue;
}
}
return i;
}
@Benchmark
public int test2(Blackhole hole) {
int i = 0;
try {
for (; i < 100_000_00; i++) {
if (i == -1) {
throw new RuntimeException();
}
hole.consume(i);
}
} catch (Exception exception) {
;
}
return i;
}这里使用了JMH提供的Blackhole来防止出现代码消除情况,Blackhole.consume 用来欺骗JIT 避免被消除,让其认为代码中的变量确实有用(作为参数传递给comsume) 这种测试最后证明,两种循环性能没有区别。
Benchmark Mode Cnt Score Error Units
CatchExceptionTest.test avgt 10 1.375 ± 0.163 ms/op
CatchExceptionTest.test2 avgt 10 1.283 ± 0.059 ms/op其他已知的不正确的优化策略还包括如下,关于这几个策略的JMH性能测试结果,参考本书提供的例子
- 错误:使用final标识的方法能指示JIT完成内联。经过JMH测试,无论是否使用final,只要方法符合JIT对内联的设定,就能实现内联,JMH性能测试也能证明俩种是一样的。在JIT一节,我们将分析JIT日志判断方法是否被内联
- 错误:使用StringBuilder 或者使用"+" 拼接字符串,两者性能是一样的。特定情况下,答案是“+” 性能有可能更好,从生成字节码能看到后者的的字节码指令会更少,这将在6.10中说明
