3.2 記憶體管理與垃圾回收
3.2 記憶體管理與垃圾回收
為什麼
Python 的記憶體管理結合了參考計數和分代垃圾回收。理解這些機制有助於寫出更高效的程式碼。
先備知識
本章目標
學完本章後,你將能夠:
- 理解參考計數的限制
- 理解分代垃圾回收的原理
- 使用
__slots__優化記憶體 - 使用
tracemalloc分析記憶體使用
【原理層】記憶體模型
Stack vs Heap
Python 的記憶體分為兩個主要區域:
1┌─────────────────────────────────────┐
2│ Stack │
3│ ┌─────────────────────────────────┐│
4│ │ 變數名稱 → 指向 Heap 的指標 ││
5│ │ a ──────→ [指標] ││
6│ │ b ──────→ [指標] ││
7│ └─────────────────────────────────┘│
8└─────────────────────────────────────┘
9 │
10 ▼
11┌─────────────────────────────────────┐
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)思考題
- 為什麼 Python 需要同時使用參考計數和垃圾回收?只用其中一種不行嗎?
__slots__為什麼不能用於繼承自內建型別的類別?- 在什麼情況下應該手動呼叫
gc.collect()?
實作練習
- 使用
tracemalloc分析一個現有程式的記憶體使用 - 將一個使用大量物件的程式改用
__slots__優化 - 使用
WeakValueDictionary實作一個自動清理的快取
延伸閱讀
上一章:PyObject 與物件模型 下一章:Bytecode 與虛擬機