第6篇:CPython內(nèi)部探究:字符串的內(nèi)存模型

Python字符串對象是一個(gè)容器

PyASCIIObject、PyCompactUnicodeObject和PyUnicodeObject都是容器對象罚拟。因?yàn)樗鼈冇袃刹糠纸M成

  • 頭部(Overhead):PyASCIIObject褂策、PyCompactUnicodeObject心肪、PyUnicodeObject初始化后的結(jié)構(gòu)體信息
  • 有效負(fù)載(Payload):就是實(shí)際保存字符串副本的有效內(nèi)存區(qū)域费就。

容器對象最早是在C++中提出的一個(gè)面向?qū)ο蟮?strong>數(shù)據(jù)結(jié)構(gòu)概念润文。容器對象通過一個(gè)頭部(Overhead)內(nèi)部一個(gè)數(shù)據(jù)指針來維護(hù)著實(shí)質(zhì)上持有對象內(nèi)存數(shù)據(jù)的堆內(nèi)存區(qū)域禾进。從而減少程序員對指針的人為操作豁跑,因?yàn)橄馛語言那樣任由程序員操作指針,C++認(rèn)為這是很危險(xiǎn)的泻云,因此容器對象通過一個(gè)類并定義了很多相關(guān)的屬性艇拍,當(dāng)中包含一個(gè)內(nèi)部數(shù)據(jù)指針(一般來說是void指針)用于指向存放對象數(shù)據(jù)的堆內(nèi)存區(qū)域,容器的這些屬性字段就實(shí)時(shí)記錄整個(gè)對象數(shù)據(jù)的運(yùn)行時(shí)狀態(tài)宠纯。并且C++的容器對內(nèi)部的數(shù)據(jù)指針是私有卸夕,外部代碼通常無法訪問或操作其內(nèi)部指針,這是面向?qū)ο缶幊讨腥萜鲗ο笫穷愋桶踩陀押玫钠殴稀6鳦Python也借鑒了這一構(gòu)思,因?yàn)镃Python是基于C實(shí)現(xiàn)的,因此無法提供有效的運(yùn)行時(shí)訪問限制,更談不上類型安全了快集。有趣的是C++所有內(nèi)置的容器對象基本上是開源,你可以做一些hack處理廉白,仍然能夠任意蹂躪其內(nèi)部指針个初。但Java、.Net對容器的構(gòu)思的實(shí)現(xiàn)更徹底了猴蹂,他們的虛擬機(jī)從實(shí)現(xiàn)層面已經(jīng)徹底封裝任何可能涉及指針類型的操作院溺。因此Java、.Net的語法層面并不存在指針這一說法晕讲。

從內(nèi)存布局來說覆获,這里PyASCIIObject、PyCompactUnicodeObject瓢省、PyUnicodeObject屬于緊湊型的容器對象,因?yàn)轭^部和有效負(fù)載部分是緊挨著的痊班。這樣的編碼設(shè)計(jì)對于內(nèi)存回收非常有利勤婚,因?yàn)閮?nèi)存釋放時(shí),能夠?qū)⒁淮笃B續(xù)的內(nèi)存歸還操作系統(tǒng)的虛擬內(nèi)存管理器(VM)涤伐,從而減少碎片的產(chǎn)生馒胆。還有一種叫做分離的容器對象,也就是說因?yàn)?strong>頭部和有效負(fù)載部分是分離的,例如CPython內(nèi)部的arena對象就屬于這一類型凝果。分離的容器對象會在內(nèi)存回收時(shí)產(chǎn)生不必要的內(nèi)存碎片祝迂,對操作系統(tǒng)造成一定的困擾。如果你曾深入領(lǐng)悟C/C++,一定會明白我說的個(gè)中體會器净。

Python字符串的內(nèi)存模型

首要的事情型雳,再來一遍—讓我們回顧一下到目前為止我們學(xué)到的知識:

  • Python中的所有內(nèi)容都是一個(gè)對象,一個(gè)變量可以引用的對象。
  • 對象按其值纠俭,類型和標(biāo)識(也稱為內(nèi)存地址)分類沿量。
    • 不可變對象的值與其身份相關(guān)聯(lián)-如果值更改,則對象也會更改冤荆。
    • 可變對象的值不依賴于其標(biāo)識-標(biāo)識在對對象所做的更改中保留朴则。
  • CPython實(shí)現(xiàn)預(yù)先分配了共享值,某些范圍的常用不可變類型钓简。
  • 當(dāng)指示Python實(shí)例化一個(gè)新的不可變對象時(shí)乌妒,它首先檢查是否存在相同對象作為共享對象

注意:本文中討論的行為特定于CPython 3.3及更高版本外邓。您不能保證在不同的Python實(shí)現(xiàn)或版本上具有相同的行為芥被。

正如我在上一篇文章中提到那樣,Python中的字符串對象實(shí)際上是unicode字符序列坐榆,我們將它們稱為專有的“文本”序列拴魄。這可以通過比較字符串中各個(gè)字符來證明這些特征,下圖通過變量a和b分別引用兩個(gè)不同的字符串席镀。

不同的字符串位于不同的堆內(nèi)存區(qū)塊匹中。這個(gè)通過id函數(shù)非常輕易區(qū)分出示例中a和b引用的內(nèi)存地址都是不一樣的。當(dāng)我們再次調(diào)用is關(guān)鍵字比較a[3]和b[5]會返回True,因?yàn)閍[3]豪诲、b[5]引用都是同一個(gè)內(nèi)存位置的字符‘n’顶捷,我們說這樣的對象叫共享對象(Share Object)。

因?yàn)?strong>每次初始化Python解釋器時(shí)屎篱,CPython會將Latin-1范圍內(nèi)的unicode編碼(0到255)作為共享庫加載到一個(gè)靜態(tài)的unicode_latin1數(shù)組,該數(shù)組的長度為256,每個(gè)ascii字符占用一個(gè)字節(jié),并且位于計(jì)算機(jī)的靜態(tài)內(nèi)存區(qū)域服赎。 后續(xù)對該范圍內(nèi)的值的任何調(diào)用都將引用到那些預(yù)先存在unicode_latin1數(shù)組的對象。

unicode_latin1數(shù)組的源代碼定義在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

上面的示例交播,我們用一個(gè)內(nèi)存圖表示重虑,我們知道變量s1、s2各自引用不同堆內(nèi)存實(shí)體上的PyASCIIObject對象秦士。

這個(gè)內(nèi)存圖解除一部份人的疑惑缺厉,對于僅掌握Python語法,并沒有閱讀過CPython源代碼的新手來說隧土,會錯(cuò)誤地認(rèn)為Python字符串就是一個(gè)類似數(shù)組的字符序列√嵴耄現(xiàn)在應(yīng)該恍然大悟了吧!可以形象地認(rèn)為Python字符串對象就是一個(gè)帶了“套”(就是頭部信息)的字符串序列(或unicode字節(jié)序列),為什么這么說呢?因?yàn)镻ython字符串對象按照內(nèi)存組織來說曹傀,它是一個(gè)容器對象辐脖。

備注:字符串對象的內(nèi)存分配由PyUnicode_New函數(shù)定義,前面3篇文章說得很清楚了皆愉,沒必要再解析嗜价。

在CPython中艇抠,Unicode字符存儲為PyUnicodeObject實(shí)例。 我們可以通過查看源代碼來查看PyUnicodeObject的格式:PyUnicodeObject根據(jù)三種不同編碼之一存儲字符炭剪。 這些編碼中的每一種占用不同的字節(jié)大小-Latin-1編碼為1字節(jié)练链,UCS-2編碼為2字節(jié),UCS-4編碼為4字節(jié)奴拦。 此大小可在Python中訪問(需要減法媒鼓,因?yàn)榇鎯ψ址璧膶?shí)際字節(jié)數(shù)大于其字符的大小):

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

我們來一個(gè)簡單的示例错妖,你們明白為什么gesizeof函數(shù)返回50個(gè)字節(jié)嗎绿鸣?

我們來看看首先我們s1指向的是一個(gè)僅包含一個(gè)ASCII字符‘M’的字符串。由于是一個(gè)ASCII字符串暂氯,那么CPython會優(yōu)先以一個(gè)字節(jié)的位寬來實(shí)例化該對象,顯然該對象類型是PyASCIIObject潮模,我們用如下圖來說明一切。

那么s1='M'表示:“變量標(biāo)簽s引用C底層堆中一個(gè)PyASCIIObject內(nèi)存實(shí)體”痴施。該P(yáng)yASCIIObject對象的頭部尺寸是48字節(jié)擎厢,而有效負(fù)載的尺寸是2字節(jié)。

我們說‘!’這個(gè)字符實(shí)際上在堆中有一個(gè)對應(yīng)的尺寸為50字節(jié)的PyASCIIObject內(nèi)存實(shí)體辣吃,類似如上圖,我這里不再貼圖动遭。只不過沒有一個(gè)變量去引用該字符串的內(nèi)存實(shí)體,我們稱為這樣的字符串對象叫“匿名字符串

問題1:s1+'!'表達(dá)式背后的內(nèi)存含義是什么呢神得?

該表達(dá)式實(shí)際上執(zhí)行concat操作厘惦,以就是說該表達(dá)式會將s1引用的PyASCIIObject內(nèi)存實(shí)體和‘!’字符對應(yīng)的PyASCIIObject內(nèi)存實(shí)體,它們各自的有效負(fù)載部分執(zhí)行合并操作哩簿,生成一個(gè)新的PyASCIIObject內(nèi)存實(shí)體宵蕉,如下圖所示。

也就是說現(xiàn)在堆內(nèi)存中有3個(gè)不同的內(nèi)存實(shí)體节榜,一個(gè)是s1變量所指向的內(nèi)存實(shí)體羡玛、一個(gè)是'!'對應(yīng)的匿名字符串內(nèi)存實(shí)體、一個(gè)是s1+'!'表達(dá)式對應(yīng)的匿名字符串內(nèi)存實(shí)體全跨,有趣的是在Python語義中 id(s1+'!')同樣會獲取該字符串對象的內(nèi)存地址缝左。

問題2:sys.getsizeof(s1+'!')-sys.getsizeof(s1)這個(gè)表達(dá)式的含義是什么呢
Ok浓若,這個(gè)表達(dá)式就表示,讀取字符串內(nèi)存實(shí)體的有效負(fù)載內(nèi)的字節(jié)數(shù)據(jù)蛇数,以1個(gè)字節(jié)位寬去解碼每個(gè)字符挪钓。

那么我們再來一個(gè)稍微復(fù)雜一點(diǎn)的例子,下圖的例子我想你應(yīng)該心中有數(shù)了吧耳舅。

?和?這兩個(gè)字符在CPython內(nèi)部是以1個(gè)字節(jié)的位寬來表示碌上,他們的ASCII編碼分別是169和174,這些都是ASCII字符集范圍內(nèi)的字符。而??這個(gè)屬于需要4個(gè)字節(jié)的位寬來表示,它的unicode編碼是128013园匹,那么4字節(jié)位寬的二進(jìn)制表示為"00000000 00000001 11110100 00001101"

>>> ord('?')
169
>>> ord('?')
174
>>> ord('??')
128013
>>> bin(ord('??'))
00000000 00000001 11110100 00001101
>>> 

字符串駐留

什么是字符串駐留(String Interning)呢深纲?其實(shí)這個(gè)跟C對待字符串在RAM中存儲方式是一樣的,就是一個(gè)"特定"的字符串在內(nèi)存中只存在一份霞丧,其他Python變量都是其引用.

我們先來個(gè)自動(dòng)駐留的示例呢岗,兩個(gè)變量引用一個(gè)字符串"Hello Lisa!?",我們同時(shí)對其字符串引用的變量蛹尝,以及字符串本身傳入id函數(shù)后豫。他們都指向“Hello Lisa!?”的真實(shí)的內(nèi)存地址。


我們嘗試執(zhí)行后突那,在腳本的上下文中同樣的代碼測試得到期望的結(jié)果挫酿。

那么其內(nèi)存圖如下,數(shù)據(jù)棧的變量標(biāo)簽A和B都指向堆中"Hello Lisa!?"對應(yīng)的PyASCIIObject實(shí)例的內(nèi)存地址140619830398512愕难。

那為什么會出現(xiàn)這種情況呢早龟?Python解釋器在執(zhí)行第一條語句前,堆內(nèi)存中還沒有該字符串猫缭,當(dāng)執(zhí)行完第一條語句時(shí)葱弟,棧中的變量A立即被分配為引用到“Hello Lisa !?”。 執(zhí)行第一條語句之后饵骨,“ Hello Lisa !?” 將以駐留的方式一直活躍在堆內(nèi)存中翘悉。這是通過調(diào)用以下任何一條CPython函數(shù)來實(shí)現(xiàn)的:

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!?"執(zhí)行時(shí),CPython內(nèi)部決定是否需要?jiǎng)?chuàng)建新的“Hello Lisa!?”實(shí)例之前,CPython首先檢查其內(nèi)聯(lián)字符串的存儲居触,以確定是否已實(shí)例化相同的字符串妖混。

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

顯然相同的字符串已經(jīng)駐留在堆中,那么變量B的“=”所謂賦值只是指向原來“ Hello Lisa !?” 實(shí)例的內(nèi)存地址轮洋,并且“ Hello Lisa !?” 實(shí)例的引用計(jì)數(shù)會+1.

字符串駐留怎么跟共享對象那么相似的呢制市?當(dāng)然! 字符串駐留背后的方法和思想都與CPython共享對象的實(shí)現(xiàn)并存弊予。 實(shí)際上祥楣,一旦一個(gè)字符串駐留在內(nèi)存后,它實(shí)質(zhì)上就等同于一個(gè)共享庫-該字符串的實(shí)例對于給定Python會話中執(zhí)行的所有程序都是全局可用的汉柒。 就像共享對象一樣误褪,通過內(nèi)部字符串,Python在時(shí)間和內(nèi)存上都可以更高效碾褂,但僅針對某些具體的應(yīng)用場景兽间。

字符串駐留的限定條件

對于Python來說,將每個(gè)被調(diào)用的字符串永久保存在內(nèi)存中是沒有意義的正塌,這最終會導(dǎo)致不必要的內(nèi)存浪費(fèi)嘀略。 取而代之的是恤溶,Python會盡最大努力專門駐留最可能被重用的字符串-標(biāo)識符字符串。 標(biāo)識符字符串包括以下內(nèi)容:

  • 函數(shù)和類名
  • 變量名
  • 參數(shù)名稱
  • 字典鍵
  • 屬性名稱

請注意帜羊,Python實(shí)際上并沒有檢測到以上內(nèi)容-首先它甚至無法做到這一點(diǎn)咒程。 而一個(gè)字符串對象在實(shí)例化駐留以否,分兩種運(yùn)行環(huán)境進(jìn)行討論.

.py的腳本文件中的字符串初始化后讼育,同時(shí)滿足以下三個(gè)條件才能達(dá)成字符串駐留帐姻。

  • 條件1:該字符串必須是編譯時(shí)常量。除非在編譯時(shí)將其作為常量字符串加載窥淆,否則字符串不會被駐留在堆內(nèi)存中卖宠。 這包括
    1. 定義為表達(dá)式的字符串-請記住,在實(shí)例化對象之前首先對表達(dá)式求值忧饭。
    2. 運(yùn)行時(shí)構(gòu)造的任何字符串(即通過方法扛伍,函數(shù)等生成的任何字符串)。

我們運(yùn)行效果如下圖词裤,由于A引用動(dòng)態(tài)構(gòu)造的字符串刺洒,在say_hello函數(shù)銷毀后,其內(nèi)部緩存在堆的字符串對象也一同銷毀,接著B再次引用say_hello函數(shù)生成的相同文字的字符串對象吼砂,但是內(nèi)存地址完全不一樣的全新對象逆航。C和D引用的是一個(gè)編譯時(shí)的字符串常量。

  • 條件2:字符串不得連續(xù)拼接渔肩,這個(gè)容易理解示例已經(jīng)解析的很清楚了因俐。
  • 條件3:字符串可以是任意編碼類型的字符串,ASCII字符、Unicode字符等周偎,并且沒有長度限制,該條件其實(shí)是條件1的補(bǔ)充說明抹剩。

我們通過一個(gè)下面的簡單示例可以得到驗(yàn)證。首先A和B引用的2字節(jié)位寬的字符串對象(由PyCompactUnicodeObject封裝)蓉坎,都是中文字 ;C和D引用的是4字節(jié)位寬的unicode字節(jié)序列(由PyUnicodeObject封裝)

運(yùn)行測試一下澳眷,PyCompactUnicodeObject和PyUnicodeObject封裝的任意長度的字符串常量,在Python運(yùn)行周期內(nèi)都是允許駐留在堆內(nèi)存中的蛉艾。

接下來钳踊,我們分析一下另一種使用環(huán)境,在Python交互命令行字符串駐留3個(gè)條件都必須同時(shí)滿足勿侯,經(jīng)過前面的分析拓瞪,我不想再過多廢話。請看下圖的在交互環(huán)境中的例子助琐。

  • 條件1:該字符串必須是編譯時(shí)常量吴藻。除非在編譯時(shí)將其作為常量字符串加載,否則字符串不會被駐留在堆內(nèi)存中弓柱。 這包括
    1. 定義為表達(dá)式的字符串-請記住沟堡,在實(shí)例化對象之前首先對表達(dá)式求值。
    2. 運(yùn)行時(shí)構(gòu)造的任何字符串(即通過方法矢空,函數(shù)等生成的任何字符串)航罗。
  • 條件2:字符串不得連續(xù)拼接,并且不得超過20個(gè)字符屁药。
  • 條件3:該字符串僅由ASCII字母粥血,數(shù)字或下劃線組成

我們分析字符串駐留的內(nèi)存特性酿箭,可以滿足使得在某些應(yīng)用場景令Python程序在字符串性能方面收益复亏。例如使用PyQt或Tkinter寫的GUI程序,通常不同圖形的模塊會引用到相同的文字字面量(例如中文工具欄或者菜單缭嫡,對話框的文字描述等)缔御,Python在初始化這些字符串常量初始化為堆中的字符串對象并駐留在堆內(nèi)存中,后續(xù)調(diào)用這些字符串妇蛀,例如加載某個(gè)菜單列表耕突,加載速度都得到快速的提升。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末评架,一起剝皮案震驚了整個(gè)濱河市眷茁,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌纵诞,老刑警劉巖上祈,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異浙芙,居然都是意外死亡登刺,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進(jìn)店門茁裙,熙熙樓的掌柜王于貴愁眉苦臉地迎上來塘砸,“玉大人,你說我怎么就攤上這事晤锥〉羰撸” “怎么了?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵矾瘾,是天一觀的道長女轿。 經(jīng)常有香客問我,道長壕翩,這世上最難降的妖魔是什么蛉迹? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮放妈,結(jié)果婚禮上北救,老公的妹妹穿的比我還像新娘荐操。我一直安慰自己,他們只是感情好珍策,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布托启。 她就那樣靜靜地躺著,像睡著了一般攘宙。 火紅的嫁衣襯著肌膚如雪屯耸。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天蹭劈,我揣著相機(jī)與錄音疗绣,去河邊找鬼。 笑死铺韧,一個(gè)胖子當(dāng)著我的面吹牛多矮,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播祟蚀,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼工窍,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了前酿?” 一聲冷哼從身側(cè)響起患雏,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎淹仑,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體肺孵,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡匀借,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了平窘。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片吓肋。...
    茶點(diǎn)故事閱讀 38,039評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖瑰艘,靈堂內(nèi)的尸體忽然破棺而出是鬼,到底是詐尸還是另有隱情,我是刑警寧澤紫新,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布均蜜,位于F島的核電站,受9級特大地震影響芒率,放射性物質(zhì)發(fā)生泄漏囤耳。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望充择。 院中可真熱鬧德玫,春花似錦、人聲如沸聪铺。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽铃剔。三九已至,卻和暖如春查刻,著一層夾襖步出監(jiān)牢的瞬間键兜,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工穗泵, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留普气,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓佃延,卻偏偏與公主長得像现诀,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子履肃,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評論 2 345