volatile 关键字 (详细解析)

前置知识

共享变量不可见性

在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到该线程修改后的变量的最新值

代码实例

public class VisibilityDemo01 {
    // main方法,作为一个主线程。
    public static void main(String[] args) {
        // a.开启一个子线程
        MyThread t = new MyThread();
        t.start();
 
        // b.主线程执行
        while(true){
            if(t.isFlag()){
                System.out.println("主线程进入循环执行~~~~~");
            }
        }
    }
}
 
class MyThread extends Thread{
    // 成员变量
    private boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 触发修改共享成员变量
        flag = true;
        System.out.println("flag="+flag);
    }
    public boolean isFlag() {
        return flag;
    }
 
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

运行结果

我们看到,子线程中已经将flag设置为true,但main()方法中始终没有读到修改后的最新值,从而循环没有能进入到if语句中执行,所以没有任何打印 , 这就是变量的不可见性

JMM

注意区别JMM和JVM JVM和JMM是有区别的,它们是两个不同的概念:

  1. JVM是Java Virtual Machine(Java虚拟机)的缩写,它是Java编程语言的核心组件之一。JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM负责执行Java程序的指令,并提供一些高级功能,如垃圾回收、内存管理、线程调度等。
  2. JMM是Java Memory Model(Java内存模型)的缩写,它是Java虚拟机规范中定义的一种抽象的概念。JMM定义了线程和主内存之间的抽象关系,即JMM中定义了线程在JVM主内存中的工作方式。JMM规范了Java虚拟机与计算机内存是如何协同工作的,包括如何读取和写入共享变量,以及在必要时如何同步访问共享变量。

JVM负责执行Java程序,并提供高级功能,而JMM则定义了线程和内存之间的抽象关系,以确保Java程序在多线程环境下的正确性

工作内存 和 主内存概念

工作内存 和 主内存概念

JMM规定如下:

  • 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

现在就可以解释 共享变量不可见性 的原因

  1. 子线程t 和 main方法 从主内存读取到数据放入其对应的工作内存(子线程t 和 main方法 谁先执行不一定),此时 flag 的值为 false
  2. 子线程t 睡眠1秒后,将flag的值更改为true,但是这个时候flag的值还没有写回主内存
  3. 当 子线程t 将flag的值写回去后,但是main方法不会再去读取主存中的值,而是读取自己工作内存中的 flag变量副本,所以while(true)读取到的值一直是false(虽然 main方法 可能会在某一时刻读取主内存中flag 的最新值来刷新flag变量副本,但这个时间我们是无法控制的)

为什么 main方法要去 读取自己工作内存中的 flag变量副本,而不每次都去主内存中读取,这类似 多级缓存的概念,线程从自己的工作内存中读取数据的速度会快于从主内存中读取数据的速度

volatile 关键字

如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见呢?有两种方法

  • 使用volatile关键字
  • 加锁

使用volatile关键字

使用volatile关键字修改该变量

private volatile boolean flag ;

运行结果

运行结果

我们看到 使用volatile关键字解决了 共享变量不可见性的问题,即 一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

工作原理

  1. 子线程t 和 main方法 从主内存读取到数据放入其对应的工作内存,此时 flag的值为false
  2. 子线程t 将flag的值更改为true
  3. 在某一时刻 子线程t 将flag的值写回主内存后,失效其他线程对此变量副本
  4. main方法 再次对flag进行操作的时候线程会从主内存读取最新的值,放入到工作内存中

加锁

修改main方法

// main方法
while(true) {
    synchronized (t) {
        if(t.isFlag()){
            System.out.println("主线程进入循环执行~~~~~");
        }
    }
}

运行结果

运行结果

可以看到同样是解决了 共享变量不可见性的问题

工作原理

  1. 某一个线程进入synchronized代码块前后,执行过程入如下:
  2. 线程获得锁
  3. 清空工作内存
  4. 从主内存拷贝共享变量最新的值到工作内存成为副本
  5. 执行代码
  6. 将修改后的副本的值刷新回主内存中
  7. 线程释放锁

虽然加锁同样能解决 共享变量不可见性的问题,但是 加锁 和 锁的释放 过程都是会有性能消耗的,所以在解决 共享变量不可见性的问题 时,首选 volatile关键字

volatile 关键字 -- 更深入的问题

除了 volatile 可以保证可见性外,volatile 还具备如下一些突出的特性:

  • volatile的原子性问题:volatile不能保证原子性操作。
  • 禁止指令重排序:volatile可以防止指令重排序操作。

volatile不保证原子性

原子性:在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行

看如下程序,该程序开启了100个线程,同时对同一个变量进行自增10000次

public class VolatileDemo04 {
    public static void main(String[] args) {
        // 1.创建一个线程任务对象
        Runnable target = new ThreadTarget01();
        // 2.开始100个线程对象执行这个任务。
        for(int i = 1 ; i <= 100 ; i++ ) {
            new Thread(target,"第"+i+"个线程").start();
        }
    }
}
 
// 线程任务类
class ThreadTarget01 implements Runnable{
    // 定义一个共享变量
    private volatile int count = 0 ;
    @Override
    public void run() {
        synchronized (ThreadTarget01.class){
            for(int i = 1 ; i <= 10000 ; i++ ) {
                count++;
                System.out.println(Thread.currentThread().getName()+"count =========>>>> " + count);
            }
        }
    }
}

最后的结果正常应该是 1000000

但是,实际上是有可能会少于 1000000 的

但是我已经运行了好多次,没有出先少于的情况,所以没运行结果哈哈

原理

count++ 操作包含 3 个步骤:

  1. 从主内存中读取数据到工作内存
  2. 对工作内存中的数据进行++操作
  3. 将工作内存中的数据写回到主内存

count++操作不是一个原子性操作,也就是说在某一个时刻对某一个操作的执行,有可能被其他的线程打断

比如:

  1. 线程A从主存中读取count的值为100,此时由于CPU的切换关系,此时CPU的执行权被切换到了B线程,A线程就处于就绪状态,B线程处于运行状态
  2. 线程B也需要从主内存中读取count变量的值,由于线程A没有对count值做任何修改,因此此时B读取到的数据还是100
  3. 线程B工作内存中count执行了+1操作,但是未刷新到主内存中
  4. 此时CPU的执行权切换到了A线程上,由于此时线程B没有将工作内存中的数据刷新到主内存,因此A线程工作内存中的变量值还是100,没有失效。
  5. A线程对工作内存中的数据进行了+1操作
  6. 线程B将101写入到主内存
  7. 线程A将101写入到主内存

虽然计算了2次,但是只对A进行了1次修改

因此,在多线程环境下,volatile关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性(在多线程环境下volatile修饰的变量也是线程不安全的)

要保证原子性操作,有两种方法:1、使用锁机制 2、原子类 这里不在展开讲

volatile禁止指令重排序

重排序:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序

重排序

重排序虽然可以提高执行的效率,但是在并发执行下,JVM虚拟机底层并不能保证重排序下带来的安全性等问题,请看如下案例

public class OutOfOrderDemo06 {
    // 新建几个静态变量
    public static int a = 0 , b = 0;
    public static int i = 0 , j = 0;
 
    public static void main(String[] args) throws Exception {
        int count = 0;
        while(true){
            count++;
            a = 0 ;
            b = 0 ;
            i = 0 ;
            j = 0 ;
            // 定义两个线程。
            // 线程A
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    i = b;
                }
            });
 
            // 线程B
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    j = a;
                }
            });
 
            t1.start();
            t2.start();
            t1.join(); // 让t1线程优先执行完毕
            t2.join(); // 让t2线程优先执行完毕
 
            // 得到线程执行完毕以后 变量的结果。
            System.out.println("第"+count+"次输出结果:i = " +i +" , j = "+j);
            if(i == 0 && j == 0){
                break;
            }
        }
    }
}

正常情况下,会有以下三种情况

  • a = 1 ; i=b(0) ; b = 1 ; j = a(1) ,最终(i = 0, j = 1)
  • b = 1 ; j=a(0) ; a = 1 ; i = b(1) ,最终(i = 1, j = 0)
  • b = 1 ; a=1 ; i = b(1) ; j = a(1) ,最终(i = 1, j = 1)

但是,在很小的情况下会出现另外一种结果 i = 0 , j = 0

这就是发生重排序的结果

比如 线程1 中先执行了 i = b,然后切换到 进程2 且先执行 j = a,然后再分别执行 a = 1,b = 1

这样输出的结果就是 i = 0 , j = 0

而使用volatile可以禁止指令重排序,从而修正重排序可能带来的并发安全问题 ,如下

public class OutOfOrderDemo07 {
    // 新建几个静态变量
    public  static int a = 0 , b = 0;
    public volatile static int i = 0 , j = 0;
 
    public static void main(String[] args) throws Exception {
        int count = 0;
        while(true){
            count++;
            a = 0 ;
            b = 0 ;
            i = 0 ;
            j = 0 ;
            // 定义两个线程。
            // 线程A
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    i = b;
                }
            });
 
            // 线程B
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    j = a;
                }
            });
 
            t1.start();
            t2.start();
            t1.join(); // 让t1线程优先执行完毕
            t2.join(); // 让t2线程优先执行完毕
 
            // 得到线程执行完毕以后 变量的结果。
            System.out.println("第"+count+"次输出结果:i = " +i +" , j = "+j);
            if(i == 0 && j == 0){
                break;
            }
        }
    }
}
end

评论

新增邮件回复功能,回复将会通过邮件形式提醒,请填写有效的邮件!