设计模式之:深入浅出 java 单例模式(Singleton)

本文详细介绍了Java中单例设计模式的多种实现方式,包括懒汉式、饿汉式、线程安全的实现(如synchronized和双重检验锁)、静态内部类,以及防范黑客破坏的方法。此外,还提到了枚举类型的单例实现。每种方法都配有实例代码,帮助读者理解和应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.背景

在Java帝国,有一个隐蔽的村庄叫IO村,村里每个人都身怀绝技。其中,SocketIO、HttpIO、FileIO更是专注于某个领域的高手。

FileIO,它十余年苦练文件存储技术,雄心壮志,决定走出村庄,去外面闯一闯。

FileIO到了城里,成功的通过了面试,进入一家IO科技责任有限公司,专门负责文件存储等工作。

2.懒汉式

FileIO刚进入公司,工作不久,就接到3、4次投诉,投诉理由是“在系统中使用FIleIO之后,频繁地发生内存抖动,导致内存吞吐量骤减”。

FIleIO大急,连忙查看日志,原来在系统运行中,自己的实例被频繁地创建与销毁。

FileIO找到主管老张,“我知道怎么解决了,让客户对我的实例进行缓存,不要频繁地创建就行”。

老张,“我们很难去规定客户怎么用,不过我们可以做一些引导。我看你也不需要创建多个实例,你可以创建一个实例给客户使用FileIO找到主管老张: “咱们能不能给客户说一下, 让客户对我的实例进行缓存,别这么频繁地创建啊”。

老张,“我们很难去规定客户怎么用,不过我们可以做一些引导。我看你也不需要创建多个实例,你可以创建一个实例给客户使用,不对外开放创建实例的权限就行”。

FileIO眼睛一亮,赶紧请教老张具体该怎么做。

老张说:“其实很容易。

第一、把自己的构造方法设置为private的,不让别人new你的实例;

第二、提供一个static方法给别人获取你的实例,你在这个方法里面返回你自己创建的实例就行”。

FileIO按照老张的思路做了修改:

public class FileIO {
    private static FileIO fileIO;
    // 唯一的 private构造方法,客户对象无法创建该对象实例
    private FileIO(){}
    public static FileIO getInstance(){
        if (fileIO == null){
            fileIO = new FileIO();
        }
        return fileIO;
    }
	//... ...
}

以后别人在调用FIleIO的时候就不再使用new的方式去创建一个FIleIO的实例,而是调用static方法getInstance()获取FileIO的实例,例:

FileIO.getInstance().openFile(fileName);

这样一来,FileIO就不会频繁的被创建了。

后来,FileIO才知道自己的这种实现机制就是“单例设计模式”,并且被人称为“懒汉式”, 可能是因为在需要的时候才创建吧, 显得很“懒”。

3.饿汉式

FileIO使用单例模式修改后解决了问题,在公司例会上被技术总监大大地表扬了一次。

FileIO非常傲娇,在村里的微信群里向小伙伴们炫耀这件事。

村里的HttpIO自己也存在类似的问题,也需要自修一下,于是它按照了FileIO在微信群中分享的思路进行修改:

public class HttpIO {
    //类加载时就初始化
    private static final HttpIO INSTANCE = new HttpIO();
    
    private HttpIO(){}
    public static HttpIO getInstance(){
        return INSTANCE;
    }
	//... ...
}

实现的方式虽有所不同,但调用方式和效果都是一样的(都能实现单例)。

HttpIO总觉得自己的实现方式更好, FileIO则说自己的Lazy方式更流行, 毕竟不调用FileIO的话对象就不会创建。

两人争执不下, 于是就去请教经验丰富的老张。

老张说,你们这都是单例设计模式的实现方式,HttpIO的实现方式在单例设计模式中被称为饿汉式”(可以能由于太饥饿, 一上来就创建了对象)两者的执行顺序有所不同:

  • FileIO的实现在第一次调用的时候先执行了getInstance()方法,再执行构造方法。
  • HttpIO的实现在第一次调用的时候先执行了构造方法,再执行getInstance()方法。

而且,饿汉式是一种线程安全的写法。

如果该实例需要比较复杂的初始化过程时,把这个过程应该写在 static{ ... }代码快中。

注意:此实现是线程安全的,当对个线程同时去访问该类的  getInstance( ) 方法时,不会初始化多个不同的对象,这是因为,JVM 在加载此类时,对于 static 属性的初始化只能由一个线程执行且仅一次。

4.线程安全(synchronized)

FileIO表示不服, 老张说,我给你举个例子,

当有多个线程并发执行getInstance()的时候,可能会出现以下的情况而导致FileIO产生多个实例。”

线程一 : FileIO.getInstance()

(FileIO:判断fileIO为null,进行fileIO实例的初始化)

线程二: FileIO.getInstance()

(FileIO: fileIO还没初始化完,依然为null, 于是进行另外一个fileIO实例的初始化)

等到两个线程都返回的时候,其实是创建了两个FileIO的实例。

线程一->FileIO:getInstance()

Note left of FileIO:判断fileIO为null,进行fileIO实例的初始化

线程二->FileIO:getInstance()

Note right of FileIO:fileIO还没初始化完,进行fileIO实例的初始化

FileIO-->线程一:返回线程一创建的fileIO实例

FileIO-->线程二:返回线程二创建的fileIO实例

FileIO恍然大悟,线程安全可得好好重视,好在以前研究过一点线程安全的问题,直接加上synchronized:

public class FileIO {
    private static FileIO fileIO;
    private FileIO(){}
    public synchronized static FileIO getInstance(){
        if (fileIO == null){
            fileIO = new FileIO();
        }
        return fileIO;
    }
    //... ...
}

这样一来,当有两个线程同时执行getInstance()方法的时候,一旦线程一获取到FileIO.class锁,线程二只能在外面等待着

在线程一执行完getInstance()的逻辑后释放FileIO.class锁,其他线程才能获取这个锁进入getInstance()方法中。

这样就避免了创建两个FileIO的实例

流程如下:

线程一->FileIO:getInstance()

Note left of FileIO:获取锁FileIO.class

Note left of FileIO:判断fileIO为null,进行fileIO实例的初始化

线程二->FileIO:getInstance()

Note right of FileIO:锁FileIO.class被持有,进入失败,等待中

FileIO-->线程一:返回线程一创建的fileIO实例

NOte left of FileIO:释放锁FileIO.class

线程二->FileIO:getInstance()

Note right of FileIO:获取到锁FileIO.class,进入

FileIO-->线程二:返回线程二创建的fileIO实例

5.线程安全-双重检验锁

老张锊一锊胡须,道:“这种做法简单明了,确实保证了线程安全, 还能更优化呢!”

FileIO不服气,“我不信,难道还有比我只加一个synchronized更简单的做法?”。

老张笑了一下,“我说的优化并不是指简单化。你想想啊,如果fileIO实例不为空时,还需要使用synchronized来限制执行时只能一个线程进入吗?来来来,老哥给你露一手。”

public class FileIO {
    private static volatile FileIO fileIO;
    private FileIO() {}
    public static FileIO getInstance() {
        if (fileIO == null) {
            synchronized (FileIO.class) {
                if (fileIO == null) {
                    fileIO = new FileIO();
                }
            }
        }
        return fileIO;
    }
    //... ...
}

“我这招江湖人称”双重检验锁“,够酷吧!”。

“老司机就是复杂,好端端的,被你弄了那么多个fileIO == null的判断,还用volatile关键字修饰fileIO,这样真的能提升性能吗?”

“you look,当有多有线程调用getInstance()方法的时候,不管三七二十一,先让他们进来。如果fileIO实例不为空,那最好了,直接return实例fileIO,跟synchronized一点都扯不上关系,所以也不会影响到性能。这是双重检验中的第一次检验。”

“oh,I know,如果fileIO是null的,就进入synchronized语句块,在synchronized语句块里面初始化对象。但为什么在synchronized语句中需要再次检查fileIO实例是否为null?”

“这就是第二次检验了,当有多个线程通过第一次检验时,假设线程拿到锁进入synchronized语句块,对fileIO实例进行初始化,释放FileIO.class锁之后,线程二持有这个锁进入synchronized语句块,此时又对fileIO对象就行初始化。所以在这里进行第二次检验防止这种意外发生。”

“我理解了,但我不明白fileIO为什么要用volatile关键字修饰?”

“我们假设线程一进入第二次检验之后就执行FileIO fileIO = new FIleIO()操作,在这个操作中,JVM主要干了三件事

1、在堆空间里分配一部分空间;

2、执行FileIO的构造方法进行初始化;

3、把fileIO对象指向在堆空间里分配好的空间。

但是,当我们编译的时候,编译器在生成汇编代码的时候会对流程顺序进行优化。优化的结果是有可能按照1-2-3顺序执行,也可能按照1-3-2顺序执行。

我们知道,执行完3的时候就fileIO对象就已经不为空了,如果是按照1-3-2的顺序执行,恰巧在执行到3的时候(还没执行2),突然跑来了一个线程,进来getInstance()方法之后判断fileIO不为空就返回了fileIO实例。

此时fileIO实例虽不为空,但它还没执行构造方法进行初始化。又恰巧构造方法里面需要对某些参数进行初始化。后来闯进来的线程糊里糊涂对那些需要初始化的参数进行操作就有可能报错奔溃了。”

我们只需要将 instance 变量声明成 volatile 就可以了。

public class Singleton {
    private volatile static Singleton instance; //声明成 volatile
    private Singleton (){}

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

有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

但是特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。

相信你不会喜欢这种复杂又隐含问题的方式,当然我们有更好的实现线程安全的单例模式的办法。

6.线程安全-静态内部类

“弄一个单例模式要这么麻烦,写这么多代码,我还是使用饿汉式单例模式算了!”,FileIO抱怨说。

老张笑着说,“那也不能这么想呀,其实我们可以根据使用场景不同来使用不一样的单例模式。如果我们需要在getInstance()方法的时候传入参数进来辅助构造方法初始化,那就得用懒汉式了,比如:“

 
public static FileIO getInstance(long maxFileSize);

FileIO一想,也对,其他情况就可以使用饿汉式了。

老张好像看出FileIO的想法,道:”其实饿汉式也有其他弊端,比如当我们不想获取FileIO的实例而是想获取其中一个全局变量的时候,在类加载的时候还是会对fileIO实例进行初始化,导致时间比较久。举例如下:

public class FileIO {
    public static final String TYPE_MP3 = ".mp3";
    private static final FileIO INSTANCE = new FileIO();
    private FileIO() {}
    public static FileIO getInstance() {
        return INSTANCE;
    }
    //... ...
}

当调用FileIO.TYPE_MP3的时候,INSTANCE实例也会被初始化,这显然不是我们需要的。所以,我们Java帝国的高手们又想出了一种叫静态内部类的单例模式,它简单又保证实例能进行懒加载。”

public class FileIO {
    // 静态内部类 static nested class
    // 我比较倾向于使用静态内部类的方法,这种方法也是《Effective Java》上所推荐的。
    private static final class FileIOHolder {
        private static final FileIO INSTANCE = new FileIO();
    }
    private FileIO() {}
    public static FileIO getInstance() {
        return FileIOHolder.INSTANCE;
    }
    //... ...
}

FileIO眼睛一亮,“这个我能理解,当执行getInstance()方法的时候就去调用FileIOHolder内部类里面的INSTANCE实例,此时FileIOHolder内部类会被加载到内存里,在类加载的时候就对INSTANCE实例进行初始化。和饿汉式一个道理,保证了只有一个实例,而且在调用getInstance()方法的时候才进行INSTANCE实例的初始化,又具有懒汉式的部分特性。”

老张满意地说,“是的,这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。在其他语言不一样适用哦!”

完整代码:

package com.test;

/**
当 JVM 加载 LazyLoadedSingleton   类时,由于该类没有 static 属性,所以加载完成后便即可返回。只有第一次调用 getInstance( ) 方法时,JVM 才会加载 LazyHolder 类,由于它包含一个 static 属性 singletonInstatnce,所以会首先初始化这个变量,这样即实现了一个即保证线程安全又支持延迟加载的单例模式。
 */
class LazyLoadedSingleton {
	private static class LazyHolder {
		private static final LazyLoadedSingleton singletonInstatnce = new LazyLoadedSingleton();
	}

	public static LazyLoadedSingleton getInstance() {
		return LazyHolder.singletonInstatnce;
	}
}

class R1 implements Runnable {
	public void run() {
		System.out.println(LazyLoadedSingleton.getInstance().toString());
	}
}

public class Singleton2 {
	public static void main(String[] args) {

		R1 r = new R1();
		for (int i = 0; i <= 100; i++) {
			Thread t = new Thread(r);
			t.start();
		}
	}
}

7.黑客破坏-反射和反序列化

听了老张一堂课,FileIO收获匪浅,这些都是以前在村里老村长没讲过的一些技巧,它非常高兴,仿佛看到自己在以后踏上人生巅峰、迎娶白富美的样子。

老张决定给它泼泼冷水,“还没完呢,在我们Java帝国,有很多被称为黑客的家伙,他们经常搞破坏,你如果只按照上面介绍的写法使用单利模式,很有可能被破坏哦!”

FileIO愣住了,接着问,“那怎么办呀,他们都是怎么破坏的?”。

老张故作深沉地答,”其实他们的破坏方式无非就是这两种:

  • 反射调用构造方法初始化新的变量
private void newInstance() throws IllegalAccessException, InstantiationException {
        FileIO fileIO = FileIO.class.newInstance();
 }
  • 序列化和反序列化产生新的实例
private void serializable() throws IOException, ClassNotFoundException {
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(fileName));
        oos.writeObject(FileIO.getInstance());
        File file = new File(fileName);
        ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
        FileIO fileIO = (FileIO) ois.readObject();
}

对于通过反射调用构造方法的破坏方式我们可以通过在增加全局变量flag,在第一次初始化的时候就设置为true,第二次初始化的时候判断到flag为true就抛出异常。但这种办法也只能避免破坏,无法彻底阻止,因为他们可以反射flag来修改flag的值。

对于使用序列化和反序列化产生新的实例的方式就容易避免了,可以增加readResolve()方法来预防。我们使用静态内部类的方式来演示如何避免:”

public class FileIO implements Serializable {
    private static final class FileIOHolder {
        private static final FileIO INSTANCE = new FileIO();
    }
    private FileIO() {}
    public static FileIO getInstance() {
        return FileIOHolder.INSTANCE;
    }
    private Object readResolve() {
        return FileIOHolder.INSTANCE;
    }
}

FileIO不解:”为什么增加readResolve()方法并在里面返回之前创建好的实例就可以避免被反序列破坏呢?“

“这是反序列化机制决定的, 在反序列化的时候会判断如果实现了serializable 或者 externalizable接口的类中又包含readResolve()方法的话,会直接调用readResolve()方法来获取实例。”,老张解释道。

8.终极招数-枚举

FileIO叹一叹气,”虽然单例看起来简单, 但是要弄一个完美的单例模式还是比较麻烦的!“

老张眨了眨眼,“我还有终极招数呢!”

“咦!”

public enum FileIO {
    INSTANCE;
    //... ...
    public File openFile(String fileName) {
        return getFile(fileName);
    }
}

“简单吧,这种单例模式是利用枚举来实现的,在调用的时候直接:”

 
FileIO.INSTANCE.openFile(fileName);

FileIO看着简单又陌生的自己,向老张投去疑惑又质疑的眼神。

老张解释:“别看你现在是枚举类型,但实际上反编译可知枚举实际上就是一个继承Enum的类。所以你的本质还是一个类,因为枚举的特点,你只会有一个实例,同时保证了线程安全、反射安全和反序列化安全。”

FileIO感慨,原来单例也可以这么简单,“你妹的老张,有这么简单的方法你不早告诉我。”

“我们重在过程,不在结果。”

Refer:

[1] Java帝国之单例设计模式

https://xiaoqinyu0000.github.io/Java/JavaSingleton/

http://bit.ly/2k5tASU

[2] 如何正确地写出单例模式

http://wuchong.me/blog/2014/08/28/how-to-correctly-write-singleton-pattern/

[3] 朝花夕拾——Java静态内部类加载

http://blog.youkuaiyun.com/zhang_yanye/article/details/50344447

[4] 深入浅出单实例SINGLETON设计模式

http://coolshell.cn/articles/265.html

[5] Java 单例以及单例所引发的思考

http://bit.ly/2FUgPGC

转载于:https://my.oschina.net/leejun2005/blog/82941

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值