深入理解 Python 虚拟机:字典(dict)的优化

1. 引言

Python 的字典(dict)是这门语言最核心、最常用的数据结构之一。无论是模块的命名空间、对象的属性查找,还是函数的关键字参数传递,背后都离不开字典的身影。正因如此,CPython 对字典的实现进行了多轮深度优化,使其在保持灵活性的同时,也能拥有出色的性能。

本文将深入 CPython 源码,剖析字典从早期版本到现代版本(Python 3.6+)的演进历程,重点讲解紧凑布局(compact layout) 、哈希表结构 、冲突解决策略以及**键共享(key-sharing)**等关键优化技术,帮助你真正理解 Python 字典为什么快、快在哪里。

2. 字典的基础:哈希表

字典本质上是一张哈希表(hash table)。它通过哈希函数将键(key)映射到一个固定大小的数组索引上,从而实现平均 O(1) 的查找、插入和删除。

2.1 哈希函数与哈希值

Python 中每个对象都可以通过 hash() 函数获取一个整数哈希值。对于整数,哈希值就是它本身(取模后);对于字符串,CPython 使用 SipHash 算法计算;对于自定义对象,则调用其 __hash__ 方法。

python 复制代码
>>> hash("hello")
-1182516015910
>>> hash(42)
42

需要注意的是,哈希值并不是直接作为数组下标使用的,而是需要经过一步**取模(mod)**运算,映射到哈希表的容量范围内。

2.2 冲突与解决

当两个不同的键计算出相同的数组索引时,就发生了哈希冲突(collision)。CPython 采用**开放寻址法(open addressing)**来解决冲突:当某个槽位已被占用时,会按照一定的探测序列(probe sequence)继续向后查找,直到找到空槽或匹配的键。

python 复制代码
# 伪代码:开放寻址的探测过程
def find_slot(table, key):
    index = hash(key) % len(table)
    while table[index] is not None and table[index].key != key:
        index = (index + 1) % len(table)  # 线性探测
    return index

CPython 实际使用的探测策略比简单的线性探测更精细,它基于哈希值的高位信息来生成探测步长,从而减少聚集(clustering)现象。

3. 传统实现:分离式存储(Python 3.5 及以前)

在 Python 3.5 及更早版本中,字典的底层结构是一个单一的 entries 数组,每个 entry 同时存储哈希值、键和值:

c 复制代码
typedef struct {
    Py_hash_t me_hash;      // 哈希值
    PyObject *me_key;       // 键
    PyObject *me_value;     // 值
} PyDictEntry;

整个字典就是这样一个 entry 数组,加上一些元信息(容量、已用数量等)。

3.1 传统实现的问题

这种实现有一个明显的缺点:内存利用率低。为了保证查找效率,哈希表的负载因子(load factor)通常维持在 2/3 以下,也就是说有大约 1/3 的槽位是空的。在传统实现中,这些空槽位同样占用完整的 entry 空间(每个 entry 24 字节),造成了大量内存浪费。

此外,遍历字典时,需要扫描整个数组,跳过所有空槽位,效率也不高。

4. 现代实现:紧凑布局(Python 3.6+)

Python 3.6 引入了一项革命性的优化------紧凑字典(compact dict) ,由 INADA Naoki 提出。这一设计将索引 与数据分离,从根本上解决了内存浪费问题。

4.1 双数组结构

现代字典由两个数组组成:

  1. 索引数组(indices) :一个 int 数组,长度等于哈希表的容量(通常是 2 的幂),存储的是 entry 在 entries 数组中的下标。
  2. entries 数组:一个紧凑的 entry 数组,只包含实际存在的键值对,按插入顺序排列。
c 复制代码
typedef struct {
    PyObject *me_key;       // 键
    PyObject *me_value;     // 值
    Py_hash_t me_hash;      // 哈希值
} PyDictKeyEntry;

4.2 查找过程

查找时,先通过哈希值定位到 indices 数组中的某个槽位,取出对应的 entry 下标,再到 entries 数组中访问真正的键值对:

python 复制代码
# 伪代码:紧凑字典的查找
def lookup(d, key):
    idx = hash(key) % len(d.indices)
    entry_index = d.indices[idx]
    if entry_index == -1:  # 空槽
        return None
    entry = d.entries[entry_index]
    if entry.key == key:
        return entry.value
    # 冲突则继续探测

4.3 内存节省

由于 indices 数组只存储整数下标(每个 1~8 字节,视容量而定),而 entries 数组只存储实际存在的键值对,空槽位不再占用完整的 entry 空间。对于一个小字典(如 8 个键值对),内存占用可以从传统实现的数百字节降低到几十字节,节省幅度可达数倍。

4.4 保持插入顺序

紧凑布局的另一个副产品是:字典现在保持插入顺序。因为 entries 数组按插入顺序追加,遍历时只需顺序扫描 entries 数组即可,无需跳过空槽。这一特性在 Python 3.7 中被正式写入语言规范,成为所有 Python 实现必须遵守的行为。

python 复制代码
>>> d = {"b": 1, "a": 2, "c": 3}
>>> list(d.keys())
['b', 'a', 'c']  # 保持插入顺序

5. 键共享字典(Key-Sharing Dict)

在 Python 3.3 中,CPython 引入了键共享字典(key-sharing dict),专门用于优化对象属性存储。

5.1 问题背景

每个 Python 对象都有一个 __dict__ 属性字典,用于存储实例属性。如果每个实例都拥有一份独立的、完整的字典结构,那么对于大量同类的实例对象,会浪费大量内存------因为它们的键(属性名)几乎完全相同,只有值不同。

5.2 共享机制

键共享字典的核心思想是:将键(属性名)和哈希值提取出来,放到一个共享的 keys 对象中,所有同类的实例共用这一份 keys;每个实例只保存自己的 values 数组。

c 复制代码
typedef struct {
    Py_ssize_t dk_refcnt;       // 引用计数
    Py_ssize_t dk_size;         // 容量
    PyDictKeyEntry *dk_entries; // 共享的键和哈希值
    // ...
} PyDictKeysObject;

实例对象的结构变为:

c 复制代码
typedef struct {
    PyObject_HEAD
    PyDictKeysObject *ma_keys;  // 指向共享的 keys
    PyObject **ma_values;       // 本实例的值数组
} PyDictObject;

5.3 收益

对于大量同类实例(例如 ORM 模型、数据类实例),键共享字典可以显著减少内存占用。假设有 10000 个实例,每个有 5 个属性,传统实现需要 10000 份完整的字典结构;而键共享实现只需要 1 份 keys + 10000 份 values 数组,内存节省非常可观。

python 复制代码
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

# 大量 Point 实例共享同一份 keys("x", "y")
points = [Point(i, i * 2) for i in range(10000)]

5.4 注意事项

键共享字典有一个限制:一旦某个实例的字典被直接修改(例如添加了新的键),该实例就会脱离共享,转为独立的完整字典 。因此,在性能敏感的场景中,应尽量避免对实例的 __dict__ 进行动态增删键的操作。

6. 其他优化细节

6.1 哈希值缓存

对于字符串键,CPython 会缓存其哈希值。字符串对象内部有一个字段用于存储计算过的哈希值,这样同一个字符串在多次作为字典键查找时,无需重复计算哈希。

c 复制代码
typedef struct {
    PyObject_HEAD
    Py_ssize_t ob_shash;  // 缓存的哈希值,-1 表示未计算
    // ...
} PyUnicodeObject;

6.2 小字典的快速路径

CPython 对容量较小的字典(如 1~5 个键值对)提供了专门的快速路径,避免通用查找逻辑的开销。这些小型字典在创建时直接使用栈上分配的固定大小数组,进一步减少内存分配次数。

6.3 调整大小策略

当字典的负载因子超过 2/3 时,字典会进行扩容,通常将容量翻倍。扩容时,indices 数组会重新分配,但 entries 数组中的键值对可以原地保留,只需重新计算每个 entry 在 indices 中的映射关系,这比传统实现中整体搬迁 entry 数组要高效得多。

7. 性能对比与实测

为了直观感受这些优化带来的收益,我们可以做一个简单的内存对比实验:

python 复制代码
import sys

# 传统方式:大量独立小字典
dicts = [{"a": i, "b": i + 1, "c": i + 2} for i in range(10000)]
print(sys.getsizeof(dicts[0]))  # 单个字典的大小

# 键共享方式:大量同类实例
class Obj:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

objs = [Obj(i, i + 1, i + 2) for i in range(10000)]
print(sys.getsizeof(objs[0].__dict__))  # 单个实例字典的大小

在 Python 3.11 中运行上述代码,你会发现单个实例的 __dict__ 大小远小于独立字典的大小,这正是键共享优化的直接体现。

8. 总结

Python 字典的优化之路,是 CPython 在内存效率 与访问速度之间不断权衡的缩影:

  • 紧凑布局将索引与数据分离,大幅降低内存占用,并顺带实现了插入有序;
  • 键共享字典针对对象属性存储场景,让大量同类实例共享键结构,节省海量内存;
  • 哈希值缓存 与小字典快速路径等细节优化,让常见操作更加高效。

理解这些底层机制,不仅能帮助你写出更高效的代码(例如避免破坏键共享、合理预估字典容量),也能让你在阅读 CPython 源码时更加游刃有余。字典虽小,却凝聚了 Python 设计者数十年的智慧。

相关推荐
晚风醉蝶1 小时前
YOLO11图标点选验证码识别实战
python·目标检测·验证码识别·yolo11
艾莉丝努力练剑1 小时前
【QT】系统相关:多线程QThread基础
开发语言·网络·c++·qt·学习·大模型
打工仔折腾 AI1 小时前
多台服务器日志分散难查?用Promtail+Loki+Grafana搭建集中检索平台
服务器·人工智能·后端·python·性能优化·django·grafana
ysu_03141 小时前
uv实战指南-从Python版本虚拟环境到pyproject依赖管理
开发语言·python·pip·uv·虚拟环境·包管理·依赖管理
打工仔折腾 AI1 小时前
DiPlay 实测:iPhone 绕过硬件盒子直连 BYD 车机的思路拆解
android·人工智能·后端·python·gradle·iphone·ai agent 实战
東隅已逝,桑榆非晚2 小时前
c++模板进阶
开发语言·c++·笔记·学习
老歌老听老掉牙6 小时前
空间曲线数据的拼接与几何变换分析
python·曲线
Cc.Y6 小时前
Java零基础入门:字符串深度掌握——从基础API到StringBuilder性能优化
java·开发语言·性能优化
databook10 小时前
使用 SciPy 库进行时间序列分析
python·数据分析·scipy