java内存模型
什么Java内存模型?
在多核处理器系统中,处理器通常有一级或者多级的内部缓存(CPU参数中经常看到的L1,L2,L3就是),他们既提高了访问数据的性能(因为数据更接近处理器而不用受内存速度的影响),同时也减少了在共享内存总线时的冲突(因为很多情况下内部缓存就以及缓存了内存的操作)。 处理器缓存可以非常明显的改善性能,但同时它也带来了一个新的挑战。 比如说,当有多个处理器(对于多核处理器就是一个核心)同时访问同一个内存地址的时候,在什么条件下他们看见的是同一个值呢? 因为不同的处理器可能都有自己的缓存, 如何保证多个处理器都必须从内存中去读取该内存地址的数据呢?
在处理器层次,一个内存模型为如下问题定义了必要和高效的条件:
*当前处理器如何“看见”其他处理器对内存地址的“写”操作
*其他处理器如何“看见”当前处理器对内存地址的“写”操作
一些处理器提出了一种强内存模型,它要求所有处理器在任何时刻对任何内存地址都看到相同的值, 另外一些处理器提出了一种弱内存模型,其实也就是一种特殊的指令: 内存屏障(memory barriers
),为了当前处理器看见其他处理器对内存的写或者其他处理器看见当前处理器的操作它要求刷新或者验证处理器缓存的数据。 在加锁(lock
)或者解锁(unlock
)的时候内存屏障经常发生。 但是对于高级语言来说它是不可见的。
由于内存屏障的缺失,强内存模型在某些情况下更容易编程。但是,即使在强内存模型下, 内存屏障也经常是必须的。 他们的位置经常是违反直觉的。 最近的处理器设计中,由于内存屏障对于贯穿多个处理器和大内存之间的内存一致性做出的保证,更倾向于弱内存模型
由于编译器重排序,线程对内存地址的写操作是否对其他线程也可见这个问题变得更加复杂。 编译器可能会认为移动某个写操作在后面是更高效的(这里涉及到编译器的优化策略, 典型的是循环体中不会改变的变量作为循环条件,然后在其他线程修改,但是根据语义分析,编译器会觉得循环条件不会改变而把变量移动位置 ),当然,这些操作都不会改变程序的语义。 所以如果编译器延迟或者提前了某个操作,这会很明显的影响其他线程看到的数据。 这些折射出了缓存缓存的影响。
更普遍的是,程序中的写操作会被向前移动。 这种情况下,其他线程可能会看到一个还没有真正发生的“写”操作。 这些灵活性都是特意设计的: 给予编译器、运行环境或者硬件在最优的顺序执行代码的灵活性。在内存模型的范畴内,我们可以达到更高的性能。
考虑如下代码:
ClassReordering{
int x =0, y =0;
publicvoid write(){
x =1;
y =2;
}
publicvoid reader(){
int r1 = y ;
int r2 = x;
}
}
多线程情况下,上述代码中的reader将会看到y=2 ,因为y在x之后被赋值。编写代码的可能认为x的值一定是1,但是,赋值操作很可能被重排序。 比如说 ,对y的赋值可能先于对x的赋值。 此时,如果对y的赋值完成过后,线程就调用了reader方法,那么r1将会是2,r2却是0 。 由于重排序带来的不确定性,r1和r2的值完全无法确定。
Java内存模型描述了多线程环境下哪种代码是合法的,线程和内存是如何相互影响。它描述了低层次的存储细节和程序变量、从真实系统中内存或者寄存器读或者写数据的关系。通过多种硬件以及多种编译器优化来实现该模型。
Java包含多种语言指令,比如 volatile, final ,synchronized
, 他们向编译器描述了并发程序的要求。 Java内存模型定义了volatile
和synchronized
的行为,最重要的,它确定在所有多种处理器平台上具有相同的行为。 也就是说,java内存模型抽象了处理器相关的并发程序处理细节,提供了一个与具体平台无关的、语义确保得到保证的并发抽象层。
其他语言是否有内存模型
C以及C++语言的多线程程序都是与具体编译器、操作系统、处理器强相关的。 不同平台下的代码不兼容。
JSR133S讲的是什么?
1997年开始,在java语言规范的17章就有了java内存模型的定义。 它定义了一些看起来是令人困惑的行为(比如final字段可能看起来改变了值)。 它也阻碍了编译器进行通用优化。
Java内存模型是一个充满雄心壮志的愿景。它是第一次有语言规范提出可以保证在并发在多种处理器之间有相同语义的内存模型。不幸的是,实现起来比想象中的还要困难。 JSR133提出了一个修复了之前的问题的一个新的内存模型。同时,改变了final和volatile 的语义。
JSR133的目标包括:
- 保留现存的安全保证,比如类型安全。 加强了其他的,比如说,变量的值不会无中生有的被创建:线程必须有存储变量值的“正当”理由(其实就是为了能看到同样的值)
- 同步程序代码必须足够简单、易懂
- 定义不正确、不完全的同步程序语义, 以最小化安全风险
- 程序编写者能够清楚的知道多线程程序如何和内存相互影响的(即Java内存模型提供的语义在任何平台都使用并且语义相同)
- 可以编写使用多种流行处理器的高效JVM实现
- 一个新的初始化安全性的保证。如果一个对象以及被正确的构造(意味着它的引用没有超出它的构造函数),那么所有看得见该引用的线程都会看见相同的构造函数中赋值的final成员变量值,即使没有使用同步(
synchronization
)。 - 多现存代码尽可能小的影响
重排序是什么?
有许多程序变量(类实例变量、类静态变量、数组)并没有按照代码中指定的顺序运行的例子,编译器为了优化可以自由选择指令顺序(语义一致的情况),处理器也可能乱序执行指令。数据可能按照完全不同代码中的顺序在处理器、处理器缓存、内存中移动。
比如说, 如果某个线程中先对变量a赋值,然后对变量b赋值, b的赋值不依赖于a的情况下,编译器可以自由改变他们的顺序。处理器缓存也可能在a之前就把b的值刷新到内存中去。 有许多可能的重排序源,比如编译器,JIT,CPU缓存。在现代处理器中,乱序执行、分支预测等手段都会导致这个问题。
编译器、运行时环境、硬件协同构造了一种“顺序执行”的假象,这意味着在单线程程序中程序是完全不会受到重排序的影响, 因为代码真的像是顺序执行下来的。 但是没有正确使用同步的多线程情况下,线程之间是否能看见相同的值或者看到改变完全是随机性的。 因为重排序使得每个线程可能都不是按照代码中的顺序执行的, 线程彼此没有交互的话很可能看到的不是相同的值。
大多数情况下,一个线程不关心其他线程做什么,如果关心就是synchronization
所做的事了。
什么是不正确的同步(incorrectly synchronization
)?
通常情况下,如下的代码会被认为是不正确的同步:
- 某个线程在写某个变量
- 另外一个线程在读该变量
- 读和写并没有通过同步进行排序
这种时候,我们就说在那个变量上发生了数据竞争(data race
),程序也就是一个没有正确同步的程序
同步(synchronization)做了什么?
同步有多个方面:最为人所知的互斥性(mutex
): 即同一时间只有一个线程拥有某个对象的监视器(monitor
),也意味着一旦一个线程进入了监视器(monitor
)对象的同步代码快中,访问同一对象的同步代码块的其他线程必须等到拥有监视器(monitor
)的线程退出同步代码库才行。
但是,synchronization
还有一个一个比互斥更重要的特性:同步确保一个线程的内存写操作对其他拥有相同监视器(monitor
)的线程可见。当我们推出同步代码块时,就释放了该监视器,它将会把处理器缓存中的数据刷新到内存中,以便于其他线程可以看到该线程所做的更改。在我们进入一个同步代码块之前,我们会申请一个监视器(monitor
),这一步骤会使得当前处理器的缓存失效而不得不从内存中重新读取数据,这样我们就可以看见任意线程所做的任何更改了。
新的内存模型语义在内存操作(read field, write field , lock ,unlock
)、线程操作(start , join
)中创造了一个非公平顺序,即一些操作会发生在另外一些操作之前(happen-before
),当一个操作在另一个操作之前时,前面一个将会确保在后面一个的前面并且是可见的。 排序的规则如下:
- 按照程序代码中的顺序,同一个线程的操作会
happens-before
之后的操作 - 同一个监视器上,
unlocl
是happens-before
接下来的lock
的 - 同一个监视器上,对一个
volatile
变量的写操作happens-before
读操作 start()
方法happens-before
调用它的线程的任意操作- 线程中的操作
happens-before
该线程中join()
方法返回的所有线程
上面的规则意味着,对于同一个对象的监视器(monitor
),当前同步块中的内存操作对于之后进入同步代码的任何线程都是可见的,也就是所有的内存操作happens-before
于监视器的释放,监视器的释放happens-before
监视器的获取。
PS:synchronization
是reentrant
的,也就是同步代码块可以调用同一个监视器的中的其他同步代码块, 也就是说拥有多次lock, 但是实际上是同一个锁Recall that a thread cannot acquire a lock owned by another thread. But a thread can acquire a lock that it already owns. Allowing a thread to acquire the same lock more than once enables reentrant synchronization. This describes a situation where synchronized code, directly or indirectly, invokes a method that also contains synchronized code, and both sets of code use the same lock. Without reentrant synchronization, synchronized code would have to take many additional precautions to avoid having a thread cause itself to block.
locksync
为了建立happens-before
关系,线程都应该是在相同的监视器(monitor
)上。线程A在对象X的同步代码块不是happens-before
之后线程B在对象Y的同步代码块。释放和申请锁必须一一对应,否则,就是一个data race
final
在新的内存模型(从JDK1.5开始的)是如何工作的?
对象的final属性在构造函数中设置,一旦一个对象被正确的构造,给final属性赋的值对于其他所有线程都是可见的了,不管是否在同步块中。 另外,这个final属性所应用的任何对象或者数组也和final属性本身一样对所有线程都是最新的。 这意味这对于final属性,不需要额外的同步代码即可保证其他线程也可以看见最新的值。
那么什么是“对象被正确的构造”?
简单的说,这意味着该对象的this引用没有“溢出”构造函数中,不然其他线程可能通过this引用访问到“初始化一半”了的对象。比如说,不要赋给静态域、也不要把其他对象作为一个回调等等。这些可以在构造函数完成过后做而不是构造函数中。
classFinalFieldExample{
finalint x;
int y;
staticFinalFieldExample f;
publicFinalFieldExample(){
x =3;
y =4;
}
staticvoid writer(){
f =newFinalFieldExample();
}
staticvoid reader(){
if(f !=null){
int i = f.x;
int j = f.y;
}
}
}
上面代码描述了如何使用final,调用reader方法的线程一定会看到x的值为3,因为x是final的。而y则不保证一定是4. 如果上述代码的构造函数是这样:
publicFinalFieldExample(){// bad!
x =3;
y =4;
//this溢出,应该避免这样的代码
global.obj =this;
}
那么线程将可以通过global.obj看到this的引用,x的值就不能保证是3.
对于final属性可以看到正确的构造函数中赋的值是非常好的特性,如果final属性本身是一个引用,那么在其他任何线程都可以保证final属性“至少”会看到构造函数中所指定的值,而如果某个线程通过该引用的方法修改了数据, 则不保证所有线程都能看到该值, 为了确保所有线程都可以看到最新的值,你还是必须使用synchronization
来同步。
final属性可以保证能看到对象构造函数值中最后指定的值, 而不是最新的值。
使用JNI来改变final值是未定义行为
如果final是引用或者数组类型,仍然需要同步synchronization
来保证所有线程都可以看见最新的值
volatile
volatile
是一个通常用于线程通信的特殊字段,每次对volatile
变量的读都会读取所有线程中最新的值。 所以,它通常被设计为多线程之间的一个标志性变量,它的值可能会不停的改变。 编译器和运行时环境都不允许在寄存器中缓存该变量的值,他们必须确保当有对volatile
变量的写时,值直接被写到主内存中去以便于其他线程可以立马看到这个改变。同样的道理,读volatile
变量时,也会清除缓存然后从主内存中重新读取数据。这也导致在volatile变量时会禁止
reorder
volatile
在JSR133中,任然是不允许被重排序的,这也使得重排序它周围的正常变量变得更难,不过这不是多线程程序编写者关心的问题=_=. 对一个
volatile变量的写操作就类似于释放一个监视器(
monitor),对一个
volatile`变量的读就类似于申请获得一个监视器('monitor')。
比如说:
classVolatileExample{
int x =0;
volatileboolean v =false;
publicvoid writer(){
x =42;
v =true;
}
publicvoid reader(){
if(v ==true){
//x可以保证是42
}
}
}
考虑一个线程调用write方法一个线程调用reader方法,write方法写42到主内存中并释放监视器, read方法申请监视器并从主内存中读取42.
从上面的介绍可以看出来volatile
的作用很类似于synchronization
,都可以保证获取到最新值, 所以对于volatile
变量的读写都具有原子性,但是必须注意的是x++这种复合操作或者多个volatile
操作是不具有原子性的,也就是结果不定的。