跳到主要内容

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 Add
public 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 #... // makeConcatWithConstants

JIT 会对拼接做优化,但循环里拼接仍然是 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 -30
public 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。
加载评论中...