这篇笔记写一些关于 C++ 数值类型和内存管理的知识点。
变量类型
可能会 cover 到的变量类型如上所示。在本文中,字节(byte)指的是 8 位的内存单元,即 1 byte = 8 bits.
简单变量
分类
整数型变量:char (1 byte), short (2 bytes), int (4 bytes), long (4 bytes), long long (8 bytes)。
浮点数变量:float (4 bytes),double (8 bytes), long double (12 or 16 bytes)。
特别地,布尔类型(bool)的值只有 true 和 false。C++ 会将 0 解释为 false,将非零值都解释成 true。
C++11 提供了固定宽度的整数类型,int16_t, int32_t, int64_t。它们的长度就分别是 16 bits, 32 bits 和 64 bits(即2/4/8字节)。
计数
整数型变量的计数方式:
如果整数型变量是带符号的(signed),那么第 0 位永远表示正负(1 负 0 正),除第 0 位以外的数字用以表示二进制数值大小。
如果整数型变量是不带符号的(unsigned),那么所有位数都是用来表示大小的。
float (32 bits) 的计数方式:
第 0 位 / 符号位(1 bit):表示正负。1 负 0 正。
第 1-8 位 / 指数位(8 bits):表示数值范围。
后面的所有位数 / 尾数位(23 bits):表示数值精度。
例如: Present -15.5 in binary bits.
1. Sign: Negative, so the first bit is 1.
2. Absolute value: 15.5 = 15(int) + 0.5(decimal)
3. Binary transform: 15 = (1111)_2, 0.5 = 1/2 = 2^(-1) = (0.1)_2
4. Offset: 15.5 = (1111.1)_2 = 1.1111 * 2^3, offset = 3+127 = 130 = (10000010)_2
5. Mantissa: 1.1111 → .1111 → (fill to 23 bits) (11110000000000000000000)_2
Now combine all the binary parts, -15.5 = (1 10000010 11110000000000000000000)_2
Double (64 bits) 的计数方式:
与 floats 的计算步骤是相同的。
符号位 1 位,指数位 11 位,尾数位 52 位。
类型转换
C++ 允许将一种类型的值赋给另一种类型的变量。
一般来说,将一个值赋给值取值范围更大的类型通常不会导致什么问题(例如将 int 转换为 long)。但是,如果将一个很大的 long 值转换成 float,就会降低精度,因为 float 只有六位有效数字。
将一个取值范围更大的值转换成更小的值(且确实超过了其取值范围时),通常只复制右边的字节。
内存
内存(Memory)就是计算机的存储空间,用于存储程序的指令、数据和状态。计算机系统的主存被组织成一个由 M 个连续的字节(byte)大小的单元组成的数组,每个字节都有唯一的一个物理地址。
计算机的存储空间是由一系列连续的存储单元组成,每个单元格表示一个 bit,一个 bit 只有 0/1 两种状态。规定 8 个 bit 为一组组成 byte,也就是字节。字节是内存寻址的最小单元,计算机能保证每一个字节的编号唯一,因此能够使用内存地址访问到正确的字节。
内存地址空间
所有的可访问的内存编号的范围决定了计算机可寻址的内存范围,它们的编号集合就被称为内存地址空间(memory address space),内存的地址空间与大家平常所说的系统是 32 位还是 64 位有关。32 位系统意味着可寻址的内存范围是 2^32 byte = 4GB,换言之如果电脑是32位的,则装超过 4GB 的内存条也无法使用。
变量的本质
在 C/C++ 中,定义一个变量的语法很简单。
当我们写一个变量的时候,实际上是向内存申请了一部分的空间来存放这些变量。例如,int 类型占 4 个字节,我就需要在计算机中申请 4 个字节。在计算机中,数字使用补码的形式来表示,999 换算成补码是 0000 0011 1110 0111 —— 这里每 4 个bit 就会被存储在一个字节中。
放在字节中有两个顺序,要么是 0000 0011 1110 0111, 或者反过来。如果把高位字节放在内存低地址,这种方法叫做大端法(big endian),反之则叫做小端法(little endian)。大端法符合人类的阅读习惯顺序。这个顺序也叫做字节序。
判断系统字节序可以通过 C++ 代码来输出运行。例如,
我们能这么写是因为整数 num 的数值被初始化为1 (0x0000 0001),然后将其指针类型从 int* 转换为 char*,我们就可以访问该整数的第一个字节。如果系统是小端字序,那么第一个字节就是 1,否则就是 0。通过判断第一个字节的值,就可以得知系统的字节序。
一般,PC 和 Mac 使用的都是小端字节序,网络传输(尤其是TCP/IP)使用的是大端字节序。Linux 视情况而定。
内存分区
一般来说,程序运行时,代码、数据等都存放在不同的内存区域,这些内存区域从逻辑上做了划分,大概以下几个区域:代码区、全局/静态存储区、栈区、堆区和常量区。
代码区
也就是 .text 段,代码区存放程序的二进制代码,它是只读的,以防止程序在运行中被意外修改。这个区域中也有可能包含一些只读的常数变量,例如字符串常量等。
全局 / 静态存储区
存储全局变量和静态变量。这个区域的内存在程序的生命周期几乎都是全局的,例如:
其中,globalVar是一个全局变量,staticVar 是一个静态变量,它们都存放在全局/静态存储区。
static 关键字
特别地,我们对 static 关键字做一些单独的说明。
在函数内部:例如上图中在 void function() 中的 static int —— 在函数内部声明的 static 变量会在整个程序生命周期内保持其值。即使函数结束后,静态变量的值也不会丢失,再次调用时该变量仍会保留之前的值。在上面的那段程序中的,我们连续调用两次 function(),会分别输出 0 和 1,即使第二次调用的时候函数内的非 static 局部变量应该已经丢失了值。
在类中(修饰变量):在类中声明的 static 成员变量是共享的,所有该类的对象都可以访问同一个变量。静态成员变量在类中声明,但必须在类外定义和初始化。根据这个性质,它适合被用来实现单例模式(singleton pattern),例如:
注意在该类中,变量 health 被标记为 static int,我们可以在 main() 函数中在没有生成 GameManager 实例的情况下就可以修改 / 使用 health。 在这里,声明单例的方法是通过在 getInstance() 中创建 static GameManager instance。由于该变量在函数中被标记为 static,再次调用本函数的时候就不会再创建该对象。
在类中(修饰方法):static 成员函数可以在不实例化对象的情况下被调用。static 成员函数只能访问类的 static 变量,不能访问非静态成员。同样还是在上面的代码中的,我们注意到(非静态)函数 show() 与静态函数 showHealth() 之间的区别。
不过无论如何,static 变量是被存放在全局 / 动态存储区的,记住这个就可以了。
内存栈
内存栈区(Stack)用于存储函数调用时的局部变量、函数参数以及返回地址。
当函数调用完成后,分配给该函数的栈空间会被自动释放。例如,
显然,s 是一个局部变量,a 和 b 是函数参数,因此它们的数据都存放在栈区。当 function() 调用结束之后的,对应的栈区所占空间就会被回收。
内存堆
内存堆区(Heap)是用于动态内存分配的区域。在 C++ 中使用 new 关键字时(或者 C 中使用 malloc)分配的内存块处于堆区。我们需要手动释放这些内存,否则可能造成内存泄漏。例如,
常量区
字符串字面量和其他编译时常量是存在于常量区(Constant Storage)的,这个区域通常也是只读的。
内存管理
动态内存分配
在 C/C++ 程序运行时需要额外的虚拟内存时,我们就会使用动态内存分配器请求新的内存块。
动态内存分配器(Dynamic Memory Allocator)维护着一个进程的虚拟内存区域,这个区域就是我们上面说过的内存堆(heap)。分配器将堆视作一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片(chunk),这个块要么是已经被分配的(allocated),要么是空闲的(free)。
已分配的块是应用程序正在使用的,空闲块则可以用来给应用程序使用。一个块不再使用时,为了之后的请求能有内存块可以使用,我们必须要释放(free)这个块。这种释放可以是:
显式的:例如 C 中的 free,C++ 中的 delete
隐式的:例如 Java, C# 中的垃圾收集器(Garbage Collector)。
当程序需要去使用一个块的时候,就要寻求动态内存分配器去分配一个块,这个分配的过程总是显式的,例如 C 中的 malloc, C++ 中的 new。
内存碎片
有的时候,内存中空闲块的总额虽然可能可以响应一个 malloc/new 的请求,但是却因为在物理上这些块被分成了多个小块且散布在堆上(或者被已分配块隔开),导致 new 无法申请到完整的一块足够大的内存块,这种情况就叫做内存碎片。
上述这种情况也被叫做外部碎片。有时也会出现内部碎片,即已分配块比其有效载荷大(因为分配器可能根据最小块分配要求给它分了一个比它大的块)。
空闲链表
空闲链表是分配器用来查找和使用空闲块的数据结构。我们实现这种数据结构的方法有很多。
隐式空闲链表
一个简单的块的结构可能是:
头:包括块的大小(前 29 位)以及分配情况(后 3 位,001表示已分配,000表示未分配)。
有效载荷:malloc/new 请求的有效载荷。
填充(可选):可能是用来满足对其要求,可能是其他。
因为空闲块实际上通过头部的大小字段就可以隐含地连接(从堆起点开始遍历,然后找到分配位为 0 的块比较大小即可)。
优点是概念和实现上都很简单,缺点是真的很慢。
显式空闲链表
在显式的一个空闲块中,头之后的结构是一个 pred 和一个 succ 指针,分别指向前一个和后一个。分配块则与隐式相同。这样一来,分配的时间从块总数的线性时间变成了空闲块的线性时间。释放块的时间可以是 O(N) 也可以是 O(1),取决于我们用 LIFO 结构存储链表,还是使用地址顺序来排序链表。
显式的优点在于优化首次适配时间,但是缺点在于更大的最小块(因为空闲块必须存储头部以及所有需要用到的指针),潜在地提高了碎片的可能性。
分离空闲链表
我们不再维护唯一的空闲链表——取而代之,我们将空闲块分成多个大小类,然后在那个大小类对应的空闲链表里面去寻找块。
C++ 中的 new / delete 与 C 中的 malloc / free 的区别
指针和引用
传递
参考资料:
C++ Primer Plus.
Comments