“这是我参与8月更文挑战的第12天,活动详情查看:8月更文挑战”
1、什么是单例模式
单例模式(Singleton Pattern)的定义:指一个类只有一个实例,且该类能自行创建这个实例的一种模式。
-
这种模式是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
-
这种模式涉及到一个单一的类(单例类),该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
注意:
- 1、单例类只能有一个实例。
- 2、单例类必须自己创建自己的唯一实例。
- 3、单例类必须给所有其他对象提供这一实例。
2、单例模式的优点和缺点
单例模式的优点:
- 单例模式可以保证内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例的情况下。
- 可以避免对资源的多重占用。
- 单例模式设置全局访问点,可以优化和共享资源的访问。
单例模式的缺点:
- 单例模式一般没有接口,不能继承,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
- 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
- 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。
3、单例模式的应用场景
对于 Java来说,单例模式可以保证在一个 JVM 中只存在单一实例。单例模式的应用场景主要有以下几个方面。
- 需要频繁创建的一些类,使用单例可以降低系统的内存压力,减少 GC。
- 某类只要求生成一个对象的时候,如一个班中的班长、每个人的身份证号等。
- 某些类创建实例时占用资源较多,或实例化耗时较长,且经常使用。
- 某类需要频繁实例化,而创建的对象又频繁被销毁的时候,如多线程的线程池、网络连接池等。
- 频繁访问数据库或文件的对象。
- 对于一些控制硬件级别的操作,或者从系统上来讲应当是单一控制逻辑的操作,如果有多个实例,则系统会完全乱套。
- 当对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并加快对象访问速度。如 Web 中的配置对象、数据库的连接池等。
4、单例模式实现的几种方式
getInstace()方法的作用
主函数当中使用此类的getInstance()函数,即可得到系统当前已经实例化的该类对象,若当前系统还没有实例化过这个类的对象,则调用此类的构造函数
4.1、饿汉式单例
//饿汉式单例
public class Hungry {
//构造器私有
private Hungry(){
}
////类加载时就初始化,主动创建
private final static Hungry hungry = new Hungry();
public static Hungry getInstance(){
return hungry;
}
}
复制代码
优点:没有加锁,避免了线程同步问题,执行效率会提高。
缺点:在类装载的时候就完成实例化,没有达到Lazy Loading的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费。
4.2、懒汉式单例
4.2.1、懒汉式,线程不安全
//懒汉式单例
public class LazyMan {
private LazyMan(){
}
//不主动创建实例,等需要的时候再创建
private static LazyMan lazyMan;
public static LazyMan getInstance(){
if (lazyMan==null){
lazyMan = new LazyMan();
}
return lazyMan;
}
}
复制代码
这段代码简单明了,而且使用了懒加载模式,但是却存在致命的问题。单线程下是OK的,但当有多个线程并行调用 getInstance() 的时候,就会创建多个实例。也就是说在多线程下不能正常工作。
做一个基本的多线程并发的测试:
//懒汉式单例
public class LazyMan {
private LazyMan(){
System.out.println(Thread.currentThread().getName()+"ok");
}
//不主动创建实例,等需要的时候再创建
private static LazyMan lazyMan;
public static LazyMan getInstance(){
if (lazyMan==null){
lazyMan = new LazyMan();
}
return lazyMan;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
LazyMan.getInstance();
}
}).start();
}
}
}
复制代码
运行,发现线程不安全
4.2.2、懒汉式,线程安全
为了解决上面的问题,最简单的方法是将整个 getInstance() 方法设为同步(synchronized)。
//懒汉式单例
public class LazyMan {
private LazyMan(){
System.out.println(Thread.currentThread().getName()+"ok");
}
//不主动创建实例,等需要的时候再创建
private static LazyMan lazyMan;
public static synchronized LazyMan getInstance(){
if (lazyMan==null){
lazyMan = new LazyMan();
}
return lazyMan;
}
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
LazyMan.getInstance();
}
}).start();
}
}
}
复制代码
这时再运行测试一下,发现一次就只能运行一个线程了。
比起上面代码仅仅在方法中多了一个synchronized修饰符,现在可以保证不会出线程问题了。但是这里有个很大(至少耗时比例上很大)的性能问题。除了第一次调用时是执行了LazyMan
的构造函数之外,以后的每一次调用都是直接返回lazyMan
对象。返回对象这个操作耗时是很小的,绝大部分的耗时都用在synchronized修饰符的同步准备上,因此从性能上来说很不划算。
我们只希望在第一次创建lazyMan
实例的时候进行同步,因此有了下面的写法——双重检验锁模式(Double Checked Locking Pattern)。
4.2.3、双重检验锁模式
双重检验锁模式(Double Checked Locking Pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 lazyMan== null
,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?==如果第一次检查instance不为null,那就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。==
package com.cheng.signle;
//懒汉式单例
public class LazyMan {
private LazyMan(){
}
//不主动创建实例,等需要的时候再创建
private static LazyMan lazyMan;
public static LazyMan getInstance(){
if (lazyMan==null){ //第一次检查
synchronized(LazyMan.class){
if (lazyMan==null){ //第二次检查
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
复制代码
上面这段代码看起来很完美,但是在极端情况下肯定是有问题。主要在于instance = new Singleton()
这句代码,因为这并非是一个原子操作,看起来是一步操作,事实上在 JVM 中这句代码大概做了下面 3 个步骤。
- 给 lazyMan分配内存空间
- 执行构造方法,初始化对象
- 将lazyMan对象指向分配的内存空间(执行完这步才保证对象new完了)
这个时候就可能会发生指令重排的现象,比如我们希望的执行步骤是123,但可能指令重排后的执行步骤是132。
指令重排造成的现象:
如果是132的情况,第一个线程A先分配内存空间,先把内存空间占用下来,占用之后再把对象放进去,这样在CPU中是可以做到的,因此A线程没有问题。但是这是突然又来了个B线程呢?线程B如果也执行132步骤的话,由于之前在线程A的时候,对象lazyMan已经指向内存空间了,因此线程B会认为lazyMan 已经是非 null 了,所以线程B会直接走return lazyMan;
这行代码,直接返回lazyMan,然后使用。但是此时lazyMan还没有完成构造,内存空间里是虚无的,所以线程B使用的时候会报错。
避免上面的问题,我们只需要将 lazyMan变量声明成 volatile 就可以了,这才是完整的双重检验锁模式。
package com.cheng.signle;
//懒汉式单例
public class LazyMan {
private LazyMan(){
}
//不主动创建实例,等需要的时候再创建
private volatile static LazyMan lazyMan;
public static synchronized LazyMan getInstance(){
if (lazyMan==null){
synchronized(LazyMan.class){
if (lazyMan==null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
复制代码
有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。**使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。**也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。
4.3、静态内部类实现单例
//静态内部类
public class Holder {
private Holder (){
}
//在外面调用内部类创建的对象
public static Holder getInstance(){
return InnerClass.HOLDER;
}
//在内部类创建对象
public static class InnerClass{
private static final Holder HOLDER = new Holder();
}
复制代码
这种写法仍然使用JVM本身机制保证了线程安全问题;由于 Holder是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。
4.4、破解单例模式
破解方式1.0
了解完双重检测锁模式后,肯定有人以为单例模式是安全的了,但是不然,因为Java里面有个特别霸道的东西——反射!
只要有反射,任何代码都不安全,就算是私有了的构造器我们也能获取到,获取到后我们就可以利用反射来创建对象,那么单例模式就被我们破解了。
下面我们就来试试破解最安全的单例模式——双重检测锁模式
package com.cheng.signle;
import java.lang.reflect.Constructor;
//懒汉式单例
public class LazyMan {
private LazyMan(){
}
//不主动创建实例,等需要的时候再创建
private volatile static LazyMan lazyMan;
//双重锁校验模式也叫DCL单例模式
public static LazyMan getInstance(){
if (lazyMan==null){
synchronized(LazyMan.class){
if (lazyMan==null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
public static void main(String[] args) throws Exception {
LazyMan instance1 =LazyMan.getInstance();//DCL单例模式创建的对象instance1
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);//通过反射获得LazyMan的空参构造器
declaredConstructor.setAccessible(true);//破除构造器的私有性
LazyMan instance2 = declaredConstructor.newInstance();//通过反射来创建的对象instance2
//输出两个对象的hashCode
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
复制代码
启动主函数,测试一下!
两个实例不一样,说明反射可以破解单例模式。
阻止方式1.0
上面这种破解的方式原理是:通过反射获得无参构造器,然后通过无参构造器来创建实例。面对这种破解方式我们可以阻止的。
阻止的方式就是在构造器上再加一层锁:
package com.cheng.signle;
import java.lang.reflect.Constructor;
//懒汉式单例
public class LazyMan {
private LazyMan(){//给构造器加锁,构造器只能new一次实例,当new第二次实例时会抛出异常
synchronized (LazyMan.class){
if (lazyMan!=null){//如果已经有实例了,抛出异常
throw new RuntimeException("不要试图用反射破解单例");
}
}
}
//不主动创建实例,等需要的时候再创建
private volatile static LazyMan lazyMan;
//双重锁校验模式
public static LazyMan getInstance(){
if (lazyMan==null){
synchronized(LazyMan.class){
if (lazyMan==null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
public static void main(String[] args) throws Exception {
LazyMan instance1 =LazyMan.getInstance();
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);//通过反射获得LazyMan的空参构造器
declaredConstructor.setAccessible(true);//破除构造器的私有性
LazyMan instance2 = declaredConstructor.newInstance();//通过反射来创建对象
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
复制代码
我们运行一下,看能不能阻止破解单例:
成功阻止,单例安全!
破解方式2.0
但是问题又来了,上面我们两个实例,第一个是通过DCL单例创建,第二个是通过反射创建,如果我们两个实例都是通过反射来创建,我们根本不用创建lazyMan实例,那么在构造器上加锁的阻止方式还有用吗,单例还安全吗?下面我们来测试一下;
public static void main(String[] args) throws Exception {
// LazyMan instance1 =LazyMan.getInstance();
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);//通过反射获得LazyMan的空参构造器
declaredConstructor.setAccessible(true);//破除构造器的私有性
LazyMan instance1 = declaredConstructor.newInstance();//通过反射来创建对象
LazyMan instance2 = declaredConstructor.newInstance();//通过反射来创建对象
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
复制代码
点击运行,比较两个实例:
根据运行结果来看,单例模式还是被破坏了,阻止方式1.0没有起到效果!
阻止方式2.0
在构造器第一次同步时,加入标志位,当实例是通过构造器创建时,改变标志位,当通过反射创建是,输出异常
import java.lang.reflect.Constructor;
//懒汉式单例
public class LazyMan {
private static boolean cheng = false;//定义一个变量,也可以是一串秘钥
private LazyMan(){
synchronized (LazyMan.class){
if (cheng==false){//当第一次同步时,标志位会变
cheng=true;
}else{
throw new RuntimeException("不要试图用反射破解单例");
}
}
}
//不主动创建实例,等需要的时候再创建
private volatile static LazyMan lazyMan;
//双重锁校验模式
public static LazyMan getInstance(){
if (lazyMan==null){
synchronized(LazyMan.class){
if (lazyMan==null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
public static void main(String[] args) throws Exception {
// LazyMan instance1 =LazyMan.getInstance();
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);//通过反射获得LazyMan的空参构造器
declaredConstructor.setAccessible(true);//破除构造器的私有性
LazyMan instance1 = declaredConstructor.newInstance();//通过反射来创建对象
LazyMan instance2 = declaredConstructor.newInstance();//通过反射来创建对象
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
复制代码
我们运行一下,看能不能阻止破解单例:
再次成功阻止,单例安全!
破解方式3.0
如果定义的标志位cheng
被反编译获取的话,那么单例又将变得不安全,因为我们可以手动修改标志位:
package com.cheng.single;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
//懒汉式单例
public class LazyMan {
private static boolean cheng = false;//定义一个变量,也可以是一串秘钥
private LazyMan(){//给构造器加锁,构造器只能new一次实例,当new第二次实例时会抛出异常
synchronized (LazyMan.class){
if (cheng==false){//当第一次同步时,标志位会变
cheng=true;
}else{
throw new RuntimeException("不要试图用反射破解单例");
}
}
}
//不主动创建实例,等需要的时候再创建
private volatile static LazyMan lazyMan;
//双重锁校验模式
public static LazyMan getInstance(){
if (lazyMan==null){
synchronized(LazyMan.class){
if (lazyMan==null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
public static void main(String[] args) throws Exception {
// LazyMan instance1 =LazyMan.getInstance();
Field cheng = LazyMan.class.getDeclaredField("cheng");//获取标志位
cheng.setAccessible(true);//破坏标志位的私有属性
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);//通过反射获得LazyMan的空参构造器
declaredConstructor.setAccessible(true);//破除构造器的私有性
LazyMan instance1 = declaredConstructor.newInstance();//通过反射来创建对象
cheng.set(instance1,false);//手动修改标志位
LazyMan instance2 = declaredConstructor.newInstance();//通过反射来创建对象
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
复制代码
点击运行,比较两个实例:
根据运行结果来看,单例模式又被破坏了,阻止方式2.0没有起到效果!
所以,道高一尺,魔高一丈!到底如何能保证单例的安全,我们还是通过源码分析,点进newInstance()的源码:如下
通过源码我们得知:通过枚举类型创建的单例不能被破坏
4.5、枚举实现单例
那么我们来建一个枚举类实现单例模式,看看是否能不被破坏。
package com.cheng.single;
//创建enum时,编译器会自动为我们生成一个继承自java.lang.Enum的类
public enum EnumSingle {
INSTANCE;
public EnumSingle getInstance(){
return INSTANCE;
}
}
class Test{
public static void main(String[] args) {
EnumSingle instance1 = EnumSingle.INSTANCE;
EnumSingle instance2 = EnumSingle.INSTANCE;
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
复制代码
这时候两个实例是相同的:
尝试破坏枚举单例
下面来验证一下,枚举单例是不是真的不能被反射破坏,
首先我们需要知道枚举类构造是无参还是有参,通过源码判断:
通过源码得知为无参构造,我们下面来测试一下是不是真的无参。
package com.cheng.single;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
//创建enum时,编译器会自动为我们生成一个继承自java.lang.Enum的类
public enum EnumSingle {
INSTANCE;
public EnumSingle getInstance(){
return INSTANCE;
}
}
class Test{
public static void main(String[] args) throws Exception {
//通过EnumSingle.INSTANCE访问实例
EnumSingle instance1 = EnumSingle.INSTANCE;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance1.hashCode());
System.out.println(enumSingle.hashCode());
}
}
复制代码
运行,查看结果
很显然,结果并不是我们想要的Cannot reflectively create enum objects
异常,而是不存在空参构造方法。运行结果是不会骗人的,说明是IDEA欺骗了我们,它告诉我们EnumSingle里存在空参构造,但其实并没有。
我们来反编译EnumSingle的源码,来看看这个类里面到底有没有空参构造制造。
编译后的代码还是有空参构造,因此还是不正确的。
到这里为止,说明这些编译工具编译后的源代码都不正确,我们必须要使用更为强大的反编译工具——jad,不熟悉怎么用jad的小伙伴可以网上学习一下。
使用jad编译
我们看一下文件夹,确实给我们生成了java文件,
打开看看:
经过jad的反编译,我们发现EmunSingle枚举类里面含有的构造器不是无参,而是有参,带有两个参数,String和int。
下面我们再来测试一下用反射能不能破坏枚举类型单例,看运行结果是不是我们期望的Cannot reflectively create enum objects
package com.cheng.single;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
//创建enum时,编译器会自动为我们生成一个继承自java.lang.Enum的类
public enum EnumSingle {
INSTANCE;
public EnumSingle getInstance(){
return INSTANCE;
}
}
class Test{
public static void main(String[] args) throws Exception {
//通过EnumSingle.INSTANCE访问实例
EnumSingle instance1 = EnumSingle.INSTANCE;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);
declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance1.hashCode());
System.out.println(enumSingle.hashCode());
}
}
复制代码
运行查看结果:
结果是我们期望的,同时也说明EnumSingle里面是有参构造。
枚举实现单例的优点:
我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。
5、总结
-
一般来说,单例模式有五种写法:懒汉、饿汉、双重检验锁(DCL)、静态内部类、枚举。上述所说都是线程安全的实现,文章给出的第一种方法不算正确的写法。
-
一般情况下直接使用饿汉式就好了,如果明确要求要懒加载(lazy initialization)会倾向于使用静态内部类,如果涉及到反序列化创建对象时会试着使用枚举的方式来实现单例。