Java单例模式:最佳实践、设计原则与代码示例

引言

Java单例模式是“四人帮”(Gang of Four, GoF)设计模式之一,属于创建型设计模式。从定义上看,它似乎是一个简单直接的设计模式,但在实际实现时却涉及许多需要考虑的因素。

在这篇文章中,我们将深入学习单例设计模式的原则,探索不同的实现方式,并分享一些最佳实践。

单例模式原则

  • 单例模式限制了类的实例化,并确保在Java虚拟机(JVM)中只存在该类的一个实例。
  • 单例类必须提供一个全局访问点,以便获取该类的实例。
  • 单例模式常用于日志记录、驱动对象、缓存以及线程池等场景。
  • 单例设计模式也应用于其他设计模式中,例如抽象工厂(Abstract Factory)、建造者(Builder)、原型(Prototype)、外观(Facade)等。
  • 单例设计模式在Java核心类库中也有应用(例如,java.lang.Runtime, java.awt.Desktop)。

Java 单例模式的实现方式

为了实现单例模式,我们有多种方法,但它们都遵循以下共同概念:

  • 私有构造函数: 限制其他类实例化该单例类。
  • 私有静态变量: 该变量是单例类的唯一实例。
  • 公共静态方法: 返回单例类的实例,这是外部世界获取单例类实例的全局访问点。

在接下来的部分,我们将学习单例模式的不同实现方法以及实现中的设计考虑事项。

1. 饿汉式初始化(Eager Initialization)

在饿汉式初始化中,单例类的实例在类加载时即被创建。饿汉式初始化的缺点是,即使客户端应用程序可能不使用该实例,它仍然会被创建。以下是静态初始化单例类的实现方式:

package com.scdev.singleton;

public class EagerInitializedSingleton {

    private static final EagerInitializedSingleton instance = new EagerInitializedSingleton();

    // 私有构造函数,防止客户端应用程序直接使用构造函数创建实例
    private EagerInitializedSingleton(){}

    public static EagerInitializedSingleton getInstance() {
        return instance;
    }
}

如果您的单例类不占用大量资源,那么饿汉式初始化是一种可行的选择。但在大多数情况下,单例类是为文件系统、数据库连接等资源创建的。除非客户端调用getInstance方法,我们应尽量避免提前实例化。此外,此方法没有提供任何异常处理选项。

2. 静态块初始化(Static Block Initialization)

静态块初始化饿汉式初始化类似,不同之处在于类的实例是在静态块中创建的,这提供了处理异常的选项。

package com.scdev.singleton;

public class StaticBlockSingleton {

    private static StaticBlockSingleton instance;

    private StaticBlockSingleton(){}

    // 静态块初始化,用于异常处理
    static {
        try {
            instance = new StaticBlockSingleton();
        } catch (Exception e) {
            throw new RuntimeException("创建单例实例时发生异常");
        }
    }

    public static StaticBlockSingleton getInstance() {
        return instance;
    }
}

无论是饿汉式初始化还是静态块初始化,它们都会在类加载时就创建实例,即使在实例被使用之前。这通常不是最佳实践,因为它可能导致不必要的资源占用。

3. 懒汉式初始化(延迟加载)

懒汉式初始化(或称延迟加载)是一种实现单例模式的方法,它在全局访问方法被首次调用时才创建实例。以下是使用这种方法创建单例类的示例代码:

package com.scdev.singleton;

public class LazyInitializedSingleton {

    private static LazyInitializedSingleton instance;

    private LazyInitializedSingleton(){}

    public static LazyInitializedSingleton getInstance() {
        if (instance == null) {
            instance = new LazyInitializedSingleton();
        }
        return instance;
    }
}

在单线程环境下,上述实现是有效的。然而,当涉及到多线程系统时,如果多个线程同时进入 if (instance == null) 条件块,就会出现问题。这将破坏单例模式,导致两个线程获取到不同的单例类实例。在接下来的章节中,我们将探讨创建线程安全单例类的不同方法。

4. 线程安全的单例模式

3. 线程安全的单例模式

创建线程安全的单例类,一种简单直接的方法是将全局访问方法(即getInstance()方法)设为同步(synchronized)方法。这样可以确保在任何给定时刻,只有一个线程能够执行该方法,从而避免了多个线程同时创建实例的问题。以下是这种方法的典型实现:

package com.scdev.singleton;

public class ThreadSafeSingleton {

    private static ThreadSafeSingleton instance;

    private ThreadSafeSingleton(){}

    public static synchronized ThreadSafeSingleton getInstance() {
        if (instance == null) {
            instance = new ThreadSafeSingleton();
        }
        return instance;
    }

}

上述实现虽然有效地保证了线程安全,但由于synchronized关键字带来的性能开销,它在并发量大时可能会降低程序的执行效率。这种开销是持续存在的,即使在单例实例已经创建之后,每次调用getInstance()方法都会进行同步。然而,我们实际上只需要在单例实例首次创建时才需要同步。为了解决这个问题,并避免不必要的性能损耗,我们可以采用双重检查锁定(Double-Checked Locking)原则。

双重检查锁定通过在同步块内部再次进行实例是否为空的检查,确保只有在实例尚未创建时才进入同步块,并且在同步块内部再次检查以防止重复创建。这样可以显著减少同步的开销,提高性能。以下代码片段展示了双重检查锁定的实现:

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

继续学习线程安全的单例模式的其他实现方式。

4. 饿汉式单例模式

5. 静态内部类单例模式(Bill Pugh Singleton Implementation)

在Java 5之前,Java内存模型存在诸多问题,并且先前的单例实现方法在多线程并发获取单例实例时可能会出现故障。因此,Bill Pugh提出了一种不同的单例模式实现方式,即利用内部静态辅助类。以下是Bill Pugh单例模式的实现示例:

package com.scdev.singleton;

public class BillPughSingleton {

    private BillPughSingleton(){}

    private static class SingletonHelper {
        private static final BillPughSingleton INSTANCE = new BillPughSingleton();
    }

    public static BillPughSingleton getInstance() {
        return SingletonHelper.INSTANCE;
    }
}

请注意代码中私有的内部静态类SingletonHelper,它包含了单例类的实例。当BillPughSingleton类被加载时,SingletonHelper类并不会立即加载到内存中。只有当首次调用getInstance()方法时,SingletonHelper类才会被加载,并创建BillPughSingleton的实例。这种方法是目前最常用的单例模式实现方式,因为它无需同步,从而避免了性能开销。

6. 使用反射来破坏单例模式

反射可以用来摧毁所有以往的单例实现方法。以下是一个示例类:

package com.scdev.singleton;

import java.lang.reflect.Constructor;

public class ReflectionSingletonTest {

    public static void main(String[] args) {
        EagerInitializedSingleton instanceOne = EagerInitializedSingleton.getInstance();
        EagerInitializedSingleton instanceTwo = null;
        try {
            Constructor[] constructors = EagerInitializedSingleton.class.getDeclaredConstructors();
            for (Constructor constructor : constructors) {
                // This code will destroy the singleton pattern
                constructor.setAccessible(true);
                instanceTwo = (EagerInitializedSingleton) constructor.newInstance();
                break;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(instanceOne.hashCode());
        System.out.println(instanceTwo.hashCode());
    }

}

当你运行上述测试类时,你会注意到两个实例的哈希码不相同,这破坏了单例模式。反射非常强大,并且在很多框架(如Spring和Hibernate)中被广泛使用。通过Java反射教程继续学习。

7. 枚举单例
为了解决反射可能导致的单例破坏问题,Joshua Bloch 建议使用枚举(Enum)来实现单例设计模式。这是因为 Java 语言本身确保了在任何 Java 程序中,枚举值都只会被实例化一次。由于 Java 枚举值是全局可访问的,因此基于枚举实现的单例模式也具有全局可访问性。然而,这种方法的一个缺点是枚举类型在某些方面不够灵活,例如,它不支持延迟初始化(Lazy Initialization)。

package com.scdev.singleton;

public enum EnumSingleton {

    INSTANCE;

    public static void doSomething() {
        // 执行一些操作
    }
}

8. 序列化与单例模式

在分布式系统中,有时候我们需要在单例类中实现Serializable接口,以便将其状态存储在文件系统中,并在以后的时间点上检索。下面是一个实现了Serializable接口的小型单例类。

package com.scdev.singleton;

import java.io.Serializable;

public class SerializedSingleton implements Serializable {

    private static final long serialVersionUID = -7604766932017737115L;

    private SerializedSingleton(){}

    private static class SingletonHelper {
        private static final SerializedSingleton instance = new SerializedSingleton();
    }

    public static SerializedSingleton getInstance() {
        return SingletonHelper.instance;
    }

}

序列化单例类的问题在于,每当我们对其进行反序列化时,它就会创建一个新的类实例。下面是一个示例:

package com.scdev.singleton;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;

public class SingletonSerializedTest {

    public static void main(String[] args) throws FileNotFoundException, IOException, ClassNotFoundException {
        SerializedSingleton instanceOne = SerializedSingleton.getInstance();
        ObjectOutput out = new ObjectOutputStream(new FileOutputStream(
                "filename.ser"));
        out.writeObject(instanceOne);
        out.close();

        // deserialize from file to object
        ObjectInput in = new ObjectInputStream(new FileInputStream(
                "filename.ser"));
        SerializedSingleton instanceTwo = (SerializedSingleton) in.readObject();
        in.close();

        System.out.println("instanceOne hashCode="+instanceOne.hashCode());
        System.out.println("instanceTwo hashCode="+instanceTwo.hashCode());

    }

}

那段代码会产生这个输出结果。

Output

instanceOne 的哈希码为 2011117821,instanceTwo 的哈希码为 109647522。

因此,这破坏了单例模式的唯一性。为了解决这个问题,我们只需要提供 readResolve() 方法的实现。

protected Object readResolve() {
    return getInstance();
}

在此之后,您会注意到测试程序中两个实例的哈希码是相同的,从而确保了单例的唯一性。

建议阅读更多关于 Java 序列化Java 反序列化 的内容。

结论

本文详细介绍了 Java 单例设计模式的最佳实践和示例。

如需继续学习,请参考更多 Java 教程

bannerAds