讲一讲什么是Java内存模型

Java内存模型虽说是一个老生常谈的问题 ,也是大厂面试中绕不过的,甚至初级面试也会问到。但是真正要理解起来,还是相当困难,主要这个东西看不见,摸不着。

这是一个比较开放的题目,面试官主要想考察的是对Java内存模型的了解到了什么程度了,然后根据回答进行进一步的提问

下面,我们就这个问题的回答列一下我们的思路
具体的思路如下

  1. 说一说Java内存模型的缘由
  2. 简略辨析JVM内存结构、Java内存模型、Java对象模型三个概念的异同
  3. 说明Java内存模型概念和核心内容
  4. 针对重排序说一说重排序的例子,重排序的好处
  5. 着重说一说可见性,说一说JVM内存的抽象、hanpens-before原则的举例、vilatile关键字相关等
  6. 举例Java中哪些操作时原子性的

然后,我们将按照上面的思路一一进行详细的描述

为什么会有Java内存模型?

  • JVM实现不同会带来不同的“翻译”效果,不同CPU平台的机器指令又千差万别,无法保证并发安全的效果一致。
  • 需要一套统一的约束去规范JVM的翻译过程,保证并发效果一致性

辨析JVM内存结构、Java内存模型、Java对象模型

JVM内存结构

与Java虚拟机在运行时区域有关

  • 整个运行区域中最大的一块,被所有线程共享

    主要存放使用new关键字创建的实例对象

    所有对象实例以及数组都要在堆上分配

  • 虚拟机栈

    线程私有运行区域

    保存基本的数据类型,以及对象的引用

    为Java方法服务

  • 方法区

    被所有线程共享的区间,用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码

    常量池属于方法区

  • 本地方法栈

    为本地(native)方法服务

  • 程序计数器

    较小的一块内存空间,可以看做是当前线程所执行字节码的行号指示器

    为了确保线程上下文切换后能恢复到正确的执行位置,每一个线程都有一个独立的线程计数器

Java对象模型

与Java对象在虚拟机中的变现形式有关

Java对象自身的存储模型

  • JVM会给这个类创建一个instanceKlass,保存在方法区,用来再JVM层表示该类
  • new一个对象时,JVM会创建一个instanceOopDesc对象,这个对象包含了对象头以及实例数据
  • 一个Java对象可以分为三部分存储在内存中,分别是:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
Java内存模型

与Java的并发编程有关

Java Memory Model (JMM):Java内存模型是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发中可以利用这些规范,更方便的开发多线程程序

如果没有JMM这样一套规范来约束,可能经过不同的JVM的重排序之后,不同的虚拟机上运行的结果不一致

volatile,synchronized,lock等的原理都是JMM

JMM的最重要的三个内容:

  • 重排序
  • 可见性
  • 原子性

重排序

例子演示:
/** * 描述: 演示重排序的现象 “直到达到某个条件才停止”,测试小概率事件 */ 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 { int i = 0; for (; ; ) { i++; x = 0; y = 0; a = 0; b = 0; CountDownLatch latch = new CountDownLatch(3); Thread one = new Thread(new Runnable() { @Override public void run() { try { latch.countDown(); latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } a = 1; x = b; } }); Thread two = new Thread(new Runnable() { @Override public void run() { try { latch.countDown(); latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } b = 1; y = a; } }); two.start(); one.start(); latch.countDown(); one.join(); two.join(); String result = "第" + i + "次(" + x + "," + y + ")"; if (x == 0 && y == 0) { System.out.println(result); break; } else { System.out.println(result); } } } } 

正常情况下,会出现以下结果:

  1. a=1;x=b(0);b=1;y=a(0),最终x=0,y=1
  2. b=1;y=a(0);a=1;x=b(1),最终x=1,y=0
  3. b=1;a=1;x=b(1);y=a(1),最终x=1,y=1

但是在极少数情况下还会发生x = 0,y= 0的情况,这就是发生了重排序了
顺序变成了:y=a;a=1;x=b;b=1;

运行结果演示如图,证明真的有小概率会发生重排序造成(0,0)的现象

什么是重排序

在线程内部的两行代码的实际执行顺序代码在Java文件中的逻辑顺序不一致,代码指令并不是严格按照代码语句顺序执行的,他们的顺序被改变了,这就是重排序。

重排序的好处

例子演示:

计算:

a = 3; b = 2; a = a + 1; 

重排序优化前的instructions

load a set to 3
store 3

load b set to 2
store b

load a set to 4
store a

经过重排序处理后

load a set to 3 set to 4
store a

load b set to 2
store b

上述少了两个指令,优化了性能

重排序的3种情况
  1. 编译器优化:包括JVM,JIT编辑器等
    当编辑器发现调整后可能会提高效率,且没有依赖关系,会进行重排
  2. CPU指令重排:和编译器的类似
  3. 内存的“重排序”:线程A的修改线程B看不到,这就是可见性问题,会体现出和重排序一样的现象,属于表面现象的重排序

可见性

什么是可见性问题

代码示例:

/** * 〈可见性问题分析〉 * * @author Chkl * @create 2020/3/4 * @since 1.0.0 */ public class FieldVisibility { int a = 1; int b = 2; private void change() { a = 3; b = a; } private void print() { System.out.println("b=" + b + ";a=" + a); } public static void main(String[] args) { while (true) { 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(); } } } 

直观的可以推测输出结果存在三种:

b = 3, a = 3
b = 2, a = 3
b = 2, a = 1

除了显而易见的三种外,其实还有可能出现第四种情况:

b = 3,a = 1

原因分析:存在可见性问题,a的值在线程1的本地缓存中进行了更新,更新并没有同步到共享缓存,导致线程2读取到错误的a的值

解决以上的可见性问题
将a和b都加上volatile修饰

volatile int a = 1; volatile int b = 2; 
为什么会有可见性问题


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

  • CPU有多级缓存,导致读的数据过期
    • 高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层
    • 线程间的对象共享变量的可见性问题不是直接由多内核引起的,而是由多缓存引起的
    • 如果所有核心都只用一个缓存,就不存在内存可见性问题
    • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后写入缓存中,然后等待刷入到主存中。所以会导致有些核心读取到的值是一个过期的值
JMM主内存与本地内存的关系
  • 所有的变量都存储在主内存中,同时内个线程也有自己独立的工作内存,工作内存中的变量内容是主内存的拷贝
  • 线程不能直接读取主内存的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
  • 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成
happens-before规则有哪些?
  1. 单线程规则:同一线程中,后执行的代码一定能看到先执行的变量的值
  2. 锁操作:一个线程的锁释放了被另一个线程拿到,拿到锁的线程可以看到之前线程锁内的所有变量
  3. volatile变量:写入的变量使被volatile修饰的,下次读取就一定能读取到
  4. 线程启动:子线程启动可以看到之前所有的改动
  5. 线程join:join执行后的线程可以看到之前的改动
  6. 传递性:如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C)
  7. 中断:如果线程被中断,那么一定能检测到中断
  8. 构造方法:对象构造方法的最后一行指令happendd-before于finalize方法的第一行指令
volatile是什么

volatile是一种同步机制,比synchronized或者Lock相关类更轻量级,因为使用volacile并不会发生上下文切换等开销很大的行为

volatile是无锁的,所以低成本

volatile只能修饰单个属性

什么时候适合用volatile
  • 一个共享变量始终只被各个线程赋值,没有其他操作
  • 作为刷新之前的触发器,实现轻量级重复
volatile的作用
  1. 可见性:读到一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性就会立刻刷入到主内存
  2. 禁止指令重排序优化:解决单例双重锁乱序问题
synchronized获取锁的分类

获取对象锁和获取类锁

  • 获取对象锁
    • 同步代码块,(synchronized(类实例对象))锁的是小括号中的实例对象
    • 同步非静态方法(synchronized method),锁的是当前对象的实例对象
  • 获取类锁的两种方式
    • 同步代码块(synchronized(类.class),锁的是类对象
    • 同步静态方法(synchronized static method),锁的是当前对象的类对象
volatile与synchronized的关系

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

原子性

什么是原子性

一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一半的情况,是不可分的。

Java中的原子操作有哪些
  • 除long和double之外的基本类型的赋值操作(64位值,当成两次32位的进行操作)
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.*包中所有类的原子操作
生成对象的过程是不是原子操作?
  • 新建对象实际上有3个步骤,并不是原子性的
    • 创建一个空对象
    • 调用构造方法
    • 创建好的实例赋值给引用

说到这里,我们基本也就把JMM说清楚了,在此记录下自己的学习过程,希望对同样正在准备面试的同学有用。


本文整理《Java并发核心知识体系精讲》


更多Java面试复习笔记和总结可访问我的面试复习专栏《Java面试复习笔记》,或者访问我另一篇博客《Java面试核心知识点汇总》查看目录和直达链接