在讲volatile关键字之前,首先了解下内存模型相关的概念。
Java内存模型
由于JVM运行程序的实体是线程,而每一个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每一个线程的私有数据区域,而Java内存模型中规定所有的变量都是存储在主内存中的,主内存中是共享内存区域,所有的线程都可以访问,但线程对变量的操作必须在工作内存中进行,首先要将变量从主内存中拷贝一份到自己的工作内存空间中,然后在进行变量的相关操作,操作完成后再将变量写回主存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量拷贝,因此不同线程间无法访问另外线程的工作内存,线程间的通信必须通过主内存来完成。
举一个简单的例子:
a = a +1;
当程序在运行过程中,会将运算需要的数据从主内存复制一份到本地内存当中,那么线程行计算时就可以直接从它的本地内存中读取数据和向其中写入数据,当运算结束之后,再将本地内存中的数据写到主内存当中。
现在假设同时有两个线程在执行上a = a + 1的这条语句,假如a的初始值为0,我们应该是希望两个线程执行完后是2,但是结果是不一定,可能存在这样的情况:
开始两个线程都分别读取a的值,拷贝到各自的工作内存中,然后线程1对a进行加1的操作,然后把a最新的值写入主内存中,但是此时a的工作内存中a的值还是0,在进行加1操作,a的值为1,然后线程2再将a的值写入主内存中,最终a的结果却是1,而不是2。
这种现象称为缓存一致性问题,为了解决缓存不一致性问题,通常有以下方法解决:
1.通过主内存加锁的方式
2.通过缓存一致性协议
由于加锁的方式会让其他线程无法访问主内存,导致效率比较低,所以出现了缓存一致性协议。其思想是:当一个线程写数据的时候,如果发现操作的变量为共享变量,即在其他线程中也存在共享变量的副本,就会发出信号去停止其他线程将该变量的缓存置为无效状态,从而当其他线程需要读取这个变量时,发现缓存中变量已经无效了,就会从主内存中重新读取。
java内存模型三大特性:可见性,原子性,有序性
1.原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
举几个例子:
a = 1;
该操作直接将数值1赋值给a,具有原子性操作。
b = a;
该操作实际包含两个操作,首先将读取a的值,再将a的值赋值给b,不是原子性操作。
a++;
该操作分为了三个步骤:读取a的值,进行+1操作,重新赋值。也不是原子性操作。
2.可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
请看下面的例子,当我们新建一个线程去修改a的值,然后我们主线程在去获取变量的a的值,看是否可以会拿到最新修改的值。
public class VolatileTest{
public static void main(String[] args) {
Test test = new Test();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.update();
System.out.println(Thread.currentThread().getName()+"将值更新为:"+test.getA());
}, "线程1").start();
while (test.getA() == 0){
//主线程读取a的值,是0则一直循环
}
System.out.println(test.getA());
}
}
class Test {
//注意是没有volatile修饰
private int a = 0;
public void update() {
a = 3;
}
public void add() {
a = a + 1;
}
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
}
运行程序:
会发现主线程一直在运行,其实此时a的值已经被线程1修改成3,但是主线程没有看到修改后的值,这就是可见性问题。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。在进行指令重排序必须考虑指令之间的数据依赖性。
比如:
int a = 1; //语句A
int b = 3;//语句B
int c = a + b;//语句C
在执行上面的语句,可以按照A、B、C执行,运行结果是:4,但是也可以按照B、A、C执行。但是C不能排到A或者B的前面。JMM保证了重排序不会影响单线程的执行,但是在多线程中却容易出问题。
例如:
boolean flag = false;
private int a = 0;
public void update() {
a = 3;
flag = true;
}
public void add() {
if(flag){
a = a + 1;
System.out.println("a的值"+a);
}
}
在上述代码中,多线程环境或者线程交替执行,在发生指令重排的情况下,结果无法预测。
当线程1先执行update()方法,该方法中发生了重排序,先将flag赋值为true,此时线程2进人add()方法,计算出结果,再回到线程1,这个时候线程才将3赋值给a,比线程2晚了一步,不是我们想要的结果。
volatile关键字:
1.保证了修饰变量的可见性(不保证原子性)
2.禁止进行指令重排
1.保证可见性
在上面java内存模型可见性介绍那里举例例子,当线程1去修改变量a的值为3时,主线程并不没有看到修改后的值,我们在变量a声明时用volatile关键字修饰,看下结果会怎么样。
private volatile int a = 0;
在运行程序:
我们发现用volatile关键字修饰后,线程1修改a后,主线程也读取到最新的值。Java提供了volatile关键字来保证可见性,当线程1在自己的工作内存中修改a的值后,会写回主内存,对其他线程可见,主线程从主内存中获取a的最新值。
2.禁止进行指令重排
我们在有序性的例子中说到,程序会发生重排序,这个时候我们可以在flag加上volatile关键字,禁止重排序,可以确保程序的有序性。即先会执行a=3的语句,在去修改flag的值。
其实这个案例中也用到了可见性的特性:
当线程1修改flag的值为true后,写会主内存中时,此时线程2的工作内存中变量flag缓存无效了,所以线程2会重新读取主内存flag的值,从而线程2读取到的最新值。
3.volatile保证原子性?
我们在上面是说了volatile关键字不保证原子性。
请看下面的例子:
public static void main(String[] args) {
Test test = new Test();
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
test.add();
}
}, "线程"+i).start();
}
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"执行结束后:"+test.getA());
class Test {
private int a = 0;
public void add() {
a = a + 1;
}
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
}
运行程序:
我们发现10个线程去运行add()这个方法,并没有得到我们想要的10000,而且每次运行的结果都不一致。注意此时a是没有用volatile关键字修饰,那我们用volatile关键字修饰看下:
我们发现还是没有得到我们想要的结果,应该有人就会想,上面的说的volatile保证了可见性,那么每一个线程对a加1后,其他线程一个是可以看到修改后的新值,那程序中有10个线程进行了1000次操作,那么最终运行后的结果应该是10000的。
首先,volatile关键字保证可见性是没有错误的,上面说到a=a+1这个操作是不具有原子性的,他是分了三个步骤:读取a的值,进行加1操作,赋予新值。
线程1对变量a进行操作,先按读取主内存中a的初始值0到自己的工作内存中,然后线程1被堵塞了,此时线程2也对变量a进行操作,线程2也会去主内存中读取a的值0到工作内存中,由于此时线程1只是对a进行读取操作,没有进行修改,所以不会导致线程2的工作内存中变量a缓存无效,所以线程2会从主内存中拿到a的初始值0,然后进行+1,并且把结果写入自己的工作内存,最后在写入主内存中。然后线程1接着进行+1操作,注意此时线程1工作内存的值还是0,进行操作后写回主内存更新为1。(我们说过修改volatile修饰的变量,会让缓存无效,然后其他线程去读取最新的值,这个怎么不对呀,我们要注意,线程1对变量a读取操作后,才堵塞的,还没有进行修改操作,然后线程2从主内存中读取变量a的值,线程1还没有修改呢,线程2压根就看不到修改值,线程2修改后,写回主内存时,线程1已经读取完了a的值)。
我们如何解决volatile不保证原子性的问题呢
加锁:
public synchronized void add() {
a = a + 1;
}
加锁后,我们得到了我们想要的结果,但是加锁的效率比较低,我们不加锁要怎么解决呢?
我们可以使用java.util.concurrent.atomic 的包里中AtomicInteger原子类来解决。
请看下面代码:
public class VolatileTest {
public static void main(String[] args) {
Test test = new Test();
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
test.add();
test.addByAtomic();
}
}, "线程"+i).start();
}
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"执行结束后:"+test.getA());
System.out.println(Thread.currentThread().getName()+"利用原子类执行结束后:"+test.atomicInteger);
}
}
class Test {
private volatile int a = 0;
AtomicInteger atomicInteger = new AtomicInteger();
public void addByAtomic(){
atomicInteger.getAndIncrement();
}
public void add() {
a = a + 1;
}
public int getA() {
return a;
}
public void setA(int a) {
this.a = a;
}
}
运行结果:
我们来看下volatile的典型应用
单例模式典型的双重检查锁定:
public class Singleton{
//需要用volatile修饰
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
使用volatile关键字是防止指令重排序,我们知道instance = new Singleton();这行代码可以分为三个步完成:1.分配对象内存空间 2.初始化对象 3.将instance指向分配好的内存地址,此时instance != null。步骤2和3不存在数据依赖关系,如果发生指令重排后变成1->3->2。
当线程1执行完第三步时,instance已经不为null,此时另外一个线程进来,判断第一个if时,发现instance不为null时,此时instance实例不一定已经初始化完成,也就造成了线程安全问题。