本节摘要:class 是编译期蓝图,对象是运行期产物。本节拆解 new 的完整动作、堆对象的字节级布局、静态成员与实例成员的不同挂载点,以及对象初始化顺序——理解这些,构造函数、字段初始化器的一切"怪现象"都有解释。
public class Order { private static int _nextId = 1000; private int _id; public List<string> Items { get; } = new(); public Order() => _id = _nextId++; }
new Order() 执行时,CLR 做四件事:查方法表拿到该类型的大小;在堆上分配这么多的内存(对象头 + 方法表指针 + 所有实例字段,含父类字段);把内存清零(引用字段自然为 null,数值字段为 0);执行构造函数链。四步里最容易被忽略的是"清零"——它解释了为什么 C# 字段不初始化也不会像 C 语言那样读到脏数据,也解释了为什么字段默认值只能是"天然零值"。
类与对象的关系,用建筑做类比很贴切:class 是施工图纸(元数据),存在方法表里,每个类型仅一份;对象是照图纸盖出的房子(堆实例),可以有很多栋。图纸不会重复画,房子必须逐栋盖——这也是静态成员与实例成员的本质区别。

两个部件各司其职:方法表指针是对象的自证身份,GetType、虚调用分派、instanceof 全靠它,2.4 节展开;对象头存哈希码与轻量级锁信息——你在对象上用 lock 时,同步块就在这 8 字节里升级或挂接。这也解释了一条编译器规则:lock 值类型直接报错,因为值类型根本没有对象头。
看布局还能得到性能直觉:一个只含 int 字段的类对象,真实占用是 24 字节(头 8 + 指针 8 + 数据 4 + 填充 4),是裸数据的六倍。数量级到百万时,这种膨胀就是 GC 压力的主要来源——第四章会回头算这笔账。
构造一个子类对象时,执行顺序是:子类字段初始化器 → 子类构造函数体(其中先隐式/显式调用父类构造)。严格说完整链是"父类字段初始化器与构造先完成,再轮到子类",看陷阱代码:
class Base { protected readonly ILogger Log = new ConsoleLog(); public Base() { Log.Write("基类构造"); } } class Child : Base { private readonly Service _svc; public Child() { Log.Write("子类构造"); // 安全:父类已完成 _svc = new Service(this); // 危险:把 this 泄漏出去 } }
危险在最后一行:构造未完成就把 this 交给外部,外部立刻调用虚方法会分派到子类版本,而子类字段可能还没初始化——读到的全是 null。规则很简单:构造函数里不调用可被重写的虚方法、不泄漏 this。字段初始化器的执行时机(进入构造函数体之前)同样要记住,它解释了"初始化器里引用另一个实例字段读到 null"这类怪现象。
静态字段挂在类型级数据上,全类型一份、生命周期与类型相同。静态构造函数在类型第一次被使用前由 CLR 保证执行且仅执行一次、线程安全——这个"仅一次"是运行时加锁实现的,所以静态构造可以实现懒加载单例而不用自己写锁(代价是静态构造里抛异常会导致类型终身不可用,后续访问抛 TypeInitializationException,排查时看内部异常才是真凶)。
C# 9 的 record 把"构造即定型"的数据类压缩成一行:
public record Point(int X, int Y);
编译器生成不可变属性、值相等比较、With 表达式支持与漂亮的 ToString。它的值相等语义与 struct 相同但仍是引用类型——两个内容相同的 record 实例 Equals 为真。DTO、消息、领域事件这类"数据包"类型用 record,行为丰富的类型用 class,这个分界线比"class 一把梭"清晰得多。
再补一条与布局直接相关的工程经验。某些序列化场景需要把百万级小对象常驻内存,把承载它们的类改成只含两个字段的只读结构体数组后,内存占用从上千兆降到两百兆以内,启动时的 2 代回收也随之消失——这就是布局知识换来的优化:对象头与方法表指针的那十六个字节,在百万量级面前从来不是零头。反过来,如果对象需要在多个组件间共享、生命周期跨越请求,类与引用语义才是正确选择。布局决定成本,生命周期决定选型,两把尺子一起用,结构体与类的边界就清晰了。
下一节看对象对外的数据通道:属性这个 C# 特色构件在编译产物里到底是什么。