一.volatile是什么
如果用一句话概括volatile的话,那volatile其实就是java虚拟机提供的轻量级的同步机制。它具有一下三个特点:
1.保证可见性
2.不保证原子性(因为不保证原子性,所以他是轻量级的)
3.禁止指令重排
二.保证可见性
首先,我们先看看下面的代码
import java.util.concurrent.TimeUnit;
class MyData{
//int testData = 0;
volatile int testData = 0;
public void addTo60(){
this.testData = 60;
}
}
public class volatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();
//工作线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " come in");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName() + " update number value: " + myData.testData);
}, "test").start();
while (myData.testData == 0)
{
//假如不使用volatile修饰变量
//这里main线程会一直在这里等待
//因为工作线程修改了变量,却不能对main线程可见,main线程会一直拿着初始值的副本,也就是0
}
System.out.println(Thread.currentThread().getName() + " mission is over");
}
}
关于volatile是怎么实现可见性的,可以总结为以下两点:
1.从主内存到工作内存<读>:每次使用变量前 先从主内存中刷新最新的值到工作内存,用于保证能看见其他现场对变量修改的最新值。
2.从工作内存到主内存<写>:每次修改变量后必须立刻同步到主内存中,用于保证其他线程可以看到自己对变量的修改。(因为cpu比内存的速度要快很多,因此这中间有一个高速缓存的概念,一般修改后的变量都是先存进高速缓存,然后再刷新到主内存中)
3.指令重排序:保证代码的执行顺序和程序的执行顺序一致。(并发环境下 代码的执行顺序与程序的执行顺序有时并不一致,会出现串行的现象固有指令重排序优化一说。JAVA1.5之后彻底修复了这个BUG在用volatile变量的时)
三.不保证原子性
volatile有个比较不好的地方就是它不保证原子性,按照惯例,下面还是先上一段代码
import java.util.concurrent.TimeUnit;
class MyData{
volatile int testData = 0;
public void add(){
this.testData ++;
}
}
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.add();
}
},"test"+i).start();
}
//后台默认有main线程和gc线程,因此这里选择当线程数量大于2时候,main线程退出暂停。
while (Thread.activeCount() > 2){
Thread.yield();
}
//这里每次运行出来的结果都是不同的
System.out.println(Thread.currentThread().getName() + " final number is " + myData.testData);
}
}
上述代码运行出来的结果不是我们想要的结果,导致这种情况出现的原因是,在并发编程中,可能存到两个线程同时去主内存中拿到变量,然后进行计算操作,例如线程a、b,同时拿到主内存中的变量x1到自己的工作内存里面,然后计算后得出同样的x2,当要把x2写回主内存时,其中一个会将数据刷新到主内存中,而另一个线程处于挂起状态,当前一个线程写入操作完成后,后一个线程被唤醒,在还没获取变量最新值的时候,立即进行写入操作(写覆盖),这时候主内存中的变量被刷新了两次,但是它的数值只增加了1,因为两个线程算出来的结果时一样的,这时候就会导致变量最后自增的结果不是我们想要的结果。
那么我们如何去解决这个问题呢?其实很简单,juc包里面有一个atomic类的数据,我们使用它来作为我们操作的对象就可以了。
import java.util.concurrent.atomic.AtomicInteger;
class MyData{
volatile int testData = 0;
public void add(){
this.testData ++;
}
volatile AtomicInteger atomicInteger = new AtomicInteger();
public void addAtom(){
atomicInteger.getAndIncrement();
}
}
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.add();
}
},"test"+i).start();
}
for (int i = 0; i < 20; i++){
new Thread(() -> {
for (int j = 0; j < 1000; j++){
myData.addAtom();
}
},"test-atomic"+i).start();
}
//后台默认有main线程和gc线程,因此这里选择当线程数量大于2时候,main线程退出暂停。
while (Thread.activeCount() > 2){
Thread.yield();
}
//这里每次运行出来的结果都是不同的
System.out.println(Thread.currentThread().getName() + " final number is " + myData.testData);
System.out.println(Thread.currentThread().getName() + " final number is " + myData.atomicInteger);
}
}
可是为什么使用atomic类的对象就可以解决原子性的问题呢?其实atomic是通过CAS来保证他的原子性的。
那,CAS又是什么呢?简单来说,CAS是compareAndSwap的缩写,意思是对比和交换。
java中CAS操作依赖于Unsafe类,Unsafe类所有方法都是native的,直接调用操作系统底层资源执行相应任务,它可以像C一样操作内存指针,是非线程安全的。
//第一个参数o为给定对象,offset为对象内存的偏移量,通过这个偏移量迅速定位字段并设置或获取该字段的值,
//expected表示期望值,x表示要设置的值,下面3个方法都通过CAS原子指令执行操作。
public final native boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
public final native boolean compareAndSwapLong(Object o, long offset,long expected,long x);
而atomicInteger在更新他的value的时候,就是调用了unsafe类中的compareAndSwapInt方法去更新的。
举个例子,在线程需要对一个变量进行写操作的时候,会先对比这个变量是否符合预期值,如果符合,则会进行写操作,如果不符合,代表有其他线程对这个变量进行了修改,则获取变量当前值作为最新值,返回重新进行计算操作,依次循环,知道在对比的时候符合预期值。例如当前变量x1=1,同时有线程a,b过来获取变量,并进行+1操作,此时线程a计算完毕,对比主内存中变量是否为1,假如是,就将结果2写进主内存中,此时线程b也计算完毕了,对比主内存中的变量值,发现2!=1,意思是有其他线程已经对这个变量进行修改了,就会把2拿回去,重新进行+1的操作,然后再次对比。
四.禁止指令重排
计算机在执行程序时,为了提供性能,编译器和处理器常常会对指令进行重排
1.源代码
2.编译器优化的重排
3.指令并行的重排
4.内存系统的重排
5.最终执行指令
单线程环境下,不需要考虑指令重排的情况,程序最终执行的顺序和代码的顺序是一致的。
处理器在进行指令重排时,必需要考虑指令之间的数据依赖性,例如 Int x = 0; x = x + 5,这样的语句是不能被重排的,因为x需要先初始化。
多线程环境下,线程交替执行,由于编译器优化重排的存在,实际运行的结果是无法保证与代码里面预期的结果一致的。
而volatile是通过插入内存屏障,禁止内存屏障前后的指令执行指令重排序优化。
举个例子,在一个使用了双端检查机制的单实例中,假如不用volatile来修饰实例的话,将会产生预期之外的结果
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName() + " 创建用例");
}
//双端检查机制
public static SingletonDemo getInstance(){
//这种是常规写法,但是在多线程环境下会有问题
//if (instance == null){
// instance = new SingletonDemo();
//}
//假如a,b,c线程同时过来,发现instance == null 则会进入锁代码块
//假如这里不加检查,则无论实例是否初始化好,线程进来都会有一个抢夺锁资源的操作,很浪费
if(instance == null)
{
//因为上锁了,假如a获得锁,b,c只能等着
synchronized (SingletonDemo.class)
{
//这里之所以要在检查一次,是因为假如不做判断
//当a线程初始化了这个实例后,b,c线程相继获取到锁资源然后进来执行锁代码块
//如果没有判断,则会直接再执行一次初始化实例
//这样明显不符合我们的要求,因此进来的时候需要再判断一次,b,c线程进来后发现instance以及不为null了,就不会初始化实例了
if(instance == null)
{
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
for(int i = 0; i < 10; i++){
new Thread(()->{
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
},"test"+i).start();
}
}
}
假如不在instance那里加上volatile修饰的话,则会有可能有某个线程在调用SingletonDemo.getInstance()的时候,获得一个null值,要想知道这里面的原因,首先我们需要知道在getInstance()方法中,instance = new SingletonDemo()这句代码具体执行了什么操作。
1.memory = allocate() //给对象分配内存
2.instance(memory) //初始化对象
3.instance = memory //将instance变量指向对象的内存地址
一般来说,都是按照以上顺序执行的,但是因为操作不存在变量依赖,所以在多线程环境下,可能会发生指令重排操作,将会变成
1.memory = allocate() //给对象分配内存
2.instance = memory //将instance变量指向对象的内存地址
3.instance(memory) //初始化对象
这样就导致一种情况,就是a线程进来后,发现instance==null,就会执行instance = new SingletonDemo()这条代码,然后这时候b线程进来了,而a线程初始化对象已经完成了“分配内存”和“修改变量引用”这两步,但是却还没有来得及执行初始化对象这一步。这时候线程b发现instance!=null,不会进入到锁代码块,会直接return instance,然而,这时候的instance所指向的内存区域,其实还没完成对象的初始化,是空的,这样,就会出现预期之外的异常了。
因此,用volatile修饰instance,这样在执行instance = new SingletonDemo()这句代码的时候,就不会发生指令重排了,也不会出现异常。