Browsed by
标签:内存模型

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
274 views
Is Java “pass-by-reference” or “pass-by-value”?

Is Java “pass-by-reference” or “pass-by-value”?

结果让人有些意外,到底是 引用传递 还是 值传递?

public class DogTest {

public static void main( String[] args ) {
Dog aDog = new Dog(“Max”);
// we pass the object to foo
foo(aDog);
// aDog variable is still pointing to the “Max” dog when foo(…) returns
if (aDog.getName().equals(“Max”)) { System.out.println(“11=”+aDog);} // true, java passes by value
if (aDog.getName().equals(“Fifi”)) { System.out.println(“22=”+aDog);} // false
}

public static void foo(Dog d) {
d.getName().equals(“Max”); // true
// change d inside of foo() to point to a new Dog instance “Fifi”
d = new Dog(“Fifi”);
if (d.getName().equals(“Fifi”)) { System.out.println(“33=”+d); } // true
}
}

class Dog {
String name;
public Dog(String s ){
name =s ;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}

@Override
public String toString() {
// TODO Auto-generated method stub
return String.format(” Dog[name=%s]”, name);
}
}

来源: methods – Is Java “pass-by-reference” or “pass-by-value”? – Stack Overflow

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
181 views
Memory Overcommit in Linux

Memory Overcommit in Linux

理解Linux的memory overcommit

Memory Overcommit的意思是操作系统承诺给进程的内存大小超过了实际可用的内存。一个保守的操作系统不会允许memory overcommit,有多少就分配多少,再申请就没有了,这其实有些浪费内存,因为进程实际使用到的内存往往比申请的内存要少,比如某个进程malloc()了200MB内存,但实际上只用到了100MB,按照UNIX/Linux的算法,物理内存页的分配发生在使用的瞬间,而不是在申请的瞬间,也就是说未用到的100MB内存根本就没有分配,这100MB内存就闲置了。下面这个概念很重要,是理解memory overcommit的关键:commit(或overcommit)针对的是内存申请,内存申请不等于内存分配,内存只在实际用到的时候才分配。

Linux是允许memory overcommit的,只要你来申请内存我就给你,寄希望于进程实际上用不到那么多内存,但万一用到那么多了呢?那就会发生类似“银行挤兑”的危机,现金(内存)不足了。Linux设计了一个OOM killer机制(OOM = out-of-memory)来处理这种危机:挑选一个进程出来杀死,以腾出部分内存,如果还不够就继续杀…也可通过设置内核参数 vm.panic_on_oom 使得发生OOM时自动重启系统。这都是有风险的机制,重启有可能造成业务中断,杀死进程也有可能导致业务中断,我自己的这个小网站就碰到过这种问题,参见前文。所以Linux 2.6之后允许通过内核参数 vm.overcommit_memory 禁止memory overcommit。

内核参数 vm.overcommit_memory 接受三种取值:

  • 0 – Heuristic overcommit handling. 这是缺省值,它允许overcommit,但过于明目张胆的overcommit会被拒绝,比如malloc一次性申请的内存大小就超过了系统总内存。Heuristic的意思是“试探式的”,内核利用某种算法(对该算法的详细解释请看文末)猜测你的内存申请是否合理,它认为不合理就会拒绝overcommit。
  • 1 – Always overcommit. 允许overcommit,对内存申请来者不拒。
  • 2 – Don’t overcommit. 禁止overcommit。

关于禁止overcommit (vm.overcommit_memory=2) ,需要知道的是,怎样才算是overcommit呢?kernel设有一个阈值,申请的内存总数超过这个阈值就算overcommit,在/proc/meminfo中可以看到这个阈值的大小:

CommitLimit 就是overcommit的阈值,申请的内存总数超过CommitLimit的话就算是overcommit。
这个阈值是如何计算出来的呢?它既不是物理内存的大小,也不是free memory的大小,它是通过内核参数vm.overcommit_ratio或vm.overcommit_kbytes间接设置的,公式如下:
【CommitLimit = (Physical RAM * vm.overcommit_ratio / 100) + Swap】

注:
vm.overcommit_ratio 是内核参数,缺省值是50,表示物理内存的50%。如果你不想使用比率,也可以直接指定内存的字节数大小,通过另一个内核参数 vm.overcommit_kbytes 即可;
如果使用了huge pages,那么需要从物理内存中减去,公式变成:
CommitLimit = ([total RAM] – [total huge TLB RAM]) * vm.overcommit_ratio / 100 + swap
参见https://access.redhat.com/solutions/665023

/proc/meminfo中的 Committed_AS 表示所有进程已经申请的内存总大小,(注意是已经申请的,不是已经分配的),如果 Committed_AS 超过 CommitLimit 就表示发生了 overcommit,超出越多表示 overcommit 越严重。Committed_AS 的含义换一种说法就是,如果要绝对保证不发生OOM (out of memory) 需要多少物理内存。

“sar -r”是查看内存使用状况的常用工具,它的输出结果中有两个与overcommit有关,kbcommit 和 %commit:
kbcommit对应/proc/meminfo中的 Committed_AS;
%commit的计算公式并没有采用 CommitLimit作分母,而是Committed_AS/(MemTotal+SwapTotal),意思是_内存申请_占_物理内存与交换区之和_的百分比。

附:对Heuristic overcommit算法的解释

内核参数 vm.overcommit_memory 的值0,1,2对应的源代码如下,其中heuristic overcommit对应的是OVERCOMMIT_GUESS:

Heuristic overcommit算法在以下函数中实现,基本上可以这么理解:
单次申请的内存大小不能超过 【free memory + free swap + pagecache的大小 + SLAB中可回收的部分】,否则本次申请就会失败。

 

参考:
https://www.kernel.org/doc/Documentation/vm/overcommit-accounting
https://www.win.tue.nl/~aeb/linux/lk/lk-9.html
https://www.kernel.org/doc/Documentation/sysctl/vm.txt
http://lwn.net/Articles/28345/

from:

理解Linux的memory overcommit

-end-

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
360 views
漫 谈 雪 崩

漫 谈 雪 崩

雪崩是指平时正常调用和被调用的A系统和B系统,突然A系统对B系统的访问超出B系统的承受能力,造成B系统崩溃。
注意将雪崩效应与拒绝服务攻击相区别:两者都是因为B系统过载导致崩溃,但后者是人为的蓄意攻击。
注意将雪崩效应与访问量激增区别:如果A系统直接面对用户,那么激增的用户将直接带来A系统和B系统的流量激增,不过这种case可以通过预估而对A系统和B系统做扩容应对。

一种雪崩case

如果A系统的访问量很平稳,那么是什么造成B的访问量激增呢?造成雪崩的原因很多,本文给出一个比较典型的case。首先先来看一个被称为火柴棍的体系结构:

\

A系统依赖B系统读服务,A系统是60台机器组成的集群,B系统是6台机器组成的集群,之所以6台机器能够扛住60台机器的访问,是因为A系统并不是每次都访问B,而是首先请求缓存,只有缓存的相应数据失效时才会请求B。
这正是cache存在的意义,它让B系统节省了大量机器,如果没有cache,B系统不得不组成60台机器的集群,如果A也同时依赖C系统呢?C系统也要60台机器,放大的流量将很快耗尽公司的资源。
然而这也不是十全十美,这个结构就像一个火柴棍顶住了上面的一辆大卡车,原因并不是火柴棍有多牛逼,而是因为大卡车上绑满了氢气球,而氢气球就是cache。我想你应该看出问题来了,如果气球破了不就傻逼了?没错,这就是雪崩的一种方式!
回到A和B的架构,造成雪崩的原因至少有下面三种:
1、B系统的前置代理发生故障或者其他原因造成B系统暂时不可用,等B系统系统服务恢复时,A系统流量将铺天盖地打过来。
2、Cache系统故障,A系统的访问将铺天盖地的打到B系统。
3、Cache故障恢复,但这时Cache为空,Cache瞬间命中率为0,相当于Cache被击穿
第一个原因不太好理解,为什么B系统恢复后流量会猛增呢?主要原因就是缓存的超时时间。当有数据超时的时候,A系统会访问B系统,但是这时候B系统偏偏故障不可用,那么这个数据只好超时,等发现B系统恢复时,B系统发现缓存里的数据已经都超时了,都成了旧数据,这时当然所有的请求就打到了A。
也许你很快就给出解决方案:既然引入Cache会有这么多雪崩隐患,那么去掉Cache,让B系统服务能力大于或等于A系统不就得了。
这种方案确实能消除雪崩,但却不是一条正确路子!这句话解释起来有点麻烦,而是举一个栗子说明:众所周知资本主义对于封建社会是一种制度进步,但是资本主义本身也会有各种各样的新问题,这些问题在封建社会中可能并不存在,如果想解决资本主义的这些问题难道要退回到封建社会么?

雪崩的预防

Client端的方案

所谓Client端指的就是上文结构中的A系统,相对于B系统,A系统就是B系统的Client。
针对造成雪崩的三个原因:B系统故障恢复、Cache故障、Cache故障恢复,看看A系统有哪些方案可以应对。

合理使用Cache应对B系统宕机

cache的每个Key除了对应Value,还对应一个过期时间T,在T内,get操作直接在Cache中拿到Key对应Value并返回。但是在T到达时,get操作主要有五种模式:
1、基于超时的简单(stupid)模式
在T到达时,任何线程get操作发现Cache中的Key和对应Value将被清除或标记为不可用,get操作将发起调用远程服务获取key对应的Value,并更新写回Cache;
2、基于超时的常规模式
在T到达时,Cache中的Key和对应Value将被清除或标记为不可用,get操作将调用远程服务获取key对应的Value,并更新写回Cache;
此时,如果另一个线程发现key和Value已经不可用,get操作还需要判断有没有其他线程发起了远程调用,如果有,那么自己就等待,直到那个线程远程获取操作成功,Cache中得key变得可用,自己直接从Cache中获取就完事了。
为了便于理解,打个比方:5个工人(线程)去港口取同样key的货(get),发现货已经过期被扔掉了,那么只需派出一个人去对岸取货,其他四个人在港口等待即可,而不用5个人全去。而基于超时的简单模式中,就相当于5个工人去港口取同样key的货,发现没有,则5个工人都去远方港口取货,5个工人彼此完全无沟通,视彼此不存在。
实现基于超时的常规模式就需要用到经典的 Java Synchronized-double-check惯用法了。
3、基于刷新的简单(stupid)模式
在T到达时,Cache中的key和相应Value不动,但是如果有线程调用get操作,将触发refresh操作,根据get和refresh的同步关系,又分为两种模式:
同步模式:任何线程发现key过期,都触发一次refresh操作,get操作等待refresh操作结束,refresh结束后,get操作返回当前Cache中key对应的Value,注意refresh操作结束并不意味着refresh成功,还可能抛了异常,没有更新Cache,但是get操作不管,get操作返回的值可能是旧值。
异步模式:任何线程发现key过期,都触发一次refresh操作,get操作触发refresh操作,不等refresh完成,直接返回Cache中的旧值。

4、基于刷新的常规模式

在T到达时,Cache中的key和相应Value不动,但是如果有线程调用get操作,将触发refresh操作,根据get和refresh的同步关系,又分为两种模式:

同步模式:get操作等待refresh操作结束,refresh结束后,get操作返回当前Cache中key对应的Value,注意refresh操作结束并不意味着refresh成功,还可能抛了异常,没有更新Cache,但是get操作不管,get操作返回的值可能是旧值。如果其他线程进行get操作,key已经过期,并且发现有线程触发了refresh操作,则自己不等refresh完成直接返回旧值。异步模式:get操作触发refresh操作,不等refresh完成,直接返回Cache中的旧值。那么显然,如果其他线程进行get操作,key已经过期,并且发现有线程触发了refresh操作,则自己不等refresh完成直接返回旧值。

再举上面码头工人的例子说明基于刷新:这次还是5工人去港口取货,这时货都在,虽然已经旧了,这时5个工人有两种选择:

派一个人去远方港口取新货,其余哥四个拿着旧货先回(同步模式);

通知一个雇佣工去远方取新货,哥五个都拿着旧货先回(异步模式);

基于刷新的简单模式和基于刷新的常规模式的区别可以参考基于超时的简单模式和基于超时的常规模式的区别,不再赘述。

5、基于刷新的续费模式

该模式和基于刷新的常规模式唯一的区别在于refresh操作超时或失败的处理上。在基于刷新的常规模式中,refresh操作超时或失败时抛出异常,Cache中的相应key-Value还是旧值,这样下一个get操作到来时又会触发一次refresh操作。

在基于刷新的续费模式中,如果refresh操作失败,那么refresh将把旧值当成新值返回,这样就相当于旧值又被续费了T时间,后续T时间内get操作将取到这个续费的旧值而不会触发refresh操作。

基于刷新的续费模式也像常规模式那样分为同步模式和异步模式,不再赘述。

下面讨论这5种cache get模式在雪崩发生时的表现,首先假设如下:

假设A系统的访问量为每分钟M次
假设cache能存Key为C个,并且key空间有N个
假设正常状态下,B系统访问量为每分钟w次,显然w=未命中数+过期数这时因为某种原因,比如B长时间故障,造成cache中得key全部过期,B系统这时从故障中恢复,五种get模式分析表现分析如下:

 

1、在基于超时和刷新的简单模式中,B系统的瞬间流量将达到和A的瞬时流量M大体等同,相当于cache被击穿。这就发生了雪崩,这时刚刚恢复的B系统将肯定会被拍死。

2、在基于超时和刷新的常规模式中,B系统的瞬间流量将和cache中key空间N大体等同。这时是否发生雪崩,B系统能否扛得住就要看key空间N是否超过B系统的流量上限了。

3、在基于刷新的续费模式中,B系统的瞬间流量为w,和正常情况相同而不会发生雪崩!实际上,在基于刷新的续费模式中,不存在cache key全部过期的情况,就算把B系统永久性的干掉,A系统的cache也会基于旧值长久的平稳运行!

从B系统的角度看,能够抵抗雪崩的基于刷新的续费模式完胜。

从A系统的角度看,由于一般情况下A系统是一个高访问量的在线web应用,这种应用最讨厌的一个词就是“线程等待”,因此基于刷新的各种异步模式较优。

综合考虑,基于刷新的异步续费模式是首选。

然而凡是有利就有弊,有两点需要注意的地方:

1、基于刷新的模式最大的缺点是key-Value一旦放入cache就不会被清除,每次更新也是新值覆盖旧值,GC永远无法对其进行垃圾收集。而基于超时的模式中,key-Value超时后如果新的访问没有到来,内存是可以被GC垃圾回收的。所以如果你使用的是寸土寸金的本地内存做cache就要小心了。

2、基于刷新的续费模式需要做好监控,不然有可能发生cache中得值已经和真实的值相差很远了,应用还以为是新值,而你也不知道。

关于具体的cache,来自Google的Guava本地缓存库支持上文的第二种、第四种和第五种get操作模式。

但是对于Redis等分布式缓存,只提供原始的get、set方法,而提供的get仅仅是获取,与上文提到的五种get操作模式不是一个概念。开发者想用这五种get操作模式的话不得不自己封装和实现。

五种get操作模式中,基于超时和刷新的简单模式是实现起来最简单的模式,但遗憾的是这两种模式对雪崩完全无免疫力,这可能也是雪崩在大量依赖缓存的系统中频繁发生的一个重要原因吧。

应对分布式cache宕机

如果是Cache直接挂了,那么上面的基于刷新的异步续费模式也就然并卵了。这时A系统铁定无法对Cache进行存取操作,只能将流量完全打到B系统,B系统面对雪崩在劫难逃…

本节讨论的预防Cache宕机仅限于分布式Cache,因为本地Cache一般和A系统应用共享内存和进程,本地Cache挂了A系统也挂了,不会出现本地Cache挂了而A系统应用正常的情况。

首先,A系统请求线程检查分布式cache状态,如果无应答等说明分布式Cache挂了,则转向请求B系统,这样一来雪崩将压垮B系统。这时可选的方案如下:

1、A系统的当前线程不请求B系统,而是打个日志并设置一个默认值。

2、A系统的当前线程按照一定概率决定是否请求B系统。

3、A系统的当前线程检查B系统运行情况,如果良好则请求B系统。

方案1最简单,A系统知道如果没有Cache,B系统可能扛不住自己的全部流量,索性不请求B系统,等待Cache恢复。但这时B系统利用率为0,显然不是最优方案,而且当请求不容易设置默认值时,这个方案就不行了。

方案2可以让一部分线程请求B系统,这部分请求肯定能被B系统hold住。可以保守的设置这个概率u=B系统的平均流量/A系统的峰值流量

方案3是一种更为智能的方案,如果B系统运行良好,当前线程请求,如果B系统过载,则不请求,这样A系统将让B系统处于一种宕机与不宕机的临界状态,最大限度挖掘B系统性能。这种方案要求B系统提供一个性能评估接口返回yes和no,yes表示B系统良好,可以请求;no表示B系统情况不妙,不要请求。这个接口将被频繁调用,必须高效。

方案3的关键在于如何评估一个系统的运行状况。一个系统中当前主机的性能参数有cpu负载、内存使用率、swap使用率、gc频率和gc时间、各个接口平均响应时间等,性能评估接口需要根据这些参数返回yes或者no,是不是机器学习里的二分类问题?关于这个问题已经可以单独写篇文章讨论了,再这里就不展开了,你可以想一个比较简单傻瓜的保守策略,结果无非是让A系统无法很好的逼近B系统的性能极限。

综合以上分析,方案2比较靠谱。如果选择方案3,建议由专门团队负责研究并提供统一的系统性能实时评估方案和工具。

应对分布式cache宕机后的恢复

不要以为成功hold住分布式cache宕机就万事大吉了,真正的考验是分布式Cache从宕机过程恢复之后,这时分布式Cache中什么都没有。

即使是上文中提到了基于刷新的异步续费策略这时也没有什么卵用,因为分布式Cache为空,无论如何都要请求B系统。这时B系统的最大流量是Key的空间取值数量。

如果Key的取值空间数量很少,则相安无事;如果Key的取值空间数量大于B系统的流量上限,雪崩依然在所难免。

这种情况A系统很难处理,关键原因是A系统请求cache返回key对应value为空,A系统无法知道是因为当前Cache是刚刚初始化,所有内容都为空;还是因为仅仅是自己请求的那个key没在Cache里。

如果是前者,那么当前线程就要像处理cache宕机那样进行某种策略的回避;如果是后者,直接请求B系统即可,因为这是正常的cache使用流程。

对于cache宕机的恢复,A系统真的无能为力,只能寄希望于B系统的方案了。

Server端的方案

相当于client端需要应对各种复杂问题,Server端需要应对的问题非常简单,就是如何从容应对过载的问题。无论是雪崩也要,还是拒绝服务攻击也罢,对于Server端来说都是过载保护的问题。对于过载保护,主要有两种现实方案和一种超现实方案。

流量控制

流量控制就是B系统实时监控当前流量,如果超过预设的值或者系统承受能力,则直接拒绝掉一部分请求,以实现对系统的保护。

流量控制根据基于的数据不同,可分为两种:

1、基于流量阈值的流控:流量阈值是每个主机的流量上限,流量超过该阈值主机将进入不稳定状态。阈值提前进行设定,如果主机当前流量超过阈值,则拒绝掉一部分流量,使得实际被处理流量始终低于阈值。

2、基于主机状态的流控:每个接受每个请求之前先判断当前主机状态,如果主机状况不佳,则拒绝当前请求。

基于阈值的流控实现简单,但是最大的问题是需要提前设置阈值,而且随着业务逻辑越来越复杂,接口越来越多,主机的服务能力实际应该是下降的,这样就需要不断下调阈值,增加了维护成本,而且万一忘记调整的话,呵呵……

主机的阈值可以通过压力测试确定,选择的时候可以保守些。

基于主机状态的流控免去了人为控制,但是其最大的确定上文已经提到:如何根据当前主机各个参数判断主机状态呢?想较完美的回答这个问题目测并不容易,因此在没有太好答案之前,我推荐基于阈值的流控。

流量控制基于实现位置的不同,又可以分为两种:

1、反向代理实现流控:在反向代理如Nginx上基于各种策略进行流量控制。这种一般针对Http服务。

2、借助服务治理系统:如果Server端是RMI、RPC等服务,可以构建专门的服务治理系统进行负载均衡、流控等服务。

3、服务容器实现流控:在业务逻辑之前实现流量控制。

第三种在服务器的容器(如 Java容器)中实现流控并不推荐,因为流控和业务代码混在一起容易乱;其次实际上流量已经全量进入到了业务代码里,这时的流控只是阻止其进入真正的业务逻辑,所以流控效果将打折;再次,如果流量策略经常变动,系统将不得不为此经常更改。

因此,推荐前两种方式。

最后提一个注意点:当因为流控而拒绝请求时,务必在返回的数据中带上相关信息(比如“当前请求因为超出流量而被禁止访问”),如果返回值什么都没有将是一个大坑。因为造成调用方请求没有被响应的原因很多,可能是调用方Bug,也可能是服务方Bug,还可能是网络不稳定,这样一来很可能在排查一整天后发现是流控搞的鬼。。。

服务降级

服务降级一般由人为触发,属于雪崩恢复时的策略,但为了和流控对比,将其放到这里。

流量控制本质上是减小访问量,而服务处理能力不变;而服务降级本质上是增强服务处理能力,而访问量不变。

服务降级是指在服务过载时关闭不重要的接口(直接拒绝处理请求),而保留重要的接口。比如服务由10个接口,服务降级时关闭了其中五个,保留五个,这时这个主机的服务处理能力将增强到二倍左右。

然而,雪崩到来时动辄就超出系统处理能力10倍,而服务降级能使主机服务处理能力提高10倍么?显然很困难,看来服务降级并不能代替流控成为过载保护的主要策略。

动态扩展

动态扩展指的是在流量超过系统服务能力时,自动触发集群扩容,自动部署并上线运行;当流量过去后又自动回收多余机器,完全弹性。

这个方案是不是感觉很不错。但是就算是前两年云计算泡沫吹的最天花乱坠的时候也没见哪个国内大公司真正用起来。据说亚马逊、谷歌可以,但是天朝不让用,自研的话还是任重道远呐!

雪崩的恢复

雪崩发生时需要运维控制流量,等后台系统启动完毕后循序渐进的放开流量,主要目的是让Cache慢慢预热。流量控制刚开始可以为10%,然后20%,然后50%,然后80%,最后全量,当然具体的比例,尤其是初始比例,还要看后端承受能力和前端流量的比例,各个系统并不相同。

如果后端系统有专门的工具进行Cache预热,则省去了运维的工作,等Cache热起来再发布后台系统即可。但是如果Cache中的key空间很大,开发预热工具将比较困难。

结论

“防患于未然”放在雪崩的应对上也适合,预防为主,补救为辅。综合上文分析,具体的预防要点如下:

1、调用方(A系统)采用基于刷新的异步续费模式使用Cache,或者至少不能使用基于超时或刷新的简单(stupid)模式。

2、调用方(A系统)每次请求Cache时检查Cache是否可用(available),如果不可用则按照一个保守的概率访问后端,而不是无所顾忌的直接访问后端。

3、服务方(B系统)在反向代理处设置流量控制进行过载保护,阈值需要通过压测获得。

from: http://www.2cto.com/os/201508/433330.html

–eof–

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
137 views
深入理解Java内存模型(七)——总结

深入理解Java内存模型(七)——总结

FROM:http://www.infoq.com/cn/articles/ java-memory-model-7

处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  1. 放松程序中写-读操作的顺序,由此产生了total store ordering内存模型(简称为TSO)。
  2. 在前面1的基础上,继续放松程序中写-写操作的顺序,由此产生了partial store order 内存模型(简称为PSO)。
  3. 在前面1和2的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了relaxed memory order内存模型(简称为RMO)和PowerPC内存模型。

注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称

对应的处理器

Store-Load 重排序 Store-Store重排序 Load-Load 和Load-Store重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO

X64

Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写-读重排序,原因在第一章以说明过:它们都使用了写缓存区,写缓存区可能导致写-读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。

上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

由于常见的处理器内存模型比JMM要弱, java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了JMM在不同处理器内存模型中需要插入的内存屏障的示意图:

如上图所示,JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java程序员呈现了一个一致的内存模型。

JMM,处理器内存模型与顺序一致性内存模型之间的关系

JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

从上图我们可以看出:常见的4种处理器内存模型比常用的3中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。

JMM的设计

从JMM设计者的角度来说,在设计JMM时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。下面让我们看看JSR-133是如何实现这一目标的。

为了具体说明,请看前面提到过的计算圆面积的示例代码:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在三个happens- before关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

由于A happens- before B,happens- before的定义会要求:A操作执行的结果要对B可见,且A操作的执行顺序排在B操作之前。 但是从程序语义的角度来说,对A和B做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这3个happens- before关系中,虽然2和3是必需要的,但1是不必要的。因此,JMM把happens- before要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不作要求(JMM允许这种重排序)。

下面是JMM的设计示意图:

从上图可以看出两点:

  • JMM向程序员提供的happens- before规则能满足程序员的需求。JMM的happens- before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的A happens- before B)。
  • JMM对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个volatile变量仅仅只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

JMM的内存可见性保证

Java程序的内存可见性保证按程序类型可以分为下列三类:

  1. 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  2. 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  3. 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在JMM中与在顺序一致性内存模型中的执行结果的异同:

只要多线程程序是正确同步的,JMM保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

JSR-133对旧内存模型的修补

JSR-133对JDK5之前的旧内存模型的修补主要有两个:

  • 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
  • 增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。现在,final具有了初始化安全性。

参考文献

  1. Computer Architecture: A Quantitative Approach, 4th Edition
  2. Shared memory consistency models: A tutorial
  3. Intel® Itanium® Architecture Software Developer’s Manual Volume 2: System Architecture
  4. Concurrent Programming on Windows
  5. JSR 133 ( Java Memory Model) FAQ
  6. The JSR-133 Cookbook for Compiler Writers
  7. Java theory and practice: Fixing the Java Memory Model, Part 2

–EOF–

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
155 views
深入理解Java内存模型(六)——final

深入理解Java内存模型(六)——final

FROM:http://www.infoq.com/cn/articles/ java-memory-model-6

与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个重排序规则:

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

下面,我们通过一些示例性的代码来分别说明这两个规则:

public class FinalExample {
    int i;                            //普通变量
    final int j;                      //final变量
    static FinalExample obj;

    public void FinalExample () {     //构造函数
        i = 1;                        //写普通域
        j = 2;                        //写final域
    }

    public static void writer () {    //写线程A执行
        obj = new FinalExample ();
    }

    public static void reader () {       //读线程B执行
        FinalExample object = obj;       //读对象引用
        int a = object.i;                //读普通域
        int b = object.j;                //读final域
    }
}

这里假设一个线程A执行writer ()方法,随后另一个线程B执行reader ()方法。下面我们通过这两个线程的交互来说明这两个规则。

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外。
  • 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。

现在让我们分析writer ()方法。writer ()方法只包含一行代码:finalExample = new FinalExample ()。这行代码包含两个步骤:

  1. 构造一个FinalExample类型的对象;
  2. 把这个对象的引用赋值给引用变量obj。

假设线程B读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序:

在上图中,写普通域的操作被编译器重排序到了构造函数之外,读线程B错误的读取了普通变量i初始化之前的值。而写final域的操作,被写final域的重排序规则“限定”在了构造函数之内,读线程B正确的读取了final变量初始化之后的值。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程B“看到”对象引用obj时,很可能obj对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值2还没有写入普通域i)。

读final域的重排序规则

读final域的重排序规则如下:

  • 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器。

reader()方法包含三个操作:

  1. 初次读引用变量obj;
  2. 初次读引用变量obj指向对象的普通域j。
  3. 初次读引用变量obj指向对象的final域i。

现在我们假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下面是一种可能的执行时序:

在上图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

如果final域是引用类型

上面我们看到的final域是基础数据类型,下面让我们看看如果final域是引用类型,将会有什么效果?

请看下列示例代码:

public class FinalReferenceExample {
final int[] intArray;                     //final是引用类型
static FinalReferenceExample obj;

public FinalReferenceExample () {        //构造函数
    intArray = new int[1];              //1
    intArray[0] = 1;                   //2
}

public static void writerOne () {          //写线程A执行
    obj = new FinalReferenceExample ();  //3
}

public static void writerTwo () {          //写线程B执行
    obj.intArray[0] = 2;                 //4
}

public static void reader () {              //读线程C执行
    if (obj != null) {                    //5
        int temp1 = obj.intArray[0];       //6
    }
}
}

这里final域为一个引用类型,它引用一个int型的数组对象。对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:

  1. 在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

对上面的示例程序,我们假设首先线程A执行writerOne()方法,执行完后线程B执行writerTwo()方法,执行完后线程C执行reader ()方法。下面是一种可能的线程执行时序:

在上图中,1是对final域的写入,2是对这个final域引用的对象的成员域的写入,3是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的1不能和3重排序外,2和3也不能重排序。

JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看的到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。

为什么final引用不能从构造函数内“逸出”

前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面示例代码:

public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;

public FinalReferenceEscapeExample () {
    i = 1;                              //1写final域
    obj = this;                          //2 this引用在此“逸出”
}

public static void writer() {
    new FinalReferenceEscapeExample ();
}

public static void reader {
    if (obj != null) {                     //3
        int temp = obj.i;                 //4
    }
}
}

假设一个线程A执行writer()方法,另一个线程B执行reader()方法。这里的操作2使得对象还未完成构造前就为线程B可见。即使这里的操作2是构造函数的最后一步,且即使在程序中操作2排在操作1后面,执行read()方法的线程仍然可能无法看到final域被初始化后的值,因为这里的操作1和操作2之间可能被重排序。实际的执行时序可能如下图所示:

从上图我们可以看出:在构造函数返回前,被构造对象的引用不能为其他线程可见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值。

final语义在处理器中的实现

现在我们以x86处理器为例,说明final语义在处理器中的具体实现。

上面我们提到,写final域的重排序规则会要求译编器在final域的写之后,构造函数return之前,插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。

由于x86处理器不会对写-写操作做重排序,所以在x86处理器中,写final域需要的StoreStore障屏会被省略掉。同样,由于x86处理器不会对存在间接依赖关系的操作做重排序,所以在x86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说在x86处理器中,final域的读/写不会插入任何内存屏障!

JSR-133为什么要增强final的语义

在旧的 Java内存模型中 ,最严重的一个缺陷就是线程可能看到final域的值会改变。比如,一个线程当前看到一个整形final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final域的值时,却发现值变为了1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java内存模型中,String的值可能会改变(参考文献2中有一个具体的例子,感兴趣的读者可以自行参考,这里就不赘述了)。

为了修补这个漏洞,JSR-133专家组增强了final的语义。通过为final域增加写和读重排序规则,可以为 java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用),就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。

参考文献

  1.   Java Concurrency in Practice
  2.  JSR 133 ( Java Memory Model) FAQ
  3.   Java Concurrency in Practice
  4.  The JSR-133 Cookbook for Compiler Writers

Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1

–EOF–

 

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
405 views
深入理解Java内存模型(五)——锁

深入理解Java内存模型(五)——锁

FROM:http://www.infoq.com/cn/articles/ java-memory-model-5

锁的释放-获取建立的happens before 关系

锁是 java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

下面是锁释放-获取的示例代码:

class MonitorExample {
    int a = 0;

    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3

    public synchronized void reader() {  //4
        int i = a;                       //5
        ……
    }                                    //6
}
好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
99 views
深入理解Java内存模型(四)——volatile

深入理解Java内存模型(四)——volatile

FROM:http://www.infoq.com/cn/articles/ java-memory-model-4

volatile的特性

当我们声明共享变量为volatile后,对这个变量的读/写将会很特别。理解volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。下面我们通过具体的示例来说明,请看下面的示例代码:

class VolatileFeaturesExample {
    volatile long vl = 0L;  //使用volatile声明64位的long型变量

    public void set(long l) {
        vl = l;   //单个volatile变量的写
    }

    public void getAndIncrement () {
        vl++;    //复合(多个)volatile变量的读/写
    }


    public long get() {
        return vl;   //单个volatile变量的读
    }
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

class VolatileFeaturesExample {
    long vl = 0L;               // 64位的long型普通变量

    public synchronized void set(long l) {     //对单个的普通 变量的写用同一个监视器同步
        vl = l;
    }

    public void getAndIncrement () { //普通方法调用
        long temp = get();           //调用已同步的读方法
        temp += 1L;                  //普通写操作
        set(temp);                   //调用已同步的写方法
    }
    public synchronized long get() { 
    //对单个的普通变量的读用同一个监视器同步
        return vl;
    }
}

如上面示例程序所示,对一个volatile变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个监视器锁来同步,它们之间的执行效果相同。

监视器锁的happens-before规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

监视器锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性:

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

volatile写-读建立的happens before关系

上面讲的是volatile变量自身的特性,对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要,也更需要我们去关注。

从JSR-133开始,volatile变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile与监视器锁有相同的效果:volatile写和监视器的释放有相同的内存语义;volatile读与监视器的获取有相同的内存语义。

请看下面使用volatile变量的示例代码:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;           //4
            ……
        }
    }
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens before规则,这个过程建立的happens before 关系可以分为两类:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据volatile规则,2 happens before 3。
  3. 根据happens before 的传递性规则,1 happens before 4。

上述happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens before保证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

volatile写-读的内存语义

volatile写的内存语义如下:

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。

以上面示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。下图是线程A执行volatile写后,共享变量的状态示意图:

如上图所示,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义如下:

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下面是线程B读同一个volatile变量后,共享变量的状态示意图:

如上图所示,在读flag变量后,本地内存B已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值也变成一致的了。

如果我们把volatile写和volatile读这两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

下面对volatile写和volatile读的内存语义做个总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所在修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile内存语义的实现

下面,让我们来看看JMM如何实现volatile写/读的内存语义。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile重排序规则表:

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从上表我们可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

这里比较有意思的是volatile写后面的StoreLoad屏障。这个屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;           //第一个volatile读
        int j = v2;           // 第二个volatile读
        a = i + j;            //普通写
        v1 = i + 1;          // 第一个volatile写
        v2 = j * 2;          //第二个 volatile写
    }

    …                    //其他方法
}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:

注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。

前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:

前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧 Java内存模型中,虽然不允许volatile变量之间重排序,但旧的 Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。

参考文献

  1. Concurrent Programming in Java™: Design Principles and Pattern
  2. JSR 133 ( Java Memory Model) FAQ
  3. JSR-133: Java Memory Model and Thread Specification
  4. The JSR-133 Cookbook for Compiler Writers
  5. Java 理论与实践: 正确使用 Volatile 变量
  6. Java theory and practice: Fixing the Java Memory Model, Part 2

–EOF–

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
444 views
深入理解Java内存模型(三)——顺序一致性

深入理解Java内存模型(三)——顺序一致性

from:http://www.infoq.com/cn/articles/ java-memory-model-3

作者 程晓明 发布于 2013年1月29日 | 欲知区块链、VR、TensorFlow等潮流技术和框架,请锁定QCon北京站!26 讨论

数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。 java内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量,
  • 在另一个线程读同一个变量,
  • 而且写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如下:

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。

假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

同步程序的顺序一致性效果

下面我们对前面的示例程序ReorderExample用监视器来同步,看看正确同步的程序如何具有顺序一致性。

请看下面的示例代码:

class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
    a = 1;
    flag = true;
}

public synchronized void reader() {
    if (flag) {
        int i = a;
        ……
    }
}
}

上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到JMM在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

和顺序一致性模型一样,未同步程序在JMM中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
  3. JMM不保证对64位的long型和double型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

第3个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和I/O设备执行内存的读/写。下面让我们通过一个示意图来说明总线的工作机制:

如上图所示,假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其它两个处理器则要等待处理器A的总线事务完成后才能开始再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

在一些32位的处理器上,如果要求对64位数据的读/写操作具有原子性,会有比较大的开销。为了照顾这种处理器, java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的读/写具有原子性。当JVM在这种处理器上运行时,会把一个64位long/ double型变量的读/写操作拆分为两个32位的读/写操作来执行。这两个32位的读/写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的读/写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

如上图所示,假设处理器A写一个long型变量,同时处理器B要读这个long型变量。处理器A中64位的写操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的写事务中执行。同时处理器B中64位的读操作被拆分为两个32位的读操作,且这两个32位的读操作被分配到同一个的读事务中执行。当处理器A和B按上图的时序来执行时,处理器B将看到仅仅被处理器A“写了一半“的无效值。

参考文献

  1. JSR-133: Java Memory Model and Thread Specification
  2. Shared memory consistency models: A tutorial
  3. The JSR-133 Cookbook for Compiler Writers
  4. 深入理解计算机系统(原书第2版)
  5. UNIX Systems for Modern Architectures: Symmetric Multiprocessing and Caching for Kernel Programmers
  6. The Java Language Specification, Third Edition

–EOF–

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
106 views
深入理解Java内存模型(二)——重排序

深入理解Java内存模型(二)——重排序

from:http://www.infoq.com/cn/articles/ java-memory-model-2

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

程序顺序规则

根据happens- before的程序顺序规则,上面计算圆的面积的示例代码存在三个happens- before关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

这里的第3个happens- before关系,是根据happens- before的传递性推导出来的。

这里A happens- before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从happens- before的定义我们可以看出,JMM同样遵从这一目标。

重排序对多线程的影响

现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
    a = 1;                   //1
    flag = true;             //2
}

Public void reader() {
    if (flag) {                //3
        int i =  a * a;        //4
        ……
    }
}
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:

如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。

下面再让我们看看,当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序的执行时序图:

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

参考文献

  1. Computer Architecture: A Quantitative Approach, 4th Edition
  2. Concurrent Programming on Windows
  3. Concurrent Programming in Java™: Design Principles and Pattern
  4. JSR-133: Java Memory Model and Thread Specification
  5. JSR 133 ( Java Memory Model) FAQ

–EOF–

好烂啊有点差凑合看看还不错很精彩 (No Ratings Yet)
Loading...
409 views
深入理解Java内存模型(一)——基础

深入理解Java内存模型(一)——基础

from: http://www.infoq.com/cn/articles/ java-memory-model-1

深入理解 Java内存模型(一)——基础

并发编程模型的分类

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型, Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java内存模型的抽象

java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数( java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由 Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。 Java内存模型的抽象示意图如下:

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为 java程序员提供内存可见性保证。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求 java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面示例:

Processor A Processor B
a = 1; //A1
x = b; //A2
b = 2; //B1
y = a; //B2
初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0

假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果。具体的原因如下图所示:

这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x = y = 0的结果。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。

下面是常见处理器允许的重排序类型的列表:

Load-Load Load-Store Store-Store Store-Load 数据依赖
sparc-TSO N N N Y N
x86 N N N Y N
ia64 Y Y Y Y N
PowerPC Y Y Y Y N

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

从上表我们可以看出:常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和x86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。

※注1:sparc-TSO是指以TSO(Total Store Order)内存模型运行时,sparc处理器的特性。

※注2:上表中的x86包括x64及AMD64。

※注3:由于ARM处理器的内存模型与PowerPC处理器的内存模型非常类似,本文将忽略它。

※注4:数据依赖性后文会专门说明。

为了保证内存可见性, java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

happens-before

从JDK5开始, java使用新的JSR -133内存模型(本文除非特别说明,针对的都是JSR- 133内存模型)。JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
  • 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。

注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before的定义很微妙,后文会具体说明happens-before为什么要这么定义。

happens-before与JMM的关系如下图所示:

如上图所示,一个happens-before规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java程序员来说,happens-before规则简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

参考文献

  1. Programming Language Pragmatics, Third Edition
  2. The Java Language Specification, Third Edition
  3. JSR-133: Java Memory Model and Thread Specification
  4. Java theory and practice: Fixing the Java Memory Model, Part 2
  5. Understanding POWER Multiprocessors
  6. Concurrent Programming on Windows
  7. The Art of Multiprocessor Programming
  8. Intel® 64 and IA-32 ArchitecturesvSoftware Developer’s Manual Volume 3A: System Programming Guide, Part 1
  9. Java Concurrency in Practice
  10. The JSR-133 Cookbook for Compiler Writers

–EOF–

跳至工具栏