前言上一篇深入分析了Synchronized的原理,介绍了Synchronized是一种锁机制,存在阻塞和性能问题,而volatile是最轻量级的java虚拟机。一种量级同步机制,volatile主要为修改共享变量提供“可见性”和“有序性”。从一个简单的Demo引出了我们今天的主题——volatile。Demo——多线程共享对象控制执行开关。publicclassDemo{privatestaticbooleanswitchStatus=false;publicstaticvoidmain(String[]args){newThread(()->{System.out.println("开始工作");while(!switchStatus);System.out.println("结束工作");})。开始();尝试{Thread.sleep(500);}catch(InterruptedExceptione){e.printStackTrace();}开关状态=真;System.out.println("命令停止工作");}}本意是想用switchStatus作为控制工作线程的开关,但实际执行后会发现结果并没有像预期的那样输出“endwork”,而是失去了连接,无法停止。无法脱离死循环。不过,如果对上面的Demo稍加修改,就可以达到预期:privatestaticvolatilebooleanswitchStatus=false;这时,当开关按预期关闭时,工作线程也关闭了。下面我就这两个现象和原理进行解答。为了让读者更好的理解,首先要介绍几个知识点(计算机内存模型,JMM-Java内存模型)。计算机内存模型为了更好的理解后续的JMM和volatile,我们先来了解一下计算机内存模型,简单介绍一下:程序执行时,当CPU收到指令需要进行计算时,会先尝试从主存中读取需要的数据,如果不是从主存中获取,则计算完成后,将结果写入CPUCache。如果没有特殊指令,CPUCache会根据操作系统自己定义的时间,在一段时间内刷新到主存中。内存中(未被volatile修饰的普通变量);当然,遇到特殊指令时,CPUCache会被刷新到主存中(volatile修饰的变量就是靠这个特性实现可见性)。CPU:处理程序中的各种指令,需要处理CPUCache和内存。CPUCache:由于CPU和内存的速度相差几个数量级,CPU直接和内存打交道是对CPU性能的浪费。因此引入了CPUCache来减少CPU的性能损失。缓存一致性协议/总线锁机制:CPUCache的引入减少了CPU性能损失的问题,同时引入了缓存不一致的问题。为了解决这个问题,采用缓存一致性协议/总线锁机制来解决。总线锁定机制CPU与其他功能部件通过总线进行通信。如果在总线上加一个LOCK#锁,那么在总线加锁期间,其他CPU无法访问内存,效率比较低。因此,需要对控制锁的粒度进行优化细化。我们只需要保证多个CPU缓存的相同数据是一致的即可。因此引入了缓存锁,其核心机制是缓存一致性协议。缓存一致性协议为了实现数据访问的一致性,每个处理器在访问内存时都需要遵循一些协议,读写时都按照协议进行操作。常见的协议有MSI、MESI、MOSI等,最常见的是MESI协议;MESI代表缓存行的四种状态(modify、Exclusive、Shared、Invalid)。嗅探技术如何保证当前处理器的内部缓存、主存以及其他处理器的缓存数据在总线上保持一致?多处理器总线嗅探。在多处理器下,为了保证每个处理器的缓存是一致的,会实现缓存缓存一致性协议。每个处理器通过嗅探总线上传播的数据来检查自己的缓存值是否已过期。如果处理器发现自己的cacheline对应的内存地址被修改,就会将当前处理器的cacheline设置为无效状态。当处理器修改这些数据时,它会从系统内存中重新读取数据库,在服务器缓存中进行处理。Java内存模型Java虚拟机规范试图定义一个Java内存模型来屏蔽各种硬件和操作系统的内存访问差异,使Java程序在各种平台上都能达到一致的内存访问效果。为了更好的执行性能,java内存模型不限制执行引擎使用处理器的特定寄存器或缓存来处理主内存,也不限制编译器调整代码顺序优化。因此,Java内存模型会存在缓存一致性问题和指令重排序问题。Java内存模型规定所有的变量都存放在主内存中(类似于计算机模型中的物理内存),每个线程都有自己的工作内存(类似于计算机模型中的缓存)。这里的变量包括实例变量和静态变量,但不包括局部变量,因为局部变量是线程私有的。线程的工作内存保存线程使用的变量的主内存副本。线程对变量的所有操作都必须在工作内存中进行,不能直接操作主内存。并且每个线程不能访问其他线程的工作内存。例如:#初始值i=0;#线程A和线程B同时操作i=i+1;首先,执行线程A从主内存中读取i=0到工作内存中。然后在workingmemory中,给i+1赋值,workingmemory会得到i=1,最后把结果写回mainmemory。如果是单线程的话,语句的执行是没有问题的。但是在多线程的情况下,线程B的本地工作内存和线程A的工作内存是同时读取的,i=0,但是线程A将i=1写入主内存,线程B做不知道情况接下来,i+1操作也做了,这时候可见性有问题:连续两次i=i+1最后的结果都是1。volatile可见性和有序性已经在介绍了上一篇文章深入剖析Synchronized原理,原子性、可见性、有序性的定义这里不再赘述。先说结论:依靠CPU缓存一致性协议和内存屏障解决可见性问题。正常情况下,volatile应该可以基于缓存一致性协议实现可见性(上面已经介绍了缓存一致性协议和嗅探技术),但是由于Java为了提高性能允许重排序(编译器重排序和处理器重排序),所以它是有必要通过内存屏障防止重新排序,以确保每个线程执行的每条指令都有一定的顺序。Java内存屏障java中所谓的四种内存屏障,即LoadLoad、StoreStore、LoadStore、StoreLoad,其实就是以上两者的结合,完成了一系列屏障和数据同步的功能。LoadLoadbarrier:对于Load1这样的语句;加载加载;Load2,在访问Load2要读取的数据以及后续的读操作之前,保证读取到Load1要读取的数据。StoreStorebarrier:对于Store1这样的语句;商店商店;Store2,在执行Store2及后续写操作之前,保证Store1的写操作对其他处理器可见。LoadStorebarrier:对于Load1这样的语句;加载存储;Store2,在Store2以及后续的写操作被刷出之前,保证Load1要读取的数据被完全读取。StoreLoadbarrier:对于Store1这样的语句;存储负载;Load2,在Load2和所有后续读操作执行之前,保证对Store1的写对所有处理器可见。它的开销是四个障碍中最大的。在大多数处理器实现中,此屏障是一个通用屏障,其功能与其他三个内存屏障相同。具有可变语义的内存屏障在每个可变写入操作之前插入一个StoreStore屏障,并在每个写入操作之后插入一个StoreLoad屏障。在每个易失性读取操作之前插入一个LoadLoad屏障,在每个读取操作之后插入一个LoadStore屏障。由于内存屏障的作用,避免了volatile变量等指令的重排序,实现了线程间的通信,从而使volatile表现出锁的特性。给出一个场景,其中volatile阻止指令重排。java中的DLC单例模式你应该不陌生,但是你注意到uniqueInstance被volatile修饰后的效果了吗?这是为了防止指令重排。publicclassSingleton{privatevolatilestaticSingletonuniqueInstance;privateSingleton(){}publicstaticSingletongetInstance(){if(uniqueInstance!=null){synchronized(Singleton.class){if(uniqueInstance!=null){uniqueInstance=newSingleton();}}}返回唯一实例;}}初始化一个类会生成多条汇编指令。总结起来,主要执行以下三点:给uniqueInstance的实例分配内存。初始化Singleton的构造函数。将uniqueInstance对象指向已分配的内存空间(uniqueInstance初始化按顺序在这一步完成)。理想情况下:1->2->3,但是Java为了提高性能允许重新排序,可能会改变初始化一个类的顺序,比如:1->3->2,这种情况下可能会出现NPE的时候,volatile被修改,防止重排序,避免获取uniqueInstance未初始化,导致NPE最后简单总结一下:volatile在指令之间插入内存屏障+缓存一致性协议,保证执行的特定顺序和某些变量的可见性。volatile通过通知CPU和编译器来保持顺序,以防止通过内存屏障进行指令重排优化。
