Java并行(4):线程安全前传之Singleton

1.寂寞的Singleton

    如果你是一名OO程序员,Singleton的名字对你来说就不会陌生,它是GoF设计模式的一种,江湖人称“单例”的便是;即便你不是OO程序员,中国人你总该是吧?那么下面一段你应该也会背:“世界上只有一个敏感词,敏感词是敏感词的一部分,敏感词是代表敏感词的唯一合法敏感词,任何企图制造两个敏感词的企图都是注定要失败的。”说的多么好!一语道破Singleton的真谛。但是,为了让帖子存活下去,为了更好地娱乐大众,下面我们从“敏感词系统”转到“世界杯系统”,我们来看:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private static WorldCup instance = new WorldCup();
	
	public static WorldCup getInstance(){
		return instance;
	}
}

    这就是一个极为简易的Singleton范例,但是雷米特同学看到这个类估计哭得心思都有了:这里的instance是eager initialization,也就是说作为世界杯的发起人,雷米特小朋友必须在提出“世界杯”这个概念的时候,就自己掏钱铸一座金杯撂那,这赛事成不成还两说。搁谁谁也不乐意。那这事咋整?雷米特老婆温柔地说,“你个完蛋败家玩意,那就等破世界杯板上钉钉,第一届举办的时候再造呗!”真是一语惊醒梦中人,雷米特立刻打开IDE,敲出下面的代码:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private static WorldCup instance;
	
	public static WorldCup getInstance(){
		if(instance == null) //A
			instance = new WorldCup(); //B
		return instance;
	}
}

    雷米特长出一口气,这回lazy initialization,总高枕无忧了吧~

2. 当Singleton遇见多线程

    这时,温柔贤惠的老婆又发话了:“你傻啊?倒霉玩意你想造多少个破杯啊?杯具啊,我~不~活~了~~我错了,我从一开始就错了,如果我不嫁过来……”雷米特表示理解不能,这么NB的代码错在哪了?

    观众朋友们来分析一下,是什么让雷米特的老婆如此伤心欲绝呢?绝大部分朋友应该已经知道了,那就是多线程的问题。在A和B之间存在一个时间差,可能有t1,t2两个线程,t1检测instance为null,没有继续执行B而是被切走,t2又检测到instance为null,这下,两个世界杯就被造出来了。Singleton名不副实。那应该如何?你可能已经对我的傻逼描述烦不胜烦了,加锁呗:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private static WorldCup instance;

	public static WorldCup getInstance() {
		synchronized (WorldCup.class) {
			if (instance == null) // A
				instance = new WorldCup();// B
			return instance;
		}
	}
}

    问题解决,不是吗?对,但不那么完美。我们知道,加锁/放锁是很费的操作,这里完全没有必要每次调用getInstance都加锁,事实上我们只想保证一次初始化成功而已,其余的快速返回就好了。

3. 又见DCL

    那也不难,用传说中的“双检锁”(Double-Checked Lock)即可:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private static WorldCup instance;

	public static WorldCup getInstance() {
		if (instance == null) { //C
			synchronized (WorldCup.class) {
				if (instance == null) // A
					instance = new WorldCup();// B
			}
		}
		return instance;
	}
}

   新加的C操作过滤掉了大量的“快速返回”,让程序只有在真正需要加锁时才去加锁,效率大涨。雷米特大喜过望,终于可以和老婆交差了。但是,结束了么?

4.安全发布

    “结束了么?”一段时间之前的一次电话面试中,面对同样的问题,面试官不怀好意地问。我立刻深深地觉得我被这两个臭不要脸的家伙彻底调戏了,万分纠结地败下阵来。相信雷米特再次面对他老婆时,也会有相同的感受。那么,看似精巧的DCL,会有什么问题呢?我们要从“安全发布”谈起。

    所谓对象的“发布”(publish),是指使他能够让当前范围以外的代码使用。为了方便理解,列举几种发布对象的常用方法:

  • 把对象的引用存到公共静态域里。
  • 把一个对象传递给一个“外部方法”,所谓外部方法是指其他类的方法或者自身可以被子类重写的方法(因为你不知道这些方法会有些什么动作)
  • 发布一个Inner Class的实例。这是因为每个Inner Class实例都保存了外部对象的引用。

    另外需要记住的规则是“当发布一个对象时,实际上隐式地发布了他的所有非私有域对象”。   

    发布对象并不可怕,可怕的是错误地发布对象。当一个对象还没有做好准备时就将他发布,我们称作“逃逸”(escape)。举一个简单的例子:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class Argentina {
	private boolean isMessiReady;

	public Argentina() {
		new Thread(new Runnable() {
			@Override
			public void run() {
				tell();  //Argentina.this
			}
		}).start();
		isMessiReady = true;
	}

	void tell() {
		System.out.println("Is Messi Here?:" + isMessiReady);
	}
}

    阿根廷队队伍还没组建好就开新闻发布会,这时Messi到底在不在呢?老马可能在放烟雾弹。这里的对象发布属于我们上面提到的第三种,即发布内部类,因为这里的Thread其实是用一个匿名内部类Runnable实现的,新线程可以访问到外部类,在我们加注释的那一行其实隐含的访问了外部类Argentina.this。这属于臭名昭著的“this逃逸”,常见情景包括了“在构造函数中添加listener,启动新线程或者调用可重写方法”。

    其实说白了,所谓逃逸,无非是对象“发布”和另一个线程访问该对象之间没有正确的Happens-before关系

    回过头来看我们上面的DCL,他虽然不是“this逃逸”,但也属于肇事逃逸的一种。一个线程t1的B操作和另一线程t2的C操作之间没有HB关系,也就是对instance的读写没有同步,可能会造成的现象是t1-B的new WorldCup()还没有完全构造成功,但t2-C已经看到instance非空,这样t2就直接返回了未完全构造的instance的引用,t2想当然地对instance进行操作,结果是微妙的。

    看到这里,结合上一次的讨论,可能你已经明白了,“说了这么久,原来还不是劳什子的可见性问题,翠花,上volatile~”

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private volatile static WorldCup instance;

	public static WorldCup getInstance() {
		if (instance == null) { //C
			synchronized (WorldCup.class) {
				if (instance == null) // A
					instance = new WorldCup();// B
			}
		}
		return instance;
	}
}

   “这下,你,们,该,满,足,了,吧?”

5. Yet another 解决方法

    恭喜你,成功了。但是,其实我还想说,恭喜你,你out了。随着时代的发展JVM的进步,DCL这样的技巧已经逐步被淘汰了,而lazy initialization holder这样的新秀在效率上和DCL已经没什么差别:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
public class WorldCup {
	private static class WorldCupHolder{
		public static WorldCup instance = new WorldCup();
	}

	public static WorldCup getInstance() {
		return WorldCupHolder.instance;
	}
}

    同样是“惰性初始化”,这个是不是更好看?

    在这里我们回过头来看看我们最初的eager initialization,你这时可能会反过来思考,他不是volatile,会不会有escape问题?不会。因为Java保证了域初始化段落对其余操作的HB关系。好了,这下,雷米特家的河东狮估计可以休矣。

6. 讨论的延续

    关于上面阿根廷的例子,写的时候我发现一点疑问,把自己的理解拿出来和大家讨论。那就是如果我把isMessiReady = true(记做A)放在新线程start(记做B)的前面,在这里新线程不就可以保证HB关系了么?因为有IntraThread原则,A hb B,而我们又有start操作hb于任何该线程的动作,比如tell(记做C),那么不就有A hb B hb C了么?可以保证新闻发布会上梅西肯定在场。那么,为什么几乎所有看到的资料里都警告说“即使是构造函数的最后一个操作,也不要启新线程、加listener、调可重写函数”云云?我的理解是,是为了防止这种情况:

div css xhtml xml Example Source Code Example Source Code [http://www.cnblogs.com/tomsheep/]
class SuperArgentina extends Argentina{
	private boolean isDiegoReady;
	public SuperArgentina() {
		super();
		isDiegoReady =true;
	}
	
	@Override
	protected void tell() {
		super.tell();
		System.out.println("Is Diego Here?:"+isDiegoReady);
	}
}

    我们拓展阿根廷为“超级阿根廷”,加入老马的状态,这样一来,老马自己在不在场就成问题了。关于上面的这段分析,我的把握并不是特别大,希望大牛们能够提点一下。

主要参考资料:

1. JavaWorld文章:Double-checked locking: Clever, but broken

2. Java Concurrency in Practice

3. GoF设计模式

posted on 2010-06-13 16:12  tomsheep  阅读(2344)  评论(0编辑  收藏  举报

导航