字符串在Python內部是如何省內存的

起步

Python3 起,str 就採用了 Unicode 編碼(注意這裏並非 utf8 編碼,儘管 .py 文件默認編碼是 utf8 )。 每一個標準 Unicode 字符佔用 4 個字節。這對於內存來講,無疑是一種浪費。python

Unicode 是表示了一種字符集,而爲了傳輸方便,衍生出裏如 utf8 , utf16 等編碼方案來節省存儲空間。Python內部存儲字符串也採用了相似的形式。函數

三種內部表示Unicode字符串

爲了減小內存的消耗,Python使用了三種不一樣單位長度來表示字符串:ui

  • 每一個字符 1 個字節(Latin-1)
  • 每一個字符 2 個字節(UCS-2)
  • 每一個字符 4 個字節(UCS-4)

源碼中定義字符串結構體:this

# Include/unicodeobject.h
typedef uint32_t Py_UCS4;
typedef uint16_t Py_UCS2;
typedef uint8_t Py_UCS1;

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

若是字符串中全部字符都在 ascii 碼範圍內,那麼就能夠用佔用 1 個字節的 Latin-1 編碼進行存儲。而若是字符串中存在了須要佔用兩個字節(好比中文字符),那麼整個字符串就將採用佔用 2 個字節 UCS-2 編碼進行存儲。編碼

這點能夠經過 sys.getsizeof 函數外部窺探來驗證這個結論:spa

20200110170427.png

如圖,存儲 'zh' 所需的存儲空間比 'z' 多 1 個字節, h 在這裏佔了 1 個字節;code

存儲 'z中' 所需的存儲空間比 '中' 多了 2 個字節,z 在這裏佔了 2 個字節。orm

大多數的天然語言採用 2 字節的編碼就夠了。但若是有一個 1G 的 ascii 文本加載到內存後,在文本中插入了一個 emoji 表情,那麼字符串所需的空間將擴大到 4 倍,是否是很驚喜。對象

爲何內部不採用 utf8 進行編碼

最受歡迎的 Unicode 編碼方案,Python內部卻不使用它,爲何?blog

這裏就得說下 utf8 編碼帶來的缺點。這種編碼方案每一個字符的佔用字節長度是變化的,這就致使了沒法按因此隨機訪問單個字符,例如 string[n] (使用utf8編碼)則須要先統計前n個字符佔用的字節長度。因此由 O(1) 變成了 O(n) ,這更沒法讓人接受。

所以Python內部採用了定長的方式存儲字符串。

字符串駐留機制

另外一個節省內存的方式就是將一些短小的字符串作成池,當程序要建立字符串對象前檢查池中是否有知足的字符串。在內部中,僅包含下劃線(_)、字母數字 的長度不高過 20 的字符串才能駐留。駐留是在代碼編譯期間進行的,代碼中的以下會進行駐留檢查:

  • 空字符串 '' 及全部;
  • 變量名;
  • 參數名;
  • 字符串常量(代碼中定義的全部字符串);
  • 字典鍵;
  • 屬性名稱;

駐留機制節省大量的重複字符串內存。在內部,字符串駐留池由一個全局的 dict 維護,該字段將字符串用做鍵:

void PyUnicode_InternInPlace(PyObject **p)
{
    PyObject *s = *p;
    PyObject *t;

    if (s == NULL || !PyUnicode_Check(s))
        return;

    // 對PyUnicodeObjec進行類型和狀態檢查
    if (!PyUnicode_CheckExact(s))
        return;
    if (PyUnicode_CHECK_INTERNED(s))
        return;
    // 建立intern機制的dict
    if (interned == NULL) {
        interned = PyDict_New();
        if (interned == NULL) {
            PyErr_Clear(); /* Don't leave an exception */
            return;
        }
    }

    // 對象是否存在於inter中
    t = PyDict_SetDefault(interned, s, s);

    // 存在, 調整引用計數
    if (t != s) {
        Py_INCREF(t);
        Py_SETREF(*p, t);
        return;
    }
    /* The two references in interned are not counted by refcnt.
       The deallocator will take care of this */
    Py_REFCNT(s) -= 2;
    _PyUnicode_STATE(s).interned = SSTATE_INTERNED_MORTAL;
}

變量 interned 就是全局存放字符串池的字典的變量名 interned = PyDict_New(),爲了讓 intern 機制中的字符串不被回收,設置字典時 PyDict_SetDefault(interned, s, s); 將字符串做爲鍵同時也做爲值進行設置,這樣對於字符串對象的引用計數就會進行兩次 +1 操做,這樣存於字典中的對象在程序結束前永遠不會爲 0,這也是 y_REFCNT(s) -= 2; 將計數減 2 的緣由。

從函數參數中能夠看到其實字符串對象仍是被建立了,內部其實始終會爲字符串建立對象,但通過 inter 機制檢查後,臨時建立的字符串會因引用計數爲 0 而被銷燬,臨時變量在內存中曇花一現而後迅速消失。

字符串緩衝池

除了字符串駐留池,Python 還會保存全部 ascii 碼內的單個字符:

static PyObject *unicode_latin1[256] = {NULL};

若是字符串實際上是一個字符,那麼優先從緩衝池中獲取:

[unicodeobjec.c]
PyObject * PyUnicode_DecodeUTF8Stateful(const char *s,
                             Py_ssize_t size,
                             const char *errors,
                             Py_ssize_t *consumed)
{
    ...

    /* ASCII is equivalent to the first 128 ordinals in Unicode. */
    if (size == 1 && (unsigned char)s[0] < 128) {
        return get_latin1_char((unsigned char)s[0]);
    }
    ...
}

而後再通過 intern 機制後被保存到 intern 池中,這樣駐留池中和緩衝池中,二者都是指向同一個字符串對象了。

嚴格來講,這個單字符緩衝池並非省內存的方案,由於從中取出的對象幾乎都會保存到緩衝池中,這個方案是爲了減小字符串對象的建立。

總結

本文介紹了兩種是節省內存的方案。一個字符串的每一個字符在佔用空間大小是相同的,取決於字符串中的最大字符。

短字符串會放到一個全局的字典中,該字典中的字符串成了單例模式,從而節省內存。

相關文章
相關標籤/搜索