MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java单例模式的实现与应用

2021-04-301.3k 阅读

一、单例模式概述

在软件开发中,单例模式是一种创建型设计模式,它确保一个类仅有一个实例,并提供一个全局访问点来访问这个实例。这种模式在许多场景下都非常有用,例如在应用程序中管理数据库连接池、线程池,或者实现日志记录器等。在Java中,单例模式的实现方式多种多样,每种方式都有其优缺点,适用于不同的场景。

二、饿汉式单例模式

2.1 饿汉式单例模式的实现

饿汉式单例模式是最直观的一种实现方式。在类加载时就创建单例实例,代码如下:

public class EagerSingleton {
    // 私有静态成员变量,在类加载时就初始化
    private static final EagerSingleton instance = new EagerSingleton();

    // 私有构造函数,防止外部实例化
    private EagerSingleton() {
    }

    // 公共静态方法,提供全局访问点
    public static EagerSingleton getInstance() {
        return instance;
    }
}

在上述代码中,instance 是一个私有静态的 EagerSingleton 实例,并且在类加载时就通过 new EagerSingleton() 进行了初始化。构造函数被声明为私有,以防止外部通过 new 关键字创建新的实例。getInstance() 方法是一个公共静态方法,它返回已经创建好的单例实例。

2.2 饿汉式单例模式的特点

  1. 优点
    • 实现简单,在类加载时就创建实例,不存在线程安全问题。因为类加载机制是由JVM保证线程安全的,在任何线程访问 getInstance() 方法之前,instance 已经被创建好了。
    • 调用速度快,由于实例在类加载时就已经创建,所以在调用 getInstance() 方法获取实例时,不需要额外的同步操作,直接返回已经创建好的实例,效率较高。
  2. 缺点
    • 资源浪费,如果这个单例实例在整个应用程序生命周期中可能永远不会被使用,但是由于类加载机制,它依然会在类加载时被创建,这就造成了内存资源的浪费。
    • 无法实现延迟加载,饿汉式单例模式不支持延迟加载,即不能在需要使用实例的时候才创建实例。

三、懒汉式单例模式

3.1 普通懒汉式单例模式的实现

懒汉式单例模式与饿汉式不同,它是在第一次调用 getInstance() 方法时才创建实例,实现代码如下:

public class LazySingleton {
    // 私有静态成员变量,初始化为null
    private static LazySingleton instance = null;

    // 私有构造函数,防止外部实例化
    private LazySingleton() {
    }

    // 公共静态方法,提供全局访问点
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

在上述代码中,instance 初始化为 null,在 getInstance() 方法中,通过判断 instance 是否为 null 来决定是否创建实例。如果 instancenull,则创建一个新的 LazySingleton 实例,然后返回该实例。

3.2 普通懒汉式单例模式的线程安全问题

普通懒汉式单例模式在多线程环境下存在线程安全问题。假设两个线程 T1T2 同时调用 getInstance() 方法,当 T1 执行到 if (instance == null) 时,由于还没有创建实例,条件为真,此时 T1 还未执行 instance = new LazySingleton(); 语句,CPU 时间片切换到 T2T2 同样执行到 if (instance == null),由于 instance 依然为 nullT2 也会执行 instance = new LazySingleton(); 语句。这样就会导致创建多个 LazySingleton 实例,违背了单例模式的原则。

3.3 线程安全的懒汉式单例模式(同步方法)

为了解决普通懒汉式单例模式在多线程环境下的线程安全问题,可以在 getInstance() 方法上添加 synchronized 关键字,使该方法成为同步方法,代码如下:

public class ThreadSafeLazySingleton {
    // 私有静态成员变量,初始化为null
    private static ThreadSafeLazySingleton instance = null;

    // 私有构造函数,防止外部实例化
    private ThreadSafeLazySingleton() {
    }

    // 公共静态同步方法,提供全局访问点
    public static synchronized ThreadSafeLazySingleton getInstance() {
        if (instance == null) {
            instance = new ThreadSafeLazySingleton();
        }
        return instance;
    }
}

通过在 getInstance() 方法上添加 synchronized 关键字,确保在同一时刻只有一个线程能够进入该方法,从而避免了多线程环境下创建多个实例的问题。

3.4 线程安全的懒汉式单例模式(同步代码块)

除了在方法上添加 synchronized 关键字,还可以使用同步代码块来实现线程安全的懒汉式单例模式,代码如下:

public class ThreadSafeLazySingletonWithBlock {
    // 私有静态成员变量,初始化为null
    private static ThreadSafeLazySingletonWithBlock instance = null;

    // 私有构造函数,防止外部实例化
    private ThreadSafeLazySingletonWithBlock() {
    }

    // 公共静态方法,提供全局访问点
    public static ThreadSafeLazySingletonWithBlock getInstance() {
        if (instance == null) {
            synchronized (ThreadSafeLazySingletonWithBlock.class) {
                if (instance == null) {
                    instance = new ThreadSafeLazySingletonWithBlock();
                }
            }
        }
        return instance;
    }
}

在上述代码中,首先通过 if (instance == null) 进行第一次判断,只有当 instancenull 时,才进入同步代码块。在同步代码块中,再次通过 if (instance == null) 进行判断,这是为了防止在多个线程同时通过第一次 if 判断后,在同步代码块中重复创建实例。这种方式既保证了线程安全,又提高了性能,因为只有在 instancenull 时才进行同步操作。

3.5 双重检查锁(DCL)实现懒汉式单例模式

双重检查锁(Double - Checked Locking,DCL)是一种更优化的实现线程安全懒汉式单例模式的方式,代码如下:

public class DCLSingleton {
    // 私有静态成员变量,使用volatile关键字修饰
    private static volatile DCLSingleton instance = null;

    // 私有构造函数,防止外部实例化
    private DCLSingleton() {
    }

    // 公共静态方法,提供全局访问点
    public static DCLSingleton getInstance() {
        if (instance == null) {
            synchronized (DCLSingleton.class) {
                if (instance == null) {
                    instance = new DCLSingleton();
                }
            }
        }
        return instance;
    }
}

在上述代码中,instance 被声明为 volatile 类型。volatile 关键字的作用是保证变量的可见性,即当一个线程修改了 instance 的值,其他线程能够立即看到这个修改。同时,双重检查锁机制通过两次 if (instance == null) 判断,既保证了线程安全,又提高了性能。第一次 if 判断是为了避免不必要的同步操作,只有当 instancenull 时才进入同步代码块。在同步代码块中,再次进行 if (instance == null) 判断,以防止多个线程同时通过第一次 if 判断后重复创建实例。

然而,在早期的Java版本中,由于指令重排序的问题,DCL 实现可能会出现问题。指令重排序是指编译器和处理器为了优化程序性能,在不改变程序语义的前提下,对指令的执行顺序进行重新排序。在创建 instance = new DCLSingleton(); 时,可能会被重排序为以下步骤:

  1. 分配内存空间。
  2. 初始化对象。
  3. instance 指向分配的内存空间。

如果发生指令重排序,当一个线程执行到步骤 3 时,instance 已经非 null,但此时对象可能还未完全初始化。如果另一个线程此时通过第一次 if (instance == null) 判断,就会返回一个未完全初始化的 instance。在Java 5.0 及以后的版本中,volatile 关键字的语义得到了增强,它会禁止指令重排序,从而保证了 DCL 实现的正确性。

3.6 懒汉式单例模式的特点

  1. 优点
    • 实现了延迟加载,只有在第一次调用 getInstance() 方法时才创建实例,避免了饿汉式单例模式中可能存在的资源浪费问题。
    • 通过合理的同步机制(如双重检查锁),在保证线程安全的同时,提高了性能。
  2. 缺点
    • 实现相对复杂,尤其是双重检查锁的实现,需要考虑指令重排序等问题,对开发者的要求较高。
    • 同步操作会带来一定的性能开销,虽然通过优化可以降低这种开销,但在高并发场景下,仍然可能成为性能瓶颈。

四、静态内部类实现单例模式

4.1 静态内部类实现单例模式的原理

静态内部类实现单例模式利用了Java的类加载机制。在Java中,类的加载是按需进行的,并且静态内部类只有在被调用时才会被加载。通过将单例实例的创建放在静态内部类中,可以实现延迟加载,同时利用类加载机制保证线程安全。

4.2 静态内部类实现单例模式的代码示例

public class StaticInnerClassSingleton {
    // 私有构造函数,防止外部实例化
    private StaticInnerClassSingleton() {
    }

    // 静态内部类
    private static class SingletonHolder {
        // 静态常量,在类加载时创建单例实例
        private static final StaticInnerClassSingleton instance = new StaticInnerClassSingleton();
    }

    // 公共静态方法,提供全局访问点
    public static StaticInnerClassSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

在上述代码中,SingletonHolder 是一个静态内部类,它包含一个静态常量 instance,在 SingletonHolder 类加载时,instance 会被初始化。getInstance() 方法通过返回 SingletonHolder.instance 来获取单例实例。由于静态内部类只有在被调用时才会被加载,所以实现了延迟加载。同时,类加载机制保证了 instance 的创建是线程安全的。

4.3 静态内部类实现单例模式的特点

  1. 优点
    • 实现简单,既实现了延迟加载,又利用类加载机制保证了线程安全,不需要额外的同步操作。
    • 性能较好,由于没有同步操作带来的性能开销,在多线程环境下表现良好。
  2. 缺点
    • 从代码结构上看,对于不熟悉静态内部类和类加载机制的开发者来说,理解起来可能有一定难度。

五、枚举实现单例模式

5.1 枚举实现单例模式的代码示例

public enum EnumSingleton {
    // 单例实例
    INSTANCE;

    // 可以添加其他方法
    public void doSomething() {
        System.out.println("Doing something in EnumSingleton.");
    }
}

在上述代码中,通过定义一个枚举类型 EnumSingleton,其中的 INSTANCE 就是单例实例。枚举类型在Java中是线程安全的,并且在类加载时就会被初始化,所以保证了单例的唯一性。

5.2 枚举实现单例模式的特点

  1. 优点
    • 实现极为简洁,代码量少,并且天然支持序列化和反序列化,不会出现反序列化重新创建实例的问题。
    • 线程安全,由Java虚拟机保证枚举实例的唯一性和线程安全性。
  2. 缺点
    • 枚举类型在功能上相对受限,如果需要对单例实例进行复杂的继承和多态操作,可能不太方便。
    • 由于枚举在类加载时就初始化实例,所以不支持延迟加载。

六、单例模式的应用场景

6.1 数据库连接池

在应用程序中,经常需要与数据库进行交互。为了提高数据库访问效率,减少频繁创建和销毁数据库连接的开销,通常会使用数据库连接池。数据库连接池可以看作是一个单例对象,它管理着一组数据库连接,应用程序通过这个单例对象获取数据库连接。例如,在Java开发中,常用的数据库连接池框架如HikariCP、C3P0等,都可以采用单例模式来实现连接池的管理。这样可以确保整个应用程序中只有一个连接池实例,避免了资源的浪费和管理的复杂性。

6.2 线程池

线程池也是一个常见的应用场景。线程池用于管理一组线程,避免频繁创建和销毁线程带来的性能开销。通过单例模式实现线程池,可以保证在整个应用程序中只有一个线程池实例,便于对线程资源进行统一管理。例如,在Java中,可以使用 Executors 类创建不同类型的线程池,并且可以将其封装为单例模式,使得应用程序中的各个模块都可以共享这个线程池。

6.3 日志记录器

日志记录器用于记录应用程序运行过程中的各种信息,如错误信息、调试信息等。在一个应用程序中,通常只需要一个日志记录器实例,以保证日志记录的一致性和规范性。通过单例模式实现日志记录器,可以确保在应用程序的不同模块中都使用同一个日志记录器,方便对日志进行统一配置和管理。例如,在Java开发中,常用的日志框架如Log4j、SLF4J等,都可以采用单例模式来实现日志记录器的管理。

6.4 配置文件管理器

在应用程序中,通常需要读取配置文件来获取各种配置信息,如数据库连接信息、系统参数等。配置文件管理器可以负责加载和管理配置文件。通过单例模式实现配置文件管理器,可以保证在整个应用程序中只有一个配置文件管理器实例,避免重复加载配置文件带来的资源浪费和数据不一致问题。这样,应用程序的各个模块都可以通过这个单例实例获取配置信息。

七、单例模式的注意事项

7.1 序列化与反序列化问题

当一个单例类实现了 Serializable 接口时,如果不进行特殊处理,在反序列化时会创建一个新的实例,从而破坏单例模式。为了避免这种情况,可以在单例类中添加 readResolve() 方法,代码如下:

public class SerializableSingleton implements Serializable {
    // 私有静态成员变量
    private static final SerializableSingleton instance = new SerializableSingleton();

    // 私有构造函数,防止外部实例化
    private SerializableSingleton() {
    }

    // 公共静态方法,提供全局访问点
    public static SerializableSingleton getInstance() {
        return instance;
    }

    // 防止反序列化时创建新的实例
    protected Object readResolve() {
        return instance;
    }
}

在上述代码中,readResolve() 方法返回已经创建好的单例实例 instance,这样在反序列化时就不会创建新的实例,从而保证了单例模式的正确性。

7.2 反射攻击问题

反射机制可以在运行时获取类的构造函数、方法和成员变量等信息,并可以通过反射来创建类的实例。对于单例模式,如果不进行防范,恶意代码可以通过反射调用私有构造函数来创建多个实例,破坏单例模式。为了防止反射攻击,可以在构造函数中添加判断,如果单例实例已经存在,则抛出异常,代码如下:

public class ReflectionSafeSingleton {
    // 私有静态成员变量
    private static ReflectionSafeSingleton instance = null;

    // 私有构造函数,防止外部实例化
    private ReflectionSafeSingleton() {
        if (instance != null) {
            throw new RuntimeException("Use getInstance() method to get the single instance.");
        }
    }

    // 公共静态方法,提供全局访问点
    public static ReflectionSafeSingleton getInstance() {
        if (instance == null) {
            synchronized (ReflectionSafeSingleton.class) {
                if (instance == null) {
                    instance = new ReflectionSafeSingleton();
                }
            }
        }
        return instance;
    }
}

在上述代码中,构造函数中添加了判断,如果 instance 已经存在,则抛出异常,提示只能通过 getInstance() 方法获取单例实例,从而防止了反射攻击。

八、总结不同实现方式的选择

  1. 饿汉式单例模式:适用于单例实例在应用程序启动时就需要创建,并且不会造成资源浪费的场景。例如,一些全局配置信息的管理类,在应用程序启动时就需要加载并初始化配置,这种情况下饿汉式单例模式是一个不错的选择。
  2. 懒汉式单例模式
    • 普通懒汉式:适用于单线程环境下的简单应用场景,由于其在多线程环境下存在线程安全问题,所以在多线程应用中一般不使用。
    • 线程安全的懒汉式(同步方法):适用于并发度不高,对性能要求不是特别高的多线程场景。因为同步方法会带来一定的性能开销,在高并发场景下可能会影响应用程序的性能。
    • 线程安全的懒汉式(同步代码块和双重检查锁):适用于高并发场景,尤其是对性能要求较高的应用。双重检查锁机制在保证线程安全的同时,尽可能地减少了同步操作带来的性能开销。
  3. 静态内部类实现单例模式:适用于需要延迟加载,并且对性能有较高要求的场景。它既实现了延迟加载,又利用类加载机制保证了线程安全,代码实现相对简洁,是一种比较推荐的实现方式。
  4. 枚举实现单例模式:适用于需要简单实现单例,并且对序列化和反序列化有要求的场景。由于枚举天然支持序列化和反序列化,并且实现简洁,所以在一些对功能要求不是特别复杂的单例场景中可以优先考虑。

在实际应用中,需要根据具体的业务需求、性能要求以及应用场景来选择合适的单例模式实现方式,以确保应用程序的正确性和高效性。同时,还需要注意单例模式在序列化、反序列化以及反射等方面可能出现的问题,并采取相应的措施进行防范。