在说volatile关键字之前,首先要了解一下java内存模型JMM。
JMM(Java Memory Model)
JMM目的是为了屏蔽各种硬件和操作系统之间的内存访问差异,从而让JAVA程序在各种平台对内存的访问一致。
JMM规定了所有的变量都存储在主存中,每个线程都有自己独立的工作空间,线程对变量的操作必须先从主存中读取到自己的工作内存中然后再进行操作,最后回写回主存。(值得注意的是,每一个线程对应由一个CPU来执行,CPU和主存之间的速度是有差异的,因此每个线程的工作内存有点相当于缓存cache的功能)。
关于主存和工作内存的交互JAVA定义了八种操作来完成,且这些操作都是原子性的:lock、unlock、read、load、use、assign、store、write。
并发编程中三大特性
(1)原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
这个很好理解,比如A给B转账1000,包含了A减少1000和B增加1000两个操作,如果这两个操作不是原子性的,很有可能在A减少1000以后程序突然终止,导致A平白无故减少1000且不会回滚。
(2)可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举一个简单的例子:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这行代码时,会先把i的初始值0加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,但是却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。
这里描述的就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
(3)有序性:即程序执行的顺序按照代码的先后顺序执行。
正常单线程的情况下,指令重排序是不会出错的。因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
但是,多线程情况下就可能发生指令重排序导致的错误,如下所示:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
volatile
volatile可以说是JAVA虚拟机提供的最轻量级的同步机制。一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
(2)禁止进行指令重排序。
对于可见性来说,当一个线程修改了volatile修饰的变量值以后,会立刻将其从工作内存写回到主内存,并且会导致其他线程工作内存中缓存的该变量失效,因此当其他线程需要这个变量时,要去住内存中得到最新的值。因此保证了线程之间的可见性。
对于有序性来说,在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。举一个例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
语句3中的flag由于是volatile修饰的,因此在指令重排的时候不能放到语句1或者语句2之前;也不能放到语句4或者语句5之后。因此volatile保证了在执行到语句3的时候,语句1和语句2是执行完的,而语句4和语句5尚未执行。但是它并不能保证1和2,4和5之间的先后顺序。
内存屏障
volatile是如何保证可以实现可见性和有序性的呢?这里引用《深入理解java虚拟机中的一段话》:“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(2)它会强制将对缓存的修改操作立即写入主存;
(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
volatile不能保证原子性
volatile关键字是不能保证原子性的,如下面的代码:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<2000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
我们理想中,最后打印出来的值是200000,但很可惜不是。这是因为虽然volatile保证了原子性(也就是说所有线程能够拿到最新修改的值),但是线程操作并不能保证原子性。
i++
这个自增操作本身不具备原子性,它包括了读取i的值,进行自增1操作,写回到工作内存,这三个步骤。
因此我们可以设想,加入某一时刻inc的值是100。此时线程1在读取inc的值以后开始阻塞;由于线程1还没有自增和结果写回,线程2读到的值也是100,如果线程的2正常执行+1并写回到主存。此时线程1中的工作内存,由于volatile的可见性,也变成了101。但是,线程1已经读取了inc=100,所以加一操作还是在100上进行的。因此会出现问题。
所以要解决原子性问题还是需要加锁,例如在自增1方法中加入synchronize或者Lock。
参考:
- https://www.cnblogs.com/dolphin0520/p/3920373.html
- https://www.nowcoder.com/discuss/794894?type=all&order=recall&pos=&page=0&ncTraceId=&channel=-1&source_id=search_all_nctrack&gio_id=CE62C234F8EE09B342CF03B97CA2BBA6-1638512157472