【Java并发基础】局部变量是线程安全的

前言

方法中的变量(即局部变量)是不存在数据竞争(Data Race)的,也是线程安全的。为了理解为什么,我们先来了一下方法是如何被执行的,然后再分析局部变量的安全性,最后再介绍利用局部变量不会共享的特点而产生的解决并发问题的一些技术。

方法是如何被执行的

int a = 7;
int[] b = fibonacci(a);
int[] c = b;

以上代码转换成CPU指令执行,方法的调用过程示意图如下:(图来自参考[1])

当调用fibonacci(a)时,CPU要先找到方法fibonacci()的地址(在CPU堆栈寄存器中),然后跳转到这个地址去执行代码(蓝色线),最后CPU执行完方法,再返回原来调用方法的下一条语句(红色线)。

CPU找调用方法的参数和返回地址,是通过堆栈寄存器。CPU支持一种线性结构,因为与方法调用有关,所以也称为调用栈

再举个例子,有三个方法A、B、C。方法A中调用方法B,方法B中调用方法C。那么将会构建出如下调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧。每个栈帧都有对应方法需要的参数和返回地址。当调用新方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。即,栈帧和方法同生共死。

三个方法生成的调用栈如上图所示。

不同的编程语言虽定义方法虽各有所异,但是它们执行方法的原理却是一致的:都是依靠栈结构解决。Java语言虽然是靠虚拟机解释执行,但是方法的调用也是利用栈结构解决的。

局部变量的存放位置

局部变量是定义在方法内,作用域也是在方法内部。当方法运行结束后,局部变量也就失效了。那么我们可以得出,局部变量的存放位置应该在调用栈中。事实上,局部变量就是存放到调用栈中的

调用栈与线程

两个线程可以同时用不同的参数调用相同的方法,那么调用栈和线程之间是什么关系呢?答案就是:每个线程都有自己独立的调用栈

所以,Java方法里面的局部变量是不存在并发问题的。每个线程都有自己独立的调用栈,局部变量保存在各自的调用栈中,不会被共享,自然也就没有并发问题。

利用不共享解决并发问题的技术: 线程封闭

当多线程访问没有同步的可变共享变量时就会出现并发问题,而解决方案之一便是使变量不共享。变量不会和其他变量共享,也就不会存在并发问题。仅在单线程里访问数据,不需要同步,我们称之为线程封闭。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。

采用线程封闭技术的案例非常多。例如一种常见的应用便为JDBC的Connection对象。从数据库连接池中获取一个Connection对象,在JDBC规范中并没有要求这个Connection一定是线程安全的。数据库连接池通过线程封闭技术,保证一个Connection对象一旦被一个线程获取之后,在这个Connection对象返回之前,连接池不会将它分配给其他线程,从而保证了Connection对象不会有并发问题。

线程封闭技术的一个具体实现是我们上面提到的局部变量的使用(栈封闭),还有一种需要提一下,即ThreadLocal类。

ThreadLoacl类

维持线程封闭性一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象相关联起来。ThreadLocal提供了get()set()等访问接口,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get()总是返回由当前执行线程在调用set()时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享
例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在线程启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。由于JDBC的连接对象不一定线程安全的,因此,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

如以下代码所示,利用ThreadLocal来维持线程的封闭性:(代码来自参考[2])

public class ConnectionDispenser {
    static String DB_URL = "jdbc:mysql://localhost/mydatabase";

    private ThreadLocal<Connection> connectionHolder
        = new ThreadLocal<Connection>() {
        public Connection initialValue() {
            try {
                return DriverManager.getConnection(DB_URL);
            } catch (SQLException e) {
                throw new RuntimeException("Unable to acquire Connection, e");
            }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用这项技术。例如,在Java 5.0之前,Integer.toString()方法使用ThreadLocal对象来保存一个12字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(需要使用加锁机制)或者每次调用时都分配一个新的缓冲区。

小结

知道方法是如何调用的也就明白了局部变量为什么是线程安全的。方法调用会产生栈帧,局部变量会放在栈帧的工作内存中,线程之间不共享,故不存在线程安全问题。后面我们介绍了基于不共享解决并发问题的线程封闭技术,除了不共享这种思想可以解决并发问题,还有两种:使用不可变变量和正确使用同步机制。

参考:
[1]极客时间专栏王宝令《Java并发编程实战》
[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

posted @ 2020-02-05 17:05  sakuraxx  阅读(4931)  评论(0编辑  收藏  举报