文档章节

你真的会写单例模式吗?

做人当本然
 做人当本然
发布于 2017/05/09 10:04
字数 2305
阅读 2
收藏 0

文章转载自「开发者圆桌」一个10年老猿原创文章传播开发经验,尤其适合初学者或刚入职场前几年程序猿的微信公众号。

单例模式可能是大家经常接触和使用的一个设计模式,你可能会这么写:

public class Test {

private static Test instance;

private Test() {

}

public static Test getInstance(){

if(instance==null){//1:A线程执行

instance=new Test();//2:B线程执行

}

return instance;

}

}

上面代码大家应该都知道,所谓的线程不安全的懒汉单例写法。在Test类中,假设A线程执行代码1的同时,B线程执行代码2,此时,线程A可能看到instance引用的对象还没有初始化,导致被new多次。

你可能会说,线程不安全,我可以对getInstance()方法做同步处理保证安全啊,比如下面这样的写法:

public class Test {

private static Test instance;

private Test() {

}

public synchronizedstatic Test getInstance(){

if(instance==null){

instance=new Test();

}

return instance;

}

}

这样的写法是保证了线程安全,但是由于getInstance()方法做了同步处理,synchronized将导致性能开销。如getInstance()方法被多个线程频繁调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被多个线程频繁的调用,那么这个方案将能够提供令人满意的性能。

那么,有没有更优雅的方案呢?前人的智慧是伟大的,在早期的JVM中,synchronized存在巨大的性能开销,因此,人们想出了一个“聪明”的技巧--双重检查锁定。人们通过双重检查锁定来降低同步的开销,代码如下:

public class Test { //1

private static Test instance; //2

private Test() {

}

public static Test getInstance() { //3

if (instance == null) { //4:第一次检查

synchronized (Test.class) { //5:加锁

if (instance == null) //6:第二次检查

instance = new Test(); //7

} //8

} //9

return instance; //10

} //11

}

如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。

坑1:指令重排问题

双重检查锁定看起来似乎很完美,这种写法是不是绝对安全呢?从语义角度来看,并没有什么问题,但是其实还是有坑。为什么呢?第7行代码可分解为如下的3行伪代码:

memory=allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance=memory; //3:设置instance指向刚分配的内存地址

伪代码中的2和3之间,可能会被重排序「在一些JIT编译器上,这种重排序是真实发生的」,2和3之间重排序之后的执行时序如下:

memory=allocate(); //1:分配对象的内存空间

instance=memory; //3:设置instance指向刚分配的内存地址,注意此时对象还没有被初始化

ctorInstance(memory); //2:初始化对象

回到示例代码第7行,如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化完成,进而导致异常的出现。

在知晓问题发生的根源之后,我们可以想出两个办法解决:一是不允许2和3重排序;二是允许2和3重排序,但不允许其他线程“看到”这个重排序。

基于volatile的解决方案,不允许2和3重排序

解决这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。

volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。volatile关键字就可以从语义上解决这个问题。

注意,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。

jdk1.5以后的版本「当然目前主流JDK版本已然是jdk1.5后续版本了,注意一下即可」,对于前面的基于双重检查锁定的方案,只需要做一点小的修改,就可以实现线程安全的延迟初始化,示例代码如下:

public class Test {

private volatilestatic Test instance;

private Test() {

}

public static Test getInstance() {

if (instance == null) {

synchronized (Test.class) {

if (instance == null)

instance = new Test();//instance为volatile,现在没问题了

}

}

return instance;

}

}

当声明对象的引用为volatile后,前面伪代码谈到的2和3之间的重排序,在多线程环境中将会被禁止。

基于类初始化的解决方案,允许2和3重排序,但不允许其他线程“看到”这个重排序

JVM在类的初始化阶段「即在Class被加载后,且被线程使用之前」,会执行类的初始化。在执行类的初始化期间,JVM会去获取多个线程对同一个类的初始化。基于这个特性,实现的示例代码如下:

public class Test {

private Test() {

}

private static class InstanceHolder {

public static Test instance = new Test();

}

public static Test getInstance() {

return InstanceHolder.instance; //这里将导致InstanceHolder类被初始化

}

}

这个方案的本质是允许前面伪代码谈到的2和3重排序,但不允许其他线程“看到”这个重排序。在Test示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化。由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能会在同一时刻调用getInstance()方法来初始化IInstanceHolder类)。Java语言规定,对于每一个类和接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

坑2:序列化与反射问题

但是,上面提到的所有实现方式都有两个共同的缺点:

1.都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。

2.可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

当然,还有一种更加优雅的方法来实现单例模式,那就是枚举写法:

public enum Singleton {

INSTANCE;

private String name;

public String getName(){

return name;

}

public void setName(String name){

this.name = name;

}

}

调用时的伪代码:

Singleton.INSTANCE.getName();

使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,Effective Java推荐尽可能地使用枚举来实现单例。

总结

代码没有一劳永逸的写法,只有在特定条件下最合适的写法。在不同的平台、不同的开发环境(尤其是jdk版本)下,自然有不同的最优解或者说较优解。

比如枚举,虽然Effective Java中推荐使用,但是在Android平台上却是不被推荐的。在这篇Android Training中明确指出:Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

再比如双重检查锁法,不能在jdk1.5之前使用,而在Android平台上使用就比较放心了(一般Android都是jdk1.6以上了,不仅修正了volatile的语义问题,还加入了不少锁优化,使得多线程同步的开销降低不少)。

最后,不管采取何种方案,请时刻牢记单例的三大要点:

  1. 线程安全

  2. 延迟加载

  3. 序列化与反序列化安全

© 著作权归作者所有

做人当本然
粉丝 0
博文 11
码字总数 12300
作品 0
东城
高级程序员
私信 提问
学了那么多年设计模式依然不会用!那可真蠢!

什么是设计模式? 设计模式(Design Pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。 设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决...

GitChat技术杂谈
2018/10/26
0
0
【设计模式笔记】(十六)- 代理模式

一、简述 代理模式(Proxy Pattern),为其他对象提供一个代理,并由代理对象控制原有对象的引用;也称为委托模式。 其实代理模式无论是在日常开发还是设计模式中,基本随处可见,中介者模式中...

MrTrying
2018/06/24
0
0
Java经典设计模式-结构型模式-适配器模式(Adapter)

适配器模式 适配器模式主要分为三类:类的适配器模式、对象的适配器模式、接口的适配器模式。 适配器模式将某个类的接口转换成客户端期望的另一个接口表示,目的是消除由于接口不匹配所造成的...

Idea
2018/01/20
0
0
Java设计模式系列一(前言)

说起设计模式,很多人都会觉得这个概念很熟悉,会想到单例模式、工厂模式等等,但是似乎又说不出来什么,说不上它的核心思想和设计原则。其实我们的项目中,为了代码复用,增加可维护性,很多...

Mooree
04/07
0
0
设计模式已经陨落了?

你现在是坐在一个程序员旁边吗?如果是的话,那么在你读下面的段落之前,有一个简单的实验。让他们到一边去,问问他们两个问题并记录下答案。首先问他们“什么是设计模式?”然后再问“说出你...

oschina
2014/03/11
9.1K
69

没有更多内容

加载失败,请刷新页面

加载更多

数据库

数据库架构 数据库架构可以分为存储文件系统和程序实例两大块,而程序实例根据不同的功能又可以分为如下小模块。 1550644570798 索引模块 常见的问题有: 为什么要使用索引 什么样的信息能成...

一只小青蛙
今天
5
0
PHP常用经典算法实现

<? //-------------------- // 基本数据结构算法 //-------------------- //二分查找(数组里查找某个元素) function bin_sch($array, $low, $high, $k){ if ( $low <= $high){ $mid = int......

半缘修道半缘君丶
昨天
5
0
GIL 已经被杀死了么?

本文原创并首发于公众号【Python猫】,未经授权,请勿转载。 原文地址:https://mp.weixin.qq.com/s/8KvQemz0SWq2hw-2aBPv2Q 花下猫语: Python 中最广为人诟病的一点,大概就是它的 GIL 了。...

豌豆花下猫
昨天
5
0
git commit message form

commit message一般包括3部分:Header、Body、Footer。 <type>(<scope>):<subject>blank line<body>blank line<footer> header是必需的,body、footer可以省略。 header中type、subject......

ninjaFrog
昨天
5
0
聊聊Elasticsearch的CircuitBreakerService

序 本文主要研究一下Elasticsearch的CircuitBreakerService CircuitBreakerService elasticsearch-7.0.1/server/src/main/java/org/elasticsearch/indices/breaker/CircuitBreakerService.ja......

go4it
昨天
5
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部