[关闭]
@dongxi 2017-07-28T14:51:44.000000Z 字数 7387 阅读 938

Java 单例模式

JAVA 设计模式


主要参考自 菜鸟教程

       单例模式是JAVA中最简单的模式之一,这种模式属于创建型模式,它提供了一种创建对象的最佳方式。
       这种模式涉及到单一的类,该类负责创建自己的对象,同时确保自己只有单个对象被创建,这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:
- 单例类只能有一个实例
- 单例类必须自己创建自己的唯一实例
- 单例类必须给所有其他对象提供这一实例


介绍

意图:保证一个类就有一个实例,并提供一个访问它的全局访问点。
主要解决:一个全局使用的类频繁地创建与销毁。
何时使用:想控制实例数目,节省系统资源。
优点: 1.内存里只有一个实例,减少内存开销,尤其是频繁创建和销毁。2. 避免对资源的多重利用。
缺点:没接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎样来实例化。
使用场景: 1.要求生产唯一序列号。2.WEB中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。3.创建一个对象需要消耗的资源过多。

单例模式实现

懒汉模式(线程不安全)

  1. public class Singleton {
  2. private static Singleton instance;
  3. private Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. if (instance == null) {
  7. instance = new Singleton();
  8. }
  9. return instance;
  10. }
  11. }

       这是一种最基本的单例模式的示例,这是一种线程不安全的模式,在并发环境中很可能会出现很多个Singleton实例。

懒汉模式(线程安全)

  1. public class Singleton {
  2. private static Singleton instance;
  3. private Singleton() {
  4. }
  5. public static synchronized Singleton getInstance() {
  6. if (instance == null) {
  7. instance = new Singleton();
  8. }
  9. return instance;
  10. }
  11. }

       相对于上面那种,这种方式能够在多线程中很好地工作,采用了synchronized方法重量锁,同时在绝大多数情况下,都是不需要同步的。

懒汉模式(双重校验)

  1. public class Singleton {
  2. private static volatile Singleton instance;
  3. private Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. if (instance == null) {
  7. synchronized (Singleton.class) {
  8. if (instance == null) {
  9. instance = new Singleton();
  10. }
  11. }
  12. }
  13. return instance;
  14. }
  15. }

       相对于上面的方法来说,这一种方法的效率极大的提升了。在实际的使用中,new创建的情况是很少的,绝大部分都是可以并行的读操作,执行效率可以大大提高。不过,这里有一点问题,可能读者也注意到了在instance起那么多了一个修饰符volatile,实际上这个修饰符起了很关键的作用,这一部分的内容将会在后面详细讲述。

饿汉模式

  1. public class Singleton {
  2. private static volatile Singleton instance = new Singleton();
  3. private Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. return instance;
  7. }
  8. }

       这是一种基于ClassLoader的方式,将在初始化阶段通过对instance赋值(这一部分可以参见我的上一篇文章JVM类加载机制),避免了多线程的同步问题。虽然大部分时间Singleton类装载都是发生在调用static方法时发生的,但是我们不能够保证没有其他的方式可以完成触发类的初始化(比如:通过反射,当然在反射的情况下又会有新的问题,这一部分内容也在最后讲述),在这种情况下,并没有实现延迟加载的效果。

静态内部类模式

  1. public class Singleton {
  2. private static class SingletonHolder {
  3. private static final Singleton INSTANCE = new Singleton();
  4. }
  5. private Singleton() {
  6. }
  7. public static Singleton getInstance() {
  8. return SingletonHolder.INSTANCE;
  9. }
  10. }

       这种方式能够达到双检锁方式一样的功效,但实现更简单。在这种方式中,INSTANCE并不会随着Singleton的装载而被实例化,只有在使用了getInstance()的条件下才会被创建,这样实现了lazy-loading的效果。

枚举方式

  1. public enum Singleton {
  2. INSTANCE;
  3. public void something() {
  4. }
  5. }

       使用枚举的方式实现单例不仅能够避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化,同时还不能通过reflection attack来调用私有构造方法。可能是现在最佳的方法,这种方法的具体内涵将后半部分进行简单的介绍。

相关知识

volatile关键字

       首先,先谈一下什么是原子操作。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换。
       比如,一个赋值操作就是一个原子操作:

  1. i = 5;

       在Java中的原子操作只有8种,lock、unlock、read、load、use、assign、store以及write,看到这里大部分人都会认为赋值操作是原子性的,但是我们并不能够下这样的定论,因为在32为JVM中long和double的赋值操作不是原子性的,这也是在并发中这两个基本数据类型经常会遇到的数据撕裂情况。
       从上面的情况我们可以知道,声明并且赋值并不会是一个原子操作:

  1. int i = 0;

       上述这一条语句至少包括两个操作:

  1. 声明一个变量i
  2. 对i进行赋值

       很显然,这会产生一个中间态:变量i已经声明但是并且赋值的情况,这对于多线程环境就十分致命了。
       我们再简单了解下指令重排,指令重排顾名思义,就是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果前提下进行指令顺序的调整。
       假设我们的双重校验方式并未采取volatile关键字修饰,那么:

  1. instance = new Singleton();

       这一条很简单的赋值语句,实际上在JVM内部已经转换为了至少三条指令:

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

       但是在JVM的即时编译器(运行期优化)中存在指令重排的优化操作。也就是说,上面的2步骤和3步骤的执行顺序并不能够保证。那么,很有可能发生:

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

       在这种情况下,instance指向分配好的内存放在的前面,而这段内存的初始化却放在了后面,这样就意味着:在线程A初始化完成这段内存之前,线程B再同步代码之前就会发现instance不为空,此时线程B就会获得instance对象进行使用这就可能发生一定的错误。
       这种情况下,volatile关键字就起到了它的作用。volatile关键字除了大家经常使用的保持内存可见性的功能之外,使用volatile关键字修饰的变量禁止指令重排序。
       volatile关键字通过内存屏障这一功能来防止指令被重新排序。为了实现这一功能,编译器再生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器排序(关于内存屏障的具体内容可以浏览并发编程网)。

通过反射破坏单例模式

       在Java中可以创建一个对象的方式,大概有四种:new、克隆、反射、序列化。
       首先,在单例模式中肯定是无法通过最基本的new来创建对象,所以对于这种我们不予考虑了。其次,克隆是通过Cloneable接口实现对象的创建,即时构造函数是私有的,通过这种方式我们也可以创建一个对象(克隆直接从内存中赋值内存区域)。不过正如前文所言,只要我们不实现克隆,那么就不可能通过克隆模式创建对象(在单例模式中并没有实现Cloneable接口的必要)。
       反射是Java中比较神奇的一个功能,Java中的反射技术可以获取类所有的方法、成员变量还能够访问私有构造方法,这样一来就会破坏单例模式的结构,我们先用双重验证测试一下:

  1. public class Singleton {
  2. private static volatile Singleton instance;
  3. private Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. if (instance == null) {
  7. synchronized (Singleton.class) {
  8. if (instance == null) {
  9. instance = new Singleton();
  10. }
  11. }
  12. }
  13. return instance;
  14. }
  15. public static void main(String[] args) {
  16. try {
  17. Constructor constructor = Singleton.class.getDeclaredConstructor();
  18. constructor.setAccessible(true);
  19. Singleton singleton1 = (Singleton) constructor.newInstance();
  20. Singleton singleton2 = (Singleton) constructor.newInstance();
  21. Singleton singleton3 = Singleton.getInstance();
  22. Singleton singleton4 = Singleton.getInstance();
  23. System.out.println(singleton1);
  24. System.out.println(singleton2);
  25. System.out.println(singleton3);
  26. System.out.println(singleton4);
  27. } catch (NoSuchMethodException e) {
  28. e.printStackTrace();
  29. } catch (IllegalAccessException e) {
  30. e.printStackTrace();
  31. } catch (InvocationTargetException e) {
  32. e.printStackTrace();
  33. } catch (InstantiationException e) {
  34. e.printStackTrace();
  35. }
  36. }
  37. }

输出结果为:

Singleton@1540e19d
Singleton@677327b6
Singleton@14ae5a5
Singleton@14ae5a5

       很显然,通过反射技术生成的两个实例不同,通过常规方法获取的两个实例相同。对于这种情况,我们也有一定的解决方案,我们可以重新定义构造函数,当构造函数第二次调用是抛出异常:

  1. private Singleton() throws Exception {
  2. if (null != instance) {
  3. throw new Exception("duplicate instance create error!" + Singleton.class.getName());
  4. }
  5. }

通过序列化破坏单例模式

       序列化是指将对象的状态信息转换为可以存储或传输的形式的过程。在序列化期间,对象将其当前状态写入临时或持久性存储区。以后可以通过从存储区读取或反序列化对象的状态,重新创建该对象
       相信大家都注意到了重新创建该对象这几个字,只要单例模式类实现了Serializable或者Externalizable接口,那么就会在反序列化的过程中在创建一个对象,这样可能在整个单例模式的生命周期中出现两个不同的实例,这是我们所不能允许的。对于这种情况也有一种简单的处理办法,通过重写readResolve函数我们可以避免这一情况,那么我们现在的双重检测模式应该是:

  1. public class Singleton implements Serializable {
  2. private static volatile Singleton instance;
  3. private Singleton() throws Exception {
  4. if (null != instance) {
  5. throw new Exception("duplicate instance create error!" + Singleton.class.getName());
  6. }
  7. }
  8. public static Singleton getInstance() throws Exception {
  9. if (instance == null) {
  10. synchronized (Singleton.class) {
  11. if (instance == null) {
  12. instance = new Singleton();
  13. }
  14. }
  15. }
  16. return instance;
  17. }
  18. private Object readResolve() throws ObjectStreamException {
  19. return instance;
  20. }
  21. }

        其实无论使用哪种接口,当从I/O中读取对象是,都会调用readResolve方法(实际上,在反序列化过程中,就是使用readResolve方法返回的对象替代直接在序列化过程中创建的对象)。

枚举类型单例模式[1]

       通过枚举类型实现一个单例模式是被很多文章推荐的,这种方式可以很简单的避免了前面我们研究探讨的问题,但对于这其中的原理可能并不是很清楚,下面我们从一个Enum类型的实现上开始讨论这个问题,首先我们先定义一个枚举类:

  1. public enum Expression {
  2. INSIDIOUS, FUNNY, SPRAY
  3. }

       这是一个很简单的枚举类,但是实际上这一部分代码在编译以后的内容却是大不相同的。我们使用反编译工具JAD进行反编译,反编译的结果为:

  1. public final class Expression extends Enum
  2. {
  3. public static Expression[] values()
  4. {
  5. return (Expression[])$VALUES.clone();
  6. }
  7. public static Expression valueOf(String name)
  8. {
  9. return (Expression)Enum.valueOf(Expression, name);
  10. }
  11. private Expression(String s, int i)
  12. {
  13. super(s, i);
  14. }
  15. public static final Expression INSIDIOUS;
  16. public static final Expression FUNNY;
  17. public static final Expression SPRAY;
  18. private static final Expression $VALUES[];
  19. static
  20. {
  21. INSIDIOUS = new Expression("INSIDIOUS", 0);
  22. FUNNY = new Expression("FUNNY", 1);
  23. SPRAY = new Expression("SPRAY", 2);
  24. $VALUES = (new Expression[] {
  25. INSIDIOUS, FUNNY, SPRAY
  26. });
  27. }
  28. }

       反编译显示的结果远远比我们想象的复杂得多。通过阅读反编译的代码我们可以很清晰的认识到实际上枚举类型是通过常见一个继承自Enum的类来完成的(至于这部分代码比较简单,在这里就不再叙述)。
       从线程安全的角度上说,枚举类型采用ClassLoader的方式来创建一个实例,所以这一过程一定是线程安全的。
       对于枚举类型,在序列化过程中Java仅仅是将枚举对象的name属性出书到结果中,在反序列化的规则则是通过valueOf方法来根据名字查找枚举对象。同时,编译器不允许对Enum的序列化机制进行定制,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。简单了解下valueOf源代码:

  1. public static <T extends Enum<T>> T valueOf(Class<T> enumType,String name) {
  2. T result = enumType.enumConstantDirectory().get(name);
  3. if (result != null)
  4. return result;
  5. if (name == null)
  6. throw new NullPointerException("Name is null");
  7. throw new IllegalArgumentException(
  8. "No enum const " + enumType +"." + name);
  9. }

       代码会尝试从EnumType这个Class对象的enumConstantDirectory方法中获取map中名为name的枚举对象,如果不存在则抛出异常。如果跟踪enumConstantDirectory方法我们则会发现这一过程是通过反射的方式调用完成的,所以我们可以认为枚举类型在序列化上是存在着保障的。
       此方法在反射上也应该是具有保障的,不过由于本人对反射了解并不够深入,所以并不是很了解这一过程,准备在以后将这一部分填充。

总结

       这篇文章相对于其他相似的文章来说,更希望能够提供更加详细的讲解,而不是就把这几种方式简单的罗列而已,不过由于本人对JVM的各种机制还处于一种简单了解的状况,所以还有所局限。如果您发现了本篇文章中存在的问题,请您跟我联系。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注