3. 多线程 volatile

163 阅读10分钟

简介

volatile 关键字,使一个变量在多个线程间可见。
A B线程都用到一个变量,java默认是A线程中保留一份copy,这样如果B线程修改了该变量,则A线程未必知道。
使用volatile关键字,会让所有线程都会读到变量的修改值。

  • 可见性:volatile字段的写操作保证对所有线程可见
  • 有序性(禁止指令重排):通过volatile关键字来保证一定的“有序性”
volatile boolean running = true; 
void m() {
        System.out.println("m start");
        while(running) {
        }
        System.out.println("m end!");
}

public static void main(String[] args) {
        T01_HelloVolatile t = new T01_HelloVolatile();
        new Thread(t::m, "t1").start();
        try {
                TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
                e.printStackTrace();
        }
        t.running = false;
}
// 主线程修改了running变量,子线程能读到。不加的话,程序一直不会停止

可见性

现代计算机的内存模型

现在我们常见的多核CPU,四核8G ,当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的!缺陷是CPU的运算速度远比内存读写速度快,所以CPU大部分时间都在等数据从内存读取,运算完数据写回内存。

现代计算机系统通过在CPU和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲,这样缓存提前从主存获取数据,CPU不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。(L1,L2,L3)

image.png 基于高速缓存的存储交互很好地解决了CPU与内存的速度矛盾,但是也引入了一个新的问题:缓存一致性(Cache Coherence)。在多CPU系统中,每个CPU都有自己的高速缓存,而它们又共享同一主内存。当多个CPU的运算任务都涉及同一变量时,可能会出现变量不一致。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

JMM java内存模型

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式,JMM是隶属于JVM的。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。 JVM对Java内存模型的实现就是:线程栈区和堆区

多线程竞争

假如现在主存中有X=0,线程A和B同时去读取X,都读取到自己线程内存中,然后A去做X++操作,这时,线程A先把自己本地内存修改完成,然后和线程B通信,线程A把自己本地内存中的X刷新到主存中,最后,线程B去主存中读取B。

有序性

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

重排序类型

image.png

  • 编译器优化的重排序。在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。采用指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

重排序

  • 这3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。 image.png

as-if-serial

不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。
As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。

内存屏障——禁止重排序

volatile关键字实现原理主要还是通过内存屏障进行控制的。编译器在生成字节码时,会在指令序列里插入内存屏障来禁止特定的重排序。对于编译器来说,自行判断最小化插入屏障总数不太可能。为此,JMM采取保守策略:

  • 在每个volatile写操作的前面加入StoreStore
  • 在每个volatile写操作的后面加入StoreLoad
  • 在每个volatile读操作的后面加入LoadLoad
  • 在每个volatile读操作的后面加入LoadStore

image.png

MESI(著名的缓存一致性协议)

M:modified 被修改 E:Exclusive 独享 S:shared 共享 I:invalid 无效

image.png

  • 当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
  • 当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。

Happens-Before原则

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

volatile 引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段的可见性

boolean running = true;
volatile static T02_VolatileReference1 T = new T02_VolatileReference1();
void m() {
    System.out.println("m start");
    while(running) {
    }
    System.out.println("m end!");
}
public static void main(String[] args) {
    new Thread(T::m, "t1").start();
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    T.running = false;
}

主线程中修改的是T.running,但是代码没有停止。

volatile并不能保证多个线程共同修改running变量时所带来的不一致问题,也就是说volatile不能替代synchronized

volatile int count = 0;
/*synchronized*/ void m() {
        for(int i=0; i<10000; i++) {
                count++;
        }
}
public static void main(String[] args) {
        T04_VolatileNotSync t = new T04_VolatileNotSync();
        List<Thread> threads = new ArrayList<Thread>();
        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);
}
  • 上一个程序,可以用synchronized解决,synchronized可以保证可见性和原子性,volatile只能保证可见性
  • volatile 加不加取决于 sync的是不是原子操作

DCL 单例模式

private volatile static Instance ins = null;

/**
* DCL方式获取单例
* @return
*/
public static Instance getInstance(){
   if (ins == null){
       synchronized (Instance.class){
           if (ins == null){
               ins = new Instance();
           }
       }
   }
   return ins;
}

假设线程一执行到instance = new Singleton()这句,这里看起来是一句话,但实际上其被编译后在JVM执行的对应会变代码就发现,这句话被编译成8条汇编指令,大致做了三件事情:
1)给instance实例分配内存
2)初始化instance的构造器
3)将instance对象指向分配的内存空间
这时候,当线程一执行2完毕,在执行3之前,被切换到线程二上,这时候instance判断为非空,此时线程二直接来到return instance语句,拿走instance然后使用,接着就顺理成章地报错(对象尚未初始化)。 synchronized虽然保证了线程的原子性,但单条语句编译后形成的指令并不是一个原子操作。
解决办法就是:禁止指令重排序优化,即使用volatile变量

另一种单例模式

内部类写法,这种从jvm虚拟机上保证了单例,并且也是懒式加载。

private Singleton() {}	
private static class Inner {
        private static Singleton s = new Singleton();
}
public static Singleton getSingle() {
        return Inner.s;
}

JMM补充

JMM 全称 Java Memory Model, 是 Java 中非常重要的一个概念,是Java 并发编程的核心和基础。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

image.png

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
  • 不允许read和load、store和write操作之一单独出现,必须成对出现。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。