1. Volatile的定义
Java语言规范第三版中对Volatile定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致性地更新,线程应该确保通过排他锁单独获得这个变量。
在多线程的场景下,当一个线程修改一个被Volatitle修饰的变量,另外一个线程也能读到该变量修改后的值,该变量即为共享变量,被所有线程共享。volatile实则为一个轻量级的synchronized,在多线程下保证了共享变量的"可见性"。
2. Volatile的使用场景
package com.hnjd.test;
import com.hnjd.entity.User;
import java.util.Scanner;
/**
* @author lwl
* @create 2021/7/17 22:14
*/
public class VolatileTest {
//private static volatile User user = new User();
private static User user = new User();
private static String temp = null;
public static void main(String[] args) throws InterruptedException {
//线程1
new Thread(()->{
user.setName("小芳");
while (true){
if(user.getName().equals(temp)){
System.out.println("当前线程为:"+Thread.currentThread().getName()+"\tuser = " + user);
break;
}
}
}).start();
//线程2
new Thread(()->{
System.out.println("当前线程为:"+Thread.currentThread().getName()+"\tuser = " + user);
Scanner sca = new Scanner(System.in);
temp = sca.nextLine();
user.setName(temp);
}).start();
}
}
当User没有被Volatile修饰,线程2对User变量进行更改时,线程1将无法访问到修改后的值,if条件判断始转为false。而当User被Volatile修饰,User变量此时为一个共享变量,线程2对User变量进行更改后,线程1能够及时得到更改后的内容,然后打断循环,结束程序。
3.实现原理分析
由于Java的线程模型规定,一个线程无法直接读写系统内存,只能读写离自己最近的L1缓存行。之所以这样设计是因为,CPU的运算速度比直接读写系统内存速度要快一百倍,速度的不匹配导致如果让CPU直接跨硬件访问内存,此时CPU运算搁置,显然是对性能的一种浪费。所以在CPU的每个核中加了L1,L2两个缓存行,在CPU中加了一个L3缓存行,线程通过读写L1缓存行,然后L1->L2->L3->系统内存。虽然牺牲了一定量的CPU内存空间,但是换来了性能的提升,解决了CPU运算速度和读取速度的不平衡,直接读取离自己近的L1缓存行必然比跨硬件读取内存要快的多。下图是多核CPU的线程模型,理想情况下线程1和线程2各占据一个核CPU运行。
因为线程只能读取L1缓存行,所以当线程1对User发送更改时,实际上更改的只是L1缓存行中的对象实例,当L1缓存行内存发生更改就写回L2,L2在写回L3,最后L3写回系统内存。虽然写回的操作十方及时,但线程2在默认情况下并不能及时的去读取重新写回的新数据。
可能看到这里会有个疑问,CPU(不同厂商的CPU会有不同的协议去实现缓存一致性,这里默认为Intel64处理器和IA-32处理器)不是有MESI(Modified-修改,Exclusive-独占,Shared-共享,Invalid-无效)缓存一致性协议吗,为什么当User没有被volatile修饰时,多线程下线程1对L1缓存行修改的变量没有同步到线程2中呢?
这是因为,该协议并不是无条件生效的,需要Java语言层面上的volatile去触发CPU层面上的缓存一致性协议。根据MESI,线程1的L1缓存行中变量user是M修改,E独占,S共享的时候,消息总线嗅探到线程2对L1缓存行中变量user执行了写操作,此时线程1中该缓存行会置为I无效,之后在线程1对该user进行读操作时,发现是I无效状态,就会去系统内存中同步最新的值,从系统内存->L3->L2->L1,不过这里有可能也是L3->L2->L1,前提是两个线程位于同一个CPU下,L3为CPU下多核共享的一个缓存行。
了解到volatile是触发缓存一致性协议的开关后,将代码略微修改后,可以发现即使volatile没有修饰user变量,而是转而修饰temp变量,由于temp变量被线程2执行写,线程1执行读,该程序触发缓存一致性协议,此时即使user变量没有被volatile修饰,却依然被两个线程共享。代码如下。
package com.hnjd.test;
import com.hnjd.entity.User;
import java.util.Scanner;
/**
* @author lwl
* @create 2021/7/17 22:14
*/
public class VolatileTest4 {
private static User user = new User();
/**
* 避免缓存行的影响
* 使user变量和temp不在一个缓存行中。
*/
private long a1,a2,a3,a4,a5,a6,a7,a8;
private volatile static String temp = null;
public static void main(String[] args) throws InterruptedException {
//线程1
new Thread(()->{
user.setName("小芳");
while (true){
if(user.getName().equals(temp)){
System.out.println("线程结束,当前线程为:"+Thread.currentThread().getName()+"\tuser = " + user.hashCode());
break;
}
if(temp!=null){
System.out.println("temp = " + temp);
}
}
}).start();
//线程2
new Thread(()->{
System.out.println("当前线程为:"+Thread.currentThread().getName()+"\tuser = " + user.hashCode());
Scanner sca = new Scanner(System.in);
temp = sca.nextLine();
user.setName(temp);
}).start();
}
/**
* 执行结果为:
* 当前线程为:Thread-1 user = 1842520111
* 123
* temp = 123
* 线程结束,当前线程为:Thread-0 user = 1842520111
*/
}
这里只是论证并不是volatile使得user成为多线程下的共享变量,而是volatile触发的缓存一致性协议使得user成为多线程下的共享变量,保持了其可见性。实际开发不推荐这样应用,因为volatile可以触发防止指令重排序的作用。字节码指令乱序时,user变量可能未执行初始化赋值的操作便和栈中的引用关联。
4.volatile的可见性
阅读到这里,可以发现volatile是通过触发CPU的缓存一致性协议来实现可见性的,那么volatile是如何出触发CPU的缓存一致性协议呢?
当User被volatile修饰后,转译为汇编代码后会多出一条Lock前缀的指令
IA-32架构软件开发者手册可知,Lock前缀的指令在多核CPU下会触发两件事
- 将当前处理器缓存行的数据写回到系统内核。
当线程2对user进行更改时,即对L1缓存行进行更改,此时L1缓存行写回到L2->L3->系统内存。Lock前缀会锁定线程2的L1缓存行并写回内存,使用缓存一致性机制来确保修改的原子性,该操作为"缓存锁定"。 - 将这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
当线程2的缓存写回到系统内存,线程1使用嗅探得知线程2打算把缓存写回内存,而该缓存又为共享变量,使用volatile触发了缓存一致性机制,那么此时正在嗅探的线程2使他的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充,即将缓存行读取从系统内存->L3->L2->L1。
5.缓存行的使用
由于多线程在确保读取缓存的效率和命中,既不能过于频繁去读取系统内存访问变量,又不能使得缓存行过大而占据过多的CPU内存,但缓存行的使用也会带来一定的问题。
目前英特尔酷睿i7,酷睿,Atom和NetBurst处理器的L1,L2,L3缓存的高速缓存行的大小是64byte,不支持部分填充缓存行,这意味着当缓存行队列的头节点和尾节点都不足64byte时,处理器会把他们读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头,尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性的机制下,会导致其他处理器不能访问字节高速缓存中的尾节点,而队列的入队和出队操作需要不停的修改头节点和尾节点,所以在多处理器(多线程)的情况下将会严重影响队列的入队和出队效率。可以通过追加64个字节的方式来填充高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行中,使头,尾节点在修改时不会互相锁定。
这里引用马士兵教育中马士兵多线程讲课中的一个例子:
public class CacheLinePadding {
public static long COUNT = 10_0000_0000L;
private static class T{
// private long p1,p2,p3,p4,p5,p6,p7;
public volatile long x = 0L;
// private long p9,p10,p11,p12,p13,p14,p15;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws Exception{
//是一个同步工具类,运行一个或多个线程一直等待,直到其他线程执行完再执行。
CountDownLatch latch = new CountDownLatch(2);
Thread t1 = new Thread(()->{
for(long i =0;i<COUNT;i++){
arr[0].x = i;
}
latch.countDown();
});
Thread t2 = new Thread(()->{
for(long i =0;i<COUNT;i++){
arr[1].x = i;
}
latch.countDown();
});
final long start = System.nanoTime();
t1.start();
t2.start();
latch.await();
System.out.println((System.nanoTime()-start)/100_0000);
}
/**
* 没有填充缓存行时运行速度为:38934
* 前后使用了7个long类型的变量来填充缓存行后的运行速度为:6712
*/
}
该例子中由于变量x使用了volatile修饰,触发了缓存一致性协议,线程t1和线程t2频繁的对x进行修改,当没有使用缓存行填充时,由于两个线程缓存的是同样的头,尾节点的高速缓存行,当t1线程对头节点修改时,会将缓存行锁定,此时t2线程不能访问自己缓存行中的尾节点。所以就采用了缓存行填充的方式解决,使得队列中的头尾节点避免加载到同一个缓存行中,使得在修改时不同的缓存行中的头节点和尾节点不会因为互相锁定而造成线程阻塞。从而提高并发编程效率。
6.后言
该文引用了Java并发编程的艺术一书中对volatile的定义,引用了马士兵教育多线程课程的例子。