最全单例模式讲解

182 阅读11分钟

什么是单例模式

单例模式是一种常用的设计模式,使用单例模式实现的类只能允许一个实例的存在, 通俗的来说单例类只能有一个实例,并且在整个项目中都能访问到这个实例。

单例模式最常见的应用就是用来读取配置信息,比如在某个程序中,该程序的配置信息存放在一个文件中,这些配置信息由一个单例对象统一读取,然后程序中的其它对象再通过该单例对象来读取这些配置信息,这种方式简化了在复杂环境下的配置管理。

优缺点以及适用场景

优点:

  1. 内存中也只有一个对象存在,节省了内存空间。
  2. 避免频繁的创建和销毁对象,可以提高性能。
  3. 避免对共享资源的多重占用,比如写文件操作。
  4. 单例对象全局可访问,且都是同一个对象。

缺点:

  1. 不适用于变化的对象,如果同一类型的对象总是在不同的使用场景中发生变化,单例就会导致数据的错误,不能保存披此的状态。
  2. 单例模式没有抽象层,所以扩展起来很麻烦。
  3. 单例类的职责过重,既要负责对象本身的功能,还要负责对象的创建,一定程度上不符合开闭原则。
  4. 滥用单例也会带来一些负面问题,比如为了节省资源,将数据库连接池对象设置为单例,可能会导致共享连接池对象的程序过多,从而出现连接池溢出。

使用场景:

  1. 整个程序运行过程中只允许有一个对象实例的类。
  2. 需要频繁创建、销毁对象的类。
  3. 创建对象时间太长或者资源开销太大的,但又经常用到的类。

单例模式实现方式

  • 懒汉式:延迟加载,只有在需要的时候才开始实例化
  • 饿汉式:不管你需不需要,都会在类初始化阶段就加载好实例
  • 静态内部类:也是懒汉式的一种实现方法,只有在实际使用时才会进行初始化
  • 枚举类型:利用枚举类型的特性实现单例

懒汉模式是单例模式所有实现方法中最重要也是需要考虑最多的一种方式,只要掌握这一种实现方式,其它实现方式也很容易掌握。

懒汉式

最基础的实现

写到这里的时候让我想起了那个程序员届非常经典的老笑话:

问:没有女朋友的人怎么办? 答:new一个不就有了

这里我们也就拿女盆友来举例吧,正好一个人也只能有一个女盆友,完美符合完美单例模式的需求。

现在我们有一个女朋友类GirlFriend类,类中有两个方法playWithMeshoppingWithMe,定义了女朋友和我的一些交互行为,有了这些基本定义之后,代码实现起来如下:

 /**
  * @author zhn
  * @version 1.0
  * @date 2023/7/11 21:19
  * @blog www.zhnblog.icu
  */
 public class GirlFriend {
     public void playWithMe() {
         System.out.println("和女朋友一起玩");
     }
 ​
     public void shoppingWithMe() {
         System.out.println("和女朋友一起出去购物");
     }
 }

好了,现在我们有女朋友了,她能正常和我们一起玩和购物。但是现在GirlFriend类不是唯一的,每次new都会创建出一个新的对象,我们可以测试一下,每次new出来的对象地址都不一样。

 /**
  * @author zhn
  * @version 1.0
  * @date 2023/7/11 21:21
  * @blog www.zhnblog.icu
  */
 public class SingletonTest {
     public static void main(String[] args) {
         GirlFriend girlFriend1 = new GirlFriend();
         GirlFriend girlFriend2 = new GirlFriend();
         System.out.println("girlFriend1=" + girlFriend1 + "\ngirlFriend2=" + girlFriend2);
         System.out.println(girlFriend1 == girlFriend2);
     }
 }
 ​
 *************************运行结果:
 girlFriend1=com.zhn.design.singleton.GirlFriend@1b6d3586
 girlFriend2=com.zhn.design.singleton.GirlFriend@4554617c
 false

如何才能让我们的对象每次new出来都是同一个对象呢?

首先要做的就是把构造器方法私有化,不允许通过new的方式去创建对象,然后去公开一个方法,在这个方法中去实例化对象,又因为只能初始化一次,所以还需要在全局定义一个本身类的属性来保存对象和判断是否初始化了。改造后的代码如下所示:

 /**
  * @author zhn
  * @version 1.0
  * @date 2023/7/11 21:19
  * @blog www.zhnblog.icu
  */
 public class GirlFriend {
     private static GirlFriend instance;
 ​
     private GirlFriend() {
 ​
     }
 ​
     public static GirlFriend getInstance() {
         if (instance == null) {
             instance = new GirlFriend();
         }
         return instance;
     }
 ​
     public void playWithMe() {
         System.out.println("和女朋友一起玩");
     }
 ​
     public void shoppingWithMe() {
         System.out.println("和女朋友一起出去购物");
     }
 }

实际代码运行测试一下:

 girlFriend1=com.zhn.design.singleton.GirlFriend@1b6d3586
 girlFriend2=com.zhn.design.singleton.GirlFriend@1b6d3586
 true

很好,现在地址是同一个,比较结果也是true了,只创建了一个对象。

多线程下的懒汉式

在单线程的情况下,上述代码其实一点问题都没有,但在多线程的情况下,上面的实现代码是有漏洞的,模拟一下多线程获取对象,代码如下:

 public class SingletonTest {
     public static void main(String[] args) {
         new Thread(() -> {
             GirlFriend girlFriend1 = GirlFriend.getInstance();
             System.out.println("girlFriend1=" + girlFriend1);
         }).start();
         new Thread(() -> {
             GirlFriend girlFriend2 = GirlFriend.getInstance();
             System.out.println("girlFriend2=" + girlFriend2);
         }).start();
     }
 }

运行一下,查看控制台(可以多运行几次)

 //结果1
 girlFriend2=com.zhn.design.singleton.GirlFriend@2131513b
 girlFriend1=com.zhn.design.singleton.GirlFriend@2131513b
 //结果2
 girlFriend1=com.zhn.design.singleton.GirlFriend@d166de5
 girlFriend2=com.zhn.design.singleton.GirlFriend@2131513b

多次执行后,可以发现有时候是同一个地址,有时候又不是。这里的原因就是因为getInstance方法在多线程访问的情况下不是安全的,很有可能创建了多次对象。

问题发现了,该如何解决呢?

不用担心,女朋友还是你的,别人怎样都抢不走。既然多线程不安全,那我们给获取实例方法加个锁不就安全了吗。

 /**
  * 使用synchronized锁住getInstance方法
  */
 public synchronized static GirlFriend getInstance() {
     if (instance == null) {
         instance = new GirlFriend();
     }
     return instance;
 }
 //此时运行结果,不管多少遍都是一样的对象
 girlFriend1=com.zhn.design.singleton.GirlFriend@2131513b
 girlFriend2=com.zhn.design.singleton.GirlFriend@2131513b
     
 girlFriend1=com.zhn.design.singleton.GirlFriend@384e8869
 girlFriend2=com.zhn.design.singleton.GirlFriend@384e8869

虽然使用synchronized锁住对象已经可以保证只创建一个对象了,但是这种方式对程序的性能是有一定的损耗的。

思考一下:是不是每一次访问都需要加锁?

很显然不是,我们只需要在instance等于null的情况下进行加锁,不等于null的情况直接返回instance就可以了。 这时候只需要将加锁代码进行延迟,不在方法上加锁,而是加在方法里,采用更细粒度的锁。具体实现代码如下:

 /**
  * 双检锁
  */
 public static GirlFriend getInstance() {
     if (instance == null) {//第一层check
         synchronized (GirlFriend.class) {
             if (instance == null) {//第二层check
                 instance = new GirlFriend();
             }
         }
     }
     return instance;
 }
 //运行测试
 girlFriend2=com.zhn.design.singleton.GirlFriend@2131513b
 girlFriend1=com.zhn.design.singleton.GirlFriend@2131513b
     
 girlFriend1=com.zhn.design.singleton.GirlFriend@6bfcab86
 girlFriend2=com.zhn.design.singleton.GirlFriend@6bfcab86

修改点说明:

  • synchronized从方法上移到了方法内,加锁是加载类上面,而不是instance,这是因为instance未创建时为null,不能进行加锁。
  • 第一层check是为了拦截instance已经创建的情况,不需要再进到代码里重新创建,提高了性能。第二层check是为了防止多线程情况下,都进入加锁的代码中,创建了多次对象。

指令重排

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

指令重排的目的是为了在不改变程序执行结果的情况下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。

我们可以使用JDK自带的javap命令去反编译.class文件去看下具体的指令内容,至于如何使用javap可以参考这篇文章

看一下创建实例对象的具体代码GirlFriend instance = new GirlFriend();,找到编译后的GirlFriend.class文件所在位置,使用javap -v GirlFriend命令进行反编译,如图所示:

image.png 这里涉及到了JVM中类加载机制,也就是加载、链接、初始化三个大阶段,在这里就不细讲了,具体的可以去参考网上文章。

通过查看字节码的方式可以看到new的过程可以简单理解为三步:

  1. 分配内存空间
  2. 初始化
  3. 引用赋值

如果程序可以按照上面的指令执行,那么是没什么问题的,但编译器或者CPU会对上面的代码进行指令重排,重排后可能会出现以下的顺序:

  1. 分配内存空间
  2. 引用赋值
  3. 初始化

在单线程的情况下,上面的结果是没有什么影响的,但在多线程的情况下,当第一个线程执行到第二步引用赋值时,第二个线程进来了,就会发现此时的instance已经被赋值了不为null,就会返回instance作为结果,但实际上instance还未执行到初始化阶段,甚至构造方法都没有执行,对象并未创建成功,这样一来就会有问题了。

那么该如何解决这个问题呢?

我们可以使用volatile去修饰instance,一旦一个共享变量被volatile修饰之后,就具备了两层语义:

  1. 保证了不同线程对这个变量的可见性,即一个线程修改了该变量的值,新值对其它线程来说是可以看到的。
  2. 禁止进行指令重排

经过这样一步步代码优化,我们得到了懒汉式单例的最终实现方式:

 /**
  * @author zhn
  * @version 1.0
  * @date 2023/7/11 21:19
  * @blog www.zhnblog.icu
  */
 public class GirlFriend {
     //禁止指令重排
     private volatile static GirlFriend instance;
 ​
     private GirlFriend() {
 ​
     }
 ​
     /**
      * 双检锁
      */
     public static GirlFriend getInstance() {
         if (instance == null) {//第一层check
             synchronized (GirlFriend.class) {
                 if (instance == null) {//第二层check
                     instance = new GirlFriend();
                 }
             }
         }
         return instance;
     }
 ​
     public void playWithMe() {
         System.out.println("和女朋友一起玩");
     }
 ​
     public void shoppingWithMe() {
         System.out.println("和女朋友一起出去购物");
     }
 }

饿汉式

饿汉式在类加载的初始化阶段就完成了类的实例化

类的初始化是指在程序执行前的准备工作,在这个阶段,静态的变量、方法、代码块会被执行。同时会开辟一块存储空间,用来存储静态的数据。并且初始化只会在类加载的时候执行一次,所以不需要担心多线程下的问题。

/**
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 21:19
 * @blog www.zhnblog.icu
 */
public class GirlFriend {
    //在类加载的时候进行初始化
    private volatile static GirlFriend instance = new GirlFriend();

    private GirlFriend() {

    }
    
    public static GirlFriend getInstance() {
        return instance;
    }

    public void playWithMe() {
        System.out.println("和女朋友一起玩");
    }

    public void shoppingWithMe() {
        System.out.println("和女朋友一起出去购物");
    }
}

静态内部类

使用类的静态内部类持有静态对象的方式来实现单例,也属于懒加载的一种实现

在单例的类中,需要定义一个静态内部类,在静态内部类定义一个外部类的静态变量,并且使用new进行对象的创建,具体代码如下所示:

/**
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 21:19
 * @blog www.zhnblog.icu
 */
public class GirlFriend {
    private GirlFriend() {

    }

    public static GirlFriend getInstance() {
        return InnerClassHolder.instance;
    }

    public void playWithMe() {
        System.out.println("和女朋友一起玩");
    }

    public void shoppingWithMe() {
        System.out.println("和女朋友一起出去购物");
    }

    //静态内部类持有外部变量
    private static class InnerClassHolder {
        private static final GirlFriend instance = new GirlFriend();
    }
}

枚举类型

通过枚举类型的特殊性,直接定义属性为当前的实例对象来进行单例的实现:

/**
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 23:02
 * @blog www.zhnblog.icu
 */
public enum GirlFriend {
    INSTANCE;
    
    public void playWithMe() {
        System.out.println("和女朋友一起玩");
    }

    public void shoppingWithMe() {
        System.out.println("和女朋友一起出去购物");
    }
}

单例模式实战:线程池

通过线程池案例来看看单例模式在实际项目中是如何使用的

首先编写一个ThreadPool去统一管理执行线程,代码如下所示:

/**
 * 线程池:管理线程执行
 *
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 23:05
 * @blog www.zhnblog.icu
 */
public class ThreadPool {
    //线程池构造器
    private ThreadPoolExecutor threadPoolExecutor = null;
    //设置并发执行的线程个数
    private static final int THREAD_COUNT = 50;

    public ThreadPool() {
        this.threadPoolExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(THREAD_COUNT);
    }

    /**
     * 接收一个线程并执行
     */
    public void execute(Thread t) {
        if (!this.threadPoolExecutor.isShutdown()) {
            this.threadPoolExecutor.execute(t);
        }
    }
}

测试执行:

/**
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 23:09
 * @blog www.zhnblog.icu
 */
public class ThreadPoolTest {
    public static void main(String[] args) {
        ThreadPool threadPool = new ThreadPool();

        threadPool.execute(new Thread(() -> {
            System.out.println("Thread");
        }));
    }
}

上面的编码方式使用的是多例,看着好像没什么问题,线程也正常执行了

如果在多处代码中使用new的方式去创建线程池,那么会出现什么问题呢?

一个new最大能并发执行50个线程,那么在多处new,比如10处,就是并发处理500个线程,如果计算机顶的住,当然是皆大欢喜。

但并发这么多线程的情况下计算机的性能肯定会受到很大的影响,所以我们需要控制在一个项目中并发执行的线程数,也就是通过控制ThreadPool的实例个数来控制线程数,这就需要用到单例模式了。

代码如下:

/**
 * 线程池:管理线程执行(懒汉式)
 *
 * @author zhn
 * @version 1.0
 * @date 2023/7/11 23:05
 * @blog www.zhnblog.icu
 */
public class ThreadPool {
    //线程池构造器
    private ThreadPoolExecutor threadPoolExecutor;
    //设置并发执行的线程个数
    private static final int THREAD_COUNT = 50;

    private volatile static ThreadPool instance;

    private ThreadPool() {
        this.threadPoolExecutor = (ThreadPoolExecutor) Executors.newFixedThreadPool(THREAD_COUNT);
    }

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

    /**
     * 接收一个线程并执行
     */
    public void execute(Thread t) {
        if (!this.threadPoolExecutor.isShutdown()) {
            this.threadPoolExecutor.execute(t);
        }
    }
}

经过测试,完美解决线程创建太多的问题