Python のメモリ管理メカニズムの完全解説

Python のメモリ管理メカニズムの完全解説

一、全体アーキテクチャの概要

Python のメモリ管理は階層化されており、下位層から上位層にかけて5つの層に分かれています:

  1. OS層malloc/freemmap を呼び出し、OSにヒープメモリを割り当てさせる
  2. Python独自のメモリプール(Arena/Pool/Block):コアとなる小オブジェクトアロケーターであり、頻繁な生成・破棄によるメモリ断片化を解消する
  3. オブジェクト専用キャッシュ:小整数プール、文字列の常駐、フリーリスト
  4. 参照カウント:デフォルトの基礎的なガベージコレクション機構。参照のないオブジェクトをリアルタイムで解放
  5. 世代別マーク・コレクション(GC):最終的な回収機構。参照カウントでは解決できない循環参照を専門に処理

二、基本アロケーター:PyObject_Malloc 階層型メモリプール(pymalloc)

背景となる問題

プログラムが int/str/list などの小さなオブジェクトを頻繁に生成・破棄する場合、OSの malloc を直接頻繁に呼び出すと、大量のメモリ断片化が発生し、システムコールのオーバーヘッドが極めて高くなります。Pythonには独自のメモリプールが組み込まれており、OSに対して事前に大きな連続したメモリ領域を割り当ててもらい、プログラム内部で自主的に分割・再利用します。

3層のメモリ構造(大きい順)

  1. Arena(アリーナ、256KB) 1回のOS呼び出しで256KBの連続したメモリを割り当て、複数のArenaがプロセスのヒープ全体を構成します。Arena内部のすべてのPoolが空き状態になった場合にのみ、メモリブロック全体がOSに解放されます。
  2. Pool(メモリプール、4KB) 1つのArenaは16個のPoolに均等に分割され、同一のPoolには同じサイズのオブジェクトブロックのみが格納されるため、統一的な管理と再利用が容易になる。
  3. Block(最小メモリブロック) Pool内部で均等に分割された最小の記憶単位であり、512バイト以下の小さなオブジェクトを格納するために専用に用いられる。

コアな割り当てルール

  • 小オブジェクト(512バイト以下):pymallocメモリプールを経由し、オブジェクトの破棄後はメモリがフリーリストに登録されて再利用されるため、直ちにOSに返却されることはない;
  • 大オブジェクト(>512バイト) :メモリプールをスキップし、システムの malloc を直接呼び出して割り当て、破棄時には free でOSに直接解放します。

Free List(フリーリスト)

各サイズのブロックごとに個別の空きリストが管理される:オブジェクトが破棄される際、ブロックのメモリはシステムに返却されず、対応するサイズのリストに格納される。同じサイズのオブジェクトを新規作成する際は、優先的にリストからメモリを再利用し、システムコールのオーバーヘッドを低減する。


三、オブジェクトのライフサイクル管理:参照カウント(Reference Count)

基本原理

すべての Python オブジェクトは、内部的に PyObject 構造体に基づいており、ヘッダーには ob_refcnt 参照カウンターが組み込まれている:

  • オブジェクトへの新しい参照が追加された場合:ob_refcnt += 1
  • オブジェクトへの参照が失効:ob_refcnt -= 1
  • ob_refcnt == 0の場合:オブジェクトのメモリを同期的かつ即座に解放し、待機遅延は発生しない

サンプルコード

Python

実行

a = [1,2] # リストの参照カウント = 1
b = a # 参照が追加され、refcnt = 2
del a # 変数を削除し、refcnt = 1
b = None # 最後の参照が切断され、refcnt = 0、リストは直ちに解放される

参照カウントが +1 となるトリガー条件

  1. 変数への代入:b = a
  2. オブジェクトを関数の引数として渡す
  3. コンテナへの格納:lst.append(obj)
  4. スライシング、浅いコピー、クラス/グローバル属性へのバインド

参照カウントが -1 になった際のトリガー条件

  1. del obj 変数の能動的な削除
  2. 変数の再代入による上書き:a = None
  3. 関数の実行終了、ローカル変数がスコープ外に出る
  4. コンテナからの要素削除、辞書のキー・値の削除

参照カウントの長所と短所

✅ 長所:

  1. リアルタイムでの回収。カウンタがゼロになった瞬間にメモリが解放され、GCによる待機遅延がない;
  2. ロジックが単純で、開発者はオブジェクトの解放タイミングを予測できる。

❌ 致命的な欠点:循環参照を処理できない

Python

実行

a = []
b = []
a.append(b)
b.append(a)
del a
del b
# aとbは互いに相手を参照しており、両者のrefcntは1のまま。参照カウントが自動的にゼロにならず、メモリリークが発生する

循環参照の脆弱性を解決するため、Pythonでは世代別ガベージコレクションが最終手段として導入された。


4. 世代別ガベージコレクション(Generational GC)(循環参照を専門に処理)

理論的基礎:世代仮説

  1. 大多数のオブジェクトのライフサイクルは極めて短く、作成後すぐに無効になります;
  2. 生存期間が長いオブジェクトほど、その後破棄されにくくなります。

Pythonはオブジェクトを3つの世代に分類しており、世代が古いほどGCのスキャン頻度は低くなります:

  • 第0世代(新生世代):新しく作成されたすべてのオブジェクト。スキャン頻度が最も高い;
  • 第1世代:第0世代で1回のGCを生き延びたオブジェクト;
  • 第2世代(老年代):長期的に存続するオブジェクト。ヒープ全体のスキャンが行われ、オーバーヘッドが最大となる。

GCの完全なワークフロー

  1. 新しく作成されたコンテナオブジェクトはデフォルトで第0世代に配置される;
  2. 第0世代のコンテナオブジェクト数が閾値に達すると、第0世代のGCがトリガーされる:
    • コンテナオブジェクト(list/dict/set/カスタムクラスのインスタンス)のみを走査する(純粋な数値や文字列は内部参照を持たないため、走査の対象外);
    • コンテナ内部の相互参照カウントを一時的に差し引き、「外部からの実際の参照」と「内部の循環参照」を区別する;
    • 参照カウントがゼロになった到達不能なオブジェクトをガベージとしてマークし、一括回収する;
    • 生存オブジェクトは第1世代に昇格する;
  3. 第1世代オブジェクトが閾値に達すると、第0世代および第1世代をスキャンし、生存オブジェクトは第2世代に昇格する;
  4. 第2世代の閾値に達すると、全3世代のスキャンが実行され、STW(停止時間)が最も長くなる。

循環参照の回収ロジック

  1. GCはコンテナクラスのオブジェクトのみをスキャンする(内部で相互参照を生成できるのはコンテナのみである);
  2. オブジェクト間の循環参照カウントを一時的に減算する;
  3. オブジェクトの実際の参照数が0になった場合 → ガベージとしてマークし解放する;依然として0より大きい場合は、外部からの有効な参照が存在することを意味するため、オブジェクトを保持する。

GC 手動制御 API

python

実行

import gc
gc.collect() # 全世代ガベージコレクションを手動でトリガーし、循環参照をクリーンアップ
gc.disable() # 自動GCを無効化(循環参照オブジェクトは恒久的にリーク)
gc.enable() # 自動GCを有効化
gc.get_threshold() # 第3世代GCのトリガー閾値を取得

五、組み込みキャッシュの最適化(メモリ常駐、重複割り当ての削減)

1. 小さな整数プール -5, 256

Pythonインタプリタの起動時、指定範囲内のすべての整数オブジェクトを事前に生成し、グローバルで一意に再利用します:

python

実行

a = 100
b = 100
print(a is b) # True、同じメモリオブジェクトを共有

a = 300
b = 300
print(a is b) # インタラクティブ環境ではFalse;スクリプトのコンパイル最適化下ではTrueとなる可能性がある

-5 ~ 256の範囲を超える整数については、リテラルごとに新しいオブジェクトが作成される。

2. 文字列のIntern化

同一の文字列は同一のメモリ領域を再利用し、割り当てのオーバーヘッドを削減します:

  • 自動キャッシュのルール:英字、数字、アンダースコアのみで構成される文字列のみが自動的にキャッシュされます。スペースや特殊記号を含む場合は自動キャッシュされません。

Python

実行

a = 「hello_world」
b = 『hello_world』
print(a is b) # True

c = 「hello world」
d = 「hello world」
print(c is d) # False

# 任意の文字列を手動で強制的にInternに格納
from sys import intern
s1 = intern(「hello world」)
s2 = intern(「hello world」)
print (s1 is s2) # True

3. タプル/浮動小数点数のキャッシュ規則

  • 空のタプル () グローバルで一意のシングルトン;空でないタプルにはグローバルキャッシュがなく、メモリプールの再利用にのみ依存する;
  • float にはグローバルな定数キャッシュがなく、作成するたびに新しいメモリが割り当てられる。

4. 空のコンテナのフリーリストによるキャッシュ

破棄された空のリストや空の辞書はメモリを解放せず、対応する型のフリーリストに格納される。新しい空のコンテナを作成する際は、そのリストのメモリを直接再利用する。


六、メモリビュー & 浅コピー・深コピー

浅コピー copy.copy()

コンテナの外側のシェルのみをコピーし、内部要素は元の参照を共有するため、新しいメモリは割り当てられない。

深コピー copy.deepcopy()

ネストされたすべての内部オブジェクトを再帰的にコピーし、独立したメモリを完全に割り当て、コピーされたオブジェクトと元のデータは完全に分離されます。

memoryview メモリビュー

下位のバイナリバイトはコピーせず、読み取り専用/読み書き可能なアクセスビューのみを作成します。超大規模な配列やバイナリストリームを処理する際にゼロコピーを実現し、メモリを節約します。


7. よくあるメモリリークのシナリオ(完全版)

1. 循環参照(最も基本的なリーク)

複数のコンテナ/カスタムクラスが互いに参照を保持しており、参照カウントが解放されない。GCを無効にすると、オブジェクトはメモリに永続的に残留する。

Python

実行

# リストの循環参照
a = []
b = []
a.append(b)
b.append(a)
del a, b

# カスタムクラスの双方向参照
class Node:
    pass
n1 = Node()
n2 = Node()
n1.next = n2
n2.prev = n1
del n1, n2

2. グローバル/静的コンテナへの無限のデータ追加

モジュールの最上位にあるグローバルリスト、辞書、クラスの静的属性のライフサイクルはプロセスと同一であり、オブジェクトが継続的に追加されてもクリーンアップロジックがないため、メモリ使用量は増加し続けます。

python

実行

# グローバルキャッシュ。プロセスの実行中、自動的にクリアされることはない
global_data = []

def add_record(item):
    global global_data
    global_data.append(item) # 無限に蓄積され、削除メカニズムがない

3. クロージャによる大規模オブジェクトへの長期参照

内側のクロージャ関数は外側のローカル変数をキャプチャします。外側の関数の実行が終了した後も、大規模オブジェクトはクロージャによって引き続き参照され、解放されません。

python

実行

def outer():
    big_arr = list(range(1000000)) # 超大規模な配列
    def inner():
        print(len(big_arr)) # クロージャが大きな配列をキャプチャ
    return inner

handler = outer()
# outer 関数の実行は終了したが、big_arr は handler によって永続的に保持される

4. LRUキャッシュには最大容量の制限がない

functools.lru_cachemaxsizeを設定しないと、呼び出し結果が無限にキャッシュされ、長期実行によりメモリが継続的に膨張する。

python

実行

from functools import lru_cache
# 容量の上限がなく、キャッシュは増加するのみで自動削除されない
@lru_cache()
def heavy_query(id):
    return db.query(id)

5. カスタムクラスで __del__ デストラクタをオーバーライド

循環参照が存在し、かつクラスが__del__を定義している場合、GCはオブジェクトの破棄実行順序を判断できず、回収を直接放棄します。その結果、オブジェクトはgc.garbageに格納され、恒久的なメモリリークとなります。

python

実行

class Demo:
    def __del__(self):
        print(「オブジェクトを破棄」)

a = Demo()
b = Demo()
a.ref = b
b.ref = a
del a, b
# GCは2つのオブジェクトを破棄できず、恒久的なメモリリークが発生する

6. C拡張ライブラリの裸メモリが手動で解放されていない

PythonのGCはPyObjectオブジェクトのみを管理しており、C層でmallocを通じて割り当てられたメモリは管理対象外であるため、手動での解放を忘れるとリークが発生する:

  • OpenCV/Pillow の画像バッファ、ファイルハンドル、データベースカーソル;
  • 独自開発の C 拡張ライブラリによる未解放メモリの割り当て。

python

実行

import cv2
img = cv2.imread(「big_img.jpg」)
# cv2.release() / cv2.destroyAllWindows() を呼び出していないため、C層の画像メモリはPythonによって回収されない

7. バックグラウンドスレッド / タイマー / 非同期タスクによるオブジェクトの保持

子スレッド、タイマータスク、asyncio コルーチンはビジネスオブジェクトを継続的にバインドしており、メインスレッドが変数を削除した後も、バックグラウンドタスクは有効な参照を保持し続ける。

python

実行

import threading

huge_data = list(range(1000000))

def loop_task(obj):
    while True:
        pass

# バックグラウンドスレッドが huge_data を永続的に保持
t = threading.Thread(target=loop_task, args=(huge_data,))
t.start()
del huge_data # 解放できず、スレッドが参照を保持し続ける

8. 強い参照を持つキャッシュと未使用の弱い参照

通常の dict/list は強い参照であり、外部からの参照がなくなってもキャッシュされたオブジェクトは自動的にクリーンアップされません。未使用の weakref.WeakDict/WeakList を使用すると、キャッシュが蓄積される原因となります。


補足:メモリリークの一般的な解決策

  1. 循環参照:双方向のネスト構造を避ける;GCをむやみに終了させない;定期的に gc.collect() を実行する;
  2. グローバルキャッシュ:キャッシュ容量の上限を設定し、Weak 弱参照コンテナを使用し、期限切れのデータを定期的にクリーンアップする;
  3. クロージャのリーク:クロージャ内の大きなオブジェクトを手動で空にし、weakref 弱参照を使用して格納する;
  4. __del__ の問題:コンテキストマネージャー __enter__/__exit__ を優先して使用し、デストラクタのオーバーライドを避ける;
  5. C言語のリソース:with文を統一して使用し、リソースを自動的に解放するとともに、ライブラリが提供するreleaseインターフェースを能動的に呼び出す;
  6. スレッド/コルーチン:タスクのライフサイクル終了時に能動的にキャンセルし、オブジェクトへの参照をnullに設定する。