← 返回

Java 为什么不能直接解析 Lambda?从一个 ORM 查询说起

📅 2026-06-10 | 🏷️ Java, Lambda, JVM | 📖 阅读约 15 分钟


问题起源

做 Java ORM 的时候,更让人羡慕的就是 C# 的 LINQ:

// C# — 直接写 Lambda,框架自动转 SQL
var users = await context.Users
    .Where(u => u.Age > 18 && u.Name == "Tom")
    .OrderBy(u => u.Id)
    .ToListAsync();

我们也想在 Java 里这么写:

// 理想中的 Java ORM 写法
query.where(u -> u.getAge() > 18 && u.getName().equals("Tom"));

但试了一下就发现,Java 运行时核心拿不到 Lambda 里面的结构u -> u.getAge() > 18 这个表达式,运行时只能执行它(传一个 User 进去,返回 true/false),但拿不到"age > 18"这个结构信息。

这就很奇怪了,C# 是怎么做到的?Java 为什么不行?


C# 编译器做了什么

C# 有一个特殊的泛型类型 Expression<TDelegate>。当编译器看到:

Expression<Func<User, bool>> expr = u => u.Age > 18;

它的行为和普通的 Func<User, bool> 完全不同

  • Func<User, bool> → 编译成可执行的方法体,运行时直接调用
  • Expression<Func<User, bool>>不编译成方法体,而是生成一棵表达式树(Expression Tree),运行时可以直接遍历

这棵树长这样:

BinaryExpression (GreaterThan)
├── MemberExpression
│   ├── Expression: ParameterExpression (u)
│   └── Member: Age (int)
├── NodeType: GreaterThan
└── ConstantExpression
    └── Value: 18

EF Core 就是拿着这棵树去生成 SQL 的。编译器在编译期就把 Lambda 的 AST 保留下来了。


Java 编译器做了什么

Java 的 Lambda 是 Java 8 引入的,语法上和 C# 很类似:

Predicate<User> p = u -> u.getAge() > 18;

但编译器的处理方式完全不同。Java 的 Lambda 是语法糖,编译后变成字节码:

// 编译器生成的 lambda$main$0 方法的字节码
// 这个方法就是 Lambda 的实际逻辑

ALOAD_0                    // 加载第一个参数(User 对象)
INVOKEVIRTUAL User.getAge  // 调用 user.getAge(),返回 int
BIPUSH 18                  // 把常量 18 压入操作数栈
IF_ICMPLE L1               // 如果 age <= 18,跳转到 L1
ICONST_1                   // 否则压入 1(true)
GOTO L2                    // 跳转到 L2
L1:
ICONST_0                   // 压入 0(false)
L2:
IRETURN                    // 返回结果

关键问题:字节码里只有"怎么做"(指令序列),没有"是什么"(语义结构)。

你没法从 IF_ICMPLE 这条指令反推出"这是一个大于比较",也没法从 INVOKEVIRTUAL User.getAge 反推出"这是一个 age 属性的 getter 调用"。指令就是指令,上下文信息在编译过程中全部丢失了。


为什么 Java 这么设计

这不是 bug,是有意的设计选择

1. 性能优先

C# 的 Expression Tree 在运行时需要遍历树结构,有额外开销。Java 的 Lambda 编译后就是普通的方法调用,JIT 编译器可以直接内联优化,性能和手写匿名类一样。

// Java Lambda 的运行时开销 = 0
// JIT 编译后和直接调用 getAge() 完全一样
users.stream().filter(u -> u.getAge() > 18);

2. invokedynamic 机制

Java 8 引入 Lambda 用的是 invokedynamic 指令,这是一个通用的延迟绑定机制。Lambda 的实现类是在第一次调用时才生成的,不是编译期就确定的。这种机制灵活但代价是丢失了编译期的类型信息。

3. JVM 规范的限制

JVM 的字节码格式从设计之初就不保留源码级别的信息(行号表是可选的,变量名通常被擦除)。要让 JVM 保留 Lambda 的 AST,需要修改字节码格式,这是不可能的。


那 Java ORM 框架怎么办

既然 Java 没有 Expression Tree,框架们就各显神通了。我梳理了五条主要的技术路线:

路线代表框架输入核心思路
Method ReferenceMyBatis PlusUser::getAge从 Serializable Lambda 提取方法名
APTQueryDSLQUser.user.age编译期扫描字段,生成元模型类
ASMu -> getAge() > 18读取编译后的字节码,逐条解析指令
Javac PluginDrinku -> getAge() > 18拦截编译器的 AST,直接访问源码结构
JDK Proxy规则引擎proxy.getAge()代理对象录制 getter 调用路径

每条路线都有自己的权衡。后面的四篇文章会逐一实现这五条路线,深入到代码层面搞清楚原理。


一个重要的前提:缓存

在开始之前需要说明一点:实际使用中,所有框架都会缓存解析结果

// MyBatis Plus 的实际行为:
// 第一次调用 User::getAge
//   → 反射调用 writeReplace
//   → 提取 SerializedLambda
//   → 解析方法名 → "age"
//   → 构建 PropertyExpression
//   → 缓存到 Map<Class, Map<String, Expression>>
//   → 耗时 ~600 ns(JDK 8)/ ~25 ns(JDK 26)

// 后续调用 User::getAge
//   → 直接读缓存
//   → 耗时 ~1 ns

// 所以性能差异只在首次解析时体现
// 运行时的查询性能几乎无差别

后面做基准测试的时候,我会区分"冷启动(首次解析)“和"热路径(缓存读取)“两种场景。


下一篇:Method Reference 和 APT — 两种不解析 Lambda,但提取属性信息的编译期方案。