Java 与编译原理
Java 与编译原理
Java 的「编译」分两段:
javac把源码编译成字节码,JVM 在运行时再把热点字节码编译成机器码。理解这两段,才能解释泛型为什么擦除、lambda 为什么不是匿名内部类、String拼接为什么在 Java 9 之后变了、synchronized在字节码里长什么样。
1. 两段编译
源码 .java
│ javac
▼
字节码 .class ──► 类加载 ──► 解释执行
│ 热点方法
▼
JIT(C1/C2)──► 机器码| 阶段 | 产物 | 工具 |
|---|---|---|
| 前端编译 | .class 字节码 | javac、javap |
| 类加载 | 方法区/元空间里的类 | ClassLoader |
| 解释执行 | 逐条字节码 | 解释器 |
| 即时编译 | 本地机器码 | C1/C2、Graal JIT |
| 运行时优化 | 内联、逃逸分析、去虚拟化 | JIT 编译器 |
javac 只做「源码 → 字节码」,不做激进优化;真正的性能优化大多发生在 JIT。
2. 字节码:栈式虚拟机
JVM 是栈式虚拟机:指令从操作数栈取数,把结果压回栈;局部变量表存参数和局部变量。
public class Add {
public static int add(int a, int b) {
return a + b;
}
public static void main(String[] args) {
int sum = 0;
for (int i = 0; i < 100_000; i++) {
sum += add(i, 1); // 制造热点,方便观察 JIT
}
System.out.println(sum);
}
}javac Add.java
javap -c Addpublic static int add(int, int);
Code:
0: iload_0 // 把局部变量 0(a)压栈
1: iload_1 // 把局部变量 1(b)压栈
2: iadd // 弹出两个 int,相加,压回结果
3: ireturn // 返回栈顶 int实例方法多一个 this:
public class AddInstance {
private int base;
public int add(int a, int b) { return this.base + a + b; }
}aload_0 // this
getfield base
iload_1
iadd
iload_2
iadd
ireturn常见指令:
| 指令 | 含义 |
|---|---|
iload_0 / aload_0 | 加载第 0 个 int / 引用局部变量 |
ldc | 从常量池加载常量 |
getfield / putfield | 读写实例字段 |
invokestatic | 调用静态方法 |
invokevirtual | 调用实例方法(虚分派) |
invokespecial | 构造器、私有方法、super 调用 |
invokeinterface | 接口方法调用 |
invokedynamic | 动态调用点(lambda、字符串拼接) |
ireturn / areturn | 返回 int / 引用 |
2.1 看 class 文件结构
javap -v -p Add会看到:
Classfile /.../Add.class
minor version: 0
major version: 65 // Java 21
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref ...
#2 = Fieldref ...
{
public static int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
...
LineNumberTable:
LocalVariableTable:
StackMapTable:
}major version 对应 JDK:Java 17 = 61,Java 21 = 65,Java 22 = 66。StackMapTable 是给字节码校验器用的类型信息,Java 7+ 必需。
3. 名字、常量与字符串
3.1 常量池
.class 里有一个常量池,存字符串字面量、类/方法/字段引用、数值常量。ldc 从常量池取;String s = "a" 会复用常量池里的同一个对象。
String a = "hello";
String b = "hello";
System.out.println(a == b); // true,同一个常量池对象
String c = new String("hello");
System.out.println(a == c); // false,新对象3.2 字符串拼接
Java 9 之前,"a" + b 会编译成 StringBuilder;Java 9+ 用 invokedynamic 调用 StringConcatFactory:
String s = "a" + b + "c";invokedynamic #... // makeConcatWithConstantsJIT 会对拼接做优化,但循环里拼接仍然是 O(n²),用 StringBuilder 或 String.join。
4. 泛型擦除
Java 的泛型是编译期特性,运行时擦除:
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true,都是 ArrayList后果:
- 不能
new T()、不能new T[]、不能instanceof List<String>; - 静态方法/静态字段不能使用类的类型参数;
- 需要运行时类型时传
Class<T>token; - 编译器会生成 bridge method 保证多态分派。
public class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> box = new Box<>();
// 字节码里 set(Object)、get()Object,编译器插入 checkcast擦除不是「没有类型检查」:编译期仍然严格检查,只是运行时看不到具体类型参数。
5. lambda 与 invokedynamic
Java 8 的 lambda 不是匿名内部类的语法糖(至少不完全是):
Runnable r = () -> System.out.println("hi");编译后是一个 invokedynamic 指令;首次执行到该调用点时完成链接,由 LambdaMetafactory 生成(或复用)一个实现类,之后就走链接好的调用点。
invokedynamic #... // run:()Ljava/lang/Runnable;好处:
- 不为每个 lambda 生成一个
.class文件; - JIT 可以做内联/去虚拟化;
- 比匿名内部类少一层对象分配(取决于捕获情况)。
invokedynamic 也用于字符串拼接(Java 9+)、部分模式匹配(Java 21+)和动态语言支持。
6. 类加载:双亲委派
Bootstrap ClassLoader(JDK 核心类)
↑
Platform ClassLoader(JDK 平台类)
↑
Application ClassLoader(classpath)
↑
自定义 ClassLoader- 双亲委派:加载一个类时先委托父加载器,父加载器找不到才自己加载;避免核心类被篡改。
ClassLoader.loadClass只加载、不初始化;Class.forName(name)默认initialize=true,会初始化。- 类的初始化在首次主动使用时触发:
new、访问静态字段/方法、反射、子类初始化等;static final编译期常量除外(常量被内联进调用方)。 - SPI(如 JDBC 驱动)常用
Thread.currentThread().getContextClassLoader()做上下文查找,让父加载器加载的接口能找到子加载器里的实现;它本身不是「打破双亲委派」——Tomcat 这类容器才是通过重写loadClass打破。
类加载的五个阶段(JVM 规范):
| 阶段 | 做什么 |
|---|---|
| 加载 | 找到二进制字节流,生成 Class 对象 |
| 验证 | 文件格式、元数据、字节码、符号引用校验 |
| 准备 | 为静态字段分配内存并赋零值(编译期常量在编译期已赋值) |
| 解析 | 把符号引用替换为直接引用(可延迟) |
| 初始化 | 执行 <clinit>:静态字段赋值 + 静态块,父类先初始化 |
Class<?> clazz = Class.forName("com.example.Plugin");
Object instance = clazz.getDeclaredConstructor().newInstance();类加载泄漏是 Java 的经典问题:自定义 ClassLoader 加载的类如果还被静态字段、线程、ThreadLocal 引用,整个 ClassLoader 和它加载的类都无法卸载(热部署/插件系统常见)。
6.1 谁在改字节码
「编译原理落到应用开发」最直接的接缝,是各种字节码增强工具:
| 工具 | 时机 | 做什么 |
|---|---|---|
| Lombok | 编译期(注解处理器) | 生成 getter/setter/构造器 |
| ASM | 编译期或运行期 | 直接读写字节码,很多框架的底层 |
| ByteBuddy / CGLIB | 运行期 | 生成子类/代理类,Spring AOP、Mockito 常用 |
| JaCoCo | 运行期(agent) | 插桩统计覆盖率 |
| Spring AOP | 运行期 | 对 Bean 生成代理,织入事务/日志/权限 |
用 javap -c 看一个 CGLIB 代理类,会看到它继承目标类、重写方法,并在方法里调用 MethodInterceptor.intercept(...)。理解这一点,就能解释「为什么 final 类/方法不能被 CGLIB 代理」「为什么自调用不走事务」。
7. JIT:热点代码才编译
JVM 启动时先解释执行,方法调用次数/循环回边达到阈值后触发 JIT:
- C1:编译快,优化保守,适合启动阶段;
- C2:编译慢,优化激进,适合长期运行的热点;
- 分层编译(Tiered Compilation,默认开启):先 C1,收集 profile,再 C2。
常见优化:
- 方法内联:把小方法展开,减少调用开销;
- 逃逸分析:对象只在方法内使用,可能做标量替换(把对象拆成局部变量)和锁消除;HotSpot 不把对象整体分配到栈上;
- 锁消除:不可能竞争的锁去掉;
- 去虚拟化:单实现/类型 profile 明确时,把虚调用变成直接调用;
- 循环展开/向量化:减少循环开销。
-XX:+PrintCompilation
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
-XX:+PrintAssembly # 需要 hsdis
-Xint # 只解释,禁用 JIT(测试用)注意:-Xcomp 强制编译所有方法,启动极慢且可能触发去优化,不能用来评估生产性能。
8. volatile 与 synchronized 在字节码里
public synchronized void inc() {
count++;
}方法级 synchronized 是 ACC_SYNCHRONIZED 标志;代码块是 monitorenter / monitorexit。下面是 synchronized (this) { count++; } 的简化字节码(省略常量池索引和异常表):
aload_0
dup
astore_1 // 把 monitor 存到局部变量,异常路径要用
monitorenter
aload_0
getfield count
iconst_1
iadd
putfield count
aload_1
monitorexit编译器还会为异常路径生成第二个 monitorexit 和对应的 Exception table;手写或删掉这些指令,字节码校验器不会放过。
volatile 字段是 ACC_VOLATILE 标志,JIT 在读写处插入内存屏障,保证可见性和有序性。字节码本身看不到屏障,屏障是 JIT/硬件层的事。
9. 反射与动态代理
Method m = User.class.getMethod("getName");
Object name = m.invoke(user);
InvocationHandler handler = (proxy, method, args) -> {
System.out.println("before " + method.getName());
return method.invoke(target, args);
};
UserApi proxy = (UserApi) Proxy.newProxyInstance(
UserApi.class.getClassLoader(),
new Class[]{UserApi.class},
handler);- 反射调用通常比直接调用慢:访问检查、基本类型实参装箱、难以内联;但 JDK 18+ 的核心反射用 MethodHandle 重写,热点路径可以被 JIT 优化,不要一概说「绝对不能内联」。
MethodHandle/VarHandle是更底层的动态调用,性能接近直接调用,但 API 更复杂。- 动态代理只能代理接口;Spring AOP 对类用 CGLIB 生成子类。
setAccessible(true)会绕过访问检查,模块化(JPMS)下可能被拒绝。
10. 常见坑
- 泛型擦除:运行时拿不到
List<String>的元素类型;需要TypeReference/Class<T>。 ==与equals:不是编译器问题,但常量池/自动装箱缓存会让==偶尔「看起来对」。- 自动装箱缓存:
Integer在-128..127复用对象,范围外==为 false。 String拼接:循环里用+仍然是 O(n²)。- 类加载泄漏:ClassLoader 被长生命周期对象引用,热部署卸载不掉。
- 反射滥用:框架层可以,业务热路径慎用。
-Xcomp评估性能:结果不可信。- 把
javac的优化当运行时优化:真正的内联/逃逸分析在 JIT。
11. 动手实验
# 1. 看字节码
javac Add.java
javap -c -p Add
# 2. 看常量池/版本/StackMapTable
javap -v -p Add | head -40
# 3. 看类加载
java -Xlog:class+load=info -cp . Add
# 4. 看 JIT 编译
java -XX:+PrintCompilation -cp . Add | head -30
# 5. 看内联(需要 diagnostic)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -cp . Add | head -30public class CompileLab {
public static void main(String[] args) {
String a = "hello";
String b = "hello";
System.out.println(a == b); // true
String c = new String("hello");
System.out.println(a == c); // false
Runnable r = () -> System.out.println("lambda");
System.out.println(r.getClass()); // 运行时生成的类
}
}12. 小结
javac把源码编译成.class字节码;JVM 在运行时解释执行,并对热点方法做 JIT。- JVM 是栈式虚拟机:局部变量表 + 操作数栈;
javap -c是最直接的观察工具。 - 泛型在编译期检查、运行时擦除;lambda 和字符串拼接用
invokedynamic。 - 类加载遵循双亲委派;
Class.forName会初始化,loadClass不会。 - JIT 的优化(内联、逃逸分析、去虚拟化)比
javac的优化重要得多;评估性能不要用-Xint/-Xcomp。 - 很多「Java 语法题」的答案其实在字节码里:
String常量池、synchronized的monitorenter、lambda 的invokedynamic。