文档章节

单例模式的正确书写方式

爱宝贝丶
 爱宝贝丶
发布于 2016/09/10 23:25
字数 3000
阅读 64
收藏 1

       单例模式在平常的生产工作中使用比较多,比如我们经常使用的Spring框架,其基本上所有的上下文环境和切面的实例都是单例的,这是单例模式应用的一种方式,即对应用程序的上下文环境进行控制;第二种方式是对一些公共资源的控制,比如我们设计一个篮球类的游戏,这里一场游戏中篮球只有一个,而争夺篮球的人则有很多个,因而我们必须对篮球类进行控制,使其自始至终只能有一个实例。

       为了保证一个类是单例的,那么我们这里就必须对实例的创建进行控制。如果一个类对客户端程序员提供了公有的构造方法,那么其还是可以创建多个实例的,因而,这里我们必须显示的为类创建一个构造器,并且将其声明为私有的。既然将构造器声明为私有的,那么怎么获取这个类的实例呢?这里我们就可以使用工厂方法,因为工厂方法属于类的一部分,我们可以在类内部声明该类的一个实例(因为是在类内部,其私有的构造方法也就可以访问,也就可以实例化),利用工厂方法返回该实例,这样我们就达到了只为该类创建一个实例的目的。基于以上两点,比较常见的单例模式的设计思路如下:

public class Singer {
  private static Singer INSTANCE = new Singer();

  private Singer() {}

  public static Singer getInstance() {
    return INSTANCE;
  }
}

或者是

public class Singer {
  private static Singer INSTANCE = null;

  private Singer() {}

  public static Singer getInstance() {
    if (INSTANCE == null) {
      INSTANCE = new Singer();
    }
    return INSTANCE;
  }
}

       第一种将该实例保存在一个静态域中,在加载Singer类时就已经初始化了该实例;第二种方式则是先声明一个静态引用,在工厂方法中对该引用进行初始化。对于客户端而言,其只需要按照如下即可获得该类的实例:

Singer singer = Singer.getInstance();

       对于上面两种初始化实例的方式,区别主要在于初始化的时机不一样,对于一些比较大或者初始化非常消耗系统资源的对象,应该使用第二种方式也即延迟初始化的方式,因为系统可能一直都不会使用该实例,因而无需在加载的时候就对其初始化消耗资源。

       这两种单例模式虽然都可以在形式上保证类只有一个实例,但这也不是绝对的。对象是否只有一个实例与对象的创建方式有关,因而我们应该思考对象创建的所有渠道以保证无论从哪种方式获取该对象都只能得到该对象的一个实例。在java中,创建对象的方式有四种:

  1. 通过new关键字进行创建;
  2. 使用ObjectInputStream和ObjectOutputStream创建;
  3. 通过反射调用对象的构造器创建;
  4. 使用对象的clone方法创建。

       前面我们介绍的两种创建单例模式的方式都只保证了通过第一种实例化的方式是无法创建的。这里我们看如下代码:

public class App {
  @Test
  public void testSingletonByStream() throws Exception {
    Singer singer = Singer.getInstance();

    File file = new File("classpath:singer.txt");
    ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(file));
    out.writeObject(singer);
    out.close();

    ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));
    Singer subSinger = (Singer) in.readObject();
    in.close();

    System.out.println(singer == subSinger);
  }
}

       当然,使用对象输入输出流对对象进行读写的时候必须要使Singer类实现Serializable接口,以保证其能序列化。这里运行这段程序,其输出结果为:

false

       这就说明通过输入输出流写入和读取的对象不是同一个对象,虽然两个对象的属性都是相同的。这也就造成了单例类有了两个实例的情形。那么如何避免这种方式获取的实例呢?在使用ObjectInputStream从文件中读取数据并将其恢复为一个对象的时候会调用该对象类型的readResolve方法,通过该方法创建该对象的实例,该方法是jvm自动帮我们加入的,那么如果我们重写该方法,并让该方法返回已经有的实例,对象输入输出流就不能创建实例了,具体代码如下:

public class Singer implements Serializable {
  private static Singer INSTANCE = null;

  private Singer() {}

  public static Singer getInstance() {
    if (INSTANCE == null) {
      INSTANCE = new Singer();
    }
    
    return INSTANCE;
  }
  
  public Object readResolve() {
    return INSTANCE;
  }
}

       重新运行前面的testSinletonByStream中的代码,会发现返回结果为

true

       从上面的分析可以看出,只要我们重写了readResolve方法,就可以避免客户端程序员通过对象输入输出流创建单例类的另一个实例。

       对于使用反射方式实例化一个类的方式,这里需要说明的是,反射实例化实际上也是调用了该类的构造方法,虽然我们将该类的构造方式声明为private修饰的,但是反射可以通过setAccessible方法来改变对象的方法属性的访问权限,比如如下代码:

public class App {
  @Test
  public void testSingletonByReflect() throws Exception {
    Singer singer = Singer.getInstance();

    Constructor<?>[] constructors = Singer.class.getDeclaredConstructors();
    for (Constructor<?> constructor : constructors) {
      if (constructor.getName().equals("chapter2.work8.Singer")) {
        constructor.setAccessible(true);
        Singer subSinger = (Singer) constructor.newInstance();
        System.out.println(singer == subSinger);
        break;
      }
    }
  }
}

       首先我们获取该类的所有构造方法,利用循环找到我们创建的构造方法,将该构造方法的访问权限提高之后通过该构造方法实例化了一个Singer对象,输出结果为

false

       通过输出结果我们可以看出通过反射确实可以另外创建该类的一个实例,这也违反了单例模式的初衷。那么如何避免这种方式创建对象呢?从上面的代码可以看出,利用反射创建该对象的实例实际上还是使用该类的构造方法,因而只要我们在该类的构造方法中进行判断,如果通过构造方法已经创建过一个对象,那么再次创建时我们就抛出一个异常以告知客户端程序员再次实例化是不允许的。具体代码如下:

import java.io.Serializable;

public class Singer implements Serializable {
  private static Singer INSTANCE = null;
  private static int count = 0;

  private Singer() {
    if (count >= 1) {
      throw new AssertionError("cannot construct other instance");
    }

    count++;
  }

  public static Singer getInstance() {
    if (INSTANCE == null) {
      INSTANCE = new Singer();
    }

    return INSTANCE;
  }

  public Object readResolve() {
    return INSTANCE;
  }
}

       可以看到,我们在构造方法中对通过构造方法创建实例的数目进行了统计,当已经实例化的数目不少于一个的时候将抛出异常。继续运行前面的testSingletonByReflect方法,可以发现这次程序抛出了我们所期望的异常。

       关于使用clone方法进行创建,这里我们需要简要说明一下。在Object类中,clone方法是受保护类型的,也就是说如果我们不重写该方法将其修改为public类型,客户端程序员是看不到该方法的,并且如果想正确的使用clone方法,子类必须实现Cloneable接口,否则调用clone方法时将抛出CloneNotSupportedException异常,无论是通过直接调用还是通过反射调用。因此,这里如果想让客户端程序员不通过clone方法产生该类型实例,我们只需要不对clone方法进行重写即可,并且在《Effective Java》中也建议不要使用clone方法来对一个对象进行克隆,其有非常多的缺点,如果确实想使用克隆的功能,正确的做法应该是使用克隆构造器或者是克隆工厂,在克隆构造器或者克隆工厂的方法声明中传入该类型的一个实例,从而对其进行深度克隆。

       上面所演示的单例类的创建方式其实还不是最终的书写方式,因为该方式并没有考虑多线程的问题,比如在上面所示的工厂方法中,如果两个线程都是首次加载该类,并且调用工厂方法获取实例的时候都是运行到 if (null == INSTANCE) 处,此时因为INSTANCE并没有实例化,其为null,这条判断对于两个线程来说都将返回true,然后两个线程都会创建各自的实例,也就产生了多个实例的情形。解决办法比较简单,这里只需要对必要的方法进行加锁即可,注意这里也必须对构造器和readResolve方法加锁,因为也有可能是多个线程同时使用反射或者对象输入输出流创建对象。具体的代码如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Singer {
    private static Lock lock = new ReentrantLock();
    private static volatile Singer INSTANCE = null;
    private static int count = 0;

    private Singer() {
        lock.lock();
        try {
            if (count >= 1) {
                throw new AssertionError("cannot construct other instance");
            }

            count++;
        } finally {
            lock.unlock();
        }
    }

    public static Singer getInstance() {
        if (null == INSTANCE) {
            lock.lock();
            try {
                if (null == INSTANCE) {
                    INSTANCE = new Singer();
                }
            } finally {
                lock.unlock();
            }
        }
        return INSTANCE;
    }
}

       对于这种单例模式的实现方式,首先由于Singer没有实现序列化接口Serializable,因而不能通过对象输入输出流创建对象,同理,其没有实现Cloneable接口,因而不能通过克隆的方式创建对象。对于通过反射创建对象的情况,由于反射最终会调用该类的构造函数,这里通过在构造函数中进行判断,避免了通过反射创建对象。因此获取对象的方式只有通过工厂方法,并且由于延迟初始化的方式只有在第一次初始化的时候才会有多线程的问题,而且锁对象是一件代价比较高昂的动作,因而这里通过判断,如果INSTANCE为空,说明是第一次加载,就将对象锁住,在锁里面再次判断,以防止两个线程都通过了外层的判断,在锁里面初始化之后,两个线程都将获得同一实例,并且后续再通过该工厂方法获取对象的时候,由于对象已经实例化,因而不会再产品锁住对象的动作,从而提高效率。这里在INSTANCE前使用volatile的目的是保证每个线程看到的实例是同一个实例。另外需要说明一点的是,如果使用急切初始化(如第一段代码)的方式实例化,则不需要使用同步,因为通过jvm可以保证类在加载的时候就已经实例化了一个对象。

       本文主要介绍了单例模式的创建条件和方式,通过分析对象的创建方式来逐步完善单例模式的创建过程,以保证该类的实例只有一个。

© 著作权归作者所有

共有 人打赏支持
上一篇: 策略模式
爱宝贝丶

爱宝贝丶

粉丝 243
博文 109
码字总数 371516
作品 0
武汉
程序员
私信 提问
加载中

评论(2)

爱宝贝丶
爱宝贝丶

引用来自“梓博最帅”的评论

写的很清晰
谢谢!
梓博最帅
写的很清晰
策略模式与SPI机制,到底有什么不同?

这里说的策略模式是一种设计模式,经常用于有多种分支情况的程序设计中。例如我们去掉水果皮,一般来说对于不同的水果,会有不同的拨皮方式。此时用程序语言来表示是这样的: 如上面代码所写...

陈树义
2018/09/03
0
0
系统架构技能之设计模式-单件模式

一、开篇 其实我本来不是打算把系统架构中的一些设计模式单独抽出来讲解的,因为很多的好朋友也比较关注这方面的内容,所以我想通过我理解及平时项目中应用到的一 些常见的设计模式,拿出来给...

wbf961127
2017/11/12
0
0
JavaScript设计模式入坑

JavaScript设计模式入坑 介绍 设计模式编写易于维护的代码。 设计模式的开创者是一位土木工程师。Σ( ° △ °|||)︴,写代码就是盖房子。 模式 模式一种可以复用的解决方案。解决软件设计中...

小小小8021
2018/10/18
0
0
JavaScript 常见设计模式

前言 设计模式,这一话题一直都是程序员谈论的"高端"话题之一。许多程序员从设计模式中学到了设计软件的灵感和解决方案。 有人认为设计模式只在 C++或者 Java 中有用武之地,JavaScript 这种...

YeeWang王大白
03/08
0
0
【设计模式笔记】(十六)- 代理模式

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

MrTrying
2018/06/24
0
0

没有更多内容

加载失败,请刷新页面

加载更多

Confluence 6 升级中的一些常见问题

升级的时候遇到了问题了吗? 如果你想尝试重新进行升级的话,你需要首先重新恢复老的备份。不要尝试再次对 Confluence 进行升级或者在升级失败后重新启动老的 Confluence。 在升级过程中的一...

honeymoose
57分钟前
2
0
C++随笔(四)Nuget打包

首先把自己编译好的包全部准备到一个文件夹 像这样 接下来新建一个文本文档,后缀名叫.nuspec 填写内容 <?xml version="1.0"?><package xmlns="http://schemas.microsoft.com/packaging/201......

Pulsar-V
今天
2
0
再谈使用开源软件搭建数据分析平台

三年前,我写了这篇博客使用开源软件快速搭建数据分析平台, 当时收到了许多的反馈,有50个点赞和300+的收藏。到现在我还能收到一些关于dataplay2的问题。在过去的三年,开源社区和新技术的发...

naughty
今天
3
0
Python3的日期和时间

python 中处理日期时间数据通常使用datetime和time库 因为这两个库中的一些功能有些重复,所以,首先我们来比较一下这两个库的区别,这可以帮助我们在适当的情况下时候合适的库。 在Python文...

编程老陆
今天
2
0
分布式面试整理

并发和并行 并行是两个任务同时进行,而并发呢,则是一会做一个任务一会又切换做另一个任务。 临界区 临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用,但是每一次,只能有...

群星纪元
今天
3
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部