getsizeof 只量列表壳:元素再大报一样大,独立构造可能没真独立

举报
yd_232353574 发表于 2026/11/15 09:26:34 2026/11/15
【摘要】 CPython 3.13.14 64 位实测:同样长度同样建法的列表,元素再大,getsizeof 报同一个数(一千项 8056 字节),它只量壳子。字符串扁平,getsizeof 报 1000041。真实占用得自己递归统计。一万项字典壳子 294992,递归值随共享与否在 574992 到 1023882 之间。用字面量写各自独立构造,编译期折叠成同一常量,一千个元素只有一个,递归值 9897。

用它估内存,结果对不上

排查一个占内存的服务,想先量一下各个对象有多大,第一反应就是 sys.getsizeof。可量出来的数跟实际内存差得离谱,一个装着上千个大字符串的列表,报出来的大小居然跟装上千个 None 的列表一模一样。这才意识到,这个量出来的根本不是我以为的那个东西。

两个列表,报同样大小

import sys
a = [None] * 1000
b = [("x" * 1000)] * 1000
print("list(1000 None)    =", sys.getsizeof(a))
print("list(1000 大字符串) =", sys.getsizeof(b))
print("getsizeof([1,2,3]) =", sys.getsizeof([1, 2, 3]))

输出:

list(1000 None)    = 8056
list(1000 大字符串) = 8056
getsizeof([1,2,3]) = 88

两个列表都是 8056 字节(CPython 3.13,64 位),一个里面是空值,一个里面每个都是一千字符的串,量出来的数却分毫不差。这就很说明问题了:getsizeof 只量了列表这个壳子本身,也就是那块存引用的连续数组,压根没往里看每个元素占多少。

它量的是浅层大小

对列表、字典、集合这类容器,getsizeof 返回的是容器自身结构的大小,不含它引用的对象。列表里存的是一千个指针,不管指针指向 None 还是大字符串,指针本身占的空间都一样,所以两个列表报同样数字。

字符串就不一样了,它是扁平结构,内容直接跟对象头连在一起:

getsizeof("x" * 1000000) = 1000041

一百万字符的串报出一百多万字节,内容算进去了。这也是很多人误以为 getsizeof 会把所有东西都算进去的原因,因为他们第一次量的恰好是字符串。

想量真实占用得递归

要看一个容器连同内容总共占多少,得自己往下递归累加:

import sys

def total(obj, seen=None):
    if seen is None:
        seen = set()
    oid = id(obj)
    if oid in seen:
        return 0
    seen.add(oid)
    size = sys.getsizeof(obj)
    if isinstance(obj, dict):
        for k, v in obj.items():
            size += total(k, seen) + total(v, seen)
    elif isinstance(obj, (list, tuple, set, frozenset)):
        for it in obj:
            size += total(it, seen)
    return size

那个 seen 集合很关键,用来记住已经量过的对象。不加它,遇到共享引用会重复累加,遇到自引用还会无限递归。实测一个装一万项的字典,壳子 294992 字节;用上面这段递归在本机测得 574992 字节。但这个数字不是定值,它随元素之间是不是同一份而变。(这段 total 只展开 dict / list / tuple / set / frozenset 这些内建容器,不进自定义对象的属性;若对象图里混有自定义类实例,它会偏低。本函数内对象都靠容器引用链保活,id 取值稳定。)

共享省下的是 value 那一块

同样是装一万项的字典,只是把 value 换掉:

{i: i}         壳子 294992   递归 574992
{i: str(i)}    壳子 294992   递归 1023882
{i: "same"}    壳子 294992   递归 575037

第一行里 key 和 value 是同一个 int 对象,seen 去重后每个整数只算一次。第二行每个 value 是一份独立的字符串,多出来的那四十多万字节全是字符串。第三行所有 value 指向同一个字符串。

这里有个容易想歪的地方:很多人以为只要 value 共享,递归值就会掉回接近壳子的水平。实测不是这样。第三行的 575037 跟第一行的 574992 基本一样,都远在 294992 之上。原因是字典的 key 按值唯一,一万个 key 必然是一万个不同的对象,seen 永远去不掉这部分开销。共享只省下 value 那一份,key 的账照算。第二行比第三行多出来的 448845 字节,全部来自 value 那一块:第二行那一万个独立字符串合计 448890 字节,第三行只剩一份 45 字节的 same,一省就是 448845。可这跟整条记录比还不到一半,占 43.8%,省的还是只有 value 那一块。

更坑的一层:以为独立,其实没独立

第二行想表达每个 value 各自独立,用 str(i) 是安全的,每次调用都会新建。可换成固定字符串就没这么老实了:

indep = [("x" * 1000) for _ in range(1000)]
print(len({id(x) for x in indep}))   # 1
print(total(indep))                  # 9897

一千个元素,id 去重后只剩一个。原因不在列表推导式,而在乘法的另一边:"x" * 1000 两端都是字面量,编译期就被折叠成了一个常量对象,推导式每一轮取的都是常量表里那一个。放进函数里也一样:

def build():
    return "x" * 1000

print(len({id(build()) for _ in range(1000)}))   # 还是 1

真要造出一千个独立对象,得让乘法有一边不是字面量:

src = "x"
n = 1000
indep = [src * n for _ in range(1000)]
print(len({id(x) for x in indep}))   # 1000
print(total(indep))                  # 1049856

同一段逻辑,递归值从 9897 变成 1049856,一百多倍。这两个列表的壳子完全一样,都是 8856,因为都是推导式建的;差的 1039959 字节全是内容,折叠版里只算了一份字符串,独立版多出 999 份,每份 1041 字节。

顺带对一下第 2 节那个用乘法建的 b:它只有 8056,比推导式建的少 800 字节。原因是列表推导按 append 逐个生长,容量被超额分配到 1100 个槽位,跟内容无关。所以同样是一千项,壳子也会因为建法不同而差 800 字节。

所以共享引用比独立构造省内存上百倍这句话本身没错,前提是你真的造出了独立对象。照字面写一遍,很可能造出来的是一千个指向同一个对象的引用,实测反而看不出差别。

定位真正吃内存的地方

我现在的用法是:getsizeof 只用来粗看单个对象的浅层开销,或者比较同种扁平对象的大小。要找服务里到底谁占内存,靠的是递归统计加共享分析,必要时上专门的内存分析工具看对象图。

把 8056 当成那个大列表的内存账,是我踩过的第一个坑,也是最容易让人误判的一个。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。