订阅更新

一个字典居然占了200MB——Python对象的内存模型

Python手册 第 3 题 约 8 分钟读完

本文目录
  1. 先说结论
  2. Python对象的内存布局
  3. 为什么一个int占28字节?
  4. dict的内存开销——为什么500万条占2.5GB?
  5. 内存优化方法
  6. 引用计数和垃圾回收
  7. 面试高频追问
  8. 总结

先说结论

Python中一切皆对象——int、float、str甚至None都是对象。每个对象至少包含引用计数(8字节)和类型指针(8字节)=16字节的开销。一个int在C语言里只占4-8字节,在Python里占28字节。一个空dict占64字节。这就是为什么同样的数据Python比C/Java占用更多内存。优化方向:使用__slots__、用array/numpy替代list、用csv.reader替代dict存储。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

Python对象的内存布局

import sys
 
# 基本类型的内存占用
sys.getsizeof(0)          # 28字节(int)
sys.getsizeof(1)          # 28字节
sys.getsizeof(2**30)      # 32字节(大整数多4字节)
sys.getsizeof(3.14)       # 24字节(float)
sys.getsizeof(True)       # 28字节(bool是int子类!)
sys.getsizeof(None)       # 16字节
sys.getsizeof('')         # 49字节(空字符串!)
sys.getsizeof('hello')    # 54字节(5个字符的字符串)
 
# 容器类型的内存占用
sys.getsizeof([])         # 56字节(空list)
sys.getsizeof({})         # 64字节(空dict)
sys.getsizeof(set())      # 216字节(空set!)
sys.getsizeof(())         # 40字节(空tuple,比list小)
 
# 注意:getsizeof只统计对象本身,不统计引用的子对象
# dict {'a': 1} 的getsizeof不包含'a'和1的内存

为什么一个int占28字节?

# CPython中int对象的C结构体(简化版)
#
# typedef struct {
#     Py_ssize_t ob_refcnt;     // 引用计数 8字节
#     PyTypeObject *ob_type;    // 类型指针 8字节
#     Py_ssize_t ob_size;       // 数值长度 8字节(支持大整数)
#     digit ob_digit[1];        // 实际数值 4字节
# } PyLongObject;
#
# 总计:8 + 8 + 8 + 4 = 28字节
# 而C语言的int只要4字节!
# Python的int比C的int大7倍
#
# 推广到所有对象:
# 每个Python对象至少有16字节的对象头
# ob_refcnt(8) + ob_type(8) = 16字节
# 再加上实际数据

dict的内存开销——为什么500万条占2.5GB?

# 500万条设备信息的dict
# devices = {
#     'device_001': {'model': 'iPhone 15', 'os': 'iOS', 'country': 'US'},
#     'device_002': {'model': 'Pixel 8', 'os': 'Android', 'country': 'JP'},
#     ... # 500万条
# }
 
# 内存分析:
# 外层dict对象本身: ~300MB(500万个哈希槽+数据表)
# 每个key(str): ~60字节 x 500万 = ~300MB
# 每个value(内层dict): ~250字节 x 500万 = ~1.2GB
#   内层dict对象: ~200字节
#   内层的key(str): ~50字节 x 3 = ~150字节
#   内层的value(str): ~60字节 x 3 = ~180字节
# 总计: ~300 + 300 + 1200 + ... ≈ 2.5GB
 
# 原始CSV只有300MB
# Python对象开销导致内存膨胀了8倍!

内存优化方法

方法一:__slots__减少对象开销

# 普通class——每个实例有一个__dict__(约100字节)
class Device:
    def __init__(self, model, os, country):
        self.model = model
        self.os = os
        self.country = country
# sys.getsizeof(Device('x','y','z')).__dict__ ≈ 200字节/实例
 
# 使用__slots__——去掉__dict__,固定字段
class Device:
    __slots__ = ('model', 'os', 'country')
    def __init__(self, model, os, country):
        self.model = model
        self.os = os
        self.country = country
# 约64字节/实例——省了2/3!
# 500万个实例:从1GB降到320MB

方法二:用Pandas/NumPy替代dict+list

# dict方式存500万条数据:约2.5GB
 
# Pandas DataFrame方式:
import pandas as pd
df = pd.read_csv('devices.csv',
    dtype={'model': 'category', 'os': 'category', 'country': 'category'})
# category类型让重复字符串只存一份!
# 内存约200-400MB(节省80-90%)
 
# NumPy方式(纯数值数据):
import numpy as np
amounts = np.array([1.5, 2.3, 4.7, ...])  # float64数组
# 100万个float:NumPy占8MB,Python list占28MB

方法三:用namedtuple或dataclass替代dict

# namedtuple——不可变,比dict省内存
from collections import namedtuple
Device = namedtuple('Device', ['model', 'os', 'country'])
d = Device('iPhone 15', 'iOS', 'US')
# 约72字节/实例(比dict的200+字节省65%)
 
# dataclass(Python 3.7+)
from dataclasses import dataclass
@dataclass(slots=True)  # Python 3.10+自动加__slots__
class Device:
    model: str
    os: str
    country: str

引用计数和垃圾回收

Python的GC和Java不一样——主要靠引用计数(Reference Counting),辅以分代垃圾回收(Generational GC)处理循环引用:

机制工作方式优点缺点
引用计数每个对象记录有多少引用指向它,为0立即回收实时回收,无暂停无法处理循环引用
分代GC分三代,定期扫描找循环引用处理循环引用有暂停(但通常很短)

大数据脚本中不太需要关心Python的GC——因为瓶颈通常在IO和CPU而不是GC。但如果你在循环里创建了大量临时对象(比如100万次字符串拼接),del手动删除不再需要的大对象可以帮助及时释放内存。

面试高频追问

追问一:Python的小整数缓存是什么?

CPython预先创建了-5到256的整数对象并缓存。a = 100; b = 100; a is b返回True(同一个对象)。a = 1000; b = 1000; a is b可能返回False(不同对象)。这就是为什么不要用is判断值相等——用==。小整数缓存的目的是减少频繁创建销毁小整数的开销。

追问二:Python的内存池(pymalloc)是什么?

CPython有自己的内存池管理小对象(小于512字节)。小对象不直接向OS申请内存,而是从Python内部的内存池中分配。好处是减少系统调用的开销和内存碎片。但释放的小对象内存不会立即归还给OS——这就是为什么你看到Python进程的内存使用只增不减(即使对象已经被回收了)。

追问三:怎么分析Python程序的内存使用?

三个工具:① sys.getsizeof()——单个对象的大小(不含子对象);② pympler.asizeof()——递归统计对象及所有子对象的总大小;③ memory_profiler——逐行分析内存变化(@profile装饰器+python -m memory_profiler script.py)。排查内存问题时memory_profiler最有用。

总结

Python内存面试讲清楚三层:① 一切皆对象的开销(int 28字节、空dict 64字节、对象头最少16字节);② 大数据场景的内存膨胀问题(500万dict从300MB CSV膨胀到2.5GB Python对象);③ 优化方法(__slots__/namedtuple省对象开销、Pandas category省字符串重复、NumPy省数值存储)。能讲出500万设备表的内存分析和优化方案,面试官会觉得你对Python内存有深度理解。

下一篇聊一个大数据面试的经典题:PySpark UDF为什么比Spark SQL慢10倍?GIL到底是什么?

你们Python脚本遇到过内存问题吗?怎么优化的?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,只讲大数据面试干货

刷题进度保存在当前浏览器