Java面试-内存模型之顺序一致性
Posted 李子捌
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java面试-内存模型之顺序一致性相关的知识,希望对你有一定的参考价值。
简介:
顺序一致性内存模型是一个理论参考模型,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
1、数据竞争和顺序一致性
当程序未正确同步时,就可能存在数据竞争。
1.1 Java内存模型规范对数据竞争的定义如下
- 在一个线程中写一个变量
- 在另一个线程中读同一个变量
- 写和读没有通过同步来排序
如果一个多线程程序能够正确同步,这个程序将是一个没有数据竞争的程序,往往存在数据竞争的程序,运行结果与我们的预期结果都会存在偏差。
1.2 JMM对多线程程序的内存一致性做的保证
如果程序正确同步(正确使用synchronized、volatile和final),程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
2、顺序一致性内存模型
2.1 特性
- 一个线程中的所有操作必须按照程序的执行顺序来执行
- (不管是否正确同步)所有的线程都只能看到一个单一的操作执行顺序,每个操作都必须原子执行且立刻对所有线程可见。
图示:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存的读/写操作。上图中可以看出, 在任意时刻最多只有一个线程可以连接到内存。因此,在多线程并发执行时,图中的开关装置能把所有的内存读/写操作串行化(即在顺序一致性模型中所有操作之间具有全序关系)。
2.2 举例说明顺序一致性模型
假设两个线程A和B并发执行。其中
A线程的操作在程序中的顺序为:A1 - A2 - A3
B线程的操作在程序中的顺序为:B1 - B2 - B3。
假设线程A和线程B使用监视器锁来正确同步,A线程的3个操作执行后释放监视器锁,随后B线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下所示:
假设线程A和线程B没有做同步,那么这个未同步的程序在顺序一致性模型中的另一种可能的效果如下所示:
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但是所有线程都只能看到一个一直的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:A1 - B1 - A2 - B2 - A3 - B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在JMM中就没有这个保证。**未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。**比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。这种情况就会出现多种运行结果。
2.3 同步程序的顺序一致性效果
对上一章的ReorderExample程序用锁来同步
package com.lizba.p1;
/**
* <p>
* 同步示例
* </p>
*
* @Author: Liziba
* @Date: 2021/6/8 21:44
*/
public class SynReorderExample {
// 定义变量a
int a = 0;
// flag变量是个标记,用来标志变量a是否被写入
boolean flag = false;
public synchronized void writer() { // 获取锁
a = 1;
flag = true;
} // 释放锁
public synchronized void reader() { // 获取锁
if (flag) {
int i = a * a;
System.out.println("i:" + i);
}
} // 释放锁
}
测试代码
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
final SynReorderExample re = new SynReorderExample();
new Thread() {
public void run() {
re.writer();
}
}.start();
new Thread() {
public void run() {
re.reader();
}
}.start();
}
执行多次结果结果都为1
总结
在上面的示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性内存模型中的执行结果相同。
总结
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区的代码“逸出”到临界区之外,那样会破坏监视器锁的语义)。JMM会在进入临界区和退出临界区的关键时间点做一些特殊处理,使得线程在这两个时间点具有顺序一致性模型中相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视锁互斥执行的特性,这里线程B无法“观察”到线程A在临界区内的重排序。JMM在具体实现上的基本方针为:在不改变(正确同步)程序执行结果的前提下,尽可能为编译器和处理器的优化打开方便大门。
2.4 未同步程序的执行特性
对于未同步或者未正确同步(代码写错了的兄弟们),JMM只提供最小的安全性:
线程执行时读取到的值不会无中生有(Out Of Thin Air)
- 之前某个线程写入的值
- 默认值(0、Null、False)-- JVM会在已经清零了内存空间(Pre-zeroed Memory)分配对象。
未同步程序在两个模型中的执行特性对比
比较内容\\模型名称 | 顺序一致性模型 | JMM模型 |
---|---|---|
单线程内顺序执行 | √ | × |
一致的操作执行顺序 | √ | × |
64位long型和double型变量写原子性 | √ | × |
第三个差异和总线的机制有关。在一些32位处理器上,处理64位的数据写操作,需要将一个写操作拆分为两个32位的写操作。
3、 64位long型和double型变量写原子性
3.1 CPU、内存和总线简述
在计算机中,数据通过总线在处理器和内存之间传递,每次处理器和内存之间的数据传递都是通过一系列的步骤来完成的,这一系列的步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(WriteTransaction),事务会读\\写内存中一个或多个物理上连续的字。
读事务 → 内存到处理器
写事务 → 处理器到内存
重点是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他处理器和I\\O设备执行内存的读\\写。
图示:
由上图所示,假设处理器A、B、C、D同时向总线发起总线事务,这时总线总裁(Bus Arbitration)会对竞争作出裁决,这里假设处理器A在竞争中获胜(总线仲裁会确保所有处理器能公平访问内存)。此时处理器A继续它的总线事务,而其他所有的总线事务必须要等待A的事务完成才能再次执行内存的读\\写操作。总线事务工作机制确保处理器对内存的访问以串行的方式执行。在任意时间点都只有一个处理器可以访问内存,这个特性能确保总线事务之间的内存读\\写操作具有原子性。
3.2 long和double类型的操作
在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,那么会有非常大的同步开销。Java语言规范中鼓励但不强求JVM对64位long型和double类型的变量写操作具有原子性。当JVM在这种处理器上运行时,会把一个64位的变量写操作拆成两个32位写操作来执行,此时写不具备原子性。
图示:
存在问题:
假设处理器A写一个long类型的变量,同时处理器B要读这个long类型的变量。处理器A中64位的写操作被拆分成两个32位的写操作,且这两个32位的写操作被分配到不同的事务中执行。此时,处理器B中64位的读操作被分配到单个读事务中执行。如果按照上面的执行顺序,那么处理器B读取的将会是一个不完整的无效值。
处理方式:
JSR-133内存模型开始(JDK1.5),写操作能拆分成两个32位写事务执行,读操作必须在单个事务中执行。
文章总结至《Java并发编程艺术》,下篇总结“volatile的内存语义”,敬请关注。
以上是关于Java面试-内存模型之顺序一致性的主要内容,如果未能解决你的问题,请参考以下文章