MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++中malloc和new调用方式对比

2023-03-175.8k 阅读

C++ 中 mallocnew 的基础概念

malloc 函数介绍

malloc 是 C 语言标准库函数,在 <stdlib.h> 头文件中声明。它的主要作用是在堆上分配指定字节数的内存空间,并返回一个指向该内存起始地址的指针。如果分配失败,malloc 返回 NULL

其函数原型为:

void* malloc(size_t size);

这里的 size 参数指定要分配的字节数。例如,要分配 10 个 int 类型的空间(假设 int 为 4 字节),可以这样调用:

#include <stdlib.h>
#include <stdio.h>

int main() {
    int* ptr = (int*)malloc(10 * sizeof(int));
    if (ptr == NULL) {
        perror("malloc failed");
        return 1;
    }
    // 使用 ptr 进行操作
    free(ptr);
    return 0;
}

在上述代码中,首先调用 malloc 分配了 10 * sizeof(int) 字节的内存空间,并将返回的 void* 指针强制转换为 int* 类型。然后检查返回值是否为 NULL,若为 NULL 则说明分配失败,通过 perror 打印错误信息。最后使用完内存后,调用 free 函数释放内存。

new 运算符介绍

new 是 C++ 的运算符,用于在堆上分配内存并构造对象(如果是对象类型)。当使用 new 分配内存时,它不仅会分配所需的内存空间,还会调用对象的构造函数(对于对象类型)。

对于基本数据类型,new 的使用方式相对简单。例如,分配一个 int 类型的变量:

#include <iostream>

int main() {
    int* num = new int;
    *num = 10;
    std::cout << "Value of num: " << *num << std::endl;
    delete num;
    return 0;
}

在这段代码中,new int 分配了一个 int 类型的内存空间,并返回一个指向该空间的指针。然后给该内存空间赋值为 10,最后使用 delete 运算符释放内存。

对于自定义类型,new 的行为更为复杂且强大。假设有一个自定义类 MyClass

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor called" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};

int main() {
    MyClass* obj = new MyClass;
    delete obj;
    return 0;
}

当执行 new MyClass 时,首先在堆上分配足够存储 MyClass 对象的内存空间,然后调用 MyClass 的构造函数。当执行 delete obj 时,先调用 MyClass 的析构函数,然后释放内存。

内存分配机制的差异

malloc 的内存分配方式

malloc 是基于系统堆内存分配的函数。它从堆中查找一块足够大小的连续内存块。当请求的内存大小小于等于当前堆中最大的空闲块时,malloc 会尝试从堆中分割出一块大小合适的内存块返回给调用者。如果堆中没有足够大的连续空闲块,malloc 可能会尝试扩展堆(通过系统调用,如 brksbrk 在 Linux 系统上),如果扩展失败,则返回 NULL

malloc 分配内存时,它只关心内存的大小,不关心所分配内存的具体类型。这就是为什么 malloc 的返回值是 void*,需要调用者手动进行类型转换。例如:

double* dptr = (double*)malloc(10 * sizeof(double));

这里将 malloc 返回的 void* 指针转换为 double* 指针,以方便后续对 double 类型数据的操作。

new 的内存分配方式

new 运算符的内存分配过程相对复杂一些。当使用 new 分配内存时,首先调用 operator new 函数来分配内存。operator new 函数本质上也是调用系统的内存分配函数(如 malloc),但在这之前,new 会做一些额外的工作。

对于对象类型,new 会在分配完内存后调用对象的构造函数。例如,对于前面定义的 MyClass 类,new MyClass 的过程如下:

  1. 调用 operator new 分配足够存储 MyClass 对象的内存空间。
  2. 在分配好的内存空间上调用 MyClass 的构造函数。

当使用 delete 释放内存时,过程则相反:

  1. 调用对象的析构函数。
  2. 调用 operator delete 释放内存。

这种机制确保了对象在创建和销毁时,其构造函数和析构函数能够正确调用,从而保证对象的完整性和资源的正确管理。

例如,考虑一个包含动态数组成员的类 MyArrayClass

class MyArrayClass {
private:
    int* arr;
    int size;
public:
    MyArrayClass(int s) : size(s) {
        arr = new int[size];
        for (int i = 0; i < size; ++i) {
            arr[i] = i;
        }
    }
    ~MyArrayClass() {
        delete[] arr;
    }
};

int main() {
    MyArrayClass* obj = new MyArrayClass(5);
    delete obj;
    return 0;
}

MyArrayClass 的构造函数中,使用 new 分配了一个动态数组 arr。在析构函数中,使用 delete[] 释放该数组。当执行 new MyArrayClass(5) 时,new 首先为 MyArrayClass 对象分配内存,然后调用其构造函数,在构造函数中又为 arr 分配内存。当执行 delete obj 时,先调用 MyArrayClass 的析构函数,释放 arr 的内存,然后调用 operator delete 释放 MyArrayClass 对象的内存。

错误处理的差异

malloc 的错误处理

malloc 在分配内存失败时,返回 NULL。调用者需要在使用返回的指针之前检查其是否为 NULL。例如:

#include <stdlib.h>
#include <stdio.h>

int main() {
    char* str = (char*)malloc(1000000000); // 假设系统无法分配这么大内存
    if (str == NULL) {
        perror("malloc failed");
        return 1;
    }
    // 使用 str 进行操作
    free(str);
    return 0;
}

在上述代码中,通过检查 str 是否为 NULL 来判断 malloc 是否成功。如果失败,使用 perror 打印错误信息。perror 函数会根据 errno 变量的值打印出相应的错误描述,errno 是一个全局变量,在 malloc 失败时会被设置为相应的错误码。

new 的错误处理

new 运算符在分配内存失败时,默认情况下会抛出 std::bad_alloc 异常。这与 malloc 返回 NULL 的处理方式不同。例如:

#include <iostream>
#include <new>

int main() {
    try {
        char* str = new char[1000000000]; // 假设系统无法分配这么大内存
        // 使用 str 进行操作
        delete[] str;
    } catch (const std::bad_alloc& e) {
        std::cerr << "new failed: " << e.what() << std::endl;
        return 1;
    }
    return 0;
}

在这段代码中,使用 try - catch 块来捕获 new 可能抛出的 std::bad_alloc 异常。如果捕获到异常,通过 e.what() 获取异常信息并打印。

此外,C++ 还提供了一种非抛出版本的 new,即 nothrow new。使用 nothrow new 时,如果内存分配失败,它返回 NULL,而不是抛出异常。例如:

#include <iostream>
#include <new>

int main() {
    char* str = new (std::nothrow) char[1000000000];
    if (str == NULL) {
        std::cerr << "nothrow new failed" << std::endl;
        return 1;
    }
    // 使用 str 进行操作
    delete[] str;
    return 0;
}

这种方式在一些不适合使用异常处理的场景(如嵌入式系统或对异常处理开销敏感的代码)中很有用。

内存释放的差异

freemalloc 的配合

当使用 malloc 分配内存后,需要使用 free 函数来释放内存。free 函数的原型为:

void free(void* ptr);

这里的 ptr 必须是 malloccallocrealloc 返回的指针。如果传递一个非上述函数返回的指针,或者已经释放过的指针,行为是未定义的。例如:

#include <stdlib.h>
#include <stdio.h>

int main() {
    int* ptr = (int*)malloc(10 * sizeof(int));
    if (ptr == NULL) {
        perror("malloc failed");
        return 1;
    }
    free(ptr);
    // 再次释放 ptr 会导致未定义行为
    // free(ptr); 
    return 0;
}

在上述代码中,释放 ptr 后如果再次释放,程序的行为是未定义的,可能导致程序崩溃或其他不可预测的错误。

deletenew 的配合

使用 new 分配内存后,需要使用 delete 运算符来释放内存。对于单个对象,使用 delete,对于数组对象,使用 delete[]。例如:

#include <iostream>

int main() {
    int* num = new int;
    *num = 10;
    delete num;

    int* arr = new int[5];
    for (int i = 0; i < 5; ++i) {
        arr[i] = i;
    }
    delete[] arr;
    return 0;
}

在上述代码中,delete num 用于释放单个 int 类型变量的内存,delete[] arr 用于释放 int 数组的内存。如果使用 delete 释放数组内存,会导致只调用数组第一个元素的析构函数(如果是对象数组),并且可能导致内存泄漏。同样,如果使用 delete[] 释放单个对象的内存,行为也是未定义的。

对于自定义类对象,delete 会先调用对象的析构函数,然后释放内存。例如:

class MyClass {
public:
    MyClass() {
        std::cout << "MyClass constructor called" << std::endl;
    }
    ~MyClass() {
        std::cout << "MyClass destructor called" << std::endl;
    }
};

int main() {
    MyClass* obj = new MyClass;
    delete obj;
    return 0;
}

在这段代码中,delete obj 首先调用 MyClass 的析构函数,然后释放 obj 所指向的内存。

对数组的处理差异

malloc 分配数组

使用 malloc 分配数组内存时,只分配指定字节数的连续内存空间,不会调用数组元素的构造函数(如果是对象数组)。例如,分配一个 int 数组:

#include <stdlib.h>
#include <stdio.h>

int main() {
    int* arr = (int*)malloc(5 * sizeof(int));
    if (arr == NULL) {
        perror("malloc failed");
        return 1;
    }
    for (int i = 0; i < 5; ++i) {
        arr[i] = i;
    }
    // 使用 arr 进行操作
    free(arr);
    return 0;
}

在上述代码中,malloc 分配了 5 * sizeof(int) 字节的内存空间,但数组元素未初始化,需要手动赋值。

如果是对象数组,情况更为复杂。假设有一个类 MyObject

class MyObject {
public:
    MyObject() {
        std::cout << "MyObject constructor called" << std::endl;
    }
    ~MyObject() {
        std::cout << "MyObject destructor called" << std::endl;
    }
};

使用 malloc 分配 MyObject 数组:

#include <stdlib.h>
#include <iostream>

int main() {
    MyObject* arr = (MyObject*)malloc(5 * sizeof(MyObject));
    if (arr == NULL) {
        std::cerr << "malloc failed" << std::endl;
        return 1;
    }
    // 这里数组元素的构造函数未被调用
    // 手动调用构造函数(不推荐这种方式,仅为演示)
    for (int i = 0; i < 5; ++i) {
        new (&arr[i]) MyObject;
    }
    // 使用 arr 进行操作
    // 手动调用析构函数
    for (int i = 0; i < 5; ++i) {
        arr[i].~MyObject();
    }
    free(arr);
    return 0;
}

在上述代码中,使用 malloc 分配了 MyObject 数组的内存,但构造函数未被调用。如果要使用数组元素,需要手动调用构造函数(使用 placement new),在释放内存前还需要手动调用析构函数。这种方式很容易出错,并且不符合 C++ 的对象管理原则。

new 分配数组

使用 new[] 分配数组内存时,不仅会分配内存,还会调用数组每个元素的构造函数(如果是对象数组)。例如,分配一个 int 数组:

#include <iostream>

int main() {
    int* arr = new int[5];
    for (int i = 0; i < 5; ++i) {
        arr[i] = i;
    }
    // 使用 arr 进行操作
    delete[] arr;
    return 0;
}

在上述代码中,new int[5] 分配了 5int 类型的内存空间,并自动初始化数组元素(对于基本类型,默认初始化)。

对于对象数组,new[] 会依次调用每个对象的构造函数。例如:

class MyObject {
public:
    MyObject() {
        std::cout << "MyObject constructor called" << std::endl;
    }
    ~MyObject() {
        std::cout << "MyObject destructor called" << std::endl;
    }
};

int main() {
    MyObject* arr = new MyObject[5];
    // 使用 arr 进行操作
    delete[] arr;
    return 0;
}

在这段代码中,new MyObject[5] 分配了 5MyObject 对象的内存空间,并依次调用每个 MyObject 对象的构造函数。当使用 delete[] arr 释放内存时,会依次调用每个 MyObject 对象的析构函数。

内存对齐的差异

malloc 的内存对齐

malloc 分配的内存通常按照系统默认的对齐方式进行对齐。在大多数系统中,malloc 分配的内存至少会按照 sizeof(void*) 的倍数进行对齐。这意味着分配的内存地址能被 sizeof(void*) 整除。例如,在 64 位系统上,sizeof(void*) 通常为 8 字节,malloc 分配的内存地址会是 8 的倍数。

这种对齐方式对于大多数基本数据类型和简单结构体来说是足够的。例如:

#include <stdlib.h>
#include <stdio.h>

int main() {
    int* ptr = (int*)malloc(sizeof(int));
    printf("Allocated address: %p\n", (void*)ptr);
    printf("Alignment: %zu\n", (size_t)ptr % sizeof(void*));
    free(ptr);
    return 0;
}

在上述代码中,通过打印分配内存地址对 sizeof(void*) 取模的结果,可以验证其对齐情况。

然而,对于一些特殊的结构体,可能需要更严格的对齐要求。例如,有些硬件设备要求特定数据类型必须以特定字节数对齐才能高效访问。在这种情况下,malloc 可能无法满足需求。

new 的内存对齐

new 运算符在分配内存时,会根据对象的类型进行适当的对齐。对于基本数据类型,它遵循系统默认的对齐规则,与 malloc 类似。但对于自定义类型,new 会根据类中成员的最大对齐要求来对齐内存。

例如,假设有一个结构体:

struct MyStruct {
    char c;
    double d;
};

在 64 位系统上,double 类型通常要求 8 字节对齐。当使用 new 分配 MyStruct 对象时,new 会确保分配的内存地址是 8 字节对齐的,以满足 double 成员的对齐要求。

#include <iostream>

struct MyStruct {
    char c;
    double d;
};

int main() {
    MyStruct* ptr = new MyStruct;
    std::cout << "Allocated address: " << ptr << std::endl;
    std::cout << "Alignment: " << (size_t)ptr % sizeof(double) << std::endl;
    delete ptr;
    return 0;
}

在上述代码中,通过打印分配内存地址对 sizeof(double) 取模的结果,可以看到 new 分配的内存地址是 8 字节对齐的。

此外,C++11 引入了 alignas 关键字,可以用于指定自定义类型的对齐要求。例如:

struct alignas(16) MyAlignedStruct {
    char c;
    double d;
};

这里 MyAlignedStruct 结构体要求 16 字节对齐。当使用 new 分配 MyAlignedStruct 对象时,new 会确保满足 16 字节对齐的要求。

性能方面的差异

malloc 的性能特点

malloc 是 C 语言标准库函数,其实现相对简单直接。在一些简单的内存分配场景中,malloc 的性能表现良好。由于 malloc 只负责分配内存,不涉及对象的构造和析构,对于基本数据类型的大量内存分配,malloc 的开销相对较小。

然而,malloc 在处理复杂的内存管理需求时,性能可能会受到影响。例如,在频繁分配和释放小块内存的场景下,malloc 可能会导致内存碎片问题。内存碎片是指由于多次分配和释放内存,使得堆中出现大量不连续的小块空闲内存,这些小块内存无法满足较大的内存分配请求,从而降低了内存利用率和分配效率。

为了减少内存碎片问题,一些 malloc 的实现采用了诸如伙伴系统(Buddy System)、堆合并等技术。但即使如此,在极端情况下,内存碎片仍然可能成为性能瓶颈。

new 的性能特点

new 运算符由于涉及对象的构造和析构,其性能开销相对 malloc 要大一些。每次使用 new 分配对象内存时,除了分配内存本身的开销,还需要调用对象的构造函数,这在对象构造函数复杂时会带来显著的性能开销。同样,在使用 delete 释放对象时,需要调用析构函数。

对于数组分配,new[] 不仅要分配内存,还要依次调用每个元素的构造函数,这也会增加性能开销。不过,new 在处理对象的内存管理方面具有更好的语义和安全性,能够确保对象的正确构造和析构,减少内存泄漏和悬空指针等问题。

在一些性能敏感的场景中,如果只需要分配基本数据类型的内存,并且对对象构造和析构没有需求,可以考虑使用 malloc 来提高性能。但在大多数 C++ 程序中,由于对象的存在,new 是更常用的内存分配方式,尽管其性能开销相对较高,但带来的代码安全性和可维护性提升往往更为重要。

例如,在一个简单的基准测试中,对比 mallocnew 分配大量 int 数组的性能:

#include <iostream>
#include <stdlib.h>
#include <chrono>

const int num_elements = 1000000;

void testMalloc() {
    auto start = std::chrono::high_resolution_clock::now();
    int* arr = (int*)malloc(num_elements * sizeof(int));
    if (arr != NULL) {
        for (int i = 0; i < num_elements; ++i) {
            arr[i] = i;
        }
        free(arr);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "malloc time: " << duration << " ms" << std::endl;
}

void testNew() {
    auto start = std::chrono::high_resolution_clock::now();
    int* arr = new int[num_elements];
    for (int i = 0; i < num_elements; ++i) {
        arr[i] = i;
    }
    delete[] arr;
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "new time: " << duration << " ms" << std::endl;
}

int main() {
    testMalloc();
    testNew();
    return 0;
}

在上述代码中,testMalloc 函数使用 malloc 分配和初始化 int 数组,testNew 函数使用 new[] 进行相同操作。通过测量时间,可以看到在这种简单的基本数据类型数组分配场景下,malloc 的性能略优于 new[]。但如果数组元素是复杂对象,new[] 的构造函数调用开销会使性能差异更加明显。

代码可维护性和可读性的差异

malloc 对代码可维护性和可读性的影响

使用 malloc 分配内存时,由于其返回 void* 指针,需要手动进行类型转换,这在一定程度上降低了代码的可读性。例如:

double* dptr = (double*)malloc(10 * sizeof(double));

从这段代码中,读者需要仔细查看类型转换部分才能确定所分配内存的实际类型。

在处理对象类型时,malloc 不调用构造函数,需要手动管理对象的初始化,这增加了代码的复杂性和出错的可能性。例如,对于前面的 MyObject 类,使用 malloc 分配对象数组时,不仅要手动调用构造函数,还要手动调用析构函数,代码变得冗长且难以理解:

MyObject* arr = (MyObject*)malloc(5 * sizeof(MyObject));
if (arr != NULL) {
    for (int i = 0; i < 5; ++i) {
        new (&arr[i]) MyObject;
    }
    // 使用 arr 进行操作
    for (int i = 0; i < 5; ++i) {
        arr[i].~MyObject();
    }
    free(arr);
}

这种手动管理对象生命周期的方式在代码维护时容易出现遗漏,导致内存泄漏或对象状态不一致等问题。

new 对代码可维护性和可读性的影响

new 运算符在代码可维护性和可读性方面具有明显优势。对于对象类型,new 会自动调用构造函数,delete 会自动调用析构函数,代码逻辑更加清晰。例如:

MyObject* obj = new MyObject;
// 使用 obj 进行操作
delete obj;

从这段代码中,很容易看出 MyObject 对象的创建和销毁过程,代码简洁明了。

对于数组分配,new[] 同样会自动调用每个元素的构造函数,delete[] 会自动调用析构函数,进一步简化了代码。例如:

MyObject* arr = new MyObject[5];
// 使用 arr 进行操作
delete[] arr;

这种方式使得代码更符合面向对象编程的思想,易于理解和维护。同时,new 的异常处理机制(默认抛出 std::bad_alloc 异常)也使得错误处理代码更加清晰,与 C++ 的异常处理机制相融合,提高了代码的健壮性。

综上所述,在 C++ 编程中,new 运算符在代码可维护性和可读性方面通常优于 malloc,尤其在处理对象类型和复杂数据结构时表现得更为突出。但在一些特定场景下,如对性能要求极高且只涉及基本数据类型的内存分配,malloc 可能仍然是一个不错的选择。开发者需要根据具体的需求和场景,权衡两者的优缺点,选择合适的内存分配方式。