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方法
- 程序计数器:主要存储当前线程所执行的字节码的行号数
Java对象模型
- Java对象自身的存储模型
- JVM会给整个类创建一个instanceKlass,保存再方法区,用来再JVM层表示该Java类
- 当我们再Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据
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
27public 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() {
public void run() {
a = 1;
x = b;
}
});
Thread tow = new Thread(new Runnable() {
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
38public class FieldVisibility {
int a = 1;
int b = 2;
public static void main(String[] args) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
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写入的时候是原子的吗?