💻CS 设计模式

单例模式

难度:⭐ | 高频指数:🔥🔥🔥

面试回答

常见问法

  • 单例模式怎么实现?懒汉和饿汉有什么区别?
  • 怎么实现线程安全的单例?
  • C++11 的 static 局部变量为什么是线程安全的?
  • 双重检查锁是什么?有什么问题?
  • 单例模式为什么要禁止拷贝和赋值?
  • 单例模式有什么缺点?

回答

单例模式保证一个类只有一个实例,并提供全局访问点。

两种经典实现:

  • 饿汉式:程序启动时就创建实例,简单但可能浪费资源
  • 懒汉式:第一次使用时才创建实例,需要考虑线程安全

C++11 推荐写法(Meyers’ Singleton):

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;  // C++11 保证线程安全
        return instance;
    }

    // 禁止拷贝和赋值
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

private:
    Singleton() = default;  // 私有构造函数
    ~Singleton() = default;
};

为什么这是最佳写法:

  1. C++11 标准保证 static 局部变量的初始化是线程安全的
  2. 懒加载(第一次调用时才构造)
  3. 代码简洁,不需要手动管理锁
  4. 禁止拷贝和赋值防止产生多个实例

追问

1. 为什么要禁止拷贝和赋值?

如果允许拷贝,用户可以通过 Singleton s = Singleton::getInstance() 创建新对象,违反”只有一个实例”的约束。所以必须 delete 拷贝构造函数和赋值运算符。

2. 饿汉式怎么写?

class Singleton {
public:
    static Singleton& getInstance() {
        return instance;
    }
private:
    Singleton() = default;
    static Singleton instance;  // 类外定义
};
Singleton Singleton::instance;  // 程序启动时构造

饿汉式天然线程安全(在 main 之前初始化),但有静态初始化顺序问题(不同编译单元的静态变量初始化顺序不确定)。

3. 单例的缺点?

  • 全局状态,增加耦合
  • 难以单元测试(不好 mock)
  • 隐藏依赖关系
  • 生命周期不好控制(析构顺序问题)

原理展开

1. 双重检查锁(DCLP)

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

为什么需要两次检查:

  • 第一次检查:避免每次调用都加锁(性能优化)
  • 第二次检查:防止多个线程同时通过第一次检查后重复创建

DCLP 的问题(C++11 之前):

instance = new Singleton() 不是原子操作,分三步:

  1. 分配内存
  2. 构造对象
  3. 将地址赋给 instance

编译器可能重排为 1→3→2。如果线程 A 执行了 1→3,线程 B 看到 instance 非空就直接使用,但对象还没构造完成。

C++11 的修复:

static std::atomic<Singleton*> instance;

// 或者直接用 static 局部变量(推荐)

2. C++11 static 局部变量的线程安全保证

C++11 标准 §6.7:

如果控制流在变量初始化期间并发进入声明,则并发执行应等待初始化完成。

编译器实现通常是:

  • 用一个隐藏的 flag + mutex 保护初始化
  • 第一个到达的线程执行初始化
  • 其他线程等待初始化完成
// 编译器大致生成的代码(伪代码)
static Singleton& getInstance() {
    static bool initialized = false;
    static std::aligned_storage<sizeof(Singleton)> storage;
    
    if (!initialized) {  // 实际用原子操作 + 锁
        lock();
        if (!initialized) {
            new (&storage) Singleton();
            initialized = true;
        }
        unlock();
    }
    return *reinterpret_cast<Singleton*>(&storage);
}

3. 模板化单例

template <typename T>
class Singleton {
public:
    static T& getInstance() {
        static T instance;
        return instance;
    }

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

protected:
    Singleton() = default;
    ~Singleton() = default;
};

// 使用
class Logger : public Singleton<Logger> {
    friend class Singleton<Logger>;  // 允许基类访问私有构造
private:
    Logger() = default;
public:
    void log(const std::string& msg) { /* ... */ }
};

Logger::getInstance().log("hello");

4. 单例的析构问题

多个单例之间可能有析构顺序依赖。如果 Singleton A 的析构函数使用了 Singleton B,但 B 先被析构了就会出问题。解决方案:避免单例间的析构依赖、使用 atexit 注册顺序。

5. 单例的替代方案

依赖注入:

// 不用单例,通过参数传入
class Service {
public:
    Service(Logger& logger, Config& config)
        : logger_(logger), config_(config) {}
private:
    Logger& logger_;
    Config& config_;
};

// 在 main 中创建并注入
int main() {
    Logger logger;
    Config config;
    Service service(logger, config);
}

优点:

  • 依赖关系显式
  • 容易测试(可以注入 mock)
  • 生命周期由调用者控制

6. 什么时候适合用单例

适合:

  • 日志系统
  • 配置管理
  • 线程池
  • 数据库连接池
  • 硬件资源管理(如打印机)

不适合:

  • 业务逻辑对象
  • 需要多实例的场景
  • 需要频繁单元测试的组件

7. 面试中的完整回答模板

1. 先写出 C++11 推荐写法(static 局部变量)
2. 解释为什么线程安全
3. 说明为什么要 delete 拷贝和赋值
4. 提到双重检查锁及其问题(加分)
5. 说明单例的缺点和替代方案(加分)

易错点

  • 忘记禁止拷贝和赋值——这是面试必问的点。
  • 不知道 C++11 static 局部变量是线程安全的——这是推荐写法的基础。
  • 写双重检查锁时忘记 memory order 问题——C++11 之前的 DCLP 是有 bug 的。
  • 说”饿汉式没有问题”——饿汉式有静态初始化顺序问题。
  • 返回指针而不是引用——返回引用更安全,避免用户 delete。
  • 忘记说单例的缺点——只说优点不说缺点,面试官会追问。
  • 析构函数设为 private 但忘记 friend——如果需要正常析构,要注意访问权限。

记忆技巧

  • 单例三要素:私有构造 + 静态方法 + 禁止拷贝
  • C++11 最佳写法:static 局部变量,一行搞定线程安全
  • DCLP 问题:new 不是原子的,可能指令重排
  • 饿汉 vs 懒汉:饿汉启动就建,懒汉用时才建
  • 单例缺点三连:全局耦合、难测试、隐藏依赖

面试速答版

单例模式保证一个类只有一个实例。C++11 推荐用 static 局部变量实现(Meyers’ Singleton),标准保证其初始化是线程安全的。必须 delete 拷贝构造和赋值运算符防止产生多个实例。双重检查锁在 C++11 之前有指令重排问题,现在用 atomic 或直接用 static 局部变量更好。饿汉式在程序启动时创建,天然线程安全但有静态初始化顺序问题。单例的缺点是全局状态增加耦合、难以单元测试、隐藏依赖关系,可以用依赖注入替代。

Related · 设计模式