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

简介: 单例模式是软件设计模式之一,确保一个类只有一个实例并提供全局访问点。


Java多线程基础-8:单例模式及其线程安全问题(一)+ https://developer.aliyun.com/article/1520523?spm=a2c6h.13148508.setting.14.61564f0e0MYpBx



三、线程安全问题


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

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



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


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


线程的抢占式执行。

多个线程修改同一变量。

修改操作不是原子的。

内存可见性问题。

指令重排序。

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


在饿汉模式的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 变成原子操作。


  1. 双重 if,减少不必要的加锁操作。


  1. 使用 volatile 禁止指重排序,保证后续线程肯定拿到的是完整对象。


相关文章
|
3天前
|
存储 安全 Java
Java多线程编程--JUC
Java多线程编程
|
4天前
|
Java API
详细探究Java多线程的线程状态变化
Java多线程的线程状态主要有六种:新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、超时等待(TIMED_WAITING)和终止(TERMINATED)。线程创建后处于NEW状态,调用start()后进入RUNNABLE状态,表示准备好运行。当线程获得CPU资源,开始执行run()方法时,它处于运行状态。线程可以因等待锁或调用sleep()等方法进入BLOCKED或等待状态。线程完成任务或发生异常后,会进入TERMINATED状态。
|
4天前
|
存储 安全 Java
Java多线程中线程安全问题
Java多线程中的线程安全问题主要涉及多线程环境下对共享资源的访问可能导致的数据损坏或不一致。线程安全的核心在于确保在多线程调度顺序不确定的情况下,代码的执行结果始终正确。常见原因包括线程调度随机性、共享数据修改以及原子性问题。解决线程安全问题通常需要采用同步机制,如使用synchronized关键字或Lock接口,以确保同一时间只有一个线程能够访问特定资源,从而保持数据的一致性和正确性。
|
4天前
|
监控 安全 Java
Java多线程的使用
Java多线程允许程序同时执行多个任务,提高了系统的整体性能和响应速度。通过创建Thread类或其子类的实例,或使用Runnable接口,Java开发者可以定义并发执行的代码段。多线程在处理复杂任务、资源共享、网络通信等方面具有显著优势,但也需要注意线程安全、同步和死锁等问题。Java提供了丰富的API和工具来处理这些并发问题,使多线程编程更加高效和可靠。
|
4天前
|
API
java-多线程-CountDownLatch(闭锁) CyclicBarrier(栅栏) Semaphore(信号量)-
java-多线程-CountDownLatch(闭锁) CyclicBarrier(栅栏) Semaphore(信号量)-
10 1
|
5天前
|
安全 Java 程序员
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(二)
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(二)
26 4
|
5天前
|
Java 程序员 调度
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
22 0
Java基础18-一文搞懂Java多线程使用方式、实现原理以及常见面试题(一)
|
9天前
|
Java 调度 数据库
Java中的多线程编程:基础与实践
【6月更文挑战第7天】本文将深入探讨Java中的多线程编程,包括其基本概念、创建方法以及在实际应用中的一些技巧。我们将通过实例来展示如何在Java中有效地使用多线程,以提高程序的性能和响应能力。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的见解。
|
12天前
|
缓存 Java 测试技术
Java性能优化(八)-多线程调优-线程池大小设置
Java性能优化(八)-多线程调优-线程池大小设置
13 0
|
13天前
|
安全 Java 容器
多线程(进阶四:线程安全的集合类)
多线程(进阶四:线程安全的集合类)
15 0