JSR133:线程规范翻译

Java虚拟机支持多条线程同时执行。多线程在Java中通过Thread类表现。对用户来说创建线程的唯一方式就是构造一个Thread类的对象;每个Java线程都和此类有关。通过调用Thread对象的start()方法来启动线程。

当线程没有正确同步的时候,多线程的行为将是不可预测的。本规范将介绍Java程序中多线程的语义,包括多个线程更新共享内存时哪些读共享内存的值的可见性规则。本规范与内存模型都适用不同的硬件架构,本规范的语义被称为Java内存模型。

本规范的语义没有介绍多线程程序应该怎样执行。而是只介绍了多线程程序允许的行为。任何执行策略只能产生允许的行为才是可接受的执行策略。

1、1         

Java提供了多种线程间通信的机制。这些方法最基本的机制就是使用监视器(monitor)实现的同步(synchronization)。Java中每一个对象都有一个线程可以锁定(lock)和解锁(unlock)的监视器。同一时间只允许一个线程持有监视器的锁。任何其他试图获取这个监视器的锁的线程将被阻塞,直到能够获取监视器的锁。

一个线程可以多次获取同一个监视器的锁,相应的,每次解锁将产生与锁定相反的影响。

synchronized表达式计算出对一个对象的引用,然后试着在对象监视器上执行锁定行为,在锁定行为完成之前,程序将不能执行。在完成锁定行为之后,synchronized体开始执行。Synchronized体完成之后,不管是正常执行完还是有异常发生,在此对象监视器将自动执行释放锁动作。

synchronized修饰的方法被调用的时候,锁行为将自动触发,直到成功获取锁后,方法体才执行。如果方法是实例方法,将锁定被执行实例对象的监视器(这个对象就是广为人知的this执行方法体期间)。如果是静态方法,将锁定定义方法的Class对象的监视器。方法执行结束后,不管有没有异常发生都将在同一个监视器上自动的调用解锁行为。

Java程序语言既不需要预防也不需要探测死锁的发生条件。程序中,多个对象中持有锁的线程应该使用合适的技术来预防死锁的发生,如果必要,创建更高级的不会死锁的原生锁。

Java.util.concurren包中提供的读和写volatile变量或者类,提供了可选的其他同步机制。

1.2           范例说明

Java内存模型从根本上并不是基于Java程序语言天然地面向对象的特性。在我们的例子中,为了简洁,我们展示的代码片段常常没有类、方法的声明或者明确的取值。大部分例子包含了两条或以上的线程,包含获取局部变量的表达式,对象的共享全局变量或者实例变量。

2 不正确的同步程序展现出来的意外行为

Java程序语言语义允许允许编译器和微处理器优化程序的执行,这影响没有正确同步的代码表现出看似不可理解的行为。

Original code                                   Valid compiler transformation

Initially, A == B == 0                       Initially, A == B == 0

Thread 1  |  Thread2                                  Thread1  |  Thread2

1: r2 = A;|  3: r1 = B                                B = 1;   |   A = 2

2: B = 1; |   4: A = 2                                r2 = A;  |   r1 = B

May observe r2 == 2, r1 == 1          May observe r2 == 2, r1 == 1

图表 1: 指令重排序导致的意外结果

图表1所示例子。这段程序包含了局部变量r1r2,也包含了对象的对象的共享变量AB。这段程序的执行可能出现看似不可能的结果r2 == 2r1 == 1。直观的说,在程序执行次序中,要么指令3先执行,要么指令1先执行。如果指令1先执行,那么不应看看到指令4写变量的结果。如果指令3先执行,不应该看到指令2写变量的结果。

如果某些执行展示出了这种行为,我们应该意识到指令4先于指令1执行,尽管指令1在指令2前面;指令2先于指令3执行,尽管指令3在指令4前面。这就是荒诞的真像。(If some execution exhibited this behavior, then we would know that instruction 4 came before instruction 1, which came before instruction 2, which came before instruction 3, which came before instruction 4. This is, on the face of it, absurd.这段看似简单却令人无比抓狂的E文,我也不知道自己理解了没有,翻译的靠谱吗!!)

然而,在每一个线程中是允许编译器这样进行指令重排序的,这样并不影响互相隔离的其他线程的执行的。如果指令3被放于指令4之后执行,指令1被放于指令2之后执行,那么结果r2 == 2 r1 == 1 就是完全合理的了。

对一些程序员来说,这种行为的原因好像是Java打乱了他们的代码顺序。其实,真正的原因是这段代码没有被正确的同步:

l      每条线程都有写变量的行为

l      另外一条线程读取同一个变量

l       读和写没有被synchronization排序

这种情况被称为数据竞争(data race),当代码中有数据竞争时,这种违反直觉的结果就是可能的了。

几种机制可能产生重排序:即时编译器(JIT编译器),处理器也可以重新排列指令。除此之外,虚拟机运行环境的内存体系架构也可能使代码的执行表象是指令重排序。为了简洁,我们简单的将编译器作为指令重排序根源。源代码编译成字节码(bytecode)的过程中也可能产生重排序或者程序转换,然而只能以本规范允许的方式进行。

              图表2展示了另外一个意料之外结果的例子,程序没有被正确同步,程序在获取共享内存时没有使用任何强制获取顺序的方式。

       Original code                                   Valid compiler transformation

Initially: p == q, p.x == 0                         Initially: p == q, p.x == 0

Thread 1 | Thread 2                                 Thread 1  |  Thread 2

r1 = p;  |  r6=p;                                   r1 = p;   |  r6=p;

r2 = r1.x|  r6.x=3;                                 r2 = r1.x |  r6.x=3;

r3 = q;  |                                          r3 = q    |

r4=r3.x  |                                          r4=r3.x   |

r5=r1.x  |                                          r5=r2;    |

May observe r2 == r5 == 0, r4 == 3?     May observe r2 == r5 == 0, r4 == 3

Figure 2: Surprising results caused by forward substitution

通常的编译器优化会把对r2的读重用到r5,因为他们两个都是在没有写交互下的读r1.x

现在考虑这种情况,线程2r6.x分配值发生在线程1r1.xr3.x之间。如果编译器决定读r5的值的时候重用r2,那么r2r5的值是0r4的值是3。这似乎也是违反直觉的:从程序员的角度看p.x的值已经从0变为3,然后又变回了0

这种行为看上去很奇怪,却是大多数JVM允许的。在原来的Java语言规范和Java虚拟机规范中是禁止这样做的,这意味着原来的Java内存模型需要更新了。

3 通常语义

程序必须正确的同步以避免由指令重排序产生的各种违法直觉的行为。正确使用同步不意味着程序的所有行为都是正确的,正确的同步却能让程序员对程序的可能行为是可解释的:正确同步程序的行为和可能的指令重排序无关。没有正确同步的程序,任何奇怪的,令人迷惑的甚至违法直觉的行为都是有可能的。

下面两点是理解程序是否正确同步的关键:

冲突获取(Conflicting Accesses:两个或以上的获取(读或写)同一个共享域或者数组元素时,至少有一个获取是写时称为冲突。

先行发生关系(Happens-Before Relationship:两个行为可以通过先行发生关系排序。如果一个动作先行发生于另一个,那么第一个行为就是先于另一个动作发生,并且是对第二个动作可见。需要强调的是两个行为间的先行发生关系并意外着Java程序中的次序就是先行发生的次序。先行发生关系通常发生在彼此有冲突的两个行为间的次序,定义在数据竞争发生的时候。下列的几种方式有先行发生关系:

l        线程中的每一个行为先行发生于随后的每个行为。

l        监视器上的解锁先行发生于随后的每个锁定。

l        volatile类型变量的写先行发生于随后对这个变量的读。

l        一条线程上,对start()方法的调用先行发生于这条线程上的任何行为。

l        一条线程上的所有行为先行发生于任何调用这条线程的join()方法成功返回线程

l        如果行为a先行发生于行为b,而且b先行发生于行为c,那么行为a先行发生于行为c

先行发生在第5节有完整定义。

当程序中包含有两个没有先行发生关系的冲突获取,就称之为数据竞争。正确同步了的程序是没有数据竞争的(3.1节包含了一个精妙却重要的澄清)。

图表3展示了一个不正确同步代码在同一段程序中的不同执行,它们都包含了冲突获取共享变量XY。两条线程锁定和解锁监视器M1。图表3.1展示的所有成对的冲突获取的执行有先行发生关系。然而图表3.2所展示的冲突获取X的执行没有先行发生关系。因此,这段程序没有被正确的同步。



(a)     Thread1首先获取锁;对X的获取       bThread首先获取锁,对X

按先行发生关系排序                           的获取没有先行发生关系

              图表3先行发生排序

如果程序被正确的同步了,程序的所有执行将表现出顺序一致性(sequentially consistent)。这需要由程序员来严格保证的。如果程序员断定他们的代码不包含数据竞争,他们也就不需要再为数据竞争的影响操心了。

3.1            顺序一致性

顺序一致性是程序执行时可见性和排序性要求的强约束。在顺序一致性的执行中,所有的单独行为总顺序与程序顺序一致。

       对每一个线程来说,每一个单独的行为都是原子的和立即可见的。如果程序不存在数据竞争,程序的执行将表现为顺序一致性。如同前面提到的一样,由于一系列原子性或非原子性的操作,没有数据竞争的顺序一致性仍然允许一定的错误发生。

       如果我们使用顺序一致性作为内存模型,许多讨论过的编译器和处理器优化将会失效。举例说,在图表2中,一旦3写入p.x发生,随后的读此变量操作将要求能看到新值。

       刚刚讨论的顺序一致性,我们利用它来做关于数据竞争和正确同步程序的重要澄清。数据竞争发生在这样的程序执行中,执行中冲突的行为没有被同步正确排序。程序中所有顺序一致性执行是没有数据竞争的执行时,程序才是正确同步的。因此,程序员只要根据他们的程序执行是不是顺序一致性的就可以推断出他们的程序是否正确同步了。

       4~6节对除了final字段的内存模型的议题做了更加充分的处理。

3.2            3.2  final字段

声明为final的字段只初始化一次,正常情况下也不能改变。final字段的详细的语义与正常字段的语义有点区别,尤其是编译器在在处理移动对final字段读跨越同步格栅和移动不明或未知方法有很大的自由度时。在此情况下,允许编译器将final字段的值保存在寄存器中而不需要从内存中重载,相应地,非final字段必须从内存中重载。

final字段允许程序员不使用同步实现线程安全的不可变对象。线程安全的不可变对象从所有的线程中看都是不变的,即使线程间有数据竞争为不可变对象传引用。这种机制为防止通过使用不正确或恶意代码对不可变对象的误用提供安全保证。

final字段应为不可变提供正确的安全保证。考虑一个对象,当它的构造函数完成后就完成了初始化。如果对一个对象的实例使用final修改就能保证其他线程看到此对象实例变量完全初始化后的正确值。

final字段的应用模型非常简单。在对象的构造函数里为final字段设值。在其他线程可能看到未执行完构造函数的地方不要写引用一个构造中的对象实例。如果遵守了这条规则,当对象的实例被其他线程所见的时候,其他线程观察到的总是被正确构造了final字段的版本。也能看到任何被final引用的对象或数组的版本至少与final字段一样新。

图表4的例子阐述了final字段怎样与普通字段比较。类FinalFieldExample有一个int类型的final字段x和另一个非finalint字段y。可能一条线程执行writer()方法时另一条线程执行reader()方法。因为writer()方法在对象构造函数完成后才写f的值,而reader方法被保证能读到f.x的正确的初始化值,f.y不是final类型的,因此reader()方法不被保证读到值4

class FinalFieldExample{

        final int x;

        int y;

        static FinalFieldExcmple f;

        public FinalFieldExample(){

               x = 3;

               y = 4;

        }

static void reader(){

       f = new FinalFieldExample();

}

static void reader(){

       if(f != null){

              int i = f.x;              //guaranteed to see 3

              int j = f.y;              //could see 0

       }

}

}

               图表4:阐述final字段语义的例子

final域设计的目的是提供必要的安全保证。考虑图表5的代码,String对象是不可变的,字符串操作操作不表现出同步性。虽然String对象的实现没有任何数据竞争,但是其他使用String对象的代码却可以有数据竞争存在,内存模型也为存在数据竞争的程序提供了微弱的保证。当String类的字段不是final类型的时候,线程2可能首先看到String对象偏移量的默认值0,从而允许与”/tmp”相等。String对象上随后的操作可能看到正确的偏移量值4,所以String对象被视为”/usr”.Java程序语言的许多安全特征依赖于视String对象为真实不可变的,即使恶意代码使用数据竞争在不同线程间传递String对象的引用。

Thread1                                                 Thread2

Global.s = “/tmp/usr”.substring(4);        String myS = Global.s;

                                             If(myS.equals(“/tmp”))

                                                 System.out.println(myS);

              图表5:没有使用final或同步,这段代码可能输出”/usr”

这仅仅是final字段语义的概述,在此没有涉及的更细节的讨论,请查阅第9章。

 

写在后面:初次翻译,本人E文水平实在有限,不翻译文章不知道翻译的辛苦。如果用直译,翻译出来可能会比较生硬;采用意译,可能曲解原意,害人害己。本文尽可能采用直译的方式。

猜你喜欢

转载自liuyh17211.iteye.com/blog/1494055
133