コネヒト開発者ブログ

コネヒト開発者ブログ

明示的メモリ管理が引き起こす問題とガベージコレクションの解法

こんにちは!バックエンドエンジニアのjunyaUです。

社会人になって2回目の冬が来ましたが、どんなに寒くても暖房だけはつけない派です⛄️

今回は明示的メモリ管理が引き起こす問題と、ガベージコレクションがどうやってこの問題に対処するのかについて書いていこうと思います〜!

はじめに

動機

元々、明示的メモリ管理を強いられるC言語を触っていた経験が少しありました。

メモリリークなどの、明示的メモリ管理による問題にちょくちょく遭遇していたので、 なぜ起こるのかはなんとなく把握しているつもりでしたが、明確に言語化することはできませんでした。

しかし、最近読んだ「ガベージコレクション: 自動的メモリ管理を構成する理論と実装」という本の中で明示的メモリ管理に対する言及がされている部分がありました。 この本を読んだ上でこの問題について考えてみると言語化できそうだなと思ったので、得た知識と自分の見解をまとめてみようと思いこの記事を書くことにしました。

結論

明示的メモリ管理が引き起こす、メモリリーク、二重解放、ダングリングポインタなどの問題は、メモリのライフサイクルに関する視点の違いから生じます。 オブジェクトが生きているかどうかの判断は、グローバルな視点で判断される必要があるのに対して、メモリの解放はローカルスコープで判断することを強いられます。 この視点のギャップが問題を引き起こします。

一方で、ガベージコレクションは明示的メモリ管理とは異なり、グローバルな視点からオブジェクトの生存状態を判断して自動的に解放を行うため、これらの問題を解決します。

次節から、両者の特徴や問題点に触れながら、ガベージコレクションがどのように問題を解決するのかを考えていこうと思います。

そもそもメモリ管理って?

プロセスのメモリ領域

プロセスのメモリ領域の簡略図
メモリ管理を理解するには、まずプロセスのメモリ領域についての基本を知っておく必要があります。

プロセスのメモリ領域は、大きく3つのセグメントに分かれます。

テキストセグメント

テキストセグメントは、プログラムの機械語命令が格納される領域です。

この領域は読み取り専用で、プログラムの実行コードが含まれています。

データセグメント

データセグメントは、プログラムのデータを格納するための領域で、主に三つの領域に分けられます。

  • データ領域 : 初期化されたグローバル変数や静的変数が格納されます。
  • BSS領域 : 初期化されていないグローバル変数や静的変数が格納されます。
  • ヒープ領域 : プログラムの実行時に、動的に確保されるメモリが配置されます。サイズは実行時に動的に変化します。

スタックセグメント

スタックセグメントは、関数のローカル変数や引数などが格納される領域です。

この領域は、LIFOの原則に基づいてデータの格納と開放が行われます。

メモリ管理とは

メモリ管理は、プログラムにおけるヒープ領域のメモリ割り当てと解放を適切に行うプロセスを指します。このプロセスは、メモリリソースを効率的に使用し、プログラムのパフォーマンスと安定性を維持するために不可欠です。

ヒープ領域の役割

ヒープ領域は、プログラム実行時に動的にメモリを確保するために使用されるメモリ領域です。

例えば、事前に要素数のわからない配列やクラスのインスタンスは静的にサイズを決定することができないため、ヒープ領域にメモリが割り当てられます。

一方、静的にサイズが決まる場合は、ヒープ以外の領域に割り当てられます。

メモリの枯渇問題

ヒープ領域のメモリは、スタック領域とは異なり、自動的に解放されません。

メモリは有限なので、不要になったヒープ領域のメモリを解放せずに割り当てを行なっていると、メモリの枯渇が発生して、新たなメモリ割り当てが不可能になる可能性があります。

これはパフォーマンスの低下や、クラッシュを引き起こす原因になります。

これを防ぐためには、不要になったメモリを適切に解放する必要があります。メモリ管理は、このようなメモリのライフサイクルを適切に管理し、システムリソースを効率的に利用するために重要な役割を果たします。

明示的メモリ管理とは

概要

明示的メモリ管理とは、プログラマが手動でメモリの割り当てと解放を管理するプロセスのことです。この方法では、プログラマはメモリを必要とする際に明示的に割り当てを行い、不要になったメモリを手動で解放します。

C言語では、malloc()を使用してヒープ領域から指定されたサイズのメモリを割り当てます。使い終わったメモリはfree()を使って解放します。

以下にその一例を示します。

int main() {
    int *array = malloc(sizeof(int) * 10);
        if (array == NULL) {
        printf("malloc failed\n");
        return 1;
    }

    // array を操作

    free(array);

    return 0;
}

このように、プログラマが明示的にfree()を使ってメモリを解放する必要があるのが、明示的メモリ管理の特徴です。freeを呼ぶのを忘れたり、不適切な場所で呼んでしまうと様々な問題が発生してしまいます。

このプロセスにおいて、free()を適切に呼び出す責任はプログラマにあります。もし free() の呼び出しを忘れるか、不適切なタイミングで呼び出すと、様々な問題が発生してしまいます。

明示的解放が引き起こす問題

メモリリーク

メモリリークは、割り当てられたメモリが適切に解放されない場合に発生します。

これにより、使用されなくなったメモリがプログラムの実行中に解放されずに残り、徐々にメモリ使用量が増加していきます。長時間実行されるようなプログラムでは特にこれが問題となり、最終的に新たなメモリ割り当てが不可能になることもあります。

ダングリングポインタ

ダングリングポインタは、すでに解放されたメモリ領域を指し続けるポインタのことを指します。

以下の例では、メモリが解放後にそのメモリ領域へのアクセスを試みる状況を示しています。

int main() {
    int *ptr = malloc(sizeof(int)); // メモリ割り当て
    *ptr = 10;
    free(ptr); // メモリ解放

    // 解放後のメモリへのアクセス(不正な操作)
    *ptr = 42;

    return 0;
}

解放されたメモリへのアクセスは、予期しない動作やデータの破壊を引き起こす可能性があります。

二重解放

二重解放は、同じメモリ領域が複数回解放されることを指します。

これは、プログラムの異なる部分で既に解放されたメモリを再度解放しようとするときに発生します。二重解放はメモリの破壊やプログラムの予期しない動作を引き起こす可能性があり、安定性やセキュリティの問題を生じさせることがあります。

明示的メモリ管理では、プログラマがメモリのライフサイクルを正確に追跡し、適切なタイミングでのみ解放を行う責任を負います。不要なメモリを即座に解放できるという利点がありますが、この手法は誤った使用によりメモリリーク、ダングリングポインタ、二重解放などの様々な問題を引き起こすリスクを伴います。

自動的メモリ管理

概要

自動的メモリ管理とは、プログラマが明示的にメモリ解放を行わなくても、専用のプロセスが自動的に不要と判断したメモリを解放するシステムを指します。

この方法は、JavaやPython、Goなどの高級言語で採用されています。

例えば、弊社が推進しているGoでヒープメモリを割り当てる場合は次のようにします。

package main

import "fmt"

type MyStruct struct {
    Field int
}

func createStruct() *MyStruct {
    // new を使って MyStruct の新しいインスタンスを作成
    ms := new(MyStruct)
    ms.Field = 10
    return ms // 関数の外部に返すことでヒープ割り当てが発生する
}

func main() {
    ms := createStruct()
    fmt.Println(ms)
}

この例では、createStruct() 内で MyStruct のインスタンスをヒープに割り当てていますが、プログラマはそのメモリを手動で解放する必要はありません。

代わりに、専用のプロセスが自動的に使われないと判断したメモリを解放してくれます。この専用のプロセスのことをガベージコレクションと呼びます。

ガベージコレクション

ガベージコレクション(GC)は、プログラムにおいて不要になったメモリを自動的に特定し、解放するシステムです。これにより、プログラマはメモリの手動解放をする必要がなくなり、メモリ管理の負担が大幅に軽減されます。

GCのプロセスでは、アプリケーションコードを実行する部分を「ミューテータ」と呼びます。ミューテータは、プログラムの実行中にメモリを割り当て、使用します。

一方で、GC自体のコードを実行する部分、つまり不要になったメモリを特定し解放する部分は「コレクタ」と呼ばれます。

メモリの割り当てられたオブジェクトは、プログラムにおいて使用される間は「生きている」と見なされます。コレクタは、プログラムの実行中に「生きていない」と判断されたオブジェクト、すなわち「ゴミ」と見なされるオブジェクトを特定し、メモリから解放します。

では、コレクタはどのようにしてゴミを判断するのでしょうか?

ゴミの判別メカニズム

GCは、ポインタの到達可能性に基づいてメモリがゴミかどうかを判断します。

到達可能性とは、グローバル変数や、アクティブなスタックフレームなどの、有限ルート集合からポインタを辿って、直接または間接的にオブジェクトにアクセスできるかという性質のことです。

ポインタ到達可能性の簡略図
上図において、ルート集合からアクセス可能なオブジェクト(例えばObjectA)は生きているとみなせます。

ObjectBObjectCはルート集合から直接アクセスできないものの、ObjectAを介して間接的にアクセス可能なため、これらは生きているとみなされます。一方で、ObjectDはどの生きているオブジェクトからも参照されていないため、ゴミと判断され回収の対象となります。

ガベージコレクションの種類

GCには、主に以下の4つの基本的なタイプがあります。

  • マークスイープGC
  • コピーGC
  • マークコンパクトGC
  • 参照カウントGC

その他のGCアルゴリズムは、通常これらのGCのどれかを組み合わせたGCとなります。

先程せっかくGoに触れたので、ここではGoで使われているマークスイープGCについて軽く紹介します。マークスイープGCは「マークフェーズ」と「スイープフェーズ」という2つのフェーズに分かれています。

マークフェーズ

マークフェーズの簡略図
マークフェーズでは、ルート集合から到達可能なオブジェクトにマークをつけます。

マークされたオブジェクトは、「生きている」と見なされます。

上図ではオブジェクトの中にマークをつけていますが、外部に管理表(ビットマップ)を持たせてそこでマークを管理する方法もあります。

スイープフェーズ

スイープフェーズでは、マークのついていないオブジェクト。つまり「ゴミ」とみなされるオブジェクトのメモリを解放します。全ての解放が終了すると、生きているオブジェクトのマークはクリアされ、新しいサイクルが始まります。

このようにマークスイープGCでは、生きているオブジェクトを特定し、それ以外を解放するため、これを「間接的GC」と呼びます。今回の主題ではないのでかなり省いた説明をしていますが、機会があれば別の記事として書こうかなと思います。

ガベージコレクションの解法

明示的解放の問題が発生する要因

前置きが長くなりましたが、ここからが本題になります。

まずは、明示的メモリ解放が直面する主要な問題であった、メモリリーク、ダングリングポインタ、そして二重解放がなぜ起こるのかをみていきます。

これらの原因を理解するためには、オブジェクトの活性とメモリ解放の判断における視点の違いを考慮する必要があります。

オブジェクトの活性を判断する視点

オブジェクトの活性(生きているかどうか、もう不要かどうか)を判断する際には、プログラム全体のグローバルのコンテキストを考慮する必要があります。

ヒープに割り当てられたオブジェクトは、スタック領域に格納されるローカル変数とは異なり、宣言されたスコープ外でも生存し続け、明示的に解放されるまでメモリを占有します。

このため、特定のスコープでそのオブジェクトが使用されていなくても、どこか一つのスコープでも使われていれば、そのオブジェクトは生きていると判断されます。

オブジェクトの解放を判断する視点

明示的な解放の際には、プログラマはその時点でのローカルスコープに基づいてメモリ解放を判断しなければなりません。

free()などの関数は特定のスコープ内で呼び出されるため、そのスコープの情報のみが判断基準となります。このローカルな視点に基づいた解放判断は、プログラム全体のコンテキストを考慮せずに行われるため、オブジェクトの活性判断のグローバルな視点との間にギャップが生じます。

この視点のギャップが、メモリリークやダングリングポインタ、二重解放といった問題の原因となります。

この問題を示したコードの簡単な例を以下に示します。

void local_scope(int *ptr) {
    // 他のスコープでまだ使われることを知らずに ptr を解放する。
    free(ptr);
}

int main() {
    // ヒープ上に整数のためのメモリを割り当てる。
    int *ptr = (int *)malloc(sizeof(int));
    *ptr = 10;

    local_scope(ptr);

    // ローカルスコープ内で解放されたことを知らずに ptr を使用する。
    printf("pointer value: %d\n", *ptr); // 未定義の振る舞いになる。

    return 0;
}

この例では、local_scope()が関数内でメモリを解放してしまうため、プログラムの他の部分でそのポインタが参照されると問題が発生します。

このコードは小さいので判断できますが、プログラムの規模が大きくなると、ファイルやモジュールが分割されることによって、このギャップの問題はもっと顕著になることが予測できます。

なぜGCではこれらの問題が起きないのか

GCが明示的メモリ管理の問題に効果的である理由は、単に自動的にメモリを解放することだけでではありません。プログラム全体を俯瞰するグローバルな視点に基づいてオブジェクトの解放を判断することにあります。

GCはプログラムの実行状態を全体的に分析し、到達可能性のアルゴリズムに基づいて、活性のあるオブジェクトを維持しつつ、不要になったオブジェクトを特定して解放します。

これによって、すべてのオブジェクトに対して、プログラムのどの部分からもアクセスできないというグローバルな条件を満たした時点でのみ、解放が行われます。

このように、GCはプログラマがローカルスコープの情報に基づいて行う判断を、プログラム全体の状態を基に行います。これにより、メモリリーク、ダングリングポインタ、二重解放といった問題を根本的に解消することが可能になります。また、プログラムが大規模化し、複数のモジュールやファイルに分割されても、GCの効果は維持されます。

ソフトウェア工学的観点の影響

他にもメモリ管理の違いは、ソフトウェア工学的観点の影響を与えます。

ソフトウェア工学において、モジュール間のコミュニケーションは最小限に抑えるということは重要な原則の一つです。この原則は、モジュール間の結合度を低く保ち、他のモジュールに依存を減らし、ソフトウェアの変更や拡張を容易にします。

しかし、明示的メモリ管理はこの原則に反し、「暗黙的なインターフェースの複雑化」という問題を引き起こします。

以下の例を元に考えてみます。

// メモリの所有権について暗黙的に知っておく必要がある関数
void process_and_free(int *data) {
    printf("データ処理中: %d\n", *data);

    free(data);
}

// メモリを割り当てて、呼び出し元が解放することを期待する関数
int* create_data() {
    int *data = (int*)malloc(sizeof(int));
    *data = 42; // 何かの値を設定する。

    return data;
}

int main() {
    // インターフェースは、所有権が呼び出し側に渡ることが暗黙的です。
    int *new_data = create_data();

    // インターフェースは、誰がメモリを解放するべきかについて暗黙的であり、
    // ドキュメントや規約の必要性を生み出します。
    process_and_free(new_data);

    return 0;
}

この例では、create_data()がメモリを割り当ててポインタを返しますが、 この関数を使用する際、呼出し側はこのポインタのメモリを解放する必要があります。

すなわち、メモリの所有権が関数の呼出し元に暗黙的に移譲されるわけですが、このルールは関数のシグネチャから明確に読み取ることができず、メモリリークのリスクを増加させます。

また、process_and_free()では、内部でメモリ解放しますが、その事実もシグネチャからは読み取れません。そのため呼び出し側はその関数の内部を知らなければ、解放済みのメモリのポインタを再度使用してしまう可能性があります。

これらの問題に対処するためには、ドキュメントやコメントなどで補足する必要がありますが、これはインターフェースを不必要に複雑化させ、エラーのリスクや認知負荷が増加させます。加えて、暗黙のルールを理解しなければコードが理解できないとなると、モジュールの再利用性は制限されてしまいます。

一方、自動メモリ管理(GC)はこのような問題を回避できます。

これは、GCがメモリ管理の責任をプログラマから引き受けることによって、インターフェースからメモリ管理の複雑さを取り除くからです。

明示的メモリ管理の場合、メモリを管理するためのコードがあちこちに散りばめられますが、GCの場合は、メモリ管理をするためのコードを書く必要がなくなり、再利用性も改善され保守性が高くなるメリットがあります。

GCの制約

ここまでGCを使うことによる利点を述べてきましたが、万能薬というわけではなく、いくつかの制約を考慮する必要があります。

パフォーマンスオーバーヘッド

GCのプロセスがパフォーマンスに影響を与えることがあります。

特にFull GCと呼ばれるタイプのGCでは、GC実行中に全てのアプリケーションスレッドが停止してしまうことがあり、これは「Stop The World」と呼ばれる現象になります。

他の種類のGCでは「Stop The World」は発生しなくても、パフォーマンス低下を引き起こすことがあります。

メモリ使用量の増加

GCは不要なオブジェクトを即座に解放しないので、メモリ使用量が増えることがあります。特にメモリリソースが限られた環境だとこれが問題になります。

実行タイミングが予想できない

GCがいつ実行されるかは予測できないため、特定のタイミングでのメモリ解放を保証することができません。これは、リソースが限られた環境や、厳格なリソース管理が必要なアプリケーションでは問題になる可能性があります。

生きているオブジェクトが使われるとは限らない

GCは、オブジェクトの生存状態を判断する際に「到達可能性」を基準にしますが、これはオブジェクトが実際に使われているかどうかを必ずしも意味しません。つまり、プログラム内で参照されているが、実際にはもう使用されていない「生きているが使われていない」オブジェクトが存在する可能性があります。

このようなオブジェクトは、GCによって回収されないため、メモリを不必要に占有し続けることになります。

他にもまだ考慮すべき制約はたくさんありますが、これらの制約はGCの利点とのトレードオフになります。GCを使用する際はこれらの制約を理解しておくことが重要になります。

まとめ

明示的メモリ管理における問題の多くは、オブジェクトの活性はグローバルなスコープで判断されるのにもかかわらず、メモリ解放はローカルスコープで判断しなければならないという判断の視点のギャップにありました。

GCは、メモリ解放の決定をグローバルスコープで行い、自動で解放することで明示的メモリの問題の解決を図ります。 それに加えて、モジュールの再利用性も向上し、メンテナンスのしやすいプログラムも書きやすくなるのでした。

しかし、GCは完璧な解決策ではなく、さまざまな制約が存在します。 これらの制約の理解し、コードがどのメモリ領域に格納されるのかを考慮しながらコードを書くことが大事なんだな〜と思いました。

メモリ管理って面白い!( ^∀^)

参考資料

書籍

  • Richard Jones, ガベージコレクション 自動的メモリ管理を構成する理論と実装, 翔泳社, 2016
  • Noam Nisan, コンピューターシステムの理論と実装 —モダンなコンピュータの作り方, オライリージャパン, 2015

サイト