Java多线程基础-8:单例模式及其线程安全问题

news2024/11/19 15:10:34

单例模式是经典的设计模式之一。什么是设计模式?代码的设计模式类似于棋谱,棋谱就是一些下棋的固定套路,是前人总结出来的一些固定的打法。依照棋谱来下棋,不说能下得非常好,但至少是有迹可循,不会下得很糟糕。代码的设计模式也是一样。

设计模式,就是软件开发中的棋谱。一些编程界的大佬,针对一些常见情景总结出了一些代码的“编写套路”。按照这样的套路来写代码,不说能写得非常好,但也至少不会写得太糟糕。以前有一个大佬写了一本书,名叫《讨论二十三种设计模式》,这本书广为流传,这里的设计模式也就是我们上面说到的。

事实上设计模式远不止“二十三种”。但在校招阶段,主要考察两个设计模式:

  1. 单例模式
  2. 工厂模式

本文主要介绍单例模式

目录

一、什么是单例模式?

二、如何实现单例模式?

1、代码实现:饿汉模式

a.饿汉模式的构造思路

b.总结:饿汉模式代码

2、代码实现:懒汉模式

三、线程安全问题

1、懒汉模式--线程不安全,饿汉模式--线程安全

2、初步解决:懒汉模式的线程安全问题

3、代码问题-1:加锁导致程序效率低——解决:更改加锁的位置

4、代码问题-2:new操作引发指令重排序——解决:以volatile修饰

四、***小结:单例模式的线程安全问题


一、什么是单例模式?

单例指的就是单个实例(instance),也就是单个对象(对象就是类的实例)。单例模式指的是某个类在进程中只有唯一一个实例(在一个程序中,只能创建一个实例(一个对象),不能创建多个对象)。

按理来说,在写代码的时候多 new 几次,就能创建多个对象了。但在语法上,是有办法禁止这样多 new 几次的操作的。 

也就是说,Java中的单例模式,实际上是借助 Java 语法,保证某个类只能够创建出一个实例,而不能被new多次。

为什么会有这样的用途?其实原因是很简单的:在有些场景下,本身它就要求某个概念是单例的。比如每个人只能同时拥有一个配偶。


二、如何实现单例模式?

Java实现单例模式的方式有很多种,这里我们主要介绍两种写法:

  1. 饿汉模式(急迫)
  2. 懒汉模式(从容)

如何理解 饿汉模式 和 懒汉模式 呢?饿汉模式就好比每次吃完饭之后,立刻就把碗给洗了(主打的就是一个急迫);懒汉模式则是每次吃完饭了,先把碗放到一边先不洗,等到吃下一顿了再洗。通常认为,懒汉模式更好,效率更高(非必要不洗碗)。

比如,中午吃饭用了4个碗,那么饿汉模式就得一次性把4个碗都洗了;而晚上吃饭要用2个碗,懒汉模式就只需要洗4个碗当中用不到的2个碗就行了。洗2个碗明显要比洗4个碗效率更高(不考虑没洗的碗会变臭~~只考虑效率)。

在计算机中的例子:打开一个硬盘上的文件,读取文件内容并显示出来。

  • 饿汉:把文件所有内容都读到内存中,并显示。
  • 懒汉:只把文件读一小部分,把当前屏幕填充上。如果用户翻页了,再读其它文件内容;如果不翻页,就省下了。

在这样的情况下,懒汉模式也是完胜饿汉模式的。

假设要读取的文件非常大,有 10G,按照饿汉模式的方式,文件打开可能都要卡半天,更何况还有内存是否足够的问题。

但懒汉模式下就可以很快速地打开,因为它只读取 1 页的内容,1 页也就几百字,可能也就读取 2k 就够了;如果用户还要读其它页的内容,懒汉再从内存里读取相应的内容,用户浏览不到的页面,也就不将它的内容加载到内存中了。

(虽然懒汉模式会增加硬盘的读取次数,但和饿汉模式的情况相比,是不值一提的。)

下面我们来看看如何用代码实现这两种单例模式。

1、代码实现:饿汉模式

a.饿汉模式的构造思路

我们先初步地创建出 Singleton 类,并在里面把对象创建出来:

// 把一个类设置成单例的
class Singleton {
    // 唯一实例的本体
    private static Singleton instance = new Singleton();    // 把对象创建出来

    // 获取到实例的方法
    public static Singleton getInstance() {
        return instance;
    }
}

注意:这里的 instance 属性要用 static 修饰,static变量保存了单例对象的唯一实例。

同时,将 instance 属性用private封装,并提供一个get方法。这样,我们就可以从外部获取instance了:

public class Test {
    public static void main(String[] args) {
        // 此时 s1 和 s2 是同一个对象
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();

    }
}

显然,此处的 s1 和 s2 获取到的实际是同一个对象。

但是,上述的代码并没有限定再次 new 对象的操作:

此处的 s3 也显然与 s1 和 s2 不是同一个对象。因此,此处必须把 new 操作给禁止掉。采用的方式是 构造方法私有化。

将构造方法用 private 修饰,可以发现,此时我们上面的 new 操作就报错了,无法通过编译。

有些同学可能会想到,用反射仍然可以获取到私有方法。一方面,反射本身就是一种非常规的手段,它本身就是不安全的;另一方面,单例模式有一种实现方式,借助枚举,也可以保证反射下的安全,这个在此不过多介绍。

b.总结:饿汉模式代码

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

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton(){ }
}
public class Test {
    public static void main(String[] args) {
        //此时s1和s2是同一个对象
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
    }
}
将一个类设置成单例的

通过Java语法来限制类实例的多次创建,从而实现单例模式:

  1. static是用于类级别的数据共享,保存了单例对象的唯一实例,可以在单例模式中用来单例实例的唯一性。
  2. 单例模式中将构造方法私有化,可以避免外部直接创建新的实例。

但饿汉模式的有一个问题,那就是实例的创建时机过早了。只要类一加载,就会创建出这个实例,可要是后面并没有用到这个实例呢?

更好的实现方式是懒汉模式。

2、代码实现:懒汉模式

懒汉模式的核心思想:非必要,不创建。懒汉模式和饿汉模式的代码实现类似,最大的区别是饿汉模式在不使用instance对象时,不把它new出来。

以下代码就是懒汉模式的实现:

class SingletonLazy {
    //先令instance引用为null
    private static SingletonLazy instance = null;

    //获取instance实例
    public static SingletonLazy getInstance() {
        if(instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }

    //构造方法私有化
    private SingletonLazy() { }
}

 ​​​

s1和s2是同一个实例


三、线程安全问题

1、懒汉模式--线程不安全,饿汉模式--线程安全

在Java多线程编程中,非常重要的一个问题就是线程安全问题。上述提到的两个代码,是线程安全的吗?即,多个线程下调用getInstance()是否会出现问题?

结论是:饿汉模式是线程安全的,而懒汉模式不是线程安全的。 

比对线程不安全的原因:线程安全问题及解决措施 

  1. 线程的抢占式执行。
  2. 多个线程修改同一变量。
  3. 修改操作不是原子的。
  4. 内存可见性问题。
  5. 指令重排序。

这里,引起懒汉模式线程不安全的最直接原因,是多个线程修改同一变量。

在饿汉模式的getInstance()中,只是单纯地读操作(return),不涉及修改。而懒汉模式的getInstance()中有一个这样的操作:先判定是否为null,再进行修改,再返回。

很明显,这里包含了修改的操作。上面的懒汉模式代码在多线程下,可能无法保证创建对象的唯一性。如下图情况中,t1和t2都会执行到对象创建的代码,从而创建出多份对象。

多创建一个对象,听起来似乎问题不大,其实不然。对象是有大有小的,有些对象管理的内存数据可能会很多,甚至可能多达几百G。如果n个线程一起调用,创建出了n个这样大的对象,后果是非常严重的。 

2、初步解决:懒汉模式的线程安全问题

深入来说,引起上述问题的原因是if判定操作修改操作不是原子的。可以通过加锁来解决这个问题。

但是,考虑到多线程代码的复杂性,不是在代码中任意写个加锁,就一定线程安全了。如下面代码所示:将synchronized加在了new对象的操作上,且以类对象作为锁对象。这样的加锁方式是不可行的,因为原代码出现线程不安全原因就是因为if判定操作与new操作不是原子的,而只把锁加载new操作上,并不能保证if判定操作和修改操作整体的原子性。

因此,应该把if操作也放到锁里,才能保证判定和new是一个原子操作。

    //获取instance实例
    public static SingletonLazy getInstance() {
        synchronized (SingletonLazy.class) {
            if (instance == null) {
                instance = new SingletonLazy();
            }
        }
        return instance;
    }

当然,也可以直接将锁加在方法上,直接保证整个方法都是原子的。

    //获取instance实例
    synchronized public static SingletonLazy getInstance() {
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }

3、代码问题-1:加锁导致程序效率低——解决:更改加锁的位置

在之前线程安全的篇章中提到过,加锁其实是一种非常低效的方式,因为加锁意味着会出现阻塞等待。事实上,应该“非必要,不加锁”。而我们上述的加锁方式中存在一个问题:不管什么时候调用getInstance(),都会触发锁的竞争。 

然而其实,此处的线程不安全只发生在首次创建对象这里。一旦对象new好了,后续再调用getInstance(),就是单纯的读操作,就没有线程安全问题了,也就没必要再加锁了。

怎么优化呢?我们就需要针对加锁再做一次判定:

什么时候需要加锁?——对象为空的时候。 

因此,要再加一层if判断,用于判断需要加锁的情况:

    //获取instance实例
    public static SingletonLazy getInstance() {
        // 这个条件用于判断是否要加锁
        // 如果对象已经有了,就不必加锁了,此时本身就是线程安全的
        if(instance == null) {
            synchronized (SingletonLazy.class) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }

 注意这两个if(instance == null)代码的辨析:

并且,虽然这俩代码是挨着的,但是实际上它们执行的时机差别会很大。 

按照我们在单线程代码中的理解,如果两行代码紧挨着,那么执行的时候,这两行代码会被迅速执行完,可以近似地看作它们是同一时机被执行。

但是,在多线程且上述两个if判断间隔着一层synchronized加锁的情况下,就不能简单地这样理解了。

加锁就可能导致线程阻塞,而等到线程阻塞被接触时,可能早已是“沧海桑田”。换句话说,这两行代码虽然看起来是相邻的,但它们执行的时间间隔可能会非常长。虽然两个条件代码完全相同,但若调用的时间间隔长了,判断结果也可能会不同。

比如在一个线程执行时,刚开始instance为null,第一个if判定成立,进入外层if;接下来获取锁时却发现,锁已经被别的线程获取了,那么这个线程此时就只能阻塞等待;等到这个线程结束阻塞、再往下走的时候,instance却已经被别的线程创建好了,不再为null,那么第二个条件判定就不成立了;该线程不会进入第二层if,也就不会重复再new一个对象了。

4、代码问题-2:new操作引发指令重排序——解决:以volatile修饰

在之前线程安全的篇章中提到过,指令重排序也可能导致线程不安全。new操作包括3个步骤:1、创建内存;2、调用构造方法;3、把地址赋值给引用。这其中就可能存在指令重排序:步骤2和步骤3的顺序可以调换。

如果程序按照 1-3-2 的方式执行new操作,就可能出现问题:

若instance为null,当t1线程执行完1和3这两个步骤后,线程突然被调度到t2;t2再去判定条件,但由于在t1中instance已经获取了内存地址,因此instance非null,条件不成立,会直接返回实例的引用。此时,t2拿到的是一个没装修过的毛坯房。

如果接下来t2继续毛坯房的后续方法,可能都是将错就错了。

总而言之,这样的线程调度时机,可能导致t2拿到的实例是不完整的,从而就出现问题了。虽然这个过程是一个极端小概率的情况,但在服务器高并发、大数据的情况下,一旦出问题,后果仍然是非常严重的。

如何解决这个问题?很简单,将instance加上volatile即可。volatile可以禁止指令重排序。

    //加上volatile
    volatile private static SingletonLazy instance = null;

    //获取instance实例
    public static SingletonLazy getInstance() {
        // 这个条件用于判断是否要加锁
        // 如果对象已经有了,就不必加锁了,此时本身就是线程安全的
        if(instance == null) {
            synchronized (SingletonLazy.class) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }

补充:这里是否涉及到内存可见性问题是存疑的。内存可见性问题的发生是由于编译器优化掉了寄存器从内存中load的这一操作,从而使得每一次读取数据的时并没有真正从内存中读取,而是只从寄存器中读取。在一个线程频繁写,一个线程频繁读的情况下,可能会出现内存可见性的问题。但是,上述代码是否涉及“频繁读”?假设N个线程一起调用,是否就相当于读了N次,这样不就会触发编译器的优化操作?

这其实是不一定的。因为每一个线程,会有自己的一套寄存器,这其中是否会出现内存安全性问题,是很难确定的。


四、***小结:单例模式的线程安全问题

  • 饿汉模式:天然就是安全的,只是读操作。
  • 懒汉模式:不安全的有读操作,也有写操作。如何保证懒汉模式的线程安全问题:
    1. 加锁,把 if 和 new 变成原子操作。
    2. 双重 if,减少不必要的加锁操作。
    3. 使用 volatile 禁止指重排序,保证后续线程肯定拿到的是完整对象。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/495843.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

JAVA9新特性

JAVA9新特性 概述 ​ 经过4次推迟,历经曲折的Java9最终在2017年9月21日发布。因为里面加入的模块化系统,在最初设想的时候并没有想过那么复杂,花费的时间超出预估时间。距离java8大约三年时间。 ​ Java 9提供了超过150项新功能特性&#x…

RSA加密的原理

一、RSA加密算法的原理 先上公式: 加密过程:其实就是明文的E次方对N取模公钥:(E,N)解密过程:密文的D次方对N取模私钥:(D,N)那么以上公式中的E、D、N分别代表什么&#x…

solidworks版本推荐

有不少人在入坑SolidWorks的时候,不知道下载哪一个版本,不清楚SolidWorks哪个版本更好用一些,其实选择适合自己的版本才是关键。那么该如何选择版本呢?不知道如何选择SOLIDWORKS版本的,可以参考以下3个方面。 一、SOL…

自动化测试工具的基本原理以及应用场景

自动化测试工具是现代软件开发流程中必不可少的组成部分,它可以通过编写脚本或使用图形用户界面工具自动化测试过程,提高测试的效率和准确性。本文将介绍自动化测试工具的基本原理以及应用场景。 自动化测试工具的基本原理 自动化测试工具通常采用的原理…

我与smardaten | 百天无码路,苦逼中带点小美好

大家好,我是云华(化名),一个不太懂技术的无代码开发者,游走在无码世界的自由闲散人。现今是我从事无码开发的第四个月,已经从小白蜕变成了熟手。前两天,睿睿老师找到我,说最近计划办…

【wpf】枚举的绑定最易用法 和 全局静态绑定的应用

背景 有时我们做配置界面的时候,有很多配置项是枚举。通过我们会用一个Combobox实现,如果能直接用Combobox绑定枚举,那将会非常方便。这里绑定将涉及到两个方面,一个是数据源的绑定,还有就是当前选择项的绑定。最后我们…

每天一道算法练习题--Day23 第一章 --算法专题 --- ----------并查集

背景 相信大家都玩过下面的迷宫游戏。你的目标是从地图的某一个角落移动到地图的出口。规则很简单,仅仅你不能穿过墙。 实际上,这道题并不能够使用并查集来解决。 不过如果我将规则变成,“是否存在一条从入口到出口的路径”,那么…

DDR5内存彻底白菜价,国外大厂却整出了比着火更离谱的骚操作

今年的 PC 硬件市场,似乎出现了明显两极分化现象。 一边是 N、A 两家新显卡价格高高在上,摆明了不坑穷人。 另一边固态硬盘、内存条又在疯狂互卷不断杀价。 四五百元的 2TB SSD,二百元的 16G 内存条早已见怪不怪。 要说面世多年的 PCIe 3.0…

对比 LVS 负载均衡群集的 NAT 模式和 DR 模式,基于 CentOS 7 构建 LVS-DR 群集

1. 对比 LVS 负载均衡群集的 NAT 模式和 DR 模式,比较其各自的优势 。 LVS(Linux Virtual Server)是一个开源的负载均衡软件,它支持多种负载均衡算法,包括 NAT 模式和 DR (Direct Routing)模式…

【数据分析之道-Matplotlib(二)】Matplotlib 绘图标记

文章目录 专栏导读1、前言2、标记(Markers)2.1关键词参数marker2.2标记参考(Marker Reference) 3、Format Strings fmt3.1fmt参数3.2线参考(Line Reference) 4、标记颜色(Marker Color)4.1关键字参数mec4.2…

《程序员面试金典(第6版)》面试题 16.14. 最佳直线(向量,C++)

题目描述 给定一个二维平面及平面上的 N 个点列表Points,其中第i个点的坐标为Points[i][Xi,Yi]。请找出一条直线,其通过的点的数目最多。 设穿过最多点的直线所穿过的全部点编号从小到大排序的列表为S,你仅需返回[S[0],S[1]]作为答案&#xf…

简述对象检测与图像分类与关键点检测区别

计算机视觉是人工智能的一个多元化领域,旨在检测和识别图像或视频的内容。大多数开始计算机视觉领域之旅的人的常见问题之一是:目标检测、图像分类和关键点检测之间有什么区别? 让我们先看看 什么是对象检测 对象检测是一种计算机视觉和图像…

gateway sentinel 流控规则持久化到 nacos

Sentinel改造 sentinel版本是1.8.6 直接看更新内容, 右侧更新后 GatewayApiController /*** Gateway api Controller for manage gateway api definitions.** author cdfive* since 1.7.0*/ RestController RequestMapping(value "/gateway/api") public class…

【操作系统】内存空间

最小的操作系统Hello world 想要pmap这个进程,需要进程号 但是这个进程在启动的一瞬间就执行完了 用GDB把程序暂停下来,然后用pmap观察地址空间 用info inferiors得到gdb里的进程号 ro 可读 :只读数据 rx 可读可执行 :代码 rw 可…

Java学习:Scanner类及其应用

Java Scanner 一、next()二、nextLine()三、应用 一、next() 用于从标准输入读取下一个字符串。该方法会扫描输入流并返回下一个非空白字符序列,以空格、制表符或换行符作为分隔符 1、next()会以空格作为分隔符,一行输入1 2 3,只会打印出1 import java.util.Scan…

Java RSA密钥转换,从RSAPrivateKey得到RSAPublicKey

概述: 在Java编程中,我们经常用到如下一段代码来生成RSA公私钥,分别拿到公私钥然后加解密计算: KeyPairGenerator keyPairGen; keyPairGen KeyPairGenerator.getInstance("RSA"); keyPairGen.initialize(2048, new S…

Oracle Instant Client + PLSQL 部署终端PC远程连接数据库服务器简易操作

系统环境: 1、win7_64bit 2、instantclient_21_10 3、plsqldev1105_x64 4、远程Oracle数据库:Oracle11g R2 操作步骤: 1、下载好Oracle Instant Client 和PLSQL程序安装包: 1.1 Oracle Instant Client 官网:https://w…

MySQL基础(五)排序与分页

1. 排序数据 1.1 排序规则 使用 ORDER BY 子句排序 ASC(ascend): 升序DESC(descend):降序 ORDER BY 子句在SELECT语句的结尾。 1.2 单列排序 SELECT last_name, job_id, department_id, hire_date FROM employees ORDER…

Redis之三大特殊数据类型:Geospatial:地理位置 hyperloglog:实现的功能是计算统计 bitmaps:位存储

三大特殊数据类型结构,十分的少见但是开源项目中依然有它们的身影 Geospatial:地理位置 实现的功能:附近的人,城市与城市之前的距离计算 添加城市经纬度到key中,经纬度则是key的value值,在正常的开发过程中&#xf…

获得 随机验证码(以图片为底层)

1:工具类 Slf4j public class RandomValidateCode {private static String baseNumLetter "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";private static String font "微软雅黑";//绘制验证码图片,返回验证码文本内容pu…