Java关键字——volatile


Java关键字——synchronized: https://blog.csdn.net/qq_41822345/article/details/105144315

一、多线程下变量的不可见性

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

代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.concurrent.TimeUnit;
/**
 * @description:    验证可见性
 * @author: Liu Wen          一个线程对共享变量的修改,另一个线程不能立即得到最新值
 * @create: 2020-03-26 23:13
 **/
public class Test01Visibility {
    public static boolean flag = true;
//    public static volatile boolean flag = true;    //保证可见性
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
                   //代码不会停止
            }
        },"Thread-1").start();

        TimeUnit.SECONDS.sleep(2);

        new Thread(()->{
            flag = false;
            System.out.println(Thread.currentThread().getName()+"已经将flag改为:"+flag);
        },"Thread-2").start();
    }
}

子线程中已经将flag设置为true,但main()方法中始终没有读到修改后的最新值,从而循环不会停止。

分析原因:这要从Java内存模型(JMM)说起。

  Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
  JMM有以下规定:
  1.所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  2.每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  3.线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  4.不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

所以:

在这里插入图片描述

1.子线程t从主内存读取到数据放入其对应的工作内存
2.将flag的值更改为true,但是这个时候flag的值还没有写回主内存
3.此时main方法读取到了flag的值为false
4.当子线程t将flag的值写回去后,但是main函数里面的while(true)调用的是系统比较底层的代码,速度快,快到没有时间再去读取主存中的值。
  所以while(true)读取到的值一直是false。(如果有一个时刻main线程从主内存中读取到了主内存中flag的最新值,那么if语句就可以执行,main线程何时从主内存中读取最新的值,我们无法控制)

总结原因:所有共享变量存在于主内存中,每个线程由自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

二、不可见性的解决方案

1.加锁synchronized

代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.concurrent.TimeUnit;
/**
 * @description:    验证可见性
 * @author: Liu Wen          一个线程对共享变量的修改,另一个线程不能立即得到最新值
 * @create: 2020-03-26 23:13
 **/
public class Test01Visibility {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
                // 增加对共享数据的打印,println是同步方法
                    System.out.println(flag);
            }
        },"Thread-1").start();
        TimeUnit.SECONDS.sleep(2);
        new Thread(()->{
            flag = false;
            System.out.println(Thread.currentThread().getName()+"已经将flag改为:"+flag);
        },"Thread-2").start();
    }
}

因为println()是同步方法,它是由synchronized关键字修饰的,对于加锁有以下两个规则:

1.如果对一个变量执行lock操作,将会清空工作内存中此变量的值。
2.对一个变量执行unlock操作之前,必须先把此变量同步到主内存中。

所以:某一个线程进入synchronized代码块前后,执行过程入如下:

a.线程获得锁(lock)
b.清空工作内存
c.从主内存拷贝共享变量最新的值到工作内存成为副本
d.执行代码
e.将修改后的副本的值刷新回主内存中
f.线程释放锁(unlock)

2.volatile关键字修饰

代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.concurrent.TimeUnit;
/**
 * @description:    验证可见性
 * @author: Liu Wen          一个线程对共享变量的修改,另一个线程不能立即得到最新值
 * @create: 2020-03-26 23:13
 **/
public class Test01Visibility {
    public static volatile boolean flag = true;    //保证可见性
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
                  //代码运行完毕
            }
        },"Thread-1").start();
        TimeUnit.SECONDS.sleep(2);
        new Thread(()->{
            flag = false;
            System.out.println(Thread.currentThread().getName()+"已经将flag改为:"+flag);
        },"Thread-2").start();
    }
}

volatile关键字工作原理:
在这里插入图片描述

  1. 子线程t从主内存读取到数据放入其对应的工作内存
  2. 将flag的值更改为true,但是这个时候flag的值还没有写会主内存
  3. 此时main方法main方法读取到了flag的值为false
  4. 当子线程t将flag的值写回去后,失效其他线程对此变量副本
  5. 再次对flag进行操作的时候线程会从主内存读取最新的值,放入到工作内存中

总结: volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

建议:使用volatile关键字,它是一个轻量级操作,保证并发共享变量可见性的同时,还能保证高并发性能。即比synchronized更高效,一样安全。

三、volatile关键字的特性

1.volatile可以实现并发下共享变量的可见性

这个见:二、不可见性的解决方案

2.volatile不保证原子性

这个典型的例子就是number++操作,它包含三个步骤:

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

代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.ArrayList;
/**
 * @description:    验证原子性
 * @author: Liu Wen
 * @create: 2020-03-26 23:26
 **/
public class Test02Atomicity {
    private static volatile  int number = 0;           //不保证原子操作
    public static void main(String[] args) throws InterruptedException{
        Runnable runnable = ()->{
            for (int i = 0; i < 1000; i++) {
                number++;
            }
        };
        ArrayList<Thread> arrayList = new ArrayList<>();
        //开启五个线程运行runnable任务。
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(runnable);
            t.start();
            arrayList.add(t);
        }
        //为了避免main主线程先执行完毕
        for (Thread t : arrayList) {
            t.join();
        }
        System.out.println("number:"+number);
    }
}
/*
输出<=5000
*/

分析原因:
1)假设此时number的值是100,线程A需要对改变量进行自增1的操作,首先它需要从主内存中读取变量number的值。由CPU的切换关系,此时CPU的执行权被切换到了B线程。A线程就处于就绪状态,B线程处于运行状态。
2)线程B也需要从主内存中读取number变量的值,由于线程A没有对number值做任何修改因此此时B读取到的数据还是100。
3)线程B工作内存中number执行了+1操作,但是未刷新之主内存中。
4)此时CPU的执行权切换到了A线程上,由于此时线程B没有将工作内存中的数据刷新到主内存,因此A线程工作内存中的变量值还是100,没有失效。A线程对工作内存中的数据进行了+1操作。
5)线程B将101写入到主内存。
6)线程A将101写入到主内存。
虽然计算了2次,但是只对number进行了1次修改。

如何解决?
1.使用加锁机制。在多线程环境下,要保证数据的安全性,我们还需要使用锁机制。
2.使用原子类。java从JDK1.5开始提供了java.util.concurrent.atomic包(简称Atomic包),这个包中的原子操作类提供了一种用法简单,性能高效(CAS操作代替锁),线程安全地更新一个变量的方式。

3.volatile关键字可以禁止指令重排序(可以保证有序性)

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。一般重排序可以分为如下三种:
1.编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2.指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
3.内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
  源代码→编译器优化重排→指令级并行重排→内存系统重排→最终执行的指令序列

重排序可以提高处理速度:
在这里插入图片描述
使用volatile可以禁止指令重排序,从而修正重排序可能带来的并发安全问题。
代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
/**
 * @description:   volatile 可以禁止指令重排序
 * @author: Liu Wen
 * @create: 2020-03-27 19:05
 **/
public class Test03Order2 {
    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 == 1 && j == 1) {
                System.out.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}
/*
代码会出现i=0,j=0的输出
*/

分析原因:在线程1和线程2内部的两行代码的实际执行顺序和代码在Java文件中的顺序是不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序改变了,这样就是发生了重排序,这里颠倒的是 a = 1 ,i = b 以及b=1,j=a的顺序,从而发生了指令重排序。造成i=b(0)先于b=1执行,j=a(0)先于a=1执行。显然这个值是不对的。

解决方案:

 private volatile static int i = 0, j = 0;   //禁止重排序
 private volatile static int a = 0, b = 0;

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

为什么volatile关键字可以禁止重排序?↓

4.volatile写读建立的happens-before关系

  背景:指令重排序导致的多个线程操作之间的不可见性。如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。

  从JDK 5开始,提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。(happens-before原则,让线程之间遵守这些原则,相当于给了编译器优化的约束)。

happens-before规则一共6条:

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()操作happensbefore于线程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可见。

volatile重排序规则:

在这里插入图片描述

  • 写volatile变量时,无论前一个操作是什么,都不能重排序。
  • 读volatile变量时,无论后一个操作是什么,都不能重排序。
  • 当先写volatile变量,后读volatile变量时,不能重排序。

四、volatile高频面试与总结

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关键字来防止此类现象。

  • 如果使用volatile修饰long和double,那么其读写都是原子操作;
  • java中对于其他类型的读写操作都是原子操作(除long和double类型以外);
  • 对于引用类型的读写操作都是原子操作,无论引用类型的实际类型是32位的值还是64位的值;
  • Java商业虚拟机已经解决了long和double的读写操作的原子性。
2.volatile在双重检查加锁的单例中的应用

单例是需要在内存中永远只能创建一个类的实例。
单例的作用:节约内存和保证共享计算的结果正确,以及方便管理。
单例模式的适用场景:
全局信息类:例如任务管理器对象,或者需要一个对象记录整个网站的在线流量等信息。
无状态工具类:类似于整个系统的日志对象等,我们只需要一个单例日志对象负责记录,管理系统日志信息。

单例模式有8种,两种饿汉单例,四种懒汉单例,一种静态内部类单例,一种枚举单例。
8种单例模式:https://blog.csdn.net/qq_41822345/article/details/104488560

懒汉式(volatile双重检查模式,推荐):线程安全,延迟加载,效率较高!

package com.liuwen.JVM虚拟机.关键字volatile.单例模式;
/**
 * @description: 懒汉式(volatile双重检查模式,推荐)
 * @author: Liu Wen
 * @create: 2020-03-27 23:34
 **/
public class SingletonDemo6 {
    //静态属性,volatile保证可见性和禁止指令重排序
    private volatile static SingletonDemo6 INSTANCE;
    //私有化构造,不对外提供访问
    private SingletonDemo6(){

    }
    public static SingletonDemo6 getInstance(){
        // 第一重检查锁定
        if(INSTANCE==null){
            // 同步锁定代码块
            synchronized (SingletonDemo6.class){
                // 第二重检查锁定
                if(INSTANCE==null){
                    // 注意:这里是非原子操作,所以需要volatile关键字修饰INSTANCE
                    INSTANCE = new SingletonDemo6();
                }
            }
        }
        return INSTANCE;
    }
}

1.为什么要保证可见性?

  • 由于可见性问题,线程A在自己的工作线程内创建了实例,但此时还未同步到主存中;此时线程B在主存中判断instance还是null,那么线程B又将在自己的工作线程中创建一个实例,这样就创建了多个实例。
  • 如果加上了volatile修饰instance之后,保证了可见性,一旦线程A返回了实例,线程B可以立即发现Instance不为null。

2.为什么要禁止重排序?

  • 对象实际上创建对象要进过如下几个步骤: a. 分配内存空间。
    b. 调用构造器, 初始化实例。
    c. 返回地址给引用
  • 所以,new Singleton()是一个非原子操作,编译器可能会重排序而线程B在线程A赋值完时判断instance就不为null了,此时B拿到的将是一个没有初始化完成的半成品。这样是很危险的。因为极有可能线程C会继续拿着个没有初始化的对象中的数据进行操作,此时容易触发“NPE异常”( NullPointerException )。
3.volatile的使用场景

1.赋值操作

  volatile不适合做a++等操作,适合做纯复制操作:如 boolean flag = false/true;
  volatile可以适合做多线程中的纯赋值操作:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全。

2.触发器

  按照volatile的可见性和禁止重排序以及happens-before规则,volatile可以作为刷新之前变量的触发器。我们可以将某个变量设置为volatile修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的且可见。

4.volatile与synchronized的区别
  • volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
  • volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。
  • volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
  • volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

五、总结

  1. volatile 修饰符适用于以下场景;某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  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可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

进击的程序猿~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值