一、单例设计模式的基本概念
在 Java 编程的广阔天地里,单例设计模式宛如一颗璀璨的明星,是一种极为实用的创建型设计模式。它的核心使命是确保一个类在整个应用程序的生命周期内仅仅存在一个实例,并且为外界提供一个全局唯一的访问点来获取这个珍贵的实例。
想象一下,在一个大型的软件系统中,数据库连接池就像是一座城市的供水系统,需要稳定且唯一的管理;日志记录器则如同城市的档案馆,所有的信息都应该汇聚到一处。如果这些关键资源被随意创建多个实例,就好比城市有多个独立的供水系统和档案馆,不仅会造成资源的极大浪费,还可能引发数据不一致等严重问题,导致整个系统陷入混乱。而单例模式就像是一位精明的城市规划师,严格把控着实例的创建,保证一切井然有序。
二、单例设计模式的多种实现方式
1. 饿汉式单例
原理
饿汉式单例就像是一个“急性子”,在类加载的时候就迫不及待地创建了单例实例,不管后续是否会真正使用到这个实例。这种方式巧妙地利用了 Java 的类加载机制,天然地避免了多线程环境下的同步问题。
示例代码
class A {// 在类加载时就创建单例实例private static final A INSTANCE = new A();// 私有构造函数,防止外部通过 new 关键字创建实例private A() {}// 提供一个公共的静态方法,用于获取单例实例public static A getInstance() {return INSTANCE;}
}
为什么使用 static final
修饰 INSTANCE
-
static
关键字的作用:在 Java 中,static
关键字用于修饰类的成员(变量或方法),使其属于类本身,而不是类的某个实例。当一个变量被声明为static
时,它在内存中只有一份拷贝,被所有该类的实例共享。在单例模式中,我们希望INSTANCE
是一个全局唯一的实例,使用static
修饰可以确保无论创建多少个A
类的实例(实际上单例模式不允许外部创建多个实例),INSTANCE
始终只有一个。而且,由于static
变量在类加载时就会被初始化,所以INSTANCE
会在类加载阶段就被创建出来。 -
final
关键字的作用:final
关键字用于修饰变量时,表示该变量是一个常量,一旦被赋值就不能再被修改。在单例模式中,我们希望INSTANCE
是一个不可变的引用,即它一旦指向了某个A
类的实例,就不能再指向其他实例。使用final
修饰INSTANCE
可以保证这一点,避免在程序运行过程中意外地改变INSTANCE
的引用,从而破坏单例的唯一性。
代码验证
public class EagerSingletonTest {public static void main(String[] args) {A instance1 = A.getInstance();A instance2 = A.getInstance();// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
当你运行上述验证代码,如果输出结果为 true
,就说明 instance1
和 instance2
实际上是同一个对象,也就证明了 A
类确实是单例的。
优缺点分析
- 优点:实现方式简单直接,而且天生具备线程安全性,无需额外的同步操作。由于类加载过程是由 JVM 保证线程安全的,所以在多线程环境下也不会出现创建多个实例的问题。
- 缺点:如果单例实例的创建过程比较耗时,或者会占用大量的系统资源,而在整个程序的运行过程中这个实例可能根本不会被使用,那么就会造成不必要的资源浪费。
2. 懒汉式单例(非线程安全)
原理
懒汉式单例则像是一个“拖延症患者”,它不会在类加载时就创建实例,而是等到第一次真正使用这个实例的时候才去创建。这种方式实现了延迟加载,避免了不必要的资源提前消耗。
示例代码
class B {// 声明一个静态变量,用于存储单例实例,但不立即初始化private static B INSTANCE;// 私有构造函数,防止外部通过 new 关键字创建实例private B() {}// 提供一个公共的静态方法,用于获取单例实例public static B getInstance() {if (INSTANCE == null) {INSTANCE = new B();}return INSTANCE;}
}
为什么使用 static
修饰 INSTANCE
同样,使用 static
修饰 INSTANCE
是为了确保它是一个全局唯一的变量,被所有 B
类的实例(虽然单例模式下通常不会有多个实例)共享。而且,getInstance()
方法是静态方法,静态方法只能访问静态变量,所以 INSTANCE
必须是静态的才能在 getInstance()
方法中被访问。
代码验证
public class LazySingletonNonThreadSafeTest {public static void main(String[] args) {B instance1 = B.getInstance();B instance2 = B.getInstance();// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
同样,运行上述验证代码,若输出为 true
,则表明 B
类在单线程环境下是单例的。
优缺点分析
- 优点:实现了延迟加载,只有在真正需要使用实例的时候才会创建,避免了资源的提前浪费。
- 缺点:这种实现方式在多线程环境下是不安全的。想象一下,当多个线程同时进入
if (INSTANCE == null)
这个条件判断语句时,可能会导致多个线程都认为INSTANCE
为null
,从而各自创建一个实例,破坏了单例的唯一性。
3. 懒汉式单例(线程安全,使用同步方法)
原理
为了解决懒汉式单例在多线程环境下的不安全问题,我们可以在 getInstance()
方法上添加 synchronized
关键字。这样一来,在多线程环境下,同一时间就只有一个线程能够进入这个方法,从而保证了单例的唯一性。
示例代码
class C {// 声明一个静态变量,用于存储单例实例,但不立即初始化private static C INSTANCE;// 私有构造函数,防止外部通过 new 关键字创建实例private C() {}// 使用 synchronized 关键字修饰方法,保证线程安全public static synchronized C getInstance() {if (INSTANCE == null) {INSTANCE = new C();}return INSTANCE;}
}
为什么使用 static
修饰 INSTANCE
和 getInstance()
方法
INSTANCE
使用 static
修饰的原因和前面一样,是为了保证它是全局唯一的变量。而 getInstance()
方法使用 static
修饰是因为我们希望通过类名直接调用这个方法来获取单例实例,而不需要创建类的实例。同时,由于 getInstance()
方法要访问静态变量 INSTANCE
,所以它也必须是静态方法。
代码验证
public class LazySingletonThreadSafeTest {public static void main(String[] args) {C instance1 = C.getInstance();C instance2 = C.getInstance();// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
运行验证代码,输出 true
就说明 C
类在多线程环境下也是单例的。
优缺点分析
- 优点:保证了在多线程环境下的线程安全性,同时也实现了延迟加载。
- 缺点:由于每次调用
getInstance()
方法都需要进行同步操作,这会带来一定的性能开销,尤其是在高并发的场景下,性能问题会更加明显。
4. 双重检查锁定单例
原理
双重检查锁定单例结合了懒汉式和同步机制的优点。它首先在不进行同步的情况下检查 INSTANCE
是否为 null
,如果不为 null
则直接返回实例,这样可以减少同步的开销。只有当 INSTANCE
为 null
时,才会进行同步操作,并且在同步块内部再次检查 INSTANCE
是否为 null
,以确保在多线程环境下不会创建多个实例。同时,为了避免指令重排序问题,需要使用 volatile
关键字修饰 INSTANCE
变量。
示例代码
class D {// 使用 volatile 关键字保证可见性,避免指令重排序private static volatile D INSTANCE;// 私有构造函数,防止外部通过 new 关键字创建实例private D() {}public static D getInstance() {if (INSTANCE == null) {synchronized (D.class) {if (INSTANCE == null) {INSTANCE = new D();}}}return INSTANCE;}
}
为什么使用 static
和 volatile
修饰 INSTANCE
static
修饰的原因:和前面几种实现方式一样,使用static
修饰INSTANCE
是为了保证它是全局唯一的变量,被所有D
类的实例共享。volatile
修饰的原因:在 Java 中,指令重排序是指编译器和处理器为了提高性能,可能会对代码的执行顺序进行重新排序。在创建对象的过程中,可能会出现指令重排序的情况,导致INSTANCE
引用在对象还未完全初始化时就被赋值。在多线程环境下,其他线程可能会看到一个未完全初始化的对象,从而引发错误。使用volatile
关键字修饰INSTANCE
可以禁止指令重排序,保证在多线程环境下的可见性和正确性。
代码验证
public class DoubleCheckedLockingSingletonTest {public static void main(String[] args) {D instance1 = D.getInstance();D instance2 = D.getInstance();// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
运行验证代码,若输出为 true
,则证明 D
类是单例的。
优缺点分析
- 优点:既保证了线程安全,又实现了延迟加载,同时还减少了同步带来的性能开销,是一种比较优秀的实现方式。
- 缺点:实现相对复杂,需要开发者深入理解
volatile
关键字和双重检查的原理。
5. 静态内部类单例
原理
静态内部类单例利用了 Java 静态内部类的特性。静态内部类在类加载时不会被加载,只有在第一次使用时才会被加载,并且类加载的过程是线程安全的。因此,这种方式既实现了延迟加载,又保证了线程安全。
示例代码
class E {// 私有构造函数,防止外部通过 new 关键字创建实例private E() {}// 静态内部类,包含一个静态常量 INSTANCE,用于存储单例实例private static class SingletonHolder {private static final E INSTANCE = new E();}// 提供一个公共的静态方法,用于获取单例实例public static E getInstance() {return SingletonHolder.INSTANCE;}
}
为什么内部类的 INSTANCE
使用 static final
修饰
static
修饰的原因:使用static
修饰INSTANCE
是为了确保它是静态内部类SingletonHolder
的静态成员,在类加载时就被初始化,并且被所有E
类的实例共享。final
修饰的原因:和前面一样,final
修饰INSTANCE
是为了保证它是一个不可变的引用,一旦指向了某个E
类的实例,就不能再指向其他实例,从而保证单例的唯一性。
代码验证
public class StaticInnerClassSingletonTest {public static void main(String[] args) {E instance1 = E.getInstance();E instance2 = E.getInstance();// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
运行验证代码,输出 true
就表明 E
类是单例的。
优缺点分析
- 优点:线程安全,实现了延迟加载,代码简洁易懂,是一种比较推荐的实现方式。
- 缺点:需要开发者对 Java 静态内部类的加载机制有一定的了解。
6. 枚举单例
原理
Java 的枚举类型天生就是线程安全的,并且可以防止反序列化重新创建新的对象。因此,使用枚举来实现单例模式是一种非常简洁、高效且安全的方式。
示例代码
enum F {INSTANCE;public void doSomething() {System.out.println("Doing something...");}
}
枚举实现单例的优势
枚举类型在 Java 中是一种特殊的类,它的实例是有限且唯一的。在枚举类型中定义的枚举常量(如 INSTANCE
)会在类加载时被创建,并且是线程安全的。同时,Java 的序列化机制对枚举类型有特殊的处理,反序列化时不会创建新的实例,从而保证了单例的唯一性。
代码验证
public class EnumSingletonTest {public static void main(String[] args) {F instance1 = F.INSTANCE;F instance2 = F.INSTANCE;// 验证两个实例是否为同一个对象System.out.println(instance1 == instance2); }
}
运行验证代码,若输出为 true
,则说明 F
枚举类型实现了单例。
优缺点分析
- 优点:线程安全,防止反序列化重新创建新的对象,实现简单,是实现单例模式的最佳方式之一。
- 缺点:相对不够灵活,因为枚举类型默认继承
java.lang.Enum
类,所以不能再继承其他类。
三、单例设计模式的使用场景
- 资源共享:在一些需要多个模块共享同一个资源的场景中,如数据库连接池、线程池等,使用单例模式可以确保资源的一致性和高效利用。因为多个实例可能会导致资源的冲突和浪费,而单例模式可以保证只有一个实例来管理这些资源。
- 配置管理:应用程序的配置信息通常只需要一个实例来管理。使用单例模式可以方便地获取和修改配置信息,避免了多个实例对配置信息的不一致修改。
- 日志记录:日志记录器通常是单例的,这样可以确保所有的日志信息都被记录到同一个地方,方便后续的查看和分析。如果有多个日志记录器实例,可能会导致日志信息分散,不利于管理。
四、单例设计模式的注意事项
序列化和反序列化问题
如果单例类实现了 Serializable
接口,在反序列化时可能会创建新的实例,从而破坏单例的唯一性。为了解决这个问题,需要重写 readResolve()
方法。
import java.io.ObjectStreamException;
import java.io.Serializable;class G implements Serializable {private static final G INSTANCE = new G();private G() {}public static G getInstance() {return INSTANCE;}// 重写 readResolve() 方法,防止反序列化创建新的实例private Object readResolve() throws ObjectStreamException {return INSTANCE;}
}
反射攻击问题
通过反射机制可以调用私有构造函数创建新的实例,这也会破坏单例的唯一性。为了防止反射攻击,可以在构造函数中添加判断逻辑。
class H {private static final H INSTANCE = new H();private static boolean isInstanceCreated = false;private H() {if (isInstanceCreated) {throw new IllegalStateException("Singleton instance already created!");}isInstanceCreated = true;}public static H getInstance() {return INSTANCE;}
}
五、总结
单例设计模式在 Java 开发中是一种非常实用的设计模式,它可以确保一个类只有一个实例,避免了资源的浪费和数据不一致的问题。在使用单例模式时,还需要注意序列化和反序列化、反射攻击等问题,确保单例的唯一性和安全性。