Java限制的原因

  首先我们先写一段代码:

1
2
3
4
5
6
7
8
9
public class Main {

public static void main(String[] args) {
int tmp = 10;
Supplier<Integer> lambda = () -> tmp = 0;
System.out.println(tmp.get());
}

}

  这段代码看起来好像没什么问题,但是却根本无法编译,编译器会告诉我们“lambda 表达式中使用的变量应为 final 或有效 final”。

  为什么会有这样的限制呢?

  其实这是为了“安全”考虑,在继续了解之前,我们要先了解一下在JVM中,变量都存储在哪些地方。

  为了简化模型,我们假设JVM讲内存分为两个区域:堆和栈。在JVM中,所有对象(不考虑虚拟机优化)都存储在堆中,类级别的变量(声明在类中的)也跟随对象一同存储在堆中,在堆栈中声明的变量(包含基础类型和对象引用)存储在栈中。例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Test {

private int example0 = 10;
private Object example1 = new Object();

public static void main(String[] args) {
int example2 = 5;
Test example3 = new Test();
int[] example4 = new int[5];
}

}

  读者不妨先思考一下,这些数据哪些存储在堆中、哪些存储在栈中。

  显而易见,example2example3example4的引用均存储在栈中,其余全部在堆中。

  接下来我们还需要了解JVM的内存管理机制,想要详细了解的可以见:《了解虚拟机之垃圾回收》

  简而言之,就是栈中的数据会随着方法的结束随栈帧一同被释放,而堆中的数据会等待GC去回收。

  现在,我们再写一段代码:

1
2
3
4
5
6
7
8
9
10
public class Main {

private Supplier<Integer> lambda;

public void text() {
int tmp = 10;
lambda = () -> tmp = 0;
}

}

  有些小伙伴可能已经看出问题了,编译器无法保证lambda仅在text中被访问,如果在text中初始化lambda,但是在text结束后再调用lambda.get(),那么此时tmp到底是什么呢?这是无法确定的,因为其所在内存已经被释放了,这个tmp就指向了一段不存在的内存。

  简单总结一下就是编译器无法保证lambda执行时其所引用的局部变量是否被销毁,因此如果允许这种操作,就非常容易出现各种奇奇怪怪的问题。

Kotlin的解决方案

  那么KT中为什么又允许这种操作呢?到底是如何做到的?我们来看一段代码:

1
2
3
4
5
6
fun main() {
var tmp = 10
val lambda = { tmp = 0 }
lambda()
println(tmp)
}

  这段代码不仅能够正常编译,并且可以正常运行。这里是tmp仍然存在的情况,如果lambda执行时tmp已经不存在了会怎么样呢?是否会报错?再看一段代码:

1
2
3
4
5
6
7
8
9
fun main() {
val lambda = test()
lambda()
}

fun test(): () -> Unit {
var tmp = 10
return { tmp = 0 }
}

  这段代码同样可以正常编译和运行。

  现在我们来揭开这段代码背后的秘密:

1
2
3
4
5
6
fun main() {
var tmp = 10
val lambda = { tmp = 0 }
lambda()
println(tmp)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final class MainKt {
public static final void main() {
final Ref.IntRef tmp = new Ref.IntRef();
tmp.element = 10;
Function0 lambda = (Function0)(new Function0() {
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}

public final void invoke() {
tmp.element = 0;
}
});
lambda.invoke();
int var2 = tmp.element;
System.out.println(var2);
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}
}

  这时我们可以发现,Kt实际上就是使用了一个语法糖,将tmp的值封装进了一个容器中,然后lambda表达式内部通过访问对象来访问tmp的值。

通用解决方案

  到这里一切谜团就解开了,实际上我们在使用Java进行开发时,解决这种问题一般也是采用这样的用法,这里我们列出几个解法:

Atom包装

  Java中创建了一系列Atomic类,这些类的目的是提供值的原子操作,但是也能用来当作容器使用:

1
2
3
4
5
6
7
8
9
10
public class Main {

public static void main(String[] args) {
AtomicInteger tmp = new AtomicInteger(10);
Supplier<Integer> lambda = () -> tmp.set(0);
lambda.get();
System.out.println(tmp.get());
}

}

自行创建容器类

  Atomic多多少少会有一点性能损失,实在介意的话也可以自己编写一个容器类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class IntWrapper {

private int value;

public IntWrapper() {}
public IntWrapper(int value) {
this.value = value;
}

public int get() {
return value;
}

public void set(int value) {
this.value = value;
}

}

数组包装

  数组包装是临时写起来比较方便的用法,但是容易混淆代码意图,不建议使用:

1
2
3
4
5
6
7
8
9
10
public class Main {

public static void main(String[] args) {
int[] tmp = {10};
Supplier<Integer> lambda = () -> tmp[0] = 0;
lambda.get();
System.out.println(tmp[0]);
}

}