Android技术知识Android必知必会Android开发经验谈

Java内存模型

2018-11-05  本文已影响2人  未子涵

本文主要内容出自周志明老师《深入理解Java虚拟机》一书,是笔者结合自己的理解,提取重点,重新组织排版后,总结的读书笔记。

计算机性能

并发处理的广泛应用,使得Amdahl代替摩尔定律成为计算机性能发展的源动力,而这种更替也代表了近年来硬件发展从追求处理器频率追求多核心并行处理的发展过程。

“压榨”处理器的运算能力

在讲解Java内存模型前,先谈谈物理计算机遇到的并发问题。为了压榨处理器的运算能力,现代计算机通常采取以下方案:

高速缓存(Cache)

直接问题:速度矛盾

CPU处理速度与内存读写速度相差几个数量级,导致CPU要等待缓慢的内存读写。

解决方案:高速缓存

加入一层读写速度尽可能接近处理器的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要的数据复制到缓存中,让运算能快速进行,当运算结束后在从缓存同步回内存之中。

引入的新问题:缓存一致性问题

在多处理器系统中,每个处理器都有独自的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,这正是物理计算机遇到的并发问题。

为了解决该问题,就需要各个处理器在访问缓存时遵循 缓存一致性协议

物理硬件和操作系统的内存模型
处理器-高速缓存-主内存间的交互关系

处理器的乱序执行(Out-Of-Order Execution)优化

为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,

处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。

CPU总是顺序的去内存中取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件、指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”

与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。

编译器的“指令重排序”优化

既然提到了处理器的乱序执行优化,这里就再简单说一下编译器的指令重排序优化,因为这两个概念比较容易混淆。

从硬件结构上讲,指令重排序是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。

并不是说指令任意重排,CPU需要能正确处理指令依赖情况以保证程序能得出正确的执行结果。譬如指令1吧地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排—— (A+10)*2A*2+10 显然不相等,但指令3可以重排到指令1和2之前或者中间,只要保证CPU执行后面依赖到A、B值的操作时能获取到正确的A和B值即可。

“乱序执行”和“指令重排序”对比
概念 执行者 发生时期 内存中指令顺序是否真的变化
乱序执行 处理器(CPU) 运行期
指令重排序 虚拟机编译器 编译期

Java内存模型

Java内存模型有以下规定:

线程-工作内存-主内存三者的交互关系

从更低层次上说,主内存就直接对应于物理硬件的内存,而虚拟机(甚至是操作系统本身的优化措施)会让工作内存优先存储于寄存器和高速缓存中。

所有变量都有副本拷贝吗?

假设访问一个10MB的对象,也要把它复制一份拷贝出来吗?显然不行,这个对象的引用、对象中某个在线程访问到的字段是有可能存在拷贝的,但不会有虚拟机实现成把整个对象拷贝一次。

volatile 变量也有工作内存的拷贝吗?

虽然volatile保证了多线程间变量的可见性,但它依然有工作内存的拷贝,只是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般。volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

内存间交互操作

工作内存的引入,使得CPU和内存的交互变得更加复杂,一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的细节,必须保证内存访问在并发下是安全的。为此Java内存模型定义了8种内存访问操作(lock、unlock、read、load、use、assign、store、wtite)及相关的规则限定,鉴于这些定义较为繁琐,这里不再做深入展开,而是介绍一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。

先行发生原则

先行发生是Java内存模型定义的两项操作之间偏序关系,如果操作A先行发生于操作B,那么在发生操作B之前,操作A产生的影响能被操作B观察到。这句话意味着什么呢?我们看如下伪代码:

// 以下操作在线程A中执行
i = 1;
// 以下操作在线程B中执行
j = i;
// 以下操作在线程C中执行
i = 2;

如果不通过某种手段明确3个操作之间的先行发生关系,那么最终 j 的值就很难确定,不具备多线程安全性。

“天然的”先行发生关系

以下是Java内存模型中“天然的”先行发生关系。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话 ,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  1. 程序次序规则:在一个线程内,按照程序代码顺序,写在前面的操作先行发生于写在后面的操作。准确地说,应该是控制流顺序,而非程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。要注意是“同一个锁”,而“后面”是指时间上的先后顺序。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指时间上的先后顺序。
  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则:一个对象的初始化完成(构造函数的执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A必定先行发生于操作C。

下面演示一下如何通过以上规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全,同时还可以感受一下“时间上的先后顺序”和“先行发生”之间有什么不同。

private int value = 0;
public void setValue(int value) {
    this.value = value;
}
public int getValue() {
    return value;
}

这是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则:

可见,尽管线程A在操作时间上先于操作B,但是无法确定线程B中“getValue()”方法的返回值,换句话说,这里的操作不是线程安全的。

那怎么修复这个问题呢?至少有两种比较简单的方案:

从上述例子,可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,也不成立,一个典型的例子就是“指令重排序”,请看下面的例子:

// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

由于在同一个线程中,根据程序次序规则,“int i = 1”的操作先行发生于“int j = 2”的操作,但是“int j = 2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题时不能受时间顺序干扰,一切必须以先行发生原则为准

原子性、可见性、有序性

其实Java内存模型就是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,我们来看看哪些操作实现了这3个特性。

原子性
可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

有序性

Java中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。

synchronized关键字在需要这3种特性时都可以作为一种解决方案,看起来很“万能”,但也间接的造就了它被滥用的局面,越“万能”的并发控制,通常会伴随着越大的性能影响。

上一篇下一篇

猜你喜欢

热点阅读