Android设计模式--单例模式的六种实现和单例模式讲解Volatile与Synchronized相关的并发

单例模式用来保证一个类只有一个实例,自行实例化此实例,并提供一个访问此实例的全局访问点。

一、饿汉模式(线程安全)

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

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        return instance;
    }

}

1、类加载时还要进行实例化,导致类加载速度变慢,但获取对象时速度很快;

2、没有实现懒加载,如果从始至终未使用此类,却默认进行实例化,会造成内存的浪费(存储在JVM内存的线程共享数据区的方法区);也就是说,只要 Java 虚拟机启动了应用程序,Singleton 类就会被实例化,不管 Singleton 类会不会被用到 。

3、由于类的加载是在 Java 虚拟机初始化应用程序时完成的,而类加载时,会立刻执行静态代码,这时候应用程序实际还未启动,且一个类只会加载一次,避免了多线程的同步问题,也就是说单例模式是线程安全的。

二、懒汉模式(线程不安全)

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static Singleton newInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

与饿汉模式相比,懒汉模式最大的改进是实现了懒加载。也就是说,此类不用就不加载,用的话在第一次调用时会进行实例化。所以说,可以节省内存资源,但第一次调用时速度会慢一些。

1、创建了多个实例;

2、多线程的安全性问题,可能导致严重的后果。

懒汉模式加上synChronized:

public class Singleton {
    
    private static Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

    public static synchronized Singleton newInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

给 newInstance() 方法加了同步锁 synchronized 。

然而事实是,这种写法是最不建议的用法。因为无论是不是多线程的环境,每次调用 newInstance() 方法都要进行同步,造成了不必要的同步开销。

三、双重检查模式(DCL)(Double Check Lock)


  public class Singleton {
    
       private static Singleton instance;
    
       // 私有构造,不允许外部通过构造实例化 Singleton.class
        private Singleton() {
        }
    
        public static Singleton newInstance() {
    1.        if (instance == null) {
    2.           synchronized (Singleton.class) {
    3.               if (instance == null) {
    4.                    instance = new Singleton();
    5.                }
                }
            }
            return instance;
        }
    
    }

3~5是为了实现懒加载,2是为了解决懒加载的线程不安全问题,1是进行同步前,先判空,如果类已经实例化,则不再进行同步,解决了每次调用 newInstance() 方法都要同步造成的不必要的同步开销问题。

但是这种写法是有问题的,看4的代码 instance = new Singleton() ,我们都知道,这不是一条原子操作,拆分来看的话,大致分为三个步骤:

1、给 Singleton 的实例分配内存空间;

2、调用 Singleton 的构造函数,初始化其成员变量;

3、将 instance 对象指向分配的内存空间(此时,instance 就不是 null 了)。

由于 Java 编译器允许处理器乱序执行(原因一),以及在 JDK 1.5 之前,JMM 中 Cache、寄存器到主内存回写顺序的规定(原因二),导致上述 2、3 步骤顺序无法保证,也就是说执行的顺序可能是 1-2-3,或者是 1-3-2。

1-2-3 的执行顺序没什么问题,我们来关注下 1-3-2 的执行顺序。

假设线程 A 正在对 Singleton 进行实例化,执行了步骤 1 和 3,还没有执行 2。这时候线程 B 进来了,判断 instance 不是 null,就取走了 instance 进行使用,可是 Singleton 的构造函数都还没执行,成员变量也没有初始化,就出错了。

这就是 DCL 的失效问题。

那怎么解决 DCL 的失效问题呢?

在 JDK 1.5 及 1.5 以前,无解;

在 JDK 1.6 及 1.6 以后,JVM 进行了调整,具体化了关键字 volatile ,我们可以在上述代码第 3 行 声明 instance 属性时加入此关键字 private static volatile Singleton instance ,就可以保证 instance 对象每次都是从主内存中读取到的。

我们来简单解释一下:

在 JMM 中,有主内存与工作内存的区分,一般声明的变量会在主内存中存在,并在工作内存中存在其一个拷贝。当工作内存对变量的拷贝进行修改时,会从工作内存同步到主内存。可是当对变量进行非原子性操作时,变量从工作内存到主内存的同步也是非原子性操作,在多线程的情况下,如果存与取同时发生,就会出现线程不安全的问题。

而 volatile 关键字,具备 “有序性” 和 “可见性” 的特性。

“有序性”,禁止指令重排序,解决导致 DCL 失效的原因一;

“可见性”,当某一工作内存对变量的拷贝进行修改时,会立即同步到主内存,同时将所有工作内存中的变量的拷贝置为无效状态,则工作内存要取用此变量就要从主内存中同步过来,解决导致 DCL 失效的原因二。

这样, volatile 关键字基本可以解决 DCL 失效的问题。(JDK <= 1.5 很少用了,可以忽略)

我们知道,使用同步锁 synchronized 进行同步操作是比较耗费资源的,volatile 要比 synchronized 节俭一些,不过还是对性能有所损耗。

正确写法如下:

public class Singleton {

  1private Singleton() {}
    private volatile static Singleton instance;//第一层锁:保证变量可见性

  2public static Singleton getInstance() {
    3.1if (single == null) {//第一次判空:无需每次都加锁,提高性能
    3.2synchronized (Singleton.class) {//第二层锁:保证线程同步
    3.3if (single == null) {//第二次判空:避免多线程同时执行getInstance()产生多个single对象
     4、               single = new Singleton();
                }
            }
        }
        return single;
    }
}

1、构造函数得私有,禁止其他对象直接创建实例;
2、对外提供一个静态方法,可以获取唯一的实例;
3、即然是双重检查模式,就意味着创建实例过程会有两层检查。第一层就是最外层的判空语句:代码3.1处的if (singleton == null),该判断没有加锁处理,避免第一次检查singleton对象非null时,多线程加锁和初始化操作;当前对象未创建时,通过synchronized关键字同步代码块,持有当前Singleton.class的锁,保证线程安全,然后进行第二次检查。
4、Singleton类持有的singleton实例引用需要volatile关键字修饰,因为在最后一步singleton = new Singleton(); 创建实例的时候可能会重排序,导致singleton对象逸出,导致其他线程获取到一个未初始化完毕的对象。

为什么要双重检查?

为什么有双重非空检查:

1、第一次非空校验是为了已经初始化时,减少线程获取锁、释放锁的时间,避免不必要的阻塞。

2、第二次非空校验,主要是为了解决在多线程的情况下,在两个线程同时进入第一次非空校验时,是还还是未初始化,其中一个线程先获取对象锁,另一个线程就在阻塞等待前一个线程释放锁。在第一个线程释放锁后,第二个线程获取锁,但是此时第二个线程已经做完了第一次非空校验,但是没有进行第二次的非空校验。第二个线程获取锁进入方法块,进行第二次非空校验。所以第二次非空校验的目的是为了防止多个线程同时通过了第一次非空校验,进行再次控制。

为什么会有重排序,volatile关键字如何禁止重排序?

重排序是指编辑器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。只要遵守as -if-serial语义(无论怎么重排序,单线程程序的执行结果不会改变)。所以编译器为了优化性能,可能会对下图中2和3步骤进行重排序,这种重排序时允许的,因为不会改变单线程(目前只有该线程独占该代码块)内程序的执行结果。

在这里插入图片描述
在单线程环境是没有问题,如果在多线程环境下,程序的执行结果就会被破坏。如下图所示,线程B在第一步判空时,singleton实例的引用已经非null,所以它不进入申请锁阶段,而直接访问对象,但此对象还没初始化完成,那么对象在实际使用就会出各种问题。

在这里插入图片描述
volatile修饰的变量本身具有可见性和原子性,所谓的可见性是指对一个volatile变量的读值,读到的值是所有线程中最新修改的值;而原子性是指对单个变量的读写具有原子性。之所以会有这两个特性,是因为会在该共享变量的汇编指令之前增加Lock指令,该Lock前缀指令会在多核处理器做两件事:

1、将当前处理器缓存行的数据写回到系统内存;

2、这个写回内存的操作会使其他处理器里缓存了该内存地址的数据无效。

ps:单核处理器一时刻只能有一条线程执行,多线程是指单核CPU对不同线程进行上下文切换和调度;多核处理器同一个时刻可能多条线程(每个核一条线程)并发执行, 这时同步非常重要,现代CPU基本都是多核了。

由于volatie变量的可见性这个特性使其 写-读 建立起了happens-before关系,从内存语义的角度上说,线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatiel变量的某个线程发出了通知。原理上讲的话,在写一个volatile变量是,JAVA内存模型(JMM)会把该线程对应的本地内存中的共享变量刷新到主内存;而在读volatile变量时,会把该线程对应的本地内存置为无效,从主内存中读取该变量。线程之间通过共享程序的volatile变量(共享状态),通过写读操作共享状态进行隐式通信。

JMM为了实现这种volatile内存语义,会限制编译器和处理器的部分重排序。

为编译器优化制定以下三条规则 :

1、第一个操作是对volatile变量的读,无论第二个操作是什么,都禁止重排序;
2、第一个操作是对volatile变量的写,第二个操作是对volatile的读,禁止重排序;
3、第二个操作是对volatile变量的写,无论第一个操作是什么,都禁止重排序;

从第2条规则就可以理解通过添加volatile关键字修饰单例的引用,可以禁止重排序。

根据这三条规则,编译器会在生成字节码时,在指令序列插入适当的,保守策略的内存屏障(一组CPU指令,实现对内存操作的顺序限制)。

1、volatile写操作前插入StoreStore屏障;
2、volatile写操作后插入StoreLoad屏障;
3、volatile读操作后插入LoadLoad屏障;
4、volatile读操作后插入LoadStore屏障;

以上内存屏障时非常保守,编译器在生成字节码时,也会进行部分优化,减少一些不必要的内存屏障,以提高性能。不同的处理器会根据自身的内存模型继续优化。

ps:JMM是为了屏蔽底层硬件内存模型不一致,为顶层开发提供一套标准的内存模型,让开发这专注要业务开发。

happens-before规则,具体怎么说来的?

答:从JDK5开始,使用了新的JSR-133内存模型,该模型定义了happens-before
规则:

1、程序顺序规则:一个线程中的每个操作,happens-before于该线程的任意后续操作;
2、监视器原则:对一个锁的解锁,happens-before于随后对该锁的加锁;
3、volatile规则:对一个volatile变量的写,happens-before 于任意后续对这个volatile域的读;
4、传递性:如果A happes-before B,B happens-before C,那么A happens-before C;
5、start()原则:线程A执行ThreadB.start()操作,start() happens-before 线程B内所有操作;
6、jion()原则:如果线程A执行 ThreadB.jion()并成功返回,那线程B的所有操作都happens-before 于A从jion()操作成功返回。

锁在双重检查单例模式起了什么作用?

在代码3.2处,用到了synchronized 关键字,对Singletion.Class对象进行了同步,确保了在多线程环境下只有一个线程对Singletion类的Class对象进行实例化。在Java中,每一个对象都可以作为锁:

1、对于普通同步方法,锁是当前实例对象;
2、对于静态同步方法,锁是当前类的Class对象;
3、对于同步方法块,锁是Synchoized括号的Class对象。

静态内部类单例模式有没有用到锁?

答:有的,JVM在类的初始化阶段(在Class被加载后,且在线程使用之前),会执行类的初始化,JVM会去获取一个锁,这个锁能同步多个线程对同一个类的初始化。

当一个线程A获取到这个初始化锁时,其他线程想要获取初始化锁只能等待;线程A执行类静态初始化和初始化静态字段的过程,就算发生类似双重检查模式的重排序,对结果也没有影响,因为此时没有其他线程可以捕获到初始化锁。线程A初始化完毕,释放锁并通知等待获取初始化锁的线程。根据happens-befroe关系中的监视器规则,当其他线程获取到初始锁时,已经能看到线程A的初始化所有操作,此时静态对象已经初始化完毕,其他线程无需再初始化。

了解过锁的原理,知道锁存储在哪么?

JVM(Java虚拟机)是基于进入和退出Monitor对象来实现方法同步和代码块同步的。同步代码块使用monitorenter指令在编译后插入到同步代码块的开始位置,使用monitorexit插入到同步代码块的结束处或异常处,monitorenter必须有对应monitorexit指令与之配对。任何对象都有一个monitor与之相关联,当且一个monitor被持有后,将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即获得对象的锁。方法则是在方法的指令前增加ACC_SYNCHRONIZED修饰符。

Synchronized用的锁是存放在Java的对象头;如果对象是数组,用3字宽存储对象头,其中一字宽用于存储数组长度;非数组,则2字宽存储对象头。在32位虚拟机,1字宽=4字节=32位。

在这里插入图片描述

了解过Java的对象头,那应该清楚锁升级的几种状态吧,说一下?

在Java SE6,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。意味着此时锁从低到高共有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态是根据线程对锁的竞争情况来定义的。32位JVM运行状态下,Mark Work的存储结构:

在这里插入图片描述
偏向锁: 线程在大多数情况下并不存在竞争条件,使用同步会消耗性能,而偏向锁是对锁的优化,可以消除同步,提升性能。当一个线程获得锁,会将对象头的锁标志位设为01,进入偏向模式.偏向锁可以在让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁。==》只有一个线程进入临界区。

轻量级锁: 当线程A获得偏向锁后,线程B进入竞争状态,需要获得线程A持有的锁,那么线程A撤销偏向锁,进入无锁状态。线程A和线程B交替进入临界区,偏向锁无法满足,膨胀到轻量级锁,锁标志位设为00。==》多个线程交替进入临界区。

重量级锁: 当多线程交替进入临界区,轻量级锁hold得住。但如果多个线程同时进入临界区,hold不住了,膨胀到重量级锁==》多个线程同时进入临界区。

为什么Synchronized够用,还要增加Volatile?

Volatile相对Synchronized来说在同步上比较轻量级,能够有效降低CPU频繁的线程上下文切换和调度。同时,Volatile的原子操作是针对单个volatile变量的写读操作,无法和Sychronized对整个方法或代码块起的作用相比较。

volatile 不具有原子性

在这里插入图片描述对单个volatile变量的读写的原子性其实是可见性的作用。因为原子性是说多个操作并成一个操作来说,因为程序是多线程的,所以原子性是为了阻止程序运行过程中进行线程切换。

四、静态内部类实现单例

public class Singleton{
    private Singleton(){
    }

    public static Singleton newInstance(){
        return SingletonHelper.instance;
    }

    private static class SingletonHelper{
        private final static Singleton instance = new Singleton();
    }

}

静态内部类 SingletonHelper 中的静态属性 instance 是随着 SingtonHelper 的加载而加载的,也就是说 一旦静态内部类 SingletonHelper 加载,就会对 Singleton 类进行实例化 ;

那问题来了,静态内部类 SingletonHelper 什么时候加载呢?

要注意的是,静态内部类不同于静态属性,不会随着宿主类(Singleton)的加载而加载,是在第一次调用静态内部类的时候再由 Java 虚拟机进行加载( return SingletonHelper.instance;),这样就避免了多线程的同步问题。

同时,这种写法也实现了懒加载,如果不调用 newInstance() 方法,就不会调用静态内部类对 Singleton 进行实例化。

可序列化的单例类的反序列化问题

以上的单例模式的写法,实际上他们都存在一个相同的问题:可序列化的单例类的反序列化问题。

存在此问题的前提是,单例类实现了 Serializable 接口,是可序列化的。

这样,我们通过序列化可将单例类的实例对象写到磁盘,然后再读回来,及进行反序列化,就可以得到此单例类的一个实例。即使构造函数是私有的,也是有办法获得单例类的一个新的实例的。

那怎么解决此问题呢?两种方案:

方案 1、利用类中可用的一个私有的钩子函数 readResolve() ,用来控制类的反序列化。看下代码示例吧。

import java.io.ObjectStreamException;
import java.io.Serializable;

public class Singleton implements Serializable{

    private static final long serialVersionUID = 0L;

    private static volatile Singleton instance;

    // 私有构造,不允许外部通过构造实例化 Singleton.class
    private Singleton() {
    }

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

    // /钩子函数,用来控制类的反序列化操作
    ///通过此钩子函数,当对类进行反序列化时,返回我们的单例的实例 instance ,就避免了反序列化时创建新实例的问题。
    private Object readResolve() throws ObjectStreamException {
        return instance;
    }

}

方案2:枚举模式

五、使用枚举实现单例

我们知道,枚举在 Java 中与普通的类是一样的,可以有自己的属性,还可以有自己的方法,最重要的是,默认枚举实例的创建是线程安全的,并且在任何情况下它都是一个单例(任何情况包括反序列化)。

让我们来看下代码示例吧。

public enum Singleton {
    INSTANCE;
}

六、使用容器实现单例

import java.util.HashMap;
import java.util.Map;

public class SingletonManager {

	// 存储单例类的容器
    private static Map<String, Object> mSingletonMap = new HashMap<>();

    // 不需要对此 单例管理类 进行实例化
    private SingletonManager() {
    }

    // 向容器中注册单例类
    public static void registerSingleton(String key, Object instance) {
        if (!mSingletonMap.containsKey(key)) {
            mSingletonMap.put(key, instance);
        }
    }

    // 从容器中获取单例类
    public static Object getSingleton(String key) {
        return mSingletonMap.get(key);
    }
}

在程序初始时,会将多种单例类型的对象注入到容器中,当需要使用时通过 key 从容器中取出相应的单例类型的对象。

这种写法,很明显可以很好对单例类型进行统一管理,存取操作对用户是透明且低耦合的,问题是容器中初始的单例类型会消耗资源,无论是否会被用到。

在 Android 系统中,系统级别的服务用的就是这种方案,如 AMS、WMS、LayoutInflater 等服务,会在合适的时候以单例的形式注册到系统中,当需要调用相应服务的时候,会通过 Context 的 getSystemService(String name) 获取。

无论是哪种实现方式,核心原理都是首先将构造函数私有化,然后通过静态方法得到相应类的实例。

单例模式的使用场景

单例的模式的应用范围很广,具体的使用场景有以下几个:

1、整个项目需要一个共享访问点或者需要共享数据;

2、创建相应对象需要耗费大量资源,如 I/O 操作、数据库连接等;

3、工具类对象;
从原理解释单例模式
附:https://blog.csdn.net/hailong0529/article/details/100678972

https://juejin.im/post/6856964867811721229

https://juejin.im/post/6844903858276139021

https://www.zhihu.com/question/297552179?sort=created

https://blog.csdn.net/qiyei2009/article/details/71813069

https://juejin.im/post/6844904147729252365

https://juejin.cn/post/6903812336302161933

https://www.cnblogs.com/lbs-171222/articles/10919069.html

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
单例模式是一种设计模式,它保证一个类只有一个实例,并提供全局访问点。 双重检验锁(Double Check Lock)方式是一种在多线程环境下实现单例模式的方式。其核心思想是在第一次调用时才创建对象,如果已经创建过对象则直接返回该对象。具体实现如下: 1. 私有化构造函数,防止外部直接创建对象。 2. 声明一个静态变量,用来保存单例对象。 3. 提供一个公共的静态方法,用来获取单例对象。 4. 在公共方法中,先判断单例对象是否已经被创建,如果没有则进行加锁,然后再次判断单例对象是否已经被创建,如果还没有则创建单例对象,最后释放锁。 以下是双重检验锁方式实现单例模式的代码示例: ``` public class Singleton { private static volatile Singleton instance; // 声明一个静态变量,用来保存单例对象 private Singleton() {} // 私有化构造函数 public static Singleton getInstance() { // 提供一个公共的静态方法,用来获取单例对象 if (instance == null) { // 第一重检验,判断单例对象是否已经被创建 synchronized (Singleton.class) { // 加锁 if (instance == null) { // 第二重检验,再次判断单例对象是否已经被创建 instance = new Singleton(); // 创建单例对象 } } } return instance; // 返回单例对象 } } ``` 需要注意的是,在第一重检验时,因为存在多线程并发访问的情况,可能会出现多个线程同时判断单例对象为null的情况,从而同时进入加锁的代码块。因此需要使用volatile关键字来保证instance变量在多线程环境下的可见性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值