案例:双重检查的单例模式
public class Singleton {
public static volatile Singleton singleton;
//构造函数私有,禁止外部实例化
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) { //当下一次再获取实例时,不需要进行进入同步块中,提高效率。
synchronized (singleton.class) {
if (singleton == null) singleton = new Singleton();
}
}
return singleton;
}
}
实例化一个对象其实可以分为三个步骤:
分配内存空间
初始化对象
将内存空间的地址赋值给对象引用
由于操作系统对指令重排序,过程可能会变成如下过程:
分配内存空间
将内存空间引用赋值给对象引用
初始化对象
如果A线程执行完同步块之后,对象还未实例化,B线程紧接着判断对象不为null,但却返回了null,这将造成问题。
为了防止变量在实例化的过程中重排序,需在变量前添加volatile修饰。
可见性的问题是:A线程修改的成员变量,B线程看不到。
引发可见性的原因是:每个线程(对应的CPU)都拥有自己独立的缓存区,数据是在缓存区操作的。
public class VolatileTest {
int a = 1;
int b = 2;
public void change(){
a = 3;
b = a;
}
public void print(){
System.out.println("b="+b+";a="+a);
}
public static void main(String[] args) {
while (true){
final VolatileTest test = new VolatileTest();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}
......
b=2;a=1
b=3;a=1 // 这里
b=3;a=3
......
出现了b = 1 是因为线程A执行了 a = 3 后只保存在了自己的缓存中,没有将结果同步到内存中,此时线程B取到的结果就还是初始化实例时的结果(a == 1)。
a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
volatile只能保证单次读写具有原子性。
强调一点:volatile可以保证变量单次读写的原子性,但是不能保证例如i ++ 操作的原子性,因为 i ++ 本质上是读、写两次操作。
看一个例子:
public class VolatileTest01 {
volatile int i;
public void addI(){
i++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
//1000个线程对i进行操作。
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10); //为了增加并发产生的几率
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}
//结果总是小于1000 因为有些线程写完结果之后没有(来得及?)同步到内存中,即不可见。
i++ 的操作其实是:
从内存中读取 i 的值到线程缓存中
对 i 加 1
将 i 的值写回内存
volatile是无法保证这三个操作是原子性的,可以通过使用 AtomicInteger 或 Synchronized来保证 + 1 的原子性。
volatile的可见性是基于内存屏障(Memory Barrier)实现的:
内存屏障是一个CPU指令,通过插入特定类型的内存屏障来禁止编译器重排序和处理器重排序
/**
* 通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
* ......
* 0x000000000295157f: and $0x37f,%rax
* 0x0000000002951586: mov %rax,%rdi
* 0x0000000002951589: or %r15,%rdi
* 0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
* 0x0000000002951591: jne 0x0000000002951a15
* ......
*/
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令。
lock 前缀的指令在多核处理器下会引发两件事情:
将当前处理器缓存行的数据写到系统内存
写回内存的操作,会让其他CPU里缓存了该内存地址的数据无效。
具体的:
对声明了 volatile 的变量进行写操作时,JVM会发送一条lock前缀的指令,将变量所在缓存行的数据写到系统内存。
缓存一致性协议(MESI):每个CPU会根据总线上传播的数据来检查自己缓存的值是不是过期了,当发现自己缓存行对应的内存地址被修改,就会将CPU缓存行设置为无效状态,当CPU对成员变量操作时,会重新从内存中读到CPU缓存。
缓存一致性:
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。
LOCK# 因为锁总线效率太低,因此使用了多组缓存。为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。
缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
//假设线程A先执行writer方法,线程B后执行reader方法
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1 线程A修改共享变量
flag = true; // 2 线程A写volatile变量
}
public void reader() {
if (flag) { // 3 线程B会读到A的修改
int i = a; // 4 线程B读共享变量
……
}
}
}
为了实现 volatile 内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
volatile 在写的前后分别插入内存屏障,而 volatile 读的后面插入两个内存屏障。
如上
例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
public volatile String lastUser;
public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}
如果读操作远远超过写操作,可以结合使用内部锁(原子性)和 volatile 变量(可见性)来减少公共代码路径的开销。
如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
参考:
https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html