简介: Java 的执行效率非常高,约为最快的C语言的一半。这在主流的编程语言中,仅次于C、Rust 和 C++。但在高性能的背后,Java 的启动性能差也令人印象深刻,大家印象中的 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<Integer> 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编译完成,因此大部分情况停留在解释执行,影响了应用启动的速度。

三 如何优化 Java 应用的启动速度

前面我们花了大量的篇幅分析了 Java 应用启动慢的主要原因,总结下就是:

  • 受到 JakartaEE 影响,常见框架考虑复用和灵活性,设计得比较复杂;
  • 为了跨平台性,代码是动态加载,并且动态编译的,启动阶段加载和执行耗时;

这两者综合起来造成了 Java 应用启动慢的现状。

Python 和 Javascript 都是动态解析加载模块的,CPyhton 甚至没有 JIT,理论上启动不会比 Java 快很多,但是它们并没有使用很复杂的应用框架,因此整体不会感受到启动性能的问题。

虽然我们无法轻易去改变用户对框架的使用习惯,但是可以在运行时层面进行增强,使启动性能尽量靠近 Native image。OpenJDK 官方社区也一直在努力解决启动性能问题,那么我们作为普通 Java 开发者,是否可以借助OpenJDK的最新特性来协助我们提升启动性能呢?

  • Class Loading

    • 通过 JarIndex 解决 JAR 包遍历问题,不过该技术过于古老,很难在现代的囊括了tomcat、fatJar的项目里使用起来
    • AppCDS 可以解决 class 文件解析处理的性能问题
  • Class Initialize: OpenJDK9 加入了 HeapArchive,可以持久化一部分类初始化相关的 Heap 数据,不过只有寥寥数个 JDK 内部 class (比如 IntegerCache )可以被加速,没有开放的使用方式。
  • JIT预热: JEP295 实现了 AOT 编译,但是存在 bug,使用不当会引发程序正确性能问题。在性能上没有得到很好的 tuning,大部分情况下看不到效果,甚至会出现性能回退。

面对 OpenJDK 上述特性所存在的问题,Alibaba Dragonwell 对以上各项技术进行了研发优化,并与云产品进行了整合,用户不需要投入太多精力就可以轻松地优化启动时间。

1 AppCDS

CDS(Class Data Sharing)在Oracle JDK1.5被首次引入,在Oracle JDK8u40中引入了AppCDS,支持JDK以外的类 ,但是作为商业特性提供。随后Oracle将AppCDS贡献给了社区,在JDK10中CDS逐渐完善,也支持了用户自定义类加载器(又称AppCDS v2)。

面向对象语言将对象(数据)和方法(对象上的操作)绑定到了一起,来提供更强的封装性和多态。这些特性都依赖对象头中的类型信息来实现,Java、Python语言都是如此。Java对象在内存中的layout如下:

+-------------+
| mark |
+-------------+
| Klass* |
+-------------+
| fields |
| |
+-------------+

mark 表示了对象的状态,包括是否被加锁、GC年龄等等。而Klass*指向了描述对象类型的数据结构 InstanceKlass :

//  InstanceKlass layout:
// [C++ vtbl pointer ] Klass
// [java mirror ] Klass
// [super ] Klass
// [access_flags ] Klass
// [name ] Klass
// [methods ]
// [fields ]
...

基于这个结构,诸如 o instanceof String 这样的表达式就可以有足够的信息判断了。要注意的是InstanceKlass结构比较复杂,包含了类的所有方法、field等等,方法又包含了字节码等信息。这个数据结构是通过运行时解析class文件获得的,为了保证安全性,解析class时还需要校验字节码的合法性( 非通过 Javac 产生的方法字节码很容易引起 JVM crash)。

CDS 可以将这个解析、校验产生的数据结构存储(dump)到文件,在下一次运行时重复使用。这个dump产物叫做Shared Archive,以jsa后缀(Java shared archive)。

为了减少 CDS 读取 jsa dump 的开销,避免将数据反序列化到InstanceKlass的开销,jsa 文件中的存储layout和InstanceKlass对象完全一样,这样在使用 jsa 数据时,只需要将 jsa 文件映射到内存,并且让对象头中的类型指针指向这块内存地址即可,十分高效。

Object:
+-------------+
| mark | +-------------------------+
+-------------+ |classes.jsa file |
| Klass* +--------->java_mirror|super|methods|
+-------------+ |java_mirror|super|methods|
| fields | |java_mirror|super|methods|
| | +-------------------------+
+-------------+

AppCDS 对 customer class loader 力不从心

jsa 中存储的InstanceKlass是对class文件解析的产物。对于 boot classloader (就是加载jre/lib/rt.jar下面的类的classloader)和 system(app) classloader (加载-classpath下面的类的 classloader ),CDS有内部机制可以跳过对 class文件 的读取,仅仅通过类名在 jsa 文件中匹配对应的数据结构。

Java 还提供用户自定义类加载器(custom class loader)的机制,用户通过Override自己的 Classloader.loadClass() 方法可以高度定制化获取类的逻辑,比如从网络上获取、直接在代码中动态生成都是可行的。为了增强AppCDS的安全性,避免因为从CDS加载了类定义反而获得了非预期的类,AppCDS customer class loader需要经过如下步骤:

  1. 调用用户定义的Classloader.loadClass(),拿到class byte stream
  2. 计算class byte stream的checksum,与jsa中的同类名结构的checksum比较
  3. 如果匹配成功则返回jsa中的InstanceKlass,否则继续使用slow path解析class文件

我们看到许多场景下,上述的第一步占据了类加载耗时的大头,此时 AppCDS 就显得力不从心了。举例来说:

bar.jar
+- com/bar/Bar.class baz.jar
+- com/baz/Baz.class foo.jar
+- com/foo/Foo.class

class path 包含如上的三个jar包,在加载class com.foo.Foo 时,大部分Classloader实现(包括URLClassloader、tomcat、spring-boot)都选择了最简单的策略(过早的优化是万恶之源): 按照jar包出现在磁盘的顺序逐个尝试抽取 com/foo/Foo.class 这个文件。

JAR 包使用了 zip 格式作为存储,每次类加载都需要遍历classpath下的 JAR 包们,尝试从 zip 中抽取单个文件,来确保存在的类可以被找到。假设有N个 JAR 包,那么平均一个类加载需要尝试访问N/2个zip文件。

在我们的一个真实场景下,N到达2000,此时 JAR 包查找开销非常大,并且远大于InstanceKlass解析的开销。面对此类场景 AppCDS 技术就力不从心了。

JAR Index

根据jar文件规范,JAR 文件是一种使用 zip封装,并使用文本在META-INF目录存储元信息的格式。该格式在设计时已经考虑了应对上述的查找场景,这项技术叫做JAR Index。

假设我们要在上述的bar.jar、baz.jar、foo.jar中查找一个class,如果能够通过类型com.foo.Foo,立刻推断出具体在哪个jar包,就可以避免上述的扫描开销了。

JarIndex-Version: 1.0

foo.jar
com/foo bar.jar
com/bar baz.jar
com/baz

通过 JAR Index 技术,可以生成出上述的索引文件INDEX.LIST。加载到内存后成为一个HashMap:

com/bar --> bar.jar
com/baz --> baz.jar
com/foo --> foo.jar

当我们看到类名com.foo.Foo,可以根据包名 com.foo 从索引中得知具体的jar包foo.jar,迅速抽取class文件。

Jar Index 技术看似解决了我们的问题,但是这项技术十分古老,很难在现代应用中被使用起来:

  • jar i 根据 META-INF/MANIFEST.MF 中的 Class-Path 属性产生索引文件,现代项目几乎不维护这个属性
  • 只有 URLClassloader 支持JAR Index
  • 要求带索引的jar尽量出现在 classpath 的前面

Dragonwell 通过 agent 注入使得 INDEX.LIST 能够被正确地生成,并出现在 classpath 的合适位置来帮助应用提升启动性能。

2 类提前初始化

类的 static block 中的代码执行我们称之为类初始化,类加载完成后必须执行完初始化代码才能被使用(创建instance、调用 static 方法)。

很多类的初始化本质上只是构造一些static field:

class IntegerCache {
static final Integer cache[];
static {
Integer[] c = new Integer[size];
int j = low;
for(int k = 0; k < c.length; k++)
c[k] = new Integer(j++);
cache = c;
}
}

我们知道 JDK 对 box type 中常用的一段区间有缓存,避免过多的重复创建,这段数据就需要提前构造好。由于这些方法只会被执行一次,因此是以纯解释的方式执行的,如果可以持久化几个static字段的方式来避免调用类初始化器,我们就可以拿到提前初始化好的类,减少启动时间。

将持久化加载到内存使用最高效的方式是内存映射:

int fd = open("archive_file", O_READ);
struct person *persons = mmap(NULL, 100 * sizeof(struct person),
PROT_READ, fd, 0);
int age = persons[5].age;

C语言几乎是直接面向内存来操作数据的,而Java这样的高级语言都将内存抽象成了对象,有mark、Klass*等元信息,每次运行之间都存在一定的变化,因此需要更加复杂的机智来获得高效的对象持久化。

Heap Archive简介

OpenJDK9 引入了HeapArchive能力,OpenJDK12中heap archive 被正式使用。顾名思义,Heap Archive技术可以将堆上的对象持久化存储下来。

对象图被提前被构建好后放进archive,我们将这个阶段称为dump;而使用archive里的数据称为运行时。dump和运行时通常不是一个进程,但在某些场景下也可以是同一个进程。

回忆下使用AppCDS后的内存布局,对象的Klass*指针指向了SharedArchive中的的数据。AppCDS对InstanceKlass这个元信息进行了持久化,如果想要复用持久化的对象,那么对象头的类型指针必须也要指向一块被持久化过的元信息,因此HeapArchive技术是依赖AppCDS的。

为了适应多种场景,OpenJDK的HeapArchive还提供了Open和Closed两种级别:

上图是允许的引用关系:

  • Closed Archive

    • 不允许引用Open Archive 和Heap中的对象
    • 可以引用Closed Archive内部的对象
    • 只读,不可写
  • Open Archive

    • 可以引用任何对象
    • 可写

这样设计的原因是对于一些只读结构,放在Closed Archive 中可以做到对GC完全无开销。

为什么只读?想象一下,假如Closed Archive中的对象A引用了heap中的对象B,那么当对象B移动时,GC需要修正A中指向B的field,这会带来GC开销。

利用 Heap Archive 提前做类初始化

支持这种结构后,在类加载后,将static变量指向被Archive的对象,即可完成类初始化:

class Foo {
static Object data;
} +
|
<---------+
Open Archive Object:
+-------------+
| mark | +-------------------------+
+-------------+ |classes.jsa file |
| Klass* +--------->java_mirror|super|methods|
+-------------+ |java_mirror|super|methods|
| fields | |java_mirror|super|methods|
| | +-------------------------+
+-------------+

3 AOT编译

除去类的加载,方法的前几次执行因为没有被JIT编译器给编译,字节码在解释模式下执行。根据本文上半部分的分析,解释执行速度约为JIT编译后的几十分之一,代码解释执行慢也启动慢的一大元凶。

传统的C/C++等语言都是直接编译到目标平台的native机器码。随着大家意识到Java、JS等解释器JIT语言的启动预热问题,通过AOT将字节码直接编译到native代码这种方式逐渐进入公众视野。

wasm、GraalVM、OpenJDK都不同程度地支持了AOT编译,我们主要围绕JEP295引入的jaotc工具优化启动速度。

注意这里的术语使用:
JEP295使用AOT是将class文件中的方法逐个编译到native代码片段,通过Java虚拟机在加载某个类后替换方法的的入口到AOT代码。
而GraalVM的的Native Image功能是更加彻底的静态编译,通过一个用Java代码编写的小型运行时SubstrateVM,该运行时和应用代码一起被静态编译到可执行的文件(类似Go),不再依赖JVM。该做法也是一种AOT,但是为了区分术语,这里的AOT单指JEP295的方式。

AOT特性初体验

通过JEP295的介绍,我们可以快速体验AOT

jaotc 命令会调用Graal编译器对字节码进行编译,产生 libHelloWorld.so 文件。这里产生的so文件容易让人误以为会直接像JNI一样调用进编译好的库代码。但是这里并没有完全使用ld的加载机制来运行代码,so文件更像是当做一个 native 代码的容器。hotsopt runtime 在加载 AOT so 后需要进行进一步的动态链接。在类加载后hotspot 会自动关联 AOT 代码入口,对于下次方法调用使用 AOT 版本。而 AOT 生成的代码也会主动与 hotspot 运行时交互,在aot、解释器、JIT 代码间相互跳转。

1)AOT 的一波三折

看起来JEP295已经实现了一套完备的AOT体系,但是为何不见这项技术被大规模使用?在 OpenJDK 的各项新特性中,AOT 算得上是命途多舛。

2)多 Classloader 问题

JDK-8206963: bug with multiple class loaders

这是在设计上没有考虑到Java的多 Classloader 场景,当多个 Classloader 加载的同名类都使用了 AOT 后,他们的 static field 是共享的,而根据 Java 语言的设计,这部分数据应该是隔开的。

由于没有可以快速修复这个问题的方案,OpenJDK 仅仅是添加了如下代码:

ClassLoaderData* cld = ik->class_loader_data();
if (!cld->is_builtin_class_loader_data()) {
log_trace(aot, class, load)("skip class %s for custom classloader %s (%p) tid=" INTPTR_FORMAT,
ik->internal_name(), cld->loader_name(), cld, p2i(thread));
return false;
}

对于用户自定义类加载器不允许使用 AOT。从这里已经可以初步看出该特性在社区层面已经逐渐缺乏维护。

在这种情况下,虽然通过 class-path 指定的类依然可以使用 AOT,但是我们常用的 spring-boot、Tomcat 等框架都需要通过 Custom Classloader 加载应用代码。可以说这一改变切掉了 AOT 的一大块场景。

3)缺乏调优和维护,退回成实验特性

JDK-8227439: Turn off AOT by default

JEP 295 AOT is still experimental, and while it can be useful for startup/warmup when used with custom generated archives tailored for the application, experimental data suggests that generating shared libraries at a module level has overall negative impact to startup, dubious efficacy for warmup and severe static footprint implications.

从此打开 AOT 需要添加 experimental 参数:

java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=...

根据 issue 的描述,这项特性编译整个模块的情况下,对启动速度和内存占用都起到了反作用。我们分析的原因如下:

  • Java 语言本身过分复杂,动态类加载等运行时机制导致 AOT 代码没法运行得像预期一样快
  • AOT 技术作为阶段性的项目在进入 Java 9 之后并没有被长期维护,缺乏必要的调优(反观AppCDS一直在迭代优化)

4)JDK16 中被删除

JDK-8255616:Disable AOT and Graal in Oracle OpenJDK

在 OpenJDK16 发布前夕,Oracle正式决定不再维护这项技术:

We haven't seen much use of these features, and the effort required to support and enhance them is significant.

其根本原因还是这项基于缺乏必要的优化和维护。而对于 AOT 相关的未来的规划,只能从只言片语中推测将来Java的AOT 有两种技术方向:

在 OpenJDK 的 C2 基础上做 AOT

  • 在 GraalVM 的 native-image 上支持完整的 Java 语言特性,需要 AOT 的用户逐渐从 OpenJDK 过渡到native-image
  • 上述的两个技术方向都没法在短期内看到进展,因此 Dragonwell 的技术方向是让现有的 JEP295 更好地工作,为用户带来极致的启动性能。

5)Dragonwell 上的快速启动

Dragonwell 的快速启动特性攻关了 AppCDS、AOT 编译技术上的弱点,并基于 HeapArchive 机制研发了类提前初始化特性。这些特性将 JVM 可见的应用启动耗时几乎全部消除。

此外,因为上述几项技术都符合 trace-dump-replay 的使用模式,Dragonwell 将上述启动加速技术统一了流程,并且集成到了 SAE 产品中。

四 SAE x Dragonwell : Serverless with Java 启动加速最佳实践

有了好的食材,还需要相匹配的佐料,以及一位烹饪大师。

将 Dragonwell 的启动加速技术和和以弹性著称的 Serverless 技术相结合更相得益彰,同时共同落地在微服务应用的全生命周期管理中,才能发挥他们缩短应用端到端启动时间的作用,因此 Dragonwell 选择了 SAE 来落地其启动加速技术。

SAE (Serverless 应用引擎)是首款面向 Serverless 的 PaaS 平台,他可以:

  • Java 软件包部署:零代码改造享受微服务能力,降低研发成本
  • Serverless 极致弹性:资源免运维,快速扩容应用实例, 降低运维与学习成本

1 难点分析

通过分析,我们发现微服务的用户在应用启动层面面临着一些难题:

  • 软件包大:几百 MB 甚至 GB 级别
  • 依赖包多:上百个依赖包,几千个 Class
  • 加载耗时:从磁盘加载依赖包,再到 Class 按需加载,最高可占启动耗时的一半

借助 Dragonwell 快速启动能力,SAE 为 Serverless Java 应用提供了一套,让应用尽可能加速启动的最佳实践,让开发者更专注于业务开发:

  • Java 环境 + JAR/WAR 软件包部署:集成 Dragonwell 11 ,提供加速启动环境
  • JVM 快捷设置:支持一键开启快速启动,简化操作
  • NAS 网盘:支持跨实例加速,在新包部署时,加速新启动实例/分批发布启动速度

2 加速效果

我们选择一些微服务、复杂依赖的业务场景典型 Demo 或内部应用,测试启动效果,发现应用普遍能降低 5%~45% 的启动耗时。若应用启动,存在下列场景,会有明显加速效果:

  • 类加载多(spring-petclinic 启动加载约 12000+ classes)
  • 依赖外部数据越少

3 客户案例

阿里巴巴搜索推荐 Serverless 平台

阿里内部的搜索推荐 Serverless 平台通过类加载隔离机制,将多个业务的合并部署在同一个 Java 虚拟机中。调度系统会按需地将业务代码合并部署到空闲的容器中,让多个业务可以共享同一个资源池,大大提高部署密度和整体的 CPU 使用率。

由于要支撑大量不同的业务研发运行,平台本身需要提供足够丰富的功能,如缓存、RPC调用。因此搜索推荐Serverless 平台的每个 JVM 都需要拉起类似 Pandora Boot 的中间件隔离容器,这将加载大量的类,拖累了平台自身的启动速度。当突增的需求进入,调度系统需要拉起更多容器以供业务代码部署,此时容器本身的启动时间就显得尤为重要。

基于 Dragonwell 的快速启动技术,搜索推荐平台在预发布环境会执行 AppCDS、Jarindex 等优化,将产生的 archive 文件打入容器镜像中,这样每一个容器在启动时都能享受加速,减少约30%的启动耗时。

潮牌秒杀SAE极致弹性

某外部客户,借助 SAE 提供的 Jar 包部署与 Dragonwell 11,快速迭代上线了某潮牌商场 App。

在面对大促秒杀时,借助 SAE Serverless 极致弹性,与应用指标 QPS RT 指标弹性能力,轻松面对 10 倍以上快速扩容需求;同时一键开启 Dragonwell 增强的 AppCDS 启动加速能力,降低 Java 应用 20% 以上启动耗时,进一步加速应用启动,保证业务平稳健康运行。

五 总结

Dragonwell 上的快速启动技术方向上完全基于 OpenJDK 社区的工作,对各项功能进行了细致的优化与 bugfix,并降低了上手的难度。这样做既保证了对标准的兼容,避免内部定制,也能够为开源社区做出贡献。

作为基础软件,Dragonwell 只能生成/使用磁盘上的 archive 文件。结合 SAE 对 Dragonwell 的无缝集成,JVM 配置、archive 文件的分发都被自动化。客户可以轻松享受应用加速带来的技术红利。

作者:
梁希,来自阿里云 Java 虚拟机团队,负责 Java Runtime 方向。主导了 Java 协程、启动优化等技术的研发和大规模落地。
代序,来自阿里云 SAE 团队,负责 Runtime 演进、 弹性能力与效率方向。主导应用弹性、Java 加速、镜像加速等技术的研发落地。

原文链接
本文为阿里云原创内容,未经允许不得转载。

探究 Java 应用的启动速度优化的更多相关文章

  1. 爱奇艺技术分享:爱奇艺Android客户端启动速度优化实践总结

    本文由爱奇艺技术团队原创分享,原题<爱奇艺Android客户端启动优化与分析>. 1.引言 互联网领域里有个八秒定律,如果网页打开时间超过8秒,便会有超过70%的用户放弃等待,对Andro ...

  2. Java Web 前端高性能优化(二)

    一.上文回顾 上回我们主要从图片的合并.压缩等方面介绍前端性能优化问题(详见Java Web 前端高性能优化(一)) 本次我们主要从图像BASE64 编码.GZIP压缩.懒加载与预加载以及 OneAP ...

  3. 初步探究java中程序退出、GC垃圾回收时,socket tcp连接的行为

    初步探究java中程序退出.GC垃圾回收时,socket tcp连接的行为 今天在项目开发中需要用到socket tcp连接相关(作为tcp客户端),在思考中发觉需要理清socket主动.被动关闭时发 ...

  4. Android性能优化之启动速度优化

    Android性能优化之启动速度优化   Android app 启动速度优化,首先谈谈为什么会走到优化这一步,如果一开始创建 app 项目的时候就把这个启动速度考虑进去,那么肯定就不需要重新再来优化 ...

  5. iOS启动速度优化

    背景 7月26号我们阿里数据iOS端发布了4.4.0版本,这次版本主要是优化了性能,其中main()阶段的启动耗时优化成果比较明显,从之前的0.5-0.7秒,降低为目前的0.1-0.2秒(main() ...

  6. JVM:从实际案例聊聊Java应用的GC优化

    原文转载自美团从实际案例聊聊Java应用的GC优化,感谢原作者的贡献 当Java程序性能达不到既定目标,且其他优化手段都已经穷尽时,通常需要调整垃圾回收器来进一步提高性能,称为GC优化.但GC算法复杂 ...

  7. Java多线程--锁的优化

    Java多线程--锁的优化 提高锁的性能 减少锁的持有时间 一个线程如果持有锁太长时间,其他线程就必须等待相应的时间,如果有多个线程都在等待该资源,整体性能必然下降.所有有必要减少单个线程持有锁的时间 ...

  8. Java 进阶7 并发优化 5 并发控制板方法

    Java 进阶7 并发优化 5 并发控制板方法 20131114 前言:          Java 中多线程并发程序中存在线程安全的问题,之前学习 Java的同步机制,掌握的同步方法只有一种就是使用 ...

  9. Java进阶7并发优化4——JDK并发数据结构

    Java进阶7并发优化4——JDK并发数据结构20131114 由于并发程序和串行程序的不同特点,在串行程序中使用的数据结构可能无法在并行程序中直接的正常使用,因为这些数据结构可能不是线程安全的,所以 ...

  10. Java 进阶7 并行优化 JDK多任务执行框架技术

    Java 进阶7 并行优化 JDK多任务执行框架技术 20131114          Java 语言本身就是支持多线程机制的,他提供了 Thread 类 Runnable 接口等简单的多线程支持工 ...

随机推荐

  1. 【leetcode 1799 N次操作后的最大分数和】状态压缩

    回溯会超时,状态压缩 class Solution { public int maxScore(int[] nums) { int len = nums.length; int size = 1 &l ...

  2. 记录-记一次不规范使用key引发的惨案

    这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助 前言 平时在使用v-for的时候,一般会要求传入key,有没有像我一样的小伙伴,为了省心,直接传索引index,貌似也没有遇到过什么问题, ...

  3. selenium 自动化常用操作

    from selenium import webdriver from selenium.webdriver.common.keys import Keys import random import ...

  4. 【教程】深入探究 JS代码混淆与加密技术

    引言 在网络世界中,保护代码安全是至关重要的一环.JS代码混淆与加密技术则成为了开发者们常用的手段之一.本文将深入探讨混淆和加密的概念,以及其实现原理和应用方法,帮助读者更好地了解并运用这些技术. 概 ...

  5. KingbaseES V8R6运维案例之---wal日志解析DDL操作

    ​ 案例说明: 通过sys_waldump解析DDL操作,获取DDL操作的日志条目具体内容. 适用版本: KingbaseES V8R3/R6 一.DDL事务操作对应的wal日志文件 # 查看当前on ...

  6. FineReport 常用日期公式

    获取当前月份最后一天 DATEINMONTH(today(), -1) 获取当前月份第一天 DATEINMONTH(today(), 1) 获取上个月最后一天 DATEINMONTH(MONTHDEL ...

  7. io_utils/time_utils

    io_utils.h #pragma once #include<stdio.h> #include<stdarg.h> void PrintBinary(unsigned i ...

  8. 区别对比 Python、Perl、VB、Ruby、C/C++、C# .Net、PHP、Java… 等多编程语言

    1. 编程语言类型:首先,Python.C/C++.Java 都是强类型的编程语言.强类型的编程语言定义如下: 强类型的编程语言是一种强制类型定义的编程语言,即一旦某一个变量被定义类型,如果不经强制转 ...

  9. SpringSecurity认证和授权流程详解

    什么是SpringSecurity Spring Security是一个Java框架,用于保护应用程序的安全性.它提供了一套全面的安全解决方案,包括身份验证.授权.防止攻击等功能.Spring Sec ...

  10. dev DEV控件:gridControl常用属性设置

    引用:https://www.cnblogs.com/kingsliu/articles/6145679.html 1.隐藏最上面的GroupPanelgridView1.OptionsView.Sh ...