讲一讲什么是Java内存模型
Java内存模型虽说是一个老生常谈的问题 ,也是大厂面试中绕不过的,甚至初级面试也会问到。但是真正要理解起来,还是相当困难,主要这个东西看不见,摸不着。
这是一个比较开放的题目,面试官主要想考察的是对Java内存模型的了解到了什么程度了,然后根据回答进行进一步的提问
下面,我们就这个问题的回答列一下我们的思路
具体的思路如下:
- 说一说Java内存模型的缘由
- 简略辨析JVM内存结构、Java内存模型、Java对象模型三个概念的异同
- 说明Java内存模型概念和核心内容
- 针对重排序说一说重排序的例子,重排序的好处
- 着重说一说可见性,说一说JVM内存的抽象、hanpens-before原则的举例、vilatile关键字相关等
- 举例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); } } } }
正常情况下,会出现以下结果:
- a=1;x=b(0);b=1;y=a(0),最终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
但是在极少数情况下还会发生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种情况
- 编译器优化:包括JVM,JIT编辑器等
当编辑器发现调整后可能会提高效率,且没有依赖关系,会进行重排 - CPU指令重排:和编译器的类似
- 内存的“重排序”:线程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规则有哪些?
- 单线程规则:同一线程中,后执行的代码一定能看到先执行的变量的值
- 锁操作:一个线程的锁释放了被另一个线程拿到,拿到锁的线程可以看到之前线程锁内的所有变量
- volatile变量:写入的变量使被volatile修饰的,下次读取就一定能读取到
- 线程启动:子线程启动可以看到之前所有的改动
- 线程join:join执行后的线程可以看到之前的改动
- 传递性:如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C)
- 中断:如果线程被中断,那么一定能检测到中断
- 构造方法:对象构造方法的最后一行指令happendd-before于finalize方法的第一行指令
volatile是什么
volatile是一种同步机制,比synchronized或者Lock相关类更轻量级,因为使用volacile并不会发生上下文切换等开销很大的行为
volatile是无锁的,所以低成本
volatile只能修饰单个属性
什么时候适合用volatile
- 一个共享变量始终只被各个线程赋值,没有其他操作
- 作为刷新之前的触发器,实现轻量级重复
volatile的作用
- 可见性:读到一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性就会立刻刷入到主内存
- 禁止指令重排序优化:解决单例双重锁乱序问题
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面试核心知识点汇总》查看目录和直达链接