深入volatile关键字(二)volatile特性,使用场景,volatile和synchronized区别

本文详细阐述了volatile关键字在并发编程中的作用,包括保证可见性、禁止指令重排序以及不保证原子性。通过实例解析了volatile在开关控制、状态标记和单例模式中的应用,并对比了与synchronized的区别。
摘要由CSDN通过智能技术生成

前面两篇都是对volatile进行铺垫:

  1. volatile关键字引入(CPU缓存模型,数据一致性,java内存模型)
  2. 深入volatile关键字(一)并发编程的三个重要特性和指令重排以及Happens-before原则
    :

1 volatile关键字的语义

volatile修饰的实例变量或者类变量具备如下两层语义:

  1. 保证了不同线程之间对共享变量操作时的可见性,也就是说当一个线程修改volatile修饰的变量,另外一个线程会立即看到最新的值。(可见性)
  2. 禁止对指令进行重排序操作。(有序性)

也就是说volatile关键字保证的就是并发的三大特性的可见性和有序性,不保证原子性

1.1 理解volatile保证可见性

之前的案例说明了volatile保证共享变量在多线程间的可见性,代码如下:

定义两个线程一个线程负责读取数据,一个线程负责修改数据

public class VolatileFoo {

    final static int MAX = 5;
	// 有无volatile 关键词修饰
   volatile  static int init_value = 0;
    
    public static void main(String[] args) {
        // 启动一个Reader线程,当发现local_value和init_value不同时,则输出init_value被修改的信息
        new Thread(() -> {
            int localValue = init_value;
            while (localValue < MAX) {
                if (init_value != localValue) {
                    System.out.println("The init_value is update to " + init_value);
                    //对localValue进行重新赋值
                    localValue = init_value;
                }
            }
        }, "Reader").start();


        // 启动Writer线程,主要用于对init_value的修改,当local_value>=5的时候则退出生命周期
        new Thread(() -> {
            int localValue = init_value;
            while (localValue < MAX) {
                System.out.println("The init_value will be changed to " + (++localValue));
                init_value = localValue;
                try {
                    //短暂休眠,目的是为了使Reader线程能够来得及输出变化内容
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }, "Writer").start();
    }
}

当init_value没有volatile修饰时,是无法保证可见性的,所以Reader线程由于后来无法获取主内存的中init_value的新值,最后进入死循环,在Reader线程中工作内存里的init_value就一直没有变化,while判断一直为false,所以最后输出结果如下:

The init_value will be changed to 1
The init_value is update to 1       (Reader线程中工作内存里的init_value就一直是1,没有从主内存中读取新值)
The init_value will be changed to 2
The init_value will be changed to 3
The init_value will be changed to 4
The init_value will be changed to 5

当init_value没有volatile修饰时,对于共享资源init_value的读操作会直接在主内存中进行(当然也会缓存到工作内存中,当其他线程对该共享资源进行了修改,则会导致当前线程在工作内存中的共享资源失效,所以必须从主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新到主内存中。**简单来说,两个线程操作init_value,虽然都是在自己的工作内存,但是操作完之后会立即刷回主内存,失效掉自己工作内存中的init_value,下次操作又会从主内存中获取。这就保证了,init_value在两个线程之间是可见的。**具体的步骤如下:

  1. Reader线程从主内存中获取init_value的值为0,并且将其缓存到本地工作内存中。
  2. Writer线程将init_value的值在本地工作内存中修改为1,然后立即刷新至主内存中。
  3. Reader线程在本地工作内存中的init_value失效(反映到硬件上就是CPU的L1或者L2的Cache Line失效)。
  4. 由于Reader线程工作内存中的init_value失效,因此需要到主内存中重新读取init_value的值。

1.2 理解volatile保证顺序性

volatile关键字对顺序性的保证就比较霸道一点,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令则可以随便怎么排序

int x = 0;
int y = 1;
volatile int z = 20;
x++;
y--;

在语句volatile int z=20之前,先执行x的定义还是先执行y的定义,我们并不关心,只要能够百分之百地保证在执行到z=20的时候x=0,y=1,同理关于x的自增以及y的自减操作都必须在z=20以后才能发生。

再比如:

private volatile boolean initialized = false;
private Context context;
public Context load(){
	if(!initialized){
		context=loadContext();
		initialized = true;//阻止重排序
	}
	return context;
}

多线程的情况下发生了重排序,比如context=loadContext()的执行被重排序到了initialized=true的后面,那么这将是灾难性的了。比如第一个线程首先判断到initialized=false,因此准备执行context的加载,但是它在执行loadContext()方法之前二话不说先将initialized置为true然后再执行loadContext()方法,那么如果另外一个线程也执行load方法,发现此时initialized已经为true了,则直接返回一个还未被加载成功的context,那么在程序的运行过程中势必会出现错误。

如果对initialize布尔变量增加了volatile的修饰,那就意味着initialize=true的时候一定是执行且完成了对loadContext()的方法调用。就避免了上面的问题。

1.3 理解volatile不保证原子性

public class VolatileTest {
    // 使用volatile关键字修饰 i
    private static volatile int i = 0;
    private static final Latch latch = new CountDownLatch(10);

    private static void inc() {
        i++;
    }

    /**
     * 创建10个线程,每个线程对i进行自增1000次操作
     */
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new Thread(() ->
            {
                for (int x = 0; x < 1000; x++) {
                    inc();
                }
                // 使计数器减1
                latch.countDown();
            }).start();
        }
        // 等待所有的线程完成工作
        latch.await();
        System.out.println(i);
    }
}

上面这段代码创建了10个线程,每个线程执行1000次对共享变量i的自增操作,但是最终的结果i不一定是10000,而且每次运行的结果也是各不相同。

之前说过i++不是个原子操作,分为三步:

  1. 从主内存中获取i的值,然后缓存至线程工作内存中。
  2. 在线程工作内存中为i进行加1的操作。
  3. 将i的最新值写入主内存中

上面三个操作单独的每一个操作都是原子性操作,但是合起来就不是,因为在执行的中途很有可能会被其他线程打断,比如:

  1. 假设此时i的值为100,线程A要对变量i执行自增操作,首先它需要到主内存中读取i的值,可是此时由于CPU时间片调度的关系,执行权切换到了线程B,A线程进入了RUNNABLE状态而不是RUNNING状态。
  2. 线程B同样需要从主内存中读取i的值,由于线程A没有对i做过任何修改操作,因此此时B获取到的i仍然是100。
  3. 线程B工作内存中为i执行了加1操作,但是未刷新至主内存中。
  4. CPU时间片的调度又将执行权给了线程A,A线程直接对工作线程中的100进行加1运算(因为A线程已经从主内存中读取了i的值),由于B线程并未写入i的最新值,因此A线程工作空间中的100不会被失效。
  5. 线程A将i=101写入主内存之中。
  6. 线程B将i=101写入到主内存中。
    两次运算实际上只对i进行了一次数值的修改变化。

2 内存屏障(了解)

volatile关键字可以保证可见性以及顺序性,那么它到底是如何做到的呢?通过对OpenJDK下unsafe.cpp源码的阅读,会发现被volatile修饰的变量存在于一个“lock;”的前缀,源码如下:

// Adding a lock prefix to an instruction on MP machine
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1:"
……
inline jint Atomic:cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
	int mp = os:is_MP();
	__asm__ volatile (LOCK_IF_MP(4) "cmpxchgl %1,(%3)""=a" (exchange_value)"r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)"cc", "memory");
	return exchange_value;
}

“lock;”前缀实际上相当于是一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:

  1. 确保指令重排序时不会将其后面的代码排到内存屏障之前
  2. 确保指令重排序时不会将其前面的代码排到内存屏障之后。
  3. 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成。
  4. 强制将线程工作内存中值的修改刷新至主内存中。
  5. 如果是写操作,则会导致其他线程工作内存(CPU Cache)中的缓存数据失效。

3 volatile的使用场景

虽然volatile有部分synchronized关键字的语义,但是volatile不可能完全替代synchronized关键字,因为volatile关键字不具备原子性操作语义,我们在使用volatile关键字的时候也是充分利用它的可见性以及有序性(防止重排序)特点。

3.1 开关控制

利用可见性的特点。

开关控制中最常见的就是进行线程的关闭操作,参考:如何关闭一个线程

public class Demo3 {
  static class MyThead extends Thread{
      /**
       * 是否关闭线程的标记,默认为false
       */
      private volatile boolean closed = false;

      @Override
      public void run() {
          System.out.println("I will start work");
          while (!closed && !isInterrupted()){
              System.out.println("i am working.");
          }
          System.out.println("I will be exiting.");
      }
      public void close()
      {
          this.closed = true;
          this.interrupt();
      }
  }

  public static void main(String[] args) throws InterruptedException {
      MyThead t = new MyThead();
      t.start();
      TimeUnit.MILLISECONDS.sleep(200);
      t.close();
  }
}

当外部线程执行close方法时,MyThead 会立刻看到closed 发生了变化(原因是因为MyThead 工作内存中的closed 失效了,不得不到主内存中重新获取)。
如果closed 没有被volatile关键字修饰,那么很有可能外部线程在其工作内存中修改了closed 之后不及时刷新到主内存中,或者MyThead 一直到自己的工作内存中读取closed 变量,都有可能导致closed =true不生效,线程就会无法关闭。

3.2 状态标记

利用顺序性特点

就是上面的这个代码:

private volatile boolean initialized = false;
private Context context;
public Context load(){
	if(!initialized){
		context=loadContext();
		initialized = true;//阻止重排序
	}
	return context;
}

3.3 Singleton设计模式(单例模式)的double-check

Singleton设计模式的double-check也是利用了顺序性特点

4 volatile和synchronized区别

总结一下两个的区别:

  1. 使用上的区别

    • volatile关键字只能用于修饰实例变量或者类变量,不能用于修饰方法以及方法参数和局部变量、常量等。
    • synchronized关键字不能用于对变量的修饰,只能用于修饰方法或者语句块。
    • volatile修饰的变量可以为null,synchronized关键字同步语句块的monitor对象不能为null。
  2. 对原子性的保证

    • volatile无法保证原子性。
    • 由于synchronized是一种排他的机制,因此被synchronized关键字修饰的同步代码是无法被中途打断的,因此其能够保证代码的原子性。
  3. 对可见性的保证

    • 均可以保证共享资源在多线程间的可见性,但是实现机制完全不同。
    • synchronized借助于JVM指令monitor enter和monitor exit对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都将会被刷新到主内存中。
    • 相比较于synchronized关键字volatile使用机器指令(偏硬件)“lock;”的方式迫使其他线程工作内存中的数据失效,不得到主内存中进行再次加载。
  4. 对有序性的保证

    • volatile关键字禁止JVM编译器以及处理器对其进行重排序,所以它能够保证有序性。
    • 虽然synchronized关键字所修饰的同步方法也可以保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在synchronized关键字所修饰的代码块中代码指令也会发生指令重排序的情况
  5. 其他

    • volatile不会使线程陷入阻塞。
    • synchronized关键字会使线程进入阻塞状态。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值