JSR133提案-修复Java内存模型
1. 什么是内存模型?
在多处理器系统中,为了提高访问数据的速度,通常会增加一层或多层高速缓存(越靠近处理器的缓存速度越快)。
但是缓存同时也带来了许多新的挑战。比如,当两个处理器同时读取同一个内存位置时,看到的结果可能会不一样?
在处理器维度上,内存模型定义了一些规则来保证当前处理器可以立即看到其他处理器的写入,以及当前处理器的写入对其他处理器立即可见。这些规则被称为缓存一致性协议
。
有些多处理器架构实现了强一致性,所有的处理器在同一时刻看到的同一内存位置的值是一样的。
而其他处理器实现的则是较弱的一致性,需要使用被称为内存屏障
的特殊机器指令使来实现最终一致性(通过刷新缓存或使缓存失效)。
这些内存屏障
通常在释放锁和获取锁时被执行;对于高级语言(如Java)的程序员来说,它们是不可见的。
在强一致性的处理器上,由于减少了对内存屏障的依赖,编写并发程序会更容易一些。
但是,相反的,近年来处理器设计的趋势是使用较弱的内存模型,因为放宽对缓存一致性的要求可以使得多处理器系统有更好的伸缩性和更大的内存。
此外,编译器、缓存或运行时还被允许通过指令重排序
改变内存的操作顺序(相对于程序所表现的顺序)。
例如,编译器可能会往后移动一个写入操作,只要移动操作不改变程序的原本语义(as-if-serial语义),就可以自由进行更改。
再比如,缓存可能会推迟把数据刷回到主内存中,直到它认为时机合适了。
这种灵活的设计,目的都是为了获得得最佳的性能,
但是在多线程环境下,指令重排会使得跨线程可见性的问题变的更复杂。
为了方便理解,我们来看个代码示例:
Class Reordering {
int x = 0, y = 0;
//thread A
public void writer() {
x = 1;
y = 2;
}
//thread B
public void reader() {
int r1 = y;
int r2 = x;
}
}
假设这段代码被两个线程并发执行,线程A执行writer(),线程B执行reader()。
如果线程B在reader()中看到了y=2,那么直觉上我们会认为它看到的x肯定是1,因为在writer()中x=1
在y=2
之前 。
然而,发生重排序时y=2会早于x=1执行,此时,实际的执行顺序会是这样的:
y=2;
int r1=y;
int r2=x;
x=1;
结果就是,r1的值是2,r2的值是0。
从线程A的角度看,x=1与y=2哪个先执行结果是一样的(或者说没有违反as-if-serial语义
),但是在多线程环境下,这种重排序会产生混乱的结果。
我们可以看到,高速缓存
和指令重排序
提高了效率的同时也引出了新的问题,这显然使得编写并发程序变得更加困难。
Java内存模型就是为了解决这类问题,它对多线程之间如何通过内存进行交互做了明确的说明。
更具体点,Java内存模型描述了程序中的变量与实际计算机的存储设备(包括内存、缓存、寄存器)之间交互的底层细节。
例如,Java提供了volatile、final和 synchronized等工具,用于帮助程序员向编译器表明对并发程序的要求。
更重要的是,Java内存模型保证这些同步工具可以正确的运行在任何处理器架构上,使Java并发应用做到“Write Once, Run Anywhere”。
相比之下,大多数其他语言(例如C/C++)都没有提供显示的内存模型。
C程序继承了处理器的内存模型,这意味着,C语言的并发程序在一个处理器架构中可以正确运行,在另外一个架构中则不一定。
2. JSR 133是关于什么的?
Java提供的跨平台内存模型是一个雄心勃勃的计划,在当时是具有开创性的。
但不幸的是,定义一个即直观又一致的内存模型比预期的要困难得多。
自1997年以来,在《Java语言规范》的第17章关于Java内存模型的定义中发现了一些严重的缺陷。
这些缺陷使一些同步工具产生混乱的结果,例如final字段可以被更改。
JSR 133为Java语言定义了一个新的内存模型,修复了旧版内存模型的缺陷(修改了final和volatile的语义)
JSR的主要目标包括不限于这些:
- 正确同步的语义应该更直观更简单。
- 应该定义不完整或不正确同步的语义,以最小化潜在的安全隐患
- 程序员应该有足够的自信推断出多线程程序如何与内存交互的。
- 提供一个新的
初始化安全性保证
(initialization safety)。
如果一个对象被正确初始化了(初始化期间,对象的引用没有逃逸,比如构造函数里把this赋值给变量),那么所有可以看到该对象引用的线程,都可以看到在构造函数中被赋值的final变量。这不需要使用synchronized或volatile。
3. 再谈指令重排序
在许多情况下,出于优化执行效率的目的,数据(实例变量、静态字段、数组元素等)可以在寄存器、缓存和内存之间以不同于程序中声明的顺序被移动。
例如,线程先写入字段a,再写入字段b,并且b的值不依赖a,那么编译器就可以自由的对这些操作重新排序,在写入a之前把b的写入刷回到内存。
除了编译器,重排序还可能发生在JIT、缓存、处理器上。
无论发生在哪里,重排序都必须遵循as-if-serial
语义,这意味着在单线程程序中,程序不会觉察到重排序的存在,或者说给单线程程序一种没有发生过重排序的错觉。
但是,重排序在没有同步的多线程程序中会产生影响。在这种程序中,一个线程能够观察到其他线程的运行情况,并且可能检测到变量访问顺序与代码中指定的顺序不一致。
大多数情况下,一个线程不会在乎另一个线程在做什么,但是,如果有,就是同步的用武之地。
4.同步都做了什么?
同步
有很多面,最为程序员熟知的是它的互斥性
,同一时刻只能有一个线程持有monitor。
但是,同步
不仅仅是互斥性
。同步还能保证一个线程在同步块中的写内存操作对其他持有相同monitor的线程立即可见。
当线程退出同步块时(释放monitor),会把缓存中的数据刷回到主内存,使主内存中保持最新的数据。
当线程进入同步块时(获取monitor),会使本地处理器缓存失效,使得变量必须从主内存中重新加载。
我们可以看到,之前的所有写操作对后来的线程都是可见的。
5. final字段在旧的内存模型中为什么可以改变?
证明final字段可以改变的最佳示例是String类的实现(JDK 1.4版本)。
String对象包含三个字段:一个字符串数组的引用value、一个记录数组中开始位置的offset、字符串长度length。
通过这种方式,可以实现多个String/StringBuffer对象共享一个相同的字符串数组,从而避免为每个对象分配额外的空间。
例如,String.substring()通过与原String对象共享一个数组来产生一个新的对象,唯一的不同是length和offset字段。
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
s2和s1共享一个字符串数组"/usr/tmp",不同的是s2的offset=4,length=4,s1的offset=0,length=8。
在String的构造函数运行之前,根类Object的构造函数会先初始化所有字段为默认值,包括final的length和offset字段。
当String的构造函数运行时,再把length和offset赋值为期望的值。
但是这一过程,在旧的内存模型中,如果没有使用同步,另一个线程可能会看到offset的默认值0,然后在看到正确的值4.
结果导致一个迷幻的现象,开始看到字符串s2的内容是'/usr',然后再看到'/tmp'。
这不符合我们对final语义的认识,但是在旧内存模型中确实存在这样的问题。
(JDK7开始,改变了substring的实现方式,每次都会创建一个新的对象)
6.“初始化安全”与final字段?
新的内存模型提供一个新初始化安全
( initialization safety)保障。
意味着,只要一个对象被正确的构造,那么所有的线程都会看到这些在构造函数中被赋值的final字段。
“正确”的构造是指在构造函数执行期间,对象的引用没有发生逃逸。或者说,在构造函数中没有把该对象的引用赋值给任何变量。
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
示例中,初始化安全
保证执行reader()方法的线程看到的f.x=3,因为它是final字段,但是不保证能看到y=4,因为它不是final的。
但是如果构造函数像这样:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
global.obj = this; // allowing this to escape
}
初始化安全
不能保证读取global.obj的线程看到的x的值是3,因为对象引用this发生了逃逸。
不仅如此,任何通过final字段(构造函数中被赋值的)可以触达的变量都可以保证对其他线程可见。
这意味着如果一个final字段包含一个引用,例如ArrayList,除了该字段的引用对其他线程可见,ArrayList中的元素对其他线程也是可见的。
初始化安全
增强了final的语义,使其更符合我们对final的直观感受,任何情况下都不会改变。
7. 增强volatile语义
volatile变量是用于线程之间传递状态的特殊变量,这要求任何线程看到的都是volatile变量的最新值。
为实现可见性,禁止在寄存器中分配它们,还必须确保修改volatile后,要把最新值从缓存刷到内存中。
类似的,在读取volatile变量之前,必须使高速缓存失效,这样其他线程会直接读取主内存中的数据。
在旧的内存模型中,多个volatile变量之间不能互相重排序,但是它们被允许可以与非volatile变量一起重排序,这消弱了volatile作为线程间交流信号的作用。
我们来看个示例:
Map configs;
volatile boolean initialized = false;
. . .
// In thread A
configs = readConfigFile(fileName);
processConfigOptions( configs);
initialized = true;
. . .
// In thread B
while (initialized) {
// use configs
}
示例中,线程A负责配置数据初始化工作,初始化完成后线程B开始执行。
实际上,volatile变量initialized扮演者守卫者的角色,它表示前置工作已经完成,依赖这些数据的其他线程可以执行了。
但是,当volatile变量与非volatile变量被编译器放到一起重新排序时,“守卫者”就形同虚设了。
重排序发生时,可能会使readConfigFile()中某个动作在initialized = true
之后执行,
那么,线程B在看到initialized的值为true后,在使用configs对象时,会读取到没有被正确初始化的数据。
这是volatile很典型的应用场景,但是在旧的内存模型中却不能正确的工作。
JSR 133专家组决定在新的内存模型中,不再允许volatile变量与其他任务内存操作一起重排序。
这意味着,volatile变量之前的内存操作不会在其后执行,volatile变量之后的内存操作不会在其前执行。
volatile变量相当于一个屏障,重排序不能越过对volatile的内存操作。(实际上,jvm确实使用了内存屏障指令)
增强volatile语义的副作用也很明显,禁止重排序会有一定的性能损失。
8. 修复“double-checked locking”的问题
double-checked locking
是单例模式的其中一种实现,它支持懒加载且是线程安全的。
大概长这个样子:
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();//
}
}
return instance;
}
它通过两次检查巧妙的避开了在公共代码路径上使用同步,从而避免了同步所带来的性能开销。
它唯一的问题就是——不起作用。为什么呢?
instance的赋值操作会与SomeThing()构造函数中的变量初始化一起被编译器或缓存重排序,这可能会导致把未完全初始化的对象引用赋值给instance。
现在很多人知道把instance声明为volatile可以修复这个问题,但是在旧的内存模型(JDK 1.5之前)中并不可行,原因前面有提到,volatile可以与非volatile字段一起重排序。
尽管,新的内存模型修复了double-checked locking
的问题,但仍不鼓励这种实现方式,因为volatile并不是免费的。
相比之下,Initialization On Demand Holder Class
更值得被推荐,
它不仅实现了懒加载和线程安全,还提供了更好的性能和更清晰的代码逻辑。大概长这个样子:
public class Something {
private Something() {}
//static innner class
private static class LazyHolder {
static final Something INSTANCE = new Something(); //static field
}
public static Something getInstance() {
return LazyHolder.INSTANCE;
}
}
这种实现完全没有使用同步工具,而是利用了Java语言规范的两个基本原则,
其一,JVM保证静态变量的初始化对所有使用该类的线程立即可见;
其二,内部类首次被使用时才会触发类的初始化,这实现了懒加载。
9. 我什么我要关心这些问题?
并发问题一般不会在测试环境出现,生成环境的并发问题又不容易复现,这两个特点使得并发问题通常比较棘手。
所以你最好提前花点时间学习并发知识,以确保写出正确的并发程序。我知道这很困难,但是应该比排查生产环境的并发问题容易的多。
参考文献
1.JSR 133 (Java Memory Model) FAQ,2004
https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#oldmm
2.volatile关键字: https://en.wikipedia.org/wiki/Volatile_(computer_programming)
3.Double-checked问题:https://en.wikipedia.org/wiki/Double-checked_locking
4.内存屏障和volatile语义: https://en.wikipedia.org/wiki/Memory_barrier
5.修复Java内存模型:https://www.ibm.com/developerworks/java/library/j-jtp03304/index.html
6.String substring 在jdk7中会创建新的数组
https://www.programcreek.com/2013/09/the-substring-method-in-jdk-6-and-jdk-7/
7.Memory Ordering : https://en.wikipedia.org/wiki/Memory_ordering
8.有MESI协议为什么还需要volatile? https://www.zhihu.com/question/296949412
9.Initialization On Demand Holder Class:
https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
JSR133提案-修复Java内存模型的更多相关文章
- 修复 Java 内存模型,第 2 部分——Brian Goetz
转自Java并发大师Brain Goetz:http://www.ibm.com/developerworks/cn/java/j-jtp03304/ (中文地址) http://www.ibm.co ...
- 修复 Java 内存模型,第 1 部分——Brian Goetz
转自Java并发大师Brain Goetz:http://www.ibm.com/developerworks/cn/java/j-jtp02244/ (中文地址) http://www.ibm.co ...
- Java 理论与实践: 修复 Java 内存模型,第 2 部分(转载)
在 JSR 133 中 JMM 会有什么改变? 活跃了将近三年的 JSR 133,近期发布了关于如何修复 Java 内存模型(Java Memory Model, JMM)的公开建议.在本系列文章的 ...
- 对Java内存模型即JMM的理解
类似物理上的计算机系统,Java虚拟机规范中也定义了一种Java内存模型,即Java Memory Model(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能 ...
- 来,了解一下Java内存模型(JMM)
网上有很多关于Java内存模型的文章,在<深入理解Java虚拟机>和<Java并发编程的艺术>等书中也都有关于这个知识点的介绍.但是,很多人读完之后还是搞不清楚,甚至有的人说自 ...
- 再有人问你Java内存模型是什么,就把这篇文章发给他
前几天,发了一篇文章,介绍了一下JVM内存结构.Java内存模型以及Java对象模型之间的区别.有很多小伙伴反馈希望可以深入的讲解下每个知识点.Java内存模型,是这三个知识点当中最晦涩难懂的一个,而 ...
- 《成神之路-基础篇》JVM——Java内存模型(已完结)
Java内存模型 本文是<成神之路系列文章>的第一篇,主要是关于JVM的一些介绍. 持续更新中 Java内存模型 JVM内存结构 VS Java内存模型 VS Java对象模型(Holli ...
- 什么是 Java 内存模型,最初它是怎样被破坏的?(转载)
活跃了将近三年的 JSR 133,近期发布了关于如何修复 Java 内存模型(Java Memory Model, JMM)的公开建议.原始 JMM 中有几个严重缺陷,这导致了一些难度高得惊人的概念语 ...
- 别再问什么是Java内存模型了,看这里!
网上有很多关于Java内存模型的文章,在<深入理解Java虚拟机>和<Java并发编程的艺术>等书中也都有关于这个知识点的介绍.但是,很多人读完之后还是搞不清楚,甚至有的人说自 ...
随机推荐
- web.xml——Error:cvc-complex-type.2.4.a: Invalid content was found starting with element
配置web.xml文件时报错 错误:cvc-complex-type.2.4.a: Invalid content was found starting with element 详细报错信息:cvc ...
- Js 改变时间格式输出格式
朋友看到的方法,非js原生的 自己封装到 function date2str(x,y) { var z={y:x.getFullYear(),M:x.getMonth()+1,d:x.getDate( ...
- 1.Redis介绍和使用场景
(1)持久化数据库的缺点 平常我们使用的关系型数据库有Mysql.Oracle以及SqlServer等,在开发的过程中,数据通常都是通过Web提供的数据库驱动来链接数据库进行增删改查. 那么,我们日常 ...
- PMP 冲!|项目整合管理
0x00概述 项目管理包括识别.定义.组合.统一与协调各项目管理过程组的过程及项目管理活动.包括在各个项目冲突的目标与方案之间进行权衡和选择. 整合管理包括进行如下选择: 资源分配: 平衡竞争性需求: ...
- docker出现相同的image条目的删除办法
一.问题:在测试docker安装的prometheus系统时,由于异常操作,使用docker image ls出现了两条一模一样的条目,如下: [root@ELK prometheus]# docke ...
- 使用turtle库画同切圆
import turtle as t t.setup(600,600,None,None) t.pensize(5) t.penup() t.pendown() t.pencolor("re ...
- Java实现 LeetCode 836 矩形重叠(暴力)
836. 矩形重叠 矩形以列表 [x1, y1, x2, y2] 的形式表示,其中 (x1, y1) 为左下角的坐标,(x2, y2) 是右上角的坐标. 如果相交的面积为正,则称两矩形重叠.需要明确的 ...
- (Java实现) 拦截导弹
1260:[例9.4]拦截导弹(Noip1999) 时间限制: 1000 ms 内存限制: 65536 KB 提交数: 4063 通过数: 1477 [题目描述] 某国为了防御敌国的导弹袭击,发展出一 ...
- Java实现 蓝桥杯VIP 算法训练 判断字符位置
判定字符位置 时间限制: 1Sec 内存限制: 128MB 提交: 487 解决: 251 题目描述 返回给定字符串s中元音字母的首次出现位置.英语元音字母只有'a'.'e'.'i'.'o'.'u'五 ...
- Java实现第九届蓝桥杯缩位求和
缩位求和 题目描述 在电子计算机普及以前,人们经常用一个粗略的方法来验算四则运算是否正确. 比如:248 * 15 = 3720 把乘数和被乘数分别逐位求和,如果是多位数再逐位求和,直到是1位数,得 ...