当前位置: 首页 > 科技观察

深入剖析Java的Volatile实现原理,再也不怕面试官问

时间:2023-03-21 19:19:00 科技观察

上一篇我们讲了synchronized的用法和实现原理。我们总是说synchronized是重量级锁,volatile是轻量级锁。为什么volatile是一种轻量级锁,以何种方式体现?而volatile的作用和实现原理是什么?本文将带你一起学习。1、什么是挥发性?Volatile是Java提供的一种轻量级的同步机制。与同步修改方法和代码块不同,volatile仅用于修改变量。而且与synchronized、ReentrantLock等重量级锁不同,volatile更轻量,因为它不会引起线程上下文切换和调度。2.volatile的作用在说volatile的作用之前,先说一下并发编程的三大特性:原子性、可见性和顺序。原子性是指一个或多个操作作为一个整体执行,或者全部执行或不执行,执行过程中不会被线程调度机制打断;一旦这样的操作开始,它将一直运行到结束。不会有任何上下文切换。可见性可见性是指当多个线程访问同一个变量时,一个线程修改变量的值,其他线程可以立即看到修改后的值。有序性为了提高程序的执行效率,编译器会对编译后的指令进行重新排序,即代码编写的顺序不一定是代码执行的顺序。在并发编程中,只有同时满足这三个特性,才能保证程序的正确执行。volatile只保证可见性和顺序,不保证原子性。volatile的作用只有两个:保证内存的可见性和禁止JVM内存重排序(guaranteedorder)。在并发多线程的情况下,为什么会出现可见性问题?如果没有控制,为什么其他线程不能立即看到一个线程修改的共享变量的值呢?这就需要说到JMM(JavaMemoryModel,Java内存模型)。3.什么是JMM?JMM(JavaMemoryModel,Java内存模型)定义了程序访问变量的规范,以屏蔽不同操作系统之间的差异。由于Java共享变量存放在主存中,而Java线程不能直接访问主存中的数据,只能将主存中的数据读取到本地内存中(相当于复制一份),然后修改本地内存中的数据,然后写回主存。这时另一个线程也将主存中的数据复制到自己私有的本地内存中。虽然线程1修改了主存中的slave数据,但是线程2无法感知,所以存在内存可见性问题。4.可见性问题JMM定义的模型会存在可见性问题。当线程1修改本地内存中的数据并刷新到主存中时,其他线程中本地内存中的数据不会发生变化。即一个线程修改了一个共享变量的值,其他线程不能立即感知到。如上流程所示,两个线程都将count=0的变量复制到自己私有的本地内存中,线程1将count的值修改为1,并写回主存,而线程2本地的count值内存还是0。那么volatile是怎么解决可见性问题的呢?volatile主要使用汇编锁前缀指令,会锁定当前内存区的缓存(缓存行),并立即将当前缓存行的数据写入主存(耗时很短),而在写入时回到主内存,它会使用MESI协议。其他线程缓存的变量地址变为无效,导致其他线程重新从主存中读取数据给自己的工作线程。什么是MESI协议?MESI协议(ModifiedExclusiveSharedOrInvalid)是每个处理器在访问缓存时遵循的一致性协议。其核心思想是:当CPU写入数据时,如果发现被操作的变量是共享变量,即在其他CPU中有该变量的副本,则发送信号通知其他CPU使该变量失效变量的cacheline,所以当其他CPU需要读取这个变量的时候,发现自己的cache中缓存这个变量的cacheline失效了,那么就会重新从内存中读取。MESI分别表示缓存行数据的四种状态,通过切换这四种状态来达到管理缓存数据的目的。状态描述监控任务M修改(Modify)缓存行有效,数据被修改,与内存中的数据不一致,数据只存在于该缓存行中。缓存行必须始终监视所有读取与缓存行对应的内存的尝试。对于其他缓存,只有将缓存行回写到内存,状态设置为E后,才能操作该缓存行对应的内存数据。独占,独占(Exclusive)该缓存行有效,数据为与内存中的数据一致。数据仅存在于该缓存行中。cacheline必须监听其他cache读取主存中cacheline对应内存的操作。一旦有这样的操作,缓存行需要变为S状态S共享(Shared)缓存行有效,数据与内存中的数据一致,同时数据存在于其他缓存中。缓存行必须监听其他缓存请求该缓存行无效或独占该缓存行,将缓存行设置为I状态I无效(Invalid)缓存行数据无效,通过MESI协议实现总线嗅探技术:总线嗅探是通过CPU监听总线上发生的数据交换操作。当总线上发生数据操作时,总线会广播相应的Notification,CPU收到通知后根据本地情况进行响应。5.顺序问题虚拟机在编译代码时,对于改变顺序后不影响最终结果的代码,虚拟机可能不一定按照我们写的顺序运行代码,可能会重新排序。实际上,重排虽然不会影响变量值,但是会造成线程安全问题。重排序可以分为三种类型:编译器优化重排序。编译器可以在不改变单线程程序语义的情况下重新安排语句的执行顺序。指令级并行重新排序。现代CPU使用指令级并行来重叠和执行多条指令。对于不具有数据依赖性的指令,CPU可以改变该语句对应的机器指令的执行顺序,并对内存系统重新排序。由于CPU采用了三级缓存结构,这使得数据加载和存储操作看似乱序执行,但重排序并不是随机重排序。指令重排序的前提是不影响单线程下的执行结果,对没有数值依赖的代码进行重排序。这就像是串行语义。在多线程的情况下还有一套更具体的规则,就是happens-before原则。happens-before由以下八个原则组成:程序顺序规则:在一个线程中,按照代码顺序,写在前面的操作发生在写在后面的操作之前(线程的执行结果是有顺序的)加锁规则:解锁操作先发生下面对同一个锁volatile变量的加锁操作规则:对一个volatile变量的写操作发生在对该变量的后续读操作之前传递规则:如果操作A发生在操作B之前,则操作B发生在操作C之前,则可以得出操作A先发生在操作C之前。线程启动规则:Thread对象的start()方法先于线程的任何其他操作发生。线程中断规则:线程中断方法interrupt()的调用先发生在被调用中断线程检测到中断事件的发生。线程终止规则:线程中的所有操作都发生在线程终止检测之前。通过Thread.join()方法的结束、Thread.isAlive()方法的返回值等方式检测到线程已经终止执行。例如在A线程中调用B.join()方法时,B线程执行完成后,B对共享变量的修改对A可见。对象终止规则:初始化方法完成一个对象的finalize发生在对象的()方法开始之前,如果这两个操作不满足以上八个原则中的任何一个,那么这两个操作就没有顺序保证,虚拟机可以重新排序这两个操作。如果操作Ahappens-before操作B,那么A在内存中所做的修改对于B是可见的。而volatile是通过插入内存屏障(MemoryBarrier),禁止在内存屏障前后重新排序优化来实现有序的。内存屏障有两个作用:一是保证某些操作的执行顺序,二是保证某些变量的内存可见性。易失性内存语义的实现:JMM是基于编译器制定的易失性重排序规则表操作。正常读写。挥发性读取。挥发性写入。正常读写可以重排。ReorderingvolatilewritecanbereorderedNoreorderingNoreordering当编译器生成字节码时,它会在指令序列中插入一个内存屏障来禁止某些类型的处理器重新排序:每个volatile写操作后插入一个LoadLoad屏障在每个volatile读操作后插入一个LoadStore屏障6.Volatile应用场景Volatile可以保证可见性和有序性,但是不能保证原子性。所以它的应用场景没有synchronized那么广泛。主要有两种场景:一种是制作状态变量,另一种是制作需要重新赋值的共享对象。例如:在第二种场景中,修改单例模式的对象很常见。publicclassSingleton{//用volatile修饰,其他线程可以立即感知privatestaticvolatileSingleton实例;privateSingleton(){}publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}返回实例;}}还有,CopyOnWriteArrayList的底层实现是一个用volatile修饰的数组,因为CopyOnWriteArrayList每次修改数据时都会重新赋值数组,而不是只修改数据中的某个值,从而保证CopyOnWriteArrayList的数据安全。