volatile是Java虚拟机提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级。
volatile具有三大特性:
- 保证可见性
- 不保证原子性
- 禁止指令重排序
1. JMM(Java内存模型)
Java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
JMM决定一个线程对共享变量的写入何时对其他线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下:
2. volatile保证可见性
2.1 可见性
public class TestVolatile { boolean status = false; /** * 状态切换为true */ public void changeStatus(){ status = true; } /** * 若状态为true,则running。 */ public void run(){ if(status){ System.out.println("running...."); } } }
上面这个例子,在多线程环境里,假设线程A执行changeStatus()方法后,线程B运行run()方法,可以保证输出"running....."吗?
答案是NO!
这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,先运行changeStatus方法,再执行run方法,自然是可以正确输出"running...."的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量status来说,线程A的修改,对于线程B来讲,是"不可见"的。也就是说,线程B此时可能无法观测到status已被修改为true。
所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。很显然,上述的例子中是没有办法做到内存可见性的。
对于普通的共享变量来讲,比如我们上文中的status,线程A将其修改为true这个动作发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B缓存了status的初始值false,此时可能没有观测到status的值被修改了,所以就导致了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,有点炮打蚊子的意思。比较合理的方式其实就是volatile。
volatile具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;
- 这个写会操作会导致其他线程中的缓存无效。
上面的例子只需将status声明为volatile,即可保证在线程A将其修改为true时,线程B可以立刻得知
volatile boolean status = false;
2.2 缓存一致性
在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
2.4 总线嗅探
那么是如何发现数据是否失效呢?
这里是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
2.5 总线风暴
总线嗅探技术有哪些缺点?
3. volatile不保证原子性
3.1 不保证原子性分析
原子性即不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要具体完成,要么同时成功,要么同时失败。
为了测试volatile是否保证原子性,我们创建了20个线程,然后每个线程分别循环1000次,来调用number++的方法;最后通过 Thread.activeCount(),来感知20个线程是否执行完毕,这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程;最
20 * 1000 = 20000
import java.util.concurrent.TimeUnit; /** * 假设是主物理内存 */ class MyData { /** * volatile 修饰的关键字,是为了增加 主线程和线程之间的可见性,只要有一个线程修改了内存中的值,其它线程也能马上感知 */ volatile int number = 0; public void addTo60() { this.number = 60; } /** * 注意,此时number 前面是加了volatile修饰 */ public void addPlusPlus() { number ++; } } /** * 验证volatile的可见性 * 1、 假设int number = 0, number变量之前没有添加volatile关键字修饰 * 2、添加了volatile,可以解决可见性问题 * * 验证volatile不保证原子性 * 1、原子性指的是什么意思? */ public class VolatileDemo { public static void main(String args []) { MyData myData = new MyData(); // 创建10个线程,线程里面进行1000次循环 for (int i = 0; i < 20; i++) { new Thread(() -> { // 里面 for (int j = 0; j < 1000; j++) { myData.addPlusPlus(); } }, String.valueOf(i)).start(); } // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值 // 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程 while(Thread.activeCount() > 2) { // yield表示不执行 Thread.yield(); } // 查看最终的值 // 假设volatile保证原子性,那么输出的值应该为: 20 * 1000 = 20000 System.out.println(Thread.currentThread().getName() + " finally number value: " + myData.number); } }
最终结果我们会发现,number输出的值并没有20000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性:
为什么会出现数值丢失?我们通过对addPlusPlus()这个方法的字节码文件进行分析:
//源代码 public void addPlusPlus() { number ++; } //转化后的字节码 public void addPlusPlus(); Code: 0: aload_0 1: dup 2: getfield #2 // Field n:I 5: iconst_1 6: iadd 7: putfield #2 // Field n:I 10: return
- 执行
getfield
- 执行
iadd
进行加1操作 - 执行
putfileld
把累加后的值写回主内存
假设我们没有加 synchronized
那么第一步就可能存在着,三个线程同时通过getfield命令,拿到主存中的 n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd
命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd
3.2 解决不保证原子性
public synchronized void addPlusPlus() { number ++; }
引入synchronized关键字后,保证了该方法每次只能够一个线程进行访问和操作,最终输出的结果也就为20000。
2.
/** * 创建一个原子Integer包装类,默认为0 */ AtomicInteger atomicInteger = new AtomicInteger(); public void addAtomic() { // 相当于 atomicInter ++ atomicInteger.getAndIncrement(); }
4. volatile禁止指令重排序
4.1 指令重排序
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
重排序也需要遵守一定规则:
1.重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
2.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
4.2 volatile禁止指令重排
首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
-
保证特定操作的顺序
-
保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
volatile内存语义的实现——JMM对volatile的内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。
volatile禁止指令重排序也有一些规则,如下:
- 当第二个操作是voaltile写时,无论第一个操作是什么,都不能进行重排序
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能进行重排序
- 当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序