【JavaEE】单例模式:饿汉模式与懒汉模式,解决懒汉模式线程不安全问题
前言:在软件开发中,有时我们需要确保一个类在内存中只存在一个实例,这不能只靠程序猿人为保证只创建了一个实例,而需要通过编程技巧约束,让我们只能创建一个实例。这可以通过单例模式(Singleton Pattern)来实现。在本文中,我们将探讨单例模式的两种主要实现方式:饿汉模式和懒汉模式,以及懒汉模式引发的线程安全问题。
目录
问题1:多线程并发环境下,该类可能会出现多个实例,违背单例原则。
一、什么是单例模式?
一种设计模式。用编程技巧确保一个类只有一个实例,提供一个全局访问点。主要有饿汉模式、懒汉模式等。
二、饿汉模式(Eager Initialization)
定义:
饿汉模式在类加载时就创建实例,因此在类加载阶段就完成了单例对象的初始化。这意味着无论是否使用这个实例,都会在类加载时创建。
说人话:

饿汉主要强调“迫切”,当一个人很饿的时候,就想立马干饭,刻不容缓,有什么事,等我吃饱再说。 因此,引申到单例模式上,第一件事就是要创建实例,有什么事等我创建好再说。
代码实现:
class singleton{
//在类加载时初始化实例
private static singleton instance=new singleton();
//static修饰类属性,就是在类对象上的
//每个类的类对象在JVM中只有一个,里面的静态成员只有一份
//私有构造方法,外部无法实例化
private singleton(){
}
//提供全局访问点
public static singleton getInstance(){
return instance;
}
}
代码要点:
1.创建类对象,用static修饰。用private修饰,外部无法直接拿到该对象。
2.创建私有构造方法,类之外的代码尝试new新对象时,要用构造方法 ,但构造方法是私有的,因此会报错,确保了无法在外部进行初始化。
3.提供一个访问此单例的方法。
编程验证用getInstance()是否取到同一个单例。
public static void main(String[] args) {
singleton s1=singleton.getInstance();
singleton s2=singleton.getInstance();
System.out.println(s1==s2);
}
验证成功。
饿汉模式的优点:
- 实现简单:饿汉模式的实现非常简单,不需要使用复杂的同步机制。
- 线程安全:由于实例在类加载时就完成初始化,因此天生是线程安全的。
饿汉模式的缺点
资源浪费:即使没有使用该实例,实例也会在类加载时被创建,可能会造成资源浪费,尤其是实例初始化开销较大时。
三、懒汉模式(Lazy Initialization)
定义:
懒汉模式在第一次调用getInstance方法时才创建实例。这种方式实现了延迟加载(Lazy Loading),即在需要时才创建实例。
说人话:
计算机里的“懒”,是高效率的意思。
举个例子:老师布置作业,没说要交,我就先不写。说了要交,我才把作业写了。如果不用交,我就不写,要交,才写。思想的精髓就是能省则省。
因此,引申到单例模式上,你需要这个单例时,我再创建,不用的话我就不创建了。如果实例已经存在,就直接把实例返回。
线程不安全版代码实现:
class singleton_lazy{ private static singleton_lazy instance=null;//先把实例定为空,要再创建 private singleton_lazy(){} //私有化构造方法 public static singleton_lazy getInstance(){ if (instance == null) { instance = new singleton_lazy(); } return instance; } }这样的代码,在单线程环境下不会有问题,但在多线程环境下会引发以下几个安全问题,以下详细解释。
线程并发执行导致的三个问题:
问题1:多线程并发环境下,该类可能会出现多个实例,违背单例原则。
当线程s1、s1同时调用getInstance时,
singleton s1=singleton.getInstance(); singleton s2=singleton.getInstance();
可能会发生如图所示情况,s2已经判断instance为空后,s1才创建实例,之后s2也会创建新实例。
创建了两个实例,这就违背了单例模式的设计原则。
究其原因,是因为
这个操作不是原子的。意思就是,这两个操作是可拆分的,之间夹杂了很多别的操作。
解决方案1:加锁,把这个操作变成原子的。
加锁后,我们可以保证只有一个线程能够初始化实例。
class singleton_lazy{
private static singleton_lazy instance=null;//先把实例定为空,要再创建
private singleton_lazy(){} //私有化构造方法
private static Object locker=new Object();
public static singleton_lazy getInstance(){
//加锁
synchronized (locker) {
if (instance == null) {
instance = new singleton_lazy();
}
}
return instance;
}
}
改进后,当线程s1拿到锁后,进入if判断语句,这时s2线程无法进入if判断语句,直到锁被s1释放。当锁被释放后,instance也已经被更新了,这样当s2拿到锁时,就会读到instance不为空,就不会创建新实例。
问题2:加锁后引发效率降低问题
加了上述的锁后,当单例不存在时创建实例确实能保护线程安全。
但当单例已被创建的前提下,每次调用instance方法时,会引发效率降低问题。
原因:即使单例已存在,也要进入一遍锁,再返回已创建好的实例,而不是直接返回已创建好的实例。这样的上锁解锁操作,带来了不必要的开销,影响性能。
解决方案2:双重检查锁
在外面加一层判断,确保单例已经被创建时,能立马返回该单例。不必进入锁操作。
class singleton_lazy{
private static singleton_lazy instance=null;
private singleton_lazy(){}
private static Object locker=new Object();
public static singleton_lazy getInstance(){
//在最外面加一层判断,确保单例已经被创建时,能立马返回该单例。不必进入锁操作。
if (instance==null){
synchronized (locker) {
if (instance == null) {
instance = new singleton_lazy();
}
}
}
return instance;
}
}
改进1+改进2的操作也叫做双重检查锁。
问题3:指令重排序问题
在Java中,指令重排序是一种编译器和CPU优化技术,它可以改变指令的执行顺序,以提高性能。这样的机制在单线程模式下是不会有问题的,但当在多线程模式下创建单例时,可能会发生指令重排序,导致其他线程在检查instance时,获取到一个尚未完全初始化的对象。
考虑以下实例化过程:
- 分配一块内存。
- 调用构造函数,初始化对象。
- 将instance指向分配的内存地址。
传统的执行顺序是123。由于指令重排序,可能会发生执行顺序132。
举个栗子:
我在街上买了一部苹果手机,我可以先带回家再激活。也可以先激活再带回家。
在单线程里,效果都是一样的,这部手机都是我的。
但在多线程里,可不一样哟,如果带回家了之后,我还没来得及激活,被家里的小猫掠夺走激活了,这手机我就激活不了了,已经登陆了小猫的id。
例子:
依旧是线程s1和s2同时调用instance(),由于多线程并发,可能会发生图中所示情况
线程s2看到instance不为null,获取到对象,但此时对象只是刚分配到内存空间,可能尚未初始化完成。此时对拿到的对象进行操作就是一个危险行为。
解决方法3:使用volatile关键字。
volatile关键字可以确保变量的所有读写操作都是从主内存中进行的,并且禁止指令重排序。
instance被声明为volatile,以确保指令不会被重排序,保证了线程安全性和实例的正确初始化。
四、最终改进版的线程安全的懒汉模式
class singleton_lazy{
private static volatile singleton_lazy instance=null;//加上volatile关键字
private singleton_lazy(){} //私有化构造方法
private static Object locker=new Object();
public static singleton_lazy getInstance(){
//加锁
synchronized (locker) {
if (instance == null) {
instance = new singleton_lazy();
}
}
return instance;
}
}
要点:
1.volatile关键字
2.双重检查锁
更多推荐



所有评论(0)