有关JMM的面试题?
- 你知道什么是Java内存模型JMM吗?
- JMM与volatile它们两个之间的关系?(下一章详细讲解)
- JMM有哪些特性or它的三大特性是什么?
- 为什么要有JMM,它为什么出现?作用和功能是什么?
- happens-before先行发生原则你有了解过吗?
计算机硬件存储体系
计算机存储结构,从本地磁盘到主存到CPU缓存,也就是从硬盘到内存,到CPU。
有多级缓存的原因是:cpu和物理主内存的速度不一致
CPU的运行并不是直接操作内存而是先把内存里边的数据读到缓存,而内存的读和写操作的时候就会造成不一致的问题
Java虚拟机规范中试图定义一种Java内存模型(java Memory Model,简称JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。推导出我们需要知道JMM
Java内存模型Java Memory Model
概念
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
原则
关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
能干什么
1 通过JMM来实现线程和主内存之间的抽象关系。
2 屏蔽各个硬件平台和操作系统的内存访问差异以实现让Java程序在各种平台下都能达到一致的内存访问效果。
JMM三大特性
原子性
指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰
可见性
是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。
Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现**“脏读”**,所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
脏读:举例演示
- 主内存中有变量 x,初始值为 0
- 线程 A 要将 x 加 1,先将 x=0 拷贝到自己的私有内存中,然后更新 x 的值
- 线程 A 将更新后的 x 值回刷到主内存的时间是不固定的
- 刚好在线程 A 没有回刷 x 到主内存时,线程 B 同样从主内存中读取 x,此时为 0,和线程 A 一样的操作,最后期盼的 x=2 就会变成 x=1
有序性
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提供性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
优点:
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的机器对指令进行重排序,使得机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。但是,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生“脏读”),简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。
从源码到最终执行示意图:
缺点:
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
拿下面例子来说, 语句三和语句四 需要依赖于 语句一和语句二的定义,无法进行指令重排序
JMM规范下,多线程对变量的读写过程
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
JMM定义了线程和主内存之间的抽象关系
- 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
- 每个线程都有一个私有的本地工作内存,本地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
总结
- 我们定义的所有共享变量都存储在物理主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
- 线程对共享变量所有的操作都必须先在线程自己的工作内存中进行,后写回主内存,不能直接从主内存中读写(不能越级)
- 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行(同级不能相互访问)
JMM规范下,多线程先行发生原则之happens-before
happens-before 先行发生原则的概念
在JMM中,如果一个操作执行的结果需要对另一个操作可见性或者代码重新排序,那么这两个操作之间必须存在happens-before(先行发生)原则。逻辑上的先后关系。
举例说明:
- x=5;线程A执行
- y=x;线程B执行
问题: y 一定等于 5 吗?、
答案是不一定的,原因就是线程A、B 是否遵循了 happens-before
(先行发生)原则
-
如果线程A的操作(x= 5)happens-before(先行发生)线程B的操作(y = x),那么可以确定线程B执行后y = 5 一定成立;
-
如果他们不遵循 happens-before(先行发生) 原则,在 X 赋值的过程可能存在一些异常操作,导致 x的值为0,那么最终 y 的值也为 0.
happens-before(先行发生) 原则 包含了 可见性 和 有序性 的约束
Happens-Before 原则 的重要性
先行发生原则(happens-before
)被定义在了JMM之中
如果Java内存模型中所有的有序性都仅靠 volatile
和 synchronized
来完成,那么有很多操作都将会变得非常啰嗦,但是我们在日常的编码中,并没有 时时、处处、次次 都使用了 volatile
和 synchronized
,这是因为Java语言中JMM原则下有一个**“先行发生”(Happens-Before)的原则限制和规矩**
它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。
前面说了那么多,Happens-Before 原则多么多么的重要,多么多么的有用,那么它到底有什么原则呢? 下面就说说 happens-before 一共有哪些原则?
happens-before总原则
- 如果一个操作
happens-before
另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 - 两个操作之间存在happens-before关系,并不一定要按照
happens-before
原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
happens-before 具体的8条原则
次序规则
一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作。
锁定规则
锁的获取的先后顺序,一个unLock
操作先行发生于后面(这里的后面是指时间上的先后)对同一个锁的lock
操作(一个线程想要lock,肯定要等前面的锁unLock
释放这个资源)
代码举例:
t2 线程执行之前 必须等待 t1 先释放锁
public class Test01 {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
new Thread(() -> {
lock.lock();
try {
System.out.println("t1 获取锁...");
} finally {
try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}
lock.unlock();
System.out.println("t1 释放锁...");
}
}, "t1").start();
new Thread(() -> {
lock.lock();
try {
System.out.println("t2 获取锁...");
} finally {
try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
lock.unlock();
System.out.println("t2 释放锁...");
}
}, "t2").start();
}
}
volatile变量规则
对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样是指时间上的先后。
传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
线程启动规则(Thread Start Rule)
Thread对象的start()
方法先行发生于此线程的每一个动作
这个应该很好理解,线程先启动
线程中断规则(Thread Interruption Rule)
对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
可以通过Thread.interrupted() 检测到是否发生中断。
也就是说你要先调用了
interrupt()
方法设置过中断标志位,我才能检测到中断发送。
线程终止规则(Thread Termination Rule)
线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过isAlive()
等手段检测线程是否已经终止执行。
对象终结规则(Finalizer Rule)
个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
finalize的通常目的是在对象被不可撤销的丢弃之前执行清理操作。
案例
public class TestDemo
{
private int value = 0;
public int getValue(){
return value;
}
public int setValue(){
return ++value;
}
}
问题:假设存在线程A和B,线程A先(时间上的先后)调用了setValue(),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?是0还是1?
答案是不确定的。
我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 可以忽略,因为他们和这段代码毫无关系):
1 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序次序规则;
2 两个方法都没有使用锁,所以不满足锁定规则;
3 变量不是用volatile修饰的,所以volatile变量规则不满足;
4 传递规则肯定不满足;
因此我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B执行,但是无法确认线程B获得的结果是什么,有可能线程A执行到一半,还没有执行++操作,线程B就执行了,所以这段代码不是线程安全的。那么怎么修复这段代码呢?
第一种方案:俩个方法都加上 synchronized 关键字,但是这样加的太重了,效率太低
//1
public class TestDemo
{
private int value = 0;
public synchronized int getValue(){
return value;
}
public synchronized int setValue(){
return ++value;
}
}
第二种方案:使用 volatile 关键字
//2
public class TestDemo
{
private int value = 0;
public synchronized int getValue(){
return value;
}
public synchronized int setValue(){
return ++value;
}
}
//把value定义为volatile变量,由于setter方法对value的修改不依赖value的值,满足volatile关键字使用场景
//理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性结合使用锁和volatile变量来减少同步的开销。
各位彭于晏,如有收获点个赞不过分吧…✌✌✌
扫码关注公众号 【我不是秃神】 回复 JUC 可下载 MarkDown 笔记