剑指offer-面试题2 实现单例模式

我也不知道面试题1去哪儿了。。

面试题2 实现单例模式

1. 单例模式的定义

  单例模式最初的定义出现于《设计模式》(艾迪生维斯理,1994):“保证一个类仅有一个实例,并提供一个访问它的全局访问点。”
  另一个常见的定义是:一个类只有一个实例,并且自行实例化向整个系统提供。
  这两句话的意思就是,当我们需要用到某个实例的时候,我们无需进行其它多余操作,而是直接通过某个接口获取到它的实例,并且这个实例在整个系统中保证唯一。
  举个简单的例子:我们在平时使用电脑时,我们希望点击“设置”按钮,就可以直接访问设置,而且要求设置在整个系统中是唯一的(这是废话),电脑的设置在这里就是一个单例。
  
  我们通过定义,得出完成单例模式需要满足下面两个条件:
  1. 生成类的实例要唯一。也就是生成代码只能执行一次,“阻止”所有想要生成新对象的操作;
  2. 生成实例的方法必须是全局方法(也就是静态)。原因是非静态方法必须通过实例进行调用,如果已经有了实例,我们还需要生成实例的方法干什么呢?
  
  那么如何具体实现单例模式呢? 

2. 一个小例子

  我们有个小需求:要获取电脑的现在时间,试着写一个MyTime 类。
  实现1:

    import java.util.Date;
    public class MyTime{
        ...
        private static Date time = new Date();
        public static Date getTime() {
            return time;
        }
        ...
    }

  实现2:

    ...
    private static Date time;
    public static Date getTime() {
        time = new Date();
        return time;
    }
    ...

  代码简单调用Date() 接口实现了MyTime 类,其中两个实现都满足了要求。
  它们有什么区别呢?第一个例子直接获取了当前时间,而第二个例子中,当我们需要time 时,调用getTime() 再进行创建,降低了初始化时间,但是每次调用都会新获取新的Date(),事实上与单例模式的定义相悖。
  这里介绍一个概念,延迟加载。延迟加载(lazy loading),就是Java虚拟机在进行类加载的时候不创建对象,当我们需要时再进行创建。这样做可以减少运行时间,提高系统的性能。
  为了提高系统性能,单例模式中应该尽量实现延迟加载(lazy loading)。
  第二个例子实现了延迟加载。但是,它不是一个单例模式,而且它是线程不安全的。后面我们会对它进行改良,实现线程安全的单例模式。

3. 饿汉模式(线程安全)

  在实现1的基础上改进一下:

    public class Ex02Singleton {
        private Ex02Singleton(){}
        private static Ex02Singleton singleton = new Ex02Singleton();
        public static Ex02Singleton getInstance(){
            return singleton;
        }
    }

  我们先创建一个Ex02Singleton 的实例,之后在调用getInstance() 方法中返回这个实例就可以了。这样实例的唯一性就得到了保证 ,这是一种可行的方法。

  这种办法为什么是线程安全的呢?这涉及到JVM在类的初始化阶段给出的线程安全性保证。因为JVM在类初始化阶段,会获取一个锁,并且每个线程都会至少获取一次这个锁以确保这个类已经加载。
  在静态初始化期间,内存的写入操作自动对所有线程可见,而singleton 的初始化就是属于静态初始化。因此,在构造期间或者被引用时,静态初始化的对象都不需要显式的同步。
  但是这个规则只适用于在构造时的状态,如果对象可变,那么在其它地方对该对象的访问还是需要使用同步来确保对对象的修改操作是可见的。
  
  优点:线程安全,代码简单;
  缺点:不能延迟加载,系统性能会有所降低。  

4. 懒汉模式( 线程不安全)

  根据实现2:

    public class Ex02Singleton {
        private Ex02Singleton(){}

        public static Ex02Singleton getInstance(){
            return new Ex02Singleton();
        }
    }

  这个例子的构造函数Ex02Singleton() 是私有的,因为一旦公有,任何人都能通过构造函数创建新的实例,这样就不能保证实例的唯一性。
  但现在的问题是:每当我们调用getInstace() 方法时,它都会返回一个新的Ex02Singleton 实例。多次调用就会产生多个Ex02Singleton 实例,这和单例模式中实例的唯一性相悖。
  如何改进呢?
  事实上我们应该先对singleton 先进行判断,如果不为null ,就直接返回;为null 时再去创建,之后返回,这样就不会有实现1的问题了。
  改进后:

    public class Ex02Singleton {
        private Ex02Singleton(){}
        private static Ex02Singleton singleton;
        public static Ex02Singleton getInstance(){
            if(singleton == null)
                singleton = new Ex02Singleton();
            return singleton;
        }
    }

  代码很简单,先声明静态的Ex02Singleton 型变量singleton ,不进行实例化,在调用getInstance() 方法时进行判断,如果singleton 还没有被实例化就进行实例化,这样做实现了延迟加载。这就是与饿汉模式相对应的懒汉模式。

懒汉模式的问题
  懒汉模式个致命的问题,这是由多线程访问时出现的线程不安全问题。看下图:

懒汉模式多线程

  有两个线程A和B,当A线程往下执行,执行完命令

    if(singleton == null)

  判定结果为true 。此时,线程被中止(这个过程是系统随机的,也有可能不中止一直执行下去)。然后线程B开始执行,它也执行到这句:

    if(singleton == null)

  因为线程A判断完就中止了,还没来得及创建实例,B执行这句的结果也会是true ,接着它创建了一个实例,到A继续执行时还会创建新的Ex02Singleton 实例。这样就有两个实例存在。
  为了避免类似的情况发生,Java中出现了同步关键字synchronized ,来保证被其修饰的代码块会被加同步锁,同一时间段内只能有一个线程访问它,直到代码执行完毕,才会释放这部分代码。
  修改一下代码,如下:

    public static synchronized Ex02Singleton getInstance(){
        if(singleton == null)
            singleton = new Ex02Singleton();
        return singleton;
    }

  这样看似不错,多线程问题得到了解决。但是同步加锁是一种耗费时间的操作,getInstance() 方法不是什么敏感操作,我们只需要在第一次实例化时需要加锁,之后调用getInstance() 方法都没有必要加锁。
  所以这种方法虽然能保证线程安全和延迟加载,但实际应用中由于效率太低,不会有人去用它。
  我们得想个方法,既能保证在实例化时加同步锁,又在每次调用getInstance() 方法时正常执行。

  优点:能延迟加载,对单线程程序无影响;
  缺点:线程不安全。

5. 双重检查锁定+volatile关键字(线程安全)

(1)DCL(双重检查加锁)
  基于上面懒汉式 + synchronized 关键字加锁的思想,我们对代码进行改进:

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

  这种双重判断被称为双重检查加锁(DCL,double check lock)。
  其中用了两个if() 判断,第一个if 先判断singleton 是否为null :如果不为null ,说明singleton 已经被初始化了,直接返回singleton
  如果singletonnull ,说明singleton 还没有被初始化,这样才会去执行synchronized 修饰的代码块内容,只在其初始化的时候调用一次。这样的设计既能保证只产生一个实例,并且只在初始化的时候加同步锁,也实现了延迟加载。
  这个就是我们需要的操作了,可在实际操作中还是会发生问题,这又是怎么回事呢?

(2)指令重排序
  指令重排序的作用是为了优化指令,提高程序运行效率。指令重排序包括编译器重排序和运行时重排序。
  JVM规范规定,指令重排序可以在不影响单线程程序执行结果前提下进行。例如

    instance = new Singleton();

可分解为如下伪代码:

    memory = allocate();   //1:分配对象的内存空间  
    ctorInstance(memory);  //2:初始化对象  
    instance = memory;     //3:设置instance指向刚分配的内存地址  

经过重新排序后:

    memory = allocate();   //1:分配对象的内存空间  
    instance = memory;     //3:设置instance指向刚分配的内存地址  
                           //注意:此时对象还没有被初始化!  
    ctorInstance(memory);  //2:初始化对象  

  将第2步和第3步调换顺序,在单线程情况下不会影响程序执行的结果,但是在多线程情况下就不一样了。
  这里需要明确的一点是:对于synchronized 关键字,当一个线程访问对象的一个synchronized(xx.class) 同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(xx.class) 同步代码块。
  线程A执行了

    instance = memory;  //这对另一个线程B来说是可见的

  此时线程B执行外层

    if (instance == null) {
        ...
    }

  发现singleton不为空,随即返回,但是得到的却是未被完全初始化的实例,在使用的时候必定会有风险,这正是双重检查锁定的问题所在。

  在JDK1.5之后,新增了volatile 关键字禁止指令重排序的功能:

    public class Ex02Singleton {
        private Ex02Singleton(){}
        private static volatile Ex02Singleton singleton;

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

  volatile 关键字禁止指令重排序的做法是在对被其修饰的变量进行操作时,增加一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的的位置)用以保证一致性。这样我们就解决了指令重排序的问题。
  (关于volatile 关键字的用法在此不详述,参见《深入理解Java虚拟机》第十二章即可。)
  
  优点:能延迟加载,也能保证线程安全;
  缺点:代码较复杂。

6. 延迟初始化占位(Holder)类模式(推荐)

  单例模式还有以下实现:

    public class Ex02Singleton {
        private Ex02Singleton(){}

        private static class InstanceHolder{
            public static final Ex02Singleton singleton = new Ex02Singleton();  
        }
        public static Ex02Singleton getInstance(){          
            return InstanceHolder.singleton;
        }
    }

  这种方式成为延迟初始化占位(Holder)类模式。该模式引入了一个内部静态类(占位类)内部静态类只有在调用时才会加载,既保证了Ex02Singleton 实例的延迟初始化,又保证了实例的唯一性。是一种提前初始化(饿汉式)和延迟初始化(饱汉式)的综合模式,推荐使用这种操作。

  这种方法基于在懒汉模式中提出的,JVM在类的初始化阶段给出的线程安全性保证,将singleton 的实例化操作放置到一个静态内部类中,在第一次调用getInstance() 方法时,JVM才会去加载InstanceHolder 类,同时初始化singleton 实例,因此,即使我们不采取任何同步策略,getInstance() 方法也是线程安全的。
  
  优点:能延迟加载,也能保证线程安全。

7. 枚举

  枚举(enum ,全称为 enumeration), 是 JDK 1.5 中引入的新特性,存放在java.lang 包中。枚举的详细用法见枚举的详细用法
  

     public enum ExSingleton {  
         INSTANCE;  
         public void someMethod() {  
         }  
     }  

  需要时使用Singleton.INSTANCE 即可实现调用。
  这种方式是Effective Java作者Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒。
  优点:代码十分简洁,而且便于操作;
  缺点:较为不常见。
  

总结

单例模式线程安全的写法有以下几种:
  1. 饿汉式(不能延迟加载);
  2. 双重检查锁(DLC)+volatile 关键字;
  3. 延迟初始化占位类模式(Holder);
  4. 枚举。

补充

  在看Android源码时发现一个方法

    package android.os;
    public abstract class AsyncTask<Params, Progress, Result> {
        …
        private static Handler getHandler() {
            synchronized (AsyncTask.class) {
                if (sHandler == null) {
                    sHandler = new InternalHandler();
                }
                return sHandler;
            }
        }
        …
    }

getHandler() 方法中,直接对if操作进行了同步锁定。这引出了一个问题:synchronized 关键字的不同操作方式:synchronized(xx.class) 和方法中带有synchronized 关键字的异同,这篇文章会抽空补上,参见 初步探究synchronized的用法

参考文章

[1] 百度百科:单例模式
[2] Java设计模式系列之单例模式
[3] 双重检查锁定(double-checked locking)与单例模式
[4] Java:单例模式的七种写法
[5] java并发中的延迟初始化
[6] java synchronized详解
[7] Java枚举enum以及应用:枚举实现单例模式

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值