Lambda 表达式引用外部变量的限制

Posted on Sat, Nov 27, 2021 Java

前言

Java 语言里,当一个 Lambda 表达式引用了外部变量时,则将这个 Lambda 表达式则称为 capturing lambdas

Lambda 可以引用的外部变量分为三种:

  1. 静态变量 & 实例变量
  2. 本地变量,并且必须是 final 或者 effectively final。

其中静态变量 & 实例变量在 Lambda 中可以进行修改,而本地变量则不行(final or effectively final)。接下来主要介绍一下什么是 effectively Final,以及在单线程与多线程等各个场景下的影响。

effectively Final

effectively final 是 Java 8 推出的一个 feature,主要目的是为了能够借助语法限制和编译器,减少一些编码时容易出错的地方。

简单来说,当一个对象或者原始类型在赋值后不再修改,则可以认为是 effectively final。如果对象内部状态发生变化但其引用并未重新赋值,仍然可以当作 effectively final。

基于 effectively final 的特性,Java 8 中不允许在匿名类、内部类和 Lambda 表达式中访问 non-final 以及 non-effectively-final 的外部本地变量

以下面代码为例:

 @FunctionalInterface
 public interface FunctionalInterface {
     void testEffectivelyFinal();
     default void test() {
         int effectivelyFinalInt = 10;
         FunctionalInterface functionalInterface
             = () -> System.out.println("Value of effectively variable is : " + effectivelyFinalInt);
     }
 }

一旦对 effectivelyFinalInt 进行重新赋值,那么编译器将会抛出一个错误。

需要注意的是,Java 编译器不会像 final 一样会对 effectively final 添加什么额外的优化,比如下面是对于 final 的优化,但对 effectively final 不会生效:

 public static void main(String[] args) {
     final String hello = "hello";
     final String world = "world";
     String test = hello + " " + world;
     System.out.println(test);
 }
 
 // final 优化为
 public static void main(String[] var0) {
     String var1 = "hello world";
     System.out.println(var1);
 }

单线程场景

Lambda 表达式使用一个外部变量时,本质上是对这个变量的拷贝。考虑如下代码:

 Supplier<Integer> incrementer(int start) {
   return () -> start++;
 }

该函数会返回一个 Lambda 表达式,当退出这个函数时,start 被回收(start 的生命周期只在这一个函数栈中),而 Lambda 表达式作为返回值,其生命周期已经超出了这个函数,因此需要拷贝一个 start 的副本。

多线程场景

接下来再看一个与外部变量保持“连接” 的例子:

 // 无法通过编译
 public void localVariableMultithreading() {
     boolean run = true;
     executor.execute(() -> {
         while (run) {
             // do operation
         }
     });
     // do something, then
     run = false;
 }

上面的代码明显存在一个问题,就是当前 run 的作用域只存在于当前的栈中,外部无法对其进行修改,从而控制线程内的行为。由于 Java 只允许 Lambda 表达式引用 final 或者 effectively final 的外部本地变量,因此可以在编译期就避开这种错误写法。

NOTE:上述代码还存在可见性、JIT 优化等问题,但不是关注的重点。

静态变量与实例变量

与外部本地变量不同,静态变量与实例变量却可以在 lambda 进行修改:

 private int start = 0;
 
 Supplier<Integer> incrementer() {
     return () -> start++;
 }
 
 // and
 private volatile boolean run = true;
 
 public void instanceVariableMultithreading() {
     executor.execute(() -> {
         while (run) {
             // do operation
         }
     });
 
     run = false;
 }

其实原因也很简单,本地变量的生命周期在栈中,而静态变量与实例变量都是在堆上,这时候就需要使用者自身去考虑线程安全、可见性等变量状态的问题,因此没必要加以限制。

因此,当在 Lambda 表达式中访问一个外部引用类型的对象时,需要注意这一点,采取一些手段(如不可变对象、不可变集合等)避免出现预期外的错误。

Java 编译器的局限

虽然前面提到的 Lambda 表达式对于外部本地变量的限制,都是通过 Java 编译器来提供检查功能的,但对于引用类型,其内部变化是 Java 编译器无法触及的,因此会出现一些绕开 final 与 effectively final 的操作:

 public void workaroundMultithreading() {
     int[] holder = new int[] { 2 };
     Runnable runnable = () -> System.out.println(IntStream
       .of(1, 2, 3)
       .map(val -> val + holder[0])
       .sum());
 
     new Thread(runnable).start();
 
     // simulating some processing
     try {
         Thread.sleep(new Random().nextInt(3) * 1000L);
     } catch (InterruptedException e) {
         throw new RuntimeException(e);
     }
 
     holder[0] = 0;
 }

这段代码,holder 是一个数组对象,内部状态发生了改变,但在该例子中,方法最终打印的值,可能是 6,也可能是 12,完全取决于 holder 的状态变化发生在子线程之前还是之后,输出值并不一定是直觉上的 12。

总结

Java 8 中,Lambda 表达式,包括匿名内部类、内部类,访问外部本地变量时,该变量必须是 final 或者 effectively final 类型的,而静态变量、实例变量则不存在该限制。

当外部本地变量为引用类型时,需要注意其内部状态的变化,否则可能会出现与其外的结果。

参考: