并发基础知识(二)

来源:互联网 发布:淘宝一键复制赚佣金 编辑:程序博客网 时间:2024/06/05 10:08

原文翻译自JSR-133 FAQ,链接地址:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html

JSR 133是关于哪方面的

 自从1997,在Java语言规范的第17章中定义的Java内存模型上发现了一些很严重的缺陷。这些缺陷不但产生了令人困惑的行为(例如观察到了final字段的值可以被改变)而且还破坏了编译器执行一些常见优化措施的能力。
 Java内存模型是一个非常雄伟的工程,它是Java语言规范首次尝试并入一个可以为在各种架构下的并发提供一致语义的内存模型。不幸的是,定义一个一致和直观的内存模型比预期困难得多。 JSR133为Java语言定义了一个解决了在早期内存模型上存在缺陷的新的内存模型。为了达到这个目标,final 和volatile关键字的语意进行了改变。JSR 133的目的是为了创建一个能够阐述volatile,synchronized,和final是怎样工作的直观框架的形式语意(formal semantic)的集合。
 JSR133目标包括:
 -维护已经存在的安全保障,比如类型安全。并加强其他安全性。例如,变量的值不会被凭空创建:每一个能够被某个线程所观察到的值,一定是能够被合理的放置在某个线程的值。
 -正确同步程序的语意应该尽可能的简单和直观。
 -定义没有完成或者是不正确同步程序的语意,以便于将潜在的安全性危险降至最低。
 -程序员应该能够自信地推断多线程程序如何与内存交互的。
 -可以在广泛流行的硬件架构中设计正确的高性能的JVM实现。
 -提出新的安全初始化的保障。如果一个对象被正确的构造(这意味着它的引用在构造期间并不会溢出(escape)),那么其它所有的看到这个对象引用的线程在不需要同步的条件下也会看到在构造方法中它的final字段。
 -对现有的代码会有最小程度的影响。

到底什么是内存模型(Memory Model)

 在多处理器的系统上,众多处理器一般都会有一个或者是更多层级的内存缓存,缓存不仅可以提高性能而且还可以加快数据访问速度(由于数据被放置在距离处理器更近的地方)同时还能减少在共享内存的总线上的流量(因为很多针对内存上的操作可以替换到在本地缓存执行,)内存缓存可以显著的提高性能,但是与此同时也衍生出了一系列的问题(present a host of challenge)。
 例如:当两个处理器同时检查相同位置上的内存空间会发生怎样的情况呢?
processor-cache-memory

 在处理器的级别上,内存模型定义了当前处理器可以感知到其它处理器对内存的写入操作对当前处理器是可见的和当前处理器的对内存写入操作是对其它处理器是可见的必要和充分条件。(At the processor level, a memory model defines necessary and sufficient conditions for knowing that writes to memory by other processors are visible to the current processor, and writes by the current processor are visible to other processors.)一些处理器展现出了强内存模型(strong memory model),这使得所有处理器在任何时刻对于给定的内存位置上都能看到相同的值(Some processors exhibit a strong memory model, where all processors see exactly the same value for any given memory location at all times.)。其他处理器表现出较弱的存储器模型,这就需要称为内存栅栏(memory barriers)的特殊指令来刷新或使本地处理器高速缓存无效,以便看到由其他处理器进行的写入或使该处理器的写入对其他处理器可见。这些内存栅栏通常在执行上锁和解锁的操作上会被执行,它们是对使用高级语言的程序员透明的。
 由于对内存栅栏需求的减少,对于在强内存模型的条件下编写程序有时是更容易的。然而即使在最强的内存模型下,内存栅栏也经常是必须的,即使它们所被置于的地方有可能令人感到疑惑。
 当前在处理器领域上的设计趋势是鼓励更弱的内存模型(weaker memory models)的。这些设计在对于缓存一致性的处理上比较宽松,因此在多处理器和更大数量上的内存上能得到更多的可扩展性(scalability)。 
 一个写入操作在什么时候对于其它线程是可见的问题会因为编译器对代码重排序(compilier’s reordering of code)而变的复杂化。例如,编译器认为将一个写入操作放在稍后来执行的情况下会更有效,只要决定后的代码不会改变原先代码的语意(semantic),那么编译器可以自由的做出更改。如果编译器延迟了一个操作,那么其它线程可能直到它被执行后才能看到这个更改,这个现象反映了缓存的影响。(if a compiler defers an operation, another thread will not see it until it is performed,this mirrors the effect of caching)。
 然而,对内存的写入操作可被移动到在程序中更早的时间执行,这种情况下,其它线程可能看到发生在实际的程序之前的写入操作(other threads might see a write before it actually “occurs” in the program.)。所有这些灵活性是通过设计 - 通过为编译器,运行时或硬件提供以最佳顺序执行操作的灵活性,在内存模型的范围内,我们可以实现更高的性能。(All of this flexibility is by design – by giving the compiler, runtime, or hardware the flexibility to execute operations in the optimal order, within the bounds of the memory model, we can achieve higher performance.)
示例代码如下:

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,因为对y的写入操作被置于在写入x的操作之后。编程人员可能认为对x进行读取操作“一定”会看到1。
 然而,写入的操作可能已经被重新排序。如果这个现象发生的话那么对于这两个变量进行读取的操作可能在对y写入操作之后,接下来再进行x的写入。那么结果就是r1的值是2,但是r2的值却是0(因为writer方法发生了重排序!)。
 Java内存模型描述了在多线程之间合法的行为和线程与内存交互的方式。它描述了程序中变量之间的关系和在实际计算机系统中存储和检索它们到存储器或寄存器的低级细节。它是以一种可以使用各种硬件和各种编译器优化正确实现的方式来实现。
 Java包括多种语言结构,包括volatile,final和synchronized,它们旨在帮助编程人员对编译器描述程序的并发需求。
 Java内存模型定义了volatile和synchronized的行为,并且更重要的是确保一个正确同步的Java程序能够在所有的处理器架构上能够正确运行。
 

重排序是什么意思

 在很多情况下,访问程序变量(对象的实例域,类静态域,数组元素)的顺序可能看起来与以它们在程序中定义不同的顺序来执行。编译器可以自由的以优化为名称的指令顺序来自由的操作。(The compiler is free to take liberties with the ordering of instructions in the name of optimization.)。处理器可能在某些情况下无序的执行指令。数据可能以与在程序中指定的不同顺序在寄存器,处理器缓存和主内存中移动。(Data may be moved between registers, processor caches, and main memory in different order than specified by the program.)。
 例如,如果一个线程首先向a字段写入,然后向b字段写入,并且b字段的值并不依赖于a的值,那么编译器会自由的重排序这些操作,并且缓存也可能会随意的在a之前将b刷新到主内存。
 重排序有多种来源,例如:编译器,JIT(Just in Time),和缓存。
 编译器,运行时环境和硬件应该一起商量去创建“好像是串行”(as-if-serial)的语意,这意味着在单线程程序中,程序并不能观察到重排序的影响,然而,重新排序可以在不正确同步的多线程程序中发挥作用,其中一个线程能够观察其他线程的影响,并且可以能够检测变量访问对于其他线程以与在程序中执行或指定的顺序不同的顺序可见 (However, reorderings can come into play in incorrectly synchronized multithreaded programs, where one thread is able to observe the effects of other threads, and may be able to detect that variable accesses become visible to other threads in a different order than executed or specified in the program.).大多数情况下,一个线程并不在意其它线程正在干什么,但是如果它要关心的话,那么就应该使用同步了。

旧的内存模型有什么问题

  在旧的内存模型上有一些很严重的问题。问题很不容易理解,因此经常违反一些规则。例如:旧的内存模型在很多情况下并不允许在每种JVM上发生各种类型的重排序。
 正是由于对旧模型存在的混乱和疑惑,迫使JSR-133的形成。
例如,有个很流行的观点:如果final字段被使用的话,那么没有必要在线程之间使用同步来保证其它线程会看到这个字段的值。虽然这是一个合理的假设和合理的行为,但是事实上,我们想要的一些事情工作的方式在旧的内存模型下并不是显示的(While this is a reasonable assumption and a sensible behavior, and indeed how we would want things to work, under the old memory model, it was simply not true)
在旧的内存模型中,并不能使其它字段和final类型的字段不一样。这意味着,同步(synchronization)是用来确保所有线程都会看到由构造器所写入一个final字段的值的唯一的方式。(synchronization was the only way to ensure that all threads see the value of a final field that was written by the constructor.)
  因此,对于一个线程来讲,它可能看到某个字段的默认值,然后在只有的某个时刻看到被构造的值。比如,这意味着对于像String这样的不可变的对象可能看起来改变他们的值,这确实是一个令人不安的前景。(a disturbing prospect indeed.)
  旧的内存模型允许对于volatile的变量写入操作可以和非volatile的变量的读取和写入操作一起被重排序,这种情况和大多数的开发者对于volatile的理解并不一致,因此导致了很大的困惑。最后,正如我们所看到的,程序员对他们的程序不正确同步时可能发生什么情况的的直觉常常是错误的(programmers’ intuitions about what can occur when their programs are incorrectly synchronized are often mistaken. )JSR-133的目标之一就是提醒要注意到这个事实。
 

不正确的同步是什么意思

  不正确的同步代码对于不同的人们可以意味着不同的事情,当我们在Java内存模型下讨论不正确的同步代码时,我们指的是在一个由一个线程向一个变量写入的地方,同时有其它线程正在读取同一变量,并且没用同步来进行排序(ordered),当些规则被违背的时候,我们说这个变量上存在数据竞争(data race)。一个存在数据竞争的程序并不是一个正确同步的程序。
  

同步(synchronization )做了什么事

 同步存在于一些方面。最容易理解的就是互斥性 – 一次只有一个线程能够持有管程(monitor)没有其它线程能够进入到受管程保护的同步代码块中,除非首次进入同步代码块的线程从代码块中退出。但是对于同步(synchronization)来讲,除了互斥的语意还存在更多的意思。同步确保了线程在同步代码块执行之前或者执行中的对内存的写入以一种可预见的方式对同步在相同管程上的其它线程是可见的,在我们退出同步代码块之后,我们释放了管程,这有了将缓存刷新至主内存的效果,因此,这个线程的写入的操作对于其它线程是可见的。在我们进入同步代码块之前,我们获取这个管程,这个操作具有使本地缓存无效的作用以便于变量从主内存被重新加载。然后,我们能够看到在释放管程之前的对内存所有的写入。
根据缓存这一点讨论,这听起来好像这些问题只影响多处理器机器.然而,在单处理器的机器上也很容易看到重排序的影响。例如,编译器不可能在获取之前或释放之后移动代码。 当我们说获取和释放对缓存的行为时,我们使用缩写来描述一些可能的效果。JMM临界区内的代码是可以进行(It is not possible, for example, for the compiler to move your code before an acquire or after a release. When we say that acquires and releases act on caches, we are using shorthand for a number of possible effects.)JMM临界区内的代码是可以进行重排序的,但JMM不允许临界区内的代码”逸出”到临界区外,因为会破坏临界区的语义。
锁定释放和获取的内存语义实例如下:

class LockMemorySemantic {    private int a = 0;    public synchronized void writer() {            a++;    }    public synchronized int reader() {            return a;    }}

这里写图片描述
假设线程A执行writer方法,随后线程B执行reader方法(因此A与B满足happens-before原则),当线程释放锁时,JMM会把线程对应本地内存中的共享变量刷新到主内存中,A线程释放锁后,共享数据的状态如图所示,当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而必须从主内存中读取共享变量。

新的内存模型语义对内存操作(读取字段,写入字段,锁定,解锁)和其他线程操作(启动和连接)创建部分排序,其中一些操作据说在其他操作之前发生。当一个动作发生在其它动作之前,那会保证第一发生的顺序在前,并且对于第二个是可见的。那么符合的即使happens-before原则,该原则如下:
1. 程序规则顺序:如果程序中操作A在操作B前,那么线程中操作A发生在操作B前。
2. 在管程上的解锁操作必须在同一个管程的加锁操作之前
3. volatile变量原则:对volatile变量的写入操作要在读取之前
4. 线程启动规则:在线程上调用Thread.start必须在线程中执行任何操作之前执行
5. 线程结束规则:线程中的任何操作必须在其它线程检测到该线程已经结束之前执行
6. 中断规则: 当一个线程在另一个线程上调用interrupt时必须在被中断线程检测到interrupt调用之前执行。
7. 终结器规则: 对象的构造函数必须在启动该对象的终结器之前执行完成
8. 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前完成,那么操作A**必须**在操作C之前完成。
在Java内存模型中说明Happen-Before关系

在Java内存模型中说明Happen-Before关系示例图

 图中给出了当两个线程使用同一个锁进行同步时,在它们执行的Happens-Before关系:
 在线程A内部的所有操作都按照它们在源程序中的先后顺序进行排序,在线程B中也是如此。由于A释放了锁M,并且B随后获得了锁M,因此A中所有在释放锁之前的操作,也就位于B中请求锁之后的所有操作之前。如果这两个线程是在不同的锁上进行同步的,那么就不能推断它们之间的动作顺序,因为在这两个线程之间并不存在Happens-Before关系。
 这意味着任何线程在退出同步块之前对线程可见的任何内存操作在进入同一监视器保护的同步块后是可见的,因为所有内存操作都发生在释放之前,并且释放发生在 获得。
 其它含义如以下模式所示,一些开发人员用这个方式来强制使用内存栅栏,但并没有什么作用:

synchronized(new Object()) {..no-op}

  这个实际上什么都没做,并且你的编译器也会完全的移除掉它,因为编译器知道没有任何其它的线程会在同一个管程上进行同步。你不得不在一个要看到其它线程结果的两个线程之间设定一个happens-before关系。
  需要特别注意的是:为了设定正确的happens-before关系,对于在相同的管程上来进行同步操作是很重要的。
  事实的情况是这样的,当A线程在X对象进行同步的时候,所有的操作只是对它(线程A)是可见的,而并不意味着对于在A线程之后的在对象Y进行同步的线程B是可见的。释放和获取锁这两个操作不得不满足正确的语意(即,在同一个管程上进行对应的释放和获取锁的操作),否则这段代码会出现数据竞争。
 

final字段是怎么看起来改变了它们的值的?

 对于final字段看起来似乎改变了它们的值的一个最好的实际例子涉及到与String类的一个特殊的实现。(P.S:这个例子已经过时了,因为忘了是从jdk是多少开始的时候,String类的subString的实现已经改变了,String对象之间不再共享char数组并由偏移量和长度来区别不同的char value的值了,新的实现是通过Arrays.copyOfRange来进行char数组的复制操作,不同String对象用不同的char数组,源码(jdk1.8.0_102)如下:

public String substring(int beginIndex) {        if (beginIndex < 0) {            throw new StringIndexOutOfBoundsException(beginIndex);        }        int subLen = value.length - beginIndex;        if (subLen < 0) {            throw new StringIndexOutOfBoundsException(subLen);        }        return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);//构造新的String对象}   
public String(char value[], int offset, int count) {        if (offset < 0) {            throw new StringIndexOutOfBoundsException(offset);        }        if (count < 0) {            throw new StringIndexOutOfBoundsException(count);        }        // Note: offset or count might be near -1>>>1.        if (offset > value.length - count) {            throw new StringIndexOutOfBoundsException(offset + count);        }        this.value = Arrays.copyOfRange(value, offset, offset+count);//构造方法最终这个来实现复制操作    }

过时的例子:

String s1 = "/usr/tmp";String s2 = s1.substring(4); 

String是通过final 的char数组和偏移量,长度来区分不同的value的,因此两个对象之间可以共享char数组并使用偏移量和长度来区分不同的value,因此,在多线程的情况下,如果调用subString方法在旧的内存模型中有可能会看到s1的默认偏移量0,然后在之后的又看到了偏移量是4(这个之后操作说的不同明确),因此好像是String的值改变了一样。
  在旧的Java内存模型会允许这样的行为,一些JVM确实表现出了这个行为。在新的Java内存模型中这个行为是不合法的。

在新的JMM下final字段是怎样工作的

  对象的final字段的值是在构造方法中进行赋值的,一旦对象被构造完成,这样就认为对象被正确的构造了。在构造方法中对final字段所赋的值不用在同步(synchronization)下对于其它线程就是可见的。此外,由这些final字段引用的任何其他对象或数组的可见值将至少与final字段一样是最新的。(In addition, the visible values for any other object or array referenced by those final fields will be at least as up-to-date as the final fields.)
  对象被正确的构造是什么意思呢?其实它的意思就是指正在构造的对象在构造期间没有任何对于这个对象的引用出现了溢出(escape)现象。换句话说,不要把正在进行构建的对象的引用放在其它线程能够看见它的地方。不要将这个对象的引用赋值给静态的字段,不要把它作为监听器注册到其它对象上等等。这些任务都应该在完成在构造方法完成之后,不要在构造之中进行。

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;    }  }}

 上述的类是一个final字段该如何被使用的例子。正在执行reader方法的线程会被保证能看到f.x的值是3,因为它是final修饰的。但并不保证y的值是4.因为它并不是final的.如果FinalFieldExample的构造方法是这样的话:

public FinalFieldExample() { // 糟糕!  x = 3;  y = 4;  //糟糕的设计,因为这使this引用被溢出了  // bad construction - allowing this to escape  global.obj = this;}

那么从global.obj这个对象读取这个引用的线程并不保证会看到x的值是3。
  对于能够看到字段被正确构造的值是非常不错的,但是如果这个字段本身就是一个引用,你也会想让你的代码知道它所指向对象(或数组)的最新值。如果你的字段是一个final修饰的,这也能被够被保证。所以,你可以有一个指向数组的final指针,而不必担心其他线程看到数组引用的正确值,但是数组内容的值是不正确的。再次说明一下,在这里的正确,我们的意思是“最新的对象的构造方法的结束”,而不是”最新的值可用“.也就是说这种最新指的是刚刚构造完成的最新,并不代表以后使用的最新。
 现在可以做出总结了,如果,在一个线程构造一个不可变的对象之后(指的是,一个只包含final字段的对象),你想确保它能够被所有其它的线程所正确的看到,有通常还是得使用同步(synchronization).没有其它方法能够做出保证,例如,一个纸箱不可变的对象的引用会被第二个线程所看到。程序从最终字段获得的保证应仔细调整,深入细致地了解如何在代码中管理并发。(The guarantees the program gets from final fields should be carefully tempered with a deep and careful understanding of how concurrency is managed in your code.)。如果你用JNI去修改final字段,那么这个行为是未定义的。

Volatile做了什么事情?

 volatile字段是用来作为在线程之间进行交流的特殊的字段,对于Volatile字段的每次读取都能看到其它线程对它进行的最后一次写入的影响,事实上,它被开发人员设计为:不会接受由于缓存和重拍序的结果而造成的失效的值的字段。
 编译器和运行时的环境禁止在寄存器缓存为它们分配内存.它们必须也得确保在它们被写入操作执行只有,立即被刷新至主内存! 
 因此,它们对于其它线程来讲是立即可见的。类似的,在volatile被读取之前,缓存一定会失效(销毁invalidate)以便于是主内存可见而不是本地缓存。在volatile变量的重排序的访问上,也有其它额外的限制。
在旧的内存模型下,对volatile变量的访问操作之间不能进行重排序,但是它们可以和其它对于非volatile变量的访问操作一起被重排序,这样的话就破坏了volatile作为一个线程到另一个线程的信号条件的实用性。(This undermined the usefulness of volatile fields as a means of signaling conditions from one thread to another.)
 在新的内存模型下,volatile变量之间还是不能进行重排序。不同的是它们和其它正常字段一起重排序不容易了。volatile字段写入操作对内存的影响和管程被释放的作用是一样的。并且从volatile字段读取操作和管程获取有着同样的内存作用。
 事实上,由于新内存模型在其它字段(volatile或非volatile)和volatile变量访问之间重排序上所施加的更为严谨约束,任何在线程A中对于volatile字段f的写入操作对于当在线程B中读取f的时候都是可见的。
这里有一个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.    }  }}

 假设一个线程正在调用writer方法,另一个线程正在调用reader昂发,在writer中v的写入将x的写入发布(release)到了主存中,并且v的读入从主存获取其值(acquire).因此,如果reader看到v的值是true,它也同时保证了罪域42的写入操作发生在其前面。这在旧的内存模型不会是真的。
 如果v不是volatile的,那么编译器很可能在writer中进行指令的重排序,并且在reader中对x的读取可能会是0.
 实际上,volatile的语义已经加强到几乎和同步差不多的等级了,为了可见性的目的,volatile字段的行为已经像半个同步(synchronization)的操作了。
 需要重点注意的是:对于在相同volatile变量进行访问的线程之间设定正确的happens-before关系是很重要的。
 情况是这样的,在线程A中对volatile 字段f的写入操作在线程B中对volatile字段g进行读取操作之后,并不都是可见的,可见是建立在相同volatile进行操作的基础上的。所以获取和发布一定要满足正确的语意(即在相同的volatile变量进行操作)。

新的内存模型解决了DCL的问题了吗?

 臭名昭著的用法DCL(或者叫做多线程下的单例模式)是一种旨在支持延迟初始化而避免同步开销的技巧。 在非常早期的JVM中,同步很慢,开发人员渴望删除它 - 可能太急切了。 双重检查的锁定习语看起来像这样:

public class DCL_1 {    private static DCL_1 sInstace;    private DCL_1() {        // incorrectly prior JVM 1.5 with old memory model    }    public static DCL_1 getInstance() {        if (sInstace == null) {            synchronized (DCL_1.class) {                if (sInstace == null) {                    sInstace = new DCL_1();                }            }        }        return sInstace;    }}

看起来DCL_1没什么毛病,但是存在这样一个问题:当A线程正在调用构造函数时,其它线程很可能在第一个判断语句上看到对象的引用不为空,因此跳过了同步代码块,直接返回了这个对象的引用,但是就在这个时候,A线程很可能还在停留在构造方法中而且对于对象的字段(field)的写入操作可能会存在重排序的现象,如此就会造成这个对象的字段没有完成初始化!因此其它线程返回的引用实际上引用的是一个并不完整的对象。
 在JVM1.5以前的内存模型是解决不了这个问题的,如果想使用单例模式可以采用懒汉式写法(eager-initialization):

class MySingleton {  public static Resource resource = new Resource();}```

在JVM1.5或者之后的发布版本可以使用volatile字段来解决DCL存在的问题:

public class DCL_2 {    private static volatile DCL_2 sInstace;//volatile field    private DCL_2() {        // solved the "DCL problem"  which in prior JVM 1.5 with old model    }    public static DCL_2 getInstance() {        if (sInstace == null) {            synchronized (DCL_2.class) {                if (sInstace == null) {                    sInstace = new DCL_2();                }            }        }        return sInstace;    }}

 在新的内存模型下,字段使用volatile引用的会解决DCL的问题,因为在进行初始化构建对象的线程与读取这个对象的线程存在happens-before的关系。(happens-before关系列表文章靠前部分已经列出。)

读者为啥要关心以上提出的问题?

 读者为什么要关心这个?并发程序的众多Bug调试起来很困难。这些Bug不会出现在测试,等待中,而是直到程序在很大的负载条件运行时才出现。并且,很难再次制造并陷入这些问题(reproduce and trap)。你最好提前就多花费些额外的努力来确保你的程序被正确的同步。虽然这并不容易,但是与尝试调试一个糟糕同步的应用对比来讲,这容易多了!

0 1