`
lifethinker
  • 浏览: 72091 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

用happen-before规则重新审视DCL

    博客分类:
  • java
阅读更多

编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的bug很难测试,DCL(Double Check Lock)就是一个典型,因此对多线程安全的理论分析就显得十分重要,当然这决不是说对多线程程序的测试就是不必要的。传统上,对多线程程序的分析是通过分析操作之间可能的执行先后顺序,然而程序执行顺序十分复杂,它与硬件系统架构,编译器,缓存以及虚拟机的实现都有着很大的关系。仅仅为了分析多线程程序就需要了解这么多底层知识确实不值得,况且当年选择学Java就是因为不用理会烦人的硬件和操作系统,这导致了许多Java程序员不愿也不能从理论上分析多线程程序的正确性。虽然99%的Java程序员都知道DCL不对,但是如果让他们回答一些问题,DCL为什么不对?有什么修正方法?这个修正方法是正确的吗?如果不正确,为什么不正确?对于此类问题,他们一脸茫然,或者回答也许吧,或者很自信但其实并没有抓住根本。

 

幸好现在还有另一条路可走,我们只需要利用几个基本的happen-before规则就能从理论上分析Java多线程程序的正确性,而且不需要涉及到硬件和编译器的知识。接下来的部分,我会首先说明一下happen-before规则,然后使用happen-before规则来分析DCL,最后我以我自己的例子来说明DCL的问题其实很常见,只是因为对DCL的过度关注反而忽略其问题本身,当然其忽略是有原因的,因为很多人并不知道DCL的问题到底出在哪里。

 

 

Happen-Before规则

 

我们一般说一个操作happen-before另一个操作,这到底是什么意思呢?当说操作A happen-before操作B时,我们其实是在说在发生操作B之前,操作A对内存施加的影响能够被观测到。所谓“对内存施加的影响”就是指对变量的写入,“被观测到”指当读取这个变量时能够得到刚才写入的值(如果中间没有发生其它的写入)。听起来很绕口?这就对了,请你保持耐心,举个例子来说明一下。线程Ⅰ执行了操作A:x=3,线程Ⅱ执行了操作B:y=x。如果操作Ahappen-before操作B,线程Ⅱ在执行操作B之前就确定操作"x=3"被执行了,它能够确定,是因为如果这两个操作之间没有任何对x的写入的话,它读取x的值将得到3,这意味着线程Ⅱ执行操作B会写入y的值为3。如果两个操作之间还有对x的写入会怎样呢?假设线程Ⅲ在操作A和B之间执行了操作C: x=5,并且操作C和操作B之前并没有happen-before关系(后面我会说明时间上的先后并不一定导致happen-before关系)。这时线程Ⅱ执行操作B会讲到x的什么值呢?3还是5?答案是两者皆有可能,这是因为happen-before关系保证一定 能够观测到前一个操作施加的内存影响,只有时间上的先后关系而并没有happen-before关系可能但并不保证 能观测前一个操作施加的内存影响。如果读到了值3,我们就说读到了“陈旧 ”的数据。正是多种可能性导致了多线程的不确定性和复杂性,但是要分析多线程的安全性,我们只能分析确定性部分,这就要求找出happen-before关系,这又得利用happen-before规则。

 

下面是我列出的三条非常重要的happen-before规则,利用它们可以确定两个操作之间是否存在happen-before关系。

  1. 同一个线程中,书写在前面的操作happen-before书写在后面的操作。这条规则是说,在单线程 中操作间happen-before关系完全是由源代码的顺序决定的,这里的前提“在同一个线程中”是很重要的,这条规则也称为单线程规则 。这个规则多少说得有些简单了,考虑到控制结构和循环结构,书写在后面的操作可能happen-before书写在前面的操作,不过我想读者应该明白我的意思。
  2. 对锁的unlock操作happen-before后续的对同一个锁的lock操作。这里的“后续”指的是时间上的先后关系,unlock操作发生在退出同步块之后,lock操作发生在进入同步块之前。这是条最关键性的规则,线程安全性主要依赖于这条规则。但是仅仅是这条规则仍然不起任何作用,它必须和下面这条规则联合起来使用才显得意义重大。这里关键条件是必须对“同一个锁”的lock和unlock。
  3. 如果操作A happen-before操作B,操作B happen-before操作C,那么操作A happen-before操作C。这条规则也称为传递规则。

 

现在暂时放下happen-before规则,先探讨一下“一个操作在时间上先于另一个操作发生”和“一个操作happen-before另一个操作之间”的关系。两者有关联却并不相同。关联部分在第2条happen-before规则中已经谈到了,通常我们得假定一个时间上的先后顺序然后据此得出happen-before关系。不同部分体现在,首先,一个操作在时间上先于另一个操作发生,并不意味着一个操作happen-before另一个操作 。看下面的例子:

public void setX(int x) {
  this.x = x;               // (1)
}

public int getX() {
  return x;                 // (2)
}

假设线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,在时间上线程Ⅰ的操作A:this.x = x先于线程Ⅱ的操作B:return x。但是操作A却并不happen-before操作B,让我们逐条检查三条happen-before规则。第1条规则在这里不适用,因为这时两个不同的线程。第2条规则也不适用,因为这里没有任何同步块,也就没有任何lock和unlock操作。第3条规则必须基于已经存在的happen-before关系,现在没有得出任何happen-before关系,因此第三条规则对我们也任何帮助。通过检查这三条规则,我们就可以得出,操作A和操作B之间没有happen-before关系。这意味着如果线程Ⅰ调用了setX(3),接着线程Ⅱ调用了getX(),其返回值可能不是3,尽管两个操作之间没有任何其它操作对x进行写入,它可能返回任何一个曾经存在的值或者默认值0。“任何曾经存在的值”需要做点解释,假设在线程Ⅰ调用setX(3)之前,还有别的线程或者就是线程Ⅰ还调用过setX(5), setX(8),那么x的曾经可能值为0, 5和8(这里假设setX是唯一能够改变x的方法),其中0是整型的默认值,用在这个例子中,线程Ⅱ调用getX()的返回值可能为0, 3, 5和8,至于到底是哪个值是不确定的。

 

现在将两个方法都设成同步的,也就是如下:

public synchronized void setX(int x) {
  this.x = x;               // (1)
}

public synchronized int getX() {
  return x;                 // (2)
}

做同样的假设,线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,这时就可以得出来,线程Ⅰ的操作A happen-before线程Ⅱ的操作B。下面我们来看如何根据happen-before规则来得到这个结论。由于操作A处于同步块中,操作A之后必须定要发生对this锁的unlock操作,操作B也处于同步块中,操作B之前必须要发生对this锁的lock操作,根据假设unlock操作发生lock操作之前,根据第2条happen-before规则,就得到unlock操作happen-before于lock操作;另外根据第1条happen-before规则(单线程规则),操作A happen-before于unlock操作,lock操作happen-before于操作B;最后根据第3条happen-before规则(传递规则),A -> unlock, unlock -> lock, lock -> B(这里我用->表示happen-before关系),有 A -> B,也就是说操作A happen-before操作B。这意味着如果线程Ⅰ调用了setX(3),紧接着线程Ⅱ调用了getX(),如果中间再没有其它线程改变x的值,那么其返回值必定是3。

 

如果将两个方法的任何一个synchronized关键字去掉又会怎样呢?这时能不能得到线程Ⅰ的操作A happen-before线程Ⅱ的操作B呢?答案是得不到。这里因为第二条happen-before规则的条件已经不成立了,这时因为要么只有线程Ⅰ的unlock操作(如果去掉getX的synchronized),要么只有线程Ⅱ的lock操作(如果去掉setX的synchronized关键字)。这里也告诉我们一个原则,必须对同一个变量的 所有 读写同步,才能保证不读取到陈旧的数据,仅仅同步读或写是不够的

 

其次,一个操作happen-before另一个操作 也并不意味着 一个操作在时间上先于另一个操作发生 。看下面的例子:

x = 3;      (1)
y = 2;      (2)

同一个线程执行上面的两个操作,操作A:x = 3和操作B:y = 2。根据单线程规则,操作A happen-before操作B,但是操作A却不一定在时间上先于操作B发生,这是因为编译器的重新排序等原因,操作B可能在时间上后于操作B发生。这个例子也说明了,分析操作上先后顺序是多么地不靠谱,它可能完全违反直观感觉。

 

最后,一个操作和另一个操作必定存在某个顺序,要么一个操作或者是先于或者是后于另一个操作,或者与两个操作同时发生。同时发生是完全可能存在的,特别是在多CPU的情况下。而两个操作之间却可能没有happen-before关系,也就是说有可能发生这样的情况,操作A不happen-before操作B,操作B也不happen-before操作A,用数学上的术语happen-before关系是个偏序关系。两个存在happen-before关系的操作不可能同时发生,一个操作A happen-before操作B,它们必定在时间上是完全错开的,这实际上也是同步的语义之一(独占访问)。

 

在运用happen-before规则分析DCL之前,有必要对“操作”澄清一下,在前面的叙述中我一直将语句是操作的同义词,这么讲是不严格的,严格上来说这里的操作应该是指单个虚拟机的指令,如moniterenter, moniterexit, add, sub, store, load等。使用语句来代表操作并不影响我们的分析,下面我仍将延续这一传统,并且将直接用语句来代替操作。唯一需要注意的是单个语句实际上可能由多个指令组成,比如语句x=i++由两条指令(inc和store)组成。现在我们已经完成了一切理论准备,你一定等不及要动手开干了(我都写烦了)。

 

 

利用Happen-Before规则分析DCL

 

下面是一个典型的使用DCL的例子:

 

public class LazySingleton {
    private int someField;
    
    private static LazySingleton instance;
    
    private LazySingleton() {
        this.someField = new Random().nextInt(200)+1;         // (1)
    }
    
    public static LazySingleton getInstance() {
        if (instance == null) {                               // (2)
            synchronized(LazySingleton.class) {               // (3)
                if (instance == null) {                       // (4)
                    instance = new LazySingleton();           // (5)
                }
            }
        }
        return instance;                                      // (6)
    }
    
    public int getSomeField() {
        return this.someField;                                // (7)
    }
}

 

为了分析DCL,我需要预先陈述上面程序运行时几个事实:

  1. 语句(5)只会被执行一次,也就是LazySingleton只会存在一个实例,这是由于它和语句(4)被放在同步块中被执行的缘故,如果去掉语句(3)处的同步块,那么这个假设便不成立了。
  2. instance只有两种“曾经可能存在”的值,要么为null,也就是初始值,要么为执行语句(5)时构造的对象引用。这个结论由事实1很容易推出来。
  3. getInstance()总是返回非空值,并且每次调用返回相同的引用。如果getInstance()是初次调用,它会执行语句(5)构造一个LazySingleton实例并返回,如果getInstance()不是初次调用,如果不能在语句(2)处检测到非空值,那么必定将在语句(4)处就能检测到instance的非空值,因为语句(4)处于同步块中,对instance的写入--语句(5)也处于同一个同步块中。

有读者可能要问了,既然根据第3条事实getInstance()总是返回相同的正确的引用,为什么还说DCL有问题呢?这里的关键是 尽管得到了LazySingleton的正确引用,但是却有可能访问到其成员变量 不正确值 ,具体来说LazySingleton.getInstance().getSomeField()有可能返回someField的默认值0。如果程序行为正确的话,这应当是不可能发生的事,因为在构造函数里设置的someField的值不可能为0。为也说明这种情况理论上有可能发生,我们只需要说明语句(1)和语句(7)并不存在happen-before关系。

 

假设线程Ⅰ是初次调用getInstance()方法,紧接着线程Ⅱ也调用了getInstance()方法和getSomeField()方法,我们要说明的是线程Ⅰ的语句(1)并不happen-before线程Ⅱ的语句(7)。线程Ⅱ在执行getInstance()方法的语句(2)时,由于对instance的访问并没有处于同步块中,因此线程Ⅱ可能观察到也可能观察不到线程Ⅰ在语句(5)时对instance的写入,也就是说instance的值可能为空也可能为非空。我们先假设instance的值非空,也就观察到了线程Ⅰ对instance的写入,这时线程Ⅱ就会执行语句(6)直接返回这个instance的值,然后对这个instance调用getSomeField()方法,该方法也是在没有任何同步情况被调用,因此整个线程Ⅱ的操作都是在没有同步的情况下调用 ,这时我们无法利用第1条和第2条happen-before规则得到线程Ⅰ的操作和线程Ⅱ的操作之间的任何有效的happen-before关系,这说明线程Ⅰ的语句(1)和线程Ⅱ的语句(7)之间并不存在happen-before关系,这就意味着线程Ⅱ在执行语句(7)完全有可能观测不到线程Ⅰ在语句(1)处对someFiled写入的值,这就是DCL的问题所在。很荒谬,是吧?DCL原本是为了逃避同步,它达到了这个目的,也正是因为如此,它最终受到惩罚,这样的程序存在严重的bug,虽然这种bug被发现的概率绝对比中彩票的概率还要低得多,而且是转瞬即逝,更可怕的是,即使发生了你也不会想到是DCL所引起的。

 

前面我们说了,线程Ⅱ在执行语句(2)时也有可能观察空值,如果是种情况,那么它需要进入同步块,并执行语句(4)。在语句(4)处线程Ⅱ还能够读到instance的空值吗?不可能。这里因为这时对instance的写和读都是发生在同一个锁确定的同步块中,这时读到的数据是最新的数据。为也加深印象,我再用happen-before规则分析一遍。线程Ⅱ在语句(3)处会执行一个lock操作,而线程Ⅰ在语句(5)后会执行一个unlock操作,这两个操作都是针对同一个锁--LazySingleton.class,因此根据第2条happen-before规则,线程Ⅰ的unlock操作happen-before线程Ⅱ的lock操作,再利用单线程规则,线程Ⅰ的语句(5) -> 线程Ⅰ的unlock操作,线程Ⅱ的lock操作 -> 线程Ⅱ的语句(4),再根据传递规则,就有线程Ⅰ的语句(5) -> 线程Ⅱ的语句(4),也就是说线程Ⅱ在执行语句(4)时能够观测到线程Ⅰ在语句(5)时对LazySingleton的写入值。接着对返回的instance调用getSomeField()方法时,我们也能得到线程Ⅰ的语句(1) -> 线程Ⅱ的语句(7),这表明这时getSomeField能够得到正确的值。但是仅仅是这种情况的正确性并不妨碍DCL的不正确性,一个程序的正确性必须在所有的情况下的行为都是正确的,而不能有时正确,有时不正确。

 

对DCL的分析也告诉我们一条经验原则,对引用(包括对象引用和数组引用)的非同步访问,即使得到该引用的最新值,却并不能保证也能得到其成员变量(对数组而言就是每个数组元素)的最新值。

 

再稍微对DCL探讨一下,这个例子中的LazySingleton是一个不变类,它只有get方法而没有set方法。由对DCL的分析我们知道,即使一个对象是不变的,在不同的线程中它的同一个方法也可能返回不同的值 。之所以会造成这个问题,是因为LazySingleton实例没有被安全发布,所谓“被安全的发布”是指所有的线程应该在同步块中获得这个实例。这样我们又得到一个经验原则,即使对于不可变对象,它也必须被安全的发布,才能被安全地共享。 所谓“安全的共享”就是说不需要同步也不会遇到数据竞争的问题。在Java5或以后,将someField声明成final的,即使它不被安全的发布,也能被安全地共享,而在Java1.4或以前则必须被安全地发布。

 

关于DCL的修正

 

既然理解了DCL的根本原因,或许我们就可以修正它。

 

既然原因是线程Ⅱ执行getInstance()可能根本没有在同步块中执行,那就将整个方法都同步吧。这个毫无疑问是正确的,但是这却回到最初的起点(返朴归真了),也完全违背了DCL的初衷,尽可能少的减少同步。虽然这不能带任何意义,却也说明一个道理,最简单的往往是最好的。

 

如果我们尝试不改动getInstance()方法,而是在getSomeField()上做文章,那么首先想到的应该是将getSomeField设置成同步,如下所示:

 

    public synchronized int getSomeField() {
        return this.someField;                                // (7)
    }

 

这种修改是不是正确的呢?答案是不正确。这是因为,第2条happen-before规则的前提条件并不成立。语句(5)所在同步块和语句(7)所在同步块并不是使用同一个锁。像下面这样修改才是对的:

	public int getSomeField() {
		synchronized(LazySingleton.class) {
			return this.someField;
		}
	}

但是这样的修改虽然能保证正确性却不能保证高性能。因为现在每次读访问getSomeField()都要同步,如果使用简单的方法,将整个getInstance()同步,只需要在getInstance()时同步一次,之后调用getSomeField()就不需要同步了。另外getSomeField()方法也显得很奇怪,明明是要返回实例变量却要使用Class锁。这也再次验证了一个道理,简单的才是好的。

 

好了,由于我的想象力有限,我能想到的修正也就仅限于此了,让我们来看看网上提供的修正吧。

 

首先看Lucas Lee的修正(这里 是原帖):

	private static LazySingleton instance;
	private static int hasInitialized = 0;
		
	public static LazySingleton getInstance() {
		if (hasInitialized == 0) {											// (4)
			synchronized(LazySingleton.class) {							// (5)
				if (instance == null) {									// (6)
					instance = new LazySingleton();						// (7)
					hasInitialized = 1;
				}
			}
		}
		return instance;												// (8)
	}

如果你明白我前面所讲的,那么很容易看出这里根本就是一个伪修正,线程Ⅱ仍然完全有可能在非同步状态下返回instance。Lucas Lee的理由是对int变量的赋值是原子的,但实际上对instance的赋值也是原子的,Java语言规范规定对任何引用变量和基本变量的赋值都是原子的,除了long和double以外。使用hasInitialized==0和instance==null来判断LazySingleton有没有初始化没有任何区别。Lucas Lee对http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 中的最后一个例子有些误解,里面的计算hashCode的例子之所以是正确的,是因为它返回的是int而不是对象的引用,因而不存在访问到不正确成员变量值的问题。

 

neuzhujf的修正:

	public static LazySingleton getInstance() {
		if (instance == null) {											// (4)
			synchronized(LazySingleton.class) {							// (5)
				if (instance == null) {									// (6)
					LazySingleton localRef = new LazySingleton();
					instance = localRef;						// (7)
				}
			}
		}
		return instance;												// (8)
	}

这里只是引入了一个局部变量,这也容易看出来只是一个伪修正,如果你弄明白了我前面所讲的。

 

既然提到DCL,就不得不提到一个经典的而且正确的修正。就是使用一个static holder,kilik在回复中给出了这样的一个修正。由于这里一种完全不同的思路,与我这里讲的内容也没有太大的关系,暂时略了吧。另外一个修正是使用是threadlocal,都可以参见这篇文章

 

步入Java5

 

前面所讲的都是基于Java1.4及以前的版本,java5对内存模型作了重要的改动,其中最主要的改动就是对volatile和final语义的改变。本文使用的happen-before规则实际上是从Java5中借鉴而来,然后再移花接木到Java1.4中,因此也就不得不谈下Java5中的多线程了。

 

在java 5中多增加了一条happen-before规则:

  • 对volatile字段的写操作happen-before后续的对同一个字段的读操作。

利用这条规则我们可以将instance声明为volatile,即:

	private volatile static LazySingleton instance;

 根据这条规则,我们可以得到,线程Ⅰ的语句(5) -> 语线程Ⅱ的句(2),根据单线程规则,线程Ⅰ的语句(1) -> 线程Ⅰ的语句(5)和语线程Ⅱ的句(2) -> 语线程Ⅱ的句(7),再根据传递规则就有线程Ⅰ的语句(1) -> 语线程Ⅱ的句(7),这表示线程Ⅱ能够观察到线程Ⅰ在语句(1)时对someFiled的写入值,程序能够得到正确的行为。

 

在java5之前对final字段的同步语义和其它变量没有什么区别,在java5中,final变量一旦在构造函数中设置完成(前提是在构造函数中没有泄露this引用),其它线程必定会看到在构造函数中设置的值。而DCL的问题正好在于看到对象的成员变量的默认值,因此我们可以将LazySingleton的someField变量设置成final,这样在java5中就能够正确运行了。

 

 

遭遇同样错误

 

在Java世界里,框架似乎做了很多事情来隐藏多线程,以至于很多程序员认为不再需要关注多线程了。 这实际上是个陷阱,这它只会使我们对多线程程序的bug反应迟钝。大部分程序员(包括我)都不 会特别留意类文档中的线程不安全警告,自己写程序时也不会考虑将该类是否线程安全写入文档中。做个测试,你知道java.text.SimpleDateFormat不是线程安全的吗?如果你不知道,也不要感到奇怪,我也是在《Java Concurrent In Practice 》这书中才看到的。

 

现在我们已经明白了DCL中的问题,很多人都只认为这只不过是不切实际的理论者整天谈论的话题,殊不知这样的错误其实很常见。我就犯过,下面是从我同一个项目中所写的代码中摘录出来的,读者也不妨拿此来检验一下自己,你自己犯过吗?即使没有,你会毫不犹豫的这样写吗?

 

第一个例子:

public class TableConfig {
	//....
	private FieldConfig[] allFields;
	
	private transient FieldConfig[] _editFields;

	//....
	
	public FieldConfig[] getEditFields() {
		if (_editFields == null) {
			List<FieldConfig> editFields = new ArrayList<FieldConfig>();
			for (int i = 0; i < allFields.length; i++) {
				if (allFields[i].editable) editFields.add(allFields[i]);
			}
			_editFields = editFields.toArray(new FieldConfig[editFields.size()]);
		}
		return _editFields;
	}
}

这里缓存了TableConfig的_editFields,免得以后再取要重新遍历allFields。这里存在和DCL同样的问题,_editFields数组的引用可能是正确的值,但是数组成员却可能null! 与DCL不同的是 ,由于对_editFields的赋值没有同步,它可能被赋值多次,但是在这里没有问题,因为每次赋值虽然其引用值不同,但是其数组成员是相同的,对于我的业务来说,它们都等价的。由于我的代码是要用在java1.4中,因此唯一的修复方法就是将整个方法声明为同步。

 

第二个例子:

	private Map selectSqls = new HashMap();

	public Map executeSelect(final TableConfig tableConfig, Map keys) {
    	if (selectSqls.get(tableConfig.getId()) == null) {
    		selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));
    	}
    	PreparedSql psql = (PreparedSql) selectSqls.get(tableConfig.getId());

    	List result = executeSql(...);
        
        return result.isEmpty() ? null : (Map) result.get(0);
    }
 

上面的代码用constructSelectSql()方法来动态构造SQL语句,为了避免构造的开销,将先前构造的结果缓存在selectSqls这个Map中,下次直接从缓存取就可以了。显然由于没有同步,这段代码会遭遇和DCL同样的问题,虽然selectSqls.get(...)可能能够返回正确的引用,但是却有可能返回该引用成员变量的非法值。另外selectSqls使用了非同步的Map,并发调用时可能会破坏它的内部状态,这会造成严重的后果,甚至程序崩溃。可能的修复就是将整个方法声明为同步:

 

 

	public synchronized Map executeSelect(final TableConfig tableConfig, Map keys)  {
		// ....
    }

但是这样马上会遭遇吞吐量的问题,这里在同步块执行了数据库查询,执行数据库查询是是个很慢的操作,这会导致其它线程执行同样的操作时造成不必要的等待,因此较好的方法是减少同步块的作用域,将数据库查询操作排除在同步块之外:

    public Map executeSelect(final TableConfig tableConfig, Map keys)  {
    	PreparedSql psql = null;
    	synchronized(this) {
	    	if (selectSqls.get(tableConfig.getId()) == null) {
	    		selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));
	    	}
	    	psql = (PreparedSql) selectSqls.get(tableConfig.getId());
    	}

    	List result = executeSql(...);
        
        return result.isEmpty() ? null : (Map) result.get(0);
    }

现在情况已经改善了很多,毕竟我们将数据库查询操作拿到同步块外面来了。但是仔细观察会发现将this作为同步锁并不是一个好主意,同步块的目的是保证从selectSqls这个Map中取到的是一致的对象,因此用selectSqls作为同步锁会更好,这能够提高性能。这个类中还存在很多类似的方法executeUpdate,executeInsert时,它们都有自己的sql缓存,如果它们都采用this作为同步锁,那么在执行executeSelect方法时需要等待executeUpdate方法,而这种等待原本是不必要的。使用细粒度的锁,可以消除这种等待,最后得到修改后的代码:

	private Map selectSqls = Collections.synchronizedMap(new HashMap())
    public Map executeSelect(final TableConfig tableConfig, Map keys)  {
    	PreparedSql psql = null;
    	synchronized(selectSqls) {
	    	if (selectSqls.get(tableConfig.getId()) == null) {
	    		selectSqls.put(tableConfig.getId(), constructSelectSql(tableConfig));
	    	}
	    	psql = (PreparedSql) selectSqls.get(tableConfig.getId());
    	}

    	List result = executeSql(...);
        
        return result.isEmpty() ? null : (Map) result.get(0);
    }

我对selectSqls使用了同步Map,如果它只被这个方法使用,这就不是必须的。作为一种防范措施,虽然这会稍微降低性能,即便当它被其它方法使用了也能够保护它的内部结构不被破坏。并且由于Map的内部锁是非竞争性锁,根据官方说法,这对性能影响很小,可以忽略不计。这里我有意无意地提到了编写高性能的两个原则,尽量减少同步块的作用域,以及使用细粒度的锁 ,关于细粒度锁的最经典例子莫过于读写锁了。这两个原则要慎用,除非你能保证你的程序是正确的。

 

 

结束语

 

在这篇文章中我主要讲到happen-before规则,并运用它来分析DCL问题,最后我用例子来说明DCL问题并不只是理论上的讨论,在实际程序中其实很常见。我希望读者能够明白用happen-before规则比使用时间的先后顺序来分析线程安全性要有效得多,作为对比,你可以看看这篇经典的文章 中是如何分析DCL的线程安全性的。它是否讲明白了呢?如果它讲明白了,你是否又能理解?我想答案很可能是否定的,不然的话就不会出现这么多对DCL的误解了。当然我也并不是说要用happen-before规则来分析所有程序的线程安全性,如果你试着分析几个程序就会发现这是件很困难的事,因为这个规则实在是太底层了,要想更高效的分析程序的线程安全性,还得总结和利用了一些高层的经验规则。关于这些经验规则,我在文中也谈到了一些,很零碎也不完全。

分享到:
评论
22 楼 qiemengdao 2012-05-29  
geng2483759 写道
引用

这里,instance变量和instance.someField是两个不同的变量,这才有可能虽然instance的值与主内存是同步的,但someField的值却不与主内存同步。

private volatile static LazySingleton instance;  


请教下,这里加了volatile有用吗,它只能保证这个引用的可见性,但是instance.someField值不一定会吧?
所以这里加volatile, 只能保证当线程A执行到new LazySingleton()的时候,另一个线程B在判断instance == null时并不会出现null的这种可能。望lz解释下,thx。

个人觉得这个volatile对双重检查正确性并没有用,这只能保证可见性。最终双重检查的正确实现依赖于JAVA5之后JVM自己的改进。
21 楼 geng2483759 2009-09-28  
引用

这里,instance变量和instance.someField是两个不同的变量,这才有可能虽然instance的值与主内存是同步的,但someField的值却不与主内存同步。

private volatile static LazySingleton instance;  


请教下,这里加了volatile有用吗,它只能保证这个引用的可见性,但是instance.someField值不一定会吧?
所以这里加volatile, 只能保证当线程A执行到new LazySingleton()的时候,另一个线程B在判断instance == null时并不会出现null的这种可能。望lz解释下,thx。
20 楼 agapple 2009-03-10  
非常精彩的文章,总于明白DCL的真正含义了
19 楼 mercyblitz 2009-03-10  
lifethinker 写道
大家都理解DCL了吗,还是不理解我这里讲的?



谢谢,我看过楼主提到的那文章,JDK5修正了JMM以前的不足,并且volatile和final有了“新”的语义。

不知道楼主看了Googletalk的视频没有,他谈到了一些使用volaitle的用途和使用,还有其他的方面。


这里贴出来,给大家参考!
地址:http://www.youtube.com/watch?v=1FX4zco0ziY&feature=channel_page

18 楼 mercyblitz 2009-03-10  
非常感谢楼主提供的文章,不过我有一个提议哦,如果楼主先讲讲reorder会更好,在文章中的HB的前提-由于JMM为了提高在多核CPU执行效率,它会对执行的代码排序,也就你说的时间上面的HB,而并且不是执行上的HB行为。

呵呵,如果我没有记错的话,在JLS说,HB是全序,而不是遍序。


PS:去年的时候,我找这样文章太久了,在整个国内,后来没有办法,只能去看Doug Lea的文章,不过还是一些同步实现不理解,希望不吝赐教哦!呵呵
17 楼 dlovek 2009-02-16  
写的非常好。
16 楼 moshalanye 2009-02-13  
    楼主 :) 我知道 你用transient的原因,只是因为你这篇文章提到的是多线程问题,而不是序列化问题,所以用不用transient不是关键,说是为了展示问题应该没错麻!
    你提供的文章我会仔细看得,先谢了。用同步实现内存可见你的文章讲的就是这个,所以我也就没有必要造次,再去提这个方式了,楼主,我也是仔细的看懂了你的文章的。
  :)
15 楼 marlonyao 2009-02-13  
<div class="quote_title">moshalanye 写道</div>
<div class="quote_div">但首先有一点,initialized immediately 是否会利用某种 方式来保证其初始化所有操作的内存可见性,而我思考native方法导致这点的实现正好是一种现存的方式。 </div>
<p> 用同步就可以保证所有操作的内存可见性。看一下<a href="http://java.sun.com/docs/books/jls/second_edition/html/execution.doc.html#44630">这里</a>吧,或许有用。</p>
14 楼 marlonyao 2009-02-13  
<div class="quote_title">FaJa 写道</div>
<div class="quote_div">
<pre name="code" class="java">public class TableConfig {
//....
private FieldConfig[] allFields;

private transient FieldConfig[] _editFields;

//....

public FieldConfig[] getEditFields() {
if (_editFields == null) {
List&lt;FieldConfig&gt; editFields = new ArrayList&lt;FieldConfig&gt;();
for (int i = 0; i &lt; allFields.length; i++) {
if (allFields[i].editable) editFields.add(allFields[i]);
}
_editFields = editFields.toArray(new FieldConfig[editFields.size()]);
}
return _editFields;
}
}</pre>
<p> </p>
<p>lz,你的_editFields为何声明为transient??而不是volatile?</p>
</div>
<p> </p>
<p>噢,给你造成误解了,这里用transient没有特别的意思。我喜欢用transient来代表这个字段表示导出的字段(即这个字段可以由其它字段计算出来),并且我还喜欢把这样的字段名称以"_"开头。这只是我个人的风格,不过我认为这是有好处的。如果TableConfig实现了Serializable接口,那么将_editFields标识为transient绝对是明智的,不同的实现可能有或者没有这个字段。</p>
<p> </p>
<p>也不是如moshalanye所说,是为了展示问题,这样的话我直接声明为private就可以了,另外这个项目使用的jdk还是1.4。</p>
<p> </p>
<p>p.s. 我就是楼主,只是用了个不同的用户名,如果造成麻烦请见谅!</p>
13 楼 moshalanye 2009-02-13  
marlonyao 写道
@moshalanye:
你没必要深入细节当中,不能以“最终调用的是native方法”作为解释的理由,因为全部用java写个虚拟机是完全可能的。

在一个类没有初始化完成时,是不可能做对这个类做任何事情的(包括创建它的实例,访问静态变量和方法)。

直接引用JLS,类初始化做的事:

Initialization of a class consists of executing its static initializers and the initializers for static fields (class variables) declared in the class.


类初始化发生在什么时候?JLS这样说:
A class or interface type T will be initialized immediately before the first occurrence of any one of the following:

    * T is a class and an instance of T is created.
    * T is a class and a static method declared by T is invoked.
    * A static field declared by T is assigned.
    * A static field declared by T is used and the field is not a constant variable (§4.12.4).
    * T is a top-level class, and an assert statement (§14.10) lexically nested within T is executed.

对你这个例子来说,访问Test的静态变量必定导致类被初始化(A static field declared by T is used and the field is not a constant variable),初始化就会执行static initializers,也就是设置Test.test的值为2。一旦初始化完成,只能得到Test.test的值为2。



纯粹是一种学习的思考,学习java基础时我已经明白主动调用导致主动装载,我明白什么时候会触发类装载,而你说的纯粹的使用java来写一个虚拟机,我们暂时不去思考它的可行性(用什么去操作内存,总是有依赖,不依赖C,难道不依赖汇编,不依赖os指令?)。但首先有一点,initialized immediately 是否会利用某种 方式来保证其初始化所有操作的内存可见性,而我思考native方法导致这点的实现正好是一种现存的方式。
   
  
   
   
引用
z,你的_editFields为何声明为transient??而不是volatile?

    声明为volatile的情况,lz 已经作了解答,仔细看文章就知道啊,不声明volatile 是为了展示会存在的问题。



    学习本来就是一个细致的事情,我不想在意识到问题的时候不去寻求一些可以涉及的知识,而且这个不正是学习语言而又不局限于语言的一种方式,所以在论坛上我一直都很尊重这种细致求学的javaeye们,即使被人觉得没有必要,但从长远来看,他们始终会是走在前面的人。
12 楼 FaJa 2009-02-13  
<pre name="code" class="java">public class TableConfig {
//....
private FieldConfig[] allFields;

private transient FieldConfig[] _editFields;

//....

public FieldConfig[] getEditFields() {
if (_editFields == null) {
List&lt;FieldConfig&gt; editFields = new ArrayList&lt;FieldConfig&gt;();
for (int i = 0; i &lt; allFields.length; i++) {
if (allFields[i].editable) editFields.add(allFields[i]);
}
_editFields = editFields.toArray(new FieldConfig[editFields.size()]);
}
return _editFields;
}
}</pre>
<p> </p>
<p>lz,你的_editFields为何声明为transient??而不是volatile?</p>
11 楼 marlonyao 2009-02-13  
@moshalanye:
你没必要深入细节当中,不能以“最终调用的是native方法”作为解释的理由,因为全部用java写个虚拟机是完全可能的。

在一个类没有初始化完成时,是不可能做对这个类做任何事情的(包括创建它的实例,访问静态变量和方法)。

直接引用JLS,类初始化做的事:

Initialization of a class consists of executing its static initializers and the initializers for static fields (class variables) declared in the class.


类初始化发生在什么时候?JLS这样说:
A class or interface type T will be initialized immediately before the first occurrence of any one of the following:

    * T is a class and an instance of T is created.
    * T is a class and a static method declared by T is invoked.
    * A static field declared by T is assigned.
    * A static field declared by T is used and the field is not a constant variable (§4.12.4).
    * T is a top-level class, and an assert statement (§14.10) lexically nested within T is executed.

对你这个例子来说,访问Test的静态变量必定导致类被初始化(A static field declared by T is used and the field is not a constant variable),初始化就会执行static initializers,也就是设置Test.test的值为2。一旦初始化完成,只能得到Test.test的值为2。
10 楼 moshalanye 2009-02-12  
     呵呵,楼主的确是辛苦,你给我的解答,我也是这么想的,主动类装载始终是可见的,我写成这样就是因为看了你的帖子后不确定是否也存在这样的多线程问题,第一个主动调用而loader Test类的Thread,另外一个Thread 同时访问Test的静态变量,是否会出现 Test.class 已经非空,而static 变量static 块中附的值仍然不可见。
    我原本个人觉得class 非空的时候,应该肯定是class对象完全初始化完成,但却从来没有思考原因,就是因为仔细看了你的这篇文章后,才有了这个疑惑。
    但我仔细想想后猜想应该是这样的,classloader 类装载最终调用的是native方法,当一个主动调用Test的线程执行的时候,调用的native方法获取class文件流并获得class对象值,是C直接操作内存,它返回的class对象就已经内存可见了,它已经脱离了jvm对内存操作的指令排序,所以Test.class 被赋值的时候,class对象就内存可见了。
     不管怎么说得感谢楼主的帖子,让我有了这些思考。:)希望高手指正我的思考不对的地方,也希望这种思考对其他人有用。
9 楼 galaxystar 2009-02-12  
写得不错.
我一般用static内部类解决DCL问题.
8 楼 lifethinker 2009-02-12  
我几乎完全重写了整篇文章,花费了我整整一个工作日,只是希望写得更清楚一些。
7 楼 lifethinker 2009-02-12  
<div class="quote_title">moshalanye 写道</div>
<div class="quote_div">    十分感谢楼主这么细致的解说,很多东西更加清晰明朗,而且楼主也提到了一个很容易忽视的问题,static synchronized  和  非static synchronized 锁对象不同,一个是class对象的 隐含锁, 一个 this 对象的隐含锁,那么我还有一个问题想让楼主和大拿帮我讲解下。<br /><br />public class Test {<br />static int test;<br />    static{  <br />        test = 2;   <br />    }<br /><br />    public static void main(String args[]){<br />       for(int i=0;i&lt;10;i++){<br />           new Thread(new TT()).start();<br />       }<br />    }<br />    <br />    static class TT implements Runnable{<br />        public void run() {<br />            System.out.println(Test.test);<br />        }<br />    }<br />}<br /><br />在多线程访问 test的时候,是否也有线程问题?static 块的发生,类对象的初始化,一定是在完成之后才做其他操作,也就是类对象的初始化操作始终都是可见的。<br />    我大致测了下,没看到0的出现.<br />我也因为这个原因,经常使用static 块来初始化声明的 final变量来解决多线程的访问问题,但是曾经有人提过这么写不好,但始终也没提高不好的原因,而我就个人理解,唯一的问题也就是破坏可控异常的处理和代码的易读性而已,而且很多情况资源装载的成功就是程序正常运行的前提。我总觉得这种资源的异常是非可控异常,完全没有抓取的必要,希望大虾们能指正</div>
<p><br /><br />多线程程序是很难测试的,当然测试还是相当有帮助的,最重要的是能够自己分析可能存在的竞争访问。<br /><br />这个程序在多线程访问test时是不会有问题的。只需要知道static语句是在<span style="text-decoration: line-through;">类加载(并被解析)</span>类初始化时就被执行的。在执行Test.test这条语句时,必须先完成Test这个类的加载并初始化,这个过程又必定设置test的值为2,因此Test.test总会得到2,这也是为什么LazySingleton的Static Holder修正能够行得通的原因。<br /><br />当然如果别的线程修改了Test.test的值,那么其它的线程可能看不到修改后的值。</p>
<p> </p>
6 楼 lifethinker 2009-02-11  
发了好久了,居然有人关注了。我得承认的文章表达得不好,很庆幸有人能够看懂。

@joachimz:
一般来说,如果局部变量的赋值不是在同步块中做,引入局部变量很少起作用。

这里引入localRef不起任何作用。同样地,虽然对localRef的引用能够得到正确的值,但是对它的实例变量someField却仍然可能得到初始值。

用happen-before规则来分析的话,当线程A执行完getInstance()方法之后,这里线程B再执行getInstance()方法,这里假定线程B已经观察到instance的值不为null,这里线程B直接返(不执行同步块)回instance的值,由于没有任何happen-before规则(因为线程B的操作都没有在同步块中执行)保证线程A的执行new Singleton()的语句happen-before线程B的操作,也就是说线程B无法看到A执行new Singleton()所造成的内存效果--设置someField变量的值,因而理论上(仅仅是理论上,实际上往往看到正确的值)线程B可能得到someField的默认值0。

这里的解释和我文中的解释完全一样,这里这再重复一遍是想说得更清楚一些。



稍微再作下说明,每个线程之所以可能看到同一个变量的不同值,是因为java虚拟机的内存模型决定的,它允许每个线程有自己的内存,这样每个线程都可能有变量的一份备份,适当的时候它会跟主内存里变量进行同步。这里,instance变量和instance.someField是两个不同的变量,这才有可能虽然instance的值与主内存是同步的,但someField的值却不与主内存同步。
5 楼 moshalanye 2009-02-10  
    十分感谢楼主这么细致的解说,很多东西更加清晰明朗,而且楼主也提到了一个很容易忽视的问题,static synchronized  和  非static synchronized 锁对象不同,一个是class对象的 隐含锁, 一个 this 对象的隐含锁,那么我还有一个问题想让楼主和大拿帮我讲解下。

public class Test {
static int test;
    static{ 
        test = 2;  
    }

    public static void main(String args[]){
       for(int i=0;i<10;i++){
           new Thread(new TT()).start();
       }
    }
   
    static class TT implements Runnable{
        public void run() {
            System.out.println(Test.test);
        }
    }
}

在多线程访问 test的时候,是否也有线程问题?static 块的发生,类对象的初始化,一定是在完成之后才做其他操作,也就是类对象的初始化操作始终都是可见的。
    我大致测了下,没看到0的出现.
我也因为这个原因,经常使用static 块来初始化声明的 final变量来解决多线程的访问问题,但是曾经有人提过这么写不好,但始终也没提高不好的原因,而我就个人理解,唯一的问题也就是破坏可控异常的处理和代码的易读性而已,而且很多情况资源装载的成功就是程序正常运行的前提。我总觉得这种资源的异常是非可控异常,完全没有抓取的必要,希望大虾们能指正
4 楼 joachimz 2009-01-23  
谢谢,第一次认真看DCL的解释。

那如果增加一个临时变量,是否正确呢?

public static LazySingleton getInstance() {  
    LazySingleton localRef  = instance;
    if (localRef == null) {                 // (4)   
        synchronized(LazySingleton.class) { // (5)   
            if (instance == null) {         // (6)   
                instance = new LazySingleton();   
            }   
            localRef = instance ;       // (7)   
        } 
    }   
    return localRef;     // (8)   
}  

3 楼 kilik 2009-01-23  
对于实现单例模式,effective java 2nd中提示了一个比较简单而且安全的实现方法。

public class Singleton {

  private Singleton() {}

  // Lazy initialization holder class idiom for static fields
  private static class InstanceHolder {
   private static final Singleton instance = new Singleton();
  }

  public static Singleton getSingleton() { 
    return InstanceHolder.instance; 
  }
}


利用的原理是:一个类直到被使用时才被初始化,而类初始化的过程是非并行的,这些都由JLS保证。

相关推荐

    第29讲Java内存模型中的happen-before1

    在 JMM 中,`happen-before` 规则是定义这些同步特性的重要概念。它是一种偏序关系,确保了在程序中两个操作之间的执行顺序,并且保证了前一个操作的结果对后一个操作可见。以下是一些 `happen-before` 的典型规则:...

    深入浅出了解happens-before原则

    "深入浅出了解happens-before原则" 在 Java 内存模型(JMM)中,happens-before 原则是一个核心概念,它规定了多线程环境下的可见性原则。该原则的核心思想是:两个操作之间的 happens-before 关系,并不意味着前一...

    Crashes Happen - Downtime Won't with Data Guard

    "Crashes Happen - Downtime Won't with Data Guard"这个主题强调了即使在系统崩溃的情况下,通过Data Guard也能避免停机时间,确保业务连续性。 Data Guard的核心是通过创建一个或多个备用数据库(standby ...

    volatile与happens-before的关系与内存一致性错误

    5. **程序次序规则**:同一个线程内的静态初始化和实例初始化按照代码的顺序happens-before。 在生产者消费者例子中,为了解决内存一致性错误,可以使用synchronized关键字来保证原子性和互斥性,或者使用java.util...

    volatile可见性的一些认识和论证

    本文探讨了Java中volatile关键词的使用和可见性问题,我们将结合JVM内存模型和happen-before概念,来讨论volatile的使用场景和例子。 一、happen-before happen-before是一个可见性原则,定义了Java语言中操作的...

    并发编程基础知识,java内存模型及多线程、volatile

    Happen-Before规则是一组用于保证线程之间执行顺序的规则,主要包括: - 程序顺序原则:在一个线程内部保证语义的串行性。 - volatile规则:volatile变量的写操作先于任何读操作。 - 锁规则:解锁操作必然发生在...

    make-fetch-happen:使获取发生在npm

    $ npm install --save make-fetch-happen 目录 fetch.defaults minipass-fetch选项 make-fetch-happen选项 opts.cacheManager opts.cache opts.proxy opts.noProxy opts.ca, opts.cert, opts.key opts....

    JSR133中文版,JSR133中文版

    Happen-Before内存模型是JSR-133中定义的一种内存模型,它描述了操作之间的顺序关系。这种内存模型可以用来确保在多线程环境下,操作的执行顺序是正确的。 JSR-133是Java语言中非常重要的规范,它定义了Java语言的...

    leetcode数组下标大于间距-happen-code:进入Leetcode~

    happen-code Get into Leetcode~ Code List 动态规划 动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式...

    Java 高并发三:Java内存模型和线程安全详解

    例如,线程的启动操作Happen-Before该线程的任何读写操作,`synchronized`块的结束Happen-Before同一锁的下次获取,以及对`volatile`变量的写操作Happen-Before其他线程对这个变量的读操作。 线程安全是指在多线程...

    ERP Make it Happen

    ERP Make it Happen ERP Make it Happen ERP Make it Happen ERP Make it Happen

    JVM面试题分享给需要的同学.zip

    6JVM内存模型的相关知识了解多少,比如重排序,内存屏障,happen-before,主内存,工作内存 7.简单说说你了解的类加载器,可以打破双亲委派么,怎么打破 8.说说你知道的几种主要的JVM参数 9.怎么打出线程栈信息 10....

    Project Make It Happen-crx插件

    Project Make It Happen Extension是一个活动日历,可以帮助我们的团队随时了解最新事件,推送通知和每日激励中心。 通过单击第一个选项卡上包含的链接登录到您的后台。 单击第一个选项卡上的日历将跳至第二个选项卡...

    make-fetch-happen:加入失败者,我们正在提出要求!

    安装$ npm install --save make-fetch-happen 目录例子const fetch = require ( 'make-fetch-happen' ) . defaults ( { cacheManager : './my-cache' // path where cache will be written (and read)} )fetch ( '...

    make-it-happen-jsonforms:使其成为JSON表单的示例

    请改为使用 。 让它发生的例子 使其成为示例 入门 首先,您可以简单地克隆此存储库并安装所有必需的依赖项。 先决条件 您需要git来克隆MiHexample存储库。 您可以从获得git。 我们还使用了许多node.js工具来初始化...

    JAVA并发编程阿里巴巴.pptx

    - **Happen-Before规则**:确保代码执行的有序性,从而保证了数据的可见性。 - 程序顺序规则:代码按照程序顺序执行。 - 监视器锁定规则:对一个监视器的解锁一定发生在后续对同一监视器加锁之前。 - volatile...

    浅谈java指令重排序的问题

    例如,可以使用"Happen-Before"的概念来描述指令重排序的规则。在"Happen-Before"中,如果操作A happens-before 操作B,则操作A的结果对操作B是可见的。 此外,还有一个重要的概念是“as-if-serial”,即:不管怎么...

Global site tag (gtag.js) - Google Analytics