いまさら理解する「メモリとは?」- Webアプリケーションエンジニアのための低レイヤー入門シリーズ その2

先日、とあるサービスで OOMKilled が発生しまして、メモリリミットに引っかかったんですが、「そもそもなんでこんなにメモリ使ってるんだ?」を調査するのに、メモリの基礎知識が改めて大事だなと思いました。

ということで、「低レイヤー入門シリーズ」の第二弾はメモリについて深掘りしていきます!

前回の「スタティックリンクとダイナミックリンク」では、プログラムがどのように実行ファイルになるかを学びました。今回は、その実行ファイルが動くときに欠かせないメモリの仕組みを、C言語を使って体感していきましょう。


なぜWebエンジニアがメモリを理解すべきか?

普段、Ruby on RailsやNode.js、Goなどで開発していると、メモリを意識することは少ないかもしれません。しかし、こんな場面に遭遇したことはありませんか?

  • 「メモリリーク」でアプリケーションが落ちた
  • 「OutOfMemoryError」に悩まされた
  • Dockerのメモリ制限で苦労した
  • パフォーマンスチューニングで「ヒープ」「スタック」という言葉が出てきた

これらを理解するためには、メモリの基礎知識が必要です。C言語は、メモリを直接操作できる言語なので、メモリの仕組みを学ぶのに最適なんです。


メモリとは何か?

概念図で理解する

まず、メモリの全体像を把握しましょう。

┌─────────────────────────────────────────────────────────────────┐
│                        物理メモリ(RAM)                          │
│                                                                 │
│  メモリは「番地(アドレス)がついた巨大なロッカー」のようなもの      │
│                                                                 │
│  ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐      │
│  │ 0x00 │ 0x01 │ 0x02 │ 0x03 │ 0x04 │ 0x05 │ 0x06 │ 0x07 │ ...  │
│  │      │      │      │      │      │      │      │      │      │
│  │ 1byte│ 1byte│ 1byte│ 1byte│ 1byte│ 1byte│ 1byte│ 1byte│      │
│  └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘      │
│     ↑                                                           │
│  アドレス(16進数で表される)                                     │
└─────────────────────────────────────────────────────────────────┘

ポイントは3つです。

  1. メモリには「アドレス」という番地がある
  2. 1つのアドレスには(基本は)1バイト(8ビット)のデータが格納できる
  3. アドレスは通常、16進数(0x...)で表現される

駅のコインロッカーを想像してみてください。ロッカー番号がアドレスで、中に入れる荷物がデータです。ただし、1つのロッカーに入る荷物は1バイト分だけ。大きなデータは、複数のロッカーにまたがって入れる必要があります。

プログラムが使うメモリ領域

プログラムが実行されると、OSはそのプログラム専用のメモリ空間を割り当てます。このメモリ空間は、いくつかの領域に分かれています。

┌─────────────────────────────────────────┐  高いアドレス
│              スタック領域                │  ← 関数のローカル変数など
│                  ↓                      │    (自動で確保・解放)
│                  ↓                      │
├─────────────────────────────────────────┤
│                                         │
│              空き領域                    │
│                                         │
├─────────────────────────────────────────┤
│                  ↑                      │
│                  ↑                      │
│              ヒープ領域                  │  ← 動的に確保するメモリ
│                                         │    (mallocなど)
├─────────────────────────────────────────┤
│              BSS領域                     │  ← 初期化されていない
│          (未初期化データ)                 │    グローバル変数
├─────────────────────────────────────────┤
│              データ領域                  │  ← 初期化された
│           (初期化済みデータ)              │    グローバル変数
├─────────────────────────────────────────┤
│              テキスト領域                │  ← プログラムのコード
│            (コード領域)                  │    (機械語)
└─────────────────────────────────────────┘  低いアドレス

今回は特にスタック領域ヒープ領域に注目します。この2つの違いがわかると、「なぜメモリリークが起きるのか」がスッキリ理解できるようになります。


環境準備

前回と同じく、実際にコードを動かしていきましょう。GCCが必要です。GCCが入っていない方は、ご自身の環境に合わせてググってインストールしてください!

# バージョン確認
gcc --version

変数とメモリアドレス

変数はメモリ上のどこにある?

まず、変数がメモリ上のどこに配置されるかを確認してみましょう。

ファイル名: address.c

#include <stdio.h>

int main() {
    int number = 42;
    char letter = 'A';
    double pi = 3.14159;
    
    printf("=== 変数の値とアドレス ===\n\n");
    
    printf("int型変数 number\n");
    printf("  値: %d\n", number);
    printf("  アドレス: %p\n", (void*)&number);
    printf("  サイズ: %zu バイト\n\n", sizeof(number));
    
    printf("char型変数 letter\n");
    printf("  値: %c\n", letter);
    printf("  アドレス: %p\n", (void*)&letter);
    printf("  サイズ: %zu バイト\n\n", sizeof(letter));
    
    printf("double型変数 pi\n");
    printf("  値: %f\n", pi);
    printf("  アドレス: %p\n", (void*)&pi);
    printf("  サイズ: %zu バイト\n", sizeof(pi));
    
    return 0;
}

コンパイルと実行

gcc -o address address.c
./address

実行結果の例

=== 変数の値とアドレス ===

int型変数 number
  値: 42
  アドレス: 0x7ffd5c8b3a4c
  サイズ: 4 バイト

char型変数 letter
  値: A
  アドレス: 0x7ffd5c8b3a4b
  サイズ: 1 バイト

double型変数 pi
  値: 3.141590
  アドレス: 0x7ffd5c8b3a40
  サイズ: 8 バイト

ここで注目すべきなのは、型によってサイズが違うこと。intは4バイト、charは1バイト、doubleは8バイト。そしてアドレスを見ると、変数ごとにちゃんと別々の場所が割り当てられているのがわかります。

解説

  • &演算子:変数の前に&をつけると、その変数のメモリアドレスが取得できます
  • %p:アドレスを表示するためのフォーマット指定子です
  • sizeof:変数や型のサイズ(バイト数)を返します
メモリのイメージ(上記の例の場合)

アドレス        値              変数名
0x7ffd5c8b3a40  [3.14159....]   pi (8バイト)
    :
0x7ffd5c8b3a4b  ['A']           letter (1バイト)
0x7ffd5c8b3a4c  [42......]      number (4バイト)
    ↓
スタック領域(高いアドレスから低いアドレスへ伸びる)
💡 Webエンジニア向けメモ
JavaScriptではtypeofで型を調べますが、メモリサイズは完全に隠蔽されています。 C言語では、型によってメモリの使用量が明確に決まります。 これが「型を意識する」ということの本質です。 Goのunsafe.Sizeof()やRustのstd::mem::size_of()でも同じようにサイズを確認できます。

ポインタの超基礎

ポインタとは?

C言語といえばポインタ!…そして、挫折ポイントでもありますよね。でも恐れることはありません。一言でいうと、

ポインタ = メモリアドレスを格納する変数

それだけです。「値そのもの」ではなく「値がある場所(住所)」を覚えておく変数、というだけ。

┌────────────────────────────────────────────────────────────┐
│                                                            │
│   通常の変数                      ポインタ変数              │
│   ┌─────────┐                   ┌─────────────┐            │
│   │   42    │                   │ 0x7ffd...4c │            │
│   └─────────┘                   └──────┬──────┘            │
│   number                               │                   │
│   (int型)                              │ 「ここを見て!」   │
│                                        ↓                   │
│                                   ┌─────────┐              │
│                                   │   42    │              │
│                                   └─────────┘              │
│                                   number                   │
│                                                            │
└────────────────────────────────────────────────────────────┘

ポインタを使ってみよう

ファイル名: pointer_basic.c

#include <stdio.h>

int main() {
    int number = 42;
    int *ptr = &number;  // numberのアドレスをptrに格納
    
    printf("=== ポインタの基本 ===\n\n");
    
    // 元の変数
    printf("number の値: %d\n", number);
    printf("number のアドレス: %p\n\n", (void*)&number);
    
    // ポインタ変数
    printf("ptr の値(= numberのアドレス): %p\n", (void*)ptr);
    printf("ptr が指す先の値(*ptr): %d\n", *ptr);
    printf("ptr 自身のアドレス: %p\n\n", (void*)&ptr);
    
    // ポインタを使って値を変更
    printf("--- ポインタ経由で値を変更 ---\n");
    *ptr = 100;  // ポインタを通じてnumberの値を変更
    printf("*ptr = 100 を実行後:\n");
    printf("number の値: %d\n", number);
    printf("*ptr の値: %d\n", *ptr);
    
    return 0;
}

実行結果

gcc -o pointer_basic pointer_basic.c
./pointer_basic
=== ポインタの基本 ===

number の値: 42
number のアドレス: 0x7ffd5c8b3a4c

ptr の値(= numberのアドレス): 0x7ffd5c8b3a4c
ptr が指す先の値(*ptr): 42
ptr 自身のアドレス: 0x7ffd5c8b3a40

--- ポインタ経由で値を変更 ---
*ptr = 100 を実行後:
number の値: 100
*ptr の値: 100

*ptr = 100 しただけなのに、numberの値も100に変わっています。同じメモリを指しているから当然なんですが、初めて見るとちょっと不思議ですよね。

ポインタの記号まとめ

* が宣言時と使用時で意味が変わるのがハマりポイントです。

記号意味使用例
* (宣言時)「これはポインタ変数です」int *ptr;
* (使用時)デリファレンス(参照先の値を取得)*ptr = 100;
&アドレスを取得&number
宣言と使用の違い

int *ptr = &number;   ← 宣言時の * は「ポインタ型」を示す
     ↓
*ptr = 100;           ← 使用時の * は「参照先にアクセス」を示す
💡 Webエンジニア向けメモ
「ポインタ」って難しそうに聞こえますが、実はJavaScriptやRubyでも似た概念はあります。 オブジェクトを変数に代入すると、値のコピーではなく「参照」が渡される、あれです。 C言語のポインタは、その参照を明示的に操作できるというだけの話なんです。

メモリの動的確保(malloc / free)

スタックとヒープ

ここまで見てきた変数は、すべてスタック領域に確保されていました。スタック領域の変数は、関数が終了すると自動的に解放されます。

一方、ヒープ領域は、プログラマが明示的に確保・解放するメモリです。

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   スタック領域                    ヒープ領域                 │
│   ─────────────                  ─────────────              │
│                                                             │
│   ✅ 自動で確保・解放              ⚠️  手動で確保・解放       │
│   ✅ 高速                         ⚠️  やや低速               │
│   ❌ サイズはコンパイル時に決定     ✅ サイズを実行時に決定可能 │
│   ❌ 関数内でのみ有効              ✅ 関数をまたいで使える     │
│                                                             │
│   用途: ローカル変数               用途: 可変長データ、        │
│        関数の引数                      長期間保持するデータ   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

RubyやJavaScriptでは new するだけでヒープにオブジェクトが作られますが、C言語では自分で「これだけのメモリをください!」とOSにお願いする必要があります。それが malloc() です。

malloc と free

ファイル名: malloc_basic.c

#include <stdio.h>
#include <stdlib.h>  // malloc, free に必要

int main() {
    printf("=== 動的メモリ確保 ===\n\n");
    
    // 整数1つ分のメモリを確保
    // ※C言語ではmallocの戻り値(void*)は暗黙的にキャストされるため、
    //   キャストは省略可能です。ここでは意図を明示するために記載しています。
    //  (C++ではキャストが必須です)
    int *ptr = (int *)malloc(sizeof(int));
    
    // 確保できたか確認(重要!)
    if (ptr == NULL) {
        printf("メモリの確保に失敗しました\n");
        return 1;
    }
    
    printf("メモリを確保しました\n");
    printf("確保したアドレス: %p\n", (void*)ptr);
    printf("確保したサイズ: %zu バイト\n\n", sizeof(int));
    
    // 確保したメモリに値を格納
    *ptr = 12345;
    printf("格納した値: %d\n\n", *ptr);
    
    // メモリを解放
    free(ptr);
    printf("メモリを解放しました\n");
    
    // 解放後のポインタはNULLにするのが安全
    ptr = NULL;
    
    return 0;
}

実行結果

gcc -o malloc_basic malloc_basic.c
./malloc_basic
=== 動的メモリ確保 ===

メモリを確保しました
確保したアドレス: 0x55a8b6c4a2a0
確保したサイズ: 4 バイト

格納した値: 12345

メモリを解放しました

malloc → 使う → free の3ステップ。これがC言語のメモリ管理の基本です。free を忘れると大変なことになりますが、それは後ほど

配列を動的に確保する

スタック上の配列は int array[5]; のようにサイズをコンパイル時に決める必要がありますが、malloc を使えば実行時にサイズを決められます

ファイル名: malloc_array.c

#include <stdio.h>
#include <stdlib.h>

int main() {
    int n;
    printf("配列の要素数を入力してください: ");
    scanf("%d", &n);
    
    // n個のint配列を動的に確保
    int *array = (int *)malloc(sizeof(int) * n);
    
    if (array == NULL) {
        printf("メモリの確保に失敗しました\n");
        return 1;
    }
    
    printf("\n%d個のint型配列を確保しました\n", n);
    printf("確保したサイズ: %zu バイト\n", sizeof(int) * n);
    printf("先頭アドレス: %p\n\n", (void*)array);
    
    // 配列に値を格納
    for (int i = 0; i < n; i++) {
        array[i] = i * 10;
    }
    
    // 配列の内容とアドレスを表示
    printf("インデックス | 値   | アドレス\n");
    printf("-------------|------|------------------\n");
    for (int i = 0; i < n; i++) {
        printf("    %d        | %3d  | %p\n", i, array[i], (void*)&array[i]);
    }
    
    // メモリを解放
    free(array);
    array = NULL;
    
    printf("\nメモリを解放しました\n");
    
    return 0;
}

同様に実行すると?

実行結果

配列の要素数を入力してください: 5

5個のint型配列を確保しました
確保したサイズ: 20 バイト
先頭アドレス: 0x55a8b6c4a2a0

インデックス | 値   | アドレス
-------------|------|------------------
    0        |   0  | 0x55a8b6c4a2a0
    1        |  10  | 0x55a8b6c4a2a4
    2        |  20  | 0x55a8b6c4a2a8
    3        |  30  | 0x55a8b6c4a2ac
    4        |  40  | 0x55a8b6c4a2b0

メモリを解放しました

アドレスが4バイトずつ増えているのがわかりますか? これは int 型が4バイトだからです。メモリ上にきれいに並んでいるのが見えると、配列の正体がよくわかりますね。

💡 Webエンジニア向けメモ
GoやRustでは make([]int, n)Vec::with_capacity(n) のように、 動的にサイズを指定して配列やスライスを作ることがよくあります。 裏側では似たようなヒープへのメモリ確保が行われています。

メモリリークを体験する

メモリリークとは?

メモリリークとは、確保したメモリを解放し忘れることで、使えるメモリが徐々に減っていく現象です。Webアプリの運用でよくある「なぜかメモリ使用量が右肩上がりで、定期的に再起動しないといけない」の原因、だいたいコレです。

ファイル名: memory_leak.c

#include <stdio.h>
#include <stdlib.h>

void bad_function() {
    // メモリを確保するが、解放しない(これがメモリリーク!)
    int *ptr = (int *)malloc(sizeof(int) * 1000);
    if (ptr == NULL) return;
    *ptr = 42;
    // free(ptr); ← これがない!
}

void good_function() {
    int *ptr = (int *)malloc(sizeof(int) * 1000);
    if (ptr == NULL) return;
    *ptr = 42;
    free(ptr);  // ちゃんと解放
}

int main() {
    printf("=== メモリリークのデモ ===\n\n");
    
    printf("bad_function を1000回呼び出します...\n");
    for (int i = 0; i < 1000; i++) {
        bad_function();
    }
    printf("完了(約4,000,000バイトがリークしています)\n\n");
    
    printf("good_function を1000回呼び出します...\n");
    for (int i = 0; i < 1000; i++) {
        good_function();
    }
    printf("完了(メモリリークなし)\n");
    
    return 0;
}

bad_function は1回呼ぶたびに sizeof(int) * 1000 = 4,000バイト をリークします。1,000回呼ぶと約4,000,000バイト(約3.8MB)の漏れです。たった数行の free の書き忘れで、これだけのメモリが無駄になるんですね。

Valgrindでメモリリークを検出

C言語にはメモリリークを検出する定番ツール Valgrind があります。Linuxでは、以下で確認できます!

# Valgrindのインストール(Ubuntu)
sudo apt install valgrind

# コンパイル(デバッグ情報付き)
gcc -g -o memory_leak memory_leak.c

# Valgrindで実行
valgrind --leak-check=full ./memory_leak

検出結果

==12345== LEAK SUMMARY:
==12345==    definitely lost: 4,000,000 bytes in 1,000 blocks
==12345==    indirectly lost: 0 bytes in 0 blocks
==12345==      possibly lost: 0 bytes in 0 blocks
==12345==    still reachable: 0 bytes in 0 blocks
==12345==         suppressed: 0 bytes in 0 blocks

definitely lost: 4,000,000 bytes — バッチリ検出されました! こんなふうに、C言語ではメモリの問題を直接確認できます。

💡 Webエンジニア向けメモ
Node.jsでも同様のメモリリークは起こります。例えば、グローバル変数に配列を追加し続けたり、イベントリスナーを解除し忘れたり、クロージャで大きなオブジェクトを参照し続けたり。原理は同じで、「使わなくなったメモリが解放されない」ことが問題です。
Node.jsの場合は --inspect フラグをつけて起動し、Chrome DevToolsの「Memory」タブでHeap Snapshotを取ると、Valgrindのようにリークを調査できます。Goなら pprof が同様のツールになります。

スタックとヒープの違いを実感する

ダングリングポインタの危険

最後に、スタックとヒープの違いが生む典型的なバグを見てみましょう。

ダングリングポインタとは、すでに無効になったメモリを指すポインタのことです。「存在しない住所が書かれたメモ」みたいなものですね。

ファイル名: dangling.c

#include <stdio.h>
#include <stdlib.h>

// ダメな例:スタック上の変数のアドレスを返す
int* bad_get_number() {
    int local = 42;
    return &local;  // ⚠️ 関数終了後、localは無効になる
}

// 良い例:ヒープ上にメモリを確保して返す
int* good_get_number() {
    int *ptr = (int *)malloc(sizeof(int));
    if (ptr == NULL) return NULL;
    *ptr = 42;
    return ptr;  // ✅ ヒープ上なので関数終了後も有効
}

int main() {
    printf("=== ダングリングポインタのデモ ===\n\n");
    
    printf("【危険】スタック上の変数を返す場合:\n");
    int *bad = bad_get_number();
    printf("アドレス: %p\n", (void*)bad);
    // ⚠️ この行は未定義動作です!何が表示されるかわかりません
    // コンパイラの最適化レベルによって挙動が変わります
    printf("値(未定義動作!): %d\n\n", *bad);
    
    printf("【安全】ヒープ上のメモリを返す場合:\n");
    int *good = good_get_number();
    if (good != NULL) {
        printf("アドレス: %p\n", (void*)good);
        printf("値: %d\n", *good);
        free(good);  // 使い終わったら解放
    }
    
    return 0;
}

コンパイル時の警告

# 最適化なしでコンパイル(未定義動作の挙動を確認するため)
gcc -Wall -O0 -o dangling dangling.c
dangling.c: In function 'bad_get_number':
dangling.c:7:12: warning: function returns address of local variable [-Wreturn-local-addr]
    7 |     return &local;
      |            ^~~~~~

コンパイラが警告してくれますね! -Wall オプションをつけてコンパイルすると、こうした危険なコードを事前にキャッチできます。

この例がまさに「スタック vs ヒープ」の違いです。スタック上の変数(local)は関数を抜けた瞬間に無効になりますが、ヒープ上のメモリ(malloc で確保したもの)は free するまで有効。だからこそ、ヒープに確保して返すのが正しいパターンなんです。ただし、前述のメモリリークのように、freeを忘れないようにしないといけません!

💡 Webエンジニア向けメモ
GoやRustでは、この種のバグはコンパイラが防いでくれます。 Goでは「エスケープ解析」によって、関数外に渡される変数は自動的にヒープに配置されます。 go build -gcflags="-m" で確認できるので、興味のある方はぜひ試してみてください。 Rustではさらに厳密で、所有権とライフタイムの仕組みによって、ダングリングポインタの発生をコンパイル時に完全に防いでいます。

まとめ

今回学んだことを整理しましょう。

┌─────────────────────────────────────────────────────────────────┐
│                        今回のまとめ                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. メモリにはアドレスがある                                     │
│     → & でアドレスを取得、%p で表示                              │
│                                                                 │
│  2. ポインタ = アドレスを格納する変数                            │
│     → int *ptr = &number;                                       │
│     → *ptr でポインタが指す値にアクセス                          │
│                                                                 │
│  3. 動的メモリ確保                                               │
│     → malloc() で確保、free() で解放                             │
│     → 解放を忘れるとメモリリーク                                 │
│                                                                 │
│  4. スタック vs ヒープ                                           │
│     → スタック:自動管理、関数内で有効                           │
│     → ヒープ:手動管理、プログラム全体で有効                     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Web系っぽい言語との対応

C言語で学んだ概念は、普段使っている言語にも対応しています。

C言語Web系っぽい言語での対応
malloc()new Object() / オブジェクト生成
free()GC(ガベージコレクション)が自動で行う
ポインタ参照(Reference)
スタックプリミティブ型の値(※言語による)
ヒープオブジェクト / 配列(※言語による)

※ スタックとヒープの割り当ては言語や実装によって異なります。例えばGoではエスケープ解析によって、見た目はローカル変数でもヒープに配置されることがあります。上記はJavaでの一般的なイメージとして参考にしてください。

前回のリンクの話と今回のメモリの話で、だいぶコンピュータの「裏側」が見えてきたのではないでしょうか? この知識があると、Dockerのリソース制限とかOOMKilledとか、「あ、あれのことね」とピンとくるようになりますよ!

たぶん!

こちらからは以上です。