本文通过学习:周阳老师-尚硅谷Java大厂面试题第二季 总结的volatile相关的笔记
volatile是Java虚拟机提供的轻量级的同步机制,三大特性为:
保证可见性、不保证原子性、禁止指令重排
一、保证可见性
import java.util.concurrent.TimeUnit;
class MyData {//主物理内存
volatile int number = 0;
public void addTo60() {
this.number = 60;
}
}
public class VolatileDemo {
public static void main(String args []) {
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName()
+ "\t update number value:" + myData.number);
}, "AAA").start();
while(myData.number == 0) {
}
//说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被main线程感知到了
System.out.println(Thread.currentThread().getName() + "\t mission is over");
}
}
/**
* AAA come in
* AAA update number value:60
* main mission is over //若number=0没被volatile修饰,则这句不打印
*/
二、不保证原子性
1、代码示例
import java.util.concurrent.TimeUnit;
class MyData {
volatile int number = 0;
public void addPlusPlus() {
number ++;
}
}
public class VolatileDemo {
public static void main(String args []) {
MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
// 这里判断线程数是否大于2,为什么是2?因为默认是有两个线程的,一个main线程,一个gc线程
while(Thread.activeCount() > 2) {
Thread.yield();//yield表示不执行
}
// 最终输出的值应该=20*1000=20000
System.out.println(Thread.currentThread().getName()
+ "\t finally number value: " + myData.number);//19504
}
}
2、数值丢失的原因?
线程1和2同时修改各自工作空间中的内容,因为可见性,需要重写入内存,但
线程1在写入的时候,线程2也同时写入,导致线程1的写入操作被挂起,导致
线程2先写,线程1后写,线程1的值覆盖了线程2的值,因此数据丢失。
n++这条命令,被拆分成了3个指令:
-getfield 从主内存拿到原始n
-iadd 进行加1操作
-putfileld 把累加后的值写回主内存
假如三个线程同时通过getfield命令,拿到主存中的n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于200
3、解决办法(synchronized / AtomicInteger)
public synchronized void addPlusPlus() { number ++; } |
AtomicInteger atomicInteger = new AtomicInteger(); public void addAtomic() { atomicInteger.getAndIncrement(); } |
三、禁止指令重排
指令重排的代码示例
public class ResortSeqDemo { int a= 0; boolean flag = false; public void method01() { a = 1; flag = true; } public void method02() { if(flag) { a = a + 5; System.out.println("reValue:" + a); } } } | 【顺序执行】 a=1 flag=true a=a+5 顺序执行,打印reValue:6 【指令重排】 flag=true a=a+5 打印reValue:5 a=1 |
四、应用-单例模式
| 方法1. synchronized 方法2. 禁用指令重排 + DCL双端检锁 DCL = Double Check Lock 双端检锁机制 |
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
synchronized (SingletonDemo.class) {
if(instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
/*
* 0 我是构造方法SingletonDemo
*/
原因是在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:
memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null
但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
memory = allocate(); // 1、分配对象内存空间
instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
instance(memory); // 2、初始化对象
这样就会造成什么问题呢?
也就是当我们执行到重排后的步骤2,试图获取instance的时候,会得到null,因为对象的初始化还没有完成,而是在重排后的步骤3才完成,因此执行单例模式的代码时候,就会重新在创建一个instance实例
指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性
所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,这就造成了线程安全的问题, 因此需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性。