游戏开发工具

单例模式简介

一、单例模式简介

单例模式简介

这个章节不同寻常,其他章节展示如何使用某个设计模式,这个章节展示如何避免使用某个设计模式。

尽管它的意图是好的,GoF描述的单例模式通常弊大于利,他们强调应该谨慎使用这个模式,但在游戏业界的口口相传中,这一提示经常被无视了。


就像其他模式一样,在不合适的地方使用单例模式就好像用夹板处理子弹伤口,由于它被滥用得太严重了,这章的大部分都在讲如何回避单例模式, 但首先,让我们看看模式本身。


当业界从C语言迁移到面向对象的语言,他们遇到的首个问题是“如何访问实例?” 他们知道有要调用的方法,但是找不到实例提供这个方法,单例(换言之,全局化)是一条简单的解决方案。

单例模式

设计模式 像这样描述单例模式:

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

我们从“并且”那里将句子分为两部分,分别进行考虑。


保证一个类只有一个实例

有时候,如果类存在多个实例就不能正确的运行。 通常发生在类与保存全局状态的外部系统互动时。


考虑封装文件系统的API类,因为文件操作需要一段时间完成,所以类使用异步操作。 这就意味着可以同时运行多个操作,必须让它们相互协调,如果一个操作创建文件,另一个操作删除同一文件,封装器类需要同时考虑,保证它们没有相互妨碍。


为了实现这点,对我们封装器类的调用必须接触之前的每个操作。 如果用户可以自由地创建类的实例,这个实例就无法知道另一实例之前的操作。 而单例模式提供的构建类的方式,在编译时保证类只有单一实例。


提供了访问该实例的全局访问点

游戏中的不同系统都会使用文件系统封装类:日志,内容加载,游戏状态保存等等。 如果这些系统不能创建文件系统封装类的实例,它们如何访问该实例呢?


单例为这点也提供了解决方案,除了创建单一实例以外,它也提供了一种获得它的全局方法,使用这种范式,无论何处何人都可以访问实例。 综合起来,经典的实现方案如下:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    // 惰性初始化
    if (instance_ == NULL) instance_ = new FileSystem();
    return *instance_;
  }

private:
  FileSystem() {}
  static FileSystem* instance_;
};


静态的instance 成员保存了一个类的实例, 私有的构造器保证了它是唯一的,公开的静态方法instance()让任何地方的代码都能访问实例,在首次被请求时,它同样负责惰性实例化该单例。


现代的实现方案看起来是这样的:

class FileSystem
{
public:
  static FileSystem& instance()
  {
    static FileSystem *instance = new FileSystem();
    return *instance;
  }

private:
  FileSystem() {}
};


哪怕是在多线程情况下,C++11标准也保证了本地静态变量只会初始化一次, 因此,假设你有一个现代C++编译器,这段代码是线程安全的,而前面的那个例子不是。


二、为什么我们使用它

为什么我们使用它

文件系统封装类在任何需要的地方都可用,而无需笨重地到处传递,类本身巧妙地保证了我们不会实例化多个实例而搞砸。它还具有很多其他的优良性质:

1、如果没人用,就不必创建实例

节约内存和CPU循环总是好的,由于单例只在第一次被请求时实例化,如果游戏永远不请求,那么它不会被实例化。

2、它在运行时实例化

通常的替代方案是使用含有静态成员变量的类,我喜欢简单的解决方案,因此我尽可能使用静态类而不是单例,但是静态成员有个限制:自动初始化。 

编译器在main()运行前初始化静态变量,这就意味着不能使用在程序加载时才获取的信息(举个例子,从文件加载的配置)。 这也意味着它们的相互依赖是不可靠的——编译器可不保证以什么样的顺序初始化静态变量。

3、惰性初始化解决了以上两个问题

单例会尽可能晚地初始化,所以那时它需要的所有信息都应该可用了,只要没有环状依赖,一个单例在初始化它自己的时甚至可以引用另一个单例。

4、可继承单例

这是个很有用但通常被忽视的能力,假设我们需要跨平台的文件系统封装类,为了达到这一点,我们需要它变成文件系统抽象出来的接口,而子类为每个平台实现接口。

 这是基类:

class FileSystem
{
public:
  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;
};


然后为一堆平台定义子类:

class PS3FileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用索尼的文件读写API……
  }
  virtual void writeFile(char* path, char* contents)
  {
    // 使用索尼的文件读写API……
  }
};

class WiiFileSystem : public FileSystem
{
public:
  virtual char* readFile(char* path)
  {
    // 使用任天堂的文件读写API……
  }
  virtual void writeFile(char* path, char* contents)
  {
    // 使用任天堂的文件读写API……
  }
};


下一步,我们把FileSystem变成单例:

class FileSystem
{
public:
  static FileSystem& instance();
  virtual ~FileSystem() {}
  virtual char* readFile(char* path) = 0;
  virtual void  writeFile(char* path, char* contents) = 0;
protected:
  FileSystem() {}
};


灵巧之处在于如何创建实例:

FileSystem& FileSystem::instance()
{
  #if PLATFORM == PLAYSTATION3
    static FileSystem *instance = new PS3FileSystem();
  #elif PLATFORM == WII
    static FileSystem *instance = new WiiFileSystem();
  #endif
  return *instance;
}


通过一个简单的编译器转换,我们把文件系统包装类绑定到合适的具体类型上,整个代码库都可以使用FileSystem::instance()接触到文件系统,而无需和任何平台相关的代码耦合,耦合发生在为特定平台写的FileSystem类实现文件中。


大多数人解决问题到这个程度就已经够了,我们得到了一个文件系统封装类,它工作可靠,它全局有效,只要请求就能获取,是时候提交代码,开怀畅饮了。


三、它是一个全局变量

短期来看,单例模式是相对良性的,就像其他设计决策一样,我们需要从长期考虑,这里是一旦我们将一些不必要的单例写进代码,会给自己带来的麻烦。

它是一个全局变量

当游戏还是由几个家伙在车库中完成时,榨干硬件性能比象牙塔里的软件工程原则更重要。 C语言和汇编程序员前辈能毫无问题地使用全局变量和静态变量,发布好游戏。 但随着游戏变得越来越大,越来越复杂,架构和管理开始变成瓶颈, 阻碍我们发布游戏的,除了硬件限制,还有生产力限制。


所以我们迁移到了像C++这样的语言, 开始将一些从软件工程师前辈那里学到的智慧应用于实际。 其中一课是全局变量有害的诸多原因:

1、理解代码更加困难

假设我们在查找其他人所写函数中的漏洞,如果函数没有碰到任何全局状态,脑子只需围着函数转, 只需搞懂函数和传给函数的变量。

现在考虑函数中间是个对SomeClass::getSomeGlobalData()的调用。为了查明发生了什么,得追踪整个代码库来看看什么修改了全局变量。你真的不需要讨厌全局变量,直到你在凌晨三点使用grep搜索数百万行代码,搞清楚哪一个错误的调用将一个静态变量设为了错误的值。

2、促进了耦合的发生

新加入团队的程序员也许不熟悉你们完美、可维护、松散耦合的游戏架构, 但还是刚刚获得了第一个任务:在岩石撞击地面时播放声音。 你我都知道这不需要将物理和音频代码耦合,但是他只想着把任务完成。 不幸的是,我们的AudioPlayer是全局可见的。 所以之后一个小小的#include,新队员就打乱了整个精心设计的架构。

如果不用全局实例实现音频播放器,那么哪怕他确实用#include包含了头文件,他还是啥也做不了。 这种阻碍给他发送了一个明确的信号,这两个模块不该接触,他需要另辟蹊径。通过控制对实例的访问,你控制了耦合。

3、对并行不友好

那些在单核CPU上运行游戏的日子已经远去。 哪怕完全不需要并行的优势,现代的代码至少也应考虑在多线程环境下工作。 当我们将某些东西转为全局变量时,我们创建了一块每个线程都能看到并访问的内存, 却不知道其他线程是否正在使用那块内存。 这种方式带来了死锁,竞争状态,以及其他很难解决的线程同步问题。


像这样的问题足够吓阻我们声明全局变量了, 同理单例模式也是一样,但是那还没有告诉我们应该如何设计游戏。 怎样不使用全局变量构建游戏?


有几个对这个问题的答案, 但是它们并非显而易见,与此同时,我们得发布游戏,单例模式看起来是万能药,它被写进了一本关于面向对象设计模式的书中,因此它肯定是个好的设计模式,对吧? 况且我们已经借助它做了很多年软件设计了。


不幸的是,它不是解药,它是安慰剂,如果浏览全局变量造成的问题列表,你会注意到单例模式解决不了其中任何一个,因为单例确实是全局状态——它只是被封装在一个类中。


四、它能在你只有一个问题的时候解决两个

它能在你只有一个问题的时候解决两个

在GoF对单例模式的描述中,“并且”这个词有点奇怪,这个模式解决了一个问题还是两个问题呢?如果我们只有其中一个问题呢? 保证实例是唯一存在的是很有用的,但是谁告诉我们要让每个人都能接触到它? 同样,全局接触很方便,但是必须禁止存在多个实例吗?


这两个问题中的后者,便利的访问,几乎是使用单例模式的全部原因,想想日志类,大部分模块都能从记录诊断日志中获益,但是,如果将Log类的实例传给每个需要这个方法的函数,那就混杂了产生的数据,模糊了代码的意图。


明显的解决方案是让Log类成为单例,每个函数都能从类那里获得一个实例,但当我们这样做时,我们无意地制造了一个奇怪的小约束,突然之间,我们不再能创建多个日志记录者了。


起初,这不是一个问题,我们记录单独的日志文件,所以只需要一个实例,然后,随着开发周期的逐次循环,我们遇到了麻烦。 每个团队的成员都使用日志记录各自的诊断信息,大量的日志倾泻在文件里,程序员需要翻过很多页代码来找到他关心的记录。


我们想将日志分散到多个文件中来解决这点,为了达到这点,我们得为游戏的不同领域创造单独的日志记录者: 网络,UI,声音,游戏,玩法。 但是我们做不到,Log类不再允许我们创建多个实例,而且调用的方式也保证了这一点:

Log::instance().write("Some event.");


为了让Log类支持多个实例(就像它原来的那样), 我们需要修改类和提及它的每一行代码,之前便利的访问就不再那么便利了。


这可能更糟,想象一下你的Log类是在多个游戏间共享的库中,现在,为了改变设计,需要在多组人之间协调改变, 他们中的大多数既没有时间,也没有动机修复它。


五、惰性初始化从你那里剥夺了控制权

惰性初始化从你那里剥夺了控制权

在拥有虚拟内存和软性性能需求的PC里,惰性初始化是一个小技巧,游戏则是另一种状况,初始化系统需要消耗时间:分配内存,加载资源等等。 如果初始化音频系统消耗了几百个毫秒,我们需要控制它何时发生,如果在第一次声音播放时惰性初始化它自己,这可能发生在游戏的高潮部分,导致可见的掉帧和断续的游戏体验。


同样,游戏通常需要严格管理在堆上分配的内存来避免碎片,如果音频系统在初始化时分配到了堆上,我们需要知道初始化在何时发生, 这样我们可以控制内存待在堆的哪里。


因为这两个原因,我见到的大多数游戏都不使用惰性初始化。 相反,它们像这样实现单例模式:

class FileSystem
{
public:
  static FileSystem& instance() { return instance_; }
private:
  FileSystem() {}
  static FileSystem instance_;
};


这解决了惰性初始化问题,但是损失了几个单例确实比原生的全局变量优良的特性,静态实例中,我们不能使用多态,在静态初始化时,类也必须是可构建的,我们也不能在不需要这个实例的时候,释放实例所占的内存。


与创建一个单例不同,这里实际上是一个简单的静态类,这并非坏事,但是如果你需要的是静态类,为什么不完全摆脱instance()方法, 直接使用静态函数呢?调用Foo::bar()比Foo::instance().bar()更简单, 也更明确地表明你在处理静态内存。


六、看看你是不是真正地需要类

如果我现在达到了目标,你在下次遇到问题使用单例模式之前就会三思而后行,但是你还是有问题需要解决,你应该使用什么工具呢? 这取决于你试图做什么,我有一些你可以考虑的选项,但是首先……

看看你是不是真正地需要类

我在游戏中看到的很多单例类都是“管理器”——那些类存在的意义就是照顾其他对象,我曾看到一些代码库中,几乎所有类都有管理器: 怪物,怪物管理器,粒子,粒子管理器,声音,声音管理器,管理管理器的管理器,有时候,它们被叫做“系统”或“引擎”,但是思路还是一样的。


管理器类有时是有用的,但通常它们只是反映出作者对OOP的不熟悉,思考这两个特制的类:

class Bullet
{
public:
  int getX() const { return x_; }
  int getY() const { return y_; }
  void setX(int x) { x_ = x; }
  void setY(int y) { y_ = y; }
private:
  int x_, y_;
};

class BulletManager
{
public:
  Bullet* create(int x, int y)
  {
    Bullet* bullet = new Bullet();
    bullet->setX(x);
    bullet->setY(y);
    return bullet;
  }
  bool isOnScreen(Bullet& bullet)
  {
    return bullet.getX() >= 0 &&
           bullet.getX() < SCREEN_WIDTH &&
           bullet.getY() >= 0 &&
           bullet.getY() < SCREEN_HEIGHT;
  }
  void move(Bullet& bullet)
  {
    bullet.setX(bullet.getX() + 5);
  }
};


也许这个例子有些蠢,但是我见过很多代码,在剥离了外部的细节后是一样的设计。 如果你看看这个代码,BulletManager很自然应是一个单例,无论如何,任何有Bullet的对象都需要管理,而你又需要多少个BulletManager实例呢?


事实上,这里的答案是零。 这里是我们如何为管理类解决“单例”问题:

class Bullet
{
public:
  Bullet(int x, int y) : x_(x), y_(y) {}
  bool isOnScreen()
  {
    return x_ >= 0 && x_ < SCREEN_WIDTH &&
           y_ >= 0 && y_ < SCREEN_HEIGHT;
  }
  void move() { x_ += 5; }
private:
  int x_, y_;
};


好了,没有管理器,也没有问题,糟糕设计的单例通常会“帮助”另一个类增加代码,如果可以,把所有的行为都移到单例帮助的类中,毕竟,OOP就是让对象管理好自己。


但是在管理器之外,还有其他问题我们需要寻求单例模式帮助,对于每种问题,都有一些后续方案可供参考。


七、将类限制为单一的实例

将类限制为单一的实例

这是单例模式帮你解决的一个问题,就像在文件系统的例子中那样,保证类只有一个实例是很重要的。 但是,这不意味着我们需要提供对实例的公众,全局访问。 我们想要减少某部分代码的公众部分,甚至让它在类中是私有的,在这些情况下,提供一个全局接触点消弱了整体架构。


我们希望有种方式能保证同事只有一个实例而无需提供全局接触点。 有好几种方法能做到。

这是其中之一:

class FileSystem
{
public:
  FileSystem()
  {
    assert(!instantiated_);
    instantiated_ = true;
  }
  ~FileSystem() { instantiated_ = false; }

private:
  static bool instantiated_;
};
bool FileSystem::instantiated_ = false;


这个类允许任何人构建它,如果你试图构建超过一个实例,它会断言并失败,只要正确的代码首先创建了实例,那么就保证了没有其他代码可以接触实例或者创建自己的实例,这个类保证满足了它关注的单一实例,但是它没有指定类该如何被使用。


这个实现的缺点是只在运行时检查并阻止多重实例化,单例模式正相反,通过类的自然结构,在编译时就能确定实例是单一的。


八、如何提供方便的访问方法

如何提供方便的访问方法

便利的访问是我们使用单例的一个主要原因,这让我们在不同地方获取需要的对象更加容易,这种便利是需要付出代价的——在我们不想要对象的地方,也能轻易地使用


通用原则是在能完成工作的同时,将变量写得尽可能局部。 对象影响的范围越小,在处理它时,我们需要放在脑子里的东西就越少,在我们拿起有全局范围影响的单例对象前,先考虑考虑代码中其他获取对象的方式:

1、传进来 最简单的解决办法,通常也是最好的,把你需要的对象简单地作为参数传给需要它的函数。 在用其他更加繁杂的方法前,考虑一下这个解决方案。

考虑渲染对象的函数。为了渲染,它需要接触一个代表图形设备的对象,管理渲染状态,将其传给所有渲染函数是很自然的,通常是用一个名字像context之类的参数。

另一方面,有些对象不该在方法的参数列表中出现。 

举个例子,处理AI的函数可能也需要写日志文件,但是日志不是它的核心关注点,看到Log出现在它的参数列表中是很奇怪的事情,像这样的情况,我们需要考虑其他的选项。

2、从基类中获得 很多游戏架构有浅层但是宽泛的继承层次,通常只有一层深。 

举个例子,你也许有GameObject基类,每个游戏中的敌人或者对象都继承它,使用这样的架构,很大一部分游戏代码会存在于这些“子”推导类中。 

这就意味着这些类已经有了对同样事物的相同获取方法:它们的GameObject基类。 

我们可以利用这点:

class GameObject
{
protected:
  Log& getLog() { return log_; }
private:
  static Log& log_;
};

class Enemy : public GameObject
{
  void doSomething()
  {
    getLog().write("I can log!");
  }
};


这保证任何GameObject之外的代码都不能接触Log对象,但是每个派生的实体都确实能使用getLog(),这种使用protected函数,让派生对象使用的模式, 被涵盖在子类沙箱这章中。

3、从已经是全局的东西中获取 移除所有全局状态的目标令人钦佩,但并不实际。 大多数代码库仍有一些全局可用对象,比如一个代表了整个游戏状态的Game或World对象。

我们可以让现有的全局对象捎带需要的东西,来减少全局变量类的数目。 不让Log,FileSystem和AudioPlayer都变成单例,而是这样做:

class Game
{
public:
  static Game& instance() 
  { 
      return instance_; 
  }
  // 设置log_, et. al. ……
  Log& getLog()         
  { 
      return *log_; 
  }
  FileSystem&  getFileSystem()  
  { 
      return *fileSystem_; 
  }
  AudioPlayer& getAudioPlayer() 
  { 
      return *audioPlayer_; 
  }
private:
  static Game instance_;
  Log *log_;
  FileSystem  *fileSystem_;
  AudioPlayer *audioPlayer_;
};


这样,只有Game是全局可见的。 函数可以通过它访问其他系统。

Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);


如果,稍后,架构被改为支持多个Game实例(可能是为了流处理或者测试), Log,FileSystem,和AudioPlayer都不会被影响到——它们甚至不知道有什么区别,缺陷是,当然,更多的代码耦合到了Game中,如果一个类简单地需要播放声音,为了访问音频播放器,上例中仍然需要它知道游戏世界。


我们通过混合方案解决这点,知道Game的代码可以直接从它那里访问AudioPlayer,而不知道的代码,我们用上面描述的其他选项来提供AudioPlayer。


从服务定位器中获得,目前为止,我们假设全局类是具体的类,比如Game,另一种选项是定义一个类,存在的唯一目标就是为对象提供全局访问,这种常见的模式被称为服务定位器模式,有单独讲它的章节。


九、单例中还剩下什么

单例中还剩下什么

剩下的问题,何处我们应该使用真实的单例模式? 

说实话,我从来没有在游戏中使用全部的GoF模式,为了保证实例是单一的,我通常简单地使用静态类,如果这无效,我使用静态标识位,在运行时检测是不是只有一个实例被创建了。


书中还有一些其他章节也许能有所帮助,子类沙箱模式通过分享状态, 给实例以类的访问权限而无需让其全局可用,服务定位器模式确实让一个对象全局可用, 但它给了你如何设置对象的灵活性。


十、再来一个模式实例

再来一个模式实例

我们将创建一个 SingleObject 类,SingleObject 类有它的私有构造函数和本身的一个静态实例。

SingleObject 类提供了一个静态方法,供外界获取它的静态实例,SingletonPatternDemo 类使用 SingleObject 类来获取 SingleObject 对象。

1.jpg


步骤 1

创建一个 Singleton 类。

SingleObject.java
public class SingleObject 
{
   //创建 SingleObject 的一个对象
   private static SingleObject instance = new SingleObject();
 
   //让构造函数为 private,这样该类就不会被实例化
   private SingleObject(){}
 
   //获取唯一可用的对象
   public static SingleObject getInstance()
   {
      return instance;
   }
 
   public void showMessage()
   {
      System.out.println("Hello World!");
   }
}


步骤 2

从 singleton 类获取唯一的对象。

SingletonPatternDemo.java
public class SingletonPatternDemo 
{
   public static void main(String[] args) 
   {
      //不合法的构造函数
      //编译时错误:构造函数 SingleObject() 是不可见的
      //SingleObject object = new SingleObject();
 
      //获取唯一可用的对象
      SingleObject object = SingleObject.getInstance();
 
      //显示消息
      object.showMessage();
   }
}


步骤 3

执行程序,输出结果:

Hello World!


十一、单例模式的几种实现方式

单例模式的几种实现方式

单例模式的实现有多种方式,如下所示:

1、懒汉式,线程不安全

是否 Lazy 初始化:是

是否多线程安全:否

实现难度:易

描述:这种方式是最基本的实现方式,这种实现最大的问题就是不支持多线程。因为没有加锁 synchronized,所以严格意义上它并不算单例模式。

这种方式 lazy loading 很明显,不要求线程安全,在多线程不能正常工作。

实例

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


接下来介绍的几种实现方式都支持多线程,但是在性能上有所差异。


2、懒汉式,线程安全

是否 Lazy 初始化:是

是否多线程安全:是

实现难度:易

描述:这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低,99% 情况下不需要同步。

优点:第一次调用才初始化,避免内存浪费。

缺点:必须加锁 synchronized 才能保证单例,但加锁会影响效率。

getInstance() 的性能对应用程序不是很关键(该方法使用不太频繁)。

实例

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


3、饿汉式

是否 Lazy 初始化:否

是否多线程安全:是

实现难度:易

描述:这种方式比较常用,但容易产生垃圾对象。

优点:没有加锁,执行效率会提高。

缺点:类加载时就初始化,浪费内存。

它基于 classloader 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用 getInstance 方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 显然没有达到 lazy loading 的效果。

实例

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


4、双检锁/双重校验锁(DCL,即 double-checked locking)

JDK 版本:JDK1.5 起

是否 Lazy 初始化:是

是否多线程安全:是

实现难度:较复杂

描述:这种方式采用双锁机制,安全且在多线程情况下能保持高性能。

getInstance() 的性能对应用程序很关键。

实例

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


5、登记式/静态内部类

是否 Lazy 初始化:是

是否多线程安全:是

实现难度:一般

描述:这种方式能达到双检锁方式一样的功效,但实现更简单,对静态域使用延迟初始化,应使用这种方式而不是双检锁方式,这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

这种方式同样利用了 classloader 机制来保证初始化 instance 时只有一个线程,它跟第 3 种方式不同的是:第 3 种方式只要 Singleton 类被装载了,那么 instance 就会被实例化(没有达到 lazy loading 效果),而这种方式是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance。

想象一下,如果实例化 instance 很消耗资源,所以想让它延迟加载,另外一方面,又不希望在 Singleton 类加载时就实例化,因为不能确保 Singleton 类还可能在其他的地方被主动使用从而被加载,那么这个时候实例化 instance 显然是不合适的。这个时候,这种方式相比第 3 种方式就显得很合理。

实例

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


6、枚举

JDK 版本:JDK1.5 起

是否 Lazy 初始化:否

是否多线程安全:是

实现难度:易

描述:这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法,它更简洁,自动支持序列化机制,绝对防止多次实例化。

这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏,在实际工作中,也很少用。

不能通过 reflection attack 来调用私有构造方法。

实例

public enum Singleton 
{  
    INSTANCE;  
    public void whateverMethod() 
    {  
    }  
}


经验之谈:一般情况下,不建议使用第 1 种和第 2 种懒汉方式,建议使用第 3 种饿汉方式,只有在要明确实现 lazy loading 效果时,才会使用第 5 种登记方式。如果涉及到反序列化创建对象时,可以尝试使用第 6 种枚举方式,如果有其他特殊的需求,可以考虑使用第 4 种双检锁方式。


十二、单例模式归纳

单例模式归纳

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

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供了一个全局访问点来访问该实例。


单例特点

1、单例类只能有一个实例。

2、单例类必须自己创建自己的唯一实例。

3、单例类必须给所有其他对象提供这一实例。


单例介绍

意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决:一个全局使用的类频繁地创建与销毁。

何时使用:当您想控制实例数目,节省系统资源的时候。

如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

关键代码:构造函数是私有的。


应用实例

1、一个班级只有一个班主任。

2、Windows 是多进程多线程的,在操作一个文件的时候,就不可避免地出现多个进程或线程同时操作一个文件的现象,所以所有文件的处理必须通过唯一的实例来进行。

3、一些设备管理器常常设计为单例模式,比如一个电脑有两台打印机,在输出的时候就要处理不能两台打印机打印同一个文件。


十三、单例模式场景

应用场景

1、需要频繁创建的一些类,使用单例可以降低系统的内存压力,减少 GC。

2、某类只要求生成一个对象的时候,如一个班中的班长、每个人的身份证号等。

3、某些类创建实例时占用资源较多,或实例化耗时较长,且经常使用。

4、某类需要频繁实例化,而创建的对象又频繁被销毁的时候,如多线程的线程池、网络连接池等。

5、频繁访问数据库或文件的对象。

6、对于一些控制硬件级别的操作,或者从系统上来讲应当是单一控制逻辑的操作,如果有多个实例,则系统会完全乱套。

7、当对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并加快对象访问速度。如 Web 中的配置对象、数据库的连接池等。


使用场景

1、要求生产唯一序列号。

2、WEB 中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。

3、创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。


十四、单例模式优缺点

单例优点

1、单例模式可以保证内存里只有一个实例,减少了内存的开销。

2、可以避免对资源的多重占用。

3、单例模式设置全局访问点,可以优化和共享资源的访问。


单例缺点

1、单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。

2、在并发测试中,单例模式不利于代码调试,在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。

3、单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。


注意事项

1、getInstance() 方法中需要使用同步锁 synchronized (Singleton.class) 防止多线程同时进入造成 instance 被多次实例化。

2、单例模式看起来非常简单,实现起来也非常简单,单例模式在面试中是一个高频面试题。希望大家能够认真学习,掌握单例模式,提升核心竞争力,给面试加分,顺利拿到 Offer。