volatile关键字详解

感谢传智播客老师的深入细致讲解
 
 volatile 关键字
1.1. 多线程下变量的不可见性
1.1.1 概述
在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到该线程修改后的变量的最新值。

1.1.2 案例演示

public class MyThread extends Thread {
    // 定义成员变量
    private boolean flag = false ;
    public boolean isFlag() { return flag;}
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
    }
    // 将flag的值更改为true
    this.flag = true ;
    System.out.println("flag=" + flag);
   }
}
public class VolatileThreadDemo {// 测试类
     public static void main(String[] args) {
         // 创建MyThread线程对象
         Thread t = new MyThread() ;
         t.start();
         // main方法
         while(true) {
             if(t.isFlag()) {
             System.out.println("执行了======");
         }
      }
   }
}
 
我们看到,子线程中已经将 flag 设置为 true ,但 main()方法中始终没有读到修改后的最新值,从而循环没有能进入到语句中执行,所以没有任何打印。
1.1.3 小结
多线程下修改共享变量会出现变量修改值后的不可见性。
 
1.2 变量不可见性内存语义
1.2.1 概述
Java 内存模型(和 Java 并发编程有关的模型):JMM java 虚拟机规范中所定义的一种内存模型, Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
Java 内存模型 (Java Memory Model) 描述了 Java 程序中各种变量 ( 线程共享变量 ) 的访问规则,以及在 JVM 中将变量 存储到内存和从内存中读取变量这样的底层细节。
JMM 有以下规定:
所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
线程对变量的所有的操作 ( 读,取 ) 都必须在工作内存中完成,而不能直接读写主内存中的变量。
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
本地内存和主内存的关系:
volatile关键字详解_第1张图片
volatile关键字详解_第2张图片
1. 子线程 t 从主内存读取到数据放入其对应的工作内存
2. flflag 的值更改为 true ,但是这个时候 flflag 的值还没有写回主内存
3. 此时 main 方法读取到了 flflag 的值为 false
4. 当子线程 t flflag 的值写回去后,但是 main 函数里面的 while(true) 调用的是系统比较底层的代码,速度快,快到
没有时间再去读取主存中的值,
所以 while(true) 读取到的值一直是 false ( 如果有一个时刻 main 线程从主内存中读取到了主内存中 flflag 的最新
值,那么 if 语句就可以执行, main 线程何时从主内存中读取最新的值,我们无法控制 )
1.2.3 小结
可见性问题的原因
所有共享变量存在于主内存中,每个线程由自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。
 
1.3. 变量不可见性解决方案
1.3.1 概述
如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见呢?
两种方案:
第一种是加锁,第二种是使用 volatile 关键字
1.3.2 解决方案
加锁
// main方法 
while(true) { 
    synchronized (t) { 
        if(t.isFlag()){ 
            System.out.println("主线程进入循环执行~~~~~"); 
       } 
    } 
}
某一个线程进入 synchronized 代码块前后,执行过程入如下:
a. 线程获得锁
b. 清空工作内存
c. 从主内存拷贝共享变量最新的值到工作内存成为副本
d. 执行代码
e. 将修改后的副本的值刷新回主内存中
f. 线程释放锁
volatile 关键字修饰
使用 volatile 关键字修改该变量
private volatile boolean flag ;

volatile关键字详解_第3张图片

 

1. 子线程 t 从主内存读取到数据放入其对应的工作内存
2. flflag 的值更改为 true ,但是这个时候 flflag 的值还没有写会主内存
3. 此时 main 方法 main 方法读取到了 flflag 的值为 false
4. 当子线程 t flflag 的值写回去后,失效其他线程对此变量副本
5. 再次对 flflag 进行操作的时候线程会从主内存读取最新的值,放入到工作内存中
总结: volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
1.3.3 小结
volatile 修饰的变量可以在多线程并发修改下,实现线程间变量的可见性。
 
 volatile 的其他特性
2.1 volatile 特性概述
volatile 总体概览
除了 volatile 可以保证可见性外, volatile还具备如下一些突出的特性:
volatile 的原子性问题 volatile 不能保证原子性操作。
禁止指令重排序: volatile 可以防止指令重排序操作。
2.2 volatile 不保证原子性
所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行。volatile 不保证原子性。
2.1.1 问题案例演示
public class VolatileAtomicThread implements Runnable { 
    // 定义一个int类型的遍历 
    private int count = 0 ; 
    @Override public void run() { 
        // 对该变量进行++操作,100次 
        for(int x = 0 ; x < 100 ; x++) { 
            count++ ; 
            System.out.println("count =========>>>> " + count); 
        } 
    } 

}
public class VolatileAtomicThreadDemo { 
    public static void main(String[] args) { 
        // 创建VolatileAtomicThread对象 
        VolatileAtomicThread volatileAtomicThread = new VolatileAtomicThread() ; 
        // 开启100个线程对count进行++操作 
        for(int x = 0 ; x < 100 ; x++) { 
            new Thread(volatileAtomicThread).start();
        } 
    } 
}
执行结果:不保证一定是 10000
2.1.2 问题原理说明
以上问题主要是发生在 count++ 操作上:
count++ 操作包含 3 个步骤:
从主内存中读取数据到工作内存
对工作内存中的数据进行 ++ 操作
将工作内存中的数据写回到主内存
count++ 操作不是一个原子性操作,也就是说在某一个时刻对某一个操作的执行,有可能被其他的线程打断。
2.1.3 小结
在多线程环境下, volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性(在多线程环境下volatile 修饰的变量也是线程不安全的)。
在多线程环境下,要保证数据的安全性,我们还需要使用锁机制。
2.1.4 问题解决
使用锁机制
我们可以给 count++ 操作添加锁,那么 count++ 操作就是临界区的代码,临界区只能有一个线程去执行,所以count++就变成了原子操作。
原子类
概述: java JDK1.5 开始提供了 java.util.concurrent.atomic ( 简称 Atomic ) ,这个包中的原子操作类提供了一种用法简单,性能高效,线程安全地更新一个变量的方式。
AtomicInteger
原子型 Integer ,可以实现原子更新操作
    public class VolatileAtomicThread implements Runnable { 
        // 定义一个int类型的变量 
        private AtomicInteger atomicInteger = new AtomicInteger() ; 
        @Override public void run() { 
            // 对该变量进行++操作,100次 
            for(int x = 0 ; x < 100 ; x++) { 
                int i = atomicInteger.getAndIncrement(); 
                System.out.println("count =========>>>> " + i); 
            } 
        } 
    }

 

2.3 禁止指令重排序
2.3.1 概述
什么是重排序 :为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
原因:一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM 对底层尽量减少约束,使其能够发挥自身
优势。因此,在执行程序时, 为了提高性能,编译器和处理器常常会对指令进行重排序 。一般重排序可以分为如下三种:
1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果 不存在数据依赖性 ,处理器
可以改变语句对应机器指令的执行顺序;
3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
2.3.2 重排序的好处
重排序可以提高处理的速度。
volatile关键字详解_第4张图片
2.3.3 重排序问题案例演示
引入 :重排序虽然可以提高执行的效率,但是在并发执行下, JVM 虚拟机底层并不能保证重排序下带来的安全性等问题,请看如下案例:
    public class OutOfOrderExecution { 
        private static int i = 0, j = 0; 
        private static int a = 0, b = 0; 
        public static void main(String[] args) throws InterruptedException { 
            int count = 0; 
            // 计数 
            while(true) { 
                count++; 
                i = 0; 
                j = 0; 
                a = 0; 
                b = 0; 
                Thread one = new Thread(new Runnable() {
                    @Override public void run() { 
                        a = 1; i = b; 
                    } 
                }); 
                Thread two = new Thread(new Runnable() { 
                    @Override public void run() { 
                        b = 1; j = a; 
                    } 
                }); 
                two.start(); 
                one.start(); 
                one.join(); 
                two.join(); 
                String result = "第" + count + "次( i= " + i + ", j= " + j + ")"; 
                if (i == 0 && j == 0) {
                    System.out.println(result); break; 
                } else { 
                    System.out.println(result); 
                } 
            } 
        } 
    }
以上程序中的 4 行代码的执行顺序决定了 最终 i j 的值 ,在执行的过程中可能会 出现三种 情况如下:
1. a = 1 ; i=b(0) ; b = 1 ; y = a(1) 最终
2. b = 1 ; j=a(0) ; a = 1 ; i = b(1) 最终
3. b = 1 ; a=1 ; i = b(1) ; j = a(1) 最终
但是有一种情况大家可能是没有发现的,经过测试如下:
在某一次中出現了i=0,j=0的情况
按照以前的观点 :代码执行的顺序是不会改变的,也就第一个线程是 a=1 是在 i=b 之前执行的,第二个线程 b=1 是在 j=a之前执行的。
发生了重排序 :在线程 1 和线程 2 内部的两行代码的 实际执行顺序 和代码在 Java 文件中的顺序是不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序改变了,这样就是发生了重排序,这里颠倒的 是 a = 1 ,i = b 以及 j=a , b=1 的顺序,从而发生了指令重排序。直接获取了 i = b(0) , j = a(0) 的值!显然这个值是不对的。
2.3.4 volatile 禁止重排序
volatile 修饰变量后可以实现禁止指令重排序!
2.3.5 小结
使用 volatile 可以禁止指令重排序,从而修正重排序可能带来的并发安全问题。
 
 volatile 内存语义
3.1 volatile 写读建立的 happens-before 关系
3.1.1 概述
上面的内容讲述了重排序原则,为了提高处理速度, JVM 会对代码进行编译优化 ,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性 。如果让程序员再去了解这些
底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。 从JDK 5 开始,提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 所以为了解决多线程的可见性问题,就搞出了happens-before 原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的不知道东南西北了!
简单来说: happens-before 应该翻译成:前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作变量a赋值为 1 ,那后面一个操作肯定能知道 a 已经变成了 1
3.1.2 happens-before 规则
具体的一共有六项规则:
1. 程序顺序规则(单线程规则)
解释:一个线程中的每个操作, happens-before 于该线程中的任意后续操作同一个线程中前面的所有写操作对后面的操作可见
2. 锁规则( Synchronized,Lock 等)
解释:对一个锁的解锁, happens-before 于随后对这个锁的加锁。
如果线程 1 解锁了 monitor a ,接着线程 2 锁定了 a ,那么,线程 1 解锁 a 之前的写操作都对线程 2 可见(线程1和线程 2 可以是同一个线程)
3. volatile 变量规则
解释:对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读。
如果线程 1 写入了 volatile 变量 v (临界资源),接着线程 2 读取了 v ,那么,线程 1 写入 v 及之前的写操作都 对线程 2 可见(线程 1 和线程 2 可以是同一个线程)
4. 传递性
解释:如果 A happens-before B ,且 B happens-before C ,那么 A happens-before C
A h-b B B h-b C 那么可以得到 A h-b C
5. start() 规则:
解释:如果线程 A 执行操作 ThreadB.start() (启动线程 B ),那么 A 线程的 ThreadB.start() 操作 happens-before于线程 B 中的任意操作。
假定线程 A 在执行过程中,通过执行 ThreadB.start() 来启动线程 B ,那么线程 A 对共享变量的修改在接下来线程B 开始执行前对线程 B 可见。注意:线程 B 启动之后,线程 A 在对变量修改线程 B 未必可见
6. join() 规则
解释:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从ThreadB.join()操作成功返回。
线程 t1 写入的所有变量,在任意其它线程 t2 调用 t1.join() ,或者 t1.isAlive() 成功返回后,都对 t2 可见
3.1.3 volatile 写读建立的 happens-before 规则
happens-before 有一个原则是:如果 A 是对 volatile 变量的写操作, B 是对同一个变量的读操作,那么 hb(A,B)
    public class VisibilityHP { 
        int a = 1; 
        int b = 2; 
        private void write() { 
            a = 3; b = a; 
        }
        private void read() { 
            System.out.println("b=" + b + ";a=" + a);
        }
        public static void main(String[] args) { 
            while (true) { 
                VisibilityHP test = new VisibilityHP(); 
                new Thread(new Runnable() { 
                    @Override public void run() { 
                        test.write(); 
                    } 
                }).start(); 
                new Thread(new Runnable() { 
                    @Override public void run() { 
                        test.read(); 
                    } 
                }).start(); 
            } 
        } 
    }
分析以上案例存在四种执行情况:
b=3;a=3
b=2;a=1
b=2;a=3
第四种情况(低概率)是:没给 b volatile, 那么有可能出现 a=1 , b = 3 。因为 a 虽然被修改了,但是其他线程不可见,而b 恰好其他线程可见,造成了 b=3 , a=1
如何解决第四种情况题呢?
按照 happens-before 规则,我们只需要给 b 加上 volatile ,那么 b 之前的写入( a = 3; )将对读取 b 之后的代码可见,也就是说即使a 不加 volatile, 只要 b 读取到 3 ,那么 b 之前的操作( a=3 )就一定是可见的,此时就绝对不会出现 b=3 的时候而读取到a=1 了。
3.1.4 volatile 重排序规则小结
volatile 重排序规则:
volatile关键字详解_第5张图片
volatile 变量时,无论前一个操作是什么,都不能重排序
volatile 变量时,无论后一个操作是什么,都不能重排序
当先写 volatile 变量,后读 volatile 变量时,不能重排序
 
 volatile 总结
4.1 long double 的原子性
概述
java 中, long double 都是 8 个字节共 64 ( 一个字节 =8bit) ,那么如果是一个 32 位的系统,读写 long double 的变量时会涉及到原子性问题,因为32 位的系统要读完一个 64 位的变量,需要分两步执行,每次读取 32 位,这样就对 double和 long 变量的赋值就会出现问题:
如果有两个线程同时写一个变量内存,一个进程写低 32 位,而另一个写高32位,这样将导致获取的 64 位数据是失效的数据。
结论 :如果是在 64 位的系统中,那么对 64 位的 long double 的读写都是原子操作的。即可以以一次性读写 long 或 double的整个 64bit 。如果在 32 位的 JVM 上, long double 就不是原子性操作了。
解决方法:需要使用 volatile 关键字来防止此类现象
对于 64 位的 long double ,如果没有被 volatile 修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32 位操作。
如果使用 volatile 修饰 long double ,那么其读写都是原子操作在实现JVM 时,可以自由选择是否把读写 long double 作为原子操作; java中对于 long double 类型的写操作不是原子操作,而是分成了两个 32 位的写操作。读操作是否也分成了两
32 位的读呢?在 JSR-133 之前的规范中,读也是分成了两个 32 位的读,但是从 JSR-133 规范开始,即 JDK5 开始,读操作也都具有原子性;
java 中对于其他类型的读写操作都是原子操作 ( long double 类型以外 ) ; 对于引用类型的读写操作都是原子操作,无论引用类型的实际类型是32 位的值还是 64 位的值; Java商业虚拟机已经解决了 long double 的读写操作的原子性。
4.2 volatile 在双重检查加锁的单例中的应用
 
    public class Singleton {
        // 静态属性,volatile保证可见性和禁止指令重排序
        private volatile static Singleton instance = null;
        private Singleton6(){} 
        public static Singleton getInstance(){ 
            // 第一重检查锁定 
            if(instance==null){ 
                // 同步锁定代码块 
                synchronized(Singleton.class){ 
                    //第二重检查锁定 
                    if(instance==null){ 
                        // 注意:非原子操作 
                        instance=new Singleton(); 
                    } 
                } 
            }
            return instance; 
        } 
    }
4.3 volatile 的使用场景
4.3.1 纯赋值操作
volatile 不适合做 a++ 等操作。 适合做纯赋值操作 : boolean flag = false/true;
4.3.2 触发器
按照 volatile 的可见性和禁止重排序以及 happens-before 规则, volatile 可以作为刷新之前变量的触发器。我们可以将某个变量设置为volatile 修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的 且可见。
 
4.4 volatile synchronized
4.4.1 区别
volatile 只能修饰实例变量和类变量,而 synchronized 可以修饰方法,以及代码块。
volatile 保证数据的可见性,但是不保证原子性 ( 多线程进行写操作,不保证线程安全 ); synchronized 是一种排他(互斥)的机制。
volatile 用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
volatile 可以看做是轻量版的 synchronized,volatile 不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile 来代替 synchronized, 因为赋值本身是有原子性的,而 volatile又保证了可见性,所以就可以保证线程安全了。
 
4.5 volatile 的总结
内存可见性、不保证原子性、禁止指令重排
1. volatile 修饰符适用于以下场景 : 某个属性被多个线程共享 , 其中有一个线程修改了此属性 , 其他线程可以立即得到修改后的值, 比如 boolean flflag ; 或者作为触发器 , 实现轻量级同步。
2. volatile 属性的读写操作都是无锁的 , 它不能替代 synchronized , 因为它没有提供原子性和互斥性。因为无锁 , 不需要花费时间在获取锁和释放锁_ , 所以说它是低成本的。
3. volatile 只能作用于属性 , 我们用 volatile 修饰属性 , 这样 compilers 就不会对这个属性做指令重排序。
4. volatile 提供了可见性 , 任何一个线程对其的修改将立马对其他线程可见。 volatile 属性不会被线程缓存 , 始终从主存中读取。
5. volatile 提供了 happens-before 保证 , volatile 变量 v 的写入 happens- before 所有其他线程后续对 v 的读操作。
6. volatile 可以使得 long double 的赋值是原子的。
7. volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
 

你可能感兴趣的:(多线程,并发库,volatile)