详细说说单例模式

483 阅读11分钟

单例模式

什么是单例模式?

单例模式是一种设计模式,用于限制类的实例化只能创建一个对象。也就是说,单例模式确保一个类只有一个实例,并提供了一种访问该实例的全局方式。在单例模式中,类的构造函数被声明为私有的,这意味着该类不能被外部实例化。相反,类提供一个静态方法或属性,以便客户端代码可以访问该类的唯一实例。单例模式通常用于需要全局访问的资源,例如配置文件、数据库连接、日志对象等。由于单例模式只允许创建一个实例,因此可以避免在系统中出现多个实例造成的资源浪费和冲突。

单例模式的实现方式?

单例模式分为懒汉式饿汉式

其中最常见的方式是使用静态变量私有构造函数

私有构造函数用于防止类的实例化,而静态变量则保存类的唯一实例

饿汉式:

public class Singleton {
//    饿汉式:在类加载时就完成了初始化,所以类加载较慢,但获取对象的速度快
    private static Singleton singleton = new Singleton();
    //    私有化构造方法
    private Singleton() {}
    
    public static Singleton getInstance() {
        return singleton;
    }
}

这样,在第一次调用Singleton.getInstance()时,单例对象就已经被创建并初始化了,之后每次调用都会返回同一个对象需要注意的是,由于饿汉式在类加载时就创建了单例对象,因此如果该对象的初始化过程比较耗时,会导致程序启动变慢。同时,如果该单例对象一直没有被使用,也会浪费一定的内存资源。

懒汉式:

public class Singleton2 {
    private volatile static Singleton2 singleton2;

//    私有化构造方法
    private Singleton2() {}
    
    public static Singleton2 getInstance() {
//        检查实例,如果不存在,就进入同步代码块
        if(singleton2 == null) {
            synchronized (Singleton2.class){
//                再次检查实例,如果不存在,就创建一个实例
                if(singleton2 == null) {
                    singleton2 = new Singleton2();
                }
            }
        }
        return singleton2;
    }
}

懒汉式单例模式(Lazy Initialization)在实现上可以采用双重检查锁(Double-Checked Locking)机制来保证线程安全

双重检查锁(Double-Checked Locking):

双重检查锁机制的基本思想是在加锁前后进行两次检查,以确保线程安全并避免重复加锁的开销。具体实现中,首先检查实例是否已经创建,如果没有创建才会进行加锁和创建实例的操作;然后在加锁后再次检查实例是否已经创建,以避免在等待锁的过程中其他线程已经创建了实例。在懒汉式单例模式中,当第一个线程访问getInstance()方法时,如果实例还未创建,则会先加锁,然后再次检查实例是否已经创建。如果没有创建,则创建实例并返回;否则直接返回已经创建的实例。之后其他线程访问getInstance()方法时,因为实例已经被创建,不会再进入加锁操作,直接返回已经创建的实例。

volatile 是一种关键字,用于修饰变量。使用 volatile 关键字修饰的变量具有如下特点:

  1. 可见性:在多线程环境下,当一个线程修改了 volatile 变量的值,其他线程可以立即看到该变量的最新值。
  2. 顺序性:volatile 变量的读写操作是有序的,即在写入 volatile 变量之前的操作都会在写入之后发生。
  3. 不保证原子性:虽然 volatile 变量保证了可见性和顺序性,但不保证原子性。如果需要保证原子性,可以使用 synchronized 或者 java.util.concurrent.atomic 包提供的原子类来实现。

为什么要volatile修饰静态变量呢?

在Java语言中,线程之间的数据交互主要通过内存的读和写来完成。当一个线程写入数据时,如果该数据不是 volatile 修饰的,那么另一个线程可能无法立即看到该数据的更新。这是因为每个线程都有自己的本地缓存,而不是直接访问主内存。

如果没有使用 volatile 修饰静态变量 instance,那么在双重检查锁定机制中的第二个判断语句中,可能会读取到一个过期的缓存值,从而导致多次创建实例对象。而使用 volatile 修饰静态变量 instance 可以确保该变量在多线程之间的可见性和顺序性,从而避免线程安全问题。

总的来说,使用 volatile 修饰静态变量 instance 是为了确保多线程环境下的线程安全性和可见性,是实现双重检查锁定机制的重要技术手段。

单例模式的优缺点

优点:

  1. 单例模式可以确保在应用程序中只有一个实例存在,从而节省了资源并提高了性能。

  2. 单例模式提供了对共享资源的中心化控制,避免了多个实例之间的冲突。

  3. 单例模式可以使代码更加简洁明了,易于维护和扩展。

  4. 单例模式可以通过使用接口来保证代码的可测试性。

缺点:

  1. 可能导致单点故障:由于单例模式只有一个实例对象,因此如果该实例对象出现问题,将会影响整个系统的正常运行。

  2. 不能完全避免线程安全问题:在多线程环境下,单例模式可能会出现线程安全问题,例如多个线程同时访问单例对象可能会导致竞争条件和死锁等问题。

  3. 不适用于大规模的对象存储:由于单例模式只有一个实例对象,因此如果该对象需要存储大量数据,可能会导致系统性能和可维护性下降。

  4. 对象生命周期受控制:由于单例模式只有一个实例对象,因此该对象的生命周期可能会比较长,需要注意内存泄漏等问题。

单例模式在实际项目中的应用

  1. 配置文件管理器:在项目中,通常需要读取和管理配置文件。可以使用单例模式来实现一个配置文件管理器,用于读取和管理项目中的配置文件,并提供全局访问点,方便其他对象访问。
public class ConfigManager {
    private static ConfigManager instance;
    private Properties props;

    private ConfigManager() {
        props = new Properties();
        try {
            props.load(new FileInputStream("config.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

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

    public String getProperty(String key) {
        return props.getProperty(key);
    }
}
ConfigManager config = ConfigManager.getInstance();
String url = config.getProperty("db.url");
String user = config.getProperty("db.user");
String password = config.getProperty("db.password");
  1. 数据库连接池:在大型项目中,通常需要连接多个数据库。可以使用单例模式来实现一个数据库连接池,用于管理多个数据库连接对象,并提供全局访问点,方便其他对象访问。
public class DruidDataSourceManager {
    private static DruidDataSource dataSource;

    private DruidDataSourceManager() {
        dataSource = new DruidDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setUrl("jdbc:mysql://localhost:3306/mydatabase");
        dataSource.setUsername("root");
        dataSource.setPassword("password");
    }

    public static DruidDataSource getInstance() {
        if (dataSource == null) {
            synchronized (DruidDataSourceManager.class) {
                if (dataSource == null) {
                    new DruidDataSourceManager();
                }
            }
        }
        return dataSource;
    }
}

该示例代码中的 getInstance() 方法返回了 DruidDataSource 类型的单例对象,该对象可以被整个应用程序共享和访问。

使用单例模式来管理数据库连接池可以减少资源占用和开销,提高应用程序的性能和可维护性。同时,该方法还可以方便地实现数据库连接的统一管理和重用。

  1. 日志记录器:在项目中,通常需要记录日志信息以便排查问题。可以使用单例模式来实现一个日志记录器,用于记录系统日志,并提供全局访问点,方便其他对象访问。

log4j就体现了日志记录器

public class LoggerManager {
    private static final Logger LOGGER = Logger.getLogger(LoggerManager.class.getName());

    private LoggerManager() {
        // 配置日志输出目的地和格式
        BasicConfigurator.configure();
    }

    public static Logger getLogger() {
        return LOGGER;
    }
}

在该示例代码中,我们使用 log4j 实现了一个简单的日志记录器。在调用 getLogger() 方法时,如果日志记录器还未创建,则会先加锁,然后再次检查日志记录器是否已经创建。如果没有创建,则创建日志记录器并返回;否则直接返回已经创建的日志记录器。

该示例代码中的 getLogger() 方法返回了 Logger 类型的单例对象,该对象可以被整个应用程序共享和访问。

  1. 系统缓存管理器:在项目中,通常需要使用缓存来提高系统性能。可以使用单例模式来实现一个系统缓存管理器,用于管理系统中的缓存对象,并提供全局访问点,方便其他对象访问。

  2. 线程池:在多线程环境中,通常需要使用线程池来管理线程资源。可以使用单例模式来实现一个线程池管理器,用于管理系统中的线程池对象,并提供全局访问点,方便其他对象访问。

public class ThreadPool {
    private static final ThreadPool INSTANCE = new ThreadPool();
    private ExecutorService executor;

    private ThreadPool() {
        int corePoolSize = 10;
        int maxPoolSize = 20;
        long keepAliveTime = 60;
        executor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
    }

    public static ThreadPool getInstance() {
        return INSTANCE;
    }

    public void execute(Runnable task) {
        executor.execute(task);
    }

    public void shutdown() {
        executor.shutdown();
    }
}

在该示例代码中,我们使用单例模式实现了一个简单的线程池,该线程池通过 getInstance() 方法返回单例对象,用于在系统中统一管理线程。在实现中,我们使用 ThreadPoolExecutor 类来创建线程池,并通过 execute() 方法来提交任务到线程池中执行。同时,我们提供了 shutdown() 方法来关闭线程池。

该线程池可以被整个系统共享和访问,方便实现不同模块之间的任务处理和并发执行。同时,由于使用了单例模式,可以避免重复创建线程池对象,提高了系统的性能和可用性。

单例模式其他实现方式

除了懒汉式和饿汉式,单例模式还有两种常见的实现方式:枚举静态内部类

枚举实现方式

public enum Singleton3 {
    INSTANCE;
    public void doSomething() {
        System.out.println("Hello World!");
    }
}

枚举实现单例模式是一种非常简洁和安全的方式。由于枚举类型的特殊性,枚举值在 JVM 中是唯一的,因此可以保证枚举实现的单例对象是线程安全的。

同时,枚举实现方式也具有很好的可读性和可维护性。枚举值本身就是单例对象,代码实现非常简单,而且可以方便地进行扩展和修改。

需要注意的是,枚举实现单例模式虽然简洁,但并不常用,因为在实际项目中可能会涉及到更加复杂的初始化和配置过程,这时候就需要使用其他方式来实现单例模式。

静态内部类实现方式

public class Singleton {
    // 私有化构造函数
    private Singleton() {}
    
    // 静态内部类,用于创建单例对象
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    // 静态方法,返回单例对象
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

在该示例代码中,我们在外部类中私有化构造函数,并在静态内部类 SingletonHolder 中定义了单例对象 INSTANCE。在 getInstance 方法中,我们返回静态内部类中定义的单例对象。由于静态内部类只会被加载一次,因此可以保证单例对象的唯一性。

需要注意的是,由于静态内部类只会在被使用时才会被加载,因此可以避免在程序启动时就创建单例对象,从而提高程序的启动速度。同时,静态内部类实现单例模式也具有很好的可读性和可维护性,可以方便地进行单例对象的扩展和修改。

各种方式的适用场景

  • 饿汉式实现方式:适用于单例对象较小且在应用中始终被频繁使用的情况。
  • 懒汉式实现方式:适用于单例对象较大或不常使用的情况。
  • 枚举实现方式:适用于所有需要使用单例模式的情况。
  • 静态内部类实现方式:适用于单例对象较大或不常使用的情况。

枚举模式不常见的原因:

  1. 无法延迟加载:由于枚举类型在类加载时就会被实例化,因此无法实现延迟加载。如果应用程序中的单例对象比较耗费资源,那么枚举实现单例模式可能会导致系统启动较慢或者占用较多的系统资源。
  2. 无法继承:枚举类型是 final 类型,因此无法被继承。如果需要扩展单例对象的功能,那么就无法使用枚举实现单例模式。
  3. 不太常见:相对于饿汉式和懒汉式实现方式,枚举实现单例模式并不是特别常见。很多开发人员更加熟悉饿汉式和懒汉式的实现方式,因此在实际开发中更容易选择这两种实现方式。

单例模式的UML类图

image.png