最近在学习并发编程,有很多东西,在实际项目开发中很少见到。比如volatile关键字,所以今天分析一下为什么这个关键字很少用。未完待续。。。
要了解这个关键字为什么很少用,就需要分析这个关键字的作用、实现原理、使用场景,这也是这篇文章的中心内容
volatile关键字的作用
概括来说,volatile关键字有以下作用:
- volatile关键字仅用于修饰变量(无法用于修饰方法),保证变量的可见性(但不能保证操作的原子性)
- 禁止JVM的指令重排序
实现上述功能的原理
保证变量的可见性
-
线程写volatile变量的时候,改变线程工作内存中的变量副本的值之后,会立马将改变后的值从自己的工作内存刷新到主内存。cpu总线嗅探机制检测到主内存的变量发生变化之后,会将其他工作内存中的变量缓存置为无效。
-
线程读volatile变量的时候,如果缓存被置为无效,则会直接从主内存读最新值到线程的工作内存中,然后再从工作内存中读volatile变量的副本,进行操作
整个过程中,不同线程都能看到变量的最新值,保证了变量的可见性。但无法保证原子性。
什么是原子性(补充内容)
定义: 即一个操作或者多个操作,要么全部执行并且不被打断,要么就都不执行。
比如:从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。
再比如:i = i+1;其中就包括,读取i的值,计算i,写入i。这行代码在java中是不具备原子性的,如果多线程运行肯定会出问题。
综上可知,对变量的写操作不依赖于当前值才是原子级别的,在多线程环境中才可以不用考虑多并发问题。比如:n=n+1、n++ 就不行。n=m+1才是原子级别的,实在没把握就使用synchronized关键字来代替volatile关键字。
禁止JVM指令重排序
内存屏障(补充内存)
内存屏障是基于特定硬件的,具体展开来非常的复杂。简单来说,内存屏障分两种:读屏障和写屏障。内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
这里的缓存主要指的是CPU缓存,如L1,L2等
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load(A) ->LoadLoad ->Load(B) | 保证load(A)的读取操作在load(B)之前执行 |
StoreStore | Store(A)-> StoreStore->Store(B) | 保证在执行Store(B)之前,Store(A)的写操作已经刷新到主内存中 |
LoadStore | Load(A)->LoadStore->Store(B) | 保证在执行Store(B)之前,Load(A)已经读取结束 |
StoreLoad | Store(A)->StoreLoad->Load(B) | 保证Load(B)读操作之前,Store(A)的写操作已经刷新到主内存 |
禁止指令重排序的原理
通过在指令序列中插入内存屏障来禁止volatile变量的指令重排序。
- 在每个volatile变量的写操作前插入StoreStore屏障:保证普通写操作刷新到主内存之后,再进行volatile写;在每个volatile变量的写操作之后插入StoreLoad屏障:保证volatile写刷新到主内存之后,再执行后续的volatile变量读操作。
- 在每个volatile变量的读操作后插入一个LoadLoad屏障:保证volatile变量的读操作完成之后,再进行后续所有的读操作;在每个volatile变量的读操作后再插入一个LoadStore屏障:保证volatile变量的读操作完成之后,在进行后续的所有写操作。
应用:双重校验锁,实现对象单例
java
//双重校验锁,实现对象单例(线程安全)
public class VolatileSingleton {
private volatile static VolatileSingleton uniqueInstance;
private VolatileSingleton(){
}
public static VolatileSingleton getUniqueInstance(){
if (uniqueInstance == null) {
synchronized (VolatileSingleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new VolatileSingleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance
采用volatile
关键字修饰也是很有必要的,uniqueInstance=new VolatileSingleton()
这句代码,不是原子操作,需要分三步执行:
- 为
uniqueInstance
分配空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
如果不用volatile
关键字修饰,JVM进行指令重排序,执行步骤有可能变为:1-3-2。单线程下,指令重排没有问题,但是多线程环境下可能会出现以下现象:
- 线程T1执行了1和3,
- 线程T2调用
getUniqueInstance()
之后发现uniqueInstance
不为空,则会直接返回uniqueInstance
,但此时该对象还没有被初始化。
参考文章:
volatile关键字无法保证原子性,会导致什么问题
示例:
java
// 实现多线程的方法之一,继承Thread类,并重写run方法
public class VolatileTest extends Thread{
static volatile int increase = 0;
static AtomicInteger aInteger = new AtomicInteger(); // 对照组
static void increaseFun() {
// 要运行在多线程环境下的方法
increase++;
aInteger.incrementAndGet();
}
//要让线程运行代码,必须要重写run方法;否则会调用父类的run方法,执行的是空方法。
//重写run方法的方式来指定我们的线程任务(所以run方法里面就是线程要执行的任务)
public void run() {
// 线程要运行的代码
int i = 0;
while (i < 10000) {
increaseFun();
i++;
}
}
public static void main(String[] args) {
VolatileTest vt = new VolatileTest();
//陆续启动10个线程同时执行
int THREAD_NUM = 10;
Thread[] threads = new Thread[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
threads[i] = new Thread(vt, "线程" + i);
//启动线程
threads[i].start();
}
//idea中会返回主线程和守护线程,
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println("volatile的值: "+increase);
System.out.println("AtomicInteger的值: "+aInteger);
}
}
上述代码运行结果:
java
volatile的值: 63326
AtomicInteger的值: 100000
出现上述情况的原因
这个程序我们跑了10个线程同时对volatile修饰的变量进行10000的自增操作(AtomicInteger实现了原子性,作为对照组),如果volatile变量是并发安全的话,运行结果应该为100000,可是多次运行后,每次的结果均小于预期值。显然上文的说法是有问题的。
volatile修饰的变量并不保证原子性,所以在上述的例子中,用volatile来保证线程安全不靠谱。我们用Javap对这段代码进行反编译,为什么不靠谱简直一目了然:
-
getstatic指令把increase的值拿到了操作栈的顶部,此时由于volatile的规则,该值是正确的。
-
iconst_1和iadd指令在执行的时候increase的值很有可能已经被其他线程加大,此时栈顶的值过期。
-
putstatic指令接着把过期的值同步回主存,导致了最终结果较小。
很多人会误认为自增操作 increase++
是原子性的,实际上,increase++
其实是一个复合操作,包括三步:
- 读取 increase 的值。
- 对 increase 加 1。
- 将 increase 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
increase
进行读取操作之后,还未对其进行修改。线程 2 又读取了increase
的值并对其进行修改(+1),再将increase
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
increase
的值进行修改(+1),再将increase
的值写回内存。
这也就导致两个线程分别对 increase
进行了一次自增操作后,increase
实际上只增加了 1。
改进代码,使其具有原子性
其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以。
- 使用
synchronized
改进increaseFun()方法
java
static synchronized void increaseFun() {
// 要运行在多线程环境下的方法
increase++;
}
- 使用
Lock
改进increaseFun( )方法
java
Lock lock = new ReentrantLock();
public void increaseFun() {
lock.lock();
try {
increase++;
} finally {
lock.unlock();
}
}
- 使用
AtomicInteger
改进(见上述代码中的对照组)
以下内容待确认
另外,再说一句,单独的volatile 不能保证原子性,但是当它配合上CAS 之后,就能实现无锁的同步(乐观锁方式)
这种方式,在
JUC
中有很多很多的例子,很经典的就是AtomicInteger
、LongAdder
之类的原子类。
javapublic class AtomicInteger { // ... private static final Unsafe unsafe = Unsafe.getUnsafe(); // ... private volatile int value; // ... }
参考文章
使用场景
Java的一些类库:CopyOnWriteArrayList、ConcurrentHashMap
有些地方会说 volatile 是一种轻量级的同步方式,实际上这里指的是它对于内存可见性 的作用。如果要更准确的表达的话,volatile 应该成为是轻量级的线程操作可见方式。如果是在多写场景下的话,他并不能提供所谓的"同步"功能,还是会产生原子性的问题。
但是,如果是一写多读 的场景,使用volatile 会变得十分的合适,在保证内存可见性 的同时,不会像synchronized 那样会引起线程上下文的切换和调度 (独占锁,会阻塞其他线程),相较起来使用和执行成本会更低。
典型的应用是 CopyOnWriteArrayList
。它在修改数据时会把整个集合的数据全部复制出来, 对写操作加锁,修改完成后, 再用 setArray()
把 array 指向新的集合。使用 volatile 可以使读线程尽快地感知 array 的修改, 不进行指令重排,操作后即对其他线程可见。
源码大致如下:
java
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final long serialVersionUID = 8673264195747942595L;
/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index);
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
final void setArray(Object[] a) {
array = a;
}
因为volatile关键字不保证原子性,并发情况下,很难准确分析是不是会有问题,为了避免出错,所以大多数会直接使用锁。
参考文章