【C++】动态内存管理

目录

1. C/C++内存分布

2. 栈和堆的区别

3. C语言动态内存管理方式

4. 常见的动态内存错误

4.1 对空指针的解引用操作

4.2 对动态开辟空间的越界访问

4.3 对非动态开辟内存使用free释放

4.4 使用free释放一块动态开辟内存的一部分

4.5 对同一块动态内存多次释放

4.6 动态开辟内存忘记释放(内存泄漏)

5. 练习题

6. C99 柔性数组

6.1 柔性数组的特点

6.2 柔性数组的使用

6.3 柔性数组的优势

7. C++动态内存管理方式

7.1 new/delete操作内置类型

7.2 new/delete操作自定义类型

8. operator new与operator delete函数

9. new和delete的实现原理

9.1 内置类型

9.2 自定义类型

10. malloc/free和new/delete的区别

11. 内存泄露

11.1 什么是内存泄漏,内存泄漏的危害

11.2 内存泄漏分类

11.3 如何检测内存泄漏

11.4 如何避免内存泄漏


1. C/C++内存分布

int globalVar = 1; // globalVar存储在静态区

static int staticGlobalVar = 1; // staticGlobalVar存储在静态区

void Test()
{
	static int staticVar = 1; // staticVar存储在静态区

	int localVar = 1; // localVar存储在栈区

	int num1[10] = { 1, 2, 3, 4 }; // num1存储在栈区,sizeof(num1) = 40

	char char2[] = "abcd";
    // char2存储在栈区,*char2存储在栈区,sizeof(char2) = 5,strenlen(char2) = 4

	const char* pChar3 = "abcd";
    // pChar3存储在栈区,*pChar3存储在常量区
    // sizeof(pChar3) = 4/8,strenlen(pChar3) = 4

	int* ptr1 = (int*)malloc(sizeof(int) * 4); // ptr1存储在栈区,*ptr1存储在堆区
    // sizeof(ptr1) = 4/8

	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
	free(ptr1);
	free(ptr3);
}

2. 栈和堆的区别

存放内容不同:

  • 栈存放局部变量、函数形参、函数返回值
  • 堆存放动态分配的对象

生长方向不同:

  • 栈是向下生长的(高地址→低地址)
  • 堆是向上生长的(低地址→高地址)

管理方式不同:

  • 栈资源是自动管理的
  • 堆资源是手动管理的,容易产生内存泄露

内存管理机制不同:

  • 只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。
  • 系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中)。

空间大小不同:

  • 栈是一块连续的内存区域,大小是操作系统预定好的,Windows下栈大小是2M(也有是1M,在编译时确定,VC中可设置)。
  • 堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit系统理论上是4G),所以堆的空间比较灵活,比较大。堆的空间远大于栈。

是否产生内存碎片:

  • 栈不会产生内存碎片,因为栈是后进先出的,在某块内存出栈之前,比它后进栈的内存都已出栈。
  • 对于堆,频繁的new/delete会造成大量碎片,使程序效率降低。

分配方式:

  • 栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloca函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。
  • 堆都是动态分配。

分配效率:

  • 栈的效率高。操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行。
  • 堆的效率低。堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存。

共享性:

  • 栈是线程私有的
  • 堆是线程共享的

3. C语言动态内存管理方式

4个动态内存函数都定义在stdlib.h头文件中。

1. malloc——分配内存块

void* malloc(size_t size);
// 分配大小为size字节的内存块,并且不做初始化处理

2. calloc——分配内存块并用0初始化

void* calloc(size_t num, size_t size);
// 分配大小为num*size字节的内存块,并且用0初始化每个字节

对于malloc、calloc的总结:

  • 成功时返回一个指向分配的内存块的指针。指针类型为void*,必须强制转换为所需要的类型,才能解引用。
  • 失败时返回NULL,所以在调用函数之后要判断返回值是不是空指针。
  • 如果size为0,返回值取决于特定的库实现(可能是也可能不是空指针),但返回的指针不得被解引用。
  • malloc和calloc的区别在于是否初始化内存块。

3. realloc——重新分配内存块

void* realloc(void* ptr, size_t size);
// 改变ptr指向的内存块的大小为size字节

对于realloc的总结:

  • 成功时返回一个指向分配的内存块的指针。指针类型为void*,必须强制转换为所需要的类型,才能解引用。
  • 失败时返回NULL,并且ptr指向的内存块不会被释放(它仍然有效,其内容不变)。所以在调用realloc之后,不能用原指针接收realloc的返回值,另定义一个指针接收realloc的返回值,当它不为NULL时,再赋值给原指针,然后把它置空。
  • 如果ptr为NULL,函数行为类似malloc。
  • 如果size为0,C90(C++98):函数行为类似free,并返回NULL;C99/C11(C++11):返回值取决于特定的库实现(可能是也可能不是空指针),但返回的指针不得被解引用。
  • realloc的扩容机制:
    1. 原地扩容:原有空间之后有足够大的空间。此时,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。
    2. 异地扩容:原有空间之后没有足够大的空间。此时,在堆空间上另找一个合适大小的连续空间来使用。这样函数返回的是一个新的内存地址。

4. free——释放内存块

void free(void* ptr);

对于free的总结:

  • 如果ptr指向的空间不是动态开辟的,则会导致未定义的行为。
  • 如果ptr为NULL,则函数不会执行任何操作。

malloc和free的使用示例:

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

int main()
{
	// 向内存申请40个字节 int arr[10];
	int* p = (int*)malloc(10 * sizeof(int));
	int* ptr = p;
	if (p == NULL)
	{
		printf("%s\n", strerror(errno));
		return 1;
	}
	// 使用
	for (int i = 0; i < 10; i++)
	{
		*ptr = i;
		ptr++; // 指针位置改变
	}
	// 释放
	free(p); // p在原位置,ptr位置改变,所以释放p
	p = NULL;
	return 0;
}

calloc和free的使用示例:

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

int main()
{
	int* p = (int*)calloc(10, sizeof(int));
	if (p == NULL)
	{
		perror("calloc");
		return 1;
	}
	// 使用
	for (int i = 0; i < 10; i++)
	{
		*(p + i) = i;
	}
	// 释放
	free(p);
	p = NULL;
	return 0;
}

malloc、realloc和free的使用示例:

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

int main()
{
	int* p = (int*)malloc(40);
	if (p == NULL)
    {
        perror("malloc");
		return 1;
    }
	// 使用
	for (int i = 0; i < 10; i++)
	{
		*(p + i) = i;
	}
	for (int i = 0; i < 10; i++)
	{
		printf("%d ", *(p + i));
	}
	// 扩容
	int* ptr = (int*)realloc(p, 80);
	if (ptr != NULL)
	{
		p = ptr;
		ptr = NULL;
	}
	// 使用
	for (i = 10; i < 20; i++)
	{
		*(p + i) = i;
	}	
	// 释放
	free(p);
	p = NULL;
	return 0;
}

4. 常见的动态内存错误

4.1 对空指针的解引用操作

void test()
{
	int* p = (int*)malloc(INT_MAX / 4);
	*p = 20; // 如果p的值是NULL,就会有问题
	free(p);
}

4.2 对动态开辟空间的越界访问

void test()
{
	int i = 0;
	int* p = (int*)malloc(10 * sizeof(int));
	if (NULL == p)
	{
		exit(EXIT_FAILURE);
	}
	for (i = 0; i <= 10; i++)
	{
		*(p + i) = i; // 当i是10的时候越界访问
	}
	free(p);
}

4.3 对非动态开辟内存使用free释放

void test()
{
	int a = 10;
	int* p = &a;
	free(p); // p指向的不是动态开辟的空间,不能free
}

4.4 使用free释放一块动态开辟内存的一部分

void test()
{
	int* p = (int*)malloc(100);
	p++;
	free(p); // p不再指向动态内存的起始位置
}

4.5 对同一块动态内存多次释放

void test()
{
    int *p = (int *)malloc(100);
    free(p);
    free(p); // 重复释放
}

4.6 动态开辟内存忘记释放(内存泄漏)

void test()
{
	int* p = (int*)malloc(100);
	if (NULL != p)
	{
		*p = 20;
	}
}
int main()
{
	test();
	while (1);
}

动态开辟的空间一定要释放,并且正确释放。忘记释放不再使用的动态开辟的空间会造成内存泄漏。

5. 练习题

例题1

void GetMemory(char* p)
{
	p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(str);
	strcpy(str, "hello world");
	printf(str);
}

主要错误:

  • 传值调用时,形参p是实参str的一份临时拷贝,对形参p的修改不会影响实参str。所以str没有改变,还是NULL,不能进行下一步的strcpy和printf。
  • 动态开辟的空间没有释放。

改正错误:

void GetMemory(char** p)
{
	*p = (char*)malloc(100);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(&str); // 传址调用
	strcpy(str, "hello world");
	printf(str);
	free(str);
	str = NULL;
}

例题2

char* GetMemory(void)
{
	char p[] = "hello world";
	return p;
}

void Test(void)
{
	char* str = NULL;
	str = GetMemory();
	printf(str);
}

主要错误:(返回栈空间地址的问题)GetMemory返回p指向的字符串首字符的地址,str接收GetMemory的返回值,但p指向的字符串(局部变量)出了作用域后销毁。所以str仅仅保存了一个地址,但地址指向的内容不再是p指向的字符串。(就像张三告诉李四明早去505房间找他,但张三今晚退房了,李四知道了门牌号,但房间里不是张三)

改正错误:

char* GetMemory(char* p)
{
	p = (char*)malloc(100);//动态内存是在堆区开辟的,不自动销毁,只能free掉
	return p;
}

void Test(void)
{
	char* str = NULL;
	str = GetMemory(str);
	strcpy(str, "hello world");
	printf(str);
	free(str);
	str = NULL;
}

例题3

void GetMemory(char** p, int num)
{
	*p = (char*)malloc(num);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(&str, 100);
	strcpy(str, "hello");
	printf(str);
}

主要错误:动态开辟的空间没有释放。

改正错误:

void GetMemory(char** p, int num)
{
	*p = (char*)malloc(num);
}

void Test(void)
{
	char* str = NULL;
	GetMemory(&str, 100);
	strcpy(str, "hello");
	printf(str);
	free(str);
	str = NULL;
}

例题4

void Test(void)
{
	char* str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);
	if (str != NULL)
	{
		strcpy(str, "world");
		printf(str);
	}
}

主要错误:free掉str只是释放动态开辟的空间,str还存在,是野指针,访问野指针指向的内容为非法访问。

改正错误:

void Test(void)
{
	char* str = (char*)malloc(100);
	strcpy(str, "hello");
	free(str);
	str = NULL; // free掉str后令str为空指针
}

6. C99 柔性数组

C99中,结构中的最后一个元素允许是未知大小的数组,这就叫做柔性数组(flexible array)成员。

struct S
{
	int n;
	float s;
	int arr[0]; // 柔性数组成员
};

有些编译器会报错无法编译可以改成:

struct S
{
	int n;
	float s;
	int arr[]; // 柔性数组成员
};

6.1 柔性数组的特点

  • 结构中的柔性数组成员前面必须至少一个其他成员。
  • sizeof返回的这种结构大小不包括柔性数组的内存。
  • 包含柔性数组成员的结构用malloc()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。
struct S
{
	int n;
	float s;
	int arr[0];
};
// sizeof(struct S)=8

6.2 柔性数组的使用

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

struct S
{
	int n;
	float s;
	int arr[0];
};

int main()
{
    // 柔性数组成员arr获得4个整型元素的连续空间
    struct S* ps = (struct S*)malloc(sizeof(struct S) + sizeof(int) * 4);
	if (ps == NULL)
	{
		return 1;
	}
    // 使用
    ps->n = 100;
	ps->s = 5.5f;
	for (int i = 0; i < 4; i++)
	{
        scanf("%d", &(ps->arr[i]));
	}

	printf("%d %f\n", ps->n, ps->s);
	for (int i = 0; i < 4; i++)
	{
		printf("%d ", ps->arr[i]);
	}
	// 调整
    struct S* ptr = (struct S*)realloc(ps, sizeof(struct S) + sizeof(int) * 10);
	if (ptr == NULL)
	{
		return 1;
	}
	else
	{
		ps = ptr;
	}
	// 释放
	free(ps);
	ps = NULL;
	return 0;
}

6.3 柔性数组的优势

上述结构也可以设计为:

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

struct S
{
	int n;
	float s;
	int* arr;
};

int main()
{
	struct S* ps = (struct S*)malloc(sizeof(struct S));
	if (ps == NULL)
		return 1;
	// 令arr指向4个整型元素的连续空间
	int* ptr = (int*)malloc(sizeof(int) * 4);
	if (ptr == NULL)
	{
		return 1;
	}
	else
	{
		ps->arr = ptr;
	}
	// 使用
	ps->n = 100;
	ps->s = 5.5f;
	for (int i = 0; i < 4; i++)
	{
		scanf("%d", &(ps->arr[i]));
	}

	printf("%d %f\n", ps->n, ps->s);
	for (int i = 0; i < 4; i++)
	{
		printf("%d ", ps->arr[i]);
	}
	// 调整
	int* p = (int*)realloc(ps->arr, sizeof(int) * 10);
	if (p == NULL)
	{
		return 1;
	}
	else
	{
		ps->arr = p;
	}
	// 释放
	free(ps->arr);
	ps->arr = NULL;
	free(ps);
	ps = NULL;
	return 0;
}

但是使用int arr[0];比int* arr;好,柔性数组有两个优点:

  • 方便内存释放。如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。
  • 提高访问速度。连续的内存有益于提高访问速度,也有益于减少内存碎片。

7. C++动态内存管理方式

C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。

7.1 new/delete操作内置类型

void Test()
{
	// 动态申请一个int类型的空间
	int* ptr4 = new int;
	// 动态申请一个int类型的空间并初始化为10
	int* ptr5 = new int(10);
	// 动态申请10个int类型的空间
	int* ptr6 = new int[10];
	delete ptr4;
	delete ptr5;
	delete[] ptr6;
}

申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和delete[],注意:匹配起来使用。

7.2 new/delete操作自定义类型

class A
{
public:
	A(int a = 0)
		: _a(a)
	{
		cout << "A():" << this << endl;
	}
	~A()
	{
		cout << "~A():" << this << endl;
	}
private:
	int _a;
};

int main()
{
	// new/delete和malloc/free最大区别是
    // new/delete对于自定义类型除了开空间还会调用构造函数和析构函数
	A* p1 = (A*)malloc(sizeof(A));
	A* p2 = new A(1);
	free(p1);
	delete p2;

	// 内置类型是几乎是一样的
	int* p3 = (int*)malloc(sizeof(int));
	int* p4 = new int;
	free(p3);
	delete p4;

	A* p5 = (A*)malloc(sizeof(A) * 10); // 不调用构造函数
	A* p6 = new A[10]; // 调用10次构造函数
	free(p5); // 不调用析构函数
	delete[] p6; // 调用10次析构函数

	return 0;
}

8. operator new与operator delete函数

new和delete是用户进行动态内存申请和释放的操作符,operator new和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。

/*
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;
申请空间失败,尝试执行空间不足应对措施,如果改应对措施用户设置了,则继续申请,否则抛异常。
*/
void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
	// try to allocate size bytes
	void* p;
	while ((p = malloc(size)) == 0)
		if (_callnewh(size) == 0)
		{
			// report no memory
			// 如果申请内存失败了,这里会抛出bad_alloc 类型异常
			static const std::bad_alloc nomem;
			_RAISE(nomem);
		}
	return (p);
}

/*
operator delete: 该函数最终是通过free来释放空间的
*/
void operator delete(void* pUserData)
{
	_CrtMemBlockHeader* pHead;
	RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
	if (pUserData == NULL)
		return;
	_mlock(_HEAP_LOCK); /* block other threads */
	__TRY
		/* get a pointer to memory block header */
		pHead = pHdr(pUserData);
	/* verify block type */
	_ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
	_free_dbg(pUserData, pHead->nBlockUse);
	__FINALLY
		_munlock(_HEAP_LOCK); /* release other threads */
	__END_TRY_FINALLY
		return;
}

/*
free的实现
*/
#define free(p) _free_dbg(p, _NORMAL_BLOCK)

通过上述两个全局函数的实现知道,operator new实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete最终是通过free来释放空间的

9. new和delete的实现原理

9.1 内置类型

如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。

9.2 自定义类型

new的原理:

  1. 调用operator new函数申请空间。
  2. 在申请的空间上执行构造函数,完成对象的构造。

delete的原理:

  1. 在空间上执行析构函数,完成对象中资源的清理工作。
  2. 调用operator delete函数释放对象的空间。

new T[N]的原理:

  1. 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请。
  2. 在申请的空间上执行N次构造函数。

delete[]的原理:

  1. 在释放的对象空间上执行N次析构函数(按对象构造顺序的逆序执行析构函数),完成N个对象中资源的清理。
  2. 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间。

10. malloc/free和new/delete的区别

malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。

不同的地方是:

  1. malloc和free是函数;new和delete是操作符。
  2. malloc申请的空间不会初始化;new可以初始化。
  3. malloc申请空间时,需要手动计算空间大小并传递;new只需在其后跟上空间的类型即可,如果是多个对象,[]中指定对象个数即可。
  4. malloc申请空间失败时,返回的是NULL,因此使用时必须判空;new不需要,但是new需要捕获异常。
  5. 申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数;new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理。
  6. new封装了malloc,直接free不会报错,但是这只是释放内存,而不会析构对象。
  7. malloc的返回类型为void*,必须强制类型转换成所需要的指针类型;new返回的是对象类型的指针,类型严格与对象匹配,不匹配时报错,所以new是类型安全的。
int* p = new float[2]; // 编译失败,new是类型安全的
int* p = (int*)malloc(2 * sizeof(double)); // 编译通过

11. 内存泄露

11.1 什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

void MemoryLeaks()
{
	// 1.内存申请了忘记释放
	int* p1 = (int*)malloc(sizeof(int));
	int* p2 = new int;

	// 2.异常安全问题
	int* p3 = new int[10];
	Func(); // 这里Func函数抛异常导致delete[] p3未执行,p3没被释放
	delete[] p3;
}

11.2 内存泄漏分类

C/C++程序中一般我们关心两种方面的内存泄漏:

堆内存泄漏(Heap leak):堆内存指的是程序执行中依据须要分配通过malloc/calloc/realloc/new等从堆中分配的一块内存,用完后必须通过调用相应的free或者delete删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。

系统资源泄漏:指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

11.3 如何检测内存泄漏

在VS下,可以使用windows操作系统提供的_CrtDumpMemoryLeaks()函数进行简单检测,该函数只报出了大概泄漏了多少个字节,没有其他更准确的位置信息。

int main()
{
	int* p = new int[10];
	// 将该函数放在main函数之后,每次程序退出的时候就会检测是否存在内存泄漏
	_CrtDumpMemoryLeaks();
	return 0;
}

// 程序退出后,在输出窗口中可以检测到泄漏了多少字节,但是没有具体的位置
Detected memory leaks!
Dumping objects ->
{79} normal block at 0x00EC5FB8, 40 bytes long.
Data: < > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.

因此写代码时一定要小心,尤其是动态内存操作时,一定要记着释放。但有些情况下总是防不胜
防,简单的可以采用上述方式快速定位下。如果工程比较大,内存泄漏位置比较多,不太好查时
一般都是借助第三方内存泄漏检测工具处理的。

11.4 如何避免内存泄漏

  • 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
  • RAII思想或者智能指针来管理资源。
  • 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  • 出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。

总结一下:内存泄漏非常常见,解决方案分为两种:1、事前预防型。如智能指针等。2、事后查错型。如泄漏检测工具。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值