注册 登录  
 加关注
   显示下一条  |  关闭
温馨提示!由于新浪微博认证机制调整,您的新浪微博帐号绑定已过期,请重新绑定!立即重新绑定新浪微博》  |  关闭

网易杭研后台技术中心的博客

 
 
 
 
 

日志

 
 

Java的同步机制在可见性方面的内存语义及实现  

来自郭忆   2014-09-24 15:14:10|  分类: 默认分类 |举报 |字号 订阅

  下载LOFTER 我的照片书  |
在并发编程中,同步机制是确保多线程正确执行的关键。Java中的同步机制主要有两方面的作用:一则是原子性,一则是可见性。原子性较容易理解,即保证临界区的代码多线程串行执行。但是可见性,涉及Java的内存模型,较为复杂。最近深入研究和整理了Java中涉及可见性相关的几种同步方法的内存语义和实现方式。
可见性的问题根源在于Java的内存模型(JMM)。按照JMM设计,Java中所有的对象都是存储在堆中的,堆内存被所有的线程共享。每个线程都包含共享内存变量的本地副本,每个线程都只能对变量的本地副本进行操作,然后与共享内存通信。每个线程对共享内存的读写之间的同步,由JMM隐式决定的,即某个线程对某个共享变量的更新何时对其他线程可见是由JMM控制的。
Java的同步机制在可见性方面的内存语义及实现 - 网易杭研后台技术中心 - 网易杭研后台技术中心的博客
这里的本地内存是JMM定义的一个抽象概念,主要包括寄存器、高速缓存等,CPU对本地内存的操作速度远远高于主内存,这样就为代码在处理器方面的优化提供了空间。我们首先看下面的一个示例程序:
public class fun{
   int a = 0;
   int b = 0;
   public void x(){
     a = 5;  //1
     int i = b;  //2
   }
   public void y(){
     b = 5; //3
     int j = a;  //4
  }
}
如果一个线程执行x函数,另外一个线程执行y函数,我们惊奇的发现,得到的结果竟然是i=j=0。其实在没有锁保护的情况下,i = 0, j = 5 或者 i = 5 , j =0 或者i = j = 5 都是不难理解。但是i = j = 0确实让人有些匪夷所思。但是我们再回顾一下JMM的设计,就不难理解了。首先这里a和b是对象实例变量,是存储在共享堆内存中的,i和j是局部变量,为线程独享。
Java的同步机制在可见性方面的内存语义及实现 - 网易杭研后台技术中心 - 网易杭研后台技术中心的博客
假设两个线程A\B同时在两个处理器并行执行,A处理器首先要完成1操作,即完成共享变量a的赋值,它更新共享变量a的本地副本。鉴于写共享内存的开销较大,处理器对执行过程进行了优化,即先执行了2操作,对共享变量b进行读取,得到b = 0;最后将写缓冲区中的a刷入堆内存,相同的执行过程在B线程一样执行。所以实际上我们发现2操作先于1操作完成,发生了重排序,而这种重排序是由于处理器的独享写缓冲引入的处理器优化,但是对实际的执行结果却造成了影响。
Java的同步机制在可见性方面的内存语义及实现 - 网易杭研后台技术中心 - 网易杭研后台技术中心的博客
 
我们知道,Java是一个跨平台的语言,编译器将程序员编写的Java源代码编译成可以在JVM上运行的字节码。我们可以说,重排序是由编译器、处理器底层发起的系统优化需求,但是这种底层优化会破坏上层用户的语义,造成执行结果与编程人员预期不一致。为了防止重排序,系统提供了内存屏障指令,不同的处理器提供了不同的内存屏障,总的来说,一共有4种内存屏障类型:
 内存屏障指令序列 说明 
LoadLoad Load1,Loadload,Load2 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。
StoreStore Store1,StoreStore,Store2 确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。
LoadStore  Load1,LoadStore,Store2确保Load1的数据在Store2和后续Store指令被刷新之前读取。
StoreLoad Store1,StoreLoad,Load2 确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。
下面是常见处理器允许的重排序类型的列表:
  Load-Load Load-Store Store-StoreStore-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
    数据依赖指的是,在同一个线程中,如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性,存在数据依赖的操作无论是编译器和处理器都不允许进行重排序,因为编译器和处理器做重排序需要遵循as-if-serial语义。它是指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。所以上个表中所有的处理器都不支持数据依赖的操作进行重排序,这也是单线程程序不需要考虑可见性问题的根本原因。
编译器和处理器的优化重排序使得上层的开发人员编写正确的多线程程序变得异常困难,这就需要在程序员和底层处理器之间建立一个规则,程序员可以基于该规则提供的内存可见性保证来编写并发程序。
JMM 处于用户和底层处理器之间,它既要考虑底层优化的需求,同时也要提供编程人员一定的控制权,保证正确的语义。JMM提供了happerns-before规则,程序员只要根据该规则编写程序,JMM就可以保证程序在多线程下的正确执行。
Java的同步机制在可见性方面的内存语义及实现 - 网易杭研后台技术中心 - 网易杭研后台技术中心的博客
 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仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
在单线程中,如果不存在数据依赖,交换两个操作是不会对单线程的执行结果产生影响的,同时有助于编译器和处理器更加高效的执行,所以JMM这里采取了阳奉阴违的处理方式,表面上承诺A happens before B,但是实际可能B先于A执行,因为这种改变并不会对程序的执行结果产生影响。
有了Happens-before原则,前面也介绍了系统提供的防止重排序的内存屏障,那我们就再研究一下JMM究竟是如果实现Happens-before规则的。
程序顺序规则
同一个线程中,存在数据依赖的,由于编译器和处理器本身就不支持重排序,所以无需考虑。对于不存在数据依赖的,由于重排序不会造成程序结果的不一致,对程序员没有影响,所以JMM默认是允许重排序的,但是需要注意的是,这样有可能会破坏多线程下的语义,造成执行错误,此时要考虑使用volatile或者锁来保证语义正确性。 
Volatile: 
Volatile的语义为:
读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。 
写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。 
要实现volatile的语义,我们必须要保证:
 是否能重排序 第二个操作  
 第一个操作 普通读/写 volatile读volatile写 
 普通读/写   NO
 volatile读 NO NO NO
 volatile写  NO NO
总的来说: volatile 变量的写之前的操作都不允许排到volatile之后。 volatile变量读之后的操作不允许排到volatile之前。 volatile写之后的volatile读不允许重排序。
  为了实现上述语义, JMM采用的对应的内存屏障:
 内存屏障第二步    
 第一步 Normal Load Normal Store Volatile Load Volatile Store
 Normal Load    LoadStore
 Normal Store    StoreStore
 Volatile Load LoadStore LoadStore LoadStore LoadStore
 Volatile Store   StoreLoad StoreStore
   
总的来说既是:
  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的前面插入一个LoadStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
这样就可以实现Volatile的语义。通过上述规则,我们就可以分析一段代码JMM究竟插入了哪些内存屏障来禁止编译器和处理器的重排序。

class X {
int a, b;
volatile int v, u;

void f() {
int i, j;
i = a;// load a
j = b;// load b
i = v;// load v
// LoadLoad
j = u;// load u
// LoadStore
a = i;// store a
b = j;// store b
// StoreStore
v = i;// store v
// StoreStore
u = j;// store u
// StoreLoad
i = u;// load u
// LoadLoad
// LoadStore
j = b;// load b
a = i;// store a
}
}

监视器规则
锁的内存语义:
  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
从锁的内存语义得知,释放锁与volatile写相同,获取锁与volatile读相同。锁的内存语义的实现主要有2种方式:
  • 利用volatile变量的写-读所具有的内存语义。
  • 利用CAS所附带的volatile读和volatile写的内存语义。
传递性规则:
由于内存屏障本身具有传递性,这样保证了Happens-before规则本身具有传递性。

了解Java中的同步机制的内存语义实现到底对我们编写多线程代码有哪些指导呢?首先,我们利用总结的知识,来分析一个常见的问题。单例是我们编写Java系统非常容易用到的一个设计模式。双重锁检查是我们常用的单例模式的实现方式,该方式可以避免锁的等待,提供效率。

class Foo {

private Helper helper = null;

public Helper getHelper() {

if (helper == null)

synchronized(this) {  

if (helper == null)

helper = new Helper();

}

return helper;

}

}

但是上述代码并不能在所有的系统中正确执行。我们定义Helper类的为:

class Helper{
int a;
int b;
public Helper(){
a = 1;
b = 1;

}
}

我们运行多次代码发现,得到的Helper对象中 a= 0 , b = 0 而不是构造函数中期望的a = 1;b = 1;
我们可以利用前面的知识进行简单的分析:同步代码模块内的程序是允许乱序的,new Helper()方法的执行为:
  • 在共享堆内存分配一个对象空间,得到起始地址;//1
  • 调用构造函数a = 1; b =1; //2
  • 对helper实例变量进行赋值。//3
我们知道2和3本身没有数据依赖,所以2和3本身允许乱序,当然在x86上面运行是不存在该问题的,因为x86是不允许store-store乱序的。所以就会存在helper指向的地址空间的实例变量a,b并没有被构造函数初始化,而是用了默认值0,所以我们得到了helper对象的变量值为0,0。

理解Java同步机制的内存语义及实现方式对我们编写高质量的并发程序具有重要意义,后续我们会对Java锁的内存语义实现进行详细分析。
  评论这张
 
阅读(1361)| 评论(0)
推荐 转载

历史上的今天

评论

<#--最新日志,群博日志--> <#--推荐日志--> <#--引用记录--> <#--博主推荐--> <#--随机阅读--> <#--首页推荐--> <#--历史上的今天--> <#--被推荐日志--> <#--上一篇,下一篇--> <#-- 热度 --> <#-- 网易新闻广告 --> <#--右边模块结构--> <#--评论模块结构--> <#--引用模块结构--> <#--博主发起的投票-->
 
 
 
 
 
 
 
 
 
 
 
 
 
 

页脚

网易公司版权所有 ©1997-2017