C++内存管理

Channing Hsu

一、堆和栈的区别

在C++中,内存管理是一个重要的概念,特别是在理解堆(heap)和栈(stack)之间的区别时。这两个区域都用于存储程序的数据,但它们的用途和管理方式有所不同。

1. 栈(Stack)

  • 定义:栈是程序运行时用于存储局部变量和函数调用信息的一块有限的内存区域。
  • 特点:
    • 生命周期:栈上的变量生命周期与它们所在的函数的执行周期相同。即当函数开始执行时,分配给该函数的局部变量被分配到栈上;当函数执行完毕,这些变量会自动释放。
    • 内存管理:栈内存的分配和释放是自动进行的,由编译器管理。这使得栈上的内存操作速度非常快。
    • 容量:栈的容量通常较小,因为它的内存空间是有限的(通常在几MB到几十MB之间)。
    • 优势:由于内存分配和释放是由编译器自动管理的,所以栈上的内存操作是非常高效的。
    • 缺点:栈空间有限,无法存储大量数据;局部变量只能在函数内部使用,无法在函数外部共享。

2. 堆(Heap)

  • 定义:堆是程序运行时用于动态分配内存的一块区域。堆上的内存可以在程序运行期间任何时候分配和释放。
  • 特点:
    • 生命周期:堆上的变量生命周期由程序员显式控制。程序员可以使用newmalloc来分配内存,并使用deletefree来释放内存。
    • 内存管理:堆内存的分配和释放需要手动管理,这使得内存管理变得灵活,但也增加了内存泄漏和碎片化的风险。
    • 容量:堆的容量通常较大,受限于计算机的物理内存大小。
    • 优势:堆可以存储大量数据,并且这些数据可以在程序的不同部分共享。
    • 缺点:手动管理内存可能会导致内存泄漏(忘记释放内存)和内存碎片(反复分配和释放内存导致内存不连续),内存分配和释放的速度相对较慢。

3. 具体区别

  1. 内存分配和释放方式
    • 栈:自动管理,由编译器控制。
    • 堆:手动管理,由程序员控制。
  2. 内存分配速度
    • 栈:速度快,分配和释放都是常数时间操作。
    • 堆:速度相对较慢,因为需要搜索适合的内存块。
  3. 生命周期
    • 栈:生命周期与函数调用周期相同。
    • 堆:生命周期由程序员控制,可以在函数调用之间存在。
  4. 容量
    • 栈:容量较小,通常在几MB到几十MB之间。
    • 堆:容量较大,受限于物理内存。

4. 示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

void stackExample() {
int x = 10; // 分配在栈上
std::cout << "Stack variable: " << x << std::endl;
} // x 在此处自动释放

void heapExample() {
int* p = new int(20); // 分配在堆上
std::cout << "Heap variable: " << *p << std::endl;
delete p; // 手动释放内存
}

int main() {
stackExample();
heapExample();
return 0;
}

在这个示例中,stackExample函数在栈上分配了一个整型变量x,函数结束时,x自动释放。而在heapExample函数中,使用new在堆上分配了一个整型变量,并在使用完后手动使用delete释放。

总之,理解堆和栈的区别对编写高效和可靠的C++程序至关重要。正确管理堆内存可以避免内存泄漏和碎片化,而充分利用栈可以提高程序的性能。

二、C++内存分区

C++程序运行时,内存被分为几个不同的区域,每个区域负责不同的任务。

  • :最上方的区域,动态增长和收缩,管理函数调用。
  • :紧接栈的下方区域,用于动态内存分配,大小根据需要扩展。
  • 全局/静态区:再往下是全局变量和静态变量的存储区,生命周期贯穿程序整个运行期。
  • 常量区:紧接全局/静态区的是常量数据存储区,数据只读。
  • 代码区:最下方区域,存储程序的可执行代码,只读以保护代码安全。

通过这个图示,能够清晰地看到C++程序在内存中的布局及各个区域的作用和特点。这种结构有助于程序员在开发过程中合理地进行内存管理,避免内存泄漏、栈溢出等常见问题。

image-20240620093158216

1. 栈 (Stack)

  • 位置:图示中顶部区域。
  • 用途:存储函数的局部变量、函数参数和调用信息。
  • 特点:
    • 自动管理,函数调用时分配,函数返回时释放。
    • 内存分配速度快。
    • 空间较小。

2. 堆 (Heap)

  • 位置:图示中栈的下方区域。
  • 用途:存储动态分配的内存。
  • 特点:
    • 手动管理,使用 newdeletemallocfree 进行分配和释放。
    • 内存分配和释放速度相对较慢。
    • 空间较大。

3. 全局/静态区 (Global/Static Area)

  • 位置:图示中堆的下方区域。

  • 用途:存储全局变量和静态变量。

  • 特点:

    • 生命周期为程序的整个运行期间。
    • 包括未初始化的全局变量(BSS段)和已初始化的全局变量和静态变量(数据段)。
  • 例子:

    1
    2
    int globalVariable = 10; // 存储在全局区
    static int staticVariable = 20; // 存储在全局/静态区

4. 常量区 (Read-Only Data Segment)

  • 位置:图示中全局/静态区的下方区域。

  • 用途:存储只读常量数据,例如字符串常量。

  • 特点:

    • 数据不可修改,保护数据的完整性。
  • 例子:

    1
    const char* str = "Hello, World!"; // 字符串常量存储在常量区

5. 代码区 (Code Segment)

  • 位置:图示中最底部区域。
  • 用途:存储程序的可执行代码。
  • 特点:
    • 通常是只读的,防止代码被意外修改。
    • 大小在编译时确定。

三、内存泄露

1. 什么是内存泄漏?

内存泄漏(memory leak)是指程序中动态分配的内存由于疏忽或错误未能被释放,从而导致这段内存无法被重新使用。内存泄漏不会导致内存物理上的消失,但会导致内存资源的浪费,最终可能导致系统性能下降或程序崩溃。

内存泄漏的检查工具包括 Valgrind 和 mtrace 等,它们能够帮助开发者找出程序中的内存泄漏问题。

2. 内存泄漏的分类

(1) 堆内存泄漏 (Heap leak)

堆内存泄漏指的是程序中使用 malloc, realloc, new 等函数从堆中分配内存后,未能正确使用 freedelete 释放这段内存,导致这段内存无法被重新使用。

1
2
3
4
5
void heapLeakExample() {
int* p = new int[10]; // 动态分配内存
// ...使用 p...
// 没有调用 delete[] p; 造成内存泄漏
}

(2) 系统资源泄漏 (Resource leak)

系统资源泄漏指的是程序使用系统分配的资源(如 Bitmap, handle, SOCKET 等)后,未能正确释放这些资源。

1
2
3
4
5
6
7
void resourceLeakExample() {
FILE* file = fopen("example.txt", "r");
if (file) {
// ...使用 file...
// 没有调用 fclose(file); 造成资源泄漏
}
}

(3) 基类的析构函数不是虚函数

当基类指针指向子类对象时,如果基类的析构函数不是虚函数,子类的析构函数将不会被调用,导致子类的资源无法释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
~Base() {
std::cout << "Base destructor" << std::endl;
}
};

class Derived : public Base {
public:
~Derived() {
std::cout << "Derived destructor" << std::endl;
}
};

void virtualDestructorExample() {
Base* obj = new Derived();
delete obj; // 只调用了 Base 的析构函数,未调用 Derived 的析构函数,导致内存泄漏
}

3. 什么操作会导致内存泄露?

  • 动态分配内存后未释放

  • 指针指向改变,但未释放动态分配的内存。

  • 基类析构函数未定义为虚函数,导致子类资源未正确释放。

4. 如何防止内存泄露?

  • 将内存的分配封装在类中:
    使用对象的构造函数进行内存的分配,在析构函数中释放内存,这样可以确保在对象生命周期结束时内存被正确释放。

  • 使用智能指针来自动管理内存

5. 构造函数、析构函数要设为虚函数吗,为什么?

  1. 析构函数需要设为虚函数:
    应该设为虚函数。如果基类指针指向子类对象时,基类析构函数不是虚函数,将导致子类的析构函数不会被调用,子类资源无法释放,造成内存泄漏。

  2. 构造函数不需要设为虚函数:
    构造函数的调用是在对象创建时进行的,虚函数机制在对象构造之前无法生效。

四、智能指针

智能指针(smart pointer)是C++11引入的一种用于自动管理动态内存的工具,可以有效防止内存泄漏和多次释放同一内存的问题。智能指针包括:

  • 共享指针 (std::shared_ptr):多个指针可以共享同一块内存,使用引用计数管理内存,当引用计数为0时,自动释放内存。

    • 可以复制和共享。
    • 适用于需要共享所有权的场景。
    • 可能存在循环引用问题,需要小心处理。
  • 独占指针 (std::unique_ptr):独占所有权,确保某一时刻只有一个指针指向该内存,自动释放内存。

    • 不能复制,但可以移动。
    • 适用于明确的所有权场景,避免不必要的复制。
  • 弱指针 (std::weak_ptr):配合 std::shared_ptr 使用,不影响引用计数,主要用于解决循环引用问题。

    • 不会增加引用计数。
    • 适用于需要观察资源但不拥有资源的场景。
    • 可以通过 std::weak_ptr::lock() 获取 std::shared_ptr,如果资源已经释放,则返回 nullptr

    示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    #include <iostream>
    #include <memory>

    void uniquePtrExample() {
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    std::cout << "unique_ptr: " << *ptr << std::endl;
    std::unique_ptr<int> ptr2 = std::move(ptr); // 所有权转移
    if (!ptr) {
    std::cout << "ptr is now null" << std::endl;
    }
    }

    void sharedPtrExample() {
    std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
    std::shared_ptr<int> ptr2 = ptr1; // 共享所有权
    std::cout << "shared_ptr1: " << *ptr1 << std::endl;
    std::cout << "shared_ptr2: " << *ptr2 << std::endl;
    }

    void weakPtrExample() {
    std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
    std::weak_ptr<int> weakPtr = sharedPtr; // 创建弱引用
    if (std::shared_ptr<int> ptr = weakPtr.lock()) { // 获取共享智能指针
    std::cout << "weak_ptr lock: " << *ptr << std::endl;
    } else {
    std::cout << "weak_ptr resource is released" << std::endl;
    }
    }

    int main() {
    uniquePtrExample();
    sharedPtrExample();
    weakPtrExample();
    return 0;
    }

五、new 和 malloc 的区别

1. 类型安全性

  • new

    • C++ 运算符。
    • 为对象分配内存并调用相应的构造函数。
    • 返回具体类型的指针,不需要进行类型转换。
    1
    int* p = new int(42); // 分配并初始化为42
  • malloc

    • C语言库函数。
    • 仅分配指定大小的内存块,不调用构造函数。
    • 返回 void*,需要进行类型转换。
    1
    2
    int* p = (int*)malloc(sizeof(int)); // 需要类型转换
    *p = 42; // 初始化值

2. 返回类型

  • new:返回具体类型的指针。
  • malloc:返回 void*,需要显式转换为适当的类型。

3. 内存分配失败时的行为

  • new:内存分配失败时抛出 std::bad_alloc 异常。

    1
    2
    3
    4
    5
    try {
    int* p = new int[1000000000000]; // 可能抛出std::bad_alloc
    } catch (const std::bad_alloc& e) {
    std::cerr << "Memory allocation failed: " << e.what() << std::endl;
    }
  • malloc:内存分配失败时返回 NULL

    1
    2
    3
    4
    int* p = (int*)malloc(sizeof(int) * 1000000000000);
    if (!p) {
    std::cerr << "Memory allocation failed" << std::endl;
    }

4. 内存块大小

  • new:可以用于动态分配数组,并且知道数组大小。

    1
    int* arr = new int[10]; // 分配一个大小为10的数组
  • malloc:只分配指定大小的内存块,不了解所分配内存块的具体用途。

    1
    int* arr = (int*)malloc(sizeof(int) * 10); // 分配大小为10的内存块

5. 释放内存的方式

  • delete:调用对象的析构函数,然后释放内存。

    1
    2
    delete p; // 释放内存并调用析构函数
    delete[] arr; // 释放数组并调用析构函数
  • free:仅释放内存块,不调用析构函数。

    1
    free(p); // 释放内存

六、delete 和 free 的区别

1. 类型安全性

  • delete:调用对象的析构函数,确保资源被正确释放。
  • free:不了解对象的构造和析构,只是简单地释放内存块。

2. 内存块释放后的行为

  • delete:在某些编译器实现中,释放内存块后可能会将指针值设置为 nullptr,以避免野指针。

    1
    2
    delete p; // 在一些编译器实现中,p可能会被设置为nullptr
    p = nullptr; // 手动设置为nullptr
  • free:不会修改指针的值,可能导致野指针问题。

    1
    2
    free(p);
    p = nullptr; // 手动设置为nullptr

3. 数组的释放

  • delete:可以正确释放通过 new[] 分配的数组。

    1
    delete[] arr; // 释放数组
  • free:不了解数组的大小,不适用于释放通过 new[] 分配的数组。

    1
    free(arr); // 释放数组,可能引发未定义行为

总结

  • newdelete 是 C++ 的运算符,提供类型安全、自动调用构造和析构函数、在内存分配失败时抛出异常等优点。
  • mallocfree 是 C 标准库函数,仅进行内存的分配和释放,不调用构造和析构函数,内存分配失败时返回 NULL

选择 newdeletemallocfree 取决于具体的编程需求。对于需要构造函数和析构函数的对象管理,使用 newdelete 更加合适。对于简单的内存分配,使用 mallocfree 也可以,但需要额外处理类型转换和构造析构问题。智能指针如 std::unique_ptrstd::shared_ptr 提供了更高层次的内存管理,推荐在现代 C++ 编程中使用。

七、什么是野指针,怎么产生,如何避免?

1. 野指针(Dangling Pointer)

野指针是指向已经被释放的或无效的内存地址的指针。使用野指针可能导致程序崩溃、数据损坏或其他不可预测的行为。

2. 野指针的产生情况

  1. 释放后没有置空指针

    1
    2
    3
    4
    int* ptr = new int;
    delete ptr;
    // 此时 ptr 成为野指针,因为它仍然指向已经被释放的内存
    ptr = nullptr; // 避免野指针,应该将指针置为 nullptr 或赋予新的有效地址
  2. 返回局部变量的指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int* createInt() {
    int x = 10;
    return &x; // x 是局部变量,函数结束后 x 被销毁,返回的指针成为野指针
    }
    // 在使用返回值时可能引发未定义行为
    // 避免
    int* createInt() {
    int* x = new int;
    *x = 10;
    return x;
    }
  3. 释放内存后没有调整指针

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int* ptr = new int;
    // 使用 ptr 操作内存
    delete ptr;
    // 此时 ptr 没有被置为 nullptr 或新的有效地址,成为野指针
    // 避免: delete ptr; ptr = nullptr;
    // 避免:使用智能指针
    #include <memory>
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    // 使用 std::unique_ptr,避免显式 delete,指针会在超出作用域时自动释放
  4. 函数参数指针被释放

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void foo(int* ptr) {
    // 操作 ptr
    delete ptr; // 释放内存
    // 避免
    ptr = nullptr;
    }

    int main() {
    int* ptr = new int;
    foo(ptr);
    // 在 foo 函数中 ptr 被释放,但在 main 函数中仍然可用,成为野指针
    // 避免在函数内释放调用方传递的指针,或者通过引用传递指针
    }

3. 野指针和悬浮引用的区别

(1) 野指针(Dangling Pointer)

  • 关联对象类型:指针类型。
  • 问题表现:访问已释放或无效内存,引发崩溃或数据损坏。
  • 产生原因:不正确管理指针生命周期。

(2) 悬浮引用(Dangling Reference)

  • 关联对象类型:引用类型。
  • 问题表现:访问已销毁的对象,引发未定义行为。
  • 产生原因:在函数中返回局部变量的引用。

(3) 避免悬浮引用

  1. 避免在函数中返回局部变量的引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int& getLocalReference() {
    int localVar = 42;
    return localVar; // 错误:返回局部变量的引用
    }

    void example() {
    int& ref = getLocalReference();
    // 使用 ref 会导致未定义行为,因为 localVar 已经被销毁
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int* getDynamicInt() {
    int* ptr = new int(42); // 动态分配
    return ptr;
    }

    void example() {
    int* ptr = getDynamicInt();
    // 使用 ptr
    delete ptr; // 记得释放内存
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    int getLocalValue() {
    int localVar = 42;
    return localVar; // 返回局部变量的值,而不是引用
    }

    void example() {
    int value = getLocalValue();
    // 使用 value
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include <memory>

    std::unique_ptr<int> getUniquePtr() {
    return std::make_unique<int>(42);
    }

    void example() {
    std::unique_ptr<int> ptr = getUniquePtr();
    // 使用 ptr,内存会在超出作用域时自动释放
    }
  2. 使用返回指针或智能指针,而不是引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int* createInt() {
    int* p = new int(42); // 动态分配内存
    return p; // 返回指针
    }

    void example() {
    int* ptr = createInt();
    std::cout << *ptr << std::endl; // 使用指针
    delete ptr; // 释放内存
    }

八、什么是内存对齐?

内存对齐是指数据在内存中的存储起始地址是某个特定值(通常是数据类型大小)的倍数。这是为了满足硬件和编译器的要求,以提高数据访问的效率。

在C语言和C++中,结构体是一种复合数据类型,包含多个成员变量。编译器为结构体的每个成员按其自然边界(alignment)分配空间,即按照数据类型的大小进行对齐。例如,一个4字节的int类型,其起始地址应该位于4字节的边界上,即起始地址能被4整除。

举例说明

  • 自然对齐:假设在32位CPU下,一个整型变量的地址为0x00000004(为4的倍数),那么它就是自然对齐的。如果地址为0x00000002(非4的倍数),则为非对齐。
  • 非对齐访问:如果一个int变量的地址是0x00000002(非4的倍数),CPU需要两次内存访问才能取到该值。

1. 为什么需要考虑内存对齐?

(1) 提高访问效率

  • 对齐数据:当数据按照硬件要求的对齐方式存储时,CPU可以更高效地访问内存。例如,一个对齐的32位整数可以在一个内存访问周期内读取。
  • 非对齐数据:非对齐的数据需要多次内存访问并进行拼凑,效率降低。

(2) 满足硬件要求

  • 严格对齐要求:一些硬件平台对对齐要求非常严格,如SPARC系统,访问未对齐的数据会导致错误。
  • 宽松对齐要求:在x86架构上,非对齐访问不会导致错误,但性能会下降。

(3) 提高缓存利用率

  • 缓存行:大多数计算机使用缓存行(cache line)从内存中加载数据到缓存中。如果数据是对齐的,一个缓存行可以装载更多的数据,从而提高缓存命中率

(4) 支持原子操作

  • 原子性操作:一些计算机架构要求原子性操作(如原子性读写)必须在特定的内存地址上执行。如果数据不对齐,可能无法执行原子操作,导致竞态条件。

2. 内存对齐的实现

编译器通常自动处理内存对齐,但有时需要显式指定对齐方式,可以使用编译器特定的指令或关键字。

结构体对齐示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <cstddef>

struct AlignedStruct {
char a; // 1 byte
int b; // 4 bytes, starts at offset 4 due to alignment
short c; // 2 bytes, starts at offset 8
};

int main() {
AlignedStruct s;
std::cout << "Size of struct: " << sizeof(s) << std::endl; // 12 bytes
std::cout << "Offset of a: " << offsetof(AlignedStruct, a) << std::endl;
std::cout << "Offset of b: " << offsetof(AlignedStruct, b) << std::endl;
std::cout << "Offset of c: " << offsetof(AlignedStruct, c) << std::endl;
return 0;
}

在上面的示例中,编译器会为结构体 AlignedStruct 中的成员按对齐要求分配空间,确保 int b 从4字节的地址开始,short c 从2字节的地址开始。这个结构体总大小为12字节,其中包含了必要的填充字节。

3. 总结

内存对齐是为了提高CPU访问内存的效率,并满足特定硬件的要求。对齐能确保数据按最优的方式存储,使CPU可以快速访问数据,减少因非对齐访问导致的性能损失。考虑内存对齐对编写高效和健壮的代码至关重要。

4. 测试题目

问题 1:以下为WindowsNT 32C++程序,请计算下面sizeof的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
char str[] = "hello";
char* p = str;
int n = 10;

// 计算
sizeof(str) = ?; // str是字符数组,包括末尾的 '\0',所以大小为 6
sizeof(p) = ?; // p为指针变量,在32位系统下大小为 4 bytes
sizeof(n) = ?; // n 是整型变量,占用内存空间4个字节

void Func(char str[100])
{
// 计算
sizeof(str) = ?; // 函数的参数为字符数组名,即数组首元素的地址,大小为指针的大小4
}

void* ptr = malloc(100);
// 计算
sizeof(ptr) = ?; // ptr指向malloc分配的大小为100 byte的内存的起始地址,sizeof(ptr)为指针的大小4

问题 2:分析运行下面的Test函数会有什么样的结果

Test1(void):
1
2
3
4
5
6
7
8
9
10
11
12
void GetMemory1(char* p)
{
p = (char*)malloc(100);
}

void Test1(void)
{
char* str = NULL;
GetMemory1(str);
strcpy(str, "hello world"); // 程序崩溃,因为str一直都是NULL,strcpy试图在NULL地址上写入字符串
printf(str);
}
Test2(void):
1
2
3
4
5
6
7
8
9
10
11
12
char* GetMemory2(void)
{
char p[] = "hello world";
return p;
}

void Test2(void)
{
char* str = NULL;
str = GetMemory2();
printf(str); // 可能是乱码,因为返回的是指向“栈内存”的指针,该指针的地址不是NULL,原先的内容已经被清除
}
Test3(void):
1
2
3
4
5
6
7
8
9
10
11
12
void GetMemory3(char** p, int num)
{
*p = (char*)malloc(num);
}

void Test3(void)
{
char* str = NULL;
GetMemory3(&str, 100);
strcpy(str, "hello");
printf(str); // 能够输出 hello,但存在内存泄露,GetMemory3 申请的内存没有释放
}

能够输出 hello,但存在内存泄露。GetMemory3 申请的内存没有释放。

Test4(void):
1
2
3
4
5
6
7
8
9
10
11
void Test4(void)
{
char* str = (char*)malloc(100);
strcpy(str, "hello");
free(str);

if (str != NULL) {
strcpy(str, "world"); // 危险,篡改动态内存区的内容,非法访问已释放的内存
cout << str << endl;
}
}

尽管在 free 之后 str 仍然不为 NULL,但它指向的内存已经被释放。继续使用 str 会导致未定义行为。篡改动态内存区的内容,后果难以预料。非常危险。因为 free(str); 之后,str 成为野指针,if (str != NULL) 语句不起作用。需要 str = NULL; // 将指针置为 NULL,避免使用已释放的内存。

九、计算机中的乱序执行

在计算机中的多线程编程中,乱序执行和内存模型是非常关键的概念,直接影响着程序的正确性和效率。以下是对这些概念的详细解释和说明:

1. 乱序执行中的保证顺序执行的情况

对同一块内存进行访问:

对同一块内存进行访问时,编译器和CPU都会保证按照程序的顺序来执行。这是因为乱序访问同一内存地址会导致数据一致性问题。例如,如果一个线程写入一个变量,而另一个线程读取该变量,必须确保读取发生在写入之后,才能保证读取到正确的数据。

变量间的依赖关系

当一个变量的值依赖于之前定义的变量时,编译器会保留这种依赖关系的顺序。例如:

1
2
int x = 5;
int y = x + 1;

在这种情况下,编译器不会重新排序xy的赋值顺序,因为y依赖于x的值。

  1. 新定义的变量的值依赖于之前定义的变量:

    • 如果新定义的变量的值依赖于之前定义的变量,编译器可能会保持这两个变量的定义顺序,以确保依赖关系正确。
  2. 乱序执行:

    • 在其他情况下,计算机可能会进行乱序执行,尤其在多线程环境下可能会产生问题。

2. 多线程乱序执行问题

单线程中,乱序执行通常不会引发问题,因为乱序执行只会改变执行速度而不会改变程序逻辑。然而在多线程环境下,乱序执行可能导致数据竞争和不一致的问题。例如:

  • 一个线程可能会在另一个线程完成操作之前读取数据,导致数据不一致。
  • 写操作的顺序不同步可能会使得读操作得到无效或未定义的值。

3. C++中的内存模型

C++标准库提供了六种内存模型来控制多线程程序中的乱序执行,以确保数据的一致性和正确性:

(1) memory_order_relaxed

这是最松散的内存顺序,保证操作不会被重新排序相对于其他操作。适合于无需同步的场景,例如统计计数。

(2) memory_order_consume

在消费者模型中使用,通常与memory_order_release搭配使用。它保证在读取某个值之后,所有对该值的依赖操作都不会被重新排序。

(3) memory_order_acquire

用于获取资源或锁定资源。保证在此操作之前的所有操作已经完成,适合在消费者从共享资源中读取数据时使用。

(4) memory_order_release

用于释放资源。它保证在此操作之后的所有操作会被推迟到此操作之后,适合在生产者更新共享资源时使用。

(5) memory_order_acq_rel

结合了acquirerelease的特点,用于需要同步的双向通信。

(6) memory_order_seq_cst

提供最强的内存顺序保证,确保所有线程都看到完全相同的操作顺序。它是C++内存模型中最严格的顺序,适合对顺序一致性要求很高的场景。

4. 副作用编程

无副作用编程

这是指函数在执行过程中,不会修改任何全局变量或对象状态,所有操作都是在局部范围内进行。例如:

1
2
3
int add(int a, int b) {
return a + b;
}

这种函数在多线程环境下不会引发数据竞争问题。

有副作用编程

有副作用的函数会对全局状态或类成员进行修改。例如:

1
2
3
void updateGlobal(int newValue) {
globalVar = newValue;
}

在多线程环境中,这种编程方式容易导致数据不一致和竞争条件。

5. 信号量 (Semaphore)

信号量用于线程之间的同步,控制线程的执行顺序。

(1) 二值信号量 (Binary Semaphore)

一种只有两种状态(有信号和无信号)的信号量,用于线程之间的事件通知。

1
std::binary_semaphore sem(0);

(2) 计数信号量 (Counting Semaphore)

可以同时持有多个线程,适用于限制资源的访问数量。

1
std::counting_semaphore<8> sem(0);

std::future 和任务链

std::future用于实现任务链,确保任务A依赖于任务B的结果。

例子: 生产者消费者问题

  1. 子线程作为消费者等待一个future

    1
    2
    std::future<int>& fut;
    fut.get();
  2. 主线程作为生产者,创建一个promise

    1
    2
    std::promise<int> prom;
    auto fut = prom.get_future();
  3. 主线程生产数据并使用promise传递数据给子线程:

    1
    prom.set_value(producedData);
  4. 子线程获取数据并执行任务。

有返回值的线程

使用std::async可以进行异步执行,支持立即执行或延迟执行。

1
2
3
4
auto futureResult = std::async(std::launch::async, []() {
return computeResult();
});
futureResult.get();

这种方式在多线程编程中大大简化了任务的管理和结果的获取。

十、字符串操作函数

在C语言中,字符串操作函数是标准库中的基础工具,常用于对字符串进行各种处理。以下是对四个常用字符串操作函数 strcpystrlenstrcatstrcmp 的详细说明和实现解释。

1. strcpy 函数

功能

strcpy 函数用于将源字符串 src 复制到目标字符串 dest。它会复制 src 包括它的终止字符 '\0',以确保 dest 也是一个以 '\0' 结尾的字符串。

函数声明

1
char* strcpy(char* dest, const char* src);

返回值

返回 dest 的指针。

使用注意

  • 需要确保 dest 有足够的空间来存放 src 的所有字符,包括终止字符 '\0'。否则会导致缓冲区溢出,可能引发未定义行为。

实现示例

1
2
3
4
5
6
7
8
char* strcpy(char* dest, const char* src) {
char* start = dest; // 保存 dest 的起始地址

// 遍历 src,将字符逐个复制到 dest
while ((*dest++ = *src++));

return start; // 返回 dest 的起始地址
}

2. strlen 函数

功能

strlen 函数用于计算字符串的长度,不包括终止字符 '\0'

函数声明

1
size_t strlen(const char* str);

返回值

返回字符串的长度(类型为 size_t),即字符的个数,不包括 '\0'

使用注意

  • 复杂度为 O(n),其中 n 是字符串的长度,因为需要遍历整个字符串。

实现示例

1
2
3
4
5
6
7
8
9
10
size_t strlen(const char* str) {
size_t length = 0;

// 遍历字符串,直到遇到终止字符
while (*str++) {
length++;
}

return length;
}

3. strcat 函数

功能

strcat 函数用于将源字符串 src 追加到目标字符串 dest 的末尾。

函数声明

1
char* strcat(char* dest, const char* src);

返回值

返回 dest 的指针。

使用注意

  • dest 必须有足够的空间来容纳 src'\0',否则会导致缓冲区溢出。
  • dest 本身必须是一个以 '\0' 结尾的字符串。

实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
char* strcat(char* dest, const char* src) {
char* start = dest;

// 移动指针到 dest 的结尾处
while (*dest) {
dest++;
}

// 将 src 的字符逐个复制到 dest 的末尾
while ((*dest++ = *src++));

return start; // 返回 dest 的起始地址
}

4. strcmp 函数

功能

strcmp 函数用于比较两个字符串 str1str2 的字典顺序。

函数声明

1
int strcmp(const char* str1, const char* str2);

返回值

  • 如果 str1 等于 str2,返回 0。
  • 如果 str1 小于 str2,返回负数。
  • 如果 str1 大于 str2,返回正数。

使用注意

  • 比较是逐字符进行的,直到找到第一个不同的字符或到达字符串的末尾。

实现示例

1
2
3
4
5
6
7
8
int strcmp(const char* str1, const char* str2) {
while (*str1 && (*str1 == *str2)) {
str1++;
str2++;
}

return *(unsigned char*)str1 - *(unsigned char*)str2;
}

综合实例

以下是一个综合使用这些字符串函数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <string.h>

int main() {
char dest[20];
const char* src = "Hello";

// 使用 strcpy 复制字符串
strcpy(dest, src);
printf("After strcpy, dest: %s\n", dest);

// 计算字符串长度
size_t len = strlen(dest);
printf("Length of dest: %zu\n", len);

// 使用 strcat 连接字符串
strcat(dest, " World");
printf("After strcat, dest: %s\n", dest);

// 比较字符串
int result = strcmp(dest, "Hello World");
if (result == 0) {
printf("Strings are equal.\n");
} else if (result < 0) {
printf("dest is less than 'Hello World'.\n");
} else {
printf("dest is greater than 'Hello World'.\n");
}

return 0;
}
评论