Java设计模式之单例模式及线程安全的懒加载实现

  • A+
所属分类:Java

在Java的世界里,诸多设计模式中最常见也最好理解的应该就是单例模式了。而我在招聘Android开发者的时候,很多应聘者的简历上也会说了解各种设计模式如单例模式,然而在提问的时候他们往往却并不能很好的给出相应的实现。

什么是单例模式?

顾名思义,就是某个类在全局中只有一个实例。如果一个类可以在外部随意通过new方法来实例化,那么它一定不是单例的。无论在哪里获取该类的实例,都应该是唯一的实例,即在不同地方获取到的实例实际上是同一个对象,哪怕是在不同的线程里获取到的依然是唯一实例。通常我们用getInstance()方法来对外提供该类的单例。

单例模式的应用场景

一些存放着全局共享变量的类就显而易见应该用单例模式,比如一个类里有一个变量,该变量表示运行设备的运行内存(RAM)大小,且提供了set和get方法。显然,在经过初始化,并且set了值之后, 其他地方调用该值的时候,如果不是单例,那么新的实例get到的值只能是默认值(如0)而不是之前所设定的。

实现一个可靠的单例模式

要想实现一个好的单例,一般来说要满足2个条件:一是要确实实现单例,即不管任何情况下该类都只能存在也给实例;二是最好实现懒加载(lazy load),即只有在首次调用到getInstance()的时候才进行实例化,如果某次程序运行没有调用到该方法,那么就不进行实例化,否则会是一种资源浪费。

显然需要实现单例的类,其构造方法一定要是private的,否则其他地方可以随意调用构造方法的话,就不可能实现什么单例了。下面,我们来看一下最常见的一些单例模式的实现(其实也是在面试别人的时候看到的)。

  • 饿汉式单例

所谓饿汉,可以理解为一个汉子饿怕了,所以不管他到底需不需要吃东西,都提前把吃的已经准备好了。当然了,假如他不饿,东西很可能就浪费了。

在第一次引用该类的时候就直接进行实例化(static关键字的作用)——即使并没有调用getInstance()方法。好处就是写起来容易,大部分人都懂,但是并不能满足按需实例化,也就是说假如我们只是引用该类的其他变量,也依然会实例化,造成了资源浪费。

  • 最简单的懒汉式单例

非常简单的懒汉式单例,在getInstance()里进行一次null判断,如果是null就new一个实例,最终返回instance对象。实现了懒加载,即只有在调用到getInstance()方法的时候才会实例化。但这种形式有个缺点:线程不安全!如果有多个线程同时调用getInstance()方法,就完全无法保证单例了,很可能导致new了多个对象,尤其是在私有构造方法里,如果里面的初始化操作比较多,就特别容易出现new了多个对象。我们可以通过new Thread()的办法来尝试在不同线程里调用getInstance()的情况:

然后我们获得了5次打印:

getInstance : com.kaelli.simpleproject.Singleton@2f1ab96

getInstance : com.kaelli.simpleproject.Singleton@f31f317

getInstance : com.kaelli.simpleproject.Singleton@b1fb304

getInstance : com.kaelli.simpleproject.Singleton@f31f317

getInstance : com.kaelli.simpleproject.Singleton@f31f317

结果很明显了,5个线程就出现3个不同的对象,这种简单的懒汉式写法肯定是行不通的。

  • 增加了线程安全的懒汉式

看起来挺不错的,给getInstance()加了锁,这样确保无论多少个线程来调用,都只能有一个线程在同一时间调用,应该是肯定不会出现多个实例了,也确保了懒加载。但还是有问题,任何时候,只要有调用了getInstance()的,其他线程、其他地方就必须排队等着解锁,而我们加锁其实只是为了单例,显然这种写法还是不合理。

  • 改进效率后线程安全的懒汉式

加锁前进行了null check,似乎比上面的代码更合理一些。但其实这种方法还是很不完善,比如有5个线程同时调用了getInstance(),大家都进行了null判断,发现instance是null,于是第一个幸运的线程开始进行初始化,其他4个线程则开始排队。当第一个线程初始化完毕,释放了锁之后,排队的线程们兴高采烈的会依次拿锁、初始化,最终你会获得5个全新的实例,显然这段代码不符合我们的要求。

  • DCL型的懒汉式

所谓DCL,就是Double-Checked Locking,也就是双重检查+锁的方式。这种形式看起来似乎没问题了:10个线程在调用getInstance()时,instance为null,一个线程拿到锁,在锁里再经过一次检查,instance依然为null,则进行初始化操作。其他线程则在进行等待,在第一个线程完成初始化并拿到了instance实例后,再依次获取实例。但实际上这种形式依然存在一个坑,有一定的可能导致获取到的instance为null!问题就出在instance = new Singleton();这里了,实际上它并不是原子性的(atomic),一个简单的初始化实际上分成3个步骤:

  1. 分配内存
  2. 将分配的内存指向实例的引用
  3. 将对象Singleton初始化给分配的内存

如果第一个线程的还卡在“将对象Singleton初始化给分配的内存”这里,第二个线程就有可能获取到锁,然后开心的以为拿到了Non-Null的instance,用的时候直接就挂了……所以这段代码还是需要更改的。

  • 给instance变量增加volatile关键字的DCL型的懒汉式

跟之前的一段代码相比,只多了一个volatile关键字。

当volatile用于一个作用域时,Java保证如下:

  1. (适用于Java所有版本)读和写一个volatile变量有全局的排序。也就是说每个线程访问一个volatile作用域时会在继续执行之前读取它的当前值,而不是(可能)使用一个缓存的值。(但是并不保证经常读写volatile作用域时读和写的相对顺序,也就是说通常这并不是有用的线程构建)。
  2. (适用于Java5及其之后的版本)volatile的读和写建立了一个happens-before关系,类似于申请和释放一个互斥锁。

显然第二点是我们所需要的。在使用了volatile之后,第一个线程的初始化未完成时,其他线程是得不到instance的,终于可以放心了。当然,仅仅适用于Java5之后的版本(应该没有用1.4甚至更古老版本的了吧?如果贵公司还用如此古老的版本,是时候跑路啦!而对于Android开发者来说,也不可能用这么老的Java了)。还有人说个别的JVM对volatile支持的不好,这个问题就暂不讨论了,反正主流的JVM是没问题的!

至此,问题已经圆满解决了!但是,还有没有更好的办法?

当然有!

  • 静态内部类实现单例

把Singleton实例放到一个静态内部类SingletonHolder中,这样就避免了静态实例在Singleton类加载的时候就创建对象,并且由于静态内部类只会被加载一次,所以这种写法也是线程安全的,可以说这种方法已经比较完善了,写起来也不复杂。

至此,我们通过各种尝试,已经实现了既保证单例又能实现延迟加载的实现了,基本上快要达到圆满了。但是,凡是就怕但是,它们依然不完美:

  1. 如果不自行实现序列化,那么每次去反序列化一个序列化的对象都会创建一个新的实例。
  2. 虽然我们的构造方法是private的,但在其他地方如果通过反射的方式还是可以调用构造方法,当然我们可以通过在构造方法里进行判断,创建第二个实例的时候抛出异常。

那么到底有没有,完美的、一劳永逸的、简洁明了的单例呢?有!

  • 枚举实现单例模式

是的,你没有看错!就是一个简单的枚举!好吧,为什么我们不再需要写一个私有的构造方法了?你可以在里面写一个,然后只要你的IDE够聪明,就会给你一个Warning:

Modifier “private”is redundant for enum constructors.

是的,enum的构造方法天生就是私有的。也直接提供了线程安全,还可以防止反序列化时创建新对象,说是完美并不夸张。以下这段话,就能看出Effective Java的作者Joshua Bloch对枚举单例的推崇了:

This approach is functionally equivalent to the public field approach, except that it is more concise, provides the serialization machinery for free, and provides an ironclad guarantee against multiple instantiation, even in the face of sophisticated serialization or reflection attacks. While this approach has yet to be widely adopted, a single-element enum type is the best way to implement a singleton.

——《Effective Java 2nd》

也许有人会有疑问,以前Android官网明确写着尽量不要用enum,因为会增加内存开销:

Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

不过呢,现在Android官网已经把这句争议了多年的话去掉了。毫无疑问,枚举一定会比直接定义常量多占用内存(因为枚举是一个完整的类,而常量只占用了最基本的内存),但我们是在实现单例啊同学!本身就要写一个类,所以枚举本来的那点问题就不算是问题了。再说句题外话吧,以Android设备现在的硬件配置,尤其是内存容量,我们完全不需要避讳枚举了。如果通过利用枚举能显著提升程序的可读性和可维护性,那么就不需要纠结这点内存消耗了。

KaelLi

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: