我们一遍一遍重复讲到,多个线程同时访问共享变量时,会导致并发问题,那在Java 语言里,是不是所有的变量都是共享变量呢?工作中我发现不少同学会给方法里的局部变量设置同步,显然这些同学并没有把共享变量说清楚。那么Java 方法里的局部变量是否存在并发问题呢?
其实很多人都知道局部变量是不存在数据竞争的,至于原因嘛,就说不清楚了。
那它背后的原因到底是什么样的呢?要弄清楚这个,你需要一点编译原理的知识,你知道在CPU 层面,是没有方法概念的。CPU 的眼里只有一条条的指令。编译程序,负责把高级语言里的方法转换成一条条的指令。所以你可以站在编译器实现的角度来思考,怎么完成方法到指令的转换?
高级语言的普通语句里,翻译成 CPU 的指令相对简单,可方法的调用就比较复杂了。例如下面这三行代码:第 1 行,声明一个 int 变量 a;第 2 行,调用方法 fibonacci(a);第 3 行,将 b 赋值给 c。
int a = 7;
int[] b = fibonacci(a);
int[] c = b;
当你调用 fibonacci(a) 的时候,CPU 要先找到方法 fibonacci() 的地址, 然后跳转到这个地址去执行代码,最后 CPU 执行完方法 fibonacci() 之后,要能够返回。 首先找到调用方法的下一条语句地址,:也就是int[] c=b;的地址,再跳转到这个地址去执行。 你可以参考下面这个图再加深一下理解。
到这里,方法调用的过程你就清楚了,但还有一个重要问题,“CPU 去哪里找到调用方法的参数和返回地址?” 如果你熟悉CPU的工作原理,你应该立刻会想到:通过CPU的堆栈寄存器。CPU 支持一种栈结构,先入后出。因为这个栈是和方法调用相关的,所以叫做调用栈。
例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
利用栈结构来支持方法调用这个方案非常普遍,以至于 CPU 里内置了栈寄存器。虽然各家编程语言定义的方法千奇百怪,但是方法的内部执行原理却是出奇的一致:都是靠栈结构解决的。Java 语言虽然是靠虚拟机解释执行的,但是方法的调用也是利用栈结构解决的。
我们已经知道方法的调用在CPU 眼里是怎么执行的。但还有一个关键问题:方法内的局部变量存哪里?
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了。局部变量应该和方法共生死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里。于是调用栈的结构就变成了下图这样。
这个结论相信很多人都知道,因为学 Java 语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
两个线程可以同时用不同的参数调用相同的方法。那调用栈跟线程是一种什么关系呢?答案:每个线程都有自己独立的调用栈。如果不是这样,那么线程就相互干扰了,如下图所示:
现在,让我们回过头来再看篇首的问题:Java 方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。再次重申一遍:没有共享,就没有伤害。
方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经称为解决并发问题的一种途径。同时还有个响亮的名字叫做 线程封闭。
即 仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会出现并发问题。