Python 的記憶體管理結合了參考計數和分代垃圾回收。理解這些機制有助於寫出更高效的程式碼。

先備知識

本章目標

學完本章後,你將能夠:

  1. 理解參考計數的限制
  2. 理解分代垃圾回收的原理
  3. 使用 __slots__ 優化記憶體
  4. 使用 tracemalloc 分析記憶體使用

【原理層】記憶體模型

Stack vs Heap

Python 的記憶體分為兩個主要區域:

 1┌─────────────────────────────────────┐
 2│              Stack                   │
 3│  ┌─────────────────────────────────┐│
 4│  │ 變數名稱 → 指向 Heap 的指標      ││
 5│  │ a ──────→ [指標]                ││
 6│  │ b ──────→ [指標]                ││
 7│  └─────────────────────────────────┘│
 8└─────────────────────────────────────┘
 91011┌─────────────────────────────────────┐
12│              Heap                    │
13│  ┌─────────────────────────────────┐│
14│  │ PyObject: [1, 2, 3]             ││
15│  │ PyObject: "hello"               ││
16│  │ PyObject: 42                    ││
17│  └─────────────────────────────────┘│
18└─────────────────────────────────────┘
  • Stack:儲存變數名稱和指標(參考)
  • Heap:儲存實際的 Python 物件

Python 的記憶體分配器

CPython 使用分層的記憶體分配器:

 1┌─────────────────────────────────────┐
 2│     Python 物件分配器               │
 3│     (PyObject_Malloc)               │
 4├─────────────────────────────────────┤
 5│     Python 記憶體分配器             │
 6│     (PyMem_Malloc)                  │
 7├─────────────────────────────────────┤
 8│     C 標準函式庫                    │
 9│     (malloc)                        │
10├─────────────────────────────────────┤
11│     作業系統                        │
12└─────────────────────────────────────┘

對於小於 512 bytes 的物件,Python 使用自己的分配器來減少系統呼叫。


【設計層】循環參考問題

參考計數的限制

參考計數無法處理循環參考:

 1import gc
 2
 3class Node:
 4    def __init__(self, name):
 5        self.name = name
 6        self.ref = None
 7
 8# 建立循環參考
 9a = Node("A")
10b = Node("B")
11a.ref = b
12b.ref = a
13
14# 刪除外部參考
15del a
16del b
17
18# 此時 A 和 B 仍互相參考,參考計數都是 1
19# 但它們已經無法被存取了(垃圾)
1刪除前:
2外部 ─→ A ←──→ B ←─ 外部
3        refcnt=2  refcnt=2
4
5刪除後:
6        A ←──→ B
7        refcnt=1  refcnt=1
8        (無法被存取,但參考計數不為 0)

分代垃圾回收

為了解決循環參考,Python 使用分代垃圾回收:

 1import gc
 2
 3# 查看 GC 統計
 4print(gc.get_count())  # (700, 10, 0) - 各代的物件數
 5
 6# 三個世代(Python 3.12 以前)
 7# Generation 0: 新物件
 8# Generation 1: 存活過一次 GC 的物件
 9# Generation 2: 存活過多次 GC 的物件
10
11# Python 3.12+ 改為四個世代
12# Young generation (1 代)
13# Old generations (2 代)
14# Permanent generation (永久)

GC 觸發時機

 1import gc
 2
 3# 查看閾值
 4print(gc.get_threshold())  # (700, 10, 10)
 5
 6# 意義:
 7# - 當 Generation 0 有 700 個物件時,觸發 Gen 0 GC
 8# - 當 Gen 0 GC 執行 10 次後,觸發 Gen 1 GC
 9# - 當 Gen 1 GC 執行 10 次後,觸發 Gen 2 GC
10
11# 手動觸發 GC
12gc.collect()
13
14# 設定閾值
15gc.set_threshold(1000, 15, 15)

【實作層】記憶體優化

使用 slots

__slots__ 可以顯著減少物件的記憶體使用:

 1import sys
 2
 3class WithoutSlots:
 4    def __init__(self, x, y):
 5        self.x = x
 6        self.y = y
 7
 8class WithSlots:
 9    __slots__ = ['x', 'y']
10
11    def __init__(self, x, y):
12        self.x = x
13        self.y = y
14
15obj1 = WithoutSlots(1, 2)
16obj2 = WithSlots(1, 2)
17
18print(sys.getsizeof(obj1))  # 48
19print(sys.getsizeof(obj2))  # 48(但沒有 __dict__)
20
21# 實際差異在 __dict__
22print(sys.getsizeof(obj1.__dict__))  # 104
23# obj2 沒有 __dict__

為什麼 __slots__ 省記憶體?

 1沒有 __slots__:
 2┌─────────────────────────┐
 3│ PyObject header (16 B)  │
 4│ __dict__ 指標 (8 B)      │
 5│ __weakref__ 指標 (8 B)   │
 6│ __dict__ → { 'x': 1,    │
 7│              'y': 2 }   │
 8│            (額外 100+ B)│
 9└─────────────────────────┘
10
11有 __slots__:
12┌─────────────────────────┐
13│ PyObject header (16 B)  │
14│ x (8 B)                 │
15│ y (8 B)                 │
16└─────────────────────────┘

slots 的限制

 1class Base:
 2    __slots__ = ['x']
 3
 4class Derived(Base):
 5    __slots__ = ['y']  # 不能與父類別重複
 6
 7    def __init__(self):
 8        self.x = 1
 9        self.y = 2
10        # self.z = 3  # 錯誤!沒有 __dict__
11
12# 如果需要動態屬性,加入 '__dict__'
13class Flexible:
14    __slots__ = ['x', '__dict__']

使用弱參考

弱參考不增加參考計數,適合用於快取:

 1import weakref
 2
 3class ExpensiveObject:
 4    def __init__(self, value):
 5        self.value = value
 6
 7# 建立物件和弱參考
 8obj = ExpensiveObject(42)
 9weak_ref = weakref.ref(obj)
10
11print(weak_ref())  # <ExpensiveObject object>
12print(weak_ref().value)  # 42
13
14# 刪除強參考
15del obj
16
17print(weak_ref())  # None(物件已被回收)

使用 WeakValueDictionary 實作快取

 1import weakref
 2
 3class Cache:
 4    def __init__(self):
 5        self._cache = weakref.WeakValueDictionary()
 6
 7    def get(self, key, factory):
 8        value = self._cache.get(key)
 9        if value is None:
10            value = factory()
11            self._cache[key] = value
12        return value
13
14cache = Cache()
15
16def create_expensive():
17    return ExpensiveObject(100)
18
19obj = cache.get('key1', create_expensive)
20# 當 obj 不再被使用時,快取會自動清理

【實作層】記憶體分析工具

使用 tracemalloc

 1import tracemalloc
 2
 3# 開始追蹤
 4tracemalloc.start()
 5
 6# 執行程式碼
 7data = [i ** 2 for i in range(10000)]
 8more_data = {str(i): i for i in range(10000)}
 9
10# 取得記憶體快照
11snapshot = tracemalloc.take_snapshot()
12
13# 顯示前 10 個記憶體使用最多的位置
14top_stats = snapshot.statistics('lineno')
15for stat in top_stats[:10]:
16    print(stat)
17
18# 比較兩個快照
19tracemalloc.start()
20snapshot1 = tracemalloc.take_snapshot()
21
22# 執行更多程式碼
23big_list = list(range(100000))
24
25snapshot2 = tracemalloc.take_snapshot()
26diff = snapshot2.compare_to(snapshot1, 'lineno')
27
28for stat in diff[:5]:
29    print(stat)

使用 gc 模組除錯

 1import gc
 2
 3# 啟用除錯
 4gc.set_debug(gc.DEBUG_LEAK)
 5
 6# 找出無法回收的物件
 7gc.collect()
 8print(gc.garbage)  # 無法回收的物件列表
 9
10# 取得所有被追蹤的物件
11all_objects = gc.get_objects()
12print(f"被追蹤的物件數量: {len(all_objects)}")
13
14# 找出特定類別的實例
15class MyClass:
16    pass
17
18instances = [obj for obj in gc.get_objects() if isinstance(obj, MyClass)]
19print(f"MyClass 實例數量: {len(instances)}")

檢測記憶體洩漏

 1import gc
 2import tracemalloc
 3
 4def find_memory_leak():
 5    tracemalloc.start()
 6
 7    # 記錄初始狀態
 8    gc.collect()
 9    snapshot1 = tracemalloc.take_snapshot()
10
11    # 執行可能洩漏的程式碼
12    for _ in range(1000):
13        suspicious_function()
14
15    # 記錄最終狀態
16    gc.collect()
17    snapshot2 = tracemalloc.take_snapshot()
18
19    # 比較
20    diff = snapshot2.compare_to(snapshot1, 'lineno')
21
22    print("記憶體增長最多的位置:")
23    for stat in diff[:10]:
24        print(stat)

【實戰】常見記憶體問題

問題 1:大量小物件

 1# 不好:建立大量小物件
 2class Point:
 3    def __init__(self, x, y):
 4        self.x = x
 5        self.y = y
 6
 7points = [Point(i, i) for i in range(1000000)]
 8
 9# 好:使用 __slots__
10class Point:
11    __slots__ = ['x', 'y']
12    def __init__(self, x, y):
13        self.x = x
14        self.y = y
15
16# 更好:使用 NumPy(如果是數值資料)
17import numpy as np
18points = np.zeros((1000000, 2))

問題 2:循環參考

 1# 不好:物件間的循環參考
 2class Parent:
 3    def __init__(self):
 4        self.children = []
 5
 6class Child:
 7    def __init__(self, parent):
 8        self.parent = parent
 9        parent.children.append(self)
10
11# 好:使用弱參考
12import weakref
13
14class Child:
15    def __init__(self, parent):
16        self.parent = weakref.ref(parent)
17        parent.children.append(self)

問題 3:全域變數累積

 1# 不好:全域快取無限增長
 2_cache = {}
 3
 4def process(key, value):
 5    if key not in _cache:
 6        _cache[key] = expensive_compute(value)
 7    return _cache[key]
 8
 9# 好:使用 LRU cache
10from functools import lru_cache
11
12@lru_cache(maxsize=1000)
13def process(key, value):
14    return expensive_compute(value)

思考題

  1. 為什麼 Python 需要同時使用參考計數和垃圾回收?只用其中一種不行嗎?
  2. __slots__ 為什麼不能用於繼承自內建型別的類別?
  3. 在什麼情況下應該手動呼叫 gc.collect()

實作練習

  1. 使用 tracemalloc 分析一個現有程式的記憶體使用
  2. 將一個使用大量物件的程式改用 __slots__ 優化
  3. 使用 WeakValueDictionary 實作一個自動清理的快取

延伸閱讀


上一章:PyObject 與物件模型 下一章:Bytecode 與虛擬機