×
广告

深入浅出 Java 并发编程 (2)

96
蒋古申
2018.08.22 19:51 字数 4079

本文目录

  • Java 内存模型与可见性
  • 指令重排序
  • 使用 volatile 关键字保证可见性
  • 使用 synchronized 关键字保证可见性
  • synchronized 和 volatile 关键字的异同

Java 内存模型与可见性

上一篇文章主要介绍了 synchronized 关键字的使用,synchronized 关键字本质是互斥锁,保证了程序在不同线程之间执行的顺序以及同步。对于 Java 程序之中的变量,在不同的线程之中,还有一个关键的性质需要了解:可见性

那么什么是可见性呢?

在理解可见性之前我们需要稍微了解一下 Java 的内存模型 (JMM),所谓 Java 内存模型,实际上指的是 Java 用于管理内存的一种规范,它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取变量这样的底层细节。对于 Java 线程来说,Java 内存模型主要把内存分成了两类:

  • 主内存:主要对应于Java堆中的对象实例数据部分
  • 线程工作内存 (本地内存):对应于虚拟机栈中的部分区域,是JMM的一个抽象概念,并不真实存在

在理解这两个内存的时候,我曾一直想把他们和之前提到过的 堆内存 和 栈内存 进行比较,但是实际上来说,主内存和工作内存与堆、栈内存并没有什么直接的联系。关于这几种内存联系的争论,可以参考这个知乎问答:

JVM中内存模型里的『主内存』是不是就是指『堆』,而『工作内存』是不是就是指『栈』?

言归正传,我们可以用一个简单的抽象示意图来理解 Java 内存模型:

Java 内存模型抽象示意图

从上面的图可以看到,假设有三个线程Thread1Thread2Thread3,它们在运行的过程中都会对变量 a 进行一定程度的操作,这些操作都是基于 JMM 给出的规定:

  • 所有的变量都存储在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写
  • 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

也就是说,线程想要对变量 a 进行操作,首先得从主内存之中获取一个 a 的副本,然后在自己的本地内存(工作内存)之中对 a 的副本进行修改。当修改操作完成以后,再将本地内存中的 “新版a” 更新到主内存之中。

说了这么多,这些东西和可见性有什么关系呢?我们先看下面的图:

线程之间通信

在图中,一开始Thread1Thread2都从主内存中获取了共享变量a的一个副本:a1a2,它们的初始值满足:a1 = a2 = a = 0,但是随着线程操作的进行,Thread2a2的值改为了1,由于线程1和线程2之间的不可见性,所以造成了a1a2值不一致,为了解决这个问题,线程2需要把自己修改过的a2先同步到主内存中(如图中红色箭头所示),然后再经由主内存刷新到Thread1中,这就是 Java 内存模型中线程同步变量的方法。

所以稍微总结一下,可见性指的是在不同的线程之中,一个线程对共享变量值的修改,能够及时地被其他线程看到。而线程1对共享变量的修改要想被线程2及时看到,必须要经过如下2个步骤:

  1. 把工作内存1中更新过的共享变量刷新到主内存中
  2. 将主内存中最新的共享变量的值更新到工作内存2中

指令重排序

在多线程环境里,除了 Java 线程本地工作内存造成的不可见性,指令重排序也会对线程间的语意和运行结果造成一定程度的影响。那么,什么是重排序?

以前有一句古话 “所见即所得” ,但是在计算机程序执行的时候却不是这个样子的,为了提高程序的性能,编译器或处理器会对程序执行的顺序进行优化,使得代码书写的顺序与实际执行的顺序未必相同

指令重排序

而计算机程序重排序主要又可以分为以下几类:

  • 编译器优化的重排序(编译器优化)
  • 指令集并行重排序(处理器优化)
  • 内存系统的重排序(处理器优化)

虽然代码执行不一定按照其书写顺序执行,但是为了保证在单线程中代码最终输出结果不会因为指令重排序而改变,编译器、运行时环境和处理器都会遵循一定的规范,这里主要是指 as-if-serial语义happens- before的程序顺序规则

as-if-serial语义: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,我们继续使用上面的例子:

int A = 1; // 1
int B = 2; // 2
int C = A + B; // 3

其中第一行和第二行执行的结果之间不存在数据的依赖性,因为第一行第二行的成功运行不需要对方的计算结果,但是第三行C的计算结果却是依赖于AB的。这个依赖关系可以用下面的示意图表示:

依赖关系

所以根据依赖关系,as-if-serial语义将会允许上述程序的第一行和第二行进行重排序,而第三行的执行一定会放在前两行程序之后。as-if-serial 语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、运行时环境和处理器共同为编写单线程程序的程序员们创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

然而在多线程情况下就不是这么简单的了,指令重排序有可能会导致交叉工作的线程在执行完相同的程序之后得到不同的结果。为此我们可以看一下下面的这个小程序:

public class Test {
    int count = 0;
    boolean running = false;

    public void write() {
        count = 1;                  // 1
        running = true;             // 2
    }

    public void read() {
        if (running) {                // 3
            int result =  count++;    // 4
        }
    }
}

这里我们定义了一个布尔值标记 running ,用来表示变量 count 的值是否已经被写入。我们假设这里现在有两个线程(分别为Thread1Thread2),Thread1 首先执行 write(),对变量 count 进行写入,然后Thread2 随即执行read()方法,那么,当Thread2运行到第四行的时候,是否能够看到Thread1对变量count进行的写入操作呢?

答案是不一定能够看得见。

我们对write()来分析,语句1语句2实际上并没有数据依赖关系,根据as-if-serial 语义,这两行代码在实际运行的时候很可能会被重排序过。同样的,对read()方法来说,if(runnig)int result = count++; 这两个语句也没有数据依赖关系,也会被重排序。那么对于线程Thread1Thread2来说,语句1语句2被重排序的时候,程序执行会出现如下的效果:

可能出现的一种执行顺序

在这种情况下,count++ 这句话在 Thread2 里面比在 Thread1count = 1 更早得到了执行,相比于重排序之前,这样得到的 count 最终的值为1,而不进行重排序的话结果是2,如此一来,重排序在多线程环境中破坏了原有的语意。同样,对于语句3语句4,大家也可以对重排序是否会导致线程不安全做出类似的分析(先考虑数据依赖关系和控制流程依赖关系)。

使用 volatile 关键字保证可见性

为了解决 Java 内存模型之中多线程变量可见性的问题,在上一篇文章中,我们可以利用synchronized互斥锁的特性来保证多线程之间的变量可见性。

但是之前也有提到,synchronized关键字实际上是一种重量级的锁,为了在这种情况下优化它,我们可以使用volatile关键字。volatile关键字可以修饰变量,一个被其修饰的变量将会具有如下特性:

  • 保证了不同线程对这个变量进行操作时的可见性(一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的)

  • 禁止进行指令重排序

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。另外的,当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。这也是为什么volatile关键字能够保证不同线程对同一个变量的可见性。

关于volatile的底层实现,我不打算深究,但是可以简要的了解一下:如果把加入volatile关键字的代码和未加入volatile关键字的代码都生成汇编代码,会发现加入volatile关键字的代码会多出一个lock前缀指令

那这个lock前缀指令是干嘛用的呢?

  • 重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 使得本CPU的 cache 写入内存
  • 写入动作也会引起别的CPU或者别的内核无效化其cache,相当于让新写入的值对别的线程可见

说了那么多,volatile的使用其实很简单,让我们一起来看个demo:

public class VolatileUse {

    private volatile boolean running = true; // 对比一下有无 volatile 关键字的时候,运行结果的差别。

    void m() {
        System.out.println("m start...");
        while (running) {

        }
        System.out.println("m end...");
    }

    public static void main(String[] args) {
        VolatileUse t = new VolatileUse();
        new Thread(t::m, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t.running = false;
    }
}

在这个小程序中,如果对 running 加上了 volatile关键字,那么最后处于主线程的操作t.running = false; 将会被 线程t 所看到,从而打破死循环,使方法m()正常结束。如果不加关键字,那么程序将一直卡在m()方法的死循环中,永远也不会输出m end...

那么volatile关键字能不能取代synchronized呢?我们再来看一个demo:

import java.util.ArrayList;
import java.util.List;

/**
 * volatile 关键字,使一个变量在多个线程间可见。
 * volatile 只有可见性,synchronized 既保证了可见性,又保证了原子性,但是效率远不如 volatile。
 *
 * @author huangyz0918
 */
public class VolatileUse02 {

    volatile int count = 0;

    void m() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
    }

    public static void main(String[] args) {
        VolatileUse02 t = new VolatileUse02();
        List<Thread> threads = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            threads.add(new Thread(t::m, "thread-" + i));
        }

        threads.forEach((o) -> o.start());

        threads.forEach((o) -> {
            try {
                o.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        System.out.println(t.count);
    }
}

尝试运行了一下:

94141

再运行一次:

97096

我们可以看到两次运行的结果不同,并且都没有达到理论上所需要达到的目标值:100000。这是为什么呢?(count++语句包含了读取count的值,自增,重新赋值操作)

可以这样理解:有两个线程 (线程A 和 线程B) 都对变量count进行自加操作,如果某一个时刻线程 A 读取了count的值为100,这时候被阻塞了,因为没有对变量进行修改,触发不了volatile的规则。

线程B 此时也读读count的值,主内存里count的值依旧为100,做自增,然后立刻就被写回主存了,为101。此时又轮到 线程A 执行,由于工作内存里保存的是100,所以继续做自增,再写回主存,101又被写了一遍。所以虽然两个线程执行了两次自增操作,结果却只加了一次。

有人说,volatile不是会使缓存行无效的吗?但是这里从线程A开始读取count的值一直到 线程B 也进行操作之前,并没有修改count的值,所以 当线程B 读取的时候,还是读的100。

又有人说,线程B将101写回主内存,不会把线程A的缓存设为无效吗?但是线程A的读取操作已经做过了啊,只有在做读取操作时,发现自己缓存行无效,才会去读主内存的值,所以这里线程A只能继续做自增了。

总的来说,volatile其实是无法完全替代synchronied关键字的,因为在某些复杂的业务逻辑里面,volatile并不能保证多线程之间的完全同步和操作的原子性。

使用 synchronized 关键字保证可见性

在看过《深入浅出 Java 并发编程 (1)》 之后,想必大家都对synchronized关键字同步锁的性质有所了解了,但是关于为什么synchronized关键字能够保证可见性还需要从synchronized实现的步骤和原理去理解。

在 Java 内存模型中,对synchronized关键字有两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。

  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁需要是同一把锁)。

这两条规定保证了线程解锁前对共享变量的修改在下次加锁时对其他线程可见,从而实现了可见性,我们再来看一下synchronized加锁前后代码具体的实现步骤:

  1. 获得互斥锁
  2. 清空工作内存
  3. 从主内存拷贝变量的最新副本到工作内存
  4. 执行代码
  5. 将更改后的共享变量的值刷新到主内存
  6. 释放互斥锁

保证可见性的步骤显而易见。

synchronized 和 volatile 关键字的异同

最后我们再来聊一聊这两个关键字的异同,这在很多互联网公司面试的过程中都属于热门考点。

简要总结概括如下:

  • volatile 不需要加锁,比 synchronized 更轻量级,不会阻塞线程。
  • 从内存可见性角度,volatile读相当于加锁,volatile写相当于解锁。
  • synchronized 既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。
  • volatile 只能修饰变量,synchronized 还可修饰方法。

关于所谓线程阻塞和死锁以及相关的问题和解决方法,我们将在以后的文章中具体介绍。

相关阅读:

本教程纯属原创,转载请声明
本文提供的链接若是失效请及时联系作者更新

テクノロジー
Web note ad 1