Java并发编程(十二)Java 中共享变量的内存可见性问题

逆流者 2021年02月21日 65次浏览

此文为读书笔记,欢迎评论,谈论问题,共同进步!


Java 的内存模型

先看下多线程下处理共享变 Java 的内存模型:
在这里插入图片描述
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间(工作内存),线程读写变量时操作的是自己工作内存中的变量。

Java内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?

双核 CPU 系统架构

上图是一个双核 CPU 系统架构,每个核有自己的控制器运算器,其中控制器包含一组寄存器操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 都共享的二级缓存
那么 Java内存模型里面的工作内存,就对应这里的 L1或者 L2 缓存或者 CPU 的寄存器。

当一个线程操作共享变量时,它首先从主内存复制共享变量到自己的工作内存,然后对工作内存里的变量进行处理,处理完后将变量值更新到主内存。

根据上图,我们假设线程A和线程B同时处理一个共享变量,假设线程A和线程B使用不同 CPU执行,并且当前两级Cache都为空,那么这时候由于Cache 的存在,将会导致内存不可见问题,下面具体分析:

  • 线程A首先获取共享变量X的值,由于两级 Cache 都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级 Cache,并且刷新到主内存。线程A操作完毕后,线程 A所在的 CPU 的两级 Cache 内和主内存里面的 X 的值都是 1。
  • 线程B 获取 X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=1。然后线程B修改X的值为2,并将其存放到线程B所在的一级Cache和共享二级Cache中,最后更新主内存中 X的值为2; 到这里一切都是好的。
  • 线程A这次又需要修改X的值,获取时一级缓存命中,并且X=1,到这里问题就出现了,明明线程B已经把X的值修改为了2,为何线程A获取的还是1呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

了解上面这些,下面我们看一个线程不安全的例子

线程不安全的例子

public class ThreadNotSafeInteger implements Runnable{

    private int value;

    public int getValue() {
        return value;
    }

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

    @Override
    public String toString() {
        return "ThreadNotSafeInteger{" +
                "value=" + value +
                '}';
    }

    @Override
    public void run() {
        for (int i = 0; i < 20000; i++) {
            value++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadNotSafeInteger threadNotSafeInteger = new ThreadNotSafeInteger();
        Thread threadA = new Thread(threadNotSafeInteger);
        Thread threadB = new Thread(threadNotSafeInteger);

        threadA.start();
        threadB.start();

        threadA.join();
        threadB.join();

        System.out.println(threadNotSafeInteger);
    }
}

ThreadNotSafeInteger{value=27124}

看执行结果:value=40000 才对,为啥执行结果不是我们所预期的呢!

那如何解决共享变量内存不可见问题?

Java 中的 synchronized 关键字

synchronized 关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用。
线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

拿到内部锁的线程会怎样释放锁?

  • 正常退出同步代码块
  • 抛出异常
  • 在同步块内调用了该内置锁资源的wait 系列方法

另外,由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。

synchronized 可以解决内存可见性问题

synchronized 有一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在 synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。
除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。
但是,synchronized 关键字会引起线程上下文切换并带来线程调度开销。

使用 synchronized 方式改动上面的代码示例:

public class ThreadSafeIntegerSynchronized implements Runnable {

    private int value;

    public int getValue() {
        return value;
    }

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

    @Override
    public String toString() {
        return "ThreadSafeIntegerSynchronized{" +
                "value=" + value +
                '}';
    }

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 20000; i++) {
                value++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadSafeIntegerSynchronized threadSafeInteger = new ThreadSafeIntegerSynchronized();
        Thread threadA = new Thread(threadSafeInteger);
        Thread threadB = new Thread(threadSafeInteger);

        threadA.start();
        threadB.start();

        threadA.join();
        threadB.join();

        System.out.println(threadSafeInteger);
    }
}
ThreadSafeIntegerSynchronized{value=40000}

执行结果达到了我们的预期

Java 中的 volatile 关键字

synchronized 方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用 volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。
当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
volatile的内存语义和synchronized有相似之处,具体来说就是,当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。

使用 volatile 关键字 方式改动上面线程不安全的代码示例:

public class ThreadNotSafeIntegerVolatile implements Runnable {

    private volatile int value;

    public int getValue() {
        return value;
    }

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

    @Override
    public String toString() {
        return "ThreadNotSafeIntegerVolatile{" +
                "value=" + value +
                '}';
    }

    @Override
    public void run() {
        for (int i = 0; i < 20000; i++) {
            value++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadNotSafeIntegerVolatile threadNotSafeInteger = new ThreadNotSafeIntegerVolatile();
        Thread threadA = new Thread(threadNotSafeInteger);
        Thread threadB = new Thread(threadNotSafeInteger);

        threadA.start();
        threadB.start();

        threadA.join();
        threadB.join();

        System.out.println(threadNotSafeInteger);
    }
}

ThreadNotSafeIntegerVolatile{value=27367}

看执行结果发现,使用volatile 关键字并不能保证线程安全,上面不是说volatile 关键字修饰的变量是内存可见的呀,为啥执行结果不是我们的预期呢?
这个是因为,虽然内存是可见,但是volatile 关键字并不能保证原子性(读-改-写,这三步要么全部成功,要么全部失败),后面博文会详细说明原子性。

下面再给一个例子:来证明volatile 关键字来保证内存可见性

public class VolatileTest {

    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo();
        Thread threadA = new Thread(td);
        threadA.start();

        while (true) {
            if (td.getFlag()) {
                System.out.println("main thread 看到了 子线程 threadA 改变后的flag的标志:" + td.getFlag());
                break;
            }
        }
    }
}

class ThreadDemo implements Runnable {

    private volatile boolean flag = false;

    @Override
    public void run() {

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;

    }

    public boolean getFlag() {
        return flag;
    }

}
main thread 看到了 子线程 threadA 改变后的flag的标志:true

总结

  • synchronized和volatile都解决了共享变量value的内存可见性问题
  • synchronized是独占锁,同时只能有一个线程获取此锁,其他调用线程也访问此锁会被阻塞,同时会存在线程上下文切换和线程重新调度的开销
  • synchronized 保证了原子性,是线程安全的;
  • volatile虽然提供了可见性保证,但并不保证操作的原子性。

引申一下:

一般在什么时候才使用 volatile 关键字呢?

  • 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取一计算一写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。