Python 字符串存储机制与内存优化详解
本文深入解析 Python 3 中 Unicode 字符串的内部存储机制,包括 Latin-1、UCS-2 和 UCS-4 三种编码方式对内存占用的影响。通过 sys.getsizeof 和 cbytes 工具演示如何检测编码类型,并详细阐述字符串驻留(String Interning)技术及其在常量、短字符串中的应用场景,帮助开发者理解并优化 Python 程序中的内存使用效率。
Python 字符串内存编码机制
自 Python 3 起,str 类型专指 Unicode 字符串。根据具体编码方案的不同,单个 Unicode 字符可能占用 4 个字节,这在某些场景下会造成内存资源的浪费。
为了平衡内存占用与运行性能,Python 内部采用以下三种策略来存储 Unicode 字符:
- 单字节编码(Latin-1)
- 双字节编码(UCS-2)
- 四字节编码(UCS-4)
在常规 Python 开发中,字符串处理往往表现一致,开发者通常无需关注底层差异。然而,当涉及大规模字符处理任务时,这些底层细节对性能的影响便不容忽视。
我们可以通过简单的实验来直观感受上述三种编码方式的差异。在此过程中,我们将使用 sys.getsizeof 函数来查询对象所占用的字节数。
>>> import sys
>>> string = 'hello'
>>> sys.getsizeof(string)
54
>>> # 1-byte encoding
... sys.getsizeof(string + '!') - sys.getsizeof(string)
1
>>> # 2-byte encoding
... string2 = '你'
>>> sys.getsizeof(string2 + '好') - sys.getsizeof(string2)
2
>>> sys.getsizeof(string2)
76
>>> # 4-byte encoding
... string3 = ':snake:'
>>> sys.getsizeof(string3 + ':computer:') - sys.getsizeof(string3)
4
>>> sys.getsizeof(string3)
80
如上所示,字符串内容不同,其采用的内部编码方式也不同。值得注意的是,Python 中的每个字符串对象都会额外占用 49 至 80 字节的内存,用于存储哈希值、字符串长度、字节数及标识符等元数据。因此,一个空字符串至少占用 49 字节也就合情合理了。
我们可以借助 cbytes 模块直接获取对象的编码类型标识:
import ctypes
class PyUnicodeObject(ctypes.Structure):
# internal fields of the string object
_fields_ = [("ob_refcnt", ctypes.c_long),
("ob_type", ctypes.c_void_p),
("length", ctypes.c_ssize_t),
("hash", ctypes.c_ssize_t),
("interned", ctypes.c_uint, 2),
("kind", ctypes.c_uint, 3),
("compact", ctypes.c_uint, 1),
("ascii", ctypes.c_uint, 1),
("ready", ctypes.c_uint, 1),
# ...
# ...
]
def get_string_kind(string):
return PyUnicodeObject.from_address(id(string)).kind
随后进行测试:
>>> get_string_kind('Hello')
1
>>> get_string_kind('你好')
2
>>> get_string_kind(':snake:')
4
若字符串中的所有字符均可由 ASCII 表示,Python 将自动采用 Latin-1 编码。简言之,Latin-1 用于表示前 256 个 Unicode 字符,支持英语、瑞典语、意大利语等多种拉丁语系语言。但对于汉语、日语、西伯尔语等非拉丁语系文字,由于它们的码位值(编码值)超出了单字节范围(0-255),Latin-1 编码便无法适用。
>>> ord('a')
97
>>> ord('你')
20320
>>> ord('!')
33
对于大多数语言文字,使用 2 个字节(UCS-2)进行编码已足够。而 4 个字节(UCS-4)编码则主要用于存储特殊符号、Emoji 表情或较为生僻的文字。
假设有一个 10GB 的纯 ASCII 文本文件,若将其加载至内存,并在其中插入一个 Emoji 表情,该文件在内存中的占用空间可能会膨胀至原来的 4 倍。对于经常处理自然语言处理(NLP)问题的开发者而言,这种差异尤为显著。
为何不使用 UTF-8 编码
Python 内部为何不直接使用 UTF-8 编码?
尽管 UTF-8 是最常见的 Unicode 编码格式,但 Python 内部并未采用它。
UTF-8 编码字符时,根据字符内容不同,占用空间在 1 至 4 个字节之间动态变化。这是一种极其节省空间的存储方式,但变长存储的特性导致字符串无法通过下标直接进行随机读取,只能遍历查找。例如,若采用 UTF-8 编码,Python 获取 string[5] 时只能逐个字符扫描直至找到目标。若采用定长编码,则只需将下标乘以指定长度(1、2 或 4)即可直接定位字符,效率更高。
字符串驻留技术详解
字符串驻留机制
Python 中的空字符串和 ASCII 字符均会应用字符串驻留(String Interning)技术。如何理解?你可以将这些字符串视为单例模式。也就是说,如果两个内容相同的字符串都使用了驻留技术,内存中实际上只开辟了一份存储空间。
>>> a = 'hello'
>>> b = 'world'
>>> a[4],b[1]
('o', 'o')
>>> id(a[4]), id(b[1]), a[4] is b[1]
(4567926352, 4567926352, True)
>>> id('')
4545673904
>>> id('')
4545673904
正如你所见,变量 a 中的字符 'o' 和变量 b 中的字符 'o' 拥有相同的内存地址。由于 Python 字符串是不可变的,提前为某些字符分配好内存位置以便后续复用是可行的优化手段。
除了 ASCII 字符和空字符串外,长度不超过 20 的字符串若内容在编译时即可确定,也会应用相同的驻留技术。
具体包括以下场景:
- 方法名与类型名
- 变量名
- 参数名
- 常量(代码中直接定义的字符串)
- 字典的键
- 属性名
当你在交互式命令行中编写代码时,语句会先被编译为字节码。因此,交互式命令行中的短字符串也会被驻留。
>>> a = 'teststring'
>>> b = 'teststring'
>>> id(a), id(b), a is b
(4569487216, 4569487216, True)
>>> a = 'test'*5
>>> b = 'test'*5
>>> len(a), id(a), id(b), a is b
(20, 4569499232, 4569499232, True)
>>> a = 'test'*6
>>> b = 'test'*6
>>> len(a), id(a), id(b), a is b
(24, 4569479328, 4569479168, False)
由于驻留机制仅适用于常量字符串,以下示例无法达到驻留效果:
>>> open('test.txt','w').write('hello')
5
>>> open('test.txt','r').read()
'hello'
>>> a = open('test.txt','r').read()
>>> b = open('test.txt','r').read()
>>> id(a), id(b), a is b
(4384934576, 4384934688, False)
>>> len(a), id(a), id(b), a is b
(5, 4384934576, 4384934688, False)
字符串驻留技术大幅减少了重复字符串的内存分配开销。Python 底层通过字典实现该技术,暂存的字符串作为字典的键。若要判断某个字符串是否已驻留,只需执行字典查找操作即可确认。
Python 的 unicode 对象实现( https://github.com/python/cpython/blob/master/Objects/unicodeobject.c )包含约 16,000 行 C 代码,其中涉及许多未在本文中提及的微优化。若想深入了解 Python 中的 Unicode 处理,建议查阅相关的 PEPs( https://www.python.org/dev/peps/ )以及 unicode 对象的源码。
总结
以上内容介绍了 Python 存储字符串时节省空间的方法,希望对大家有所帮助。如有任何疑问,欢迎留言,小编会及时回复。感谢大家对本站的支持! 若本文对您有帮助,欢迎转载,请注明出处,谢谢!
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













