Volatile原理

在说Volatile原理前先熟悉一下相关的基本概念原子性、可见性、有序性

Volatile原理

关键字VolatileJava虚拟机提供的最轻量级同步机制,当一个变量被定义为volatile后,其将具备两种特性:保证此变量对所有线程可见性禁止指令重排序。通常适用于一个线程写多个线程读的场景。

某些情况volatile的同步机制的性能确实优于锁,但虚拟机对锁进行了许多消除和优化。很难量化认为volatilesynchronized快多少;即便如此大多数场景下volatile总开销仍然要比锁低。

volatile自己与自己比较,volatile读性能消耗与普通变量几乎相同,但写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

volatile与锁之间选择唯一依据仅仅是volatile语义能否满足使用场景的需求

Volatile保证当前CPU缓存、其他CPU缓存、主内存、工作内存间的数据一致及读写逻辑代码的有序执行

可见性

volatile变量在各个线程的工作内存中不存在一致性问题,在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。若这里initFlag不加volatile关键字,线程B将感知不到initFlag值的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class VolatileVisibilitySample {
private volatile boolean initFlag = false;
public void save() {
this.initFlag = true;
String threadname = Thread.currentThread().getName();
System.out.println("线程:" + threadname + ":修改共享变量initFlag");
}

public void load() {
String threadname = Thread.currentThread().getName();
while (!initFlag) {
//线程在此处空跑,等待initFlag状态改变
}
System.out.println("线程:" + threadname + "当前线程嗅探到initFlag的状态的改变");
}

public static void main(String[] args) {
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(() -> {
sample.save();
}, "threadA");

Thread threadB = new Thread(() -> {
sample.load();
}, "threadB");

threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}

Java中的运算并非原子操作,导致volatile变量的运算并发下一样是不安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static final int THREAD_COUNT = 20;
public static volatile int race = 0;

public static void increase() {
race++;
}

public static void main(String[] args) {
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i1 = 0; i1 < 10000; i1++) {
increase();
}
System.out.println(Thread.currentThread().getId() + " completed");
}
});
threads[i].start();
}

while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}

若正确并发最后结果应为200000,但实际运行结果基本上为160000多一点。问题出在race++increase()方法的字节码可以看到,race++是由4条字节码指令完成的。

1
2
3
4
5
6
7
public static void increase();
Code:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return

题外话,这里使用的是Thread.activeCount() > 2而不是Thread.activeCount() > 1是因为我使用的是IDEAIDEA是用得反射,所以还有一个monitor监控线程。但是在JavaEclipse中是1

1
2
3
java.lang.ThreadGroup[name=main,maxpri=10]
Thread[main,5,main]
Thread[Monitor Ctrl-Break,5,main]

使用字节码来分析并发问题,仍不严谨,因为即使编译出来只有一条字节码指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语意,若是编译执行,一条字节码指令也可能转化成若干本地机器码指令,可以通过使用-XX:+PrintAssembly参数输出反汇编来分析会更加严谨。

访问volatile变量时不会执行加锁操作volatile变量是一种比sychronized关键字更轻量级的同步机制。对非volatile变量进行读写时,每个线程先从内存拷贝变量到CPU缓存中。若计算机有多个CPU,每个线程可能在不同的CPU上被处理,每个线程可以拷贝到不同的CPU cache。声明volatile变量时,JVM保证每次读变量都从主内存中读取,跳过CPU cache这一步

volatile 保证变量对所有线程的可见性,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新可见性只能保证每次读取的是最新的值,但volatile没办法保证对变量操作的原子性

指令重排序

若定义initialized变量没有使用volatile修饰,可能会由于指令重排序优化,导致A线程中最后一句initialized = true被提前执行。从而导致B线程中使用的配置信息可能出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Map configOptions;
char[] configText;
volatile boolean initialized = false;

// 线程A中执行
public void init(String fileName) {
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
}

// 线程B中执行
public void use() throws InterruptedException {
while (!initialized) {
Thread.sleep(1);
}
doSomethingWithConfig();
}

这里若不发生指令重排序,不可能出现x=0且y=0的情况,但下面的代码执行会产生该情况,证明了指令重排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static int x = 0, y = 0, a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0; y = 0; a = 0; b = 0;
Thread t1 = new Thread(() -> {
shortWait(10000);
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
log.info(result);
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}

volatile 禁止指令重排序volatile修饰的变量,赋值后多执行了一个lock addl $0x0, (%esp)操作,该操作相当于一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置,只有一个CPU访问内存时,不需要内存屏障lock的作用是使得本CPUCache写入内存,该写入操作也会引起别的CPU或别的内核无效化其Cache。所以通过该操作可让volatile变量的修改对其他CPU立即可见。

从硬件架构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给个相应电路单元处理。但并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保障程序能得出正确额的执行结果。lock addl $0x0, (%esp)指令把修改同步到内存,意味着所有之前的操作都已经执行完成,这样便形成了指令重排序无法越过内存屏障的效果

volatile内存语义的实现

重排序分为编译器重排序处理器重排序,为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。JMM针对编译器制定的volatile重排序规则表;

第一个操作 第二个操作:普通读写 第二个操作:volatile读 第二个操作:volatile写
普通读写 可以重排 可以重排 不可以重排
volatile读 不可以重排 不可以重排 不可以重排
volatile写 可以重排 不可以重排 不可以重排

第二个操作volatile时,不管第一个操作是什么,都不能重排序。该规则确保volatile写之前的操作不会被编译器重排序到volatile写之后

第一个操作volatile时,不管第二个操作是什么,都不能重排序。该规则确保volatile读之后的操作不会被编译器重排序到volatile读之前

第一个操作volatile第二个操作volatile时,不能重排序

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略:

  • 在每个volatile操作的前面插入一个StoreStore屏障
  • 在每个volatile操作的后面插入一个StoreLoad屏障
  • 在每个volatile操作的后面插入一个LoadLoad屏障
  • 在每个volatile操作的后面插入一个LoadStore屏障
1
2
3
4
5
6
7
8
9
10
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite () {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}

volatile变量内存交互操作

volatile变量内存交互操作T表示一个线程,VW表示两个volatile变量,在进行readloaduseassignstorewrite操作时满足如下规则:

该规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值。只有当线程T对变量V执行的前一个操作是load时,线程T才能对变量V执行use操作;且仅当线程T对变量V执行的最后一个操作时use时,线程T才能对变量V执行load操作。线程T对变量V的use操作可以认为是和线程T对变量V的load、read操作相关联,必须连续译器出现。

该规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量V所作的修改。仅当线程T对变量V执行的前一个操作时assign时,先吃T才能对变量V执行store操作;且仅当线程T对变量V的执行的最后一个操作是store时,线程T才能对变量V执行assign操作。线程T对变量V的assign操作可以认为是和线程T对变量V的store、write操作相关联,必须连续译器出现。

该规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。若动作A是线程T对变量V实施的use或assign操作,动作F是和动作A相关联的load或store操作,动作P是和动作F相应的对变量V的read或write操作;若动作B是线程T对变量W实施的use或assign操作,动作Q是和动作G相关联的load或store操作,动作Q是和动作G相应的对变量W的read或write操作;若A优于B则P优于Q。

C和C++

CC++中的volatile作用:优化无效代码、禁止指令重排序、直接完成简单运算、读写都走内存不走寄存器volatile是通过lock指令触发了读写屏障,通过MESI缓存一致性协议从而实现了当前CPU、其他CPU和主存之间的一致性

内存屏障

内存屏障又称内存栅栏,是一个CPU指令作用有两个,一是保证特定操作执行顺序,二是保证某些变量内存可见性,利用该特性实现volatile内存可见性。由于编译器和处理器都能执行指令重排优化。若在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

JVM中的四种内存屏障storeloadstorestoreloadloadloadstore

编译器屏障

在解决编译乱序问题时,需要使用barrier()编译屏障,在code中使用barrier()可以阻止编译器对该code的编译优化,可以防止编译屏障之前的code和编译屏障之后的code出现编译乱序

1
#definebarrier() _asm_ _volatile_("": : :"memory")
CPU屏障

是为了解决缓存一致性问题,分为锁总线(ifencesfencemfence锁缓存(lock

锁总线的方式效率极低基本不怎么用,32位机32根总线、64位机64根总线,ifence是一种Load Barrier读屏障、sfence是一种Store Barrier写屏障、mfence是一种全能型的屏障,具备ifencesfence的能力。

Lock不是一种内存屏障,但是它能完成类似内存屏障的功能,Lock会对CPU总线和高速缓存加锁,锁缓存其实首先是通过MESI缓存一致性协议来完成的。

使用场景

符合以下两条运算场景:

  • 运算结果不依赖变量的当前值,或能够确保只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

shutdown()方法被调用时,能保证所以线程中执行doWork()方法都立即停止下来。

1
2
3
4
5
6
7
8
9
10
11
volatile boolean shutdownRequested;

public void shutdown() {
shutdownRequested = true;
}

public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

双锁检查单例模式

双锁检查单例模式volatile的作用是防止高并发情况下指令重排序造成的线程安全问题

1
2
3
4
5
6
7
8
9
10
11
12
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getSingleton(){
if(singleton == null){
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}

这里若不加volatile在多线程环境下就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化,因为这里对象的初始化是分为三步,第二步和第三步可能被重排序

1
2
3
memory = allocate(); 	 // 1.分配对象内存空间
instance(memory); // 2.初始化对象
instance = memory; // 3.设置instance指向刚分配的内存地址,此时instance!=null