线程的安全性问题

Java 对线程的支持是一把双刃剑。多线程虽然拥有提高多核处理器能力、便于编程建模等优点,但也存在一些列风险,如安全性问题、活跃性问题、性能问题。本文将分析多线程带来的安全性问题。

一、竞态条件

1.什么是竞态

多线程编程中经常遇到的一个问题就是对同样的输入,程序的输出有时候是正确的,有时候是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态(Race Condition)

竞态往往伴随着读取脏数据问题,即线程读取到一个过时的数据更新丢失问题。不过竞态不一定就导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。

2.竞态条件的类型

2.1 read-modify-write

读-改-写操作,该操作可以分为三步:读取一个共享变量的值(read),对该共享变量的值做一些操作(modify),更新该共享变量的值(write)。如下代码所示,其中的 count++ 就是一个典型的 read-modify-write 类型竞态条件。

/**
 * @author hncboy
 */
public class UnsafeCounter implements Runnable {

    private static int count;

    public static void main(String[] args) throws InterruptedException {
        Runnable r = new RaceConditionDemo1();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }

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

上述代码的运行输出结果小于等于 20000。count++ 可以用如下伪代码表示的几个指令的组合:

load(count, r1);  // ① read:将 count 从内存读取到寄存器 r1
increment(r1);    // ② modify:将寄存器 r1 中 count 的值加 1
store(count, r1); // ③ write:将寄存器 r1 中 count 的值写入对应的内存

一个线程在执行完指令 ① 后到开始执行指令 ② 的这段时间内,其他线程可能已经更新了共享变量 count 的值,这就导致该线程在执行指令 ② 的时候使用的是共享变量 count 的旧值,然后该线程又将根据这旧值计算后的结果更新到共享变量,使得之前其他线程对该共享变量所做的操作被覆盖,造成了更新丢失

2.2 check-then-act

检测而后行动操作,该操作可以分为两步:读取到某个条件共享变量的值,根据该变量的结果采取对应的动作。如下代码所示,该版本的单例模式就会发生 check-then-act 类型的竞态条件。假设线程 1 和线程 2 同时执行 getInstance 方法,线程 1 判断了 instance 为 null,因此创建了一个 ExpensiveObject 对象。线程 2 也需要判断 instance 是否为空,但此时的 instance 是否为空,取决于不可预测的顺序,包括线程调度方法以及线程 1 需要花多久时间来初始化 ExpensiveObject 对象并设置 instance。如果当线程 2 检查发现 instance 为空时,线程 1 和线程 2 都会创建 ExpensiveObject 对象并返回不同的结果。

/**
 * @author hncboy
 */
public class LazyInitRace {

    private ExpensiveObject instance = null;

    public ExpensiveObject getInstance() {
        if (instance == null) {
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

3.竞态产生的条件

从上述的两个例子中我们可以分析竞态产生的一般条件。假设 O1 和 O2 是并发访问共享变量 V 的两个操作,且并非都是读操作。如果一个线程正在执行 O1 操作的期间,另外一个线程正在执行 O2 操作,那么无论 O2 操作是正在读取还是更新 V 都会导致竞态条件。

竞态可以被看作访问同一组共享变量的多个线程所执行的操作相互交错,比如一个线程读取共享变量并以该共享变量为基础进行计算的期间另外一个线程更新了该共享变量的值而导致的 干扰(读脏数据)冲突(丢失更新) 的结果。

对于局部变量而言,由于不同的线程各自访问的是各自的那一份局部变量,因此局部变量的使用并不会导致竞态。

二、线程安全

1.什么是线程安全

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要额外的同步或协同,这个类都能表现出正确的行为,那么我们就称其是线程安全的,相应地我们称这个类具有线程安全性。反之,如果一个类在单线程环境下运作正常而在多线程环境下则无法正常运作,那么这个类就是非线程安全的。因此,一个类如果能够导致竞态,那么它就不是线程安全的;而一个类如果是线程安全的,那么它就不会导致竞态。

2.线程安全类的设计

从线程安全的定义中我们可以看出,如果一个线程安全的类在多线程环境下能够运作,那么它在单线程环境下也能正常运作。那样的话为什么不把所有的类都设计成线程安全?

  • 一个类是否需要线程安全与这个类预期被使用的方式有关,如果一个类不会被用在多线程程序中,那就没有必要被设计成线程安全的类。
  • 将一个类设计为一个线程安全的类需要考虑多方面的因素如性能问题、设计问题,代价过高。

3.线程安全的本质

一个类如果不是线程安全的,那么它在多线程环境下会存在线程安全问题。线程安全问题可以概括为3个方面:原子性、可见性和有序性。

三、原子性

1.什么是原子性

原子(Atomic)字面意思是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应的我们称该操作具有原子性

生活中的从 ATM 取钱的例子就是具有原子性:从 ATM 的角度,取钱涉及到了一系列操作如扣减账户余额、新增交易记录、吐钞机吐钱等。但对用户来说,我们从 ATM 取钱就仅仅是一个取钱的操作。该操作要么成功,用户账户余额减少,我们拿到现金;要么失败,用户账户余额不变,我们没有拿到现金。不会存在我们账户余额减少却没有取到钱的情况。该例子中的账户余额就是共享变量,ATM 机和用户分别相当于上述原子操作的执行线程其它线程

2.原子操作的不可分割

通过代码示例来体会下原子操作的不可分割含义。假设线程 1 执行 updateHostInfo 方法更新 HostInfo 信息,线程 2 执行 connToHost 方法来与主机进行连接。那么 updateHostInfo 方法中的操作必须是一个原子操作,即该操作是不可分割的。否则可能线程 1 刚执行了 setIp 方法,线程 2 此时却执行了 connectToHost 方法,导致 port 没有被修改就进行连接,从而无法建立网络连接。因为 updateHostInfo 方法中的操作不是原子操作,所以导致了这一错误的发生。

/**
 * @author hncboy
 */
public class AtomicExample {

    private HostInfo hostInfo;
    
    public void updateHostInfo(String ip, int port) {
        hostInfo.setIp(ip);
        hostInfo.setPort(port);
    }

    public void connectToHost() {
        String ip = hostInfo.getIp();
        int port = hostInfo.getPort();
        connectToHost(ip, port);
    }

    private void connectToHost(String ip, int port) {
        // doSomething
    }

    private static class HostInfo {
        private String ip;
        private int port;
		/** get,set */
    }
}

2.1 不可分割的含义

  • 第一个含义是指访问某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经完成要么还未开始,即其它线程不会看到该操作执行的中间状态
  • 第二个含义是访问同一组共享变量的原子操作是不能够被交错的,这就是排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)冲突(丢失更新)
  • 由原子操作不可分割的特性可知,使一个操作具有原子性就可以消除该操作导致竞态的可能。

2.2 原子操作注意点

  • 原子操作是针对访问共享变量的操作而言的。涉及局部变量访问的操作,我们不需要关心该操作是否是原子操作,因为局部变量是线程私有的,无法共享,我们可以把直接把这一类操作都看作原子操作。
  • 原子操作是从该操作的执行线程以外的线程来描述的,也就是是该操作只有在多线程的环境下才有意义。在单线程的环境中我们不需要考虑一个操作是否具有原子性,我们也可以直接把这一类操作都看作原子操作。

3.Java 中的原子操作

3.1 Java 中如何保证原子性

  • 使用锁:锁具有排他性,能够保障一个共享变量在同一时刻只能被一个线程访问,排除了同一时刻多个线程访问同一个变量导致干扰和冲突的可能,即消除了竞态。
  • 使用 CAS:锁通常是在软件这一层面实现的,而 CAS 则直接在硬件层面进行操作。

3.2 Java 中变量的原子操作

在 Java 语言中,对除 long 和 double 以外的基本数据类型(byte、boolean、short、char、float、int)的变量和引用类型的变量的写操作都是原子性的。在 JLS 的 17.7 中规定,在 32 位虚拟机上,对 64 位的 long 和 double 数据类型变量的写操作会被视为两个单独的写入,每个 32位 写一个,这可能会导致一个线程读取到其它线程更新该变量的中间结果。我们将 long 和 double 类型的变量用 volatile 修饰,就可以保证对该变量写操作的的原子性。volatile 关键字仅能保障变量写操作的原子性,并不能保障其它操作(如 read-modify-write 和 check-then-act 复合操作)的原子性。Java 语言针对任何变量的读操作都是具有原子性的。

我们可以利用 Java 语言对变量(除 long/double)写操作的保证,修改下上面 updateHostInfo 函数,使该函数的操作具有原子性。代码如下所示,我们可以通过 hostInfo = newHostInfo 的方式使其对引用变量 hostInfo 的写操作具有原子性。

public void updateHostInfo(String ip, int port) {
    HostInfo newHostInfo = new HostInfo(ip, post);
    hostInfo = newHostInfo; // 具有原子性
}

4.原子操作+原子操作!=原子操作

简单的将原子操作组合在一起,并不能保证原子性。如下代码所示,共享变量 a 和 b 是 int 类型,操作 ① 和 ② 都是原子操作,但是在线程 1 执行完了语句 ① 之后,另一个线程 2 可以读取这两个共享变量的值,此时读取到变量 a 是正确的,而变量 b 的值则是线程 1 执行操作的中间结果,这和原子操作不可分割的特性冲突,所以 原子操作+原子操作!=原子操作。

a = 1; // ①
b = 2; // ②

四、可见性

1.什么是可见性

如果一个线程对某个共享变量更新后,后续访问该变量的线程可以读取到该变量更新后的结果,那么我们就称这个线程对该共享变量的更新对其他线程可见,否则我们就称这个线程对该共享变量的更新对其它线程不可见。可见性就是指一个线程对共享变量更新的结果对于读取相应共享变量的线程是否可见的问题。

2.为什么会有可见性问题

看一段出现可见性问题的代码,这段代码理论上应该是在运行 1 秒后输出result,但是却一直运行下去,因为子线程没有看到主线程对 stop 变量的修改。

/**
 * @author hncboy
 */
public class VisibilityDemo {

    private static boolean stop;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
            }
            System.out.println("result:" + i);
        }).start();
        System.out.println("start");
        Thread.sleep(1000);
        stop = true;
    }
}

可见性问题与计算机的存储系统有关。处理器并不是直接与主内存打交道而执行的内存读、写操作。而是通过寄存器、高速缓存、写缓冲区等部件执行内存读、写操作的。从这个角度看,这些部件都相当于主内存的副本,我们可以把这些部件看作处理器对内存的缓存,每个处理器都会将自己需要的数据读取到缓存中,数据修改后也是写入到缓存中,然后等待刷入到主内存中,所以会导致有些处理器读取到的值是一个过期的值。处理器的多级缓存如下图所示。

3.如何保证可见性

我们只需要在上述代码的 stop 变量前加一个 volatile 关键字。然后选择运行,过了1秒就输出 result 了。

private volatile static boolean stop;

这里 volatile 关键字起到的一个作用是保证可见性:读一个 volatile 变量之前,需要先使相应的本地缓存失效,这样就必须从主内存中读取到最新值,写一个 volatile 属性会立即刷入到主内存。当然除了 volatile 可以让变量保证可见性外,如 synchronized、Lock、Thread.join() 和 Thread.start() 等都可以保证可见性。

另外,由于某些处理器(如 x86)在可见性方面足够“强大”,加上实际工作中我们能够接触到的处理器类型非常有限,因此可见性问题并不是必然出现的。

4.单核处理器的可见性问题

可见性问题是多线程衍生出来的问题,与目标运行环境是单核还是多核无关。也就是说,单核处理器中实现的多线程编程也可能出现可见性问题。于是进行实验测试下单核处理器下的线程可见性问题。

4.1 准备单核处理器

在 VMware 中安装 Windows7 镜像,硬件配置如下图,给系统分配了 1 个处理器和 2GB 内存。

4.2 安装 JDK

安装的系统版本为 Windows7 旗舰版 32 位操作系统。因此安装 32 位的 JDK,这里采用的版本为 JDK_8.0.1310.11_32bit,安装完配置下环境变量就行了。

通过查看的 JDK 的版本,类型 Client VM,后面我们采用 Server 的方式运行。

4.3 运行代码

将上面用于演示可见性的代码复制到虚拟机编译并运行,在 server 模式下运行结果如下,发现只打印了 start,可见单核处理器也存在可见性问题。在 client 模式下也,该段代码可以正常运行输出 result,未发生线程可见性问题,不过一旦子线程数量超过 2,也只能打印两个子线程的输出语句。

server 模式的 JVM 将比 client 模式的 JVM 进行更多的优化,例如将循环中未被修改的变量提升到循环外部,因此在开发环境(client 模式的 JVM)中能正确运行的代码,可能会在部署环境(server 模式的 JVM)中运行失败。

4.4 单核 处理器可见性总结

在目标运行环境为单核的情况下,多线程的并发执行是通过时间片轮转进行的。这时,虽然多个线程运行在同一个处理器上,但是由于在发生上下文切换的时候,一个线程对寄存器变量的修改会被作为该线程的线程上下文而存储起来,这会导致另外一个线程无法看到该线程对这个变量的修改。因此,单核处理器系统中实现的多线程编程也可能出现可见性问题。

5.可见性和原子性的关系

原子性可以保证一个线程所读取到的共享变量要么是该变量的初始值,要么是该变量被更新后的值,而不是更新过程中的一个相当于“半成品”的值。而可见性保证一个线程可以读取到相应共享变量被更新后的值。

因此,从保障线程安全的角度来看,只保障原子性可能是不够的,有时候还要同时保障可见性。可见性和原子性同时得以保障才能确保一个线程能够正确地看到其他线程对共享变量所作的更新。

6.线程的启动、终止与可见性

6.1 线程启动可见性

JLS 保证父线程在启动子线程之前对共享变量的更新对于子线程是可见的,代码如下所示。

/**
 * @author hncboy
 */
public class ThreadStartVisibility {

    private static int result;

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            System.out.println(result);
        });
        result = 1; // ①
        thread.start();
        result = 2; // ②
    }
}

我们如果将语句 ② 注释,则由于在线程启动前,语句 ① 将共享变量 result 的值更新为了1,所以子线程读取到的共享变量 result 的值一定为1,因为父线程在子线程启动前对共享变量的更新对子线程的可见性是有保证的。如果不将语句②注释,父线程在子线程启动之后对共享变量 result 的更新对子线程的可见性是没有保证的,结果可能是1,也可能是2。

6.2 线程终止可见性

JLS 保证一个线程终止后该线程对共享变量的更新对于调用该线程的 join() 方法的线程而言也是可见的,代码如下。子线程 thread 在运行时将将共享变量 result 的值改为 1,因此在主线程中的 thread.join() 执行结束后,后面的语句读取到的共享变量 result 的值为 1 也一点是可以保证的。

/**
 * @author hncboy
 */
public class ThreadJoinVisibility {

    private static int result;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            result = 1;
        });
        thread.start();
        thread.join();
        System.out.println(result);
    }
}

五、有序性

1.什么是有序性

有序性指在什么情况一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的。所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。

2.重排序

2.1 什么是重排序

我们写的程序在代码上的操作有先后关系,但是在多核处理器的环境下,这种操作执行顺序可能是没有保障的:编译器可能改变两个操作的先后顺序,处理器可能不是完全按照程序的目标代码所指定的顺序执行指令。另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫作重排序

重排序是对内存访问操作(读、写)所做的一种优化,它可以在不改变单线程执行语义的情况下提升程序的性能。但是,在多线程的情况下可能会影响程序执行结果的正确性,造成线程安全问题。和可见性问题一样,重排序也不是必然出现的。

举个例子演示下重排序的问题,代码如下所示,主线程启动了两个子线程 thread1 和 thread2,两个子线程内部分别对共享变量 x,y,a,b 进行读写操作,为了验证重排序发生的问题,将这些操作放到循环中进行,使用 CountDownLatch 保证这两个线程中的读写操作同时运行。

/**
 * @author hncboy
 */
public class OutOfOrder {

    private static int x = 0, y = 0, a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            x = y = a = b = 0;
            CountDownLatch latch = new CountDownLatch(1);
            Thread thread1 = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            });

            Thread thread2 = new Thread(() -> {
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b = 1;
                y = a;
            });

            thread1.start();
            thread2.start();
            latch.countDown();
            thread1.join();
            thread2.join();

            System.out.println("第" + i + "次(" + x + ", " + y + ")");
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

如果不发生重排序的话,那正确的运行结果应该只有 3 种情况:

  • a=1; x=b; b=1; y=a; 最终结果 x=0,y=1
  • b=1; y=a; a=1; x=b; 最终结果 x=1,y=0
  • a=1; b=1; x=b; y=a; 最终结果 x=1,y=1

但是循环最终被 break 了,也就是出现了 x=0,y=0 的情况,出现这种情况的可能的执行顺序为:y=a; a=1; x=b; b=1;也就是可能发生 b=1 和 y=a 重排序。

2.2 重排序类型

重排序包括编译器、处理器和存储子系统重排序(写缓冲区、高速缓存)。先定义几个与内存操作顺序有关的术语。

  • 源代码顺序:我们编写的 .java 源文件中指定的代码顺序。
  • 程序顺序:编译后字节码文件 .class 的顺序。
  • 执行顺序:在给定处理器上实际执行的内存访问顺序。
  • 感知顺序:给定处理器所感知到的该处理器和其他处理器的内存访问操作发生的顺序。

根据上述定义,划分重排序的类型,其中编译器指令重排序和处理器指令重排序都属于指令重排序,指令重排序是真实的对指令的顺序做了调整,重排序对象为指令

  • 编译器指令重排序
    • javac 编译器:程序顺序和源代码顺序不一致。
    • JIT 编译器:执行顺序和程序顺序不一致。
  • 处理器指令重排序
    • 处理器:执行顺序和程序顺序不一致。
  • 存储子系统重排序
    • 写缓冲区、高速缓存:源代码顺序、程序顺序、执行顺序一致,感知顺序和执行顺序不一致。

2.3 编译器指令重排序

Java 中的编译器分为静态编译器(javac)动态编译器(JIT)。javac 编译器是将 Java 源文件(.java)编译为字节码文件(.class ),是在编译阶段介入的。JIT 编译器是将字节码动态编译为本地机器码,是在 Java 运行时介入的。执行 Java 文件,除了JIT 编译执行,其实还有解释执行,在 Java 中解释执行和编译执行共同存在的。

编译器在不改变单线程程序运行结果正确的情况下会对源代码顺序进行调整,从而造成程序顺序和源代码顺序不一致,不过 javac 编译器基本上不会进行指令重排序,而 JIT 编译器则可能执行指令重排序。

2.4 处理器指令重排序

2.4.1 什么是乱序执行

处理器也可能执行指令重排序,造成执行顺序和程序顺序不一致。现代处理器为了提高指令的执行效率,往往不是按照程序顺序逐一执行指令的,而是动态的调整指令的顺序,哪条指令就绪就先指令哪条指令。这就是处理器对指令进行的重排序,也被称为乱序执行(Out-of-order Execution)

2.4.2 乱序执行顺序
  • 顺序读取:指令会一条一条的按照程序顺序被处理器读取。
  • 乱序执行:哪条指令先就绪就先执行,执行结果(进行的写寄存器或写内存操作)会先存入重排序缓冲器(ROB,Reorder Buffer)
  • 顺序提交:ROB 会将各个指令的执行结果按读取顺序顺序提交到寄存器和内存。

虽然指令的执行顺序是乱序的,但是提交的时候是按照程序顺序提交,因此处理器的指令重排序并不会对单线程程序造成影响。

2.4.3 猜测执行

处理器的乱序执行还采用了一种猜测执行的技术。猜测执行能够造成 if 语句的语句体先于其条件语句被执行,这也就造成了指令重排序。

if (条件语句) {
    语句体
}

如果 if 的语句体先于条件语句执行,则会将语句体的执行结果临时存放到 ROB 中,接着再去读取条件语句,如果条件语句的值为 true,那么再将 ROB 中存放的结果写入到内存中。如果条件语句的值为 false,则将 ROB 中的该结果抛弃以达到语句体未被执行的效果。因此,猜测执行造成的指令重排序不会影响单线程程序的正确性。

2.5 存储子系统重排序

2.5.1 存储子系统来源

处理器直接从主内存获取数据是很慢的,因此引入了高速缓存,处理器通常通过 高速缓存(cache) 访问主内存。在此基础上,现代处理器还引入了 写缓冲区(Store Bufer) 以提供写高速缓存的效率。有的处理器(x86)对所有写主内存的操作都是通过写缓冲区进行的。这里将高速缓存和写缓冲区统称为 存储子系统

2.5.2 什么是存储子系统重排序

指令重排序的重排序对象是指令,是真实的对指令的顺序进行调整,而存储子系统重排序是一种现象而不是一种动作。没有真实的对指令顺序进行调整,而是造成了一种指令的执行顺序像是被调整过一样。该现象就是存储子系统重排序,也叫做内存重排序。这里的关键是,由于存储子系统仅对自己的处理器可见,他会导致处理器执行内存操作的顺序可能会与内存实际的操作顺序不一致。

2.5.3 内存重排序类型

约定:虽然内存重排序只是一种现象,但是为了方便讨论,在讨论内存重排序的时候一般采用指令重排序的方式来表述,如采用“内存操作 A 被重排序到内存操作 B 之后”的方式。

从处理器的角度来说:

  • 读内存操作的实质是从指定的 RAM 地址加载数据到寄存器,因此读内存操作通常被叫做 Load
  • 写内存操作的实质是将数据存储到指定地址表示的 RAM 存储单元中,因此写内存操作通常被称为 Store

根据 Load 和 Store 内存操作可以组成 4 种不同的内存重排序:

  • LoadLoad 重排序:一个处理器先后执行两个读内存 Load 操作 L1 和 L2,其他处理器对这两个内存操作的感知顺序可能是 L2 和 L1,即 L1 被重排序到 L2 之后。
  • StoreStore 重排序:一个处理器先后执行两个写内存 Store 操作 S1 和 S2,其他处理器对这两个内存操作的感知顺序可能是 S2 和 S1,即 S1 被重排序到 S2 之后。
  • LoadStore 重排序:一个处理器先后执行读内存 Load 操作 L1 和写内存 Store 操作 S2,其他处理器对这两个内存操作的感知顺序可能是 S2 和 L1,即 L1 被重排序到 S1 之后。
  • StoreLoad 重排序:一个处理器先后执行写内存 Store 操作 S1 和读内存 Load 操作 L2,其他处理器对这两个内存操作的感知顺序可能是 L2 和 S1,即 S1 被重排序到 L2 之后。

常见处理器允许的重排序类型如下表所示,常见的处理器都允许 StoreLoad 重排序,常见的处理器都不允许对存在数据依赖的操作做重排序。

重排序类型LoadLoadLoadStoreStoreStoreStoreLoad数据依赖
SPARC-TSONNNYN
x86NNNYN
IA64YYYYN
PowerPCYYYYN

3.as-if-serial 语义

3.1 什么是 as-if-serial 语义

重排序不会随意地对指令、内存操作的结果进行杂乱无章的排序或顺序调整,而是遵循一定的规则。编译器(主要是 JIT 编译器)、处理器(包括其存储子系统)都会遵循这些规则,从而给编写单线程程序的程序员创建了一个幻觉:指令是按照代码顺序执行的,这种幻觉就被称为 as-if-serial 语义

3.2 数据依赖关系

为了保证 as-if-serial 语义,存在数据依赖关系的语句不会被重排序,因为这种重排序会改变执行结果。如果两个操作指令访问同一个变量,且其中一个操作指令为写操作,那么这两个操作就存在数据依赖关系。数据依赖的关系可分为下面 3 种类型。

类型代码示例说明
写后读a = 1; b = a;写一个变量之后,再读取一个变量
写后写a = 1; a = 2;写一个变量之后,再写这个变量
读后写a = b; b = 1;读一个变量之后,再写这个变量

这里的数据依赖关系只是针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖关系不被编译器和处理器考虑。

3.3 控制依赖关系

如果一条语句指令的执行结果会决定另外一条语句指令能否被执行,那么这两条语句指令之间就存在控制依赖关系。存在控制依赖关系的语句最典型的就是 if 语句中的条件语句和语句体。允许这种重排序意味着处理器可能先执行 if 语句体中的内存访问操作,然后再执行相应的条件判断,即上面所讲的猜测执行。允许对存在控制依赖关系的语句进行重排序可以提高处理器的性能。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

4.单核处理器的重排序问题

单核处理器上实现的多线程是通过分配时间片进行的实现的,会不会造成重排序需要具体分情况。

4.1 编译期重排序

编译期重排序,即静态编译器(javac 编译器)造成的重排序会对运行在单核处理器上的多线程产生影响。如以下代码所示。

result = 1; // ①
ready = true; // ②

假设 javac 编译器在编译的时候将代码的顺序调整为:

ready = true; // ②
result = 1; // ①

在该情况下,当一个线程运行完语句 ② 的时候,发生了上下文切换。另外一个线程被切换进来运行,那么这个线程在看到 ready 值为 true 的时候,实际上语句 ① 还没有被上一个切出来的线程所执行,那么此时的重排序就有可能影响该切入线程的正确性。

4.2 运行期重排序

运行期重排序,包括 JIT 编译器、处理器乱序执行以及存储子系统导致的重排序,并不会对单处理器上运行的多线程产生影响,即在这些线程看来处理器像是按照程序顺序执行指令。

因为当这些运行期重排序发生的时候,相关的指令还没有完全执行完毕,即它们的执行结果还没有被刷入主内存,此时处理器(单核)通常不会立即进行上下文切换运行另外一个线程,而是等这些正在执行的指令执行完毕之后再进行上下文切换。

因此,当前线程被切出而另外一个线程被切入的时候,被切出的线程中被重排序的指令已经执行完毕了,因此重排序对于这个被切入的线程就像不存在一样。

4.如何保证有序性

我们知道 as-if-serial 语义可以保障重排序不影响单线程程序的正确性。从该角度出发,我们可以理解为:有序性的保障可以通过某些措施使得 as-if-serial 语义扩展到多线程程序,即重排序要么不发生,要么即使发生了也不会影响多线程程序的正确性。

因此,有序性的保障可以理解为从逻辑上禁止部分重排序,当然不能从物理上完全禁止重排序使得处理器按照源代码顺序执行指令,这会大大降低处理器的性能。

从底层的角度来说,禁止重排序是通过调用处理器提供相应的指令(内存屏障 Memory Barrier,刚好对应内存重排序)来实现的。Java 作为一个跨平台的语言,也提供了如 volatile 和 synchronized 关键字来保障有序性。在上述 OutOfOrder 类的重排序例子中,我们只要给变量声明 volatile 关键字,就可以保障有序性。此处 volatile 关键字的另外一个作用就是禁止指令重排序优化。

private volatile static int x = 0, y = 0, a = 0, b = 0;

5.可见性和有序性的关系

  • 可见性是有序性的基础。可见性描述的是一个线程对共享变量的更新对于其他线程是否可见或者说在什么情况下可见的问题。有序性描述的是一个处理器上运行的线程对共享变量的更新,在其他处理器上的其他线程看来,这些线程是以怎样的顺序观察到这些更新的问题。因此,可见性是有序性的基础。
  • 有序性影响可见性。由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。

六、总结

  • 线程安全可以概括为三个方面:原子性、可见性和有序性。
  • 竞态条件分为 read-modify-write 和 check-then-act 复合操作,使该复合操作具有原子性即可消除导致竞态的可能。
  • Java 中对任何变量的读操作都是原子的,对除 long 和 double 以外变量的写操作都是原子的。
  • 可见性和内存重排序是由于处理器的多级缓存导致的,
  • 单核处理器中也可能存在可见性和有序性问题。
  • 重排序分为编译器指令重排序、处理器指令重排序和内存重排序。
  • 编译器和处理器无论怎么优化,都必须遵守 as-if-serial 语义,单线程程序的执行结果不能改变。
  • volatile 关键字可以保证可见性和有序性。
  • 锁和 CAS 可以保证原子性。

参考资料

《Java 并发编程实战》

《Java 多线程编程实战指南(核心篇)》

《Java 并发编程的艺术》

https://software.intel.com/en-us/forums/intel-moderncode-for-parallel-architectures/topic/289345

灿烂一生
微信扫描二维码,关注我的公众号
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值