为什么有单例模式?
单例模式(Singleton),也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个全局对象,这样有利于我们协调系统整体的行为。
实现原理是什么?
构造方法是private+static方法+if语句判断
注意:不同的实现方式它的实现原理肯定是有所区别的,综合来看!!
实现方式有哪些?
懒汉式、双重锁、饿汉式、静态内部类、枚举
懒汉式
- 好处:启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例、避免空间浪费;
- 缺点:线程不安全,if语句存在竞态条件
单例类
package com.example;
/**
* @BelongsProject: BigK
* @BelongsPackage: com.example
* @Author: dengLiMei
* @CreateTime: 2023-06-28 10:04
* @Description: 单例模式
* @Version: 1.0
*/
public class Singleton {
//提供一个全局变量让全局访问
private static Singleton instance;
//私有构造方法,堵死外界利用new创建此类实例的可能
private Singleton() {
}
//获得实例的唯一全局访问点
public static Singleton GetInstance() {
//当多线程来临的时候判断是否为null,此时instance就是临界资源,会实例化多个
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
客户端
//反射破坏封装性
Singleton instance1 = Singleton.GetInstance();
// 使用反射获取私有构造函数
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
// 通过反射创建第二个实例
Singleton instance2 = constructor.newInstance();
System.out.println(instance1); // 输出第一个实例的内存地址
System.out.println(instance2); // 输出第二个实例的内存地址
这里我是通过反射的方式去获取对象,然后对获取到的对象进行判断,运行代码之后我们会发现:
两个对象的内存地址并不相同,违背了单一性,那我们如何解决这个问题呢?可能屏幕前有些小伙伴想到了加锁的方式去做,没错,我们用大家比较常见的synchronized实现看看吧。
懒汉式变种-synchronized
- 好处:线程安全
- 缺点:并发性能差,synchronized加锁,不管有没有对象都加锁
单例类
package com.example;
/**
* @BelongsProject: BigK
* @BelongsPackage: com.example
* @Author: dengLiMei
* @CreateTime: 2023-06-28 10:14
* @Description: 懒汉单例:在第一次被引用时,才会将自己实例化
* @Version: 1.0
*/
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {
System.out.println("创建一次");
}
public static LazySingleton GetInstance() {
//方法一:加锁-把判断的这部分逻辑上锁
//好处:线程安全
//缺点:并发性能差,synchronized加锁,不管有没有对象都加锁
//解决方案:双重锁
synchronized ("") {
if (instance == null) {
instance = new LazySingleton();
}
}
return instance;
}
//方法二:同步代码段
public static synchronized LazySingleton getSingleton() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
客户端
//懒汉模式:加锁保证线程安全
Runnable r3 = () -> {
DoubleLockSingleton s1 = DoubleLockSingleton.GetInstance();
DoubleLockSingleton s2 = DoubleLockSingleton.GetInstance();
if (s1 == s2) {
System.out.println("两个对象是相同的实例");
}
};
Thread t1 = new Thread(r3);
Thread t2 = new Thread(r3);
t1.start();
t2.start();
通过运行结果我们会发现两个线程获取到的对象是同一个,实现了单例。
但是大家可以思考一下这样会不会存在什么问题呢?线程因为每次访问 getInstance() 方法时都需要获取锁,即使实例已经被创建,会在高并发环境下其实是比较影响性能的。并且会导致每次调用 getInstance() 方法都需要获取锁,而不是在需要时才创建实例。那我们可不可以当单例对象没有被创建的时候才去加锁呢?双重锁可以做到
懒汉式变种-双重锁
- 好处:实现线程安全地创建实例,而又不会对性能造成太大影响。
- 缺点:无效等待,同步效率地,锁占用资源(反射会破坏单一性)
单例类
package com.example;
/**
* @BelongsProject: BigK
* @BelongsPackage: com.example
* @Description: 懒汉单例——双重锁
* @Version: 1.0
*/
public class DoubleLockSingleton {
//volatile:禁止指令重排序(防止部分初始化)
private static volatile DoubleLockSingleton instance;
private DoubleLockSingleton() {
System.out.println("实例化了一次");
}
//原理:双重if,延迟实例化,避免每次进行同步的性能开销
public static DoubleLockSingleton GetInstance() {
//第一层判断:先判断实例是否存在,不存在再加锁处理
if (instance == null) {
synchronized ("") {
//第二层判断
if (instance == null) {
instance = new DoubleLockSingleton();
}
}
}
return instance;
}
}
客户端
DoubleLockSingleton instance1 = DoubleLockSingleton.GetInstance();
// 使用反射获取私有构造函数
Constructor<DoubleLockSingleton> constructor = DoubleLockSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
// 通过反射创建第二个实例
DoubleLockSingleton instance2 = constructor.newInstance();
System.out.println(instance1); // 输出第一个实例的内存地址
System.out.println(instance2); // 输出第二个实例的内存地址
这里我们依旧使用反射去获取单例对象。我们运行看看效果:
发现构造方法被调用了两地,并且获取到的两个对象的地址也不同,依旧是破坏了单例性。
双重锁实现方式是在第一次创建实例的时候同步,以后就不需要同步了。反射的使用让我们的单例类又不攻自破,没关系,咱们还有其他方式——饿汉式
饿汉式
- 优点:类加载阶段创建,保证了线程安全
- 缺点:可能存在没有被使用的可能,造成资源浪费
单例类
package com.example;
/**
* 饿汉模式:类加载时初始化单例,以后访问时直接返回即可
*/
public class HungrySingleton {
//类加载阶段就实例化
private static final HungrySingleton singleton = new HungrySingleton();
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return singleton;
}
}
客户端
//获取单例对象
HungrySingleton singleton = HungrySingleton.getInstance();
// 使用反射获取单例对象
try {
Class<?> singletonClass = Class.forName("com.example.HungrySingleton");
// 获取私有构造函数
Constructor<?> constructor = singletonClass.getDeclaredConstructor();
constructor.setAccessible(true);
// 通过反射实例化对象
HungrySingleton singletonReflection = (HungrySingleton) constructor.newInstance();
// 验证是否为同一对象
System.out.println(singleton == singletonReflection); // 输出 true
} catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
使用反射获取单例对象,我们看下输出结果:
在整个应用程序的生命周期中,无论是否会用到该单例实例,都会在类加载时创建实例,可能会导致资源的浪费。饿汉模式无法实现延迟加载,即在需要时才创建实例。这可能会导致在应用程序启动时就创建了大量的实例,占用内存。
基于这些原因,尽管饿汉模式是一种简单且线程安全的单例模式实现方式,但在资源利用、延迟加载和异常处理等方面存在一些问题。所以我们在实际使用过程中需要根据具体场景选择合适的单例模式实现方式
静态内部类
好处:
- 懒加载:静态内部类的方式能够实现懒加载,即在需要时才会加载内部类,从而创建单例对象。这样可以避免在类加载时就创建单例对象,节省了资源。
- 线程安全:静态内部类的方式利用了类加载机制和静态变量的特性,能够保证在多线程环境下也能够保持单例的唯一性,而且不需要使用同步关键字。
- 延迟加载:由于静态内部类的加载是在需要时才进行的,因此能够实现延迟加载,即在第一次使用时才会创建单例对象。
缺点:静态内部类的方式需要额外的类加载和内存开销,因为它需要创建一个内部类对象,而内部类对象的创建需要额外的内存开销。
单例类
package com.example;
/**
* 静态内部类
*
*/
public class StaticInnerSingleton {
//静态内部类
private static class SingletonHolder {
private static final StaticInnerSingleton INSTANCE = new StaticInnerSingleton();
}
private StaticInnerSingleton (){}
public static final StaticInnerSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
客户端
//获取单例对象
StaticInnerSingleton singleton = StaticInnerSingleton.getInstance();
// 使用反射获取单例对象
try {
Class<?> singletonClass = Class.forName("com.example.StaticInnerSingleton");
// 获取私有构造函数
Constructor<?> constructor = singletonClass.getDeclaredConstructor();
constructor.setAccessible(true);
// 通过反射实例化对象
StaticInnerSingleton singletonReflection = (StaticInnerSingleton) constructor.newInstance();
// 验证是否为同一对象
System.out.println(singleton == singletonReflection); // 输出 true
} catch (ClassNotFoundException | NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
我们来看看运行结果:
获取的两个对象的地址是不相同的,实现了单例。
它利用了类加载的特性和静态内部类的懒加载特性,解决了饿汉模式的资源浪费和懒汉模式的线程安全问题。具体实现方式是在外部类中定义一个私有的静态内部类,内部类中创建单例实例,并且利用类加载的特性保证了实例的唯一性。同时,由于静态内部类是在需要的时候才加载,因此实现了延迟加载的效果。也是比较推荐的一种方式
枚举
优点:线程安全、防止反序列化重新创建新的对象
单例类
package com.example;
/**
* 枚举方式
*/
public enum EnumSingleton {
INSTANCE;
}
客户端
// 获取单例对象
EnumSingleton singleton1 = EnumSingleton.INSTANCE;
EnumSingleton singleton2 = EnumSingleton.INSTANCE;
// 验证是否为同一对象
System.out.println(singleton1 == singleton2); // 输出 true
我们来让控制台打印输出看看结果:
在Java中,枚举类型是线程安全的,并且保证在任何情况下都是单例的。因此,使用枚举实现单例模式是一种推荐的方式。具体实现方式是定义一个包含单个枚举常量的枚举类型,这个枚举常量就是单例实例。由于枚举类型在Java中是天然的单例,因此不需要担心线程安全和反射攻击等问题。
使用场景有哪些?
Windows的Task Manager(任务管理器)、回收站
使用时如何选择?
在实际业务场景中,可以根据具体需求选择适合的单例模式。如果需要在应用启动时创建对象,且对性能要求较高,可以选择饿汉式或双重校验锁;如果需要延迟加载对象,可以选择静态内部类或枚举单例模式;如果对线程安全要求较高,可以选择双重校验锁或静态内部类单例模式