本文按:
红色字体-重要 绿色字体-不太懂 蓝色字体-非常重要
一. 什么是内存模型
二. 其他语言C++是否有内存模型
三. JSR 133 是关于什么的
四. 重排序是什么意思
五. 旧的JMM有什么缺陷
六. 不正确的同步意味着什么
七. 同步都做了哪些操作
八. 在旧的内存模型中final域为什么可以改变值
九. final域在新的JMM中是如何工作的
十. volatile做了什么
十一. 新的内存模型是否修复了”double-checked locking” 问题
十二. 如何自己实现VM
十三. 为什么需要关注JMM
一. 什么是内存模型
在多处理器系统中,处理器通常会有一层或者多层高速缓存用来提高获取数据的速度(因为数据离处理器最近)或者减少共享内存总线上的流量(许多内存操作可以通过本地缓存被满足)。高速缓存可以明显的提升性能但是也带来了许多新的问题。比方说当两个处理器同时检测同一个内存地址上的数据会发生什么?在什么样的条件下他们可以看到相同的数据?
在处理器层面,内存模型定义了一个充分必要条件,以便知道在当前的处理器中,其他处理器对内存的写入对当前处理器是可见的;以及当前处理器对内存的写入对于其他处理器是可见的。因此有些处理器显示出很强的内存模型,在它的内存模型中,任何时候,所有的处理器看到的在同一个内存地址中的值都是一致的。其它的一些处理器展示出比较弱的内存模型,在他们的内存模型中,特殊指令-内存屏障(memory barriers)被使用来刷新本地缓存,从而使得自己的写入对其它处理器可见,或者其它处理器的写入被当前处理器可见。这些memory barriers通常在Lock或者unLock操作中被执行,在高级语言中对于开发人员不可见。
有时,为强内存模型编写程序更容易,因为不需要memory barriers。但是实际上在很多最强的内存模型中,memory barriers也是必要的; 通常memory barriers的位置是违反直觉的。现在处理器设计的趋势更倾向于弱内存模型,因为他们对缓存一致性(cache consistency)的放松可以在多个处理器和更大的内存中获得更大的可扩展性。
一个写操作被另一个线程可见的问题,它因为编译器的重排序二而更加复杂。因为编译器可能觉得在不影响语义的前提下推迟写操作能提升程序的效率,如果这个编译器推迟了写操作,那么另一个线程就不会及时看到。
此外,写操作也可能被提前执行,在这种情况下,其他线程可能会提前看到写操作。所有的这些灵活性都是在虚拟机许可的范围内被设计以最大程度提高性能。
如下面这段程序:
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
这段代码在两个线程中被并发执行,读线程读取y的值为2,此时开发人员可能会假定读取x的值一定是1。但是实际上写操作可能被重排序,如果发生了重排序,那么读取的x的值可能是0。
Java内存模型解释了在多线程代码中什么样的行为是合法的,以及线程是如何通过内存交互的。它描述了程序中的变量之间的关系,以及在一个真实的计算机系统中从内存或寄存器中存储和检索它们的底层细节它以一种可以正确使用各种硬件和各种编译器优化的方式实现这一点
为了帮助程序员向编译器描述程序的并发性需求,Java包含了很多语言结构,比如volatile, final, and synchronized .Java内存模型定义了volatile和synchronized的行为,更重要的是,它确保了一个正确同步的Java程序在所有处理器架构上正确运行。
二 其他语言,比如c++是否有内存模型?
大多数其他语言,比如C, C++,不能提供对多线程直接的支持。这些语言对编译器和体系结构中发生的各种重排序的保护严重依赖于线程库(如pthreads)、使用的编译器和运行代码的平台所提供的保证。
三 JSR 133 是关于什么的?
自从1997年以来,Java内存模型中很多严重的缺陷被发现,这些问题在Java语言规范的17章中记录。这些缺陷允许混淆行为或者破坏编译器执行的优化。比如final域可以改变值。
Java内存模型是一项宏伟的工程,这是第一次,一个编程语言规范试图包含一个可以为各种架构的并发性提供一致语义的内存模型。不幸的是,定义一个既一致又直观的内存模型要比预期的困难得多。 JSR133 为Java语言定义了一个新的内存模型,在这个模型中修复了之前内存模型的缺陷。 为了达到这个目的,final 和 volatile的语义被改变。
完整的语义可在http://www.cs.umd.edu/users/pugh/java/memoryModel。然而理解synchronization 这样看似简单的概念实际上很复杂。 JSR 133的目标是创建一组正式的语义,使得volatile、synchronized和final运行。
四 重新排序是什么意思
在许多情况下,访问程序变量(对象实例字段、类静态字段和数组元素)可能会以不同于程序指定的顺序执行。编译器可以自由地以优化的名义对指令进行排序。在某些情况下,处理器可以乱序执行指令。数据可以在寄存器、处理器缓存和主内存之间移动,其顺序不同于程序所指定的顺序。
举个例子,如果一个线程写入字段a,然后写入字段b, 字段b的值不依赖a的值。 编译器可以自由的对这些操作重排序;缓存也可以自由的先将b的值刷新到主内存。有很多的潜在来源重新排序,如编译器、JIT和缓存。
编译器、运行时和硬件应该一起创建一个“as -if -串行语义”的幻象,这意味着在单线程程序中,程序不应该能够观察到重排序的影响。然而,重排序可以在不正确的同步多线程程序中发挥作用,其中一个线程能够观察其他线程的影响,并且能够检测到其他线程的变量访问,其顺序与程序中执行或指定的顺序不同。
大多数时候,一个线程不关心另一个线程在做什么。但当它发生时,这就是同步的作用。
五 旧的JMM有什么缺陷
旧的内存模型有几个严重的问题。它很难理解,因此很多被违反。例如,在许多情况下,旧模型不允许在每个JVM中进行重排序。这种对旧内存模型混乱的实现形式促使jsr - 133的形成。
1)例如,一个广为接受的观点是,如果使用final字段,那么线程之间的同步就没有必要保证另一个线程会看到该字段的值。虽然这是一个合理的假设和合理的行为,也是我们希望的。 但实际上在旧的内存模型下的事实并非如此。在旧的内存模型下,final域和其他的域被一样对待,这意味着同步变成了唯一可以让所有线程看到final域的方式。
因此,线程可能会看到字段的默认值,然后在以后的某个时间看到其构造的值。例如,这意味着诸如字符串之类的不可变对象似乎可以改变它们的值。。。
2)旧的线程模型中允许volatile的写操作可以和nonvolatile的读写操作重排序,这有背于开发者对volatile的认知。
六. 不正确的同步意味着什么
在Java内存模型中不正确的同步是指下面这样的代码:
1)一个线程对一个变量进行写操作
2)另一个线程对该变量进行读操作
3)读和写不是同步的命令
七. 同步都做了哪些操作
同步有很多方面。
1)互斥-在同一时刻只能有一个线程可以获得监听器,所以在监听器上的同步意味着一个线程进入到synchronized block以后,任何其他的线程不能进入到同一个监听器下的代码块,一直到之前的线程退出 synchronized block
但与互斥相比,同步更重要。同步可以确保一个线程在synchronized block 中 或者在synchronized block 之前执行的代码对其它线程(需要获取同一个锁的线程)可见。
1> 线程退出synchronized block后,释放锁;
2> 释放操作会使数据从本地缓存刷新到主内存, 使得写操作对其它线程可见;
3> 线程进入synchronized block之前,获取锁;
4> 获取操作会使得本地处理器缓存中的数据失效,变量会从主内存中reload
5> 这些操作确保当前线程可以读取之前线程的所有写操作
从缓存的角度讨论这个问题,似乎这些问题只会影响到多处理器机器。然而,可以很容易地在单处理器上看到重新排序的效果。例如,在获得锁或释放锁之前,编译器不可能移动您的代码。当我们说获得锁和释放锁对缓存的影响时,我们使用的是一些可能的简写。
新的内存模型语义在内存操作(read、write、lock、unlock)和其他线程操作(start 和 join)上创建了部分排序,在这些操作中,有些操作被指定为 happen before other operations。当一个操作需要发生在另一个操作之前时,首先要保证排序正确以及被第二个操作可见,排序规则是:
1. Each action in a thread happens before every action in that thread that comes later in the program’s order.
2. An unlock on a monitor happens before every subsequent lock on that same monitor.
3. A write to a volatile field happens before every subsequent read of that same volatile.
4. A call to start() on a thread happens before any actions in the started thread.
5. All actions in a thread happen before any other thread successfully returns from a join() on that thread.
这意味着下面的代码是不正确的:
synchronized (new Object()) {}
这实际上是指没有任何操作,编译器会将这些代码整个remove掉,因为使用了new Object()意味着没有其他线程将会在整个锁上同步。
重要说明:对于两个线程来说,同步同一个监视器是很重要的,以便在正确建立 happens-before 关系。release 和 acquire 操作需要在同一个监视器上执行。
八. 在旧的内存模型中final域为什么可以改变值
以String为例:
字符串作为一个具有三个字段的对象来实现——字符数组(array),偏移量(offset),长度(length)。这样实现字符串的基本原理是,它允许多个字符串和StringBuffer对象共享相同的字符数组,避免额外的对象分配和复制。例如,方法string . substring()可以通过创建一个新字符串来实现,该字符串与原始字符串共享相同的字符数组,并且只在长度和偏移字段中有所不同。对于字符串,这些字段都是最后的字段。这三个字段都是final的。
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
这段代码在旧的内存模型中多线程环境下s2的值可能会在”/usr” 和 “/tmp”之间切换。因为线程读取的offset会变化。但是新的Java内存模型中做了调整。
九. final域在新的JMM中是如何工作的
final域的值在构造方法中被指定。包含final字段的对象被正确创建后,final域的值会被指定,这些值可以对其它线程自动可见。除此之外,任何final域引用的其它对象都与finalz字段一样最新。
对象被正确创建指的是什么?
它只是意味着在构建过程中不允许对象的引用发生“逃逸”(参考Safe Construction Techniques )。换句话说,不要包含一个任何其他线程都可以看到的引用;不要包含static 域,不要将其注册为任何其他对象的侦听器。这些工作需要在构造方法执行完成后再执行。
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方法的线程可以确保读到的值是3,但是不能确保读到y的值是4,因为y不是final域。
如果代码变成下面呢?
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
调用global.obj.read()的线程读取的x的值也不能确保为3。因为程序可能按照下面的顺序执行:(1)创建对象(2)读取global.obj.x (3)将x = 3赋值给final域。
如果对象中的一个final域本身是一个引用,对象本身可以看到这个final 引用的最新值,但是如果需要其他的线程也看到最新值仍然需要使用同步。
十. volatile做了什么
volatile 被用来交换线程间的通信状态。任何一个volatile 读操作的数据都来自于最后一次写操作,这实际上是一个可以被程序员指定的字段,
1> 这样的字段不会接受任何来自cache的过期的值。
2> 禁止编译器和运行时将它们分配到寄存器中。
3> 同时任何一次写操作完成后都会把最新的值刷新到main memory
4> 同样,在volatile被读取之前,本地高速缓存中的数据必须要将值写入到main memory中去。
4> 除此之外还有一些其他对于volatile 操作重排序的限制
在以前的虚拟机模型中,对多个volatile变量的访问操作之间不可以重排序,但是对volatile和非volatile的访问可以重排序。这削弱了volatile字段作为从一个线程传递到另一个线程的条件的有用性。
在新的虚拟机模型中,对多个volatile变量的访问操作之间不可以重排序,但是降低了volatile和非volatile的访问重排序的可能性。对volatile 变量的写操作类似于锁被释放,对volatile变量的读操作类似于加锁。
实际上因为新的内存模型对访问volatile和其他非volatile指令重排序的限制,任何对A线程可见的域,当线程写入数据到volatile 域后,所有的域对B线程可见。
volatile使用例子:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
十一 . 新的内存模型是否修复了”double-checked locking” 问题
双重检查锁定(也被叫做多线程单例模式)的设计最初是为了支持懒加载时避免出现多个单例。在早期的JVM中,同步非常缓慢,许多开发者迫切想要去掉同步。因而出现了下面的代码:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
这段代码看上去很聪明的避免了同步问题。但是实际上它没有用!!!最明显的原因是,初始化实例和写入实例字段的写操作可以由编译器或缓存重新排序,可能会返回一个部分构造的东西,结果是我们会读取到有一个没有被构造完全的东西。更深层的讨论可以看下面的文章:
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
使用下面的代码可以既简单又线程安全的创建单例:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
十二. 如何自己实现VM
传送门:http://gee.cs.oswego.edu/dl/jmm/cookbook.html .
十三. 为什么需要关注JMM
并发的问题非常难以定位。通常他们不会出现在测试环境中,一旦发生将会是系统高并发的时候,并且难以解决。对于开发人员来讲了解并发知识有助于帮助自己正确的定位问题和解决问题。
翻译来源:https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html