跳到主要内容
约 12 分钟3 张卡

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 一定比匿名内部类快吗?​

10.3 为什么循环变量有时可以被捕获?​

10.4 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 的函数体则延迟到调用时执行。若接收者来自可变字段,前者保留创建时的对象,后者读取执行时的字段值。接收者表达式中的副作用,也分别发生在这两个时机。

12. 参考资料​