码农高天
乘法构造通过精准分配仅申请所需指针,而字面量与推导式分别因一次性扩容和逐步追加触发带对齐的超额预分配,导致看似相同的列表占用从80到120字节不等。
AI-assisted summary of 【Python】三个看似一样的列表,占用内存空间竟然不一样多? — original source. Check the original for full context.
Source published:
Key ideas
- 在 64 位 Python 3.9 环境下,创建同样包含三个元素的列表 `[0]*3`、`[0, 0, 0]` 和 `[0 for _ in range(3)]` 占用的内存分别为 80 字节、120 字节和 88 字节。 (0:00)
- `[0] * 3` 底层使用 `list_repeat` 与 `list_prealloc` 精准申请 3 个指针槽位,完全无冗余空间,因此内存占用最小。 (3:00)
- `[0, 0, 0]` 依托 `LIST_EXTEND` 一次性触发 `list_resize(3)`,经 CPython 超额扩容公式与 4 字节对齐后直接预分配 8 个槽位,导致内存占用最大。 (7:30)
- 推导式 `[0 for _ in range(3)]` 逐个执行 append 操作,在首次调用 `list_resize(1)` 时预分配 4 个槽位,后续两次追加因命中可用空间缓冲未再触发扩容。 (7:30)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2023 年 9 月 27 日发布。视频源于作者在技术交流群中看到的一个讨论,简介指出三种创建 Python 列表的不同方式在底层占用的内存空间居然各不相同,因此作者展开了深入探究。
## TL;DR 视频通过反编译字节码与深挖 CPython 源码(Objects/listobject.c),系统性剖析了 `[0] * 3`、`[0, 0, 0]` 与 `[0 for _ in range(3)]` 内存占用分别为 80 字节、120 字节和 88 字节的根本原因。乘法初始化通过 `list_repeat` 与 `list_prealloc` 精准分配 3 个槽位;字面量构建通过 `list_extend` 触发单次 `list_resize(3)`,因 CPython 的超额预分配公式与 4 字节对齐机制直接获得了 8 个槽位;列表推导式则逐次调用 `list_append`,首个元素分配了 4 个槽位后,后续两次追加均命中已分配缓冲而未触发重新分配。由此展示了 Python 在动态数组内存管理上为了避免内存碎片和频繁系统调用所采取的工程权衡。
## Atmosphere & style 视频兼具硬核极客的技术探究感与娓娓道来的教学亲和力,开篇从一个反直觉的群聊疑难切入,迅速勾起观众的好奇心。作者像带领观众拆解精密机械一样,循序渐进地由 Python 语法穿透至字节码,再深入到 C 源码中的位运算公式进行严密推演。整个过程条理清晰、抽丝剥茧,当荒谬的表面现象最终被底层工程哲学的合理性完全解释时,带给观众一种豁然开朗的智性满足感。
## Bulletin - [00:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=0s) 在 64 位 Python 3.9 环境下,创建同样包含三个元素的列表 `[0]*3`、`[0, 0, 0]` 和 `[0 for _ in range(3)]` 占用的内存分别为 80 字节、120 字节和 88 字节。 - [03:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=180s) `[0] * 3` 底层使用 `list_repeat` 与 `list_prealloc` 精准申请 3 个指针槽位,完全无冗余空间,因此内存占用最小。 - [07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) `[0, 0, 0]` 依托 `LIST_EXTEND` 一次性触发 `list_resize(3)`,经 CPython 超额扩容公式与 4 字节对齐后直接预分配 8 个槽位,导致内存占用最大。 - [07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) 推导式 `[0 for _ in range(3)]` 逐个执行 append 操作,在首次调用 `list_resize(1)` 时预分配 4 个槽位,后续两次追加因命中可用空间缓冲未再触发扩容。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=0s) Python 语法层面看似相同的三个列表由于底层字节码指令不同,在 C 语言层面的内存分配策略产生了显著分化。 - [00:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=0s) 通过 `dis` 模块可发现三者核心指令差异:`[0] * 3` 对应 `BINARY_MULTIPLY`,字面量对应 `LIST_EXTEND`,推导式对应循环中的 `LIST_APPEND`。 - [03:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=180s) 在 64 位系统的 Python 3.9 中,空列表 `PyListObject` 基础开销为 56 字节,每个元素指针占用 8 字节。 - [03:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=180s) `list_repeat` 采用乘法倍率按需精准分配内存,通过 `list_prealloc` 仅申请 $3 \times 8 = 24$ 字节指针空间,总内存为 80 字节。 - [04:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=280s) 字面量与列表推导式底层均依赖 `list_resize`,但前者是一步到位请求 3 个槽位,后者是连续三次逐步请求 1、2、3 个槽位。 - [05:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=340s) 现代动态数组为了避免频繁 `malloc` 系统调用与内存碎片,普遍采用冗余超额预分配的扩容策略。 - [05:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=340s) CPython 的 `list_resize` 设有缓冲机制,当 `allocated/2 <= newsize <= allocated` 时直接复用已分配内存而不重分配。 - [05:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=340s) `list_resize` 的扩容公式基于当前尺寸位移并加上固定偏移量,随后抹去二进制末两位执行 4 槽位对齐。 - [07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) 字面量列表代入公式计算得到 9 个槽位,向下对齐后预分配 8 个槽位,总内存计算为 $56 + (8 \times 8) = 120$ 字节。 - [07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) 列表推导式在插入第一个元素时按公式预分配 4 个槽位,后两次插入未超出缓冲保护区间,总内存保持在 $56 + (4 \times 8) = 88$ 字节。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=0s) 一个群聊谜题与三种列表初始化的字节码差异
前两天码农高天在一个技术交流群里看到了一个极其有趣的讨论:有人发现在 Python 中创建三个看似一模一样的列表,最终占用的内存大小居然完全不同。熟悉 Python 的开发者都知道,无论是使用乘法列表 `[0] * 3`、直接写字面量 `[0, 0, 0]`,还是使用列表推导式(list comprehension)`[0 for _ in range(3)]`,最终生成的都是包含三个元素 `[0, 0, 0]` 的普通列表。然而,一旦通过 `sys.getsizeof()` 测量这三个对象的内存开销,会发现三者的内存占用大小截然不同。为了彻底弄明白底层机理,高天决定带大家从字节码一路深挖到底层的 C 语言实现。
```mermaid flowchart LR accTitle: "三种创建列表表达式及其底层机制" accDescr: "展示三种看似相同的列表创建表达式在底层字节码与 C 实现上的对应关系" subgraph S1["Python 表达式"] E1["[0] * 3"] E2["[0, 0, 0]"] E3["[0 for _ in range(3)]"] end subgraph S2["字节码指令 (dis)"] B1["BINARY_MULTIPLY"] B2["LIST_EXTEND"] B3["LIST_APPEND"] end subgraph S3["C源码底层函数"] C1["list_repeat / list_prealloc"] C2["list_resize(3)"] C3["三次调用 list_resize(n+1)"] end E1 --> B1 --> C1 E2 --> B2 --> C2 E3 --> B3 --> C3 ```
Python 作为一种运行在 C 语言编写的虚拟机上的脚本语言,会先将源代码编译为字节码(bytecode),再由虚拟机逐条解释执行。因此,对比 Python 语法差异最直接有效的手段就是利用内置的 `dis` 模块。当把这三个表达式分别传入 `dis.dis` 之后,字节码层面立刻暴露出了显著区别:“起码你能看出来这三个字节码是不一样的,这个就足够了,起码给了我们一个研究的方向。” - `[0] * 3`:核心指令是 `BINARY_MULTIPLY`,即在单元素列表基础上执行乘法操作; - `[0, 0, 0]`:实际上构建了一个包含三个 `0` 的常量元组(tuple),然后利用指令 `LIST_EXTEND` 一次性将该元组追加到一个空列表上; - `[0 for _ in range(3)]`:内部逻辑相对复杂,但最核心的操作是依靠循环机制,一次又一次调用 `LIST_APPEND` 逐个压入元素。
[03:00](https://www.youtube.com/watch?v=3z0OOJffEFM&t=180s) 追溯 CPython 源码:`list_repeat` 与精准分配
要揭开内存占用的具体差异,唯一的途径是深入到 CPython 的源码实现,具体文件位于 `Objects/listobject.c`。高天首先带着大家剖析内存占用最小的第一种方式:`[0] * 3`。在 CPython 中,乘法指令底层调用的是 `list_repeat(PyListObject *a, Py_ssize_t n)` 函数。
```mermaid flowchart TD accTitle: "list_repeat 内存精准分配流程" accDescr: "展示 list_repeat 函数如何根据原列表长度与倍数精准申请所需槽位" A["传入单元素列表 a (size=1) 与乘数 n=3"] --> B["计算所需大小: size = a->ob_base.ob_size * n = 3"] B --> C["调用 list_prealloc(size)"] C --> D["底层执行 PyMem_New 申请恰好 3 个指针的内存空间"] D --> E["空列表基础开销 56 字节 + 3 × 8 字节 = 80 字节"] ```
在 `list_repeat` 中,代码通过传入的列表对象 `a` 以及倍数 `n`,调用 `list_prealloc(size)` 分配目标内存。这里的关键逻辑在于 `size` 的计算:它是直接通过原列表当前的 `size` 乘以倍率 `n` 得出的。因为原列表 `[0]` 的大小是 1,乘以 3 之后需要的槽位数精准地等于 3。跟踪到 `list_prealloc` 可以看到,底层使用的是 `PyMem_New` 向操作系统要内存,也就是“他需要多少 size,他就申请了多少 size”,整个过程完全是定制且精准的,没有任何多余的预分配。 - 在 64 位操作系统及 Python 3.9 环境下,一个空的 `PyListObject` 基础开销为 56 个字节(bytes); - 64 位平台上的每个指针占用 8 个字节; - 恰好申请 3 个指针空间对应 $3 \times 8 = 24$ 字节; - 基础开销加上元素指针:$56 + 24 = 80$ 字节,这正是内存占用最小的由头。
[04:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=280s) 字面量与推导式的底层机制:一次扩容与逐次扩容的对比
紧接着看字面量列表 `[0, 0, 0]` 的情况,它通过 `list_extend` 机制实现。在 `list_extend` 函数中,向操作系统申请或调整内存使用的是 `list_resize` 函数。系统首先读取列表起始长度 $m$(初始为 0),再结合待插入元组的元素量计算出新容量 $n$(即 3),随后直接触发一次 `list_resize(self, 3)`。它是一步到位地申请到目标长度。
相比之下,列表推导式使用的是 `list_append`,其底层调用的是 `app1` 辅助函数。翻看代码会发现,`app1` 内部同样调用了 `list_resize`,但它的调用方式与 `list_extend` 完全不同:由于 append 是一项单元素追加操作,它每次都会执行 `list_resize(self, n + 1)`。从空列表开始填满 3 个元素,`app1` 实际上经历了三次连续调用: 1. `list_resize(self, 1)` 2. `list_resize(self, 2)` 3. `list_resize(self, 3)`
高天在此设问:既然两者最后都达到了长度 3,为什么一次到位的 `list_resize(3)` 与三次逐步调用的 `list_resize` 最终得到的内存储备会完全不一样?答案就深藏在 `list_resize` 的动态扩容策略中。
[05:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=340s) 可变数组的扩容哲学与 CPython 的冗余分配公式
高天解释道,普通人直觉里的动态可变列表可能是“需要 3 个空间就要 3 个,需要 4 个就要 4 个”。但如果在现实中这么干,每次扩容都去调用 C 语言的 `malloc` 或触发系统调用(system call),不仅性能极其低下,还会制造海量的内存碎片。因此现代编程语言实现动态数组时无一例外都会采用超额预分配的优化机制:“绝大部分的语言里面在实现可变长度数组的时候都是有一定的优化算法的,一般来说就是猜你大概要用多少,我先给你备着……这都是非常非常常见的优化手段,在 Python 里也不例外。”
```mermaid flowchart TD accTitle: "list_resize 内存分配与缓存判定流程" accDescr: "说明 list_resize 先判断现有分配是否满足条件,不满足时按公式计算新分配大小并对齐" A["传入 newsize"] --> B{"newsize 是否满足 allocated/2 <= newsize <= allocated?"} B -- "是 (在可用缓冲内)" --> C["直接更新 ob_size = newsize,不重新分配内存并快速返回"] B -- "否 (超出范围)" --> D["计算新容量: new_allocated = (newsize >> 3) + (newsize < 9? 3: 6) + newsize"] D --> E["4 字节对齐: new_allocated = (new_allocated + 3) & ~3"] E --> F["调用 PyMem_Realloc 申请对应数量的指针空间"] ```
在 CPython 的 `list_resize` 中,有两个至关重要的设计: 1. **可用空间缓冲判断**:结构体维护了一个 `allocated` 字段表示当前一共申请了多少槽位。如果请求的 `newsize` 恰好满足 `allocated >= newsize && newsize >= (allocated >> 1)`,说明当前闲置的槽位完全够用,Python 甚至根本不会调用底层重新分配,只会直接修改已用元素计数 `ob_size = newsize` 并立即返回。 2. **超额扩容算法与对齐**:当容量超出当前范围必须真正重分配时,CPython 采用了一个带有位移和对齐的公式来超额分配槽位,高天将其总结为核心计算逻辑: - 基础溢出计算:取 `newsize` 右移 3 位,加上常数偏移(小尺寸下为 3 或 6),再加上 `newsize` 本身; - 边界对齐:将算出来的槽位数按照 4 进行向下取整对齐(通过位运算抹除二进制最后两位)。
[07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) 揭晓谜底:120 字节与 88 字节的计算实录
高天把具体的数值带入源码公式,完整还原了另外两个数值的推导全貌:
对于字面量列表 `[0, 0, 0]`(`list_extend`): - 一上来直接调用 `list_resize(self, 3)`,此时 `newsize = 3`; - 代入核心公式计算:`3 >> 3` 为 0,加上判定偏移量 6,再加上 `newsize` 本身的 3,得到 $0 + 6 + 3 = 9$; - 对数字 9 执行向 4 对齐:9 的二进制是 `1001`,清空末两位(与 `~3` 按位与)后得到 `1000`,也就是 8; - 因此系统直接一口气为其预分配了 8 个槽位; - 算上 8 个指针槽位($8 \times 8 = 64$ 字节)和基础结构开销 56 字节,总计 $64 + 56 = 120$ 字节。
对于列表推导式 `[0 for _ in range(3)]`(三次连续 `list_append`): - 第一步调用 `list_resize(self, 1)`,此时 `newsize = 1`; - 代入公式计算:`1 >> 3` 为 0,加上判定偏移量 6,再加上 `newsize` 本身的 1,得到 $0 + 6 + 1 = 7$; - 对数字 7 执行向 4 对齐:7 的二进制清空末位抹去余数后变成了 4,因此系统首次为它分配了 4 个槽位; - 第二步与第三步追加元素调用 `list_resize(self, 2)` 和 `list_resize(self, 3)` 时,由于新需求尺寸均在已分配的 4 个槽位以及半数缓冲保护区间内,CPython 触发了命中机制,直接更新列表长度就返回了,根本没有触发二次内存分配! - 最终列表只占用了预分配的 4 个槽位:4 个指针($4 \times 8 = 32$ 字节)加上基础结构 56 字节,总计 $32 + 56 = 88$ 字节。
| 创建语法 | 底层主要操作 | CPython 内存调整方式 | 预分配槽位数 | 最终内存占用计算 | |:--- |:--- |:--- |:--- |:--- | | `[0] * 3` | `BINARY_MULTIPLY` | `list_repeat` $\rightarrow$ `list_prealloc` 精准申请 | 3 个 | $56 + (3 \times 8) = \mathbf{80}$ 字节 | | `[0, 0, 0]` | `LIST_EXTEND` | 一次性 `list_resize(3)`,超额预分配并向 4 对齐 | 8 个 | $56 + (8 \times 8) = \mathbf{120}$ 字节 | | `[0 for _ in range(3)]` | `LIST_APPEND` | 先 `list_resize(1)` 分配 4 个,后续两次命中缓存未扩容 | 4 个 | $56 + (4 \times 8) = \mathbf{88}$ 字节 |
高天在片尾感慨道:“其实有的时候你觉得很神奇甚至有一点荒谬的东西呢,如果你仔仔细细的去研究一下下面代码,它都是可以解释的,而且你认真琢磨一下,它都是很合理的。”如果观众对这类剖析 Python 底层原理的内容感兴趣,欢迎点赞一键三连关注,后续他还会继续带来关于 CPython 机制的深入剖析与分享。
## Notable quotes > [07:30](https://www.youtube.com/watch?v=3z0OOJffEFM&t=450s) 其实有的时候你觉得很神奇甚至有一点荒谬的东西呢,如果你仔仔细细的去研究一下下面代码,它都是可以解释的,而且你认真琢磨一下,它都是很合理的。 — 高天
> [05:40](https://www.youtube.com/watch?v=3z0OOJffEFM&t=340s) 绝大部分的语言里面在实现可变长度数组的时候都是有一定的优化算法的,一般来说就是猜你大概要用多少,我先给你备着……这都是非常非常常见的优化手段,在 Python 里也不例外。 — 高天
## Mentioned Python, CPython, dis, 高天, sys.getsizeof, PyMem_New, PyMem_Realloc, list_repeat, list_prealloc, list_resize, list_extend, list_append, app1
## Video description 前两天在群里看到一个问题,三种不同的建立列表的方式,占用内存空间竟然是不一样的。
我研究了一下,发现事情并不简单……
[Source](https://www.youtube.com/watch?v=3z0OOJffEFM)