前言

用 Python 写代码时,列表(list)是我们最常打交道的数据结构 —— 存数据、遍历、修改,似乎 “拿来就用” 就行。但你有没有遇到过这样的困惑:

  • 同样是存 100 个元素,列表[1,2,3,…100]和[“a”,“b”,“c”,…“z”,…]的内存占用差了好几倍;
  • 明明只存了 10 个元素,用sys.getsizeof()查看列表内存,却发现比 “10 个元素的大小之和” 大很多;
  • 频繁给列表append元素时,程序偶尔会卡顿一下,像是 “卡壳” 了。

这些问题的根源,都藏在 Python 列表的内存存储本质里。今天我们就扒开列表的 “外衣”,从底层逻辑讲清:列表到底怎么存数据?为什么会有内存差异?以及如何优化列表内存占用,让代码跑得更快。


一、先破误区:Python 列表不是 “数组”,而是 “动态数组”

很多人会把 Python 列表和 C 语言的 “数组” 画等号,但其实两者差别巨大 —— 这也是理解列表内存的关键。

先看一个对比:

很多人会把 Python 列表和 C 语言的 “数组” 画等号,但其实两者差别巨大 —— 这也是理解列表内存的关键。

先看一个对比:

特性 c语言数组 Python列表
元素类型 必须统一(如果全是int) 可混合(入[1,“a”,True])
内存存储 直接存元素本身 存元素的‘引用’(地址)
大小是否可变 固定,创建后不能扩容 动态,可随时添加/删除元素
内存占用计算 元素大小×个数 引用大小×预分配容量+元素本身内存

简单说:Python 列表是 “动态数组”,它存储的不是元素本身,而是指向元素的 “引用”(类似指针)。

  • C 语言数组像 “快递箱”,每个箱子里直接装着物品(元素),所有箱子大小一致,排列整齐;
  • Python 列表像 “快递单表格”,表格里每一行只写着 “快递存放地址”(引用),真正的物品(元素)存放在其他地方,表格的行数可以随时增加,且每行的地址可以指向不同类型的物品。

二、列表内存存储的 “两层结构”:引用数组 + 实际对象

要彻底搞懂列表内存,必须记住它的 “两层结构”:

1.第一层:列表自身的 “引用数组”(固定大小,动态扩容)

当你创建一个列表(比如lst = [1, “a”, True])时,Python 会先在内存中开辟一块连续空间,用来存储 “引用”—— 每个引用占 8 字节(64 位 Python),不管指向的元素是什么类型。

比如lst = [1, “a”, True]的引用数组:

引用1(8字节) 引用2(8字节) 引用3(8字节)
指向 int 对象 1 指向 str 对象“a” 指向 bool 对象 True

这个 “引用数组” 的大小,就是列表的 “容量(capacity)”—— 它不等于列表的 “长度(length)”。比如你用lst = []创建空列表时,Python 会默认分配 4 个引用的容量(即 32 字节),但此时列表长度为 0。

2. 第二层:引用指向的 “实际对象”(分散存储,类型独立)

列表的引用数组里,每个地址都指向内存中另一个地方的 “实际对象”—— 比如 int 对象、str 对象、自定义类对象等。这些对象的内存占用各不相同:

  • int 对象(小整数,如 1-256):Python 会缓存,每个占 28 字节(64 位 Python);
  • str 对象(如 “a”):每个字符串的内存 = 固定 overhead(49 字节) + 字符长度 × 1 字节(UTF-8);
  • bool 对象(True/False):单例对象,内存固定(28 字节)。

这些实际对象在内存中是分散存储的,列表只通过 “引用” 关联它们 —— 这就是为什么列表能存混合类型元素的原因。

3. 用代码直观看内存结构

我们用sys.getsizeof()(查看对象自身内存)和id()(查看对象内存地址)来验证:

import sys
# 1. 创建列表,查看列表自身内存(引用数组的大小)
lst = [1, "a", True]
print(f"列表自身内存:{sys.getsizeof(lst)} 字节")  # 输出:48字节(3个引用×8字节 + 列表头部信息24字节)
# 2. 查看列表中每个元素的引用(地址)
for elem in lst:
print(f"{elem} 的内存地址:{id(elem)}")  # 输出不同的地址,对应实际对象
# 3. 查看实际对象的内存(不是列表的内存)
print(f"int对象1的内存:{sys.getsizeof(1)} 字节")    # 输出:28字节
print(f"str对象'a'的内存:{sys.getsizeof('a')} 字节")# 输出:50字节(49+1)
print(f"bool对象True的内存:{sys.getsizeof(True)} 字节")# 输出:28字节

关键结论:列表的内存占用 = 列表自身内存(引用数组 + 头部信息) + 所有元素对象的内存之和。

三、列表内存差异的 3 个核心原因

现在回到开篇的问题:为什么同样是 100 个元素,列表内存占用差很多?本质是 “元素对象的内存差异” 和 “列表扩容机制” 导致的。

1. 原因 1:元素类型不同,实际对象内存差巨大

这是最主要的原因。我们对比 “存 100 个 int” 和 “存 100 个字符串” 的内存:

import sys
# 案例1:列表存100个int(1-100)
int_lst = list(range(1, 101))
# 列表自身内存(100个引用×8 + 头部24)≈ 824字节
print(f"int列表自身内存:{sys.getsizeof(int_lst)} 字节")
# 所有int对象内存(每个28字节,小整数缓存复用,实际重复对象不占额外内存)
int_total = sum(sys.getsizeof(elem) for elem in int_lst)
print(f"100个int对象总内存:{int_total} 字节")  # 输出:2800字节(100×28)
print(f"int列表总内存(估算):{sys.getsizeof(int_lst) + int_total} 字节")  # ≈ 3624字节
# 案例2:列表存100个不同字符串("str_1"到"str_100")
str_lst = [f"str_{i}" for i in range(1, 101)]
# 列表自身内存和int列表一致,≈824字节
print(f"str列表自身内存:{sys.getsizeof(str_lst)} 字节")
# 所有str对象内存(每个字符串长度不同,内存不同)
str_total = sum(sys.getsizeof(elem) for elem in str_lst)
print(f"100个str对象总内存:{str_total} 字节")  # 输出:≈6000字节(每个str约60字节)
print(f"str列表总内存(估算):{sys.getsizeof(str_lst) + str_total} 字节")  # ≈ 6824字节

差异一目了然:100 个字符串的总内存是 100 个 int 的 2 倍多,因为每个字符串对象的内存(约 60 字节)远大于 int 对象(28 字节)。

如果字符串更长(比如每个是 100 字符的文本),差异会更大 —— 字符串越长,sys.getsizeof(str)的值越大,列表总内存自然飙升。

2. 原因 2:列表的 “动态扩容” 机制,导致内存冗余

Python 列表是动态的,当你用append()添加元素时,一旦 “长度(length)” 超过 “容量(capacity)”,列表会自动扩容 —— 申请一块更大的内存,把原来的引用数组复制过去,原来的内存会被回收。

扩容规则(不同 Python 版本略有差异):

  • 当容量 < 512 时:每次扩容到原来的 2 倍;
  • 当容量 ≥ 512 时:每次扩容到原来的 1.125 倍。‘

这种 “预分配” 机制会导致列表有 “内存冗余”—— 比如你只存了 101 个元素,列表可能已经分配了 200 个引用的容量,多余的 99 个引用位置是空的,但依然占用内存。

用代码看扩容过程:

import sys
lst = []
# 跟踪列表长度和内存的变化
for i in range(10):
lst.append(i)
length = len(lst)
memory = sys.getsizeof(lst)
print(f"长度:{length},内存:{memory} 字节")

输出结果(64 位 Python):
长度:1,内存:40 字节(初始容量4,4×8+24=56?不同版本可能有差异,核心是扩容)
长度:2,内存:40 字节(未超容量)
长度:3,内存:40 字节(未超容量)
长度:4,内存:40 字节(未超容量)
长度:5,内存:72 字节(扩容到8,8×8+24=88?实际输出可能不同,核心是容量翻倍)

可以看到:当长度超过容量时,内存突然增加 —— 这就是扩容导致的内存冗余。如果频繁 append 大量元素,会多次触发扩容,不仅占用更多内存,还会有复制引用的性能开销。

3. 原因 3:元素是否被复用(缓存机制)

Python 对小整数(-5 到 256)、短字符串等对象有 “缓存机制”—— 这些对象会被提前创建,重复使用时不会开辟新内存。

比如你创建lst1 = [1,2,3]和lst2 = [1,2,3],两个列表中的 1、2、3 指向同一个 int 对象,不会占用双倍内存。但如果是大整数(如 1000)或长字符串,每次创建都会开辟新内存,列表总内存会增加。

用代码验证缓存:

# 小整数(1)被缓存,两个列表的1指向同一地址
lst1 = [1]
lst2 = [1]
print(id(lst1[0]) == id(lst2[0]))  # 输出:True(同一对象)
# 大整数(1000)不被缓存,两个列表的1000指向不同地址
lst3 = [1000]
lst4 = [1000]
print(id(lst3[0]) == id(lst4[0]))  # 输出:False(不同对象)

这意味着:存小整数、短字符串的列表,元素对象内存会被复用,总内存更低;存大整数、长字符串的列表,元素对象内存无法复用,总内存更高。

四、4 个列表内存优化建议,代码效率直接提升

理解了内存本质,我们就能针对性优化 —— 既能减少内存占用,又能提升运行速度。

  1. 优化 1:存同类型数值?用 array 模块代替列表

如果列表只存同类型数值(如 int、float),用array.array比列表省内存 —— 因为 array 直接存元素本身,不是引用,且没有对象的额外开销。

对比代码:

import sys
import array
# 列表存1000个int
lst = list(range(1000))
print(f"列表内存:{sys.getsizeof(lst)} 字节 + 元素对象内存 ≈ 8024 + 28000 = 36024 字节")
# array存1000个int('i'表示int类型)
arr = array.array('i', range(1000))
print(f"array内存:{sys.getsizeof(arr)} 字节")  # 输出:4028 字节(1000×4 + 头部28字节,int占4字节)

差异惊人:1000 个 int 的 array 内存(4028 字节)只有列表的 1/9!因为 array 没有引用数组,直接存二进制数值,且没有 int 对象的 28 字节开销。

适用场景:科学计算、数据处理中存大量同类型数值(如传感器数据、股价数据)。

  1. 优化 2:提前知道列表大小?先预分配容量

避免频繁扩容的关键是 “提前分配足够的容量”—— 如果知道列表最终会有 1000 个元素,不要用空列表多次 append,而是直接创建指定大小的列表。

对比两种方式的内存和性能:

import sys
import time
# 方式1:空列表append(会触发多次扩容)
start_time = time.time()
lst1 = []
for i in range(10000):
lst1.append(i)
print(f"append方式内存:{sys.getsizeof(lst1)} 字节")  # 扩容后容量可能是16384,内存≈131096字节
print(f"append方式耗时:{time.time() - start_time:.6f} 秒")
# 方式2:预分配容量(用[0]*n或list(range(n)))
start_time = time.time()
lst2 = [0] * 10000  # 直接创建10000个元素的列表,容量=长度,无冗余
for i in range(10000):
lst2[i] = i
print(f"预分配方式内存:{sys.getsizeof(lst2)} 字节")  # 容量=10000,内存≈80024字节
print(f"预分配方式耗时:{time.time() - start_time:.6f} 秒")

结果:预分配方式的内存比 append 少 40%,耗时也更少 —— 因为没有扩容的复制开销。

  1. 优化 3:清理无用引用,避免内存泄漏

列表的引用会 “持有” 对象 —— 如果列表不被使用了,但依然引用着大对象(如大字符串、大字典),这些对象无法被垃圾回收(GC),会导致内存泄漏。

优化方法:

  • 不再使用的列表,及时赋值为None(断开引用);
  • 列表中存过大对象时,使用后及时清空列表(lst.clear())。

示例:

# 错误:列表引用大对象,用完后未清理
big_str = "a" * 1024 * 1024  # 1MB的大字符串
lst = [big_str]
# 列表用完后,没有清理引用,big_str无法被GC回收
# lst = None  # 正确:赋值为None,断开引用
# 正确:清理列表引用
lst.clear()  # 清空列表中的引用
lst = None   # 断开列表自身的引用
  1. 优化 4:处理超大数据?用生成器代替列表

如果只是遍历数据,不需要随机访问(如lst[0]),用生成器(generator)代替列表 —— 生成器不会一次性把所有元素加载到内存,而是 “按需生成”,内存占用几乎可以忽略。

对比列表和生成器:

import sys
# 列表:生成100万个int,内存占用巨大
lst = list(range(1000000))
print(f"列表内存:{sys.getsizeof(lst)} 字节")  # 输出:8000040 字节(≈8MB)
# 生成器:生成100万个int,内存仅占用生成器自身大小
gen = (i for i in range(1000000))
print(f"生成器内存:{sys.getsizeof(gen)} 字节")  # 输出:112 字节(固定大小)
# 遍历生成器,按需生成元素
for i in gen:
if i > 100:
break

适用场景:批量处理数据(如读取大文件、处理 API 返回的大量数据),只需遍历一次,不需要保存所有元素。

五、总结:列表内存的 3 个核心要点

  1. 列表存的是引用,不是元素本身:列表的内存 = 引用数组内存 + 所有元素对象内存,元素类型不同,内存差异巨大;
  2. 动态扩容导致内存冗余:append 时会预分配容量,提前知道大小就预分配,避免频繁扩容;
  3. 优化看场景:同类型数值用 array,超大数据用生成器,及时清理引用避免泄漏。

其实列表的内存逻辑,本质是 Python “灵活” 与 “性能” 的权衡 —— 它用引用和动态扩容换来了灵活的使用体验,但也带来了内存开销。理解这种权衡,才能在 “方便” 和 “高效” 之间找到平衡,写出更优雅的 Python 代码。

如果你之前没注意过列表的内存问题,不妨用sys.getsizeof()检测一下自己的代码 —— 可能会有意外的发现!欢迎在评论区分享你的检测结果,或者聊聊你遇到过的列表内存问题~

↓ ↓ ↓ 加下方名片找我,直接拿源码还有案例 ↓ ↓ ↓

更多推荐