楔子
先來簡單回顧一下,我們知道字典裏面有一個ma_keys和ma_values,其中ma_keys是一個指向PyDictKeysObject的指針,ma_values是一個指向PyObject *數組的二級指針。當哈希表爲分離表時,鍵由ma_keys維護,值由ma_values維護;當哈希表爲結合表時,鍵和值均由ma_keys維護。
那麼當我們在銷燬一個PyDictObject時,也肯定是要先釋放ma_keys和ma_values。
如果是分離表,會將每個value的引用計數減1,然後釋放ma_values;再將每個key的引用計數減1,然後釋放ma_keys。最後再釋放PyDictObject本身。
如果是結合表,由於key、value都在ma_keys中,將每個key、value的引用計數減1之後,只需要再釋放ma_keys即可。最後再釋放PyDictObject本身。
整個過程還是很清晰的,只不過這裏面遺漏了點什麼東西,沒錯,就是緩存池。在介紹浮點數的時候,我們說不同的對象都有自己的緩存池,當然字典也不例外。並且除了PyDictObject之外,PyDictKeysObject也有相應的緩存池,畢竟它負責存儲具體的鍵值對。
那麼下面我們就來研究一下這兩者的緩存池。
PyDictObject緩存池
字典的緩存池和列表的緩存池高度相似,都是採用數組實現的,並且容量也是80個。
#ifndef PyDict_MAXFREELIST #define PyDict_MAXFREELIST 80 #endif static PyDictObject *free_list[PyDict_MAXFEELIST]; static int numfree = 0; //緩存池當前存儲的元素個數
開始時,這個緩存池什麼也沒有,直到第一個PyDictObject對象被銷燬時,緩存池裏面纔開始接納被銷燬的PyDictObject對象。
static void dict_dealloc(PyDictObject *mp) { //獲取ma_values指針 PyObject **values = mp->ma_values; //獲取ma_keys指針 PyDictKeysObject *keys = mp->ma_keys; Py_ssize_t i, n; //因爲要被銷燬,所以讓GC不再跟蹤 PyObject_GC_UnTrack(mp); //用於延遲釋放 Py_TRASHCAN_SAFE_BEGIN(mp) //調整引用計數 //如果values不爲NULL,說明是分離表 if (values != NULL) { //將指向的value、key的引用計數減1 //然後釋放ma_values和ma_keys if (values != empty_values) { for (i = 0, n = mp->ma_keys->dk_nentries; i < n; i++) { Py_XDECREF(values[i]); } free_values(values); } DK_DECREF(keys); } //否則說明是結合表 else if (keys != NULL) { //結合表的話,dk_refcnt一定是1 //此時只需要釋放ma_keys,因爲鍵值對全部由它來維護 //在DK_DECREF裏面,會將每個key、value的引用計數減1 //然後釋放ma_keys assert(keys->dk_refcnt == 1); DK_DECREF(keys); } //將被銷燬的對象放到緩存池當中 if (numfree < PyDict_MAXFREELIST && Py_TYPE(mp) == &PyDict_Type) free_list[numfree++] = mp; else //如果緩存池已滿,則將釋放內存 Py_TYPE(mp)->tp_free((PyObject *)mp); Py_TRASHCAN_SAFE_END(mp) }
同理,當創建字典時,也會優先從緩存池裏面獲取。
static PyObject * new_dict(PyDictKeysObject *keys, PyObject **values) { //... if (numfree) { mp = free_list[--numfree]; } //... }
因此在緩存池的實現上,字典和列表有着很高的相似性。不僅都是由數組實現,在銷燬的時候也都會放在數組的尾部,創建的時候也會從數組的尾部獲取。當然啦,因爲這麼做符合數組的特性,如果銷燬和創建都是在數組的頭部操作,那麼時間複雜度就從O(1)變成了O(n)。
我們用Python來測試一下:
d1 = {k: 1 for k in "abcdef"} d2 = {k: 1 for k in "abcdef"} print("id(d1):", id(d1)) print("id(d2):", id(d2)) # 放到緩存池的尾部 del d1 del d2 # 緩存池:[d1, d2] # 從緩存池的尾部獲取 # 顯然id(d3)和上面的id(d2)是相等的 d3 = {k: 1 for k in "abcdefghijk"} # id(d4)和上面的id(d1)是相等的 d4 = {k: 1 for k in "abcdefghijk"} print("id(d3):", id(d3)) print("id(d4):", id(d4)) # 輸出結果 """ id(d1): 1363335780736 id(d2): 1363335780800 id(d3): 1363335780800 id(d4): 1363335780736 """
輸出結果和我們的預期是相符合的,以上就是PyDictObject的緩存池。
PyDictKeysObject緩存池
PyDictKeysObject也有自己的緩存池,同樣基於數組實現,大小是80。
//PyDictObject的緩存池叫 free_list //PyDictKeysObject的緩存池叫 keys_free_list //兩者不要搞混了 static PyDictKeysObject *keys_free_list[PyDict_MAXFREELIST]; static int numfreekeys = 0; //緩存池當前存儲的元素個數
我們先來看看它的銷燬過程:
static void free_keys_object(PyDictKeysObject *keys) { //將每個entry的me_key、me_value的引用計數減1 for (i = 0, n = keys->dk_nentries; i < n; i++) { Py_XDECREF(entries[i].me_key); Py_XDECREF(entries[i].me_value); } #if PyDict_MAXFREELIST > 0 //將其放在緩存池當中 //當緩存池未滿、並且dk_size爲8的時候被緩存 if (keys->dk_size == PyDict_MINSIZE && numfreekeys < PyDict_MAXFREELIST) { keys_free_list[numfreekeys++] = keys; return; } #endif PyObject_FREE(keys); }
銷燬的時候,也是放在了緩存池的尾部,那麼創建的時候肯定也是先從緩存池的尾部獲取。
static PyDictKeysObject *new_keys_object(Py_ssize_t size) { PyDictKeysObject *dk; Py_ssize_t es, usable; //... //創建 ma_keys,如果緩存池有可用對象、並且size等於8, //那麼會從 keys_free_list 中獲取 if (size == PyDict_MINSIZE && numfreekeys > 0) { dk = keys_free_list[--numfreekeys]; } else { // 否則malloc重新申請 dk = PyObject_MALLOC(sizeof(PyDictKeysObject) + es * size + sizeof(PyDictKeyEntry) * usable); } } //... return dk; }
所以PyDictKeysObject的緩存池和列表同樣是高度相似的,只不過它想要被緩存,還需要滿足一個額外的條件,那就是dk_size必須等於8。很明顯,這個限制是出於對內存方面的考量。
我們還是來驗證一下。
import ctypes class PyObject(ctypes.Structure): _fields_ = [("ob_refcnt", ctypes.c_ssize_t), ("ob_type", ctypes.c_void_p)] class PyDictObject(PyObject): _fields_ = [("ma_used", ctypes.c_ssize_t), ("ma_version_tag", ctypes.c_uint64), ("ma_keys", ctypes.c_void_p), ("ma_values", ctypes.c_void_p)] d1 = {_: 1 for _ in "mnuvwxyz12345"} print( PyDictObject.from_address(id(d1)).ma_keys ) # 1962690551536 # 鍵值對個數超過了8,dk_size必然也超過了 8 # 那麼當銷燬d1的時候,d1.ma_keys不會被緩存 # 而是會直接釋放掉 del d1 d2 = {_: 1 for _ in "a"} print( PyDictObject.from_address(id(d2)).ma_keys ) # 1962387670624 # d2 的 dk_size 顯然等於 8 # 因此它的 ma_keys 是會被緩存的 del d2 d3 = {_: 1 for _ in "abcdefg"} print( PyDictObject.from_address(id(d3)).ma_keys ) # 1962699215808 # 儘管 d2 的 ma_keys 被緩存起來了 # 但是 d3 的 dk_size 大於 8 # 因此它不會從緩存池中獲取,而是重新創建 # d4 的 dk_size 等於 8 # 因此它會獲取 d2 被銷燬的 ma_keys d4 = {_: 1 for _ in "abc"} print( PyDictObject.from_address(id(d4)).ma_keys ) # 1962387670624
所以從打印的結果來看,由於d4.ma_keys和d2.ma_keys是相同的,因此證實了我們的結論。不像列表和字典,它們是隻要被銷燬,就會放到緩存池裏面,因爲它們沒有存儲具體的數據,大小是固定的。
但是PyDictKeysObject不同,它存儲了entry,每個entry佔24字節。如果內部的entry非常多,那麼緩存起來會有額外的內存開銷。因此Python的策略是,只有在dk_size等於8的時候,纔會緩存。當然這三者在緩存池的實現上,是基本一致的。
小結
到此,字典相關的內容我們就全部介紹完了。總的來說,Python的字典是一個被高度優化的數據結構,因爲解釋器在運行的時候也重度依賴字典,這就決定了它的效率會非常高。
當然,我們沒有涉及字典的全部內容,比如字典有很多方法,比如keys、values、items方法等等,我們並沒有說。這些有興趣的話,可以對着源碼看一遍,不是很難。
總之我們平時,也可以儘量多使用字典。
以上就是本次分享的所有內容,想要了解更多歡迎前往公衆號:Python編程學習圈,每日干貨分享