first commit

This commit is contained in:
张乾
2024-10-16 09:22:22 +08:00
parent 206fad82a2
commit bf199f7d5e
538 changed files with 97223 additions and 2 deletions

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 JNI的运行机制
我们经常会遇见 Java 语言较难表达,甚至是无法表达的应用场景。比如我们希望使用汇编语言(如 X86_64 的 SIMD 指令)来提升关键代码的性能;再比如,我们希望调用 Java 核心类库无法提供的,某个体系架构或者操作系统特有的功能。
在这种情况下,我们往往会牺牲可移植性,在 Java 代码中调用 C/C++ 代码(下面简述为 C 代码),并在其中实现所需功能。这种跨语言的调用,便需要借助 Java 虚拟机的 Java Native InterfaceJNI机制。
关于 JNI 的例子,你应该特别熟悉 Java 中标记为native的、没有方法体的方法下面统称为 native 方法)。当在 Java 代码中调用这些 native 方法时Java 虚拟机将通过 JNI调用至对应的 C 函数(下面将 native 方法对应的 C 实现统称为 C 函数)中。
public class Object {
public native int hashCode();
}
举个例子Object.hashCode方法便是一个 native 方法。它对应的 C 函数将计算对象的哈希值,并缓存在对象头、栈上锁记录(轻型锁)或对象监视锁(重型锁所使用的 monitor以确保该值在对象的生命周期之内不会变更。
native 方法的链接
在调用 native 方法前Java 虚拟机需要将该 native 方法链接至对应的 C 函数上。
链接方式主要有两种。第一种是让 Java 虚拟机自动查找符合默认命名规范的 C 函数,并且链接起来。
事实上我们并不需要记住所谓的命名规范而是采用javac -h命令便可以根据 Java 程序中的 native 方法声明,自动生成包含符合命名规范的 C 函数的头文件。
举个例子在下面这段代码中Foo类有三个 native 方法分别为静态方法foo以及两个重载的实例方法bar。
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
}
通过执行javac -h . org/example/Foo.java命令我们将在当前文件夹对应-h后面跟着的.生成名为org_example_Foo.h的头文件。其内容如下所示
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Foo */
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: org_example_Foo
* Method: foo
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (IJ)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (Ljava/lang/String;Ljava/lang/Object;)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *, jobject, jstring, jobject);
#ifdef __cplusplus
}
#endif
#endif
这里我简单讲解一下该命名规范。
首先native 方法对应的 C 函数都需要以Java_为前缀之后跟着完整的包名和方法名。由于 C 函数名不支持/字符,因此我们需要将/转换为_而原本方法名中的_符号则需要转换为_1。
举个例子org.example包下Foo类的foo方法Java 虚拟机会将其自动链接至名为Java_org_example_Foo_foo的 C 函数中。
当某个类出现重载的 native 方法时Java 虚拟机还会将参数类型纳入自动链接对象的考虑范围之中。具体的做法便是在前面 C 函数名的基础上追加__以及方法描述符作为后缀。
方法描述符的特殊符号同样会被替换掉,如引用类型所使用的;会被替换为_2数组类型所使用的[会被替换为_3。
基于此命名规范你可以手动拼凑上述代码中Foo类的两个bar方法所能自动链接的 C 函数名并用javac -h命令所生成的结果来验证一下。
第二种链接方式则是在 C 代码中主动链接。
这种链接方式对 C 函数名没有要求。通常我们会使用一个名为registerNatives的 native 方法,并按照第一种链接方式定义所能自动链接的 C 函数。在该 C 函数中,我们将手动链接该类的其他 native 方法。
举个例子Object类便拥有一个registerNatives方法所对应的 C 代码如下所示:
// 注Object 类的 registerNatives 方法的实现位于 java.base 模块里的 C 代码中
static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls,
methods, sizeof(methods)/sizeof(methods[0]));
}
我们可以看到,上面这段代码中的 C 函数将调用RegisterNatives API注册Object类中其他 native 方法所要链接的 C 函数。并且,这些 C 函数的名字并不符合默认命名规则。
当使用第二种方式进行链接时,我们需要在其他 native 方法被调用之前完成链接工作。因此我们往往会在类的初始化方法里调用该registerNatives方法。具体示例如下所示
public class Object {
private static native void registerNatives();
static {
registerNatives();
}
}
下面我们采用第一种链接方式并且实现其中的bar(String, Object)方法。如下所示:
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
printf("Hello, World\n");
return;
}
然后,我们可以通过 gcc 命令将其编译成为动态链接库:
# 该命令仅适用于 macOS
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c
这里需要注意的是动态链接库的名字须以lib为前缀以.dylib(或 Linux 上的.so为扩展名。在 Java 程序中我们可以通过System.loadLibrary("foo")方法来加载libfoo.dylib如下述代码所示
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
int i = 0xDEADBEEF;
public static void main(String[] args) {
try {
System.loadLibrary("foo");
} catch (UnsatisfiedLinkError e) {
e.printStackTrace();
System.exit(1);
}
new Foo().bar("", "");
}
}
如果libfoo.dylib不在当前路径下我们可以在启动 Java 虚拟机时配置java.library.path参数使其指向包含libfoo.dylib的文件夹。具体命令如下所示
$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo
Hello, World
JNI 的 API
在 C 代码中,我们也可以使用 Java 的语言特性,如 instanceof 测试等。这些功能都是通过特殊的 JNI 函数JNI Functions来实现的。
Java 虚拟机会将所有 JNI 函数的函数指针聚合到一个名为JNIEnv的数据结构之中。
这是一个线程私有的数据结构。Java 虚拟机会为每个线程创建一个JNIEnv并规定 C 代码不能将当前线程的JNIEnv共享给其他线程否则 JNI 函数的正确性将无法保证。
这么设计的原因主要有两个。一是给 JNI 函数提供一个单独命名空间。二是允许 Java 虚拟机通过更改函数指针替换 JNI 函数的具体实现,例如从附带参数类型检测的慢速版本,切换至不做参数类型检测的快速版本。
在 HotSpot 虚拟机中JNIEnv被内嵌至 Java 线程的数据结构之中。部分虚拟机代码甚至会从JNIEnv的地址倒推出 Java 线程的地址。因此如果在其他线程中使用当前线程的JNIEnv会使这部分代码错误识别当前线程。
JNI 会将 Java 层面的基本类型以及引用类型映射为另一套可供 C 代码使用的数据结构。其中,基本类型的对应关系如下表所示:
引用类型对应的数据结构之间也存在着继承关系,具体如下所示:
jobject
|- jclass (java.lang.Class objects)
|- jstring (java.lang.String objects)
|- jthrowable (java.lang.Throwable objects)
|- jarray (arrays)
|- jobjectArray (object arrays)
|- jbooleanArray (boolean arrays)
|- jbyteArray (byte arrays)
|- jcharArray (char arrays)
|- jshortArray (short arrays)
|- jintArray (int arrays)
|- jlongArray (long arrays)
|- jfloatArray (float arrays)
|- jdoubleArray (double arrays)
我们回头看看Foo类 3 个 native 方法对应的 C 函数的参数。
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2 (JNIEnv *, jobject, jstring, jobject);
静态 native 方法foo将接收两个参数分别为存放 JNI 函数的JNIEnv指针以及一个jclass参数用来指代定义该 native 方法的类即Foo类。
两个实例 native 方法bar的第二个参数则是jobject类型的用来指代该 native 方法的调用者也就是Foo类的实例。
如果 native 方法声明了参数,那么对应的 C 函数将接收这些参数。在我们的例子中第一个bar方法声明了 int 型和 long 型的参数,对应的 C 函数则接收 jint 和 jlong 类型的参数第二个bar方法声明了 String 类型和 Object 类型的参数,对应的 C 函数则接收 jstring 和 jobject 类型的参数。
下面我们继续修改上一小节中的foo.c并在 C 代码中获取Foo类实例的i字段。
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
printf("Hello, World 0x%x\n", value);
return;
}
我们可以看到,在 JNI 中访问字段类似于反射 API我们首先需要通过类实例获得FieldID然后再通过FieldID获得某个实例中该字段的值。不过与 Java 代码相比,上述代码貌似不用处理异常。事实果真如此吗?
下面我就尝试获取了不存在的字段j运行结果如下所示
$ java org.example.Foo
Hello, World 0x5
Exception in thread "main" java.lang.NoSuchFieldError: j
at org.example.Foo.bar(Native Method)
at org.example.Foo.main(Foo.java:20)
我们可以看到printf语句照常执行并打印出Hello, World 0x5但这个数值明显是错误的。当从 C 函数返回至 main 方法时Java 虚拟机又会抛出NoSuchFieldError异常。
实际上,当调用 JNI 函数时Java 虚拟机便已生成异常实例,并缓存在内存中的某个位置。与 Java 编程不一样的是,它并不会显式地跳转至异常处理器或者调用者中,而是继续执行接下来的 C 代码。
因此,当从可能触发异常的 JNI 函数返回时,我们需要通过 JNI 函数ExceptionOccurred检查是否发生了异常并且作出相应的处理。如果无须抛出该异常那么我们需要通过 JNI 函数ExceptionClear显式地清空已缓存的异常。
具体示例如下所示为了控制代码篇幅我仅在第一个GetFieldID后检查异常以及清空异常
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
if((*env)->ExceptionOccurred(env)) {
printf("Exception!\n");
(*env)->ExceptionClear(env);
}
fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
// we should put an exception guard here as well.
printf("Hello, World 0x%x\n", value);
return;
}
局部引用与全局引用
在 C 代码中,我们可以访问所传入的引用类型参数,也可以通过 JNI 函数创建新的 Java 对象。
这些 Java 对象显然也会受到垃圾回收器的影响。因此Java 虚拟机需要一种机制,来告知垃圾回收算法,不要回收这些 C 代码中可能引用到的 Java 对象。
这种机制便是 JNI 的局部引用Local Reference和全局引用Global Reference。垃圾回收算法会将被这两种引用指向的对象标记为不可回收。
事实上,无论是传入的引用类型参数,还是通过 JNI 函数除NewGlobalRef及NewWeakGlobalRef之外返回的引用类型对象都属于局部引用。
不过,一旦从 C 函数中返回至 Java 方法之中,那么局部引用将失效。也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用。
这就意味着,我们不能缓存局部引用,以供另一 C 线程或下一次 native 方法调用时使用。
对于这种应用场景,我们需要借助 JNI 函数NewGlobalRef将该局部引用转换为全局引用以确保其指向的 Java 对象不会被垃圾回收。
相应的,我们还可以通过 JNI 函数DeleteGlobalRef来消除全局引用以便回收被全局引用指向的 Java 对象。
此外,当 C 函数运行时间极其长时,我们也应该考虑通过 JNI 函数DeleteLocalRef消除不再使用的局部引用以便回收被引用的 Java 对象。
另一方面,由于垃圾回收器可能会移动对象在内存中的位置,因此 Java 虚拟机需要另一种机制,来保证局部引用或者全局引用将正确地指向移动过后的对象。
HotSpot 虚拟机是通过句柄handle来完成上述需求的。这里句柄指的是内存中 Java 对象的指针的指针。当发生垃圾回收时,如果 Java 对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。
实际上,无论是局部引用还是全局引用,都是句柄。其中,局部引用所对应的句柄有两种存储方式,一是在本地方法栈帧中,主要用于存放 C 函数所接收的来自 Java 层面的引用类型参数;另一种则是线程私有的句柄块,主要用于存放 C 函数运行过程中创建的局部引用。
当从 C 函数返回至 Java 方法时,本地方法栈帧中的句柄将会被自动清除。而线程私有句柄块则需要由 Java 虚拟机显式清理。
进入 C 函数时对引用类型参数的句柄化和调整参数位置C 调用和 Java 调用传参的方式不一样),以及从 C 函数返回时清理线程私有句柄块,共同造就了 JNI 调用的额外性能开销(具体可参考该 stackoverflow 上的回答)。
总结与实践
今天我介绍了 JNI 的运行机制。
Java 中的 native 方法的链接方式主要有两种。一是按照 JNI 的默认规范命名所要链接的 C 函数,并依赖于 Java 虚拟机自动链接。另一种则是在 C 代码中主动链接。
JNI 提供了一系列 API 来允许 C 代码使用 Java 语言特性。这些 API 不仅使用了特殊的数据结构来表示 Java 类,还拥有特殊的异常处理模式。
JNI 中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的 Java 对象。不同的是,局部引用在 native 方法调用返回之后便会失效。传入参数以及大部分 JNI API 函数的返回值都属于局部引用。
今天的实践环节,请阅读该文档中的 Performance pitfalls 以及 Correctness pitfalls 两节。

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 Java Agent与字节码注入
关于 Java agent大家可能都听过大名鼎鼎的premain方法。顾名思义这个方法指的就是在main方法之前执行的方法。
package org.example;
public class MyAgent {
public static void premain(String args) {
System.out.println("premain");
}
}
我在上面这段代码中定义了一个premain方法。这里需要注意的是Java 虚拟机所能识别的premain方法接收的是字符串类型的参数而并非类似于main方法的字符串数组。
为了能够以 Java agent 的方式运行该premain方法我们需要将其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中指定所谓的Premain-class。具体的命令如下所示
# 注意第一条命令会向 manifest.txt 文件写入两行数据,其中包括一行空行
$ echo 'Premain-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java -javaagent:myagent.jar HelloWorld
premain
Hello, World
除了在命令行中指定 Java agent 之外,我们还可以通过 Attach API 远程加载。具体用法如下面的代码所示:
import java.io.IOException;
import com.sun.tools.attach.*;
public class AttachTest {
public static void main(String[] args)
throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
if (args.length <= 1) {
System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
return;
}
VirtualMachine vm = VirtualMachine.attach(args[0]);
vm.loadAgent(args[1]);
}
}
使用 Attach API 远程加载的 Java agent 不会再先于main方法执行这取决于另一虚拟机调用 Attach API 的时机。并且它运行的也不再是premain方法而是名为agentmain的方法。
public class MyAgent {
public static void agentmain(String args) {
System.out.println("agentmain");
}
}
相应的,我们需要更新 jar 包中的 manifest 文件使其包含Agent-Class的配置例如Agent-Class: org.example.MyAgent。
$ echo 'Agent-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java HelloWorld
Hello, World
$ jps
$ java AttachTest <pid> myagent.jar
agentmain
// 最后一句输出来自于运行 HelloWorld 的 Java 进程
Java 虚拟机并不限制 Java agent 的数量。你可以在 java 命令后附上多个-javaagent参数或者远程 attach 多个 Java agentJava 虚拟机会按照定义顺序,或者 attach 的顺序逐个执行这些 Java agent。
在premain方法或者agentmain方法中打印一些字符串并不出奇我们完全可以将其中的逻辑并入main方法或者其他监听端口的线程中。除此之外Java agent 还提供了一套 instrumentation 机制,允许应用程序拦截类加载事件,并且更改该类的字节码。
接下来,我们来了解一下基于这一机制的字节码注入。
字节码注入
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
classfileBuffer[2], classfileBuffer[3]);
return null;
}
}
}
我们先来看一个例子。在上面这段代码中premain方法多出了一个Instrumentation类型的参数我们可以通过它来注册类加载事件的拦截器。该拦截器需要实现ClassFileTransformer接口并重写其中的transform方法。
transform方法将接收一个 byte 数组类型的参数,它代表的是正在被加载的类的字节码。在上面这段代码中,我将打印该数组的前四个字节,也就是 Java class 文件的魔数magic number0xCAFEBABE。
transform方法将返回一个 byte 数组代表更新过后的类的字节码。当方法返回之后Java 虚拟机会使用所返回的 byte 数组来完成接下来的类加载工作。不过如果transform方法返回 null 或者抛出异常,那么 Java 虚拟机将使用原来的 byte 数组完成类加载工作。
基于这一类加载事件的拦截功能我们可以实现字节码注入bytecode instrumentation往正在被加载的类中插入额外的字节码。
在工具篇中我曾经介绍过字节码工程框架 ASM 的用法。下面我将演示它的tree 包(依赖于基础包),用面向对象的方式注入字节码。
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
if ("main".equals(methodNode.name)) {
InsnList instrumentation = new InsnList();
instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
instrumentation
.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
methodNode.instructions.insert(instrumentation);
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
上面这段代码不难理解。我们将使用ClassReader读取所传入的 byte 数组并将其转换成ClassNode。然后我们将遍历ClassNode中的MethodNode节点也就是该类中的构造器和方法。
当遇到名字为"main"的方法时我们会在方法的入口处注入System.out.println("Hello, Instrumentation!");。运行结果如下所示:
$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
Hello, Instrumentation!
Hello, World!
Java agent 还提供了另外两个功能redefine和retransform。这两个功能针对的是已加载的类并要求用户传入所要redefine或者retransform的类实例。
其中redefine指的是舍弃原本的字节码并替换成由用户提供的 byte 数组。该功能比较危险,一般用于修复出错了的字节码。
retransform则将针对所传入的类重新调用所有已注册的ClassFileTransformer的transform方法。它的应用场景主要有如下两个。
第一在执行premain或者agentmain方法前Java 虚拟机早已加载了不少类而这些类的加载事件并没有被拦截因此也没有被注入。使用retransform功能可以注入这些已加载但未注入的类。
第二,在定义了多个 Java agent多个注入的情况下我们可能需要移除其中的部分注入。当调用Instrumentation.removeTransformer去除某个注入类后我们可以调用retransform功能重新从原始 byte 数组开始进行注入。
Java agent 的这些功能都是通过 JVMTI agent也就是 C agent 来实现的。JVMTI 是一个事件驱动的工具实现接口,通常,我们会在 C agent 加载后的入口方法Agent_OnLoad处注册各个事件的钩子hook方法。当 Java 虚拟机触发了这些事件时,便会调用对应的钩子方法。
JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
举个例子,我们可以为 JVMTI 中的ClassFileLoadHook事件设置钩子从而在 C 层面拦截所有的类加载事件。关于 JVMTI 的其他事件,你可以参考该链接。
基于字节码注入的 profiler
我们可以利用字节码注入来实现代码覆盖工具例如JaCoCo或者各式各样的 profiler。
通常,我们会定义一个运行时类,并在某一程序行为的周围,注入对该运行时类中方法的调用,以表示该程序行为正要发生或者已经发生。
package org.example;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class MyProfiler {
public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();
public static void fireAllocationEvent(Class<?> klass) {
data.computeIfAbsent(klass, kls -> new AtomicInteger())
.incrementAndGet();
}
public static void dump() {
data.forEach((kls, counter) -> {
System.err.printf("%s: %d\n", kls.getName(), counter.get());
});
}
static {
Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
}
}
举个例子上面这段代码便是一个运行时类。该类维护了一个HashMap用来统计每个类所新建实例的数目。当程序退出时我们将逐个打印出每个类的名字以及其新建实例的数目。
在 Java agent 中我们会截获正在加载的类并且在每条new字节码之后插入对fireAllocationEvent方法的调用以表示当前正在新建某个类的实例。具体的注入代码如下所示
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.startsWith("java") ||
className.startsWith("javax") ||
className.startsWith("jdk") ||
className.startsWith("sun") ||
className.startsWith("com/sun") ||
className.startsWith("org/example")) {
// Skip JDK classes and profiler classes
return null;
}
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
for (AbstractInsnNode node : methodNode.instructions.toArray()) {
if (node.getOpcode() == NEW) {
TypeInsnNode typeInsnNode = (TypeInsnNode) node;
InsnList instrumentation = new InsnList();
instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
"(Ljava/lang/Class;)V", false));
methodNode.instructions.insert(node, instrumentation);
}
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
你或许已经留意到,我们不得不排除对 JDK 类以及该运行时类的注入。这是因为对这些类的注入很可能造成死循环调用并最终抛出StackOverflowException异常。
举个例子假设我们在PrintStream.println方法入口处注入System.out.println("blahblah")由于out是PrintStream的实例因此当执行注入代码时我们又会调用PrintStream.println方法从而造成死循环。
解决这一问题的关键在于设置一个线程私有的标识位,用以区分应用代码的上下文以及注入代码的上下文。当即将执行注入代码时,我们将根据标识位判断是否已经位于注入代码的上下文之中。如果不是,则设置标识位并正常执行注入代码;如果是,则直接返回,不再执行注入代码。
字节码注入的另一个技术难点则是命名空间。举个例子,不少应用程序都依赖于字节码工程库 ASM。当我们的注入逻辑依赖于 ASM 时,便有可能出现注入使用最新版本的 ASM而应用程序使用较低版本的 ASM 的问题。
JDK 本身也使用了 ASM 库,如用来生成 Lambda 表达式的适配器类。JDK 的做法是重命名整个 ASM 库为所有类的包名添加jdk.internal前缀。我们显然不好直接更改 ASM 的包名,因此需要借助自定义类加载器来隔离命名空间。
除了上述技术难点之外基于字节码注入的工具还有另一个问题那便是观察者效应observer effect对所收集的数据造成的影响。
举个利用字节码注入收集每个方法的运行时间的例子。假设某个方法调用了另一个方法,而这两个方法都被注入了,那么统计被调用者运行时间的注入代码所耗费的时间,将不可避免地被计入至调用者方法的运行时间之中。
再举一个统计新建对象数目的例子。我们知道即时编译器中的逃逸分析可能会优化掉新建对象操作但它不会消除相应的统计操作比如上述例子中对fireAllocationEvent方法的调用。在这种情况下我们将统计没有实际发生的新建对象操作。
另一种情况则是我们所注入的对fireAllocationEvent方法的调用将影响到方法内联的决策。如果该新建对象的构造器调用恰好因此没有被内联从而造成对象逃逸。在这种情况下原本能够被逃逸分析优化掉的新建对象操作将无法优化我们也将统计到原本不会发生的新建对象操作。
总而言之,当使用字节码注入开发 profiler 时,需要辩证地看待所收集的数据。它仅能表示在被注入的情况下程序的执行状态,而非没有注入情况下的程序执行状态。
面向方面编程
说到字节码注入就不得不提面向方面编程Aspect-Oriented ProgrammingAOP。面向方面编程的核心理念是定义切入点pointcut以及通知advice。程序控制流中所有匹配该切入点的连接点joinpoint都将执行这段通知代码。
举个例子,我们定义一个指代所有方法入口的切入点,并指定在该切入点执行的“打印该方法的名字”这一通知。那么每个具体的方法入口便是一个连接点。
面向方面编程的其中一种实现方式便是字节码注入比如AspectJ。
在前面的例子中我们也相当于使用了面向方面编程在所有的new字节码之后执行了下面这样一段通知代码。
`MyProfiler.fireAllocationEvent(<Target>.class)`
我曾经参与开发过一个应用了面向方面编程思想的字节码注入框架DiSL。它支持用注解来定义切入点用普通 Java 方法来定义通知。例如,在方法入口处打印所在的方法名,可以简单表示为如下代码:
@Before(marker = BodyMarker.class)
static void onMethodEntry(MethodStaticContext msc) {
System.out.println(msc.thisMethodFullName());
}
如果有同学对这个工具感兴趣,或者有什么需求或者建议,欢迎你在留言中提出。
总结与实践
今天我介绍了 Java agent 以及字节码注入。
我们可以通过 Java agent 的类加载拦截功能,修改某个类所对应的 byte 数组,并利用这个修改过后的 byte 数组完成接下来的类加载。
基于字节码注入的 profiler可以统计程序运行过程中某些行为的出现次数。如果需要收集 Java 核心类库的数据,那么我们需要小心避免无限递归调用。另外,我们还需通过自定义类加载器来解决命名空间的问题。
由于字节码注入会产生观察者效应,因此基于该技术的 profiler 所收集到的数据并不能反映程序的真实运行状态。它所反映的是程序在被注入的情况下的执行状态。
今天的实践环节,请你思考如何注入方法出口。除了正常执行路径之外,你还需考虑异常执行路径。

View File

@ -0,0 +1,110 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 Graal用Java编译Java
最后这三篇文章,我将介绍 Oracle Labs 的 GraalVM 项目。
GraalVM 是一个高性能的、支持多种编程语言的执行环境。它既可以在传统的 OpenJDK 上运行,也可以通过 AOTAhead-Of-Time编译成可执行文件单独运行甚至可以集成至数据库中运行。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
今天这一篇,我们就来讲讲 GraalVM 的基石 Graal 编译器。
在之前的篇章中,特别是介绍即时编译技术的第二部分,我们反反复复提到了 Graal 编译器。这是一个用 Java 写就的即时编译器,它从 Java 9u 开始便被集成自 JDK 中,作为实验性质的即时编译器。
Graal 编译器可以通过 Java 虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时它将替换掉 HotSpot 中的 C2 编译器,并响应原本由 C2 负责的编译请求。
在今天的文章中,我将详细跟你介绍一下 Graal 与 Java 虚拟机的交互、Graal 和 C2 的区别以及 Graal 的实现细节。
Graal 和 Java 虚拟机的交互
我们知道,即时编译器是 Java 虚拟机中相对独立的模块,它主要负责接收 Java 字节码,并生成可以直接运行的二进制码。
具体来说,即时编译器与 Java 虚拟机的交互可以分为如下三个方面。
响应编译请求;
获取编译所需的元数据(如类、方法、字段)和反映程序执行状态的 profile
将生成的二进制码部署至代码缓存code cache里。
即时编译器通过这三个功能组成了一个响应编译请求、获取编译所需的数据,完成编译并部署的完整编译周期。
传统情况下,即时编译器是与 Java 虚拟机紧耦合的。也就是说,对即时编译器的更改需要重新编译整个 Java 虚拟机。这对于开发相对活跃的 Graal 来说显然是不可接受的。
为了让 Java 虚拟机与 Graal 解耦合我们引入了Java 虚拟机编译器接口JVM Compiler InterfaceJVMCI将上述三个功能抽象成一个 Java 层面的接口。这样一来,在 Graal 所依赖的 JVMCI 版本不变的情况下,我们仅需要替换 Graal 编译器相关的 jar 包Java 9 以后的 jmod 文件),便可完成对 Graal 的升级。
JVMCI 的作用并不局限于完成由 Java 虚拟机发出的编译请求。实际上Java 程序可以直接调用 Graal编译并部署指定方法。
Graal 的单元测试便是基于这项技术。为了测试某项优化是否起作用,原本我们需要反复运行某一测试方法,直至 Graal 收到由 Java 虚拟机发出针对该方法的编译请求,而现在我们可以直接指定编译该方法,并进行测试。我们下一篇将介绍的 Truffle 语言实现框架,同样也是基于这项技术的。
Graal 和 C2 的区别
Graal 和 C2 最为明显的一个区别是Graal 是用 Java 写的,而 C2 是用 C++ 写的。相对来说Graal 更加模块化,也更容易开发与维护,毕竟,连 C2 的作者 Cliff Click 大神都不想重蹈用 C++ 开发 Java 虚拟机的覆辙。
许多开发者会觉得用 C++ 写的 C2 肯定要比 Graal 快。实际上在充分预热的情况下Java 程序中的热点代码早已经通过即时编译转换为二进制码,在执行速度上并不亚于静态编译的 C++ 程序。
再者,即便是解释执行 Graal也仅是会减慢编译效率而并不影响编译结果的性能。
换句话说,如果 C2 和 Graal 采用相同的优化手段,那么它们的编译结果是一样的。所以,程序达到稳定状态(即不再触发新的即时编译)的性能,也就是峰值性能,将也是一样的。
由于 Java 语言容易开发维护的优势,我们可以很方便地将 C2 的新优化移植到 Graal 中。反之则不然,比如,在 Graal 中被证实有效的部分逃逸分析partial escape analysis至今未被移植到 C2 中。
Graal 和 C2 另一个优化上的分歧则是方法内联算法。相对来说Graal 的内联算法对新语法、新语言更加友好,例如 Java 8 的 lambda 表达式以及 Scala 语言。
我们曾统计过数十个 Java 或 Scala 程序的峰值性能。总体而言Graal 编译结果的性能要优于 C2。对于 Java 程序来说Graal 的优势并不明显;对于 Scala 程序来说Graal 的性能优势达到了 10%。
大规模使用 Scala 的 Twitter 便在他们的生产环境中部署了 Graal 编译器,并取得了 11% 的性能提升。Slides, Video该数据基于 GraalVM 社区版。)
Graal 的实现
Graal 编译器将编译过程分为前端和后端两大部分。前端用于实现平台无关的优化(如方法内联),以及小部分平台相关的优化;而后端则负责大部分的平台相关优化(如寄存器分配),以及机器码的生成。
在介绍即时编译技术时我曾提到过Graal 和 C2 都采用了 Sea-of-Nodes IR。严格来说这里指的是 Graal 的前端,而后端采用的是另一种非 Sea-of-Nodes 的 IR。通常我们将前端的 IR 称之为 High-level IR或者 HIR后端的 IR 则称之为 Low-level IR或者 LIR。
Graal 的前端是由一个个单独的优化阶段optimization phase构成的。我们可以将每个优化阶段想象成一个图算法它会接收一个规则的图遍历图上的节点并做出优化并且返回另一个规则的图。前端中的编译阶段除了少数几个关键的之外其余均可以通过配置选项来开启或关闭。
Graal 编译器前端的优化阶段(局部)
感兴趣的同学可以阅读 Graal repo 里配置这些编译优化阶段的源文件
HighTier.javaMidTier.java以及LowTier.java。
我们知道Graal 和 C2 都采用了激进的投机性优化手段speculative optimization
通常这些优化都基于某种假设assumption。当假设出错的情况下Java 虚拟机会借助去优化deoptimization这项机制从执行即时编译器生成的机器码切换回解释执行在必要情况下它甚至会废弃这份机器码并在重新收集程序 profile 之后,再进行编译。
举个以前讲过的例子,类层次分析。在进行虚方法内联时(或者其他与类层次相关的优化),我们可能会发现某个接口仅有一个实现。
在即时编译过程中,我们可以假设在之后的执行过程中仍旧只有这一个实现,并根据这个假设进行编译优化。当之后加载了接口的另一实现时,我们便会废弃这份机器码。
Graal 与 C2 相比会更加激进。它从设计上便十分青睐这种基于假设的优化手段。在编译过程中Graal 支持自定义假设,并且直接与去优化节点相关联。
当对应的去优化被触发时Java 虚拟机将负责记录对应的自定义假设。而 Graal 在第二次编译同一方法时,便会知道该自定义假设有误,从而不再对该方法使用相同的激进优化。
Java 虚拟机的另一个能够大幅度提升性能的特性是 intrinsic 方法,我在之前的篇章中已经详细介绍过了。在 Graal 中,实现高性能的 intrinsic 方法也相对比较简单。Graal 提供了一种替换方法调用的机制,在解析 Java 字节码时会将匹配到的方法调用,替换成对另一个内部方法的调用,或者直接替换为特殊节点。
举例来说,我们可以把比较两个 byte 数组的方法java.util.Arrays.equals(byte[],byte[])替换成一个特殊节点,用来代表整个数组比较的逻辑。这样一来,当前编译方法所对应的图将被简化,因而其适用于其他优化的可能性也将提升。
总结与实践
Graal 是一个用 Java 写就的、并能够将 Java 字节码转换成二进制码的即时编译器。它通过 JVMCI 与 Java 虚拟机交互,响应由后者发出的编译请求、完成编译并部署编译结果。
对 Java 程序而言Graal 编译结果的性能略优于 OpenJDK 中的 C2对 Scala 程序而言,它的性能优势可达到 10%(企业版甚至可以达到 20%!)。这背后离不开 Graal 所采用的激进优化方式。
今天的实践环节,你可以尝试使用附带 Graal 编译器的 JDK。在 Java 1011 中,你可以通过添加虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler来启用或者下载我们部署在Oracle OTN上的基于 Java 8 的版本。
在刚开始运行的过程中Graal 编译器本身需要被即时编译,会抢占原本可用于编译应用代码的计算资源。因此,目前 Graal 编译器的启动性能会较差。最后一篇我会介绍解决方案。

View File

@ -0,0 +1,207 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 Truffle语言实现框架
今天我们来聊聊 GraalVM 中的语言实现框架 Truffle。
我们知道,实现一门新编程语言的传统做法是实现一个编译器,也就是把用该语言编写的程序转换成可直接在硬件上运行的机器码。
通常来说,编译器分为前端和后端:前端负责词法分析、语法分析、类型检查和中间代码生成,后端负责编译优化和目标代码生成。
不过,许多编译器教程只涉及了前端中的词法分析和语法分析,并没有真正生成可以运行的目标代码,更谈不上编译优化,因此在生产环境中并不实用。
另一种比较取巧的做法则是将新语言编译成某种已知语言,或者已知的中间形式,例如将 Scala、Kotlin 编译成 Java 字节码。
这样做的好处是可以直接享用 Java 虚拟机自带的各项优化,包括即时编译、自动内存管理等等。因此,这种做法对所生成的 Java 字节码的优化程度要求不高。
不过,不管是附带编译优化的编译器,还是生成中间形式并依赖于其他运行时的即时编译优化的编译器,它们所针对的都是编译型语言,在运行之前都需要这一额外的编译步骤。
与编译型语言相对应的则是解释型语言,例如 JavaScript、Ruby、Python 等。对于这些语言来说,它们无须额外的编译步骤,而是依赖于解释执行器进行解析并执行。
为了让该解释执行器能够高效地运行大型程序,语言实现开发人员通常会将其包装在虚拟机里,并实现诸如即时编译、垃圾回收等其他组件。这些组件对语言设计 本身并无太大贡献,仅仅是为了实用性而不得不进行的工程实现。
在理想情况下,我们希望在不同的语言实现中复用这些组件。也就是说,每当开发一门新语言时,我们只需要实现它的解释执行器,便能够直接复用即时编译、垃圾回收等组件,从而达到高性能的效果。这也是 Truffle 项目的目标。接下来,我们就来讲讲这个项目。
Truffle 项目简介
Truffle 是一个用 Java 写就的语言实现框架。基于 Truffle 的语言实现仅需用 Java 实现词法分析、语法分析以及针对语法分析所生成的抽象语法树Abstract Syntax TreeAST的解释执行器便可以享用由 Truffle 提供的各项运行时优化。
就一个完整的 Truffle 语言实现而言,由于实现本身以及其所依赖的 Truffle 框架部分都是用 Java 实现的,因此它可以运行在任何 Java 虚拟机之上。
当然,如果 Truffle 运行在附带了 Graal 编译器的 Java 虚拟机之上,那么它将调用 Graal 编译器所提供的 API主动触发对 Truffle 语言的即时编译,将对 AST 的解释执行转换为执行即时编译后的机器码。
在这种情况下Graal 编译器相当于一个提供了即时编译功能的库,宿主虚拟机本身仍可使用 C2 作为其唯一的即时编译器,或者分层编译模式下的 4 层编译器。
我们团队实现并且开源了多个 Truffle 语言例如JavaScriptRubyRPython以及可用来解释执行 LLVM bitcode 的Sulong。关于 Sulong 项目,任何能够编译为 LLVM bitcode 的编程语言,例如 C/C++,都能够在这上面运行。
下图展示了运行在 GraalVM EE 上的 Java 虚拟机语言,以及除 Python 外 Truffle 语言的峰值性能指标2017 年数据)。
这里我采用的基线是每个语言较有竞争力的语言实现。
对于 Java 虚拟机语言Java、Scala我比较的是使用 C2 的 HotSpot 虚拟机和使用 Graal 的 HotSpot 虚拟机。
对于 Ruby我比较的是运行在 HotSpot 虚拟机之上的 JRuby 和 Truffle Ruby。
对于 R我比较的是 GNU R 和基于 Truffle 的 FastR。
对于 C/C++,我比较的是利用 LLVM 编译器生成的二进制文件和基于 Truffle 的 Sulong。
对于 JavaScript我比较的是 Google 的 V8 和 Graal.js。
针对每种语言我们运行了上百个基准测试求出各个基准测试峰值性能的加速比并且汇总成图中所示的几何平均值Geo. mean
简单地说明一下,当 GraalVM 的加速比为 1 时,代表使用其他语言实现和使用 GraalVM 的性能相当。当 GraalVM 加速比超过 1 时,则代表 GraalVM 的性能较好;反之,则说明 GraalVM 的性能较差。
我们可以看到Java 跑在 Graal 上和跑在 C2 上的执行效率类似,而 Scala 跑在 Graal 上的执行效率则是跑在 C2 上的 1.2 倍。
对于 Ruby 或者 R 这类解释型语言,经由 Graal 编译器加速的 Truffle 语言解释器的性能十分优越,分别达到对应基线的 4.1x 和 4.5x。这里便可以看出使用专业即时编译器的 Truffle 框架的优势所在。
不过,对于同样拥有专业即时编译器的 V8 来说,基于 Truffle 的 Graal.js 仍处于追赶者的位置。考虑到我们团队中负责 Graal.js 的工程师仅有个位数,能够达到如此性能已属不易。现在 Graal.js 已经开源出来,我相信借助社区的贡献,它的性能能够得到进一步的提升。
Sulong 与传统的 C/C++ 相比,由于两者最终都将编译为机器码,因此原则上后者定义了前者的性能上限。
不过Sulong 将 C/C++ 代码放在托管环境中运行,所有代码中的内存访问都会在托管环境的监控之下。无论是会触发 Segfault 的异常访问,还是读取敏感数据的恶意访问,都能够被 Sulong 拦截下来并作出相应处理。
Partial Evaluation
如果要理解 Truffle 的原理,我们需要先了解 Partial Evaluation 这一个概念。
假设有一段程序P它将一系列输入I转换成输出O即P: I -> O。而这些输入又可以进一步划分为编译时已知的常量IS和编译时未知的ID。
那么我们可以将程序P: I -> O转换为等价的另一段程序P': ID -> O。这个新程序P'便是P的特化Specialization而从P转换到P'的这个过程便是所谓的 Partial Evaluation。
回到 Truffle 这边,我们可以将 Truffle 语言的解释执行器当成P将某段用 Truffle 语言写就的程序当作IS并通过 Partial Evaluation 特化为P'。由于 Truffle 语言的解释执行器是用 Java 写的,因此我们可以利用 Graal 编译器将P'编译为二进制码。
下面我将用一个具体例子来讲解。
假设有一门语言 X只支持读取整数参数和整数加法。这两种操作分别对应下面这段代码中的 AST 节点Arg和Add。
abstract class Node {
abstract int execute(int[] args);
}
class Arg extends Node {
final int index;
Arg(int i) { this.index = i; }
int execute(int[] args) {
return args[index];
}
}
class Add extends Node {
final Node left, right;
Add(Node left, Node right) {
this.left = left;
this.right = right;
}
int execute(int[] args) {
return left.execute(args) +
right.execute(args);
}
}
static int interpret(Node node, int[] args) {
return node.execute(args);
}
所谓 AST 节点的解释执行,便是调用这些 AST 节点的execute方法而一段程序的解释执行则是调用这段程序的 AST 根节点的execute方法。
我们可以看到Arg节点和Add节点均实现了execute方法接收一个用来指代程序输入的 int 数组参数并返回计算结果。其中Arg节点将返回 int 数组的第i个参数i是硬编码在程序之中的常量而Add节点将分别调用左右两个节点的execute方法 并将所返回的值相加后再返回。
下面我们将利用语言 X 实现一段程序计算三个输入参数之和arg0 + arg1 + arg2。这段程序解析生成的 AST 如下述代码所示:
// Sample program: arg0 + arg1 + arg2
sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
这段程序对应的解释执行则是interpret(sample, args)其中args为代表传入参数的 int 数组。由于sample是编译时常量因此我们可以将其通过 Partial Evaluation特化为下面这段代码所示的interpret0方法
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.execute(args);
}
Truffle 的 Partial Evaluator 会不断进行方法内联(直至遇到被`@TruffleBoundary注解的方法。因此上面这段代码的interpret0方法在内联了对Add.execute方法的调用之后会转换成下述代码
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.left.execute(args) + sample.right.execute(args);
}
同样我们可以进一步内联对Add.execute方法的调用以及对Arg.execute方法的调用最终将interpret0转换成下述代码
static int interpret0(int[] args) {
return args[0] + args[1] + args[2];
}
至此,我们已成功地将一段 Truffle 语言代码的解释执行转换为上述 Java 代码。接下来,我们便可以让 Graal 编译器将这段 Java 代码编译为机器码,从而实现 Truffle 语言的即时编译。
节点重写
Truffle 的另一项关键优化是节点重写node rewriting
在动态语言中,许多变量的类型是在运行过程中方能确定的。以加法符号+为例,它既可以表示整数加法,还可以表示浮点数加法,甚至可以表示字符串加法。
如果是静态语言,我们可以通过推断加法的两个操作数的具体类型,来确定该加法的类型。但对于动态语言来说,我们需要在运行时动态确定操作数的具体类型,并据此选择对应的加法操作。这种在运行时选择语义的节点,会十分不利于即时编译,从而严重影响到程序的性能。
Truffle 语言解释器会收集每个 AST 节点所代表的操作的类型,并且在即时编译时,作出针对所收集得到的类型 profile 的特化specialization
还是以加法操作为例,如果所收集的类型 profile 显示这是一个整数加法操作,那么在即时编译时我们会将对应的 AST 节点当成整数加法;如果是一个字符串加法操作,那么我们会将对应的 AST 节点当成字符串加法。
当然,如果该加法操作既有可能是整数加法也可能是字符串加法,那么我们只好在运行过程中判断具体的操作类型,并选择相应的加法操作。
这种基于类型 profile 的优化,与我们以前介绍过的 Java 虚拟机中解释执行器以及三层 C1 编译代码十分类似,它们背后的核心都是基于假设的投机性优化,以及在假设失败时的去优化。
在即时编译过后,如果运行过程中发现 AST 节点的实际类型和所假设的类型不同Truffle 会主动调用 Graal 编译器提供的去优化 API返回至解释执行 AST 节点的状态,并且重新收集 AST 节点的类型信息。之后Truffle 会再次利用 Graal 编译器进行新一轮的即时编译。
当然,如果能够在第一次编译时便已达到稳定状态,不再触发去优化以及重新编译,那么,这会极大地减短程序到达峰值性能的时间。为此,我们统计了各个 Truffle 语言的方法在进行过多少次方法调用后,其 AST 节点的类型会固定下来。
据统计,在 JavaScript 方法和 Ruby 方法中80% 会在 5 次方法调用后稳定下来90% 会在 7 次调用后稳定下来99% 会在 19 次方法调用之后稳定下来。
R 语言的方法则比较特殊,即便是不进行任何调用,有 50% 的方法已经稳定下来了。这背后的原因也不难推测,这是因为 R 语言主要用于数值统计,几乎所有的操作都是浮点数类型的。
Polyglot
在开发过程中,我们通常会为工程项目选定一门语言,但问题也会接踵而至:一是这门语言没有实现我们可能需要用到的库,二是这门语言并不适用于某类问题。
Truffle 语言实现框架则支持 Polyglot允许在同一段代码中混用不同的编程语言从而使得开发人员能够自由地选择合适的语言来实现子组件。
与其他 Polyglot 框架不同的是Truffle 语言之间能够共用对象。也就是说在不对某个语言中的对象进行复制或者序列化反序列化的情况下Truffle 可以无缝地将该对象传递给另一门语言。因此Truffle 的 Polyglot 在切换语言时,性能开销非常小,甚至经常能够达到零开销。
Truffle 的 Polyglot 特性是通过 Polyglot API 来实现的。每个实现了 Polyglot API 的 Truffle 语言,其对象都能够被其他 Truffle 语言通过 Polyglot API 解析。实际上,当通过 Polyglot API 解析外来对象时,我们并不需要了解对方语言,便能够识别其数据结构,访问其中的数据,并进行进一步的计算。
总结与实践
今天我介绍了 GraalVM 中的 Truffle 项目。
Truffle 是一个语言实现框架,允许语言开发者在仅实现词法解析、语法解析以及 AST 解释器的情况下,达到极佳的性能。目前 Oracle Labs 已经实现并维护了 JavaScript、Ruby、R、Python 以及可用于解析 LLVM bitcode 的 Sulong。后者将支持在 GraalVM 上运行 C/C++ 代码。
Truffle 背后所依赖的技术是 Partial Evaluation 以及节点重写。Partial Evaluation 指的是将所要编译的目标程序解析生成的抽象语法树当做编译时常量,特化该 Truffle 语言的解释器,从而得到指代这段程序解释执行过程的 Java 代码。然后,我们可以借助 Graal 编译器将这段 Java 代码即时编译为机器码。
节点重写则是收集 AST 节点的类型,根据所收集的类型 profile 进行的特化,并在节点类型不匹配时进行去优化并重新收集、编译的一项技术。
Truffle 的 Polyglot 特性支持在一段代码中混用多种不同的语言。与其他 Polyglot 框架相比,它支持在不同的 Truffle 语言中复用内存中存储的同一个对象。
今天的实践环节,请你试用 GraalVM 中附带的各项语言实现。你可以运行我们官网上的各个示例程序。

View File

@ -0,0 +1,116 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 SubstrateVMAOT编译框架
今天我们来聊聊 GraalVM 中的 Ahead-Of-TimeAOT编译框架 SubstrateVM。
先来介绍一下 AOT 编译,所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。
而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。它的成果可以是需要链接至托管环境中的动态共享库,也可以是独立运行的可执行文件。
狭义的 AOT 编译针对的目标代码需要与即时编译的一致,也就是针对那些原本可以被即时编译的代码。不过,我们也可以简单地将 AOT 编译理解为类似于 GCC 的静态编译器。
AOT 编译的优点显而易见:我们无须在运行过程中耗费 CPU 资源来进行即时编译,而程序也能够在启动伊始就达到理想的性能。
然而与即时编译相比AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序 profile 的投机性优化(并非硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序 profile 来绕开这两个限制)。这两者都会影响程序的峰值性能。
Java 9 引入了实验性 AOT 编译工具jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
在启动过程中Java 虚拟机将加载参数-XX:AOTLibrary所指定的动态共享库并部署其中的机器码。这些机器码的作用机理和即时编译生成的机器码作用机理一样都是在方法调用时切入并能够去优化至解释执行。
由于 Java 虚拟机可能通过 Java agent 或者 C agent 改动所加载的字节码,或者这份 AOT 编译生成的机器码针对的是旧版本的 Java 类,因此它需要额外的验证机制,来保证即将链接的机器码的语义与对应的 Java 类的语义是一致的。
jaotc 使用的机制便是类指纹class fingerprinting。它会在动态共享库中保存被 AOT 编译的 Java 类的摘要信息。在运行过程中Java 虚拟机负责将该摘要信息与已加载的 Java 类相比较,一旦不匹配,则直接舍弃这份 AOT 编译的机器码。
jaotc 的一大应用便是编译 java.base module也就是 Java 核心类库中最为基础的类。这些类很有可能会被应用程序所调用,但调用频率未必高到能够触发即时编译。
因此,如果 Java 虚拟机能够使用 AOT 编译技术,将它们提前编译为机器码,那么将避免在执行即时编译生成的机器码时,因为“不小心”调用到这些基础类,而需要切换至解释执行的性能惩罚。
不过,今天要介绍的主角并非 jaotc而是同样使用了 Graal 编译器的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计与实现
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,并且能够无缝衔接 C 代码的 Java 运行时。它与 jaotc 的区别主要有两处。
第一SubstrateVM 脱离了 HotSpot 虚拟机,并拥有独立的运行时,包含异常处理,同步,线程管理,内存管理(垃圾回收)和 JNI 等组件。
第二SubstrateVM 要求目标程序是封闭的即不能动态加载其他类库等。基于这个假设SubstrateVM 将探索整个编译空间并通过静态分析推算出所有虚方法调用的目标方法。最终SubstrateVM 会将所有可能执行到的方法都纳入编译范围之中,从而免于实现额外的解释执行器。
有关 SubstrateVM 的其他限制,你可以参考这篇文档。
从执行时间上来划分SubstrateVM 可分为两部分native image generator 以及 SubstrateVM 运行时。后者 SubstrateVM 运行时便是前面提到的精简运行时,经过 AOT 编译的目标程序将跑在该运行时之上。
native image generator 则包含了真正的 AOT 编译逻辑。它本身是一个 Java 程序,将使用 Graal 编译器将 Java 类文件编译为可执行文件或者动态链接库。
在进行编译之前native image generator 将采用指针分析points-to analysis从用户提供的程序入口出发探索所有可达的代码。在探索的同时它还将执行初始化代码并在最终生成可执行文件时将已初始化的堆保存至一个堆快照之中。这样一来SubstrateVM 将直接从目标程序开始运行,而无须重复进行 Java 虚拟机的初始化。
SubstrateVM 主要用于 Java 虚拟机语言的 AOT 编译,例如 Java、Scala 以及 Kotlin。Truffle 语言实现本质上就是 Java 程序,而且它所有用到的类都是编译时已知的,因此也适合在 SubstrateVM 上运行。不过,它并不会 AOT 编译用 Truffle 语言写就的程序。
SubstrateVM 的启动时间与内存开销
SubstrateVM 的启动时间和内存开销非常少。我们曾比较过用 C 和用 Java 两种语言写就的 Hello World 程序。C 程序的执行时间在 10ms 以下,内存开销在 500KB 以下。在 HotSpot 虚拟机上运行的 Java 程序则需要 40ms内存开销为 24MB。
使用 SubstrateVM 的 Java 程序的执行时间则与 C 程序持平,内存开销在 850KB 左右。这得益于 SubstrateVM 所保存的堆快照,以及无须额外初始化,直接执行目标代码的特性。
同样,我们还比较了用 JavaScript 编写的 Hello World 程序。这里的测试对象是 Google 的 V8 以及基于 Truffle 的 Graal.js。这两个执行引擎都涉及了大量的解析代码以及执行代码因此可以当作大型应用程序来看待。
V8 的执行效率非常高,能够与 C 程序的 Hello World 相媲美,但是它使用了约 18MB 的内存。运行在 HotSpot 虚拟机上的 Graal.js 则需要 650ms 方能执行完这段 JavaScript 的 Hello World 程序,而且内存开销在 120MB 左右。
运行在 SubstrateVM 上的 Graal.js 无论是执行时间还是内存开销都十分优越,分别为 10ms 以下以及 4.2MB。我们可以看到,它在运行时间与 V8 持平的情况下,内存开销远小于 V8。
由于 SubstrateVM 的轻量特性它十分适合于嵌入至其他系统之中。Oracle Labs 的另一个团队便是将 Truffle 语言实现嵌入至 Oracle 数据库之中这样就可以在数据库中运行任意语言的预储程序stored procedure。如果你感兴趣的话可以搜索 Oracle Database Multilingual EngineMLE或者参阅这个网址。我们团队也在与 MySQL 合作,开发 MySQL MLE详情可留意我们在今年 Oracle Code One 的讲座。
Metropolis 项目
去年 OpenJDK 推出了Metropolis 项目他们希望可以实现“Java-on-Java”的远大目标。
我们知道,目前 HotSpot 虚拟机的绝大部分代码都是用 C++ 写的。这也造就了一个非常有趣的现象,那便是对 Java 语言本身的贡献需要精通 C++。此外,随着 HotSpot 项目日渐庞大,维护难度也逐渐上升。
由于上述种种原因,使用 Java 来开发 Java 虚拟机的呼声越来越高。Oracle 的架构师 John Rose 便提出了使用 Java 开发 Java 虚拟机的四大好处:
能够完全控制编译 Java 虚拟机时所使用的优化技术;
能够与 C++ 语言的更新解耦合;
能够减轻开发人员以及维护人员的负担;
能够以更为敏捷的方式实现 Java 的新功能。
当然Metropolis 项目并非第一个提出 Java-on-Java 概念的项目。实际上JikesRVM 项目和Maxine VM 项目都已用 Java 完整地实现了一套 Java 虚拟机(后者的即时编译器 C1X 便是 Graal 编译器的前身)。
然而Java-on-Java 技术通常会干扰应用程序的垃圾回收、即时编译优化,从而严重影响 Java 虚拟机的启动性能。
举例来说,目前使用了 Graal 编译器的 HotSpot 虚拟机会在即时编译过程中生成大量的 Java 对象,这些 Java 对象同样会占据应用程序的堆空间,从而使得垃圾回收更加频繁。
另外Graal 编译器本身也会触发即时编译,并与应用程序的即时编译竞争编译线程的 CPU 资源。这将造成应用程序从解释执行切换至即时编译生成的机器码的时间大大地增长,从而降低应用程序的启动性能。
Metropolis 项目的第一个子项目便是探索部署已 AOT 编译的 Graal 编译器的可能性。这个子项目将借助 SubstrateVM 技术,把整个 Graal 编译器 AOT 编译为机器码。
这样一来在运行过程中Graal 编译器不再需要被即时编译,因此也不会再占据可用于即时编译应用程序的 CPU 资源,使用 Graal 编译器的 HotSpot 虚拟机的启动性能将得到大幅度地提升。
此外,由于 SubstrateVM 编译得到的 Graal 编译器将使用独立的堆空间,因此 Graal 编译器在即时编译过程中生成的 Java 对象将不再干扰应用程序所使用的堆空间。
目前 Metropolis 项目仍处于前期验证阶段,如果你感兴趣的话,可以关注之后的发展情况。
总结与实践
今天我介绍了 GraalVM 中的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,和能够无缝衔接 C 代码的 Java 运行时。它是一个独立的运行时,拥有自己的内存管理等组件。
SubstrateVM 要求所要 AOT 编译的目标程序是封闭的,即不能动态加载其他类库等。在进行 AOT 编译时,它会探索所有可能运行到的方法,并全部纳入编译范围之内。
SubstrateVM 的启动时间和内存开销都非常少,这主要得益于在 AOT 编译时便已保存了已初始化好的堆快照并支持从程序入口直接开始运行。作为对比HotSpot 虚拟机在执行 main 方法前需要执行一系列的初始化操作,因此启动时间和内存开销都要远大于运行在 SubstrateVM 上的程序。
Metropolis 项目将运用 SubstrateVM 项目,逐步地将 HotSpot 虚拟机中的 C++ 代码替换成 Java 代码,从而提升 HotSpot 虚拟机的可维护性,也加快新 Java 功能的开发效率。
今天的实践环节请你参考我们官网的SubstrateVM 教程AOT 编译一段 Java-Kotlin 代码。

View File

@ -0,0 +1,43 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
尾声丨道阻且长,努力加餐.html
说句实话,我也不知道是怎么写完这 36 篇技术文章的。
一周三篇的文章接近近万字,说多不多,对我而言还是挺困难的一件事。基本上,我连续好几个月的业余时间都贡献给写作,甚至一度重温了博士阶段被论文支配的恐怖。我想,这大概也算是在工作相对清闲的国外环境下,体验了一把 997 的生活。
这一路下来,我感觉写专栏的最大问题,其实并不在于写作本身,而在于它对你精力的消耗,这种消耗甚至会让你无法专注于本职工作。因此,我也愈发地佩服能够持续分享技术的同行们。还好我的工作挺有趣的,每天开开心心地上班写代码,只是一到下班时间就蔫了,不得不应付编辑的催稿回家码字。
我在写作的中途,多次感受到存稿不足的压力,以致于需要请年假来填补写作的空缺。不过,最后做到了风雨无阻、节假无休地一周三更,也算是幸不辱命吧。
说回专栏吧。在思考专栏大纲时,我想着,最好能够和杨晓峰老师的 Java 核心技术专栏形成互补,呈现给大家的内容相对更偏向于技术实现。
因此,有读者曾反馈讲解的知识点是否太偏,不实用。当时我的回答是,我并不希望将专栏单纯写成一本工具书,这样的知识你可以从市面上任意买到一本书获得。
我更希望的是,能够通过介绍 Java 虚拟机各个组件的设计和实现,让你之后遇到虚拟机相关的问题时,能够联想到具体的模块,甚至是对于其他语言的运行时,也可以举一反三相互对照。
不过,当我看到 Aleksey Shipilev介绍 JMH 的讲座时,发现大部分的内容专栏里都有涉及。于是心想,我还能够在上述答复中加一句:看老外的技术讲座再也不费劲了。
还有一个想说的是关于专栏知识点的正确性。我认为虚拟机的设计可以写一些自己的理解,但是具体到目前 HotSpot 的工程实现则是确定的。
为此,几乎每篇专栏我都会大量阅读 HotSpot 的源代码,和同事讨论实现背后的设计理念,在这个过程中,我也发现了一些 HotSpot 中的 Bug或者年久失修的代码又或者是设计不合理的地方。这大概也能够算作写专栏和我本职工作重叠的地方吧。
我会仔细斟酌文章中每一句是否可以做到达意。即便是这样,文章肯定还有很多不足,比如叙述不够清楚,内容存在误导等问题。许多读者都热心地指了出来,在此感谢各位的宝贵意见。接下来一段时间,我会根据大家的建议,对前面的文章进行修订。
专栏虽然到此已经结束了,但是并不代表你对 Java 虚拟机学习的停止, 我想,专栏的内容仅仅是为你打开了 JVM 学习的大门,里面的风景,还是需要你自己来探索。在文章的后面,我列出了一系列的 Java 虚拟机技术的相关博客和阅读资料,你仍然可以继续加餐。
你可以关注国内几位 Java 虚拟机大咖的微信公众号R 大,个人认为是中文圈子里最了解 Java 虚拟机设计实现的人,你可以关注他的知乎账号;你假笨,原阿里 Java 虚拟机团队成员现PerfMa CEO江南白衣唯品会资深架构师占小狼美团基础架构部技术专家杨晓峰前甲骨文首席工程师。
如果英文阅读没问题的话你可以关注Cliff Click、Aleksey Shipilëv他的JVM Anatomy Park十分有趣和Nitsan Wakart的博客。你也可以关注Java Virtual Machine Language Submit和Oracle Code One前身是 JavaOne 大会)中关于 Java 虚拟机的演讲,以便掌握 Java 的最新发展动向。
当然,如果对 GraalVM 感兴趣的话,你可以订阅我们团队的博客。我会在之后考虑将文章逐一进行翻译。
其他的阅读材料,你可以参考 R 大的这份书单,或者这个汇总贴。
如果这个专栏激发了你对 Java 虚拟机的学习热情,那么我建议你着手去阅读 HotSpot 源代码,并且回馈给 OpenJDK 开源社区。这种回馈并不一定是提交 patch也可以是 Bug report 或者改进建议等等。

View File

@ -0,0 +1,492 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
工具篇 常用工具介绍
在前面的文章中,我曾使用了不少工具来辅助讲解,也收到了不少同学留言,说不了解这些工具,不知道都有什么用,应该怎么用。那么今天我便统一做一次具体的介绍。本篇代码较多,你可以点击文稿查看。
javap查阅 Java 字节码
javap 是一个能够将 class 文件反汇编成人类可读格式的工具。在本专栏中,我们经常借助这个工具来查阅 Java 字节码。
举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
编译过后,我们便可以使用 javap 来查阅 Foo.test 方法的字节码。
$ javac Foo.java
$ javap -p -v Foo
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#24 // Foo.tryBlock:I
#3 = Fieldref #7.#25 // Foo.finallyBlock:I
#4 = Class #26 // java/lang/Exception
#5 = Fieldref #7.#27 // Foo.catchBlock:I
#6 = Fieldref #7.#28 // Foo.methodExit:I
#7 = Class #29 // Foo
#8 = Class #30 // java/lang/Object
#9 = Utf8 tryBlock
#10 = Utf8 I
#11 = Utf8 catchBlock
#12 = Utf8 finallyBlock
#13 = Utf8 methodExit
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 test
#19 = Utf8 StackMapTable
#20 = Class #31 // java/lang/Throwable
#21 = Utf8 SourceFile
#22 = Utf8 Foo.java
#23 = NameAndType #14:#15 // "<init>":()V
#24 = NameAndType #9:#10 // tryBlock:I
#25 = NameAndType #12:#10 // finallyBlock:I
#26 = Utf8 java/lang/Exception
#27 = NameAndType #11:#10 // catchBlock:I
#28 = NameAndType #13:#10 // methodExit:I
#29 = Utf8 Foo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/Throwable
{
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int catchBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int finallyBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int methodExit;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public Foo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
line 13: 5
line 14: 10
line 10: 13
line 11: 14
line 13: 19
line 14: 24
line 13: 27
line 14: 33
line 15: 35
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 7 /* same */
}
SourceFile: "Foo.java"
这里面我用到了两个选项。第一个选项是 -p。默认情况下 javap 会打印所有非私有的字段和方法,当加了 -p 选项后,它还将打印私有的字段和方法。第二个选项是 -v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码那么可以用 -c 选项来替换 -v。
javap 的 -v 选项的输出分为几大块。
\1. 基本信息,涵盖了原 class 文件的相关信息。
class 文件的版本号minor version: 0major version: 54该类的访问权限flags: (0x0021) ACC_PUBLIC, ACC_SUPER该类this_class: #7以及父类super_class: #8的名字所实现接口interfaces: 0、字段fields: 4、方法methods: 2以及属性attributes: 1的数目。
这里属性指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解。
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
class 文件的版本号指的是编译生成该 class 文件时所用的 JRE 版本。由较新的 JRE 版本中的 javac 编译而成的 class 文件,不能在旧版本的 JRE 上跑否则会出现如下异常信息。Java 8 对应的版本号为 52Java 10 对应的版本号为 54。
Exception in thread "main" java.lang.UnsupportedClassVersionError: Foo has been compiled by a more recent version of the Java Runtime (class file version 54.0), this version of the Java Runtime only recognizes class file versions up to 52.0
类的访问权限通常为 ACC_ 开头的常量。具体每个常量的意义可以查阅 Java 虚拟机规范 4.1 小节 [1]。
\2. 常量池,用来存放各种常量以及符号引用。
常量池中的每一项都有一个对应的索引(如 #1),并且可能引用其他的常量池项(#1 = Methodref #8.#23)。
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
...
#8 = Class #30 // java/lang/Object
...
#14 = Utf8 <init>
#15 = Utf8 ()V
...
#23 = NameAndType #14:#15 // "<init>":()V
...
#30 = Utf8 java/lang/Object
举例来说,上图中的 1 号常量池项是一个指向 Object 类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话,那么它的叶节点会是字符串常量,如下图所示。
\3. 字段区域,用来列举该类中的各个字段。
这里最主要的信息便是该字段的类型descriptor: I以及访问权限flags: (0x0002) ACC_PRIVATE。对于声明为 final 的静态字段而言,如果它是基本类型或者字符串类型,那么字段区域还将包括它的常量值。
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
另外Java 虚拟机同样使用了“描述符”descriptor来描述字段的类型。具体的对照如下表所示。其中比较特殊的我已经高亮显示。
\4. 方法区域,用来列举该类中的各个方法。
除了方法描述符以及访问权限之外每个方法还包括最为重要的代码区域Code:)。
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
...
10: goto 35
...
34: athrow
35: aload_0
...
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
...
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
...
代码区域一开始会声明该方法中的操作数栈stack=2和局部变量数目locals=3的最大值以及该方法接收参数的个数args_size=1。注意这里局部变量指的是字节码中的局部变量而非 Java 程序中的局部变量。
接下来则是该方法的字节码。每条字节码均标注了对应的偏移量bytecode indexBCI这是用来定位字节码的。比如说偏移量为 10 的跳转字节码 10: goto 35将跳转至偏移量为 35 的字节码 35: aload_0。
紧跟着的异常表Exception table:)也会使用偏移量来定位每个异常处理器所监控的范围(由 from 到 to 的代码区域以及异常处理器的起始位置target。除此之外它还会声明所捕获的异常类型type。其中any 指代任意异常类型。
再接下来的行数表LineNumberTable:)则是 Java 源程序到字节码偏移量的映射。如果你在编译时使用了 -g 参数javac -g Foo.java那么这里还将出现局部变量表LocalVariableTable:),展示 Java 程序中每个局部变量的名字、类型以及作用域。
行数表和局部变量表均属于调试信息。Java 虚拟机并不要求 class 文件必备这些信息。
LocalVariableTable:
Start Length Slot Name Signature
14 5 1 e Ljava/lang/Exception;
0 41 0 this LFoo;
最后则是字节码操作数栈的映射表StackMapTable: number_of_entries = 3。该表描述的是字节码跳转后操作数栈的分布情况一般被 Java 虚拟机用于验证所加载的类,以及即时编译相关的一些操作,正常情况下,你无须深入了解。
2.OpenJDK 项目 Code Tools实用小工具集
OpenJDK 的 Code Tools 项目 [2] 包含了好几个实用的小工具。
在第一篇的实践环节中,我们使用了其中的字节码汇编器反汇编器 ASMTools[3],当前 6.0 版本的下载地址位于 [4]。ASMTools 的反汇编以及汇编操作所对应的命令分别为:
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
该反汇编器的输出格式和 javap 的不尽相同。一般我只使用它来进行一些简单的字节码修改,以此生成无法直接由 Java 编译器生成的类,它在 HotSpot 虚拟机自身的测试中比较常见。
在第一篇的实践环节中,我们需要将整数 2 赋值到一个声明为 boolean 类型的局部变量中。我采取的做法是将编译生成的 class 文件反汇编至一个文本文件中,然后找到 boolean flag = true 对应的字节码序列,也就是下面的两个。
iconst_1;
istore_1;
将这里的 iconst_1 改为 iconst_2[5],保存后再汇编至 class 文件即可完成第一篇实践环节的需求。
除此之外,你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过 class 文件允许出现参数类型相同、而返回类型不同的方法,并且,在作为库文件时 Java 编译器将使用先定义的那一个,来决定具体的返回类型。
具体的验证方法便是在反汇编之后,利用文本编辑工具复制某一方法,并且更改该方法的描述符,保存后再汇编至 class 文件。
Code Tools 项目还包含另一个实用的小工具 JOL[6],当前 0.9 版本的下载地址位于 [7]。JOL 可用于查阅 Java 虚拟机中对象的内存分布,具体可通过如下两条指令来实现。
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap
$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap
3.ASMJava 字节码框架
ASM[8] 是一个字节码分析及修改框架。它被广泛应用于许多项目之中,例如 Groovy、Kotlin 的编译器,代码覆盖测试工具 Cobertura、JaCoCo以及各式各样通过字节码注入实现的程序行为监控工具。甚至是 Java 8 中 Lambda 表达式的适配器类,也是借助 ASM 来动态生成的。
ASM 既可以生成新的 class 文件,也可以修改已有的 class 文件。前者相对比较简单一些。ASM 甚至还提供了一个辅助类 ASMifier它将接收一个 class 文件并且输出一段生成该 class 文件原始字节数组的代码。如果你想快速上手 ASM 的话,那么你可以借助 ASMifier 生成的代码来探索各个 API 的用法。
下面我将借助 ASMifier来生成第一篇实践环节所用到的类。你可以通过该地址 [9] 下载 6.0-beta 版。)
$ echo '
public class Foo {
public static void main(String[] args) {
boolean flag = true;
if (flag) System.out.println("Hello, Java!");
if (flag == true) System.out.println("Hello, JVM!");
}
}' > Foo.java
# 这里的 javac 我使用的是 Java 8 版本的。ASM 6.0 可能暂不支持新版本的 javac 编译出来的 class 文件
$ javac Foo.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java
...
public class FooDump implements Opcodes {
public static byte[] dump () throws Exception {
ClassWriter cw = new ClassWriter(0);
FieldVisitor fv;
MethodVisitor mv;
AnnotationVisitor av0;
cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Foo", null, "java/lang/Object", null);
...
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
mv.visitVarInsn(ILOAD, 1);
...
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
}
...
可以看到ASMifier 生成的代码中包含一个名为 FooDump 的类,其中定义了一个名为 dump 的方法。该方法将返回一个 byte 数组,其值为生成类的原始字节。
在 dump 方法中,我们新建了功能类 ClassWriter 的一个实例,并通过它来访问不同的成员,例如方法、字段等等。
每当访问一种成员,我们便会得到另一个访问者。在上面这段代码中,当我们访问方法时(即 visitMethod便会得到一个 MethodVisitor。在接下来的代码中我们会用这个 MethodVisitor 来访问(这里等同于生成)具体的指令。
这便是 ASM 所使用的访问者模式。当然,这段代码仅包含 ClassWriter 这一个访问者,因此看不出具体有什么好处。
我们暂且不管这个访问者模式先来看看如何实现第一篇课后实践的要求。首先main 方法中的 boolean flag = true; 语句对应的代码是:
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
也就是说,我们只需将这里的 ICONST_1 更改为 ICONST_2便可以满足要求。下面我用另一个类 Wrapper来调用修改过后的 FooDump.dump 方法。
$ echo 'import java.nio.file.*;
public class Wrapper {
public static void main(String[] args) throws Exception {
Files.write(Paths.get("Foo.class"), FooDump.dump());
}
}' > Wrapper.java
$ javac -cp /PATH/TO/asm-all-6.0_BETA.jar FooDump.java Wrapper.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar:. Wrapper
$ java Foo
这里的输出结果应和通过 ASMTools 修改的结果一致。
通过 ASM 来修改已有 class 文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起:
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
这段代码的功能便是读取一个 class 文件,将之转换为 ASM 的数据结构,然后再转换为原始字节数组。其中,我使用了两个功能类。除了已经介绍过的 ClassWriter 外,还有一个 ClassReader。
ClassReader 将读取“Foo”类的原始字节并且翻译成对应的访问请求。也就是说在上面 ASMifier 生成的代码中的各个访问操作,现在都交给 ClassReader.accept 这一方法来发出了。
那么,如何修改这个 class 文件的字节码呢?原理很简单,就是将 ClassReader 的访问请求发给另外一个访问者,再由这个访问者委派给 ClassWriter。
这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。
import java.nio.file.*;
import org.objectweb.asm.*;
public class ASMHelper implements Opcodes {
static class MyMethodVisitor extends MethodVisitor {
private MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api, null);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello, World!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
}
static class MyClassVisitor extends ClassVisitor {
public MyClassVisitor(int api, ClassVisitor cv) {
super(api, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if ("main".equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
}
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new MyClassVisitor(ASM6, cw);
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
}
这里我贴了一段代码,在 ClassReader 和 ClassWriter 中间插入了一个自定义的访问者 MyClassVisitor。它将截获由 ClassReader 发出的对名字为“main”的方法的访问请求并且替换为另一个自定义的 MethodVisitor。
这个 MethodVisitor 会忽略由 ClassReader 发出的任何请求,仅在遇到 visitCode 请求时生成一句“System.out.println(“Hello World!”);”。
由于篇幅的限制,我就不继续深入介绍下去了。如果你对 ASM 有浓厚的兴趣,可以参考这篇教程 [10]。
你对这些常用工具还有哪些问题呢?可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。
[1]
https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1
[2]
http://openjdk.java.net/projects/code-tools/
[3]
https://wiki.openjdk.java.net/display/CodeTools/asmtools
[4]
https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz
[5]
https://cs.au.dk/~mis/dOvs/jvmspec/ref21.html
[6]
http://openjdk.java.net/projects/code-tools/jol/
[7]
http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar
[8]
https://asm.ow2.io/
[9]
https://repository.ow2.org/nexus/content/repositories/releases/org/ow2/asm/asm-all/6.0_BETA/asm-all-6.0_BETA.jar
[10]
http://web.cs.ucla.edu/~msb/cs239-tutorial/