前言
Java 语言里,当一个 Lambda 表达式引用了外部变量时,则将这个 Lambda 表达式则称为 capturing lambdas。
Lambda 可以引用的外部变量分为三种:
- 静态变量 & 实例变量
- 本地变量,并且必须是 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 类型的,而静态变量、实例变量则不存在该限制。
当外部本地变量为引用类型时,需要注意其内部状态的变化,否则可能会出现与其外的结果。
参考: