高并发编程从入门到精通(六)

1,613 阅读14分钟

面试中最常被虐的地方一定有并发编程这块知识点,无论你是刚刚入门的大四萌新还是2-3年经验的CRUD怪,也就是说这类问题你最起码会被问3年,何不花时间死磕到底。消除恐惧最好的办法就是面对他,奥利给!(这一系列是本人学习过程中的笔记和总结,并提供调试代码供大家玩耍

上章回顾

1.Synchronized的优缺点?

2.Synchronized使用需要注意哪几点?

3.什么是死锁?什么情况下会导致死锁?

请自行回顾以上问题,如果还有疑问的自行回顾上一章哦~

本章提要

本章学习完成,我们会对volatile关键字有深入的理解,同时对线程安全也会有更加深入的理解,通过对JMM对线程安全的处理来说明线程安全的三个特性,同时通过对比volatilesynchronized来阐述两者的差异和适用场景。

本章代码下载

一、知识储备

在讲volatile关键字之前我们需要有一些前置知识需要重温一下,经过重温这部分知识,相信同学们能更好的理解volatile关键字。

并发编程的三个特性

并发编程的三个特性分别是原子性,可见性和有序性,本小节通过三个特性的描述和JMM如何保障三个特性来回顾这块知识点。

1.原子性

简单来说就是一次或者多次操作,要么全部一次性完成,要么全部一次性不完成。具体是什么意思呢?我们通过几个例子来看下。

1. x = 10

当线程在操作赋值操作的时候,首先会把当前线程工作内存中的副本x赋值为10,然后会同步到主存中,把主存中的x同时也赋值为10。同学们可能会说,那如果赋值完成之后,另一个线程又操作x把主存中的x改变为11了,这种情况是不是就不是原子性了呢?

其实这是一个理解上的误区,这其实是已经针对主存x的两次赋值操作了,我们这边关注的纬度是单次赋值操作,所以说这两个单次赋值操作全部都完成了。所以说赋值操作是原子性的。

2. x = y

这个操作语句看似也是一个赋值操作,但是其实不然。这个所谓的赋值需要拆分出来看。

1.从主存或者工作内存中读取y的值

2.讲读取到的y的值赋值给x,并把x的值写入主存中

这两步并不是同生共死的,并不是那种要么全部都一次性执行,要么全部都一次性不执行的情况,第一步是读取y值,第二步是给x赋值操作,这两步完全可以独立开来,所以我们说这个操作不是原子性操作。

3. x++

这个相对来说比x = y要好理解一些,这里需要拆分为3步来看

  1. 从主存或者工作内存中读取x的值
  2. 对x进行自增 x+1
  3. 将x自增之后的值再次赋值给x,并修改主存中x的值

显然这三步也不是同生共死的,所以也不是原子性操作。


可以看到除了简单的赋值操作意外,另外两种全部都不是原子性操作,同学们如果还是有疑惑的可以再回去过体会一下。这里我们稍微总结一下哪些操作是原子性操作。

  • 简单的读取和赋值操作是原子性的,这是JMM给我们保证好了,变量间的赋值不是原子性的。
  • 多个原子性操作合并在一起就不一定是原子性操作了,类比x=y
  • 通过上一章的学习,我们知道synchronized关键字可以保障代码的原子性。

2.可见性

可见性指的是,多线程并发编程中,一旦有线程修改了主存中一个共享变量的值,其他线程能立即感知到,并重新从主存中获取新的共享变量的值,而不是沿用线程本地工作内存中的副本的值

上一章的学习中我们知道,synchronized可以保障并发编程中的可见性原则,他的原理就是使用监视锁的排他性来保证至多只有一个线程能够获取到监视锁,并且只有当前监视锁释放之后,其他线程才能再次进入synchronized包裹的代码。但是其劣势就是需要损耗线程的执行效率。

3.有序性

有序性指的是代码的执行顺序。由于JVM为了提高程序的运行效率,提供了一种指令重排序的特性。也就是说代码可能不会按照你的编码顺序执行,但是一定严格遵守指令之间的数据依赖关系,但是业务之间的依赖关系并不会保证,在多线程开发过程中这就会导致一些有序性相关的问题。

我们通过一个简单的例子看下这个问题。

public class InstructionReorder {


  public static class PrintOut {
    public void getStr() {
      System.out.println(Thread.currentThread().getName() + "返回了呗");
    }
  }

  private static boolean initStatus = false;
  static PrintOut printOut = null;

  public static void main(String[] args) {
    new Thread(() -> {
      //判断当前singleExample是否已经是实例化
      if (!initStatus) {
        //模拟指令重排序
        initStatus = true;
        printOut = new PrintOut();
        printOut.getStr();
//        //未进行指令重排序
//        initStatus = true;
      } else {
        printOut.getStr();
      }
    }, "一号线程").start();

    new Thread(() -> {
      //判断当前singleExample是否已经是实例化
      if (!initStatus) {
        printOut = new PrintOut();
        printOut.getStr();
        initStatus = true;
      } else {
        printOut.getStr();
      }
    }, "二号线程").start();

  }
}

这里我们启动两个线程,使用private static boolean initStatus = false;初始化状态判断共享变量static PrintOut printOut = null;是否已经被初始化,我们在一号线程模拟指令重排序的场景

 //判断当前singleExample是否已经是实例化
      if (!initStatus) {
        //模拟指令重排序
        initStatus = true;
        printOut = new PrintOut();
        printOut.getStr();
//        //未进行指令重排序
//        initStatus = true;
      } else {
        printOut.getStr();
      }

输出:

一号线程返回了呗
Exception in thread "二号线程" java.lang.NullPointerException
	at src.com.lyf.page5.InstructionReorder.lambda$main$1(InstructionReorder.java:44)
	at java.lang.Thread.run(Thread.java:748)

可以看到,在一号线程启动之后,initStatus状态值被修改为true,二号线程启动之后判断当前对象已经是初始化完成状态了,直接调用printOut.getStr();,抛出了空指针异常。

感兴趣的同学可以注释掉模拟指令重排序的代码,开启正常执行顺序的代码,多次调用查看结果。

至此JVM指令重排序导致的有序性问题就描述完啦~,同学们应该大致明白有序性是咋回事了吧。

好奇的同学一定会问,如果这样子那么重排序岂不是很坑,为什么要带这个功能呢?

正常情况下指令重排序是严格遵循数据之间的依赖关系的,并且遵守happen-before原则。我们继续来看看happen-before原则到底是啥玩意儿?

happen-before原则

  • 程序次序原则:在一个线程内,代码按照编写时的次序执行,虽然JVM可能会对程序代码进行指令重排序,但是他会保证一个线程内执行结果和顺序执行结果一致。这里的一个重点就是一个线程内,所以对并发编程他并不会保证程序对执行次序一定是我们想要对执行次序,也就是我们上面举例的有序性问题。
  • 锁定原则:无论在多线程还是单线程环境下,同一个锁一旦处于锁定状态,如果其他线程需要再次锁定这个锁那么必定需要调用锁的unlock状态。举个例子线程A锁定了监视锁MUTEX,此时线程B需要获取MUTEX,那么必然需要调用监视锁MUTEX的unlock操作。
  • volatile原则:对一个volatile变量对写操作happen-before对这个变量对读操作。简单来说就是,两个线程对共享变量x来进行读写操作,A对x进行读操作,B对x进行写操作,那么B的写操作一定会发生在A的读操作之前。
  • 传递原则:A,B,C三个线程操作,A先于B,B先于C,那么A先于C。这个很好理解。
  • 线程启动原则:线程执行逻辑的任何操作都需要在线程真正启动起来之后才能执行起来。也就是调用start并获取到CPU执行权的时候,线程才会真正运行起来,不然只能算作是一个线程对象。
  • 线程中断原则:对线程执行interrupt()方法一定要优先于捕获到中断信号,这句话的意思是指如果线程收到了中断信号,那么再次之前势必要有interrupt()。
  • 线程终结原则:线程中所有的操作都要优先发生于线程的终止检测,通俗地讲,线程的任务执行、逻辑单元执行肯定发生于线程死亡之前。
  • 对象终结原则:一个对象的初始化的完成一定发生在一个对象被回收之前。

二、Volatile解析

volatile关键的两个作用

  • 保证了共享变量的可见性。
  • 禁止指令重排序,保障顺序性。

volatile保证可见性

我们这里使用一个例子来说明这一特性

传代码:

public class VolatileTest {

   final static int MAX = 5;
//  static volatile int init_value = 0;
   static int init_value = 0;

  public static void main(String[] args) {
    new Thread(() ->
    {
      int localValue = init_value;
      while (localValue < MAX) {
        if (init_value != localValue) {
          System.out.println("value read to "+init_value);
          localValue = init_value;
        }
      }
    }, "Reader").start();

    //保证 读线程先启动
    try {
      TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }

    new Thread(() ->
    {
      int localValue = init_value;
      while (localValue < MAX) {
        System.out.println("value update to "+(++localValue));
        init_value = localValue;
        try {
          TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }, "Updater").start();
  }
}

首先我们不加volatile关键字修饰

输出:

value update to 1
value update to 2
value update to 3
value update to 4
value update to 5

加上之后,输出:

value update to 1
value read to 1
value update to 2
value read to 2
value update to 3
value read to 3
value update to 4
value read to 4
value update to 5
value read to 5

相比之下我们可以看到,当读线程率先启动起来当时候,如果不加volatile关键字,读线程每次读取的都是自己工作空间里面的init_value和局部变量init_value,这两个在读线程无法感知init_value变化的情况下始终都是相等的,这点好理解,因为每次读取的都是工作空间的值。但是主存中的值早就已经改变了,所以在加上volatile关键字的时候,读线程能感知到init_value在主存中的刷新。具体可以氛围以下几步

1.读线程读取主存的init_value=0,并存在自己的本地工作空间内。

2.更新线程读取主存的init_value=0,并存在自己的本地工作空间内,同时对该值进行累加操作,结果得到init_value=1,刷新自己的本地工作空间和主存中的值,并通知所有持有该共享变量的线程他们的共享变量的值已经过期了。

3.读线程在while循环的时候发现本地工作空间中的init_value已经过期不可用,所以需要到主存中重新读取,得到init_value=1。此时init_value!=localValue成立,所以才能输出。

volatile保证顺序性

volatile禁止了JVM和处理器对volatile关键字修饰的指令进行重排序。简单理解就是一旦一句语句被volatile修饰了,那么这道语句就是一道屏障,在他前面的永远在他前面,在他后面的永远在他后面。但是前面和后面语句之间仍然存在指令重排序。举个例子

  int a = 0;
  int b = 0;
  volatile int C = 2;
  int d = 1;
  int e = 2;

在执行C = 2的赋值操作之前,我们的a = 0b = 0一定是执行完成了,但是至于a先赋值完成还是b先赋值完成这里就可能存在指令重排序。同理d,e的赋值操作一定都是在C = 2的赋值操作之后,并且这两者之间存在指令重排序的可能。

volatile不保证原子性

首先我们回顾一下,原子性的特点就是要么全部一次性完成,要么全部一次性不完成。一般情况下,CPU对线程的轮询调度并不能得到我们的信任,也就是存在很大的不确定性。仔细回顾以下voltile关键字是如何生效的过程,想象以下使用volatile关键字的时候可能会出现以下这种情况:

1.创建A,B两个线程同时,对sum进行累加操作,并且sum被volatile关键字修饰,保证不同线程之间共享变量的可见性。

2.A读取到sum = 1,此时CPU执行权暂时放弃A,给到线程B,B读取到sum=1,CPU执行权给到A,直到A完成一次累加并把自己线程工作空间到值刷新,但是还未刷新主存之前,CPU执行权给到B。

3.B做了和A一摸一样到操作,同时B把值sum = 2写入主存,CPU执行权给到A

4.A也把sum = 2写入主存,此时我们发现明明是2次累加,但是结果只算了一次。

这里的关键点不知道同学们get到没有。在我看来这里可以分为两部分来看

1.不管是什么操作,工作内存和主存的写入这两步分开开都是线程安全的,但是两步放在一起就不是线程安全了,也就是不是原子性操作了。因为不同线程可以交替执行这两个步骤。

2.volatile修饰的是变量,所以变量具体进行哪些操作他并不能保证是线程安全的,类似 volatile int C = 0进行C++操作的时候就不能保证原子性。

总结来说就是volatile的关注点是在单个变量的读写的可见性和顺序性,而不保证这个变量进行的其他所有行为是原子性的,所以不能保证是线程安全的。

volatile和synchronized比较

从多线程编程的三个特性来看

1.可见性

volatile:通过使用及其指令lock的方式迫使其他线程工作内存的数据失效,从而需要从主存去获取

sychronized:排他性,同一时间只有一个线程可以获取到monitor lock

2.原子性

volatile:不保证

synchronized:排他性来保证原子性

3.有序性

volatile:通过禁止JVM指令重排序来保证有序性

synchronized:排他性来保证有序性

从性能上来比较

虽然synchronized看起来很美好,但是缺点是通过串行化的编程方式来实现的线程安全,牺牲了一定的性能,并且可能会导致线程阻塞。而volatile虽然并不能保证原子性,在线程安全的角度上来讲有很大的局限性,但是他的优势是不会陷入阻塞状态,程序运行性能上来讲会更好一些。

volatile关键字使用场景

同学们可能会奇怪,既然你连原子性都保证不了,那我要你何用?

虽然有一定的局限性,但是如果使用得当的话volatile关键字还是有不错的效果的,一般使用volatile的时候需要满足以下两个条件:

1.对变量的写操作一依赖于变量当前值。

这一点好理解,开关控制,状态控制这种场景就比较适合使用volatile

2.volatile修饰的变量的操作需要是线程安全的操作。

这一点可以从两个方面来理解。首先是volatile修饰的变量进行的操作都是读写操作,仅读写变量JVM会为我们保障线程的安全性,同时可以提供可见性和有序性。第二种就是volatile修饰的变量的各种非原子性操作都使用锁来保障其线程安全性,这里可以对比double-check方式。

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
    //volatile 作用是在这里
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

感兴趣的同学可以看下上面代码,这边提供的是可见性原则,同时volatile还能通过禁止指令重排序来保证多个实例变量的初始化顺序,我们之后会针对单例模式的线程安全进行详细的讲解,这里我们就暂时不展开。

本章将的知识点比较多需要同学们自行消化一下,下期我要问的哦。好啦~我们这一期的学习到这里就结束啦!感谢大家的点赞👍支持~~