原文链接:http://www.yeeyan.com/articles/view/2091/974
正文
这里讲的是关于 Java 并发机制 的基础模块及如何设计合理的并发机制的抽象思维和设计模式。
有这么几个知识点:
1 “先行发生”的次序( happens-before ordering )
2 “ volatile ”修饰符的使用
3 线程安全的延迟初始化
4 “ Final ”字段
5 关于 Java 并发机制 的一些建议
happens-before ordering
当我们在 Java 里谈起互斥锁定 ( mutual - exclusion lock )时,通常都指当我首先进入了一个互斥锁定 ,其他人试图获得这个同样的互斥锁定时,必须在我释放了之后才可以。这是 Java 或 C ++里关于互斥锁定 的最重要的属性。但事实上,这不是互斥锁定唯一的属性。还有一个属性是可见性( visibility) ,它和次序属性( ordering )紧密相关。
当一个线程使用一个锁定 时,它决定了其他线程何时 可以看到该线程在锁定后所做的更新。当一个线程对一个变量进行写的操作时,这个写的操作是否会被其他线程看到将取决于该线程使用的是何种锁定。
下面是一个小测验。有下面这段程序:
x=y=0;
//now start threads
//thread 1
x=1;
j=y;
//thread 2
y=1;
i=x;
问题是,在线程 1 和 2 执行完毕后,有没有可能 i 和 j 都等于 0 ?
我们知道,如果 i 和 j 结果都为 0 的话,对 y 的读(在 j = y 里用到)一定比对 y 的写先发生,类似地,对 x 的读一定比对 x 的写先发生?那么,这可能吗?
答案是肯定的。事实上,编译器和处理器都可能对上述程序重新排序,尤其在使用多个处理器,赋值并没有在主内存里同步 时。现代的 java 内存模型使上述现象成为可能。上面的程序显然是错误的未经同步 的代码,因为它没有使用锁定。当不同的线程需要读写同一个数据时,必须使用锁定的技术。
再看看下面一段非常关键的代码。可以说,这段代码是全篇演讲的核心。
thread 1 :
ref1.x = 1;
lock M;
glo = ref1;
unlock M;
thread 2:
lock M;
ref2 = glo;
unlock M;
j = ref2.x;
thread1 里有几个写的操作,在对 glo 变量进行写的操作之前,它首先对对象 M 进行了锁定 。在 thread2 里,当 thread1 释放了对 M 的锁定 之后,它过得了对 M 的锁定 ,并开始对 glo 的读操作。问题是,在 thread1 里的写操作, thread2 进行读操作时,可以看到吗?
答案是肯定的,原因是 thread1 里对 M 对象的释放和 thread2 里对同一个对象 M 的获得,形成了一个配对。可以这样想,当 M 在 thread1 里被释放后,在 thread1 里所作的更新就被推出(到主内存),随后的在 thread2 里对 M 的获得,就会抓取所有在 thread1 里所作的更新。作为 thread2 能得到在 thread1 里的更新,这就是 happens - before 的次序。
一个释放的操作和相匹配的之后发生的获得操作就会建立起业已发生的次序。在同一个线程里的执行次序也会建立起业已发生的次序 ( 后有例子会涉及到在同一线程里的执行次序问题 ) 。 业已发生的次序是可以转换的。
如果同时有两笔对同一个内存地址的访问,其中一笔是写的操作,并且内存地址不是 volatile 的,那么这两笔访问在 VM 里的执行次序就会按照“先行发生”的规则来排。
下面举一些例子来说明问题。请看下面的程序:
int z = o.field1;
//block until obtain lock
synchronized(o){
//get main memory value of field1 and field2
int x = o.field1;
int y = o.field2;
o.field3 = x+y;
//commit value of field3 to main memory
}
//release lock
moreCode();
像你从这个程序的注释里读到的一样,你会期望看到,在锁定 发生后, x 和 y 会被从主要内存里读到的 field1 和 field2 赋值, field3 被赋值后在锁定 释放后被推到主内存里,这样,其他线程应该由此得到最近的更新。
想起来是蛮符合逻辑的。实际所发生的可能不一定如此,下面一些特殊情况会造成 happens - before 的次序失效。
1 如果 o 是本地线程的对象?因为锁定 的是本地线程里的对象,在其他线程里不可能获得一个相匹配的锁定,所以对本地线程对象的锁定不起作用,
2 是否有现有对 o 的锁定 还未被释放?如果此前已有一个对象的锁定,在该锁定被释放之前,对同一个对象的再锁定不起作用。
Volatile 修饰符
当一个字段被多个线程同时访问,至少其中一个访问是进行写操作,我们可以采用的手段有以下两种:
1 采用锁定 来避免同时访问
2 用 volatile 来定义该字段,这样做有两个作用,一是增强程序的可读性,让读者知道这是一个将被多线程访问操作的字段;另外一个作用是在 JVM 对该字段的处理上,可以得到特殊的保证。
volatile 是 java 里除锁定 之外的重要同步 手段。首先, volatile 字段的读和写都直接进主内存,而不会缓存在寄存器中;其次, volatile 字段的读和写的次序是不能更改的;最后,字段的读和写实质上变成了锁定 模型里的获得和释放。
对一个 volatile 字段的写总是要 happens - before 对它的读;对它的写类似于对锁定 的释放;对它的读类似于进入一个锁定。
就 volatile 修饰符对可见性的影响,让我们看看下面的代码:
class Animator implements Runnable {
private volatile boolean stop = false;
public void stop () { stop = true;}
public void run() {
while (!stop){
oneStep();
try { Thread.sleep(100);} …;
}
}
private void oneStep() { /*…*/ }
}
这段程序里主要有两个线程,一个是 stop ,一个是 run 。注意,如果不用 volatile 来修饰 stop 变量, happens - before 的次序就不会得到体现, stop 线程里对 stop 变量的写操作不会影响其他线程,所以编译器不会去主内存里读取 stop 线程对 stop 变量的改变。这样,在 run 线程里就会出现死循环,因为在 run 线程里从始至终使用的只是 stop 变量初始化时的值。
由于编译器优化的考虑,如果没有 volatile 来修饰 stop 变量, run 线程永远都不会读到其他线程对 stop 变量的改变。
就 volatile 对执行次序保证的作用,我们看看下面的代码:
class Future {
private volatile boolean ready;
private Object data;
public Object get() {
if (!ready)
return null;
return data;
}
public synchronized void setOnce(Object o){
if (ready) throw…;
data = o;
ready = true;
}
}
首先一点还是由于 volatile 的使用使得 happens - before 的次序得以体现, setOnce 方法对 ready 变量的写操作的结果一定会被 get 方法中的读操作得到。
其次,更重要的,如果 ready 变量不被 volatile 来修饰,当线程 A 叫到 setOnce 方法时,可能按照 data=o; ready=true; 的次序来执行程序,但是另一个线程 B 叫到 setOnce 方法时,可能会按照 ready=true;data=o; 的次序来执行。可能发生的一个情况是当线程 B 执行完 ready=true 时,线程 A 正在检查 ready 变量,结果造成 data 未有写操作的情况下就完成了方法。 data 可能是垃圾值,旧值,或空值。
有关 volatile 的另外一点是被 volatile 修饰的变量的非原子操作化。比如,执行 volatile value++ ;的命令时,如果在对 value 加 1 后要写回 value 时,另外一个线程对 value 做写的操作,之前加和的操作就会被影响到。
就 JVM 而言,对 volatile 变量的读操作是没有额外成本的,写操作会有一些。
线程安全的延迟初始化
首先有下面一段代码:
Helper helper;
Helper getHelper() {
if (helper == null)
synchronized(this){
if (helper ==null)
helper = new Helper();
}
return helper;
}
这段代码是典型的延迟初始化的产物。它有两个目的:一是让初始化的结果能被多线程共用;一是一旦对象初始化完毕,为了提高程序的效率,就不再使用同步 锁定。如果不是由于第二点,实施对整个方法的同步其实是最保险的,而不是如本段代码中的只是对段的同步。
这段代码的问题是,对 helper 的写操作锁定 是存在的,但是却没有相匹配的获得锁定来读 helper ,因此, happens-before 的关系没有建立起来,进入同步 段来初始化 helper 的唯一可能是 helper==null 。如果一个线程过来检查是否 helper == null ,如果碰巧不是的话,它却不能得到其他线程对 helper 的更新(因为没有 happens-before 的关系),所以最后它返回的很可能是一个垃圾值。
在这里建立 happens - before 的关系的方法很简单,就是对 helper 加上 volatile 的修饰符, volatile Helper helper;
线程安全的 immutable 对象
基本原则是尽可能的使用 immutable 对象,这样做会有很多优点,包括减少对同步 机制的需要;
在类里,可以将所有变量定义为 final ,并且在构建完成前,不要让其他线程看到正在构建的对象。
举个例子,线程 1 新建了一个类的实例;线程 1 在没有使用同步 机制的情况下,将这个类的实例传递给线程 2 ;线程 2 访问这个实例对象。在这个过程中,线程 2 可能在线程 1 对实例构建完毕之前就得到对实例的访问权,造成了在同步 机制缺失的情况下的数据竞争。
关于 Java 并发机制 的一些有益建议
尽可能的使用已经定义在 java.util.concurrent 里的类来解决问题,不要做得太底层。增强对 java 内存模型的理解,搞懂在特定环境下释放和获得锁定 的意义,在你需要自己去构想和实施并发机制 时,这些都会用得上。
在一个单线程的环境下使用并发类可能会产生可观的开销,比如对 Vector 每一次访问的同步 ,每一笔 IO 操作等等。在单线程环境下,可以用 ArrayList 来代替 Vector 。也可以用 bulk I/O 或 java.nio 来加快 IO 操作。
看看下面一段代码:
ConcurrentHashMap<String,ID> h;
ID getID(String name){
ID x = h.get(name);
if (x==null){
x=new ID();
h.put(name,x);
}
return x;
}
如果你只调用 get (),或只调用 put ()时, ConcurrentHashMap 确实是线程安全的 。但是,在你 调用完 get 后, 调用 put 之前,如果有另外一个线程 调用了 h.put(name,x) ,你再执行 h.put(name,x) ,就很可能把前面的操作覆盖掉了。所以,即使在线程安全的情况下,你还有有可能违法原子操作的规则。
减少同步 机制的开销:
1 避免在多线程间共用可变对象
2 避免使用旧的,线程不安全的数据结构,如 Vector 或 Hashtable
3 使用 bulk IO 和 java.nio 里的类
在使用锁定 时,减少锁定的范围和持续时间。
关于 java 内存模型和并发机制 ,以下是一些有用的参考信息:
1 http://www.cs.umd.edu/~pugh/java/memoryModel
2 订阅 mailing list : http://altair.cs.oswego.edu/mailman/listinfo/concurrency-interest
3 参考书目: Java Concurrency in Practice