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)存中卖宠。 這包括
- 定義為表達(dá)式的字符串-請記住,在實(shí)例化對象之前首先對表達(dá)式求值忧饭。
- 運(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)存中弓柱。 這包括
- 定義為表達(dá)式的字符串-請記住沟堡,在實(shí)例化對象之前首先對表達(dá)式求值。
- 運(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è)菜單列表耕突,加載速度都得到快速的提升。