第6篇:CPython内部探究:字符串的内存模型

Python字符串对象是一个容器

PyASCIIObject、PyCompactUnicodeObject和PyUnicodeObject都是容器对象。因为它们有两部分组成

  • 头部(Overhead):PyASCIIObject、PyCompactUnicodeObject、PyUnicodeObject初始化后的结构体信息
  • 有效负载(Payload):就是实际保存字符串副本的有效内存区域。

容器对象最早是在C++中提出的一个面向对象的数据结构概念。容器对象通过一个头部(Overhead)内部一个数据指针来维护着实质上持有对象内存数据的堆内存区域。从而减少程序员对指针的人为操作,因为像C语言那样任由程序员操作指针,C++认为这是很危险的,因此容器对象通过一个类并定义了很多相关的属性,当中包含一个内部数据指针(一般来说是void指针)用于指向存放对象数据的堆内存区域,容器的这些属性字段就实时记录整个对象数据的运行时状态。并且C++的容器对内部的数据指针是私有,外部代码通常无法访问或操作其内部指针,这是面向对象编程中容器对象是类型安全和友好的。而CPython也借鉴了这一构思,因为CPython是基于C实现的,因此无法提供有效的运行时访问限制,更谈不上类型安全了。有趣的是C++所有内置的容器对象基本上是开源,你可以做一些hack处理,仍然能够任意蹂躏其内部指针。但Java、.Net对容器的构思的实现更彻底了,他们的虚拟机从实现层面已经彻底封装任何可能涉及指针类型的操作。因此Java、.Net的语法层面并不存在指针这一说法。

从内存布局来说,这里PyASCIIObject、PyCompactUnicodeObject、PyUnicodeObject属于紧凑型的容器对象,因为头部和有效负载部分是紧挨着的。这样的编码设计对于内存回收非常有利,因为内存释放时,能够将一大片连续的内存归还操作系统的虚拟内存管理器(VM),从而减少碎片的产生。还有一种叫做分离的容器对象,也就是说因为头部有效负载部分是分离的,例如CPython内部的arena对象就属于这一类型。分离的容器对象会在内存回收时产生不必要的内存碎片,对操作系统造成一定的困扰。如果你曾深入领悟C/C++,一定会明白我说的个中体会。

Python字符串的内存模型

首要的事情,再来一遍—让我们回顾一下到目前为止我们学到的知识:

  • Python中的所有内容都是一个对象,一个变量可以引用的对象。
  • 对象按其值,类型和标识(也称为内存地址)分类。
    • 不可变对象的值与其身份相关联-如果值更改,则对象也会更改。
    • 可变对象的值不依赖于其标识-标识在对对象所做的更改中保留。
  • CPython实现预先分配了共享值,某些范围的常用不可变类型
  • 当指示Python实例化一个新的不可变对象时,它首先检查是否存在相同对象作为共享对象

注意:本文中讨论的行为特定于CPython 3.3及更高版本。您不能保证在不同的Python实现或版本上具有相同的行为。

正如我在上一篇文章中提到那样,Python中的字符串对象实际上是unicode字符序列,我们将它们称为专有的“文本”序列。这可以通过比较字符串中各个字符来证明这些特征,下图通过变量a和b分别引用两个不同的字符串。

不同的字符串位于不同的堆内存区块。这个通过id函数非常轻易区分出示例中a和b引用的内存地址都是不一样的。当我们再次调用is关键字比较a[3]和b[5]会返回True,因为a[3]、b[5]引用都是同一个内存位置的字符‘n’,我们说这样的对象叫共享对象(Share Object)。

因为每次初始化Python解释器时,CPython会将Latin-1范围内的unicode编码(0到255)作为共享库加载到一个静态的unicode_latin1数组,该数组的长度为256,每个ascii字符占用一个字节,并且位于计算机的静态内存区域。 后续对该范围内的值的任何调用都将引用到那些预先存在unicode_latin1数组的对象

unicode_latin1数组的源代码定义在Objects/unicodeobject.c文件中有定义

#ifdef LATIN1_SINGLETONS
/* Single character Unicode strings in the Latin-1 range are being
   shared as well. */
static PyObject *unicode_latin1[256] = {NULL};
#endif

上面的示例,我们用一个内存图表示,我们知道变量s1、s2各自引用不同堆内存实体上的PyASCIIObject对象。

这个内存图解除一部份人的疑惑,对于仅掌握Python语法,并没有阅读过CPython源代码的新手来说,会错误地认为Python字符串就是一个类似数组的字符序列。现在应该恍然大悟了吧!可以形象地认为Python字符串对象就是一个带了“套”(就是头部信息)的字符串序列(或unicode字节序列),为什么这么说呢?因为Python字符串对象按照内存组织来说,它是一个容器对象

备注:字符串对象的内存分配由PyUnicode_New函数定义,前面3篇文章说得很清楚了,没必要再解析。

在CPython中,Unicode字符存储为PyUnicodeObject实例。 我们可以通过查看源代码来查看PyUnicodeObject的格式:PyUnicodeObject根据三种不同编码之一存储字符。 这些编码中的每一种占用不同的字节大小-Latin-1编码为1字节,UCS-2编码为2字节,UCS-4编码为4字节。 此大小可在Python中访问(需要减法,因为存储字符串所需的实际字节数大于其字符的大小):

typedef struct {
    PyCompactUnicodeObject _base;
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

我们来一个简单的示例,你们明白为什么gesizeof函数返回50个字节吗?

我们来看看首先我们s1指向的是一个仅包含一个ASCII字符‘M’的字符串。由于是一个ASCII字符串,那么CPython会优先以一个字节的位宽来实例化该对象,显然该对象类型是PyASCIIObject,我们用如下图来说明一切。

那么s1='M'表示:“变量标签s引用C底层堆中一个PyASCIIObject内存实体”。该PyASCIIObject对象的头部尺寸是48字节,而有效负载的尺寸是2字节。

我们说‘!’这个字符实际上在堆中有一个对应的尺寸为50字节的PyASCIIObject内存实体,类似如上图,我这里不再贴图。只不过没有一个变量去引用该字符串的内存实体,我们称为这样的字符串对象叫“匿名字符串

问题1:s1+'!'表达式背后的内存含义是什么呢?

该表达式实际上执行concat操作,以就是说该表达式会将s1引用的PyASCIIObject内存实体和‘!’字符对应的PyASCIIObject内存实体,它们各自的有效负载部分执行合并操作,生成一个新的PyASCIIObject内存实体,如下图所示。

也就是说现在堆内存中有3个不同的内存实体,一个是s1变量所指向的内存实体、一个是'!'对应的匿名字符串内存实体、一个是s1+'!'表达式对应的匿名字符串内存实体,有趣的是在Python语义中 id(s1+'!')同样会获取该字符串对象的内存地址。

问题2:sys.getsizeof(s1+'!')-sys.getsizeof(s1)这个表达式的含义是什么呢
Ok,这个表达式就表示,读取字符串内存实体的有效负载内的字节数据,以1个字节位宽去解码每个字符。

那么我们再来一个稍微复杂一点的例子,下图的例子我想你应该心中有数了吧。

©和®这两个字符在CPython内部是以1个字节的位宽来表示,他们的ASCII编码分别是169和174,这些都是ASCII字符集范围内的字符。而🐍这个属于需要4个字节的位宽来表示,它的unicode编码是128013,那么4字节位宽的二进制表示为"00000000 00000001 11110100 00001101"

>>> ord('©')
169
>>> ord('®')
174
>>> ord('🐍')
128013
>>> bin(ord('🐍'))
00000000 00000001 11110100 00001101
>>> 

字符串驻留

什么是字符串驻留(String Interning)呢?其实这个跟C对待字符串在RAM中存储方式是一样的,就是一个"特定"的字符串在内存中只存在一份,其他Python变量都是其引用.

我们先来个自动驻留的示例,两个变量引用一个字符串"Hello Lisa!?",我们同时对其字符串引用的变量,以及字符串本身传入id函数。他们都指向“Hello Lisa!?”的真实的内存地址。


我们尝试执行后,在脚本的上下文中同样的代码测试得到期望的结果。

那么其内存图如下,数据栈的变量标签A和B都指向堆中"Hello Lisa!?"对应的PyASCIIObject实例的内存地址140619830398512。

那为什么会出现这种情况呢?Python解释器在执行第一条语句前,堆内存中还没有该字符串,当执行完第一条语句时,栈中的变量A立即被分配为引用到“Hello Lisa !?”。 执行第一条语句之后,“ Hello Lisa !?” 将以驻留的方式一直活跃在堆内存中。这是通过调用以下任何一条CPython函数来实现的:

PyAPI_FUNC(void) PyUnicode_InternInPlace(PyObject **);
PyAPI_FUNC(void) PyUnicode_InternImmortal(PyObject **);
PyAPI_FUNC(PyObject *) PyUnicode_InternFromString(
    const char *u              /* UTF-8 encoded string */
    );

在第二条语句"Hello Lisa!?"执行时,CPython内部决定是否需要创建新的“Hello Lisa!?”实例之前,CPython首先检查其内联字符串的存储,以确定是否已实例化相同的字符串。

/* Use only if you know it's a string */
#define PyUnicode_CHECK_INTERNED(op) \
    (((PyASCIIObject *)(op))->state.interned)

显然相同的字符串已经驻留在堆中,那么变量B的“=”所谓赋值只是指向原来“ Hello Lisa !?” 实例的内存地址,并且“ Hello Lisa !?” 实例的引用计数会+1.

字符串驻留怎么跟共享对象那么相似的呢?当然! 字符串驻留背后的方法和思想都与CPython共享对象的实现并存。 实际上,一旦一个字符串驻留在内存后,它实质上就等同于一个共享库-该字符串的实例对于给定Python会话中执行的所有程序都是全局可用的。 就像共享对象一样,通过内部字符串,Python在时间和内存上都可以更高效,但仅针对某些具体的应用场景。

字符串驻留的限定条件

对于Python来说,将每个被调用的字符串永久保存在内存中是没有意义的,这最终会导致不必要的内存浪费。 取而代之的是,Python会尽最大努力专门驻留最可能被重用的字符串-标识符字符串。 标识符字符串包括以下内容:

  • 函数和类名
  • 变量名
  • 参数名称
  • 字典键
  • 属性名称

请注意,Python实际上并没有检测到以上内容-首先它甚至无法做到这一点。 而一个字符串对象在实例化驻留以否,分两种运行环境进行讨论.

.py的脚本文件中的字符串初始化后,同时满足以下三个条件才能达成字符串驻留

  • 条件1:该字符串必须是编译时常量。除非在编译时将其作为常量字符串加载,否则字符串不会被驻留在堆内存中。 这包括
    1. 定义为表达式的字符串-请记住,在实例化对象之前首先对表达式求值。
    2. 运行时构造的任何字符串(即通过方法,函数等生成的任何字符串)。

我们运行效果如下图,由于A引用动态构造的字符串,在say_hello函数销毁后,其内部缓存在堆的字符串对象也一同销毁,接着B再次引用say_hello函数生成的相同文字的字符串对象,但是内存地址完全不一样的全新对象。C和D引用的是一个编译时的字符串常量。

  • 条件2:字符串不得连续拼接,这个容易理解示例已经解析的很清楚了。
  • 条件3:字符串可以是任意编码类型的字符串,ASCII字符、Unicode字符等,并且没有长度限制,该条件其实是条件1的补充说明。

我们通过一个下面的简单示例可以得到验证。首先A和B引用的2字节位宽的字符串对象(由PyCompactUnicodeObject封装),都是中文字 ;C和D引用的是4字节位宽的unicode字节序列(由PyUnicodeObject封装)

运行测试一下,PyCompactUnicodeObject和PyUnicodeObject封装的任意长度的字符串常量,在Python运行周期内都是允许驻留在堆内存中的。

接下来,我们分析一下另一种使用环境,在Python交互命令行字符串驻留3个条件都必须同时满足,经过前面的分析,我不想再过多废话。请看下图的在交互环境中的例子。

  • 条件1:该字符串必须是编译时常量。除非在编译时将其作为常量字符串加载,否则字符串不会被驻留在堆内存中。 这包括
    1. 定义为表达式的字符串-请记住,在实例化对象之前首先对表达式求值。
    2. 运行时构造的任何字符串(即通过方法,函数等生成的任何字符串)。
  • 条件2:字符串不得连续拼接,并且不得超过20个字符
  • 条件3:该字符串仅由ASCII字母,数字或下划线组成

我们分析字符串驻留的内存特性,可以满足使得在某些应用场景令Python程序在字符串性能方面收益。例如使用PyQt或Tkinter写的GUI程序,通常不同图形的模块会引用到相同的文字字面量(例如中文工具栏或者菜单,对话框的文字描述等),Python在初始化这些字符串常量初始化为堆中的字符串对象并驻留在堆内存中,后续调用这些字符串,例如加载某个菜单列表,加载速度都得到快速的提升。