单例模式(Singleton):饿汉式与懒汉式实现,线程安全分析

单例模式,说白了就是一个类只能有一个实例。你想想看,有些资源天生就该是唯一的——比如配置文件管理器、日志系统、线程池。如果搞出多个实例,轻则资源浪费,重则数据错乱。

我刚开始写C++那会儿,觉得单例模式太简单了,不就是把构造函数藏起来嘛。直到有一次线上服务莫名其妙地崩溃,排查了三天才发现是单例的线程安全问题。嗯,从那以后我再也不敢小看这个模式了。

饿汉式:简单粗暴,但有点浪费

饿汉式的思路很简单:程序启动时就创建好实例。就像你饿得不行,饭还没上桌就先啃馒头。

class Singleton {
public:
    static Singleton* getInstance() {
        return &instance_;
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static Singleton instance_;
};

// 在cpp文件中定义
Singleton Singleton::instance_;

这个实现有什么好处?线程安全。因为实例在main函数执行前就初始化好了,这时候还是单线程环境,根本不存在竞争条件。

但缺点也很明显:启动慢。如果这个单例初始化很重(比如要加载配置文件、建立数据库连接),那程序启动就会卡住。而且,如果程序从头到尾都没用过这个单例,那资源就白白浪费了。

注意:饿汉式在C++中有一个坑——静态初始化顺序问题。如果两个单例互相依赖,它们的初始化顺序是不确定的,可能导致未定义行为。我在项目中遇到过这种问题,后来改用懒汉式才解决。

懒汉式:需要时才创建

懒汉式就聪明多了:只有第一次调用getInstance时才创建实例。就像你饿到不行了才去找吃的。

class Singleton {
public:
    static Singleton* getInstance() {
        if (instance_ == nullptr) {
            instance_ = new Singleton();
        }
        return instance_;
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static Singleton* instance_;
};

Singleton* Singleton::instance_ = nullptr;

这个版本有个致命问题——线程不安全。两个线程同时调用getInstance,可能都发现instance_是nullptr,然后各自new了一个对象。结果呢?一个线程用A实例,另一个线程用B实例,单例就不"单"了。

核心问题:懒汉式需要解决两个问题——1) 保证只创建一个实例;2) 保证获取实例的操作是原子的。

线程安全方案一:加锁

最简单的办法就是加锁。每次调用getInstance都上锁,保证只有一个线程能进入。

#include <mutex>

class Singleton {
public:
    static Singleton* getInstance() {
        std::lock_guard<std::mutex> lock(mutex_);
        if (instance_ == nullptr) {
            instance_ = new Singleton();
        }
        return instance_;
    }
private:
    static std::mutex mutex_;
    static Singleton* instance_;
};

这个方案能保证线程安全,但性能很差。每次调用都要加锁解锁,即使实例已经创建好了。在高并发场景下,这个锁会成为性能瓶颈。

线程安全方案二:双检锁(DCLP)

双检锁的思路是:先检查一次,如果实例已经存在就直接返回,不用加锁。只有实例不存在时才加锁创建。这样大部分调用都不需要锁,性能好很多。

#include <mutex>

class Singleton {
public:
    static Singleton* getInstance() {
        if (instance_ == nullptr) {          // 第一次检查
            std::lock_guard<std::mutex> lock(mutex_);
            if (instance_ == nullptr) {      // 第二次检查
                instance_ = new Singleton();
            }
        }
        return instance_;
    }
private:
    static std::mutex mutex_;
    static Singleton* instance_;
};

等等,你以为这样就完美了?没那么简单。这里有个大坑——内存屏障(Memory Barrier)问题。

为什么会这样?因为CPU和编译器会重排指令。new Singleton()这行代码,实际上做了三件事:

  1. 分配内存
  2. 调用构造函数
  3. 将地址赋值给instance_

如果CPU把第3步重排到第2步之前,那另一个线程在第一次检查时发现instance_不为null,就直接返回了。但这时候对象还没构造完成!访问这个半成品对象,后果就是崩溃。

我曾经踩过这个坑:在一个高并发服务器中用了双检锁,测试环境跑得好好的,上线后偶尔崩溃。查了整整两天,最后发现是内存屏障的问题。从那以后,我再也不敢手写双检锁了。

解决方案:C++11 magic static

C++11引入了一个神器——magic static。它保证:函数内部的静态变量初始化是线程安全的,而且只初始化一次。

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance_;
        return instance_;
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

这个实现有多优雅?零行锁代码,线程安全,延迟初始化,性能最优。C++标准保证,当第一个线程进入getInstance时,其他线程会等待初始化完成。而且编译器会处理好内存屏障的问题。

我的建议:在C++11及以后的版本中,直接用magic static实现单例。这是最安全、最简洁、性能最好的方式。除非你还在用C++03,否则别折腾双检锁了。

各种实现对比

实现方式 线程安全 延迟初始化 性能 推荐度
饿汉式 ⭐⭐⭐
懒汉式(不加锁)
懒汉式(加锁) ⭐⭐
双检锁(DCLP) 需内存屏障 ⭐⭐
magic static ⭐⭐⭐⭐⭐

知识体系图

单例模式实现方案 单例模式 饿汉式 懒汉式 magic static 静态初始化 线程安全(单线程初始化) 启动慢,可能浪费资源 延迟初始化 需处理线程安全 双检锁需内存屏障 C++11标准保证 线程安全 + 延迟初始化 零锁代码,性能最优 推荐:C++11+ 使用 magic static

避坑指南

我曾经踩过的坑,分享给你:

  • 不要手写双检锁——除非你精通内存模型,否则大概率会写错。用magic static它不香吗?
  • 注意单例的析构顺序——如果单例A依赖单例B,而B先被析构了,A访问B就会崩溃。可以用智能指针管理生命周期。
  • 单例不是银弹——滥用单例会变成全局变量,导致代码耦合严重。我见过一个项目用了20多个单例,改一个功能要改十几个文件。
  • 测试友好性——单例很难mock,不利于单元测试。如果可能,用依赖注入替代单例。

嗯,单例模式就讲到这里。记住一句话:能用magic static就别折腾别的。C++11已经帮我们解决了所有痛点,何必自己造轮子呢?


公众号:蓝海资料掘金营,微信deep3321