Fari

java多线程编程实战指南 笔记

线程简介

进程是程序的运行实例,是动态的,运行一个java程序实际上就是一个java虚拟机进程

进程是程序向操作系统申请资源(内存空间、文件句柄等)的基本单位,线程是cpu调度的最小单位

一个进程可以包含多个线程,这些线程贡献进程申请的资源

Thread的start方法是启动一个线程,但该方法的调用并不一定立即启动线程,得看系统的线程调度器决定。线程是一次性的,即该方法只能调用一次

run方法是线程的具体任务逻辑,它是由JVM自动调用的,该方法执行结束,则线程也就结束了。由于该方法是一个public的方法,当然可以手动调用,不过手动调用时它就是在当前线程下执行的普通方法了

java种,一个线程就是一个对象,但与普通对象不同的是,线程对象需要额外分配操作栈空间内存,并且可能绑定一个内核线程

Thread、Runnable创建线程的区别
// 方式一:通过匿名内部类的方式创建Thread的子类
new Thread(){
    @Override
    public void run() {
        // ...
    }
}.start();

// 方式二:通过传入一个Runnable接口的实现类来启动一个Thread
new Thread(new Runnable() {
    @Override
    public void run() {
        // ...
    }
}).start();
  1. 继承Thread对象本质上是基于继承的技术,而通过创建一个Thread对象并在构造器中传入Runnable实现则是基于组合的方式。从解耦的原则上来说,组合优于继承

  2. 可以只创建一个Runnable的实现并传入多个Thread中,使得它们可以共享Runnable中的变量,但可能引发并发问题

  3. Thread对象就是继承了Runnable接口,但需要明确的是,Runnable接口和多线程运行并没有强制的关系,它只是一种可以运行的方法的抽象,很多接口并不需要使用多线程调用Runnable,这种情况下,创建Thread对象会更消耗资源,因为它会自动开辟栈空间并且绑定内核线程

可以将线程设置为守护线程,但是必须在start方法之前设置,否则会抛异常。守护线程和用户线程的区别在于,是否会影响JVM的停止,当用户线程执行结束后,不管是否存在守护线程,都会停止JVM

一个线程可以创建另一个线程,则它们就是父子关系。默认情况下一个线程是否是守护线程,取决于其父线程是否是守护线程,它们会保持一致。

线程状态

线程安全

什么是竞态?

竞态就是指多线程环境下,对某共享变量的操作可能出现不符合预期的情况,例如多线程下的 i++ 操作

线程安全的问题主要体现在3个方面:原子性、可见性、有序性

原子性

原子操作不可分割,意思是从其他线程的角度来看,对某共享变量的操作要么没执行要么执行完成,不存在执行到一半的状态

java中有两种方式实现原子操作,一个是锁,另一个是CAS

注:

  1. java中的long和double的操作不具备原子性,即多线程环境下可能读写一半的值。

  2. java中的volatile不具备原子性

  3. 但是两者结合就不一样了,jvm规范中特别指明了,使用volatile修饰的long或double的写操作具有原子性

可见性

一个变量更新后,其他线程可以立即读取到最新的值,即为可见性

JMM用于屏蔽物理机内存模型,因为每个cpu都会有自己的寄存器,但它们也有共同的内存空间,这就和JMM模型一致了。程序中的可见性是多线程衍生出的问题,它与实际用多少个cpu是无关的,这是因为线程的切换会导致寄存器的值出现上下文的切换,从宏观角度来看,其实就是在模拟多cpu

有序性

多处理器的情况下,从一个处理器视角看另一个处理器运行的程序是按照程序预期顺序执行的(这句话容易引发歧义,见下)。有些情况下,处理器并不会按照程序代码的顺序执行,例如发生了指令重排序或内存重排序(见下)。

所谓有序性,就是指避免重排序对多线程环境产生影响

注:对有序性的一个误解是从一个处理器看另一个处理器执行的指令是完全按照程序代码顺序来的,这是错误的,所谓有序性本意并非不允许重排序,而是说另一个处理器执行的代码,就算发生了重排序,对我这个处理器执行的代码结果是按照代码预期顺序的,例如下面的代码:

处理器1

A = 0
B = 1
S = 2
-------------------t1时刻

处理器2


-------------------t1时刻
l1 = S
l2 = A + B

所谓有序性是指,处理器2在t1时刻开始,看到的处理器1已经将A、B、S正确赋值了,但是赋值顺序是无所谓的。也就是说,有序性是指多个处理器对同一个数据的处理上是有序的

指令重排序不会对单线程任务产生影响是因为:指令执行产生的结果会放到重排序缓冲器,而不是直接写入寄存器或者内存中,重排序缓冲器会将缓存结果按照正确的顺序提交到寄存器或主内存。

注:单处理器多线程的情况下,编译期的指令重排序也可能产生问题。这是因为存在线程的上下文切换模拟了多处理器

处理器处理完数据后并非直接放回主存中,而是会经过一系列高速缓存,所以,即使没有发生指令重排序,这些高速缓存在同步数据过程中也可能发生内存交换感知上的重排序,称之为内存重排序。例如:一个处理器按代码顺序分别给 data=1,flag=True, 赋值,其中,flag和data为多处理器共享变量,初始值分别为False和0。另一个处理器先循环判断flag,如果为True则结束循环并打印data。两个处理器一起执行。预期结果是,当处理器1执行完flag=True后,处理器2应该打印出data=1。然而实际情况可能是,处理器1执行完flag=True并同步主存后,此时,data虽然赋值了1,但还没来得及同步回主存,处理器2就将data=0这个旧值打印出来了。

线程同步机制

线程同步机制是一套用于协调线程间共享数据访问的规则,目的是处理线程安全问题

广义上讲,java提供的同步机制有:锁、volatile、final、static、wait()/notify()等

线程安全问题产生的根源是对共享资源的并发访问,最简单的解决方法就是将并发访问转换为串行访问,转换方法就是使用锁,即共享资源一次只能给一个线程访问

锁能够保护共享数据的安全,包括保障了 原子性、可见性和有序性

在java平台中,锁的获得隐含着刷新处理器缓存的动作,锁的释放则隐含着刷新处理器缓存的动作,所以可以保证可见性

相当于在加锁和解锁的过程中产生了一个临界区,在这个临界区中的操作都是原子的。

锁能生效的前提有两个:

  1. 一个共享资源使用同一个锁

  2. 不管是写还是读,都要先获取到锁

synchronized

java中,任何一个对象都有一个与之关联的锁,称之为监视器或内部锁,它是一种排他锁,可以保证原子性、可见性、有序性。同步方法的整个方法体就是一个临界区

Lock接口

显式锁是 java.util.concurrent.locks.Lock 接口的实例,例如 java.util.concurrent.locks.ReentrantLock

相比之下,内部锁更简单易用,不容易导致锁泄漏(意外不释放锁),但显式锁灵活性更高

内存屏障

内存屏障是被插入两个指令中间的一个指令,作用是禁止编译器、处理器对屏障前后的指令重排序从而保证有序性。它就像指令中间的一堵墙,使得其两侧的指令无法穿越它(得看具体什么屏障,屏障一般都是单向屏障,要实现两侧屏蔽效果可以使用两种单向屏障)

但是,要知道可见性是有序性的基础,所以内存屏障还需要保证可见性,即刷新或冲刷处理器缓存。

当然,单一的内存屏障可能只有单一的功能,例如只能刷新缓存或者只能阻止之后/前的读写指令重排到屏障前/后,可以组合使用。

有序性的保证是根据 Acquire/Release 屏障实现,可见性的保证是依据 Load/Store 屏障实现,例如 synchronized 的内存屏障就是下面这样用的:

内存屏障的使用使得中间代码形成了一个临界区,需要注意的是

  1. 临界区内部的代码无法重排序到外部,但是外部的代码可以重排序进临界区。如此,从外部看来就保证了临界区代码的有序性

  2. 临界区可以看成是一个整体,临界区两边的代码也可以重排序

volatile

volatile也可以称为一种轻量级锁,它不会引起上下文的切换,只能保证可见性和有序性,但对于long和double来说,虽然是多步操作,但也能保证其读写的原子性。另 volatile 也能保证对单变量或引用修改的原子性(比如给某个volatile变量赋值)

对volatile变量的读写操作使用的是不同的内存屏障

写之前要先禁止前面的读写操作与该变量的写操作重排序,写完后还要冲刷缓存

读之前要先刷新缓存,读完后还需要禁止读操作与下面的读写操作重排序

如果volatile修饰的是数组,则只会影响数组的引用,而对数组元素的操作仍然不具有volatile特性。若要实现该功能,可以使用 AtomicIntegerArray等。对于引用型数据类型同理,你只能保证该引用地址的可见性,而对地址中的内容并不具有可见性

CAS

CAS能够将 read-modify-write 或 check-and-act 之类的操作转为换原子操作,CAS本身就说一个原子操作,它是处理器的一个原语指令

原子变量类 Atomics

基于volatile和CAS对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。例如可以实现原子的 count++ 操作

它包含:

final和static

java中的类采用了延迟初始化,即一个类被jvm加载后,其所有的类变量都只是零值,只有当首次访问该类的任意一个静态变量才会调用其初始化代码

static仅仅保证读线程能够读取相应字段的初始值(初始值也有一个从零值赋值的过程),而不是相对新值。对于引用类型,他能保证该变量的引用对象已经初始化完毕(执行完构造器的所有代码),故static的可见性和有序性的保证仅在一个线程初次读取静态变量的时候起作用

一个对象中的非静态变量就不能保证初始值能被正确读取了,可能读取到零值,但是如果给它加上final就有这种保证了。

当一个对象被发布出去的时候,可以保证该对象所有的final属性都是初始化完毕的

假如有一个对象如下:

class A {
    int n1;
    final int n2;
    
    public A(n1, n2) {
        this.n1 = n1;
        this.n2 = n2;
    }
}

那么以下这段最简单的代码都可能产生安全问题:

A a = new A(1, 2);

new Thread(()->{
    System.out.println(a.n1);
}).start();

因为JIT可能会将代码内联为:

ref = allocate(A.class);  // 分配内存
ref.n1 = n1;
ref.n2 = n2;

A a = ref  // 发布对象

如此一来,代码就有被重排序的可能,由于 n2 被final修饰,所以在发布对象前,它能保证被正确赋值,但是n1的赋值语句可能被重排序到发布对象之后。进而引发安全问题

安全发布和逸出

在构造器中将this赋值给一个共享变量是非常不安全的,这是因为构造器在赋值说明构造器还没执行完,说明对象还没有构造完,而此时如果提前就将this暴露出去其他地方就可能引用到未构造完成的对象。

为了避免this代表的当前对象逃逸到其他线程,应该避免在构造器中启动新的线程。因为创建内部类时,会将this传入到内部类中,可能引起安全问题

如果要安全地发布一个对象,则可以:

  1. 使用static修饰引用该对象的变量

  2. 使用final修饰引用该对象的变量

  3. 使用volatile修饰。。。

  4. 使用AtomicReference来引用该对象

  5. 对对象的访问加锁

线程使用

可以通过 int n = Runtime.getRuntime().availableProcessors() 获取逻辑核数

对于CPU密集型的线程,可以将线程数设置为 n+1,加1是因为线程可能由于某些原因(例如缺页中断等)被切出,此时可以让多的一个线程立刻补上

对于IO密集型的线程,可以设置更多线程以应付cpu空闲,但不建议设置过多线程,因为每次io都可能导致上下文的切换,如果线程多的话,则会出现频繁上下文切换,可以考虑从1开始尝试向2n靠近

但是一般商用服务器还会限制cpu使用率不能超过一个阈值以应对突发情况(例如75%),亦或者线程任务本身就不好判断是cpu密集还是io密集,此时就可以按照下面这个公式来设置:

线程数 = cpu数 * 使用阈值 * (1 + 单任务IO等待时间 / 单任务cpu计算时间)

io等待时间/cpu计算时间可以通过 jvisualvm提供的数据进行计算

当然,真正用的时候肯定不能死套公式,还需要考虑其他程序的情况、网络情况、集群部署等各种因素

线程间通信

如果一个方法会导致线程状态转换为 wait 或 block,则称之为阻塞方法,可能导致上下文切换。反之,如果不会导致线程暂停,则称之为非阻塞方法

wait/notify
synchronized (lock) {
      lock.wait();  // 会抛 InterruptedException 异常
}

注:同步锁和内部的wait必须使用同一个对象,否则运行报错

wait会释放锁,所以一个线程调用notify唤醒wait线程后,如果调用notify的线程还没释放锁,则wait线程虽然被唤醒了,但还是需要等待获取锁,即从 WAIT 状态转换为 BLOCK 状态

换句话说,wait会释放锁,notify不会释放锁

notify可能会唤醒任意一个wait的线程,而notifyAll会唤醒所有wait的线程,它们一起竞争锁,没拿到锁的继续block

实现原理:java中,每个对象都会维护两个集合:Entry Set 和 Wait Set,前者用于存放申请该对象内部锁的线程,后者用于存放该对象上的等待线程。notify会任意唤醒wait set中的线程,然后竞争锁,竞争成功后才从wait set中移除

可能出现的问题:

  1. 过早唤醒:不同任务的线程都在同一个对象上wait,此时某线程调用notifyAll,那些原本不该被唤醒的线程也被唤醒了。这种情况可以使用JUC中的Condition做更灵活的等待唤醒机制

  2. 信号丢失:由于代码的原因,可能先执行了notify之后才执行wait,导致wait一直处于等待状态。这种一般是代码的错误,找自己的原因

  3. 欺骗性唤醒:可能由于系统本身原因,wait的线程在没有任何唤醒的情况下不wait了。解决方法是将wait放在一个循环代码块中,只要不满足某个条件就继续wait

  4. 上下文切换:由于wait会释放锁,而notify之后还会抢占锁,故可能导致多次锁的申请与释放,这一过程可能引起上下文切换。并且线程发生状态转换时本身也会出现上下文切换。等等,这一过程可能发生很多次上下文切换。所以应该尽量避免过早唤醒和notify后应尽快释放锁,防止出现多次线程状态转换

Thread.join(long)就是使用 wait/notify 实现的

条件变量Condition

wait/notify是一种偏底层的方法,并且可能导致过早唤醒或信号丢失的问题,JUC提供了更灵活的方法,即 Condition 的await/signal/signalAll 的方法。Condition可以通过任意显示锁的 Lock.newCondition() 方法创建

类似于每个Object的wait set,Condition内部也显式维护了一个等待队列(可能使用链表实现)

可以针对不同的任务设置不同的condition来避免过早唤醒的问题

并且condition还解决了不知道wait是超时结束还是被唤醒的问题,condition提供了awaitUntil()方法,该方法返回一个bool值,如果为true,则表示被唤醒的。

但是它们的开销差别不大

CountDownLatch

Thread.join()可以阻塞到其他线程执行结束,但有时,我们并不需要其他线程完全执行结束才继续,可能其他线程执行出某个结果后就可以了,例如:

new Thread(()->{
  ...
  // CountDownLatch 可以使得线程执行一半就能让阻塞线程继续
  ...
  // join只能等待线程彻底执行结束
}).start()

CountDownLatch 内部维护一个整型变量表示计数器,当它为0时计数器就不再发生变化,此时即使调用 countDown() 或 CountDownLatch.await() 也不会抛异常或阻塞线程,故 CountDownLatch 是一次性的,它只能实现一次等待和唤醒。

CyclicBarrier

有时多个线程可能需要互相等待对方执行到代码的某个位置,然后代码才能继续执行,就可以使用CyclicBarrier。

与CountDownLatch不同的是,CyclicBarrier是可以重用的。

它内部使用分代的概念,每一代都用一个递减的整型表示,当其减小到0时,由最后一个线程执行 signalAll()唤醒其他线程,然后进入下一代并将整型恢复为初始值

可以用于实现模拟高并发,即让所有线程准备完毕后,同时唤醒它们

Semaphore

初始化一个整型配额,调用 Semaphore.acquire() 获取一个配额,如果获取到了则立即返回并配额减一,否则等待,Semaphore.release()会释放配额,即配额+1。

所以这两个方法应该配对使用,但只能是在代码中保证这一点,如果多次release,可能导致配额越来越多,即使超过了初始值也不会报错而是继续增加,且不影响acquire,acquire只会判断配额数而不会校验初始值

线程中断

每个线程都有一个 interrupt 属性,该属性默认为false,其他任何线程都能修改这个值。

但是处不处理是你的事情,例如你可以在代码中调用 isInterrupted() 方法判断是否被其他线程设置为中断,并编写中断程序,但是,如果你的代码正在 wait 或者 sleep,该如何判断中断呢?这也是为什么 wait 或 sleep 会抛出 InterruptedException 异常的原因,当线程暂停时,其他线程设置了中断信号会抛出该异常。并且有些wait方法连异常都不抛,例如 CountDownLatch.await()

你可以通过 Thread.interrupted() 将线程中断标志位置位。

线程停止

java线程有一个stop方法可以强制停止线程,但是这种做法会引起安全问题已经被标记废除。试想你的线程正在执行一个原子操作,理论上讲,它要么成功要么失败,但是执行到一半时你强行stop,它就违反了原子操作了。

所以正确停止线程的方式是使用线程中断或者一个volatile变量作为通知,由任务线程自行处理结束过程。即正确的线程停止过程需要发送停止信号的线程和任务线程一起配合完成。

Java如何保障线程安全

无状态对象

无状态对象:所谓状态简单说就是共享数据(允许读写的),无状态对象其实指的就是对象中包含了多线程可共享的数据。

无状态对象具有线程安全性,即调用该对象的任何方法都无需加锁,并且无状态对象自身的方法实现上也无需使用锁

无状态对象是不包含任何实例变量或者任何可更新的静态变量的,但这只是必要条件,例如如果无状态对象的某个方法中使用了某个全局变量或全局单例对象,则该方法仍然需要加锁,所以仍然不能称之为无状态对象

那么问题来了,既然无状态对象不包含任何实例变量,那么它的各个实例本质上应该都是一样的,调用任何实例的任何方法效果应该也是相同的,那为什么不直接将这些方法写成静态方法,而是去创建一个无状态对象再调用呢?

首先,使用静态方法是没问题的,但是,java语言具有抽象的特点(abstract),对于一些相同的功能但是实现方式不同的代码,我可以定义多个静态方法,但更好是实践是我先定义出一个抽象的接口和方法出来,然后根据实际情况传入不同的实现。例如java中很多排序的方法就要求你传入一个 Comparator 的实现实例。

不可变对象

不可变对象在不加锁的情况下也天然具备安全性

所谓不可变对象需要满足以下所有条件:

  1. final修饰的类实例,这是为了防止子类改变其方法

  2. 所有字段都是final修饰的,它有两重意思:1)防止被修改。2)被final修饰的字段在初始化实例后一定是创建好的

  3. 构造函数没有this逃逸。例如构造函数中创建匿名内部类

  4. 任何字段,若其是引用类型且数据内容可变,则这些字段必须为private修饰,且不能直接通过get等方式暴露出去,最起码应该包装成不可变的形式暴露。因为即使是final修饰的字段,其内容仍然是可变的。例如 final List l; 你仍然可以修改list中的元素。这种情况下,就能通过实现 Iterable 接口,让外部使用迭代器进行遍历

事实上,可以将不可变对象直接看成是一个 final 修饰的基本数据类型,它在任何情况下的使用 都是线程安全的

String就是一个典型的不可变对象

使用不可变对象有利有弊:

弊:每次对不可变对象的修改都是创建一个新的不可变对象,所以对内存要求更大

利:对于垃圾回收来说,年轻代对象引用老年代对象相比于老年代对象引用年轻代来说,更有利于垃圾回收,这是因为对于后者来说,回收老年代的对象时,需要将包含该对象的卡表中的对象全部扫描一遍,以确定老年代中是否还有对该对象的引用,这会增加开销。而使用不可变对象是前者,不会有这种开销(这里没搞通为什么是前者,后者具体为什么开销大也没整太明白)

ThreadLocal
static ThreadLocal<Integer> tl = new ThreadLocal<>();  // threadLocal对象一般作为某个类的静态字段使用
new Thread(() -> {
  tl.set(2);
  tl.get(); // 2
}).start();

new Thread(() -> {
  tl.set(3);
  tl.get(); // 3

  ThreadLocal<Integer> tl2 = new ThreadLocal<>();  // 线程内创建ThreadLocal也行
  tl2.set(5);
  tl2.get();  // 5
}).start();

每个线程内部都有一个 ThreadLocalMap 对象,创建一个ThreadLocal对象后,在线程内调用set方法,会将该ThreadLocal对象作为key,将其set的值作为value存到ThreadLocalMap中,所以,这个ThreadLocal对象不管在线程外还是线程内创建都可以。

可能导致的问题:

  1. 一个线程可能执行多个任务,而每个任务所需的数据是不同的,此时可能发生数据错乱,需要从代码层面解决该问题

  2. 内存泄漏

装饰器模式

装饰器模式实现线程安全的思想是:为非线程安全的对象创建一个相应的线程安全的外包装对象。访问时只能通过外包装对象访问真实对象

java中有 java.util.Collections.synchronizedList/Set/Map等可以实现该功能

缺点是通过上述方法可以得到迭代器对象,但是该对象并非线程安全,访问该对象还是需要加锁。再者这种方法锁的粒度比较大,容易引起竞争

并发集合

JUC提供了很多线程安全的集合对象,例如 CopyOnWriteArrayList、ConcurrentHashMap 等。它们的使用本身就是线程安全的,无需额外加锁

总的来说,它们实现线程安全的遍历有两种方式:

  1. 使用快照:在并发过程中,每个线程拿到的数据都是数据某个版本的快照,所以比较安全。缺点是集合得到的Iterator不支持remove,会报异常,另外如果集合比较大,创建快照会有较大的内存或处理器开销

  2. 使用CAS或者一些小粒度的锁

线程的活性故障

死锁

必要非充分条件(只要有死锁,一定都成立):

  1. 资源互斥

  2. 资源不可抢夺

  3. 占有并等待

  4. 循环等待资源:等待的线程必须形成一个环

解决方法:

  1. 使用粗粒度锁,消除条件3

  2. 将锁排序后,只能按序申请,消除条件4

  3. 设置等待锁时间,消除条件3

死锁的恢复

如果死锁已经产生该怎么办?

定义一个线程专门检测死锁,如果检测到系统的死锁,则随机给死锁线程发送一个中断。

但死锁的恢复操作性并不强,一是因为中断不一定会被处理,例如使用显式锁阻塞的线程就不会处理中断,这种情况下只能重启JVM,再者就是就算处理了,指不定什么时候又出现了。第三就是,处理死锁后可能带来逻辑上的问题,可能破坏一致性

锁死

线程被唤醒的条件永远不会成立,则称之为锁死。例如:

  1. 信号丢失:notify先于wait调用,或者CountDownLatch.countDown()没有放到finally中

  2. 嵌套锁锁死,见下图:受保护方法执行到monitorY.wait()后释放monitorY并等待,但是monitorX并没有被释放,所以此时通知方法就会一直阻塞在monitorX上,这种情况看起来很像死锁,但它并不是死锁,因为第一个线程并发阻塞状态,而是等待状态,所以使用死锁检测的方法不能检测出死锁状态。

线程饥饿

非公平模式下使用读写锁导致的,读锁不互斥,所以可以不停地获取锁,导致写锁永远无法获取。

活锁

一直尝试申请锁,但一直申请不到,即线程一直在运行,但没有一点进展。

线程管理

如何更好地使用线程

捕获异常退出的线程

线程出现未捕获的异常可能导致线程退出,此时可以给线程设置一个UncaughtExceptionHandler,当异常退出后该怎么办:

thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("bingo");  // thread线程异常退出后调用该方法
    }
});
线程池

线程的创建和销毁需要消耗资源,并且需要创建内核线程和栈空间与之绑定,使用线程池可以有效减少这方面的消耗。

java中的线程池对象是 java.util.concurrent.ThreadPollExecutor

使用 shutdown() 方法关闭线程池时,已提交的任务会继续执行,新提交的任务会执行拒绝策略。shutdownNow()会使得任务停止,其返回已经提交但尚未执行的任务,但是,其内部时使用interrupt机制来停止正在执行的任务的,所以那些无法响应中断的任务可能永远也不会停止。

当线程正在执行的任务需要等待尚在任务队列中的任务执行结果时,线程池可能发生死锁。所以,这种有依赖关系的任务应该交给不同的线程池去处理

java中常用的四种线程池类型(通过 Executors 的静态方法创建):

JMM

硬件层面上来说。每个cpu都有自己的缓存,多个cpu共享主内存,JMM的作用就是屏蔽硬件的差异。

但存在缓存就可能出现不一致的情况,就需要使用缓存一致性协议,其实质就是如何防止读脏数据和更新丢失的问题,MESI是一种常见的缓存一致性协议:

MESI(Modified-Exclusive-Shared-Invalid),x86处理器就是使用该协议,它有点像读写锁,读数据是共享的,写数据是独占的。MESI是缓存中各个数据的四个状态,当为M时,表示该缓存内的这条数据已经被当前处理器修改了(当前缓存和主存已经不一致了)。当为E或S时,表示缓存和主存一致,当为I时,表示当前这条数据没有在缓存或者缓存失效。MESI还定义了处理器之间的一些通信协议,例如当一个cpu要读取一条数据时,当它发现该数据缓存内状态为I时,则会向主存发送一个read消息,此时其他cpu可以拦截该消息,如果其他cpu内有修改后的数据,则会立即刷新到主存并返回给该cpu一个数据返回。当要修改某数据时,会先抢占“锁”,即发送一条消息让所有cpu缓存中的这条数据失效(置为I)并且会阻塞其他处理器对该数据的操作,然后在缓存中修改该数据,并将状态置为M,此时,其他cpu要读取该数据时,它就可以拦截读的请求并将该数据同步到主存并返回该数据。

内存重排序

MESI的缺点是它对数据的写会阻塞其他cpu对该数据的操作,解决方法是给高速缓存再引入一个写缓存(缓存的缓存),当数据状态为M或E时,直接将数据写入写缓存而不去获取“锁”,但是这又会引发新一轮的缓存的缓存一致性问题,并且可能导致指令重排序的效果,即当修改了一个数据后,数据写入写缓存,写缓存与高速缓存和主存同步需要时间,这段时间可能执行其他指令,从其他cpu角度来看,这个修改数据的操作就被重排序到其他那些指令后面了。这种重排序称为 StoreLoad 重排序,还可能导致StoreStore、LoadStore、LoadLoad重排序。但不同的处理器可能只允许部分重排序效果

可见性

出现可见性的问题可能有三个原因导致的:写缓存、无效队列(见下)、存储转发。它们三个都是MESI的优化手段

由于写缓存的存在,一个处理器对数据的修改并不会立即同步回主存,也不会阻塞其他处理器对该数据的读取,所以其他处理器可能读到过期数据,这就是可见性问题的根源。

内存屏障中的 Store Barrier 就有冲刷处理器的写缓存的功能,但是冲刷写缓存只是解决了可见性的一半,可见性的另一半原因是无效队列导致的,无效队列也是MESI的一个优化手段,即处理器收到将某缓存数据置为无效的消息后,并不是立即将其置为无效,而是将该数据加入无效队列然后立即返回,这样可以减少消息的响应时间。

所以,有无效队列的情况下,如果数据被处理器加入了无效队列,但随后并未真正将其缓存数据删除,还是可能读取到过期数据。

内存屏障中的 Load Barrier 就是用来解决这个问题的,它回强制使得无效队列中的数据都标记为I。

另外存储转发也可能导致可见性问题,大致是因为读取了写缓存中的旧值而没读高速缓存中的新值导致的。

内存屏障

处理器支持哪些内存重排序(LoadLoad、LoadStore、StoreStore、StoreLoad)就会提供相应的禁止重排序的指令。基本形式为 XY内存屏障,其中X和Y可以表示Load或Store,XY内存屏障表示的意思是:指令指令左边的X操作不会和右边的Y操作重排序,比如StoreLoad屏障就表示该指令之前对数据的写操作一定发生在该指令之后的任意读操作之前。

另一方面也说明,XY屏障两侧的指令可以在单侧重排序,并且非XY的操作可以跨越屏障进行重排序

注:内存屏障中的Load和Store是对所有数据生效的,例如:

A = 1
B = 2
// 此处插入 StoreStore 屏障意思是:当代码执行到下方的C=3时,A和B已经赋值了
C = 3

例如LoadLoad屏障就是通过清空无效队列来实现禁止LoadLoad重排序的,StoreStore屏障可以通过对写缓存中的条目进行标记来实现禁止StoreStore重排序,对数据进行标记后,则该数据的写操作要先于该屏障之后的写操作提交到高速缓存或主存

java中获取屏障和释放屏障相当于是由多个内存屏障组成的复合屏障,例如获取屏障就相当于同时使用 LoadLoad 和 LoadStore 来避免屏障之前的读与其后面的读写重排序,释放屏障相当于 LoadStore 和 StoreStore 一起用,避免之后的写与之前的读写重排序

volatile的实现就是,假如有一个volatile变量V,对V的修改前会插入 LoadStore 和 StoreStore 屏障,以保证修改V之前所有变量都正确赋值了,对V的读取后会插入 LoadLoad 和 LoadStore 屏障就能保证对V的读取先于之后变量的读写。总之,就是对volatile的写要在所有读写之后,读要在所有读写之前

JIT编译器会在volatile变量写之后插入StoreLoad屏障,它是一个功能强大的屏障,可以实现其他三个屏障的功能,并且可以充当加载屏障,JIT会在volatile读操作前插入加载屏障,相当于LoadLoad

Java对synchronized的实现与volatile实现相似,其在获取锁和释放锁的前后也是加上了一些内存屏障,再加上锁具有排他性,两者结合就确保了临界区内操作的原子性

x86处理器只支持StoreLoad重排序,所以它也只有一种内存屏障,也就是说,x86下,只会在volatile写操作后插入StoreLoad屏障即可,其他操作都不需要内存屏障

内存屏障的缺点就是它可能会阻止一些编译器或处理器的优化,以及对写缓存和无效队列的操作,这俩也是比较耗时的,因此,jvm可能会对多个内存屏障进行合并或省略等优化

final修饰的变量可以保证在使用前已经正确地初始化了,它也是通过内存屏障实现的

jmm

java内存模型并非一个物理模型,而是一个抽象的,通过多种功能配合实现的一种机制,这个机制的目的是为了解决 原子性、可见性、有序性 的问题。

jmm可以通过synchronized、volatile、final手动指明可见性或有序性,但是有些操作其本身就需要保证可见性,例如一定是要先启动线程,然后才能run线程,试想如果jmm连这两个过程的有序性都不能保证,那么java开发得有多复杂,所以jmm天然就内置了一些可见性和有序性的规则,即happens-before,jmm已经能够确保它们是可见的有序的

jvm对非long和double可以保证原子性,对于可见性和有序性,jmm使用happens-before来解答,更准确地来说,happens-before是jvm可见性的一系列保证,而有序性就是建立在这些可见性上的(见前文有序性的描述)。

例如,jvm规定了一系列动作,入变量的读写、锁的申请与释放、线程的启动与join等,假如两个动作A、B直接有happens-before关系(即逻辑上有前后关系),则jmm可以保证A的操作结果对B是可见的,即A的执行结果会在B之前提交到高速缓存或主存。例如 happens-before 规定了线程的启动(start)一定先于线程的运行(run)

注:happens-before不仅保证单线程的可见性,同时保证多线程下的可见性

例如,如果 A happens-before B,B happens-before C,则可以表示为 A -> B -> C,具有传递性

有了happens-before规则后,我们就能知道代码中任意两个动作之间是否存在可见性的关系。

Tags: