getsizeof 只量列表壳:元素再大报一样大,独立构造可能没真独立
用它估内存,结果对不上
排查一个占内存的服务,想先量一下各个对象有多大,第一反应就是 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 当成那个大列表的内存账,是我踩过的第一个坑,也是最容易让人误判的一个。
- 点赞
- 收藏
- 关注作者
评论(0)