Java并发编程volatile关键字的作用
日常编程中出现volatile关键字的频率并不高,大家可能对volatile关键字比较陌生,再深入一点也许是听闻volatile只能保证可见性而不能保证原子性,无法有效保证线程安全,于是更加避免使用volatile,简简单单加上synchronize关键字就完事了。本文稍微深入探讨volatile关键字,分析其作用及对应的使用场景。
并发编程的几个概念简述
首先简单介绍几个与并发编程相关的概念:
- 可见性
可见性是指变量在线程之间是否可见,JVM中默认情况下线程之间不具备可见性。
- 原子性
对于a=0操作是属于原子操作,但a=a+1则不是原子操作,因为这里涉及到要先读取原来a的值,然后再为a加1,当涉及多线程同时执行该语句时,会出现值不稳定的情况,所以非原子操作在并发场景下是不安全的。
- 有序性
java内存模型中允许编译器和处理器进行指令重排优化,重排过程中不会影响单个线程的指令执行顺序,但会影响多线程环境中的运行正确性
- 指令重排
在多核CPU的情况下,为了充分利用时间片,提高指令执行效率,处理器会根据一定规则对指令进行重排序,由于规则的限定,指令重排后理论上最终运行结果不变。
volatile的主要作用
volatile的主要作用是实现可见性和禁止指令重排
- 实现可见性
在JVM内存模型中内存分为主内存和工作内存,各线程有独自的工作内存,对于要操作的数据会从主内存拷贝一份到工作内存中,默认情况下工作内存是相互独立的,也就是线程之间不可见,而volatile最重要的作用之一就是使变量实现可见性。
- 禁止指令重排
虽然指令重排理论上不会影响执行结果的正确性,但指令重排只能保证底层的机器语言重排序后结果正确,而对于Java高级语言,所以在没有干预的情况下并不能确保每条语句在编译对应的指令重排后与期望的执行效果一致。
对于以下示例,由于ready没有指定volatile,当变量ready线程间不可见时,可能导致线程中读不到ready的新值,无法停止循环;如果指令重排序,可能在线程执行前变量ready已赋值为true,导致线程内容不打印。
publicclassNoVisibility{ privatestaticbooleanready; privatestaticintnumber; privatestaticclassReaderThreadextendsThread{ @Override publicvoidrun(){ while(!ready){ Thread.yield(); } System.out.println("1"); } } publicstaticvoidmain(String[]args){ newReaderThread().start(); ready=true; } }
为什么volatile不能保证线程安全?
想要线程安全必须保证原子性,可见性,有序性,而volatile只能保证可见性和有序性。
volatile字段主要是让线程从主内存中获取值从而保证可见性,但是CPU中还有一层高速缓存——寄存器,对于非原子性操作,在底层指令运算中还是会出现数据缓存导致运算结果不正确的情况,从而无法保证线程安全。
简单来说,volatile在多cpu环境下不能保证其它cpu的缓存同步刷新,因此无法保证原子性。
为什么不直接用synchronized
synchronized可保证原子性、可见性、有序性,能有效保证线程安全,但是有个缺点是性能开销较大,而volatile是轻量级的线程安全实现方案,在某些特定场合下也能保证线程安全。由于synchronized的便捷性,也容易导致synchronized的滥用。
双重检查锁
因为volatile不能简易的实现线程安全,需要有较深入的了解才能正确使用,所以volatile也显得更为复杂,使用频率也较低,而volatile的一个典型使用例子是双重检查锁模式。
双重检查锁通常用于单例模式或延迟赋值的场景,其代码通常如下
publicclassSingleton{ privatevolatilestaticSingletonuniqueSingleton;//1.为变量添加volatile修饰符 privateSingleton(){ } publicSingletongetInstance(){ if(null==uniqueSingleton){//2.第一重检查 synchronized(Singleton.class){//3.synchronized加锁 if(null==uniqueSingleton){//4.第二重检查 uniqueSingleton=newSingleton(); } } } returnuniqueSingleton; } }
以下是对这段代码的一些疑问及解答:
Q:为什么不在getInstance方法直接加synchronized?
A:只有在第一次初始化时才需要加锁,如果在getInstance方法上加锁则每次获取实例时都会对整段代码块加锁,影响性能
Q:为什么需要双重检查?
A:如果多线程同时通过了第一次检查,其中一个线程需要通过了第二次检查才进行实例化对象,其余线程在后续等待获取到锁后则判断到变量非空,跳过赋值操作。
Q:为什么uniqueSingleton需要添加volatile关键字?
A:对于uniqueSingleton=newSingleton();语句,实际上可以分解成以下三个步骤:
- 分配内存空间
- 初始化对象
- 将对象指向刚分配的内存空间
但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就成了:
- 分配内存空间
- 将对象指向刚分配的内存空间
- 初始化对象
现在考虑重排序后,两个线程发生了以下调用:
Time | ThreadA | ThreadB |
T1 | 检查到uniqueSingleton为空 | |
T2 | 获取锁 | |
T3 | 再次检查到uniqueSingleton为空 | |
T4 | 为uniqueSingleton分配内存空间 | |
T5 | 将uniqueSingleton指向内存空间 | |
T6 | 检查到uniqueSingleton不为空 | |
T7 | 访问uniqueSingleton(此时对象还未完成初始化) | |
T8 | 初始化uniqueSingleton |
在这里添加volatile关键字主要是避免在对象未完整完成对象创建就已经被其他线程读取,造成空指针异常。
总结
- volatile的主要作用是实现可见性和禁止指令重排。
- 线程安全需要满足可见性、有序性、原子性。
- volatile可以保证可见性和有序性,但是无法保证原子性,所以是线程不安全的。(非原子操作可能会导致数据缓存在CPU的cache中,产生数据不一致)
- synchronized关键字虽然可以保证可见性、有序性、原子性,而且用法简单,但是性能开销大。
- 双重检查锁模式是volatile的典型使用场景,双重检查锁通常用于实现单例模式或延迟赋值。
以上就是Java并发编程volatile关键字的作用的详细内容,更多关于Javavolatile关键字的资料请关注毛票票其它相关文章!