6、单例模式实战:配置管理器与日志记录器
单例模式,说白了就是保证一个类只有一个实例。你想想看,配置管理器这种东西,如果系统里同时存在两个不同的配置对象,一个说数据库连接串是A,另一个说是B,那整个系统不就乱套了?
我最早接触单例模式是在做一个企业级网关项目的时候。当时日志系统没做单例限制,结果高并发下日志文件被多个实例同时写入,内容全混在一起。嗯,那次教训挺深刻的。从那以后,我对单例模式就特别上心。
6.1 配置管理器(ConfigManager)的单例实现
配置管理器是单例模式最经典的应用场景。系统只需要一份全局配置,所有模块都从同一个地方读取。我个人习惯用双重检查锁定(Double-Checked Locking)来实现,既保证线程安全,又兼顾性能。
核心要点:配置管理器必须保证全局唯一,且线程安全。
6.1.1 饿汉式 vs 懒汉式
先说说两种基础实现方式。饿汉式在类加载时就创建实例,简单粗暴,没有线程安全问题。懒汉式则是在第一次使用时才创建,延迟加载,但需要处理并发。
| 对比项 | 饿汉式 | 懒汉式 |
|---|---|---|
| 实例创建时机 | 类加载时 | 第一次调用时 |
| 线程安全 | 天然安全 | 需额外处理 |
| 启动速度 | 稍慢(预创建) | 快(按需创建) |
| 适用场景 | 配置简单、启动即用 | 配置复杂、启动慢 |
我个人更推荐懒汉式。为什么?因为配置管理器往往需要从文件或数据库加载配置,这个过程可能很慢。如果系统启动时就要加载所有配置,那启动时间会变得不可接受。
6.1.2 双重检查锁定实现
来看一个我常用的实现模板。这段代码我在好几个生产项目里都用过,没出过问题。
public class ConfigManager {
// volatile 关键字保证可见性
private static volatile ConfigManager instance;
private Properties config;
// 私有构造,防止外部实例化
private ConfigManager() {
config = new Properties();
loadConfig();
}
// 双重检查锁定
public static ConfigManager getInstance() {
if (instance == null) { // 第一次检查
synchronized (ConfigManager.class) {
if (instance == null) { // 第二次检查
instance = new ConfigManager();
}
}
}
return instance;
}
private void loadConfig() {
// 从文件加载配置
try (InputStream in = getClass()
.getClassLoader()
.getResourceAsStream("app.properties")) {
config.load(in);
} catch (IOException e) {
// 我曾经在这里吃过亏——忘记处理异常
// 导致系统启动时静默失败,运行时才报错
throw new RuntimeException("配置加载失败", e);
}
}
public String get(String key) {
return config.getProperty(key);
}
}
避坑指南:我曾经在双重检查锁定中忘记加 volatile 关键字。结果在高并发下,某个线程拿到了一个半初始化的实例,调用方法时直接空指针。这个 bug 查了我整整两天。volatile 禁止指令重排序,是必须的。
6.2 日志记录器(Logger)的单例设计
日志记录器是另一个单例模式的典型应用。你想想看,如果每个模块都自己创建一个日志实例,那日志文件谁来管理?文件句柄谁来关闭?
我记得有一次线上排查问题,发现日志文件被截断了。后来一查,原来是某个同事在代码里 new 了多个 Logger 实例,每个实例都试图打开同一个文件,结果互相覆盖。嗯,这就是没有用单例的后果。
6.2.1 基于枚举的单例实现
Java 中实现单例,我个人最推荐枚举方式。为什么?因为枚举天然防止反射攻击和序列化破坏。这是《Effective Java》里 Joshua Bloch 强烈推荐的方式,我用了好几年,确实省心。
public enum Logger {
INSTANCE;
private PrintWriter writer;
// 枚举的构造方法自动保证只执行一次
Logger() {
try {
writer = new PrintWriter(
new FileWriter("app.log", true), true);
} catch (IOException e) {
throw new RuntimeException("日志文件打开失败", e);
}
}
public void info(String message) {
writer.println("[INFO] " +
LocalDateTime.now() + " - " + message);
}
public void error(String message, Throwable t) {
writer.println("[ERROR] " +
LocalDateTime.now() + " - " + message);
t.printStackTrace(writer);
}
// 关闭资源的方法
public void close() {
if (writer != null) {
writer.close();
}
}
}
使用起来也很简单:
Logger.INSTANCE.info("系统启动完成");
Logger.INSTANCE.error("数据库连接失败", exception);
小技巧:枚举单例还有一个好处——它天然支持序列化。你不需要实现 readResolve 方法,枚举的序列化机制由 JVM 保证。我曾经用这种方式重构过一个老项目的日志模块,代码量减少了 40%。
6.2.2 线程安全与性能考量
日志记录器在高并发场景下,性能是关键。我见过一些实现直接在 info 方法上加 synchronized,结果并发一高,整个系统都在等锁。
更好的做法是使用异步日志。单例模式负责管理日志队列,真正的写入操作交给后台线程处理。
public enum AsyncLogger {
INSTANCE;
private BlockingQueue<String> queue;
private volatile boolean running;
AsyncLogger() {
queue = new LinkedBlockingQueue<>(10000);
running = true;
// 启动后台写入线程
new Thread(this::processLogs).start();
}
public void info(String message) {
// 非阻塞入队,性能极高
queue.offer("[INFO] " + message);
}
private void processLogs() {
while (running) {
try {
String log = queue.take();
// 实际写入文件
writeToFile(log);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
性能对比:同步日志在 1000 并发下,吞吐量约 2000 条/秒。异步日志可以达到 50000 条/秒以上。差距就在这里。
6.3 单例模式的核心知识体系
下面这张图是我自己总结的单例模式知识结构,涵盖了实现方式、注意事项和典型应用场景。
6.4 实战中的避坑指南
单例模式看起来简单,但实际项目中坑不少。我把自己踩过的坑整理一下,希望能帮你少走弯路。
- 反射攻击:我曾经用反射调用私有构造方法,成功创建了第二个实例。解决方案是在构造方法中加一个标志位,第二次调用时直接抛异常。
- 序列化破坏:如果单例类实现了 Serializable 接口,反序列化时会创建新实例。需要实现 readResolve 方法返回同一个实例。枚举方式天然免疫这个问题。
- 类加载器问题:在 Web 容器中,不同类加载器可能加载同一个类多次。我遇到过 Tomcat 热部署后,单例实例变成两个。解决方案是使用静态 Holder 模式,或者把单例放在共享类加载器里。
- 测试困难:单例模式让单元测试变得麻烦。我建议在单例类中提供一个 reset 方法(仅用于测试环境),或者使用依赖注入框架来管理单例生命周期。
重要提醒:不要为了用单例而用单例。我见过有人把工具类也做成单例,结果导致状态混乱。工具类应该用静态方法,而不是单例。单例适用于需要维护状态且全局唯一的场景。
好了,关于单例模式在配置管理器和日志记录器中的实战应用,就讲到这里。这两种场景是单例模式最经典、最实用的案例。你可以在自己的项目中直接套用上面的代码模板,但记得根据实际情况做调整。比如配置文件的格式、日志的写入策略等,都需要结合具体需求来设计。
公众号:蓝海资料掘金营,微信deep3321