最早接触到volatile的关键字的时候, 是用在多线程控制地方,一个主的线程通过quitFlag标志来控制子线程的启停,子线程通过循环来判断标记是否为true,为true则退出,这时候如果不用volatile 关键字修饰quitFlag在主线程更改后, 子线程可能无法立刻看到修改,导致无法及时退出的问题,甚至无法退出的问题。
一 volatile保障了可见性
上面情况,如果用volatile 来修饰quitFlag关键字,则可以及时退出。
public class TestQuitFlag {
// 这种可能无法即时退出
// private static boolean quitFlag = false;
// 这种情况可以正常退出
private static volatile boolean quitFlag = false;
public static void main(String [] args) throws InterruptedException {
new Thread(){
@Override
public void run() {
while (!quitFlag) {
System.out.println(Thread.currentThread()+" is running");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Child thread is stop");
}
}.start();
Thread.sleep(3000);
quitFlag = true;
System.out.println("Main is exit..");
}
}
原因是Java对缓存进行了抽象,java的JMM内存模型,将线程访问的内存分为工作内存和主内存,工作内存只有本线程才可以操作,Java操作的数据先保存到本地内存中,更改后刷新到主内存中,其他线程读取变量的时候每次都从主内存中同步到它的本地内存中,如下图:
主内存和工作内存
二 volatile 与线程安全
volatile 保障了可见性,不具有原子性,不能保障线程的安全。有些说法可以部分保障线程安全,我认为那种可见性不能算是线程安全。
简单的测试下,累加这种典型的场景:
import java.util.ArrayList;
import java.util.List;
public class TestCounter {
private static volatile int count = 0;
public static void main(String [] args) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 0; i< 10 ; i++) {
threads.add(new Thread(){
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
count++;
}
}
});
}
threads.forEach(thread->{thread.start();});
threads.forEach(thread->{
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("Main is exit..");
System.out.println("result:" + count);
}
}
一共启动10个线程,每个线程计数1000次,如果是线程安全的结果应该是10000,打印结果如下:
执行结果
如果改动下,通过synchronized 来控制累加,代码如下:
import java.util.ArrayList;
import java.util.List;
public class TestQuitFlag {
private static volatile int count =0 ;
public static void main(String [] args) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 0; i< 10 ; i++) {
threads.add(new Thread(){
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
synchronized (TestQuitFlag.class) {
count++;
}
}
}
});
}
threads.forEach(thread->{thread.start();});
threads.forEach(thread->{
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("Main is exit..");
System.out.println("result:" + count);
}
}
通过synchronized 包下代码块,执行的结果就是10000了,这里面要注意下所有线程的synchronized的传入参数要是同一个对象,如果不是,则达不到锁的目的。比如刚才代码中:
synchronized(TestQuitFlag.class) 改成synchronized(this)是操作不同的对象,则达不到锁的目的。
当然在java中有性能更高的累加方法,那就是采用Atomic*系列类,这些类因为采用CAS的方式进行加锁,所以性能更好些,这里就不再举例了。
三 volatile 可以防止指令重排
volatile 可以防止指令重排,JVM虚拟机在执行Java字节码的时候,为了提升性能,在不影响程序语义的情况下,会对指令进行重排。当然除了JVM,编译器或cpu都可能会进行指令重排。
典型的代码场景是双重锁检查单例写法,具体展示如下:
public class Singleton {
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null ) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这里面必须给singleton添加volatile关键字,为什么要添加关键字,这里和volatile的防止指令重排问题有关。这里面主要和singleton = new Singleton();
这句代码相关,
这句代码实际执行的时候分为三步操作:
- 申请一块内存。
- 调用Singleton的构造函数初始化。
- 将singleton引用指向这块内存空间,这样singleton执行就不是null了。
这三步处于锁控制的范围内,当时如果没有volatile 情况下,会发生指令重排,而引起错误。来举个例子:
1) 线程1 执行到synchronized同步代码块中,判断singleton为null,这时候开始执行
singleton = new Singleton();
代码。
2) 由于指令产生了重排,所以执行的代码顺序是1->3->2 , 执行完3,之后singleton不是null了,这时候线程时间片时间到,线程休眠。
3) 其他线程再调用getInstance()
判断singleton不为null,直接返回singleton使用,当时我们知道,其实这个变量现在是未初始化的。其他线程使用了这个未初始化的变量,从而造成问题。
volatile 关键字给JVM指明修饰的字段可能在其他的线程中发生修改,所以
如下图:
无volatile情况
加上volatile 关键字后,看JVM编译后的代码会多一句:
lock addr $0x0,(%esp)
这个指令相当于一个内存屏障,只有一个cpu,并不需要;如果有两个或两个以上cpu访问访问的时候,会将cache本地内存的数据同步到主内存中,通过这个操作让volatile变量在其他的内存中立刻可见,也保证了后续的指令不能重排到lock指令之前。
顺便说下,DCL实现的单例模式,还常被问到的点,为什么两次判断singleton是否为null。
顺便说下:
- 第一次判断singleton 是否为null,在不为null的时候可以不用进入到同步代码块,快速返回,提升了性能。
- 第二次判断singleton是否为null,一个线程在判断singleton为null,进入到同步代码块之前休眠了,这时候另外一个线程因为判断singleton为null,则先进入了同步代码块,执行完毕后;开始的线程仍然可以进入同步代码块,如果不判断singleton是否为null,则会再次创建个单例对象,违反了我们的单例的初衷。
网友评论