从 DCL 的对象安全发布谈起

lock

对于 DCL(Double Check Lock)情况下的对象安全发布,一直理解得不足够清楚;在通过和同事,以及和互联网上一些朋友的讨论之后,我觉得已经把问题搞清楚了。我把我对这个问题的理解简要记录在这里。

现在有代码 A:

class T {
	private static volatile T instance;
	public M m; // 这里没有 final 修饰

	public static T getInstance() {
		if (null == instance) {
			synchronized (T.class) {
				if (null == instance) {
					instance = new T();
					instance.m = new M();
				}
			}
		}
		return instance;
	}
}

以及代码 B: 

class T {
	private static volatile T instance;
	public M m; // 这里没有 final 修饰

	public static T getInstance() {
		if (null == instance) {
			synchronized (T.class) {
				if (null == instance) {
					T temp = new T();
					temp.m = new M();
					instance = temp;
				}
			}
		}
		return instance;
	}
}

这两段代码是否做到了对象安全发布?

这里需要稍微解释一下,所谓对象安全发布,在这里可以这样理解,有一个线程 X 调用 getInstance 方法,第一次来获取对象,instance 为空,这个时候进入同步块,初始化了 instance 并返回;这以后另一个线程 Y 也调用 getInstance 方法,不进入同步块了,获取到的 instance 对象是否 一定 是预期的—— 即对象的 m 属性不为空?如果是,表示对象被安全发布了,反之则不是。

happens-before 一致性

仔细读了读 JSR-133 的规范文档,里面定义了 happens-before(hb)一致性:

Happens-before consistency says that a read r of a variable v is allowed to observe a write w to v if, in the happens-before partial order of the execution trace:

  • r is not ordered before w (i.e., it is not the case that r hb w), and 
  • there is no intervening write w' to v (i.e., no write w' to v such that w hb w' hb r).

这就是说,如果任何时候在满足以下这样两个条件的情况下,对一个对象的读操作 r,都能得到对于对象的写操作 w 的结果(读的时候要能返回写的结果),我们就认为它就是满足 happens-before 一致性的:

  • 读必须不能发生在写操作之前;
  • 没有一个中间的写操作 w' 发生在 w 和 r 之间。

满足这样一致性的内存模型,是一种极度简化的内存模型,它允许 JVM 实现的时候,对于绝大多数情况下不需要满足 happens-before 的对象和操作,可以在保证单个线程运行结果正确的情况下做尽可能多的优化, 比如代码乱序执行,比如从主内存中缓存某些变量到寄存器等等

volatile 和 happends-before 的关系

A write to a volatile field happens-before every subsequent read of that volatile.

就是说,对于 volatile 修饰的属性的读写操作满足 happens-before 一致性。

再结合代码来看,代码 A 对于 m 的赋值发生在 volatile 修饰的 instance 之后,不能保证线程 X 中给 instance 的属性赋的值 new M() 能被线程 Y 看到;而代码 B 所有对于实例初始化的操作都放 instance=temp;(即对 volatile 修饰的属性 instance 的写操作)之前,这些操作的结果都是“ 可见的”。也就是说,代码 A 无法安全发布对象,但是代码 B 可以。

需要说明的是,如果对于代码 B,干脆去掉属性 m,但是也拿掉 volatile,变成如下情况呢?

class T {
	private static T instance;

	public static T getInstance() {
		if (null == instance) {
			synchronized (T.class) {
				if (null == instance) {
					instance = new T();
				}
			}
		}
		return instance;
	}
}

这种情况下对象又无法安全发布了,因为没有了 volatile 的约束, 对象初始化的行为和把对象赋给 instance 的行为是乱序的(前面已经介绍过了,只需要保证结果正确即可,在这里就是保证 getInstance 方法返回的结果是正确的;但是,在 getInstance 方法内部,当 instance 不为空的时候,T 的初始化行为却未必已经完成),这个就有可能取到一个没有初始化完成的残缺的对象。

除了 volatile 关键字以外,还有哪些情况下也满足 happens-before 一致性呢?

  • Each action in a thread happens-before every subsequent action in that thread.
  • An unlock on a monitor happens-before every subsequent lock on that monitor.
  • A write to a volatile field happens-before every subsequent read of that volatile.
  • A call to start() on a thread happens-before any actions in the started thread.
  • All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
  • If an action a happens-before an action b, and b happens before an action c, then a happens- before c.

简单说,就是同一个线程的后续行为,加锁,启动子线程,线程 join() 操作和满足传递性的三个操作这六种情况,其他所有的情况都不具备 happens-before 一致性。值得一提的是其中的第一条,需要理解其中的“subsequent action”(后续行为),比如调用一个方法返回的结果应当是正确的,类的每一条静态语句的执行结果也是正确的。这是 hb 内存模型在降低约束、提供更多优化可能的同时,必须要做到的正确性上的保证。

final 在 JSR-133 中的增强

由于 final 的值本身是不可被重写入的(所谓的“ 不变” 对象),那么编译器就可以针对这一点进行优化:

Compilers are allowed to keep the value of a final field cached in a register and not reload it from memory in situations where a non-final field would have to be reloaded.

编译器可以把 final 修饰的属性的值缓存在寄存器里面,并且在执行过程中不重新加载它。

但是,如果对象属性不使用 final 修饰,在构造器调用完毕之后,其他线程未必能看到在构造器中给对象实例属性赋的真实值(除非有其他可行的方式保证 happens-before 一致性,比如前面提到的代码 B):

A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object’s final fields.

仅当在使用 final 修饰属性的情况下,才可以保证在对象初始化完成之后,外部能够看到对象正确的属性值。

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; // guaranteed to see 3
			int j = f.y; // could see 0
		}
	}
}

这个例子正式规范里面给出的,上面属性 x 使用了 final 修饰,而 y 没有,在某一时刻,一个线程调用 writer() 的时候,FinalFieldExample 被初始化;之后另一个线程去取得这个对象,首先最开始的时候 f 未必一定不为空,因为 f 并没有任何 happens-before 一致性保证(f 可能被赋了一个构造并未完成的对象),其次对于属性 x,由于 final 关键字的效应,f 不为空的时候,f 已经初始化完成,所以 f.x 一定为准确的 3,但是 f.y 就不一定了。

还有其它的单例对象安全发布的方式:

public class T {
  private static final T instance = new T(); // final 可少吗?
  public final M m = new M(); // final 可少吗?

  public static T getInstance() {
    return instance;
  }
}

这种是很常见的,还有一种叫做 Initialization On Demand Holder 的方式:

class T {
	public final M m = new M(); // final 可少吗?

	private static class LazyHolder {
		public static T instance = new T();
	}

	public static T getInstance() {
		return LazyHolder.instance;
	}
}

这两段代码在不使用的时候都可以保证对象安全发布的,因为 这种写法下,对于属性的初始化会在对象的构造器调用前完成 ,这是前面说的 happens-before 的第一种(Each action in a thread happens-before every subsequent action in that thread.),属于对程序正确性的要求。

附件:JSR-133 规范下载

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

20,669 次阅读

8 thoughts on “从 DCL 的对象安全发布谈起

  1. <code>class T {
        private static T instance;
     
        public static T getInstance() {
            if (null == instance) {
                synchronized (T.class) {
                    if (null == instance) {
                        instance = new T();
                    }
                }
            }
            return instance;
        }
    }</code>
    这种写法错误的原因是什么呢?synchronized 的语义保证了在退出语句块时本地内存刷新
    主内存。假设 A 线程内 new T() 是乱序的,但是 B 线程读取 instance 变量是主内存中的,应该还是 null,因为 A 线程还没退出 synchronize 块,还没来得及回写主内存啊。

    1. 因为 synchronized 关键字,线程 B 检查发现 instance 确实不为空,所以就不进入同步块,这没有问题,然而,线程 B 得到的 instance 却未必是一个已经完全实例化了的 T 对象,换言之,并没有任何途径保证“new T()”happens-before“ 给 instance 的赋值”。

      如果你还是无法理解,可以看看这篇文章 http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 帮助你理解

      1. 这个问题我理解了,是因为 A 线程的解锁操作和 B 线程对 instance 的第一次检查(同
        步块之前的)之间没有 happens-before 关系,线程 A 虽然执行了刷回主内存的操作,
        但是线程 B 无法保证能读到正确的。因为脱离了监视器之间的 happens-before 约束。

  2. class T {
        private static volatile T instance;
     
        public static T getInstance() {
            if (null == instance) {
                synchronized (T.class) {
                    if (null == instance) {
                        instance = new T();
                    }
                }
            }
            return instance;
        }
    }
     
     
    如果是以上写法,是安全发布么?
    instance = new T();
    这一句是不是相当于
    T temp = new T();
    instance = temp;
    呢?
    instance = new T(); 不是一个原子写操作,会有问题么?

    1. 是安全发布。就是因为 volatile 保证了“new T()” 必定 happens-before 于“ 对 instance 的赋值”。

      T temp=new T(); instance=temp; 这一小段确实不是原子操作,但是,因为 instance 有 volatile 修饰,那么无论赋值前怎么个执行顺序,都一定 happens-before 于给 instance 赋值这一步之前,因此是没有问题的。

      1. 为什么 volatile 能保证 new T() 必定 happens-before 于“ 对象 instance 的赋值”?volatile 只是保证每次的读操作都能读到最后写的内容。写成 T temp = new T();instance = temp; 是因为可以利用 Each action in a thread happens-before every subsequent action in that thread。再利用 valotile 的写 happens-before 读。可以参考 http://www.infoq.com/cn/articles/java-memory-model-4。
         
         
         
         

  3. final 怎么从 jmm 角度理解?没明白 final 是怎么保证可见性的。如果 final 的是一个对象呢?final 保证引用不变,但如何保证属性值发生变化时的可见性呢?

    1. 请阅读规范,这是我的理解:

      final 假定你的对象是事实不可变的,并且保证在构造方法返回的时候,取得的是正确构造完成的对象(也包括这个对象内部的引用对外真实可见);但是这以后的时间通过调用 final 修饰的对象的方法,引起 final 对象内部的改变,规范里面并没有说明(我没有找到),因此是无法保证的。

      when the object is seen by another thread, that thread will always see the correctly constructed version of that object’s final fields. It will also see versions of any object or array referenced by those final fields that are at least as up-to-date as the final fields are.

发表评论

电子邮件地址不会被公开。

back to top