JavaEE 初阶(10)——多线程8之“单例模式”

目录

[一. 设计模式](#一. 设计模式)

[二. 单例模式](#二. 单例模式)

[2.1 饿汉模式](#2.1 饿汉模式)

[2.2 懒汉模式](#2.2 懒汉模式)

[a. 加锁synchronized](#a. 加锁synchronized)

[b. 双重if判定](#b. 双重if判定)

[c. volatile关键字(双重检查锁定)](#c. volatile关键字(双重检查锁定))


一. 设计模式

设计模式 是在软件工程中解决常见问题的经典解决方案。针对一些特定场景给出的一些比较好的解决方案,只要按照设计模式来写代码,就可以使代码不会太差(保证了代码的下限)。

设计模式比较适用于C++,Java,C#,但是对于 Python 或 Erlang 这些语言,这里的很多设计模式都是不适用的。设计模式适合具有一定的编程经验之后再去学习,如果缺少编程经验,会比较难以理解。

二. 单例模式

单例模式 是Java中最简单的设计模式之一,它确保一个类只有一个实例,并提供一个全局访问点。一个Java程序中,某个类要求只有唯一一个实例,适合使用单例模式*(单例模式前提是"一个进程中",如果有多个Java进程,自然每个进程中都可以有一个实例了)*

在Java中,实现单例模式主要有两种方式:"饿汉模式"和"懒汉模式"

单例模式三部曲:

  • static 修饰 instance 成员变量(类变量)
  • 构造方法私有
  • 静态全局访问点
2.1 饿汉模式

在饿汉模式中,单例对象在类加载时就被立即初始化。这意味着类加载完成后,单例对象就已经创建好了,不管你是否需要它。

java 复制代码
public class SingletonEager {
    //静态实例变量
    private static SingletonEager instance = new SingletonEager();
    //构造方法私有
    private SingletonEager(){

    }
    //全局访问点(每次需要通过getInstance来获取实例的)
    public static SingletonEager getInstance(){
        return instance;
    }
}

饿汉模式中的 "饿" 的意思是 "迫切"(eager),即在类被加载的时候,就会创建出这个单例的实例。

优点:

  • 简单易实现,类加载时就完成了实例化,避免了线程安全问题

缺点:

  • 如果自始至终未使用过这个实例,则会造成内存浪费
2.2 懒汉模式

在懒汉模式中,单例对象在第一次使用时才进行初始化。这种方式可以延迟对象的创建,只有在实际需要时才会创建对象。

java 复制代码
public class SingletonLazy {
    //静态变量声明时不初始化
    private static SingletonLazy instance;
    //构造方法私有
    private SingletonLazy(){}
    //初次使用会进行初始化
    public static SingletonLazy getInstance(){
        if(instance == null){
            instance = new SingletonLazy();
        }
        return instance;
    }
}

懒汉模式中的 "懒" 的意思是推迟了创建实例的时机,首次调用getInstance,才会创建实例

计算机中,谈到的"懒"是褒义词,意思是效率会更高,能不搞就不搞,很多时候,就可以把这部分开销就省下了

相比饿汉模式,懒汉模式的效率会更高一些。

比如:有一个编辑器,打开一个非常大(1G)的文本文档

  • 一启动,就把所有的文本内容都读取到内存中,然后再显示到界面上 [饿汉]
  • 启动之后,只加载一小部分数据(一个屏幕能显示的最大数据),随着用户进行翻页操作,再按需加载剩下的内容 [懒汉]

很明显,懒汉模式会造成巨大的开销,而懒汉模式开销更小~~


* 但是,如果在多线程下,调用getInstance 是否会有线程安全问题呢?------ 多个线程针对一个变量进行修改:如果只是读取,则没有问题;先判定,再修改的这种代码模式,属于典型的线程不安全代码,判定和修改之间可能涉及到线程的切换。

当线程A初次调用 getInstance,判断 instance 为null,会执行创建实例(但还未执行就被调度走了的情况下);此时调度线程B,线程B刚好执行 getInstance,此时判断 instance 仍然为 null,又会执行一次创建实例。

这样,同一个进程中就会执行两次创建实例的操作。虽然第二次创建,覆盖了 instance 的值,使得第一次创建的实例,没有引用指向,很快就会被垃圾回收机制给消除掉(instance 的引用仍是唯一的),但这样多次new操作已经造成了线程安全问题~~

因此,需要对判断和初始化这两个操作打包成原子的.....


a. 加锁synchronized
java 复制代码
public class SingletonLazy {
    private static SingletonLazy instance;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        //将判断和初始化操作打包成原子操作,加锁
        synchronized (SingletonLazy.class){
            if(instance == null){
                instance = new SingletonLazy();
            }
        }
        return instance;
    }
}

注意 :此处的 return 操作不用加到同步代码块中。因为,无论 return 操作是在本线程还是其他线程,return的值都是Instance内存中最新的值!!


加锁之后,确实解决了线程安全问题,但是加锁同样也可能带来阻塞.....

如果上述代码已经 new 完对象了,if 的判断分支再也进不去了,后续的代码 都是单纯的 读操作,已经没有线程安全问题了。但是以后只要调用 getInstance ,都会触发加锁操作,但在第一次初始化后,其实已经没有必要加锁了。加锁以后,还会产生阻塞,影响到性能。

因此,针对这个问题,还要进一步改进------通过条件判断,在应该加锁的时候,才加锁;不需要加锁的时候,直接跳过加锁


b. 双重if判定
java 复制代码
public class SingletonLazy {
    private static SingletonLazy instance;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        //再加一个if判断,instance不为null,直接返回值即可,不用加锁阻塞
        if(instance == null){
            synchronized (SingletonLazy.class){
                if(instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}

以往写的代码中,从未遇到过同样的if 连着写:

曾经的单线程代码中,这样的写法是毫无意义的,两个条件值一定相同。

但是在多线程代码中,任意两个代码之间,都可能穿插其他线程的逻辑。synchronized会使代码出现阻塞,一旦阻塞之后,啥时候恢复执行,是无法预知的。在这个过程中,很可能其他线程就把这个值给修改了~~

这俩条件,只是恰好写法一样,实际上,作用是完全不同的~~

外面的 if 判定 是否要加锁;里面的 if 是判定 是否要创建对象

只不过巧了!在这个代码中,通过同样的方式,完成上述两种判定的~~


但是,这个代码可能还会因为指令重排序(编译器优化),引起线程安全问题(如果是单线程代码,编译器都能准确的进行判断;如果是多线程代码,编译器也是可能出现误判

在创建实例 instance = new SingletonLazy () 时,这行代码实际上可以分解为以下三个操作:

  1. 分配内存空间

  2. 初始化对象

  3. 将对象引用指向分配的内存空间(执行后instance值不再为null)

在没有volatile关键字的情况下,编译器和处理器可能会对上述操作进行重排~~

例如:

a. 分配内存空间

b. 将对象引用指向分配的内存空间(执行后instance值不再为null)

c. 初始化对象

如果发生了这种重排,假设 线程A 正在执行 instance = new SingletonLazy (),而 线程B 几乎同时调用 getInstance 方法,可能会发生以下情况:

  • 线程A 执行了 a 和 b,此时 instance 已经不为 null,但是对象还没有被初始化
  • 线程B 进入 getInstance 方法,执行第1次检查,发现 instance 不为 null,因此直接返回 instance
  • 线程B 在使用未完全初始化的 instance 时,可能会遇到空指针异常或其他错误。

在Java中,所有的对象引用默认值都是null,如果声明了一个对象引用但没有进行初始化 ,那么试图访问这个引用的任何方法或属性都会抛出空指针异常

此处相当于引用已经不为null了,但是没有为 instance 中的属性和方法进行初始化,因此使用 instance 的时候会抛出空指针异常。

因此,为了解决这个问题,要对 instance 变量用 volatile关键字 修饰

* "指令重排序"的详细讲解在 ++JavaEE初阶(6)++

* volatile 关键字的特性(保证"可见性"与"有序性",不保证"原子性")详细讲解在 ++JavaEE初阶(8)++

c. volatile关键字(双重检查锁定)
java 复制代码
public class SingletonLazy {
    //volatile关键字修饰instance
    private static volatile SingletonLazy instance;
    private SingletonLazy(){}
    public static SingletonLazy getInstance(){
        if(instance == null){
            synchronized (SingletonLazy.class){
                if(instance == null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}

volatile关键字 的作用之一就是禁止对 volatile变量 前后的操作进行指令重排。这样,当线程A创建 SingletonLazy 实例时,即使发生了指令重排,其他线程在读取 instance 变量时也能保证看到的是完全初始化后的对象。volatile 不仅保证了变量的可见性,还防止了指令重排可能带来的问题。

换个角度看,加上volatile之后,也能禁止对 instance 赋值操作 插入到其他操作之间。


总结:

设计模式:软件开发中,针对一些特定套路,给出的特定解决方案

单例模式:

  • 饿汉模式:程序启动,类加载的时候,就会创建实例------不涉及线程安全问题
  • 懒汉模式:在程序第一次使用这个实例的时候,才会创建实例------涉及到线程安全问题

a. 加锁把if判定和new赋值操作,打包成原子操作

b. 双重 if 判定,解决加锁的性能问题

c. volatile关键字解决指令重排问题

相关推荐
天使day18 小时前
SpringMVC
java·spring·java-ee
寻找沙漠的人20 小时前
理解JVM
java·jvm·java-ee
寻找沙漠的人20 小时前
JavaEE 导读与环境配置
java·spring boot·java-ee
重生之我在字节当程序员1 天前
如何实现单例模式?
单例模式
夕泠爱吃糖1 天前
如何实现单例模式?
单例模式
m0_607548761 天前
什么是单例模式
开发语言·javascript·单例模式
Am心若依旧4091 天前
[c++进阶(三)]单例模式及特殊类的设计
java·c++·单例模式
因特麦克斯1 天前
如何实现对象的克隆?如何实现单例模式?
c++·单例模式
Theodore_10221 天前
3 需求分析
java·开发语言·算法·java-ee·软件工程·需求分析·需求