我们在学校都学过字母表 —— 它将所有字母按照特定顺序排列,例如英语中的 A、B、C,或是罗马尼亚语中的 A、Ă、Â(没错,罗马尼亚语的字母表开头竟然包含了多个 A 的变体)。如今已无人确切知晓这种排序方式的起源,但考虑到现代字母大多源自古希腊的 Α、Β、Γ,甚至更早的古埃及或其他古老文字体系,我们实在难以追溯其最初形成的逻辑。
字母表是一种极其实用的工具,它帮助我们对世间万物进行有序归类:从昆虫世界中,“蚂蚁”(ant) 总是排在“蜜蜂”(bee) 之前;到厨房橱柜里的香料架上,如果你不是按颜色或使用频率来整理的话,那么津巴布韦特产的穆富什瓦干菜(Mufushwa)恐怕只能永远待在角落里了。正是这样的排序方式,让我们的日常生活变得井然有序。
毕竟这是一本关于编程、更具体地说是 C++ 编程 的书。我们必须聚焦主题,而不是沉迷于“蜜蜂与飞鸟”这类词典式排序的趣味讨论(显然,在字母序中 bee 是排在 bird 之前的)。
然而,当我们把目光转向 C++ 的类时,情况就变得有所不同了。在类的内部,这些可见性限制几乎不存在 —— 类的所有成员方法都可以相互访问,成员函数之间可以自由调用,整个类的世界看起来简单又和谐……
此刻我仿佛听到了你的惊呼:“但是你不应该在类内部显式调用析构函数或构造函数吧!”,但通常情况下确实不该这样做。如果执意要这样做,语法层面没有问题 —— 谁也拦不住你:
struct a_class {
void reboot() {
this->~a_class();
new (reinterpret_cast<void*>(this)) a_class();
}
}
然而,亲爱的读者,如果你真的写出那样的代码,必将自食其果。让我们回归正题:排序。
人类的思维方式天生倾向于秩序。我们需要清晰地把握工作的整体脉络,快速定位信息所在的位置,并以最高效的方式访问它们 —— 哪怕只是在类中查找某个成员这样看似微不足道的小事,也应当确保能够轻松、迅速地找到所需内容。
在无数个为寻找类中“失踪”成员而焦头烂额的夜晚之后,一位在 BigGameDev 公司任职的游戏开发者(姑且称他为 Joe)终于灵光一闪:为什么不把所有类成员按照字母顺序排列呢?
真是个绝妙的主意!这样一来,所有人都能更轻松地定位所需的成员函数或变量。看看这代码,多么整洁、统一又赏心悦目:
struct point {
bool active;
double x;
double y;
double z;
}
这个用例本身并不复杂 —— 它只是在游戏中用于表示某个点的位置,通过 x、y、z 三个坐标值,并附带一个布尔状态值来指示该点是否处于活动状态。一切运行良好,游戏流畅,玩家也十分满意。
然而某天,项目主程注意到对这个点的某些操作耗时较长(具体是哪些操作,以及为何必须执行这些操作,这里暂且不表)。他认为,这些操作仅应在三个坐标值都发生变化时才执行,从而提升性能。
Joe 是个严谨而细致的人,首先想到的方案是:在类中存储三个 double 类型变量,用于记录上一次的 x、y 和 z 值。每次更新坐标时,对比新旧值,只有当任一坐标发生改变时,才触发相关操作。
但很快,Joe 否定了这个方案,转而提出了另一个更为“节俭”的方法:
class point {
bool active;
double x;
bool x_changed;
double y;
bool y_changed;
double z;
bool z_changed;
};
优美如诗 —— 他写的代码向来如此。Joe 将新版本的代码提交到代码库,等待夜间构建完成后,测试团队就能拿到“热腾腾”的新版本。当然,他并没有立刻去度假(毕竟他是个敬业的开发者,而且夏天还没真正开始),打算等测试通过后再安排行程。
然而,那一夜,自动化测试全线崩溃。所有测试套件无一通过,监控面板红得像某些国家的旗帜一样刺眼。第二天清晨,测试部门报告出现致命错误:游戏在运行中频繁崩溃。
日志显示,99.9% 的报错最终都指向一个根本性问题 —— 内存不足。应用程序的内存占用突然翻倍,测试机几乎无法维持目标帧率。除了持续飙升的内存分配指标外,几乎所有操作都变得异常迟缓。除 Joe 对点类的重构之外,唯一的变化是另一位开发者将主菜单背景色从深灰改成了黑色(而本该配合 Joe 修改的同事因孩子生病请假了)。
技术主管(我们姑且称这位编程高手为 Jimmy)扫了一眼代码后,几乎是脱口而出:“Joe,虽然你按字母顺序排列成员的做法很整洁,但你必须调整类成员的顺序。”Joe 的脸顿时涨得通红,就像持续集成监控屏上那片刺目的红色。但他还是强压惊讶,虚心请教原因。难道 Jimmy 看不出这段代码的优雅与美感吗?
Jimmy 的解释让他目瞪口呆:“可能忽略了 C++ 中一个至关重要的机制 —— 内存对齐。”。
C++类的内存布局受成员大小/对齐要求、继承关系和编译器填充字节影响。每个数据成员根据类型占据特定字节数 —— 这点Joe应该清楚,但他可能忽略了内存对齐问题。每个成员必须存储在对其对齐要求整数倍的内存地址上(对齐要求通常等于类型大小,也可通过编译器指令调整)。
为满足对齐约束,编译器会在成员间插入填充字节,并在类末尾填充使其大小为最大对齐要求的整数倍。以团队原有类为例(假设该架构下double类型占8字节),其内存布局原本可能是这样的:
按照这种内存对齐方式,原始类的总大小为 32 字节。然而,如今 Joe 新增了三个 bool 类型的成员变量(每个占 1 字节),编译器在进行内存布局时,可能会按照如下方式安排这些成员:
每个1字节的bool值都必须填充至8字节,以确保后续的double类型成员能正确对齐到内存地址。这使得类的总大小膨胀至56字节 —— 3个填充至8字节的bool值加上3个8字节的double值,总共占据了56字节空间。Clang编译器提供了一个查看类内存布局的编译选项:-fdump-record-layouts。为验证此案例,我们创建了包含该点类定义的简单源文件,并通过编译器进行分析:
> $ clang -cc1 -fdump-record-layouts main.cpp
*** Dumping AST Record Layout
0 | struct Point
0 | _Bool active
8 | double x
16 | _Bool x_changed
24 | double y
32 | _Bool y_changed
40 | double z
48 | _Bool z_changed
| [sizeof=56, dsize=56, align=8,
| nvsize=56, nvalign=8]
上述数据清楚地验证了我们最初的猜想:原本每个仅占 1 字节的 bool 值,实际上却占据了 8 字节的空间。(注:我们已创建名为 main.cpp 的源文件,其中包含了该 Point 结构体的定义。)
为了解决这一问题,显然需要采取进一步的优化措施。接下来,试按照以下方式重新调整类成员的声明顺序:
class point {
bool active;
bool x_changed;
bool y_changed;
bool z_changed;
double y;
double x;
double z;
}
这个改动看似微不足道(除了对Joe的字母排序强迫症造成了一些“伤害”),但我们通过将所有 bool 类型成员集中排列,显著提升了类的内存结构紧凑性。基于前述分析 —— 特别是充分考虑了各类型的内存占用后。由此,我们得出一个重要原则:应将小类型变量集中存放(所谓“小类型”,是指那些占用字节数最少的变量类型,例如在当前实现中的 bool 类型仅占 1 字节)。
过这样的成员顺序重组后,新的内存布局如下所示(实际布局可能因架构差异而略有不同):
再次使用 Clang 编译器进行验证后,类的内存占用情况与之前的版本相比有了显著的不同(注:我们也相应地修改了 main.cpp 文件中的结构体定义):
> $ clang -cc1 -fdump-record-layouts main.cpp
*** Dumping AST Record Layout
0 | struct Point
0 | _Bool active
1 | _Bool x_changed
2 | _Bool y_changed
3 | _Bool z_changed
8 | double x
16 | double y
24 | double z
| [sizeof=32, dsize=32, align=8,
| nvsize=32, nvalign=8]
由此可见,现在四个 bool 值在内存中实现了连续排列,只需在它们之后插入一段填充字节,就能满足后续 double 类型成员的对齐要求。如果我们恰好有一个大小为 4 字节的字段,完全可以让它紧接在最后一个 bool 成员之后、第一个 double 之前 —— 这样一来,原本用于填充的空间就被有效利用,真正做到“零浪费”。
听完 Jimmy 的解释,Joe 终于明白了问题的症结所在。虽然他此前从未真正遇到过内存对齐相关的问题,但他立刻意识到这是一个不容忽视的重要课题,并决定深入研究。
通过查阅相关资料,他发现了一个引人深思的事实:内存对齐是硬件架构、性能优化和系统设计共同作用的结果。
现代处理器在设计时,通常要求数据按照特定的边界对齐,以便更高效地访问内存。例如:
下面这段代码,在这些老式处理器上运行就会引发SIGBUS错误导致程序终止:
#include <cstdlib>
int main(int argc, char **argv) {
char *cptr = (char*)malloc(sizeof(int) + 1);
int* iptr = (int *) ++cptr;
*iptr = 42;
return 0;
}
这种未对齐的内存访问错误在操作系统层面往往会造成严重后果 —— 轻则导致应用程序崩溃,重则在一些老旧系统上甚至可能引发整个系统的宕机。
你也许会好奇:这里的“错误类型 7”究竟代表什么?其实答案很简单 —— 7 是 SIGBUS 信号对应的错误编号。它通常表示发生了非法的内存访问,比如访问了未对齐的数据地址。作者的Linux机器上,这个定义可以在/usr/include/x86_64-linux-gnu/bits/signum-arch.h文件的第34行找到:
/* Historical signals specified by POSIX. */
#define SIGBUS 7 /* Bus error. */
而像x86_64等新一代处理器(甚至包括老旧的80286及其后续x86平台处理器)通常能优雅处理未对齐访问,仅会产生轻微性能损耗。不过通过以下汇编指令,我们可以让这些好脾气的处理器立刻变得暴躁:
AT&T (64 bit) | Intel (32 bit) |
---|---|
pushf orl $0x40000,(%rsp) popf |
pushfd or dword ptr [esp], 40000h popfd |
这段代码通过位或(OR)操作修改了 x86 架构中 EFLAGS 寄存器的特定比特位。其中,十六进制值 0x40000 对应的是设置 AC(Alignment Check,对齐检查)标志位。当该标志位与 CR0 寄存器中的 AM(Alignment Mask,对齐掩码)位同时被启用时,处理器将对数据访问的内存对齐情况进行严格检查 —— 发现未按自然边界对齐的访问,便会触发异常。
EFLAGS 是 x86 CPU 中的一个特殊功能寄存器,包含多个用于反映处理器状态的标志位。这些标志既可以用来控制处理器的行为,也能指示当前执行环境的状态,包括算术运算条件、控制功能及系统设置等。Intel开发者中心https://www.intel.com/content/www/us/en/resources-documentation/developer.html提供了大量关于底层架构特性的详尽文档,我们强烈建议有兴趣深入了解底层机制的读者前往查阅。
虽然插入类似上面的代码可以人为触发 SIGBUS 信号(当然,实际开发中没有人会故意编写导致崩溃的逻辑),但与其关注这种极端情况,不如来看看我们的老朋友 Joe 在类成员排序上遇到的另一个问题。