2021-09-06

探究 Java 应用的启动速度优化

高性能和快启动速度,能否鱼和熊掌兼得?
Java 作为一门面向对象编程语言,在性能方面的卓越表现独树一帜。

《Energy Efficiency across Programming Languages,How Does Energy, Time, and Memory Relate?》这份报告调研了各大编程语言的执行效率,虽然场景的丰富程度有限,但是也能够让我们见微知著。

从表中,我们可以看到,Java 的执行效率非常高,约为最快的 C 语言的一半。这在主流的编程语言中,仅次于 C、Rust 和 C++。

Java 的优异性能得益于 Hotspot 中非常优秀的 JIT 编译器。Java 的 Server Compiler(C2) 编译器是 Cliff Click 博士的作品,使用了 Sea-of-Nodes 模型。而这项技术,也通过时间证明了它代表了业界的最先进水平:

著名的 V8(JavaScript 引擎)的 TurboFan 编译器使用了相同的设计,只是用更加现代的方式去实现;
Hotspot 使用 Graal JVMCI 做 JIT 时,性能基本与 C2 持平;
Azul 的商业化产品将 Hotspot 中的 C2 compiler 替换成 LLVM,峰值性能和 C2 也是持平。
在高性能的背后,Java 的启动性能差也令人印象深刻,大家印象中的 Java 笨重缓慢的印象也大多来源于此。高性能和快启动速度似乎有一些相悖,本文将和大家一起探究两者是否可以兼得。

JAVA 启动慢的根因
1、框架复杂

JakartaEE 是 Oracle 将 J2EE 捐赠给 Eclipse 基金会后的新名字。Java 在1999年推出时便发布了 J2EE 规范,EJB(Java Enterprise Beans) 定义了企业级开发所需要的安全、IoC、AOP、事务、并发等能力。设计极度复杂,最基本的应用都需要大量的配置文件,使用非常不便。

随着互联网的兴起,EJB 逐渐被更加轻量和免费的 Spring 框架取代,Spring 成了 Java 企业开发的事实标准。Spring 虽然定位更加轻量,但是骨子里依然很大程度地受 JakartaEE 的影响,比如早期版本大量 xml 配置的使用、大量 JakartaEE 相关的注解(比如JSR 330依赖注入),以及规范(如JSR 340 Servlet API)的使用。

但 Spring 仍是一个企业级的框架,我们看几个 Spring 框架的设计哲学

在每一层都提供选项,Spring 可以让你尽可能的推迟选择。
适应不同的视角,Spring 具有灵活性,它不会强制为你决定该怎么选择。它以不同的视角支持广泛的应用需求。
保持强大的向后兼容性。
在这种设计哲学的影响下,必然存在大量的可配置和初始化逻辑,以及复杂的设计模式来支撑这种灵活性。我们通过一个试验来看:

我们跑一个spring-boot-web的helloword,通过-verbose:class可以看到依赖的class文件:

$ java -verbose:class -jar myapp-1.0-SNAPSHOT.jar | grep spring | head -n 5
[Loaded org.springframework.boot.loader.Launcher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.ExecutableArchiveLauncher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.JarLauncher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.archive.Archive from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.LaunchedURLClassLoader from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]

$ java -verbose:class -jar myapp-1.0-SNAPSHOT.jar | egrep ‘^[Loaded’ > classes
$ wc classes
7404 29638 1175552 classes
class 个数到达惊人的 7404 个。

我们再对比下 JavaScript 生态,使用常用的 express 编写一个基本应用:

const express = require(‘express’)
const app = express()

app.get(’/’, (req, res) => {
res.send(‘Hello World!’)
})

app.listen(3000, () => {
console.log(Example app listening at http://localhost:${port})
})
我们借用 Node 的 debug 环境变量分析:

NODE_DEBUG=module node app.js 2>&1 | head -n 5
MODULE 18614: looking for “/Users/yulei/tmp/myapp/app.js” in ["/Users/yulei/.node_modules","/Users/yulei/.node_libraries","/usr/local/Cellar/node/14.4.0/lib/node"]
MODULE 18614: load “/Users/yulei/tmp/myapp/app.js” for module “.”
MODULE 18614: Module._load REQUEST express parent: .
MODULE 18614: looking for “express” in ["/Users/yulei/tmp/myapp/node_modules","/Users/yulei/tmp/node_modules","/Users/yulei/node_modules","/Users/node_modules","/node_modules","/Users/yulei/.node_modules","/Users/yulei/.node_libraries","/usr/local/Cellar/node/14.4.0/lib/node"]
MODULE 18614: load “/Users/yulei/tmp/myapp/node_modules/express/index.js” for module “/Users/yulei/tmp/myapp/node_modules/express/index.js”

$ NODE_DEBUG=module node app.js 2>&1 | grep ‘: load "’ > js
$ wc js
55 392 8192 js
这里只依赖了区区 55个 js 文件。

虽然拿 spring-boot 和 express 比并不公平。在 Java 世界也可以基于 Vert.X、Netty 等更加轻量的框架来构建应用,但是在实践中,大家几乎都会不假思索地选择 spring-boot,以便享受 Java 开源生态的便利。

2、一次编译,到处运行

Java 启动慢是因为框架复杂吗?答案只能说框架复杂是启动慢的原因之一。通过 GraalVM 的 Native Image 功能结合 spring-native 特性,可以将 spring-boot 应用的启动时间缩短约十倍。

Java 的 Slogan 是 “Write once, run anywhere”(WORA),Java 也确实通过字节码和虚拟机技术做到了这一点。

WORA 使得开发者在 MacOS 上开发调试完成的应用可以快速部署到 Linux 服务器,跨平台性也让 Maven 中心仓库更加易于维护,促成了 Java 开源生态的繁荣。

我们来看一下 WORA 对 Java 的影响:

Class Loading
Java 通过 class 来组织源码,class 被塞进 JAR 包以便组织成模块和分发,JAR 包本质上是一个 ZIP 文件:

$ jar tf slf4j-api-1.7.25.jar | head
META-INF/
META-INF/MANIFEST.MF
org/slf4j/
org/slf4j/event/EventConstants.class
org/slf4j/event/EventRecodingLogger.class
org/slf4j/event/Level.class
每个 JAR 包都是功能上比较独立的模块,开发者就可以按需依赖特定功能的 JAR,这些 JAR 通过 class path 被JVM 所知悉,并进行加载。

根据,执行到 new 或者 invokestatic 字节码时会触发类加载。JVM 会将控制交给 Classloader ,最常见的实现 URLClassloader 会遍历 JAR 包,去寻找相应的 class 文件:

for (int i = 0; (loader = getNextLoader(cache, i)) != null; i++) {
Resource res = loader.getResource(name, check);
if (res != null) {
return res;
}
}
因此查找类的开销,通常和 JAR 包个数成正比,在大型应用的场景下个数会上千,导致整体的查找耗时很高。

当找到 class 文件后 JVM 需要校验 class 文件的是否合法,并解析成内部可用的数据结构,在 JVM 中叫做 InstanceKlass ,听过 javap 窥视一下class文件包含的信息:

$ javap -p SimpleMessage.class
public class org.apache.logging.log4j.message.SimpleMessage implements org.apache.logging.log4j.message.Message,org.apache.logging.log4j.util.StringBuilderFormattable,java.lang.CharSequence {
private static final long serialVersionUID;
private java.lang.String message;
private transient java.lang.CharSequence charSequence;
public org.apache.logging.log4j.message.SimpleMessage();
public org.apache.logging.log4j.message.SimpleMessage(java.lang.String);
这个结构包含接口、基类、静态数据、对象的 layout、方法字节码、常量池等等。这些数据结构都是解释器执行字节码或者JIT编译所必须的。

Class initialize
当类被加载完成后,要完成初始化才能实际创建对象或者调用静态方法。类初始化可以简单理解为静态块:

public class A {
private final static String JAVA_VERSION_STRING = System.getProperty(“java.version”);
private final static Set idBlackList = new HashSet<>();
static {
idBlackList.add(10);
idBlackList.add(65538);
}
}
上面的第一个静态变量 JAVA_VERSION_STRING 的初始化在编译成字节码后也会成为静态块的一部分。

类初始化有如下特点:

只执行一次;
有多线程尝试访问类时,只有一个线程会执行类初始化,JVM 保证其他线程都会阻塞等待初始化完成。
这些特点非常适合读取配置,或者构造一些运行时所需要数据结构、缓存等等,因此很多类的初始化逻辑会写的比较复杂。

Just In Time compile
Java 类在被初始化后就可以实例对象,并调用对象上的方法了。解释执行类似一个大的 switch…case 循环,性能比较差:
while (true) {
switch(bytocode[pc]) {
case AALOAD:

break;
case ATHROW:

break;
}
}
我们用 JMH 来跑一个 Hessian 序列化的 Micro Benchmark 试验:

$ java -jar benchmarks.jar hessianIO
Benchmark Mode Cnt Score Error Units
SerializeBenchmark.hessianIO thrpt 118194.452 ops/s

$ java -Xint -jar benchmarks.jar hessianIO
Benchmark Mode Cnt Score Error Units
SerializeBenchmark.hessianIO thrpt 4535.820 ops/s
第二次运行的 -Xint 参数控制了我们只使用解释器,这里差了26倍,这是直接机器执行的执行和解释执行的差异带来的。这个差距跟场景的关系很大,我们通常的经验值是50倍。

我们来进一步看下 JIT 的行为:

$ java -XX:+PrintFlagsFinal -version | grep CompileThreshold
intx Tier3CompileThreshold = 2000 {product}
intx Tier4CompileThreshold = 15000 {product}
这里是两项 JDK 内部的 JIT 参数的数值,我们暂不对分层编译原理做过多介绍,可以参考Stack Overflow。Tier3 可以简单理解为(client compiler)C1,Tier4 是 C2。当一个方法解释执行2000次会进行 C1 编译,当 C1 编译后执行15000次后就会 C2 编译,真正达到文章开头的 C 的一半性能完全体。

在应用刚启动阶段,方法还没有完全被JIT编译完成,因此大部分情况停留在解释执行,影响了应用启动的速度。

使用python中的pymsql完成如下:表结构与数据创建 1. 建立 `users` 表和 `orders` 表。 `users` 表有用户ID、用户名、年龄字段,(id,name,age) `orders` 表有订单ID、订单日期、订单金额,用户id字段。(id,order_date,amount,user_id) 2 两表的id作为主键,`orders` 表用户id为users的外键 3 插入数据 `users` (1, '张三', 18), (2, '李四', 20), (3, '王五', 22), (4, '赵六', 25), (5, '钱七', 28); `orders` (1, '2021-09-01', 500, 1), (2, '2021-09-02', 1000, 2), (3, '2021-09-03', 600, 3), (4, '2021-09-04', 800, 4), (5, '2021-09-05', 1500, 5), (6, '2021-09-06', 1200, 3), (7, '2021-09-07', 2000, 1), (8, '2021-09-08', 300, 2), (9, '2021-09-09', 700, 5), (10, '2021-09-10', 900, 4); 查询语句 1. 查询订单总金额 2. 查询所有用户的平均年龄,并将结果四舍五入保留两位小数。 3. 查询订单总数最多的用户的姓名和订单总数。 4. 查询所有不重复的年龄。 5. 查询订单日期在2021年9月1日至9月4日之间的订单总金额。 6. 查询年龄不大于25岁的用户的订单数量,并按照降序排序。 7. 查询订单总金额排名前3的用户的姓名和订单总金额。 8. 查询订单总金额最大的用户的姓名和订单总金额。 9. 查询订单总金额最小的用户的姓名和订单总金额。 10. 查询所有名字中含有“李”的用户,按照名字升序排序。 11. 查询所有年龄大于20岁的用户,按照年龄降序排序,并只显示前5条记录。 12. 查询每个用户的订单数量和订单总金额,并按照总金额降序排序。
06-03
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qq_40942860

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值