java多线程学习 volatile关键字的使用

volatile关键字是java虚拟机提供的最轻量级的同步机制,用来修饰变量,可以保证变量线程间的可见性且禁止指令重排序。
为了更好的理解volatile关键字,先来说下java内存模型

java内存模型

在Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。那么Java内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。
注意,为了获得较好的执行性能,Java内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在java内存模型中,也会存在缓存一致性问题和指令重排序的问题。

  • 缓存一致性问题:
    • cpu执行程序的时候,会将运算所需的数据从内存复制一份到自己的高速缓存区,读取数据或写入数据都是对高速缓存区操作,执行结束后再将高速缓存的值刷新到主存中
    • 多线程并发执行的时候,每个线程都把需要的数据从主存中复制到自己所在的cpu的高速缓存里,所以一个变量可能在多个高速缓存中存在副本,读写过程中就容易造成缓存中变量和主存中的变量不一致的问题,这就是缓存一致性问题。

Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存

保证线程见的可见性
  • 共享普通变量来说,java内存模型约定了变量在工作内存中发生变化了之后,必须要回写到工作内存(迟早要回写但并非马上回写),
  • 但对于volatile变量,java内存模型要求工作内存中发生变化之后,必须马上写回到主内存,而线程读取volatile变量的时候,必须马上到主内存中去取最新值而不是读取本地工作内存的副本.
  • volatile本质上是根据硬件级别的MESI 缓存一致性协议实现的
public class myThread7 implements Runnable {
    //可以对比一下有无volatile的情况
   /*volatile*/ boolean running = true;
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+"开始执行");
        while (running){

        }
        System.out.println(Thread.currentThread().getName()+"结束执行");
    }

    public static void main(String[] args) {
        myThread7 m = new myThread7();
        new  Thread(m,"t1").start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        m.running = false;

    }
}

java多线程学习 volatile关键字的使用_第1张图片
java多线程学习 volatile关键字的使用_第2张图片
ps:不用volatile的情况下,程序可能也会正常结束,但不是一定可以正常结束,使用volatile的作用就是保证在多线程情况下程序也能正常结束.

禁止指令重排序

  • 指令重排序:
    • 编译器重排序:为了优化程序的性能,编译器在不影响程序逻辑结果的前提下,会重新对程序源代码编译出的字节码指令进行重新排序
    • 处理器重排序:(多核)cpu在执行指令的时候,在不影响逻辑结果的前提下,也会对指令进行重排序
    • 在单线程的情况下,指令重排序不会影响程序执行的结果,而且可以很好的优化程序的性能.在多线程的情况下,有可能回出现线程安全问题.
  • volatile禁止指令重排序有两层意思
    • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行
    • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

双检查锁单例模式

双检查单例模式是volatile使用场景里一个比较简单的例子

  • 单例模式:保证某个类在jvm内存中有且只有一个实例
    • 特点:
      • 类构造器私有
      • 持有自己类型的属性
      • 对外提供获取实例的静态方法
  • 双检查: 不加锁前检查一次instance== null,加锁之后,在同步代码块再检查一次instance==null
/**
 * Created by IntelliJ IDEA
 *
 * @author manzuo
 * @date 2020/5/17 2:33
 */
public class Singleton {
    //必须用volatile修饰
    //防止实例化Singleton的时候,指令被重排序
    private static volatile Singleton instance;
    private Singleton(){

    }
    public static Singleton getInstance(){
        if (instance==null){
            synchronized (Singleton.class){
                if (instance==null){
                    instance = new Singleton();
                    /*new SingLeton()的操作分成三步
                    *1. 申请一块内存
                    *2. 按照构造方法的参数初始化内存(这里省略了这个类的其他参数,在实际业务中会包含其他逻辑以及参数
                    *3. 将这块内存的地址赋值给instance
                    * 如果instance不用volatile修饰,上面的相关指令可能会被重排序
                    * 比如按照 1,3,2的步骤来实例化该类
                    * 这样new Singleton()的操作还没结束,instance已经不为null,其他线程就可能拿到未初始化的instance
                    */
                }
            }
        }
        return  instance;
    }
}

volatile不能保证原子性

  • 线程安全的三个要求:(线程安全问题的本质)
    • 原子性:提供互斥访问,同一时刻只有一个线程能对共享数据进行操作
    • 可见性:当一个线程修改了共享变量的值,其他线程能立即得知这个修改
    • 有序性:指的是程序按照代码的先后顺序执行。
      • 个人理解:
        • 由于为了性能优化,编译器和处理器会进行指令重排序,但对于单线程的情况来说,指令重排序不会影响程序运行的结果,所以还可以认为是有序的。但多线程的情况下,指令重排序就会影响程序运行的结果。
        • synchronized通过锁机制,同一时刻只能只能有一个线程访问,所以可以看成是单线程,所以可以实现有序性。volatile通过禁止指令重排序也可以做到有序性。
          synchronized可以同时实现原子性、可见性、有序性,所以可以通过synchronized关键字实现线程安全,volatile虽然可以保证可见性和有序性,但volatile没有实现互斥操作,所以不能保证原子性
public class myThread8 extends Thread {
    volatile int count = 0;
  @Override
  public void run(){
      //每个线程循环10000次count++操作
      for (int i = 0; i < 10000; i++) {
          count++;
      }
  }
    public static void main(String[] args)
    {
        myThread8 m = new myThread8();
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            //新建10个线程,加入threads中
            Thread thread = new Thread(m,"thread-"+i);
            threads.add(thread);
        }
        //逐一启动线程
        threads.forEach((thread -> thread.start()));
        //在主线程中利用join方法,等待所有方法完成
        threads.forEach(thread -> {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        //所有子线程运行结束,打印count值
        System.out.println(m.count);

    }
}

java多线程学习 volatile关键字的使用_第3张图片
运行发现,count的值总是小于100000. 虽然用volatile变量可以保证count++之后的结果对其他线程可见,但是count++这个虽然看起来只有一句语句,但本身并不是原子操作.
count++执行的时候有三个原子操作:从主存中读取count值到线程自己的工作内存里,在工作内存里进行count+1操作,把最新的count值写回主存里,只有写回到主存里之后,最新的count值才会被其他线程获取.
假设某一时刻,线程1完成count++的操作并写回主存里,此时count=100,然后线程2,线程3同时读取count的最新值是100,同时进行+1操作,同时写回主存里,count值被置为101,而正确的值应该为102.多个线程执行的时候,这种情况会经常发生,因为volatile不能保证对count变量的操作是原子性的.

先行发生原则(happens-before)

关于java有序性,补充知识如下:
(摘抄自《深入理解java虚拟机》)
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作("后面"指的是时间的先后顺序)
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作("后面"指的是时间的先后顺序)
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
    对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

你可能感兴趣的:(java学习,多线程)