Skip to content

多线程前置知识

多线程内存可见性

CPU和JVM的重排序

CPU及JVM为了优化代码执行效率,会对代码进行重排序,其中包括:

  • 编译器重排序(没有先后依赖关系的语句,编译器可以重新调整语句执行顺序)
  • CPU指令重排序(让没有依赖关系的多条指令并行)
  • CPU内存重排序(CPU有自己的缓存,指令执行顺序和写入主内存顺序不一致)

其中CPU内存重排序是导致内存可见性的主因。根据JMM内存模型,我们描述下过程:

如果线程需要修改共享变量,那么线程A会拷贝共享变量的副本到本地线程中并对其进行修改,之后会将值写回共享内存中(时间不确定),但在写回之前,线程B读取共享变量到本地准备修改,而此时线程A修改共享变量的操作对线程B不可见

重排序规则:

as-if-serial

不管怎么重排序,单线程程序的执行结果不能被改变。只要操作之间没有数据依赖性,那么编译器和CPU都可以任意重排序。

happen-before(JVM层面)

为了明确多线程场景下那么可以重排序,哪些不可以重排序,引入了JMM内存模型,而JMM提供了happen-before规范,用于在开发者编写程序和系统运行之间效率找到平衡点,它描述了两个操作之间的内存可见性,若A happen before B,如果A在B之前执行,则A的执行结果必须对B可见

  • 单线程的每个操作,happen-before 于该线程中任意后续操作。
  • 对volatile变量的写入,happen-before 于后续对这个变量的读取
  • 对于synchronized的解锁,happen-before于后续对这个锁的加锁。
  • 对final域的写(构造函数中),happen-before于对final域所在对象的读。

happen-before传递性

假设线程A先调用了set(),设置了a=5,之后线程B调用了get(),返回一定是a=5。

java
class Test {
    private int a = 0;
    private volatile int c = 0;

    void set() {
        a = 5;// step 1
        c = 1;// step 2
    }

    int get() {
        int d = c;// step 3
        return a;// step 4
    }
}

因为step1和step2在同一块内存中,所以step1 happen-before step2,同理step3 happen before step4,且因为c是volatile变量,根据volatile变量的写 happen-before volatile变量的读,以及happen-before传递性,step1 的结果一定对step4可见。

volatile

作用

volatile保证了内存的可见性,对于共享变量操作会直接从共享内存中读取,修改时会直接将结果刷入共享内存,其次禁止了volatile修饰的变量和非volatile变量之间的重排序

原理

为了禁止编译器重排序和CPU重排序,底层原理是通过内存屏障指令来实现的。

编译器内存屏障

只是为了告诉编译器不要对指令进行重排序,但编译完成后,这种内存屏障就消失了,CPU不会感知到编译器中内存屏障的存在。

CPU内存屏障

由CPU提供的指令(不同的CPU架构,提供的指令不同),可以由开发者显示调用,volatile就是通过CPU内存屏障指令来实现的。

实现流程:

  • 在volatile写操作的前面插入一个StoreStore屏障。保证volatile写操作不会和之前的写操作重排序。
  • 在volatile写操作的后面插入一个StoreLoad屏障。保证volatile写操作不会和之后读操作重排序。
  • 在volatile读操作后面插入一个LoadLoad屏障 + LoadStore屏障。保证volatile读操作不会和之前的读操作、写操作重排序。

与synchronized关键字的异同

多线程会产生三大问题:原子性、有序性和可见性。

synchronized和volatile在共享变量的操作上具有相同的内存语义(从主内存读取,立即写入主内存),保证了变量的可见性。但是synchronized相比volatile还具有原子性(阻塞和排他性,同一时刻只能有一个线程执行,而volatile是非阻塞的),所以volatile是弱化版的synchronized

java
class Test {
    // 这里的flag就可以不用锁同步
    private static volatile boolean flag = true;
    // 模拟AtomicInteger
    private static CasUnsafe UNSAFE = new CasUnsafe(0);
    
    // 按照顺序打印1-100的奇偶数
    public static void main(String[] args) {
        THREAD_POOL.execute(() -> {
            while (UNSAFE.getValue() < 100) {
                if (flag) {
                    System.out.println(UNSAFE.incrementAndGet());
                    flag = false;
                }
            }
        });
        THREAD_POOL.execute(() -> {
            while (UNSAFE.getValue() < 100) {
                if (!flag) {
                    System.out.println(UNSAFE.incrementAndGet());
                    flag = true;
                }
            }
        });
        THREAD_POOL.shutdown();
    }
}

Q:什么时候用volatile而可以不用synchronized?

A:如果写入变量值不依赖变量当前值(count++就是依赖当前值,先去内存读取值,然后将当前值+1,将计算后的值赋给count。比如),那么就可以用volatile。

DCL(Double Check Lock)

双重检查加锁问题简称DCL,用于懒汉式单例的一种写法,问题如下所示:

java
public class DoubleCheckSingleton {

    /**
     * 为什么这个地方要使用volatile修饰?
     *
     * 首先我们需要了解JVM是存在`编译器优化重排`功能的(编译器在不改变单线程语义情况下,重新安      * 排代码的执行顺序。但是不保证多线程情况)
     * 执行如下代码
     * singleton = new DoubleCheckSingleton();
     * 在JVM是分成三步的:
     * 1. 开辟空间分配内存
     * 2. 初始化对象
     * 3. 将singleton引用指向分配的内存地址
     *
     * 在不使用volatile时,可能被JVM优化成
     * 1. 开辟空间分配内存
     * 3. 将singleton引用指向分配的内存地址
     * 2. 初始化对象
     *
     * 那么当线程A执行1&3步的时候,线程B获取了CPU执行权,去验证`null == singleton`,
     * 发现不为null,直接返回一个未初始化完成的singleton,导致程序出错。
     *
     * volatile禁止被修饰变量的 编译器重排序 和 处理器重排序(内存屏障) (JDK1.5后)
     *
     */
    private static volatile DoubleCheckSingleton singleton;

    private DoubleCheckSingleton() {
    }

    public static DoubleCheckSingleton getInstance() {
        // 不是任何线程进来都尝试去获取锁,而是先判断singleton是否为null,优化性能
        if (null == singleton) {
            // 尝试去获取锁,保证线程安全
            synchronized (DoubleCheckSingleton.class) {
                // 获取锁后判断singleton是否为null
                if (null == singleton) {
                    singleton = new DoubleCheckSingleton();
                }
            }
        }
        return singleton;
    }
}

MESA

在解释MESA模型之前,我们需要了解什么是管程:又称为监视器,它是描述并实现对共享变量的管理与操作,使其在多线程下能正确执行的一个管理策略。可以理解成临界区资源的管理策略。MESA模型是管程的一种实现策略,Java使用的就是该策略。

相关术语

  • enterQueue管程的入口队列,当线程在申请进入管程中发现管程已被占用,那么就会进入该队列并阻塞。
  • varQueue条件变量等待队列,在线程执行过程中(已进入管程),条件变量不符合要求,线程被阻塞时会进入该队列。
  • condition variables:条件变量,存在于管程中,一般由程序赋予意义,程序通过判断条件变量执行阻塞或唤醒操作。
  • 阻塞和唤醒:wait()和await()就是阻塞操作。notify()和notifyAll()就是唤醒操作。

模型概念图

Synchronized和Lock在MSEA监视器模型中的区别在于前者只有一个条件变量,后者可以有多个

执行流程

  1. 多个线程进入入口等待队列enterQueue,JVM会保证只有一个线程能进入管程内部,Synchronized中进入管程的线程随机。
  2. 进入管程后通过条件变量判断当前线程是否能执行操作,如果不能跳到step3,否则跳到step4。
  3. 条件变量调用阻塞方法,将当前线程放入varQueue,等待其他线程唤醒,跳回step1。
  4. 执行相应操作,执行完毕后调用notify/notifyAll等唤醒操作,唤醒对应varQueue中的一个或多个等待线程。
  5. 被唤醒的线程会从varQueue放入enterQueue中,再次执行step1。
  6. 被唤醒的线程不会立即执行,会被放入enterQueue,等待JVM下一次选择运行,而正在运行的线程会继续执行,直到程序执行完毕。