您的位置:首页 > 编程语言 > Java开发

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

2010-06-13 16:12 369 查看
1.寂寞的Singleton

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


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,敲出下面的代码:


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名不副实。那应该如何?你可能已经对我的傻逼描述烦不胜烦了,加锁呗:


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)即可:


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)。举一个简单的例子:


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~”


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已经没什么差别:


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、调可重写函数”云云?我的理解是,是为了防止这种情况:


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设计模式
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: