笔记之volatile的理解
1.volatile是java虚拟机提供的轻量级同步机制
有以下的三大特点:可见性、禁止指令重排、不保证原子性
1.1 可见性
保证可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
import java.util.concurrent.TimeUnit;
/**
* @version 1.0 2021/4/20
*/
public class VolatileDemo {
public static void main(String[] args) {
visibilityByVolatile();//验证volatile的可见性
}
/**
* volatile可以保证可见性,及时通知其他线程,主物理内存的值已经被修改
*/
public static void visibilityByVolatile() {
MyData myData = new MyData();
//第一个线程是thread1
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
//线程暂停3s
TimeUnit.SECONDS.sleep(3);
myData.addToSixty();
System.out.println(Thread.currentThread().getName() + "\t update value:" + myData.num);
} catch (Exception e) {
e.printStackTrace();
}
}, "thread1").start();
new Thread(()->{
//第二个线程是thread2
System.out.println(Thread.currentThread().getName() + "\t 进入循环,其值为:" + myData.num);
while (myData.num == 0) {
//如果myData的num一直为零,main线程一直在这里循环
}
System.out.println(Thread.currentThread().getName() + "\t num值被改,循环结束, num value is " + myData.num);
},"thread2").start();
}
}
class MyData {
//int num=0;
volatile int num = 0;
public void addToSixty() {
this.num = 60;
}
}
没有给num加上volatile的运行结果:
thread1 come in
thread2 进入循环,其值为:0
thread1 update value:60
thread2的值是0,一直死循环
给num加上volatile的运行结果:
thread1 come in
thread2 进入循环,其值为:0
thread1 update value:60
thread2 num值被改,循环结束, num value is 60
thread2的值被改为了60,跳出死循环
为什么添加volatile前后,会有不同的效果呢?
这是因为基本数据常量num的值是存储在主内存中的,而多线程使用num的值时,其实是复制了一份num的值到线程各自的本地工作内存空间,而没有对主内存的值直接进行操作。这就导致了当一个线程对获取的值进行操作后写会主内存时,另一个线程因为没有再次去获取值,一直是之前获取的值,不知道num的值已经更新了,一直在那里等。
而volatile可以解决这个问题在于:volatile保证了可见性,及时通知其他线程,主物理内存的值已经被修改。比如当一个线程(thread1)对num值在本地工作内存修改后,会去在主内存也进行修改,然后主内存会进行对其他的线程(如thread2)进行及时的通知,其以前获得的num值已经被更新了,thread2需要重新去获取num的值来更新thread2的本地工作内存,所以才会有加了volatile后,thread2会跳出死循环。
1.2 不保证原子性
原子性:不可分割、完整性,即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要整体完整,要么同时成功,要么同时失败。
验证示例(变量添加volatile关键字,方法不添加synchronized):
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author ql
* @version 1.0 2021/4/20
*/
public class VolatileDemo {
public static void main(String[] args) {
atomicByVolatile();//验证volatile不保证原子性
}
public static void atomicByVolatile(){
MyData myData = new MyData();
for(int i = 1; i <= 20; i++){
new Thread(() ->{
for(int j = 1; j <= 1000; j++){
myData.addSelf();
myData.atomicAddSelf();
}
},"Thread "+i).start();
}
//等待上面的线程都计算完成后,再用main线程取得最终结果值
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
//Thread.activeCount() 方法返回活动线程的当前线程的线程组中的数量。
while (Thread.activeCount()>2){
// Thread.yield() 方法,使当前线程(main主线程)由执行状态,变成为就绪状态,让出cpu时间,在下一个线程执行时候,此线程有可能被执行,也有可能没有被执行。
Thread.yield();
}
System.out.println(Thread.currentThread().getName()+"\t finally num value is "+myData.num);
System.out.println(Thread.currentThread().getName()+"\t finally atomicnum value is "+myData.atomicInteger);
}
}
class MyData {
volatile int num = 0;
public void addToSixty() {
this.num = 60;
}
public void addSelf(){
num++;
}
//创建一个带原子性的对象,默认是0
AtomicInteger atomicInteger = new AtomicInteger();
public void atomicAddSelf(){
原子性的操作++
atomicInteger.getAndIncrement();
}
}
运行结果:
main finally num value is 19849
main finally atomicnum value is 20000
理论上两次计算得到的值都应该是20000,但是为什么num 的值小于20000呢?
这是因为虽然num++在我们看来好像只有一步操作,但是实际上,它里面包含了三步操作(先获取值,再进行计算,最后再回去),这在单线程中一看好像没有什么问题,但是一到多线程的话,就GG了,比如同时有多个线程获取值进行操作写回去的时候,可能相差的时间太小,导致前一个刚修改完,另一个紧跟着修改,虽然看上去像修改了两次,但是其值只得到了一次计算,从而导致最后得到的计算值小于2000。
而AtomicInteger 可以保证其计算的结果正确在于其方法在进行写回去的时候,会先进行一次比较,比较现在的值是否和刚获取的值相同,如果相同的话,就进行赋值,否则的话,再次去获得一次值,进行重新计算。(底层原理,CAS)
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
1.3 禁止指令重排
有序性:在计算机执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下三种:
graph LR
源代码 --> id1["编译器优化的重排"]
id1 --> id2[指令并行的重排]
id2 --> id3[内存系统的重排]
id3 --> 最终执行的指令
style id1 fill:#ff8000;
style id2 fill:#fab400;
style id3 fill:#ffd557;
在单线程环境里面,可以确保程序最终执行结果和代码顺序执行的结果一致,并且处理器在进行重排顺序的时候,不是乱重排的,必须要考虑指令之间的数据依赖性(比如对于变量是先初始化,才可以进行赋值,不可能先进行赋值,在进行初始化)
public void mySort(){
int x=11;
int y=12;
x=x+5;
y=x*x;
}
//上面的执行顺序可能是
//1234
//2134
//1324
//不可能是3或者4执行在第一个
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性时无法确定的,结果无法预测
重排代码实例:
声明变量:int a,b,x,y=0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
结 果 | x = 0 ,y=0 |
如果编译器对这段程序代码执行重排优化后,可能出现如下情况:
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x= a; | y = b; |
结 果 | x = 2, y=1 |
这个结果说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的
volatile实现禁止指令重排,从而避免了多线程环境下程序出现乱序执行的现象。
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,他的作用有两个:
1.保证特定操作的执行顺序
2.保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排优化。如果在之前Memory Barrier告诉了编译器和CPU,不管什么指令都不能对这条Memory Barrier指令里面的内存进行重排顺序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
graph TB
subgraph
bbbb["对Volatile变量进行读操作时,会在读操作之前加入一条load屏障指令,从内存中读取共享变量"]
ids6[Volatile]-->red3[LoadLoad屏障]
red3-->id7["禁止下边所有普通读操作和上面的volatile读重排序"]
red3-->red4[LoadStore屏障]
red4-->id9["禁止下边所有普通写操作和上面的volatile读重排序"]
red4-->id8[普通读]
id8-->普通写
end
subgraph
aaaa["对Volatile变量进行写操作时,回在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新回到主内存"]
id1[普通读]-->id2[普通写]
id2-->red1[StoreStore屏障]
red1-->id3["禁止上面的普通写操和下面的volatile写重排序"]
red1-->id4["Volatile写"]
id4-->red2[StoreLoad屏障]
red2-->id5["防止上面的volatile写和下面可能有的volatile读写重排序"]
end
style red1 fill:#ff0000;
style red2 fill:#ff0000;
style red4 fill:#ff0000;
style red3 fill:#ff0000;
style aaaa fill:#ffff00;
style bbbb fill:#ffff00;
2. JMM(java内存模型)
JMM(Java Memory Model)本身是一种抽象的概念,并不真实存在,他描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
JMM关于同步的规定:
1.线程解锁前,必须把共享变量的值刷新回主内存
2.线程加锁前,必须读取主内存的最新值到自己的工作内存
3.加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有的成为栈空间),工作内存是每个线程的私有数据区域,而java内存模型中规定所有变量都存储在主内存,主内存是贡献内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先需要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存的变量副本拷贝,因此不同的线程件无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
JMM拥有可见性、原子性、有序性
3. 在那些地方用过volatile
当普通单例模式在多线程情况下:
public class SingletonDemo {
//禁止指令重排volatile
// private static volatile SingletonDemo instance=null;
private static SingletonDemo instance=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingleDemo()");
}
public static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i <20; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}
其构造方法在多线程的情况下可能会被执行多次:
0 我是构造方法SingleDemo()
1 我是构造方法SingleDemo()
解决方式:
1.单例模式DCL代码
DCL (Double Check Lock双端检锁机制)在加锁前和加锁后都进行一次判断
//DCL(double check lock双端检锁机制)
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class){
if(instance==null)
instance = new SingletonDemo();
}
}
return instance;
}
大部分运行结果构造方法只会被执行一次,但指令重排机制会让程序很小的几率出现构造方法被执行多次
DCL(双端检锁)机制不一定线程安全,原因时有指令重排的存在,加入volatile可以禁止指令重排
原因是在某一个线程执行到第一次检测,读取到instance不为null时,instance的引用对象可能没有完成初始化。instance=new SingleDemo();可以被分为一下三步(伪代码):
memory = allocate();//1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance执行刚分配的内存地址,此时instance!=null
步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化时允许的,如果3步骤提前于步骤2,但是instance还没有初始化完成
但是指令重排只会保证串行语义的执行的一致性(单线程),但并不关心多线程间的语义一致性。
所以当一条线程访问instance不为null时,由于instance示例未必已初始化完成,也就造成了线程安全问题。
2. 单例模式volatile代码
为解决以上问题,可以将SingletongDemo实例上加上volatile
private static volatile SingletonDemo instance=null;
综上,修改后的代码是:
public class SingletonDemo {
//禁止指令重排volatile
private static volatile SingletonDemo instance=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingleDemo()");
}
//DCL(double check lock双端检锁机制)
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 <20; i++) {
new Thread(()->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}