Lambda 表达式
Lambda 表达式为函数式接口提供实现,让一段行为可以作为参数或返回值传递。函数式接口约定一个需要实现的抽象方法,编译器据此确定 Lambda 的参数和返回类型。
本文从目标类型和常用接口入手,再解释变量捕获、this、方法引用和异常处理。最后介绍运行时实现,以及创建函数对象和调用函数时的求值差异。
1. 面试核心
先记住这几条,再读正文- 函数式接口。 函数式接口的抽象方法可以统一为一个方法,Lambda 按这个方法实现行为。卡 · 目标类型
- 目标类型。 Lambda 的参数、返回类型和可抛出的受检异常,由目标函数式接口的抽象方法确定。卡 · 目标类型
- 局部变量捕获。 Lambda 捕获的局部变量须为
final或有效 final,捕获引用指向的对象仍可修改。 - this 的含义。 Lambda 中的
this指向外层对象,匿名内部类中的this指向匿名类实例。 - 方法引用的绑定时机。 绑定实例的方法引用在创建时确定调用哪个对象,该对象为
null时会立即抛出异常。卡 · 方法引用的求值时机
2. 目标类型
编译器需要知道 Lambda 要实现哪个接口,才能检查这段行为是否符合要求。这个接口称为目标类型,可以由赋值语句提供:
Predicate<String> isBlank = value -> value.isBlank();
左侧的 Predicate<String> 确定了参数、返回类型和方法名:
- 参数类型是
String。 - 返回类型是
boolean。 - Lambda 要实现的方法是
test。
因此,示例中的 value 被推断为 String,isBlank() 的布尔结果也符合接口要求。Lambda 自身没有一个脱离接口的独立类型,必须从所在表达式获得目标类型。除了赋值,方法参数、返回值和显式类型转换也能提供目标类型。
比如,下面的方法声明返回 Predicate<String>,编译器就按这个接口检查返回的 Lambda:
static Predicate<String> longerThan(int length) {
return value -> value.length() > length;
}
调用 longerThan(3) 得到一个判断函数,执行它时会检查字符串长度是否大于 3。外层的 length 怎样被保留下来,后面的变量捕获一节会进一步说明。
2.1 函数式接口
函数式接口只要求实现一个抽象方法,用来定义这段行为的输入和输出。接口还须是非 sealed 接口,也就是未用 sealed 限定实现者范围。例如:
@FunctionalInterface
public interface Converter<S, T> {
T convert(S source);
}
这里的 convert 接收 S,返回 T,Lambda 只需实现这个转换。接口中的 default、static 和 private 方法已经有实现,所以不计入抽象方法数量。与 Object 公开方法签名相同的声明,也不增加需要 Lambda 实现的方法。
如果多个父接口声明了同签名抽象方法,返回类型也兼容,它们可以统一为一个方法。因此,函数式接口的判定看的是需要实现的抽象方法,不能只数接口中写了几条方法声明。
@FunctionalInterface 是可选注解,用来要求编译器检查上述规则。后续若误加第二个独立抽象方法,编译就会报错。因此,准备作为 Lambda 目标的公开接口应当添加它。
2.2 Lambda 与接口类型
同样一段 Lambda 可以分别匹配不同接口,但创建后的对象仍按各自接口使用:
Callable<String> task = () -> "done";
Supplier<String> supplier = () -> "done";
两段 Lambda 都没有参数,并返回字符串 "done"。编译器分别检查后,允许它们实现 Callable<String> 和 Supplier<String>。
两个接口仍有自己的类型和使用场景。Callable.call() 可以声明受检异常,Supplier.get() 则没有这种声明。因此,Lambda 写法相同,也无法把已有的 Callable<String> 对象直接赋给 Supplier<String>。
3. 常用函数式接口
选择函数式接口时,先看行为接收什么、返回什么,以及是否要修改外部状态。java.util.function 为常见需求提供了以下接口:
| 接口 | 抽象方法 | 语义 |
|---|---|---|
Function<T, R> | R apply(T value) | 把 T 转换为 R |
Predicate<T> | boolean test(T value) | 判断条件 |
Consumer<T> | void accept(T value) | 消费值并产生副作用 |
Supplier<T> | T get() | 延迟提供值 |
UnaryOperator<T> | T apply(T value) | 同类型转换 |
BinaryOperator<T> | T apply(T left, T right) | 合并两个同类型值 |
下面用已有方法实现其中四个接口。:: 是方法引用,表示把已有方法作为接口实现:
Function<User, String> name = User::displayName;
Predicate<User> active = User::active;
Consumer<User> audit = auditService::record;
Supplier<User> guest = User::guest;
这四个变量分别表示转换、判断、消费与提供结果:
name接收用户,返回显示名。active接收用户,返回是否活跃的布尔值。audit接收用户并记录审计信息,返回类型为void。guest在调用get()时才执行工厂方法,提供访客对象。
处理大量基本类型数据时,可使用 IntFunction、IntPredicate、IntConsumer 等专门接口。它们直接接收 int,省去把整数转换成 Integer 包装对象的装箱过程,从而减少分配开销。
3.1 函数组合
已有的判断或转换可以组合成新函数。比如,先检查用户是否活跃,再检查年龄;先取得显示名,再去掉首尾空格:
Predicate<User> activeAdult = User::active;
activeAdult = activeAdult.and(user -> user.age() >= 18);
Function<User, String> normalizedName = User::displayName;
normalizedName = normalizedName.andThen(String::trim);
and 要求两个判断都成立,第一个为 false 时会跳过第二个。andThen 则把前一个函数的返回值交给后一个函数,所以显示名取得后才执行 trim。
这种组合适合简短的纯转换,也就是只根据输入计算输出的行为。异常恢复、远程调用和多步状态修改涉及额外执行顺序,放在独立流程中更容易理解和调试。
4. 外层变量捕获
Lambda 可以读取创建位置之外的局部变量,这种保留外层变量值的行为称为捕获。被捕获的变量必须是 final 或有效 final。有效 final 指变量虽然没有写 final,但赋值后没有再修改:
int minimumLength = 3;
Predicate<String> accepted = value -> value.length() >= minimumLength;
// minimumLength = 5; // 编译错误:变量不再是有效 final
这里捕获了 minimumLength 的值 3。若取消最后一行的注释,变量就会再次赋值,编译器因此报错。
Lambda 的执行时间可以晚于外层方法返回,此时外层局部变量已经结束生命周期。捕获值让 Lambda 仍能使用创建时的参数。若允许双方继续修改同一个局部变量,就还需规定修改怎样共享、何时可见。有效 final 规则固定了捕获值,避免引入这套局部变量共享语义。
4.1 可变对象捕获
局部变量保存对象引用时,捕获的是这个引用。引用保持不变,它指向的对象仍然可以修改:
List<String> names = new ArrayList<>();
Consumer<String> addName = names::add;
addName.accept("Lin");
创建 names::add 时,方法引用立即保留 names 当时指向的列表。调用 addName.accept("Lin") 后,原列表就包含了 "Lin",因为两处使用的是同一个对象。
绑定方法引用无需局部变量满足有效 final。若改成 Lambda name -> names.add(name),被捕获的 names 才需要满足这个限制。两种写法都允许修改列表内容。
修改外部对象属于副作用。多个线程同时调用这个 Consumer,就会并发修改同一个 ArrayList,仍需同步或隔离状态。函数式代码采用无副作用转换时,更容易分别计算和组合结果。
4.2 成员字段访问
Lambda 可以读写当前对象的字段:
final class Counter {
private int value;
Runnable incrementer() {
return () -> value++;
}
}
这里保留的是外层 Counter 对象的引用。调用返回的 Runnable 时,Lambda 才访问并增加这个对象的 value 字段,所以字段可以变化。
多个线程同时执行 value++ 时,读取、加一和写回会相互交错,仍会产生数据竞争。Lambda 没有为字段访问增加同步,需要使用与普通方法相同的并发保护。
5. this 的含义
Lambda 中的 this 沿用外层作用域的含义。在实例方法里创建 Lambda 时,它指向外层对象:
final class Service {
Runnable task() {
return () -> System.out.println(this.getClass().getSimpleName());
}
}
执行这个任务会打印 Service,因为 this 指向创建任务的 Service 对象。匿名内部类中的 this 则指向匿名类实例。把匿名内部类改写成 Lambda 时,若代码使用了 this 或 super,就要检查访问的对象是否随之改变。
Lambda 实现对象的具体类名、对象身份和序列化形式,由编译器及运行时实现决定。JVM 可以采用不同策略,所以业务代码应依赖接口定义的行为。
6. 方法引用
已有方法能直接完成所需行为时,可以用方法引用省去重复的调用表达式。常见写法有四种形式:
Function<String, Integer> parse = Integer::parseInt; // 静态方法
Supplier<String> getName = user::displayName; // 特定对象实例方法
Function<String, String> trim = String::trim; // 任意对象实例方法
Supplier<List<String>> listFactory = ArrayList::new; // 构造方法
user::displayName 已经绑定了具体的 user 对象,所以调用 getName.get() 时无需再传入用户。String::trim 则把函数的第一个参数作为接收者,也就是实际调用方法的对象。它可以理解为 value -> value.trim()。
目标接口已知、参数对应关系直观时,方法引用能让代码更简洁。需要重新排列参数、补充条件或处理异常时,普通 Lambda 更容易把逻辑写清楚。
7. 异常与 Lambda
Lambda 也遵守 Java 的受检异常规则。受检异常要求调用方处理或声明抛出,因此目标接口必须允许传播该异常:
Callable<String> callable = () -> Files.readString(path);
// Supplier<String> supplier = () -> Files.readString(path);
// 编译错误:Supplier.get 不声明 IOException
Files.readString(path) 声明了受检异常 IOException。Callable.call() 声明了 throws Exception,所以第一行可以编译。Supplier.get() 没有声明受检异常,因此注释中的赋值会编译失败。
需要传播 I/O 失败时,可以定义带明确异常声明的业务接口。也可以在调用边界转换成有业务含义的异常,保留失败原因。若 I/O 与数据转换是两个阶段,将它们拆开会更容易处理重试和恢复。
受检异常的传播规则见 Java 异常。
8. 运行时实现
理解运行时实现,有助于判断 Lambda 是否会分配对象,以及能否依赖它的对象身份。OpenJDK 的 javac 使用 invokedynamic 表达创建 Lambda 的调用位置。invokedynamic 是一种在运行时链接目标实现的字节码指令。
执行到这里时,LambdaMetafactory 等引导逻辑负责链接接口实现。所以,编译结果无需为每个 Lambda 生成一个普通匿名内部类文件。
无捕获 Lambda 可以复用实例,有捕获 Lambda 则需保留捕获值。实际分配与优化还会受到捕获状态和调用热度影响,语言规范没有保证每次是否复用对象。因此,业务代码不能用 == 判断两个 Lambda 的行为是否相同。
对日常开发更重要的是目标类型和行为契约。只有排查性能、字节码或框架兼容问题时,才需要进入 invokedynamic 和调用点细节。
9. 方法引用的求值时机
方法引用和 Lambda 看起来可以互换,创建时的求值时机却有区别。绑定实例的方法引用会立即计算接收者表达式,确定要调用哪个对象。Lambda 则在执行函数体时,才计算函数体中的表达式:
String text = null;
// Supplier<Integer> first = text::length; // 创建时抛 NullPointerException
Supplier<Integer> second = () -> text.length(); // 创建成功
// second.get(); // 调用时抛 NullPointerException
示例中的 text 为 null。创建 text::length 时就要检查接收者,因此立即抛出 NullPointerException。Lambda 可以创建成功,直到调用 second.get() 才访问 text.length(),这时才抛出异常。
对 holder.current::run,创建时就固定了字段当时指向的对象。对 () -> holder.current.run(),执行时才读取 current 字段。若字段在此期间指向另一个对象,两者就会调用不同对象。接收者表达式若有副作用,也会在各自的求值时机发生。
10. 常见问题
下面补充接口继承、循环捕获和序列化中的边界,避免把“能编译”直接当成“适合这样使用”。
10.1 函数式接口能有多个方法吗?
可以包含多个默认、静态和私有方法,它们已经有实现。多个父接口的同签名抽象方法,只要返回类型兼容,也可以统一实现。若两个方法参数相同但返回类型互不兼容,就无法统一为一个供 Lambda 实现的方法。
10.2 Lambda 一定比匿名内部类快吗?
语法本身无法决定快慢。对象分配和 JIT 即时编译优化,取决于捕获状态、调用目标是否稳定,以及 JDK 版本。开发时先按 this 的含义、接口需求和可读性选择,再对热点代码做基准测试。
10.3 为什么循环变量有时可以被捕获?
增强 for 每次迭代都有新的局部变量,捕获的是当次迭代的值。只要循环体内没有再次给这个变量赋值,它就符合有效 final 要求。普通 for 会持续修改同一个索引变量,所以无法直接捕获。可以在循环体内把索引复制到新的 final 或有效 final 局部变量,再捕获这个副本。
10.4 Lambda 可以序列化吗?
目标类型实现 Serializable 时,Lambda 才具备序列化资格,捕获的对象也需满足序列化要求。不过,生成格式与捕获实现会受到代码和运行时变化影响,不适合作为长期协议。任务持久化应保存任务类型与业务参数,再由稳定代码重建行为。
11 · 面试题卡片
11.1 什么是函数式接口,Function、Consumer 和 Supplier 有什么区别
出现公司:快手、印宏网络科技
考察重点
- 说明函数式接口怎样用一个抽象方法约定 Lambda 的行为。
- 根据输入、输出和副作用区分常用接口。
- 解释默认方法为什么不增加需要 Lambda 实现的方法。
参考回答
函数式接口只要求实现一个抽象方法,Function 用于输入到输出的转换,Consumer 消费输入,Supplier 提供输出。
Lambda 按这个方法确定参数、返回类型和可抛出的受检异常。接口可以包含已有实现的默认、静态或私有方法,@FunctionalInterface 则要求编译器检查函数式接口资格。
选择常用接口时,区分它们的输入与返回结果:
Function<T,R>接收一个 T,转换后返回 R。Consumer<T>接收一个 T,执行写日志等操作,返回类型为void。Supplier<T>没有参数,调用get()时提供一个 T。
11.2 Lambda 表达式依赖什么目标类型,平时怎样使用
出现公司:用友
考察重点
- 解释编译器怎样根据函数式接口检查 Lambda。
- 按转换、判断、消费和提供结果的需求选择接口,并判断是否适合方法引用。
- 说明有效 final 限制约束的是局部变量,还是变量指向的对象。
参考回答
Lambda 依赖函数式接口作为目标类型,编译器从赋值、方法参数或返回值等位置取得这个类型。
接口的抽象方法约定参数、返回类型和可传播的异常,所以同一段 Lambda 可以分别匹配不同接口。数据转换用 Function,条件判断用 Predicate。消费输入用 Consumer,延迟提供结果用 Supplier。已有方法与参数直接对应时,还可以使用方法引用。
Lambda 可以捕获 final 或有效 final 的局部变量。捕获对象引用后,引用固定,但对象状态仍可变化。因此,并发调用时仍要同步或隔离可变对象。
11.3 obj::method 和 () -> obj.method() 的异常时机相同吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释方法引用和 Lambda 分别何时计算接收者,并据此判断空指针异常和字段变化的影响。
相关内容:方法引用的求值时机。
参考回答
异常时机不同:obj 为 null 时,obj::method 在创建阶段就抛出异常,Lambda 则在调用时抛出。
绑定方法引用需要立即确定并检查接收者,Lambda 的函数体则延迟到调用时执行。若接收者来自可变字段,前者保留创建时的对象,后者读取执行时的字段值。接收者表达式中的副作用,也分别发生在这两个时机。