Java并发核心知识体系精讲——Java内存模型(二)

Java内存模型——底层原理

自顶向下(学习方法)

  • 先将试用场景,再讲用法,最后讲原理
  • 直观的了解、具体而感性的认识、有助于加深理解、最后分析源码
  • 兴趣:连这个原理有什么作用都不知道的话,我们肯定是没有兴趣的,而没有兴趣就意味着我们学不好

到底什么叫“底层原理”?本章研究的内容是什么?

从Java代码到CPU指令

  • 最开始,我们编写的Java代码,是*.java文件
  • 在编译(javac命令)后,从刚刚的*.java文件会编译出一个新的java字节码文件(*.class)
  • JVM会执行刚才生成的字节码文件(*.class),并把字节码文件转化为机器指令
  • 机器指令可以直接在CPU上执行,也就是最终的程序执行

JVM实现会带来不同的”翻译“,不同的CPU平台的机器指令又千差万别,无法保证并发实现的效果一致(如果只是做一份编译,直接进行运行,不同的机器,对相同的编译内容理解也是不一样的,最终会导致运行结果不一致)
中断开始向下转移:转化过程的规范、原则(进行约束,以保证相同的代码最终能拿到相同的结果)

三兄弟:JVM内存结构 VS Java内存模型 VS Java对象模式

  • JVM内存结构:和Java虚拟机的运行时区域有关
  • Java内存模型:和Java的并发编程有关
  • Java对象模型:和Java对象在虚拟机中的表现形式有关

JVM内存结构

  • 堆:整个运行区域最大的一块,主要存储实例对象,包括数组,如果这些对象不再有引用会被垃圾回收
  • 虚拟机栈:保存基本数据类型,以及对对象的引用
  • 方法区:已经加载的静态变量类信息以及常量信息包括永久引用
  • 本地方法栈:主要是Native方法
  • 程序计数器:主要存储当前线程所执行的字节码的行号数

JVM内存结构图

Java对象模型

  • Java对象自身的存储模型
  • JVM会给整个类创建一个instanceKlass,保存再方法区,用来再JVM层表示该Java类
  • 当我们再Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据

Java对象模型图示

JMM是什么?

为什么需要JMM

  • C语言不存在内存模型的概念
  • 依赖处理器,不同处理器结果不一样
  • 无法保证并发安全
  • 需要一个标准,让多线程运行的结果可预期

JMM是规范

  • Java Memory Model
  • 是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序
  • 如果没有这样的一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样,那是很大的问题
  • volatile、synchronized、Lock等的原理都是JMM
  • 如果没有JMM,那就需要我们自己指定什么时候使用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序

重排序

重排序的代码案例、什么是重排序

  • 代码演示如下
  • 4行赋值代码的执行顺序决定了最终x和y的结果,一共有3种情况:
    • a=1;x=b(0);b=1;y=a(1),最终结果是x=0,y=1
    • b=1;y=a(0);a=1;x=b(1),最终结果是x=1,y=0
    • b=1;a=1;x=b(1);y=a(1),最终结果是x=1,y=1
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      public class OutOfOrderExecution {

      private static int x = 0, y = 0;
      private static int a = 0, b = 0;

      public static void main(String[] args) throws InterruptedException {
      Thread one = new Thread(new Runnable() {
      @Override
      public void run() {
      a = 1;
      x = b;
      }
      });
      Thread tow = new Thread(new Runnable() {
      @Override
      public void run() {
      b = 1;
      y = a;
      }
      });
      one.start();
      tow.start();
      one.join();
      tow.join();
      System.out.println("x = " + x + ", y = " + y);
      }
      }
  • 根据上面的代码可以看出,一般意义来说,是不可能出现x=0和y=0的情况的,但是这种结果却又出现了
  • 只有执行顺序为 y=a;a=1;x=b;b=1; 的时候才会出现x=0,y=0

指令重排序现象结果图

  • 什么是重排序:在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序,这里被颠倒的是y=a和b=1这两行语句

重排序的好处:提高处理速度

如下图所示,左边三行代码,对应三段指令,当指令重拍之后,就会对应到右边的顺序,对a来说少了一次读和写操作

指令重排序的好处示意图

重排序的3种情况:编译器优化、CPU指令重拍、内存的”重排序“

  • 编译器优化:包括JVM,JIT编译器等
  • CPU指令重排:就算编译器不发生重拍,CPU也可能对指令进行重排
  • 内存的“重排序”:线程A的修改线程B看不到(表面看起来像重排,实质是并发问题),引出可见性的问题

可见性

案例:演示什么是可见性

  • 具体演示代码如下所示,开启两个线程,同时操作变量a和b
  • 一共会出现如下四种结果:
  • a=3;b=2;(线程1刚修改了a,还未修改b,线程2进行打印)
  • a=1;b=2;(线程2先于线程1运行)
  • a=3;b=3;(线程1运行完毕,线程2打印运行)
  • a=1;b=3;(从代码上看,这种情况几乎是不可能的,但是却真实出现了,线程1的操作对线程2来说,并不是完全可见的,线程2无法或者只能看到部分线程1的操作)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    public class FieldVisibility {

    int a = 1;
    int b = 2;

    public static void main(String[] args) {
    FieldVisibility test = new FieldVisibility();
    new Thread(new Runnable() {
    @Override
    public void run() {
    try {
    Thread.sleep(1);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    test.change();
    }
    }).start();
    new Thread(new Runnable() {
    @Override
    public void run() {
    try {
    Thread.sleep(1);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    test.print();
    }
    }).start();
    }
    private void print() {
    System.out.println("b = " + b + " a = " + a);
    }
    private void change() {
    a = 3;
    b = a;
    }
    }

为什么会有可见性问题

  • CPU有多级缓存,导致读的数据过期
    • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以再CPU和主内存之间多了cache层
    • 线程间的对于共享变量的可见性问题不是直接由多个引起的,而是由多缓存引起的
    • 如果多有核心都只用一个缓存,那么也就不存在内存可见性问题了
    • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中,多以会导致有些核心读取的值是一个过期的值

JMM的抽象:主内存和本地内存

什么是主内存和本地内存

  • Java作为高级语言,屏蔽了这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念。
  • 这里说的本地内存并不是真的一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象

主内存和本地内存的关系

  • JMM有一些规定
    • 所有的变量都是存储再主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
    • 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后同步到主内存中
    • 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间=需要通信,必须借助主内存中转来完成
  • 所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题

Happens-Before原则

  • 单线程原则
    • 排在后面的语句一定能看到前面的语句的操作
    • 不影响重排序,如果重排序了,那么排在后面的也是能看到前面的语句的操作的
  • 锁操作(synchronized和Lock)
    • 如果一个线程对某把锁进行了解锁,另一个线程对这把锁进行了加锁,那么后面的线程一定能看到前面线程解锁前的所有操作
  • volatile变量
    • 如果变量被volatile变量修饰,只要写入了就一定能被其他线程获取到
  • 线程启动
    • 子线程在启动的时候,能看到之前主线程的所有操作
  • 线程join
    • 一旦针对某个线程进行了join,那么后面的语句一定能看到这个线程join之前的所有操作
  • 传递
    • 一份代码中有很多行,原则上第一行执行完第二行一定能看到第一行的操作,第三行也一定能看到第二行的操作,所以第三行也可以看到第一行的操作
  • 中断
    • 一个线程被其他线程中断(interrupt)时,那么检测中断(isInterrupted)或者抛出InterruptedException一定能看到
  • 构造方法
    • finalize()方法一定能看到构造方法中的最后一行语句(不做重点)
  • 工具类的happens-Before原则
    • 线程安全的容器get一定能看到在此之前的put等存入动作
    • CountDownLath
    • Semaphore
    • Future
    • 线程池
    • CyclicBarrier

近朱者赤:给b加了volatile,不仅b被影响,也可以实现轻量级同步
b之前的写入操作(对应代码b = a)对读取b后面的代码(print b)都可见,所以在writerThread里面对a的赋值,一定会对readerThread里面的读取可见,所以这里面的a即使不加volatile,只要b读到3,就可以由happens-before原则保证读取到的都是3,而不可能读取到1
(因为a对b进行了赋值,所以该语句的上一句,也就是对a的赋值操作,也必须完成之后,并且可见a之后,才能对b进行赋值操作)

volatile关键字

volatile是什么?

  • volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为
  • 如果一个变量被修饰成volatile,那么JVM就知道了这个变量可能会被并发修改
  • 但是开销小,相应的能力也小,虽然说volatile是用来同步的保证线程安全的,但是volatile做不到synchronized那样的原子保护,volatile仅在很有限的场景下才能发挥作用

volatile的适用场景

  • 不适用:a++
  • 适用场景1:boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全
  • 适用场景2:作为刷新之前变量的触发器

volatile的两点作用

  • 可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
  • 禁止指令重排序优化:解决单例双重锁乱序问题

volatile和synchronized的关系

  • volatile在这方面可以看做是轻量版的synchronized:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值操作自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全

volatile小结

  • volatile修饰符适用于一下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag,或者作为触发器,实现轻量级同步
  • volatile属性的读写操作都是无锁的,它不能替代synchronized因为它没有提供原子性和互斥性,因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的
  • volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序
  • volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主内存中读取
  • volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作
  • volatile可以使得long和double的赋值是原子的,后面马上会讲long和double的原子性

能保证可见性的措施

  • 除了volatile可以保证可见性之外,synchronized、Lock、并发集合、Thread.join()和Thread.start()等都可以保证可见性
  • 具体看happens-before原则的规定

升华:对synchronized可见性的正确理解

  • synchronized不仅保证了原子性,还保证了可见性
  • synchronized不仅让被保护的代码安全,还近朱者赤(synchronized加锁时可以看到所有加锁前的操作,所以另一个线程在加锁的时候,也可以看到这个线程加锁前的操作)

原子性

什么是原子性

  • 一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,整个操作是不可分割的(ATM即取钱)
  • i++不是原子性的(首先会先读取i的值,然后执行加1操作,然后写回去,这三步操作,两个线程完全是可能交替执行的)
  • 用synchronized实现原子性

Java中的原子操作有那些?

  • 除了long和double之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
  • 所有引用referebce的赋值操作,不管是32位机器还是64位的机器
  • java.concurrent.Atomic.* 包中所有类都是原子类

long和double的原子性

  • 问题描述:官方文档、对于64位值的写入,可以分为两个32位的操作进行写入,读取错误、使用volatile解决
  • 结论:在32位的JVM上,long和double的操作不是原子性的,但是在64位的JVM上是原子性的
  • 实际开发中:商用Java虚拟机中不会出现这种问题

原子操作 + 原子操作 != 原子操作

  • 简单的把原子操作组合在一起,并不能保证整体依然具有原子性
  • 比如去ATM取两次钱是独立的原子操作,但是期间可能把银行卡借给其他人,也就是被其他线程打断或修改。
  • 全同步的HashMap也不完全安全(比如先从中取出一个值,进行判断然后修改,然后再put,这一系列操作并不是原子性的,所以可能会出现问题,类似i++)

常见面试问题

  • JMM应用实例:单例模式8中写法、单例和并发编程的关系

    • 单例模式的作用(为什么要使用单例模式)

      节省内存和计算(假如某一资源在获取后就不会经常变动,而获取的过程又很耗费cpu或内存,可以使用单例)
      保证结果正确(有时候可能会做一些统计,为了速度,可以使用多线程进行统计,但是最终还是要有一个全局的单例计数器)
      方便管理(比如一些工具类)

    • 单例模式适用场景

      无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是他帮我们记录日志信息,除此之外,并不需要在它的实例对象上存储任何状态,这时候我们就只需要一个实例对象即可
      全局信息类:比如我们在一个类上记录网站的访问次数,我们不希望有的访问记录在对象A上,有的却在对象B上,这个时候我们就让这个类成为单例

  • 讲一讲Java内存模型

  • volatile和synchronized的异同

  • 什么是原子操作?Java中有那些原子操作?生产对象的过程是不是原子操作?

  • 什么是内存可见性

  • 64位的double和long写入的时候是原子的吗?