Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

面试题 2:实现 Singleton 模式

问题描述

设计一个类,只能生成该类的一个实例。

懒汉式,线程不安全

在第一次调用获取实例方法时分配内存,实现了懒加载。但是当有多个线程并行调用 getInstance() 的时候,就会创建多个实例,也就是说在多线程下不能正常工作。

public class Singleton {
  private static Singleton instance;
  private Singleton() {}
  public static Singleton getInstance() {
    if (instance == null) {
      instance = new Singleton();
    }
    return instance;
  }
}

懒汉式,线程安全

为了解决上面的问题,最简单的方法是将整个 getInstance() 方法设为同步(synchronized)。

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

也可以对 getInstance() 方法里的代码块设为同步。

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

使用 synchronized 虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。synchronized 修饰的同步方法比一般方法要慢很多,如果多次调用 getInstance(),累积的性能损耗就比较大了。此外,由于在任何时候只能有一个线程调用 getInstance() 方法,但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。

双重校验锁

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。

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

可以看到上面在同步代码块外多了一层 instance 为空的判断。由于单例对象只需要创建一次,如果后面再次调用 getInstance() 只需要直接返回单例对象。因此,大部分情况下,调用 getInstance() 都不会执行到同步代码块,从而提高了程序性能。

但是,这里还有一个隐患,主要是因为 instance = new Singleton() 这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将 instance 对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

这里要提到 Java中 的指令重排优化。所谓指令重排优化是指在不改变原语义的情况下,通过调整指令的执行顺序让程序运行的更快。

由于指令重排优化的存在,导致初始化 Singleton 和将对象地址赋给 instance 的顺序是不确定的。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

不过还好在 JDK1.5 及之后版本增加了 volatile 关键字。volatile 的一个语义是禁止指令重排序优化,也就保证了 instance 变量被赋值的时候对象已经是初始化过的,从而避免了上面说到的问题。

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

饿汉式

因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。

但是它的缺点也很明显,它不是一种懒加载模式。即使这个单例没有用到也会被创建,而且在类加载之后就被创建,内存就被浪费了。

class Singleton {
  private static final Singleton instance = new Singleton();
  private Singleton() {}
  public static Singleton getInstance() {
    return instance;
  }
}

静态内部类

与饿汉式一样,这种方式也是利用了类加载机制来保证只创建一个 instance 实例,因此不存在多线程并发的问题,是线程安全的。

此外,它是在内部类里面去创建对象实例,只要应用中不使用内部类,JVM 就不会去加载这个单例类,也就不会创建单例对象,从而实现懒汉式的延迟加载。

class Singleton {
  private static class SingletonHolder {
    public static final Singleton INSTANCE = new Singleton();
  }
  private Singleton() {}
  public static Singleton getInstance() {
    return SingletonHolder.INSTANCE;
  }
}

枚举

public enum Singleton {
  INSTANCE;
}

通过 Singleton.INSTANCE 就可以获取到单例对象。

枚举除了线程安全和防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。

总结

一般情况下直接使用饿汉式就好了,如果明确要求要懒加载可以使用静态内部类,如果涉及到反序列化创建对象时可以使用枚举的方式来实现单例。