Java中的volatile

目录

1.内存可见性

1.1 Java内存模型(JMM)

1.2 内存可见性 

1.2 复合操作

2.禁止指令重排序

2.1为什么要指令重排序?

2.2 禁止指令重排序


volatile是Java提供的一种轻量级同步机制,主要有两个作用:

  • 保证内存可见性
  • 防止指令重排序

1.内存可见性

在此之前我们先来了解JMM:

1.1 Java内存模型(JMM)

java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。

  JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下:

1.2 内存可见性 

首先看如下代码:

public class TestVolatile {
    boolean status = false;

    /**
     * 状态切换为true
     */
    public void changeStatus(){
        status = true;
    }

    /**
     * 若状态为true,则running。
     */
    public void run(){
        if(status){
            System.out.println("running....");
        }
    }
}

 在多线程环境中,假设A线程执行changeStatus()后,B线程执行run()可以保证输出"running..."吗?

答案是NO!

       这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,先运行changeStatus方法,再执行run方法,自然是可以正确输出"running...."的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量status来说,线程A的修改,对于线程B来讲,是"不可见"的。也就是说,线程B此时可能无法观测到status已被修改为true。那么什么是可见性呢?

可见性:当一条线程修改了共享变量的值,新值对于其他线程来说是立即可以得知的。

对于普通的共享变量来讲,比如我们上文中的status,线程A将其修改为true这个动作发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B缓存了status的初始值false,此时可能没有观测到status的值被修改了,所以就导致了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了。比较合理的方式其实就是volatile。

volatile可以保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:

1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;

2.这个写会操作会导致其他线程中的缓存无效。

上面的例子只需将status声明为volatile,即可保证在线程A将其修改为true时,线程B可以立刻得知

1.2 复合操作

先看如下代码:

package test;

import java.util.concurrent.CountDownLatch;

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
    public static volatile int num = 0;
    //使用CountDownLatch来等待计算线程执行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //开启30个线程进行累加操作
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num++;//自加操作
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待计算线程执行完
        countDownLatch.await();
        System.out.println(num);
    }
}

执行结果: 

224291

为什么结果不是300000呢?

可见性意思就是说当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。由于操作非原子,即第一个线程只要不重新读取变量值会导致可见性对此线程的丧失,所以该线程并不知道主存的值被修改了

对于num++这个操作,因为num++不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:

  1.读取

  2.加一

  3.赋值

  所以,在多线程环境下,有可能线程A将num读取到本地内存中,此时其他线程可能已经将num增大了很多,线程A依然对过期的num进行自加,重新写到主存中,最终导致了num的结果不合预期,而是小于30000

针对num++这类复合类的操作,可以使用java并发包中的原子操作类原子操作类是通过循环CAS的方式来保证其原子性的。

/**
 * Created by chengxiao on 2017/3/18.
 */
public class Counter {
  //使用原子操作类
    public static AtomicInteger num = new AtomicInteger(0);
    //使用CountDownLatch来等待计算线程执行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //开启30个线程进行累加操作
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num.incrementAndGet();//原子性的num++,通过循环CAS方式
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待计算线程执行完
        countDownLatch.await();
        System.out.println(num);
    }
}

具体见Java原子类详解

2.禁止指令重排序

2.1为什么要指令重排序?

指令重排序是为了提高程序并发效率,原则是:

  • 重排序后的程序运行结果和单线程运行结果一致
  • 重排序操作不会对存在数据依赖关系的操作进行重排序

为什么指令重排序会提高程序并发效率呢?这里先理解一下CPU的最小调度单位是线程这个概念。首先一个CPU同时只能处理一个线程,在最初单核CPU的时候,是通过轮询的方式去完成多线程的,在线程之间完成上下文切换。随着计算机技术的发展,现在都是多核CPU,其中每个CPU也是在轮询线程,只不过多核CPU并发效率更高了。

这就存在一个问题,CPU的运算速度要远快于对内存的操作,将工作内存数据写入主内存即物理内存时,如果两个CPU同时需要写入同一块内存区域,这就需要一个CPU等待另一个CPU写入完成后再写,这就造成了CPU的浪费,而这种情况在单核CPU是不存在的,所以需要指令重排序。

举个例子:

int a = 1;
int b = 2;

a和b需要写入不同的内存区域,在多线程中,如果CPU1是先写入a到内存a,再写入b到内存b,那么CPU2必然也是这个顺序,这就容易造成两个CPU想同时往内存a中写入,这就需要一个CPU等待另一个写入完成,这就造成了CPU的等待浪费。但是如果线程2中指令重排序一下,变为int b = 2; int a = 1;那么CPU2就是先写入b到内存b,再写入a到内存a,这样两块CPU就可以同时写入,这才是真正的多核CPU,这就是指令重排序的目的。

指令重排序要遵循happen-and-before以下原则:

1.一个unlock操作,先行发生于对同一对象的lock操作,这里包括发生在其它线程中的lock操作。

2.volatile修饰的变量写先发生于读,这保证了该变量的可见性。

3.thread的start()方法先行发生于该线程的每一个动作。

4.thread的join()方法即终止方法后发生于该线程的每一个动作,可以用thread.alive()方法的返回值判断该线程是否已经终止。

5.thread的interrupte()方法即中断方法先行发生于被中断线程的代码检测到中断事件的发生,通过thread.interrupte()方法检测线程是否已中断。

6.一个对象的初始化完成即其构造方法的调用结束要先行与他的终结方法例如finalize()。

7.动作有传递性,如果动作A先于动作B,动作B先于动作C,那么动作A先于动作C。

2.2 禁止指令重排序

使用volatile关键字修饰共享变量可以禁止重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
  volatile禁止指令重排序的一些规则:
  1.当第二个操作是voaltile写时,无论第一个操作是什么,都不能进行重排序
  2.当地一个操作是volatile读时,不管第二个操作是什么,都不能进行重排序
  3.当第一个操作是volatile写时,第二个操作是volatile读时,不能进行重排序

参考:

https://www.cnblogs.com/chengxiao/p/6528109.html 

https://blog.csdn.net/weixin_42447959/article/details/82860318

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值