ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python对象池、内建属性与属性拦截器:内存管理与属性访问深度解析

Python对象池、内建属性与属性拦截器:内存管理与属性访问深度解析 有一次我在一个技术群里看到有人贴了一段代码问为什么a 257; b 257; a is b在交互环境里返回False而换成256就是True。群里瞬间炸出一堆答案有人说这是小整数池有人说这是intern机制还有人说你运气不好对象被 GC 回收了。其实这些回答都沾边但没说到根上。这个话题牵扯出来的正是 Python 内存管理里最常被聊、也最容易被搞混的三样东西对象池小整数池、大整数池/free list、intern 机制、对象内建属性、以及属性拦截器。我把它们串在一起梳理了一遍顺带附上这些年实际踩过的一些坑。1. 从一道送命题说起为什么 256 能共享、257 就不能如果你刚接触 Python 不久可能被is和的区别搞晕过。简单说比较的是两个变量的值是否相等is比较的是两个变量是否引用同一个对象也就是内存地址是否相同。日常写代码我几乎只用因为业务逻辑关心的是值对不对而不是是不是同一块内存。但面试或者读框架源码时is却经常冒出来尤其是在判断单例对象、枚举值、默认参数的时候。小整数池就是导致上面那个送命题的元凶之一。CPython 在启动的过程中会把-5到256之间的整数对象提前创建好放在一个全局数组里。这个区间内不管你用哪种方式拿到这些整数比如字面量100、2**3、int(50)最终拿到的都是同一个预先创建好的对象。所以 a 256 b 256 a is b True而257不在这个预先创建的范围内普通情况下每次计算都会创建一个新对象所以a is b就是False。这里有一个非常经典的坑为什么有人在自己的.py文件里写a, b 257, 257然后用a is b一测发现是True原因是 CPython 编译代码块时会把相同的常量折叠进同一个常量元组co_consts。也就是说在同一个代码块里出现的两个257编译器认为它们内容一样直接复用了同一个常量对象。这段代码和交互环境逐行执行的路径不一样所以结果相反。实战中我遇到过不止一次有人因为这种表现差异误以为大整数也是缓存的然后在生产代码里用is比较整数最后被线上数据狠狠教育了一顿。所以关于小整数池你只需要记住两个重点第一它是 CPython 实现的缓存机制不是 Python 语言规范强制的第二它的范围通常是[-5, 256]这个范围的选择有一定工程考量——256是一个字节能表示的最大值索引、计数、编码转换里出现频率极高而-5以下在常见循环边界里很少用到。这个池子里的对象会被全局持有引用不会被垃圾回收所以任何时候你拿到100它都是同一个对象。2. 大整数池到底存不存在free list 与常量缓存的真实关系很多人听过大整数池这个词但翻遍官方文档也找不到正儿八经的大整数池条目。这不是你资料找得不对而是这本来就是一个民间叫法背后其实是几层不同机制的混合效果。我也曾经被这个词带偏过花了不少时间才理清楚这里直接给你拆开讲。第一层是编译期的常量缓存也就是上面说的co_consts。这个缓存对任何数值都生效不管多大只要在同一个代码块里出现多次且值相同编译器就可能复用同一个对象。但它有明显的局限性离开当前代码块就不保证有效进入函数内部、循环内部行为都会变。第二层是小整数池硬编码只覆盖[-5, 256]。这一层范围明确、全局有效。第三层才是重点也是大整数池这个说法最接近的真相内存块级别的 free list。在新一些的 CPython 实现里整数对象被销毁时如果 free list 还没满这块内存块不会立刻归还给操作系统而是被放进一个空闲列表里。下次再创建整数对象时解释器会优先从空闲列表里取一块内存出来复用。这就像你租房退租后中介把钥匙保管在手里等下一个租客来了直接给钥匙省去了重新找房源、重新签约的整套流程。这个机制带来一个很有意思的现象如果你连续创建、销毁同样的大整数id()可能会重复出现。下面这段代码你在多数新版本 CPython 里跑会看到前两次打印的地址相同def get_id(): a 10**18 b 10**18 return id(a), id(b) print(get_id()) print(get_id())第一次调用时创建了两个10**18它们可能在同一个代码块中被常量复用所以id一样函数返回后这两个对象引用计数归零内存块进入 free list。第二次调用时新对象又复用了之前释放的内存块所以你看到的地址可能还是那一个。这很容易给人造成一种大整数也被池化了的错觉。但你要清醒free list 缓存的是内存块不是数值。它不关心这块内存以后装的是10**18还是10**19只要大小够用就能复用。因此你绝对不能依赖id()是否相同来判断两个大整数是否相等也不该用is去比较任何整数。曾经有个同事为了优化性能在字典取值后判断对象是否相同时用了is结果在数据量大、对象频繁创建销毁的环境里出现了诡异的间歇性 bug排查了很久才发现是 free list 让不同对象的内存地址偶尔重合。这种 bug 极其隐蔽复现条件苛刻代价非常高。工程上对整数比较只有一个安全姿势用。如果你想深挖 free list 的具体实现可以直接去看 CPython 源码里int_free_list相关的逻辑不同小版本的容量上限和触发条件略有差异但这属于知道就行的奇技淫巧不建议依赖它写业务代码。3. intern 机制字符串世界的同一张身份证字符串驻留intern是另一个绕不开的缓存机制。它的核心思想很朴素字符串是不可变对象既然内容一样的字符串谁也不会修改它那就不如让所有相同内容的字符串都指向同一个对象既省内存又省比较时间。这个思路和整数对象池如出一辙都是用共享换效率。CPython 的字符串驻留分几个层次。第一层是标识符驻留也就是变量名、函数名、类名、属性名这些源码里出现的名字在编译阶段就会被驻留。它们几乎全部是合法的标识符形式而 Python 源码里这些名字又会被反复引用驻留的收益非常高。第二层是单字符字符串和空字符串CPython 内部有一张针对 Latin-1 单字符的缓存表所以像a、1这种单字符字符串大部分情况下拿到的都是同一个对象。第三层是整数字符串0到256这些由数字组成的字符串也会被缓存用于和整数对象配合使用。不过最容易被误解的是第四层看起来像标识符的字符串字面量。在新一些的 CPython 版本中源码里出现的字符串字面量如果内容恰好满足标识符的字符组成规则字母、数字、下划线且不以数字开头也有可能会被自动驻留。所以你会看到这样的现象 a hello_world b hello_world a is b True c hello world d hello world c is d False问题在于有可能这三个字。字符串驻留是否触发和字符串长度、代码块的编译方式、版本实现都有关系。比如某些版本对非标识符字符串也可能做驻留优化某些版本在交互环境下的行为又和文件里不一样。最坑的是is的结果在不同小版本之间都可能变化你完全不能拿它当逻辑判断用。如果你确实需要确保两个字符串是同一个对象办法是显式调用sys.intern()import sys key1 sys.intern(some_long_business_key) key2 sys.intern(some_long_business_key) print(key1 is key2) # True这个操作会去一张全局驻留表里查如果已经存在相同内容的字符串就直接返回已有对象否则把新的加进去。它最明显的收益体现在两个场景一是大量重复字符串做比较时驻留后可以改成is比较直接比指针速度飞快二是重复出现的字符串只保留一份内存降低内存占用。我做过一次日志解析工具的重构源数据里来自几十万个用户请求包含大量重复的状态字段把每个字段值都sys.intern()之后内存占用从 800 多 MB 降到了 200 多 MB解析时间也缩短了不少。但同样要泼一盆冷水sys.intern()的内存是常驻的驻留表不会主动释放如果你把大量的动态字符串比如带随机数、时间戳的内容也塞进去内存反而会爆炸。这个工具适合低基数、高重复的数据不适合高基数、低重复的数据用之前一定要权衡清楚。4. 内建属性每个对象身上都带着的固定行李说完了对象池接下来聊内建属性。你随便创建一个 Python 对象它身上除了你自己定义的属性之外还有一大堆出厂自带的属性比如__dict__、__class__、__doc__、__module__这些统称为内建属性。它们不是某个类私有设计出来的而是 Python 对象模型的一部分。我整理了一张常见内建属性的表方便你平时查阅属性作用说明__dict__存储实例属性的字典实例的命名空间默认每个实例都有__class__指向实例所属的类用于类型判断和动态调用__doc__文档字符串类、函数、模块的说明文本__name__名称类名、函数名、模块名__module__定义所在模块名序列化和调试时常用__slots__限制可定义的实例属性声明后实例不再自动创建__dict____init__初始化方法创建实例后调用这里我最想多说几句的是__dict__因为它直接和内存管理挂钩。默认情况下你创建一个类的两个实例它们各自维护一个独立的属性字典__dict__。这个字典让实例属性可以动态添加、读取、修改非常灵活但灵活性是有代价的每个实例都要多占一份字典对象的内存。典型的空字典大约占 64 字节左右当一个程序里创建了几百万个小对象时光是实例的__dict__就会积少成多。如果你需要的是固定结构的对象__slots__是一个值得研究的优化手段。在类里声明__slots__ (name, age)之后实例不再自动生成__dict__属性被改为在底层用类似 C 结构体的方式存储。带来的直接效果是单个实例的内存显著下降同时属性访问速度也会快一点。代价是不能再随意添加未声明的属性。我的经验是如果要做大规模数据建模并且实例数量达到十万级以上__slots__带来的收益是可以直接量出来的如果只是百八十个对象就不用折腾了灵活性的价值更大。内建属性还有一个容易忽略的点类和实例的__dict__是两回事。类对象的__dict__是一个mappingproxy对象里面装着类的方法、类变量、描述符实例的__dict__才是存实例属性的普通字典。你用dir(obj)看到的一长串名字其实是 Python 把类属性、实例属性和一部分内建属性合并排序后的结果不要把它和__dict__混为一谈。调试属性冲突时可以先分别打印obj.__dict__和type(obj).__dict__看清楚属性到底落在哪一层再决定怎么处理。5. 属性拦截器把属性的读写接管到自己手里如果说内建属性是每个对象身上预装好的硬件设施那属性拦截器就是你自定义的操作系统可以劫持属性的读取和赋值流程。Python 提供了三个主要入口__getattr__、__setattr__、__delattr__以及一个更底层的__getattribute__。先说最常用的__getattr__。它有一个精准的触发条件只有在常规属性查找失败之后才会被调用。也就是说实例自身属性找不到、类属性也找不到、描述符也没有匹配时Python 才会把控制权交给你。正因如此它非常适合做三件事慵懒加载属性用到时才算、动态属性生成、以及给已发布类的缺失属性做向后兼容的兜底。__getattribute__则完全不同它在每一次属性访问时无条件触发优先级凌驾于普通查找、__getattr__之上。我在实际项目里很少去重写__getattribute__因为它一旦写出问题影响的不是某个属性而是这个对象的所有属性访问包括方法名和特殊方法。如果你真的需要这种级别的控制一个固定需要注意的细节是在__getattribute__里访问别的属性时不要再走self.xxx不然会无限递归应该改走object.__getattribute__(self, xxx)。__setattr__是另一个高频拦截点。每次给实例属性赋值都会调用它。它很强大但也是无限递归的重灾区。因为你在__init__里用self.name xxx这种写法其实就是在调用__setattr__如果__setattr__内部又写了self.name xxx就会自己调自己直接RecursionError。正确姿势是用object.__setattr__(self, name, value)来绕过自己的拦截逻辑。同理__delattr__里删除属性时也要走object.__delattr__(self, name)。给你看一个我实际用过的简化版配置对象。它实现了三个能力属性赋值时做类型校验、冻结后拒绝修改、读取不存在属性时返回默认值而不是抛异常class Config: _frozen False def __init__(self, **kwargs): for key, value in kwargs.items(): object.__setattr__(self, key, value) def __setattr__(self, name, value): if self._frozen: raise AttributeError(配置已冻结不能修改) if not isinstance(name, str) or not name.isidentifier(): raise ValueError(非法属性名) object.__setattr__(self, name, value) def __getattr__(self, name): if name.startswith(_): raise AttributeError(name) return None def freeze(self): object.__setattr__(self, _frozen, True)注意我在__getattr__里对下划线开头的属性名兜底抛出了AttributeError。这个细节很重要因为框架代码、调试工具经常访问这类私有属性如果__getattr__什么都不判断就返回None会让一些依赖异常来感知属性不存在的逻辑失效。一个有经验的开发者会在重写__getattr__时对所有可能是内部约定的属性名保持谨慎。属性拦截器真正的价值在于它把 Python 对象从默认的属性容器变成了可编程的行为协议。整个对象池、内建属性、属性拦截器不是三个孤立的主题它们在我做框架设计时经常同时出现对象池决定了内部数据怎么共享和缓存内建属性决定了对象的基础内存布局属性拦截器则是一张可以灵活调整的适配层让你对外的接口设计不必被底层机制牵着走。6. 一次属性访问背后对象池和内建属性的联动把前面这几块内容拼起来看一个很明显的结论是Python 对象不是孤零零的一块内存它的行为由底层的值对象缓存和顶层的属性访问协议共同决定。举个具体场景——你访问user.name解释器先要通过类型对象找到user的类查看类及 MRO 链上有没有定义name描述符没有再去实例的__dict__里找如果依然没有才会轮到你可能重写的__getattr__。这一整条链路走的都是object.__getattribute__提供的默认逻辑。而name这个字符串本身无论是属性名还是值如果内容相同很可能已经被 intern 机制复用了同一个字符串对象。至于user、name这些标识符早在模块编译阶段就被驻留因此属性的名字比较和存储也能吃到驻留的福利。至于池的部分则是在创建值对象整数、字符串时决定它们是否共享同一份内存。从这个联动里可以提炼出几个写代码时值得长期遵守的策略。第一多用少用is除非你明确知道自己在比较单例、比较None、或者已经显式intern过的字符串。第二关注对象生命周期而不是盯着某一次的结果。你在 REPL 里测出小整数池、自动驻留、free list 的各种巧合很可能换个代码块、换个版本就变了。第三想控制内存和性能优先从数据规模和实例数量入手比如大量重复字符串用sys.intern大量小对象用__slots__这两个手段的收益最容易测量。我自己的习惯是给团队定三条简单的编码红线一所有对象比较都用二除了判单例外不用is三所有重写__setattr__的类必须在单元测试里覆盖初始化路径和冻结路径。这些看似保守的规则恰恰是从那几次线上诡异 bug 里换回来的教训。Python 的内存管理机制设计得很精妙但精妙的背面就是细节多、版本差异大与其去赌某个机制的巧合表现不如先把代码写到不依赖机制也能正确的健壮程度。这样才能既享受对象池带来的性能红利又不被它偶尔的意外行为绊倒。
返回列表