分类: C++

  • class 什么时候会触发内存对齐?

    在 C++ 中,类(class)的内存对齐会在以下情况下触发:

    1. 类的成员变量类型具有对齐要求
    2. 类中包含继承关系时,基类与派生类的对齐可能受影响
    3. 内存对齐受架构(如 32 位、64 位)和编译器的实现策略影响

    下面详细解析 什么时候会触发内存对齐 以及 内存对齐的规则


    内存对齐触发的条件

    1. 类的成员变量类型的对齐要求

    每种数据类型都有其对齐要求(alignment requirements),编译器会将类的成员变量按照其对齐要求进行排列,必要时填充字节(padding)以满足对齐规则。
    对齐的关键点:

    • 数据类型的对齐要求是由其大小决定的,例如:
      • char 通常为 1 字节对齐。
      • int 通常为 4 字节对齐。
      • double 通常为 8 字节对齐(在 64 位系统上)。
    • 成员变量的起始地址必须是其对齐要求的倍数。

    例子:

    #include <iostream>
    #include <cstddef>
    class Example {
    char a; // 1 字节
    int b; // 4 字节
    char c; // 1 字节
    };

    int main() {
    std::cout << “Size of Example: “ << sizeof(Example) << std::endl;
    return 0;
    }

    分析:

    1. char a 占 1 字节。
    2. 接下来的 int b 需要对齐到 4 字节,因此在 char a 后插入 3 字节的填充。
    3. char c 占 1 字节,但类的大小最终需要满足最严格的成员对齐(int 的对齐是 4 字节),因此添加填充到总大小为 12 字节。

    输出结果:

    Size of Example: 12

    2. 基类和派生类的对齐

    当类有继承关系时,基类的对齐要求会影响派生类的内存布局,通常派生类的起始地址需要满足基类的对齐要求。

    例子:

    #include <iostream>
    #include <cstddef>
    class Base {
    double d; // 8 字节对齐
    };

    class Derived : public Base {
    char c; // 1 字节
    };

    int main() {
    std::cout << “Size of Base: “ << sizeof(Base) << std::endl;
    std::cout << “Size of Derived: “ << sizeof(Derived) << std::endl;
    return 0;
    }

    分析:

    1. Base 只有一个 double,占 8 字节。
    2. Derived 需要满足 Base 的 8 字节对齐,因此 char c 后会填充 7 字节,保证整个 Derived 的大小是 Base 的倍数。

    输出结果:

    Size of Base: 8
    Size of Derived: 16

    3. 虚函数表指针(vptr)的对齐

    如果类中包含虚函数,编译器会为每个对象增加一个虚函数表指针(vptr),这个指针通常遵循指针类型的对齐要求(通常是 4 字节或 8 字节)。

    例子:

    #include <iostream>

    class Base {
    virtual void func() {}
    };

    class Derived : public Base {
    char c; // 1 字节
    };

    int main() {
    std::cout << “Size of Base: “ << sizeof(Base) << std::endl;
    std::cout << “Size of Derived: “ << sizeof(Derived) << std::endl;
    return 0;
    }

    分析:

    1. Base 中有虚函数,因此包含一个 vptr 指针(64 位系统上占 8 字节)。
    2. Derived 继承了虚函数表指针,同时增加了 char c,因此最终大小需要对齐到 8 字节。

    输出结果:

    Size of Base: 8
    Size of Derived: 16

    4. 对齐受类对齐属性(alignment specifier)影响

    C++11 提供了 alignas 关键字,可以显式设置类或变量的对齐要求。

    例子:

    #include <iostream>
    #include <cstddef>
    struct alignas(16) Aligned {
    char c;
    };

    int main() {
    std::cout << “Size of Aligned: “ << sizeof(Aligned) << std::endl;
    std::cout << “Alignment of Aligned: “ << alignof(Aligned) << std::endl;
    return 0;
    }

    输出结果:

    Size of Aligned: 16
    Alignment of Aligned: 16

    5. 编译器和目标平台对齐策略

    • 自然对齐(natural alignment):
      大多数编译器会默认对齐到数据类型的大小倍数(如 int 为 4 字节对齐)。
    • 强制对齐(forced alignment):
      某些平台可能强制对齐到更大的倍数(如 64 位系统可能对齐到 8 字节)。
    • 数据结构的整体对齐:
      类的大小会向最严格对齐要求对齐。

    如何查看类的内存布局

    1. 使用 sizeof 查看类的大小
      std::cout << sizeof(ClassName) << std::endl;
    2. 使用 offsetof 查看成员偏移
      std::cout << offsetof(ClassName, memberName) << std::endl;
    3. 使用调试工具(如 GDB)查看布局

    总结

    • 类的内存对齐会在以下场景触发:
      1. 成员变量的类型有对齐要求。
      2. 类中包含继承关系。
      3. 类有虚函数表指针。
      4. 显式使用 alignas 设置对齐。
    • 影响内存对齐的因素:
      1. 成员变量的类型。
      2. 编译器实现和目标平台。
      3. 类的继承关系和虚函数。
    • 优化建议:
      • 将对齐要求较高的成员变量放在类的开头,减少填充字节。
  • 小字符串优化 SSO

    什么是小字符串优化(SSO, Small String Optimization)

    小字符串优化(SSO) 是现代 C++ 标准库对 std::string 的一种优化技术,用于减少短字符串的内存分配开销。SSO 的核心思想是:将长度较短的字符串直接存储在 std::string 对象内部,而不使用动态内存分配


    SSO 的工作原理

    1. 普通情况下的字符串存储
      • 如果没有 SSO,std::string 会将字符串内容存储在堆内存中。
      • 这意味着每次分配和释放字符串时都会调用动态内存分配函数(如 newdelete 或类似机制),这会增加性能开销。
    2. 启用 SSO 时的行为
      • 对于短字符串(如 15 个字符以内的字符串),std::string 的实现会直接将字符串内容存储在对象的内部缓冲区中,而不是使用动态分配。
      • 这样,短字符串的创建、修改和销毁变得更加高效,因为避免了堆操作。
    3. 大字符串的行为
      • 如果字符串长度超过了 std::string 对象的内部缓冲区容量,就会回退到动态分配模式,将字符串存储在堆上。

    SSO 的实现细节

    对象结构

    典型的 std::string 在启用 SSO 时,其内部结构可能如下:

    1. 内部缓冲区
      • 存储短字符串的数据。
      • 通常为固定大小(如 15 或 23 字节,根据具体的实现和对齐规则)。
    2. 元数据
      • 包括字符串的当前长度(size)和缓冲区的容量(capacity)。
      • 使用额外的标志位(如高位或容量字段)来区分短字符串和动态分配的字符串。
    3. 动态缓冲区指针
      • 对于长字符串,指向堆上的实际字符串存储位置。

    示意图

    以下展示了一个可能的 SSO 内部结构:

    字段 类型 说明
    长度(size) size_t 当前字符串的长度
    缓冲区(data) char[16] 存储短字符串或堆缓冲区地址的空间
    容量(capacity/flag) size_t 缓冲区的容量或标志位
    • 短字符串:data 字段存储实际内容,长度受限于固定的内部缓冲区大小。
    • 长字符串:data 字段存储指向堆分配内存的指针。

    SSO 的典型实现

    启用 SSO 的示例代码

    以下代码展示了如何验证 SSO 是否生效:

    cpp
    #include <iostream>
    #include <string>
    #include <cstring>
    int main() {
    // 测试短字符串
    std::string shortStr = “Short”;
    std::cout << “Short string: “ << shortStr << std::endl;
    std::cout << “Address of short string content: “
    << static_cast<const void*>(shortStr.data()) << std::endl;// 测试长字符串
    std::string longStr = “This is a very long string that exceeds the SSO limit.”;
    std::cout << “Long string: “ << longStr << std::endl;
    std::cout << “Address of long string content: “
    << static_cast<const void*>(longStr.data()) << std::endl;

    // 验证短字符串与长字符串的地址是否一致
    if (shortStr.data() >= reinterpret_cast<const char*>(&shortStr) &&
    shortStr.data() < reinterpret_cast<const char*>(&shortStr + 1)) {
    std::cout << “SSO enabled for short strings.” << std::endl;
    } else {
    std::cout << “Short strings are dynamically allocated.” << std::endl;
    }

    return 0;
    }

    输出示例

    假设 SSO 已启用,输出可能类似如下:

    text
    Short string: Short
    Address of short string content: 0x7ffee4d1c760
    Long string: This is a very long string that exceeds the SSO limit.
    Address of long string content: 0x5634c8d1fba0
    SSO enabled for short strings.
    • 短字符串的地址位于对象内部。
    • 长字符串的地址位于堆上。

    SSO 的优点

    1. 性能提升
      • 避免动态内存分配和释放的小字符串操作非常高效。
      • 小字符串的创建、拷贝和销毁操作显著加速。
    2. 节省内存
      • 小字符串不需要额外的堆分配,减少了内存分配的碎片化。
    3. 降低堆压力
      • 减少堆分配的次数,适合小型频繁操作的应用场景。

    SSO 的限制

    1. 仅限短字符串
      • 一旦字符串长度超过了内部缓冲区容量,就需要回退到动态分配。
    2. 实现依赖
      • SSO 是标准库的实现细节,具体行为和优化取决于使用的 C++ 标准库(如 GCC 的 libstdc++ 或 Clang 的 libc++)。
    3. 对象大小
      • 启用 SSO 会导致 std::string 对象本身变得更大(通常是 24 字节或 32 字节),但这对性能提升是值得的。

    SSO 的验证方式

    如果你希望验证某个 std::string 是否使用了 SSO,可以观察 data() 返回的地址是否与 std::string 对象的内部地址重叠。例如:

    cpp
    if (str.data() >= reinterpret_cast<const char*>(&str) &&
    str.data() < reinterpret_cast<const char*>(&str + 1)) {
    std::cout << "SSO is active." << std::endl;
    } else {
    std::cout << "SSO is not active." << std::endl;
    }

    总结

    • 什么是 SSO:短字符串直接存储在 std::string 对象内部,避免堆分配。
    • 如何判断是否启用 SSO:检查短字符串内容的地址是否与 std::string 对象重叠。
    • 优点:提升小字符串操作性能,减少堆内存开销。
    • 注意事项:仅对短字符串有效,长字符串仍然会动态分配。
  • C++ STL chrono和monotonic_time_get_milliseconds的性能对比

    1. 核心区别

    1. monotonic_time_get_milliseconds
      • 通常是一个自定义或平台相关的函数。
      • 如果底层实现依赖于 clock_gettime(CLOCK_MONOTONIC)(Linux/Unix)或类似的 API,其性能会与平台的系统调用开销直接相关。
      • 提供时间戳通常以毫秒为单位,可能会涉及额外的转换(如从纳秒到毫秒)。
    2. std::chrono::steady_clock::now()
      • C++ 标准库的一部分,基于平台的单调时钟实现。
      • 在大多数平台上,std::chrono::steady_clock 通常包装了底层的单调时钟 API:
        • Linux/Unix:clock_gettime(CLOCK_MONOTONIC)
        • Windows:QueryPerformanceCounter
        • macOS:mach_absolute_time
      • 提供的时间单位是 std::chrono::time_point,用户可以选择需要的时间精度。

    2. 性能比较

    以下是两者性能的主要比较点:

    指标 monotonic_time_get_milliseconds std::chrono::steady_clock::now()
    调用开销 依赖于实现,通常接近底层API性能 包装底层API,额外开销极小
    时间精度 毫秒(可能固定) 可提供纳秒或其他精度
    可移植性 可能受限于实现 高,跨平台一致
    额外转换开销 可能需从纳秒到毫秒转换 无需手动转换,按需选择单位
    典型性能(Linux) 20-60 纳秒(如果基于 clock_gettime 50-100 纳秒(轻微额外开销)

    3. 具体比较场景

    1. 低级性能比较
      • 如果 monotonic_time_get_milliseconds 是直接封装了 clock_gettime(CLOCK_MONOTONIC) 或类似的函数,其性能可能稍微优于 std::chrono::steady_clock::now(),因为后者会经过轻量的标准库封装。
      • 对于高频调用场景(如游戏引擎或实时系统),这种微小的开销可能会累积,但差异仍然非常小(一般不超过几十纳秒)。
    2. 灵活性与可用性
      • std::chrono::steady_clock 提供了更多灵活性,支持多种时间精度(纳秒、微秒、毫秒等),同时是标准库的一部分,具备更高的可移植性。
      • 如果只需要毫秒精度,并且性能至关重要,monotonic_time_get_milliseconds 的简单实现可能会更快。
    3. 代码维护与可靠性
      • 使用 std::chrono::steady_clock 可以减少对特定平台 API 的依赖,提高代码的可维护性和跨平台性。
      • 自定义的 monotonic_time_get_milliseconds 可能需要额外考虑平台兼容性和潜在的错误。

    4. 实际性能测试

    在实践中,性能差异可以通过微基准测试来评估。例如,下面是一个简单的 C++ 性能测试代码:

    #include
    #include
    #include
    // Example monotonic_time_get_milliseconds implementation
    long long monotonic_time_get_milliseconds() {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
    }

    int main() {
    constexpr int iterations = 1000000;

    // Test monotonic_time_get_milliseconds
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) {
    volatile auto time = monotonic_time_get_milliseconds();
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << “monotonic_time_get_milliseconds: ”
    << std::chrono::duration_cast(end – start).count()
    << ” microseconds\n”;

    // Test std::chrono::steady_clock::now
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; ++i) {
    volatile auto time = std::chrono::steady_clock::now();
    }
    end = std::chrono::high_resolution_clock::now();
    std::cout << “std::chrono::steady_clock::now: ”
    << std::chrono::duration_cast(end – start).count()
    << ” microseconds\n”;

    return 0;
    }

    输出:
    monotonic_time_get_milliseconds: 21185 microseconds
    std::chrono::steady_clock::now: 21424 microseconds


    5. 总结

    • 性能
      • 如果 monotonic_time_get_milliseconds 是直接调用底层 API,它的性能可能略高,但差距通常很小。
      • 对于绝大多数应用场景,std::chrono::steady_clock::now() 的额外开销可以忽略。
    • 推荐
      • 如果需要高性能计时,并且代码需跨平台,使用 std::chrono::steady_clock::now() 是更好的选择。
      • 如果只关注毫秒级别性能,且代码限定在特定平台上,monotonic_time_get_milliseconds 可能会有微小优势。