10.Java内存模型之底层原理
一.概述
1.彩蛋:自顶向下的好处
-
从场景到用法最后讲原理
二.到底什么叫“底层原理”?本章研究的内容是什么?
-
重要性:Java面试的必考知识点。只有学会了这一张的内容,才能说你真正懂了并发
1.从Java代码到CPU指令
-
JVM实现会带来不同的“翻译”,不同的CPU平台的机器指令又千差万别,无法保证并发安全的效果一致
三.JVM内存结构 VS Java内存模型 VS Java对象模型
-
容易混淆:三个截然不同的概念,但是很多人容易弄混
-
JVM内存结构:和Java虚拟机的运行时区域有关
-
Java内存模型:和Java的并发编程有关
-
Java对象模型:和Java对象在虚拟机中的表现形式有关
1.JVM内存结构(5个区域)
-
堆:new创建的实例对象【动态分配】
-
方法区:静态,常量,永久引用(static修饰的)
-
Java栈(虚拟机栈):基本数据类型和对象引用
-
本地方法栈:Native方法
-
程序计数器:执行的位置
2.Java对象模型
-
Java对象模型:Java对象本身的存储模型
-
JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类
-
当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据
3.Java内存模型(JMM)
(1)为什么需要JMM
-
早期的编程语言例如C语言不存在内存模型的概念,都是依赖于处理器,不同处理器结果可能不一样,这样就无法保证并发安全,此时需要一个标准让多线程运行结果可预期。
(2)JMM是一种规范
-
Java Memory Model
-
是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序。
-
如果没有这样的一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样那是很大的问题。
(3)JMM是工具类和关键字的原理
-
volatile、synchronized、Lock等的原理都是JMM
-
如果没有JMM,那就需要我们自己指定什么时候用内存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序。
(4)最重要的3点内容:重排序、可见性、原子性
四.重排序
-
重排序的代码案例、什么是重排序
-
重排序的好处:提高处理速度
-
重排序的3种情况:编译器优化、CPU指令重排、内存的“重排序”
1.重排序案例及分析
(1)案例
import java.util.concurrent.CountDownLatch; /** * 描述:演示重排序的现象”直到达到某个条件才停止“,测试小概率事件 */ 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 count = 0; for (; ; ) { count++; x = y = a = b = 0; CountDownLatch latch = new CountDownLatch(1); Thread one = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } a = 1; x = b; }); Thread two = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } b = 1; y = a; }); two.start(); one.start(); latch.countDown(); one.join(); two.join(); String result = "第" + count + "次(" + x + "," + y + ")"; System.out.println(result); if (x == 0 && y == 0){ break; } } } }
(2)分析
-
我们一般认为这四行代码的执行书序最终决定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
-
但是出现了第4种情况(0,0)的情况:
-
这是由于重排序发生了,4行代码执行顺序的其中一种可能:y = a(0),a = 1,x = b(0),b = 1
-
(3)什么是重排序
-
在线程1内部的两行代码的实际执行顺序和代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,她们的顺序被改变了,这就是重排序,这里被点到的是y=a和b=1这两行语句
2.重排序的好处:提高处理速度
-
对比重排序前后的指令优化
(1) 重排序的3种情况
-
编译器优化:包括JVM,JIT编译器等
-
CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排
-
内存的“重排序”:线程A的修改但线程B却看不到,引出可见性问题
-
volatile可以禁止指令重排序,而synchronized不可以禁止指令重排【单例模式双重锁中在synchronized中的变量赋值,变量使用volatile修饰】
五.可见性
-
什么是可见性问题
-
为什么会有可见性问题
-
JMM的抽象:主内存和本地内存
-
Happens-Before原则
-
volatile关键字
-
能保证可见性的措施
-
升华:对synchronized可见性的正确理解
1.什么是可见性问题
(1)代码实例
/** * 描述:演示可见性带来的问题 */ public class FieldVisibility { int a = 1; int b = 2; private void change() { a = 3; b = a; } private void print() { System.out.println("a=" + a + ",b=" + b); } public static void main(String[] args) { while (true) { FieldVisibility test = new FieldVisibility(); new Thread(() -> { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } test.change(); }).start(); new Thread(() -> { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } test.print(); }).start(); } } }
(2)分析4种情况
-
a = 3,b = 2:这是由于第一个线程只改变完a=3还没有做b=a时,第二个线程开始打印了
-
a = 1,b = 2:这是第二个线程先打印了,第一个线程还没有改变数据导致的
-
a = 3,b = 3:这是第一个线程改变完了数据,第二个线程打印
-
b = 3,a = 1:这是由于第二个线程去查看a但a的数据还没有同步过去导致的
(3)使用volatile关键字可以解决可见性问题:直接将线程中改变的数据放入到主内存
2.为什么会有可见性问题
-
CPU有多级缓存,导致读的数据过期
-
高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间多了Cache层
-
线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。
-
如果所有的核心只用一个缓存,那额也就不存在内存可见性问题
-
每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以导致有些核心读取的值是一个过期的值
-
3.JMM的抽象:主内存和本地内存
(1)什么是主内存和本地内存
-
Java作为高级语言,屏蔽了这些底层细节,用JMM定义了一套读写内存数据的规范,虽然我们不需要关心一级缓存和二级缓存的问题,但是JMM抽象了主内存和本地内存的概念。
-
这里说的本地内存并不是真的是一块给每个线程分配的内存,而是JMM的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象
(2)JMM有以下规定
-
所有的变量都是存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
-
线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
-
主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信就必须借助主内存中转来完成
(3)所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题
4.Happens-Before规则有哪些
-
什么是happens-before:前面发生了后面能看得到
-
规则:
-
单线程原则
-
锁操作(synchronized和Lock)
-
volatile变量
-
线程启动
-
线程join
-
传递性
-
中断
-
构造函数
-
工具类的Happens-Before原则
-
线程安全的容器get一定能看到在此之前的put等存入动作
-
CountDownLatch
-
Semaphore
-
Future
-
线程池
-
CyclicBarrier
-
(1)单线程原则
-
一个线程之内的后面的语句一定能看到前面的语句的操作结果【但不影响重排序】
(2)锁操作(synchronized和Lock)
-
加锁之前的线程B一定能看到解锁之前的线程A所有操作
Lock:线程B能看到线程A执行的所有结果
synchronized:线程B能看到线程A操作的所有结果
(3)volatile变量
-
A,B线程都可以看到改变之后的结果
(4)线程启动
-
子线程启动后能够看到主线程之前语句改变后的结果
(5)线程join
-
线程join方法之后的语句一定能看到线程join时和之前执行的语句
(6)传递性
-
B语句可以看到之前的A语句,C语句可以看到之前的B语句,所以C语句可以看到之前执行的A语句
(7)中断
-
一个线程被其他线程中断(interrupt)时,那么检测中断(isInterrupted)或者抛出InterrupedException一定能看到,例如A线程被中断,B线程检测A线程状态一定能够检测到中断
(8)构造函数【finalize方法现在已经弃用】
-
对象构造方法的最后一行指令happens-before于finalize()方法的第一行指令
(9)工具类的Happens-Before原则
-
线程安全的容器get一定能看到在此之前的put等存入动作
-
CountDownLatch
-
Semaphore
-
Future
-
线程池
-
CyclicBarrier
(10)案例:hanppens-before演示
-
此处只需要在b之前加上volatile修饰,只要读到b=3则a一定为3
5.volatile关键字
-
volatile是什么
-
volatile的适用场合
-
volatile的作用:可见性、禁止重排序
-
volatile和synchronized的关系
-
学以致用:用volatile修正重排序问题
-
volatile小结
(1)volatile是什么
-
volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为
-
如果一个变量被修饰成volatile,那么JVM就知道这个变量可能会被并发修改
-
但是开销小,相应的能力也小,虽说volatile是用来同步的保证线程安全的,但是volatile做不到synchronized那样的原子保护,volatile仅在很有限的场景下才能发挥作用
(2)volatile的适用场合【只适用于原子性操作(原子性操作是要么取值,要么赋值执行性一个操作,该操作不可被拆分)】
-
a++不适用:不适用于又要取值,取值之后又要赋值的操作【这样的操作会被拆分多步保证不了原子性】
/** * volatile不适用场景 */ public class NoVolatile implements Runnable{ volatile int a; AtomicInteger realA = new AtomicInteger(); public static void main(String[] args) throws InterruptedException { Runnable r = new NoVolatile(); Thread thread1 = new Thread(r); Thread thread2 = new Thread(r); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(((NoVolatile) r).a); System.out.println(((NoVolatile) r).realA.get()); } @Override public void run() { for (int i = 0; i < 100000; i++) { a++; realA.incrementAndGet(); } } }
-
适用场合1:boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全【只进行赋值还是保证原子性】
-
适用场合2:作为刷新之前变量的触发器
-
initialized作为触发器,由于之前volatile保证可见性且禁止指令重排序,由于Happens-Before原则保证了initialized=true之前语句都执行了且可以看到
-
(3)volatile的两点作用
-
可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
-
禁止指令重排序优化:解决单例双重锁乱序问题
(4)volatile和synchronized的关系
-
volatile在这方面可以看做是轻量级的synchronized:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用volatile来代替synchronized或者替代原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全
(5)volatile小结
-
volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如只进行原子性操作【例如boolean flag只进行赋值,对于a++则不是原子性操作进行取值加赋值】,或者作为触发器,实现轻量级同步
-
volatile属性的读写操作都是无锁的,它不能代替synchronized,因为没有提供原子性和互斥性。因为无锁,则不需要花费时间在获取锁和释放锁上所以成本低。
-
volatile只能作用于属性,我们用volatile修饰属性这样编译器就不会对这个属性做指令重排序
-
volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主存中读取
-
volatile提供了happens-before保证,一旦写入后续可以读取到最新的值
-
volatile可以使得long和double的赋值是原子性。
6.能保证可见性的措施
-
除了volatile可以让变量保证可见性外,synchronized、Lock、并发集合、Thread.join()和Thread.start()等都可以保证的可见性
-
具体看happens-before原则的规定
7.升华:对synchronized可见性的正确理解
-
synchronized不仅保证了原子性还保证了可见性,没有禁止指令重排【原子性是指令不可以被打断,指令重排是指令顺序可以改变】
-
synchronized保证了happens-before原则,下列代码:只有当a,b,c都赋值成功后,d=6才会赋值。同时synchronized保证可见性所以设置aa=a时可以看到a,b,c,d都已经被修改过了的值
六.原子性
1.什么是原子性
-
一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一般的情况,是不可分割的。
-
i++不是原子性:先取i的值,再将i+1,最后赋值给i
2.Java中原子操作有哪些
-
除long和double之外的基本类型(int,byte,boolean,short,char,float)的赋值操作
-
所有引用reference的赋值操作,不管是32位机器还是64位机器
-
java.concurrent.Atomic.*包中的所有类的原子操作
(1)long和double的原子性
-
问题描述:long和double都是64位的所以分为两次32位操作进行写入,这样可能会产生读取错误,使用volatile来解决
-
结论:在32位的JVM上,long和double的操作不是原子的,但是在64位JVM上是原子的
-
实际开发中:商用Java虚拟机中不会出现自动实现了long和double的原子性
(2)原子操作 + 原子操作 != 原子操作
-
简单地把原子操作组合在一起,并不能保证整体依然具有原子性
-
全同步的HashMap也不完全安全
七.面试常见问题
1.JMM应用实例:单例模式8种写法、单例和并发的关系
2.单例模式的8种写法
-
https://www.cnblogs.com/zhihaospace/p/12452647.html,最优推荐双重检查和枚举
-
注意双重检查中的对象使用volatile,这是由于新建对象不是原子性的会重排序,具有三个步骤(重排序使得顺序可能会变化导致NPE)
-
新建空的对象
-
调用初始化
-
赋值给变量
-
-
双重检查中的对象如果不使用volatile会重排序导致NPE
(1)使用枚举的单例模式创建和使用
-
创建
-
使用
(2)枚举的单例模式好处
-
《Effective Java》推荐使用
-
写法简单
-
线程安全有保障:第一次使用枚举才会被加载进来
-
避免反序列化破坏单例
(3)总结
(4)单例模式面试常见问题
-
饿汉式缺点:需要的的时候就加载,造成浪费
-
懒汉式缺点:写法复杂可能导致问题
-
为什么要用双重锁:线程安全,性能提高
-
为什么双重检查模式要用volatile:新建对象不是原子性的,我们防止编译器重排序导致的NPE问题,使用volatile禁止重排序保证可见性
(5)synchronized没有禁止指令重排在双重检查模式中显现出来,只是保证原子性(不可被打断)和可见性
-
初始化三个步骤:新建空对象,执行初始化函数中的程序,赋值给变量
-
当第一个线程A进入到步骤5进行初始化,发生指令重排:新建空对象,赋值给变量,最后执行初始化函数中的程序。第二个线程B进入步骤2同时第一个线程刚好执行完赋值变量但没有执行初始化函数中的程序,此时线程B然后变量给其他程序用导致NPE问题。
-
所以我们使用volatile放到变量上使得防止指令重排导致NPE问题
3.讲一讲什么是Java内存模型
-
先讲三兄弟,再讲重排序(好处,赋值a和b的例子),可见性(主内存和本地内存,happens-before,volatile,synchronized)和原子性(原子包,long和double以及其他基本类型)
4.volatile和synchronized的异同?
-
volatile是轻量级synchronized
5.什么是原子操作?Java中哪些原子操作?生成对象的过程是不是原子操作?
-
原子操作是一组操作不可切分,Java中有三种原子操作,生成对象不是原子操作
6.什么是内存可见性?
-
CPU结构多级缓存
7.64位的long和double写入的时候是原子的吗?
-
一般来说不是原子的,根据JVM不同而不同,商用的JVM不需要考虑这个问题
8.补充:
-
volatile作用:可见性和禁止指令重排
-
synchronized作用:可见性,原子性和可重入性