vlambda博客
学习文章列表

Codull 的逆袭之路——你还不会单例模式吗?

聊聊单例模式

大佬的介绍


anthony1314


实力巨菜的退役 ACMer

前言

在我们度过美好的五一假期的时候,我却被老板催着交稿,不知写什么推文比较好,突然有一位靓仔发了这么一段话给我。
Codull 的逆袭之路——你还不会单例模式吗?

这件事告诉我们一定要好好学习设计模式!!
对于一个也没有上过设计模式这门课的菜鸡的我来说,设计模式也是我人生中的一道难关,作为一个优秀的程序猿,设计模式是我们必备的技能,那么我们就来聊聊设计模式中最简单的
单例模式
由于笔者只会 Java,所以以下讲解皆由 Java 代码来解释,如有错误,欢迎指出交流。

介绍

单例模式 (Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
  • 意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点
  • 主要解决: 减少一个全局使用的类频繁地创建与销毁所带来的消耗
  • 特点:
    • 类构造器私有
    • 持有自己类型的属性
    • 对外提供获取实例的静态方法
  • 使用场景:
    • 需要频繁实例化然后销毁的对象
    • 创建对象时耗时过多或者耗资源过多,但又经常用到的对象
    • 有状态的工具类对象
    • 频繁访问数据库或文件的对象,如 I/O 与数据库的连接等

实现

懒汉式(一)

想到单例模式,你肯定会第一时间想到这么写~
  
    
    
  
public class Singleton {
    private static Singleton instance;
    private Singleton (){}

    public static Singleton getInstance() {
     if (instance == null) {
         instance = new Singleton();
     }
     return instance;
    }
}
上述写法大概是很多同学刚开始入门写的单例模式,这种写法被称作为
懒汉式单例模式。
懒汉式的特点就是只有在单例实例在第一次被使用时构建,延迟初始化。
如果细心的同学会发现,上面的写法并不是很好,没错,就是没办法保证线程安全性。线程安全是 Java 并发编程中很重要的一个知识点,也许你会想起一个看起来很复杂但其实很常见的单词 synchronized。如果对于这个单词不是很熟悉的话,可以听我接下来的讲解,会的同学可以直接跳过这一部分。

synchronized

概念

synchronized 是 Java 中的关键字,是利用锁的机制来实现同步的。
锁机制有如下两种特性
  • 互斥性 :即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。
  • 可见性 :必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。

锁的分类

  • 对象锁: 在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。
  • 类锁: 在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。

用法

synchronized 可以修饰方法和代码块
  • 修饰代码块
    • synchronized(this|object) {}
    • synchronized(类.class) {}
  • 修饰方法
    当修饰静态方法的时候,线程获取到的则是类锁,反之获取到的对象锁。
    • 修饰非静态方法
    • 修饰静态方法

关于其他

通过对 synchronized 的了解,有兴趣的同学如果想了解更多的关于 Java 的锁机制,可以去了解 ReentrantLock 以及 CAS 之类的锁机制,在这里我就不多叙述~ JVM 中的锁优化有兴趣也可以自行去了解~
通过对上面  synchronized 的了解,那我们就要把他用到我们的懒汉式单例模式中来,保证线程安全。

懒汉式(二)

  
    
    
  
public class Singleton {
    private static Singleton instance;
    private Singleton (){}
    public static synchronized Singleton getInstance() {
     if (instance == null) {
         instance = new Singleton();
     }
     return instance;
    }
}
通过在静态方法中加入了 synchronized,从而保证了单例,保证了多线程下的安全,但是也有缺点,加锁会影响效率,并发其实是一种特殊情况,大多时候这个锁占用的额外资源都浪费了,这种打补丁方式写出来的结构效率很低。

饿汉式

  
    
    
  
public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton (){}
    public static Singleton getInstance() {
     return instance;
    }
}
与懒汉式比较,我们可以发现饿汉式式直接在运行这个类的时候进行一次加载,之后直接访问。
显然,这种方法没有起到延迟加载的效果,但是由于是在刚开始运行的时候就进行加载,所以并不存在线程不安全的问题。

双重校验锁(DCL)

  
    
    
  
public class DoubleLock {
    public static volatile DoubleLock doubleLock = null;
    private DoubleLock(){}
    public static DoubleLock getInstance(){
        if(doubleLock == null){
            synchronized (DoubleLock.class){
                if(doubleLock == null){
                    doubleLock = new DoubleLock();
                }
            }
        }
        return doubleLock;
    }
}

好奇的同学,会发现我们在这个版本的单例模式相对懒汉式单例模式多了一些东西,为什么要对其判断两次呢?
  • 第一次判断,假设会有好多线程,如果 doubleLock 没有被实例化,那么就会到下一步获取锁,只有一个能获取到,如果已经实例化,那么直接返回了,减少除了初始化时之外的所有锁获取等待过程
  • 第二次判断是因为假设有两个线程 A、B,两个同时通过了第一个 if,然后 A 获取了锁,进入然后判断 doubleLock 是 null,他就实例化了 doubleLock,然后他出了锁,这时候线程 B 经过等待 A 释放的锁,B 获取锁了,如果没有第二个判断,那么他还是会去 new DoubleLock(),再创建一个实例,所以为了防止这种情况,需要第二次判断
  • 明白了两次判断的作用后,细心的同学就发现了我们在 DoubleLock 声明时,在类前面加了这么一个关键字volatile,这里对于这个关键字涉及到了两个概念指令重排序,内存可见。
    对于 下面这一句代码 其实分为三步:
         
           
           
         
    doubleLock = new DoubleLock();

    • 开辟内存分配给这个对象
    • 初始化对象
    • 将内存地址赋给虚拟机栈内存中的 doubleLock 变量

注意上面这三步,第 2 步和第 3 步的顺序是随机的,这是计算机指令重排序的问题。
假设有两个线程,其中一个线程执行下面这行代码,如果第三步先执行了,就会把没有初始化的内存赋值给 doubleLock,然后恰好这时候有另一个线程执行了第一个判断 if(doubleLock == null),然后就会发现 doubleLock 指向了一个内存地址,这另一个线程就直接返回了这个没有初始化的内存,所以要防止第 2 步和第 3 步重排序。
对于这个 volatile 关键字不是很明白的同学可以听我接下来的讲解,了解的同学可以直接跳过~

volatile

讲 volatile 之前,我们先来普及一个概念 JMM

JMM(JavaMemoryModel)

JMM :Java 内存模型,是 java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别( 注意这个跟JVM完全不是一个东西 ,总是有同学搞错,虽然笔者初学也经常把这两者搞混 )。

JMM 规定

所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

可见性

因此,我们在使用volatile修饰共享变量,可以解决可见性问题。
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写会了,他其他已经读取的线程的变量副本就会失效了,需要都数据进行操作又要再次去主内存中读取了。
volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

禁止指令重排序

什么是重排序?
为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
内存屏障 volatile 通过内存屏障来保证不会被执行重排序。
也正是通过禁止指令重排序的特点,我们才得以写出安全的 DCL 单例模式。

静态内部类

  
    
    
  
public class Singleton {
    private static class SingletonHolder {
     private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
     return SingletonHolder.INSTANCE;
    }
}
这种方式能达到 DCL 方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。
这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。
这种方式同样利用了类加载机制来保证初始化 instance 时只有一个线程,它跟饿汉式不同的是:饿汉式只要 Singleton 类被装载了,那么 instance 就会被实例化(没有达到延迟效果),而这种方式是 Singleton 类被装载了,instance 不一定被初始化。
因为 SingletonHolder 类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance。
想象一下,如果实例化 instance 很消耗资源,所以想让它延迟加载,另外一方面,又不希望在 Singleton 类加载时就实例化,因为不能确保 Singleton 类还可能在其他的地方被主动使用从而被加载,那么这个时候实例化 instance 显然是不合适的。这个时候,这种方式相比饿汉式就显得很合理。

枚举方法

  
    
    
  
public enum Singleton {
    INSTANCE;
    public void otherMethods() {
    }
}
这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。
在枚举序列化的时候,Java 仅仅是将枚举对象的 name 属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf 方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了 writeObject、readObject、readObjectNoData、writeReplace 和 readResolve 等方法。
普通的 Java 类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。由于反序列化后的对象是重新 new 出来的,所以这就破坏了单例。
但是,枚举的反序列化并不是通过反射实现的。所以,也就不会发生由于反序列化导致的单例破坏问题。
枚举单例这种方法问世以来,许多分析文章都称它是实现单例的最完美方法——写法超级简单,而且又能解决大部分的问题。但其同样也有缺点,我们无法通过 Java 的反射特性来调用私有构造方法,在需要继承的场景,也无法适用。

总结

通过上面对于 Java 的几种单例模式简单的介绍,希望你可以对单例模式有了一些清晰的了解,笔者也只是一个 普通的大三学生 ,平时很少总结 Java 基础知识,如有错误,欢迎指出交流~~

小广告

如果有与代码或者计算机有关的知识想要了解的话!



我们会第一时间为你准备你的专属攻略!