🫐

JavaScriptのMap/SetとWeakMap/WeakSetの違いを実際のコードで検証してみた

に公開3

はじめに

TypeScriptのブルーベリー本(プロを目指す人のためのTypeScript入門)を読んでいて、WeakMapとWeakSetについて学びました。本では以下のように説明されていました:

このような違いが発生する理由は、Mapオブジェクトが生き残っていれば列挙系のメソッドを用いてキーの値を取得できるからです。逆に言えば、列挙系のメソッドを廃することでキーのガベージコレクションを可能にしたのがWeakMapであると見ることもできます。

この説明を読んで、実際にどうなるのか気になったので、コードで検証してみました。

MapとSetの基本

まず、基本的なMapとSetの使い方を確認しましょう。

// Map: キー・値ペアのコレクション
const map = new Map();
map.set('key1', 'value1');
map.set({ id: 1 }, 'object value');

// Set: ユニークな値のコレクション
const set = new Set();
set.add('value1');
set.add({ id: 1 });

MapとSetは以下の特徴があります:

  • 任意の型をキー/値として使用可能
  • 列挙メソッド(keys(), values(), entries())が利用可能
  • 強参照でオブジェクトを保持

WeakMapとWeakSetの特徴

ブルーベリー本では、WeakMapとWeakSetの特徴を以下のように説明しています:

列挙系メソッドの廃止

MapとWeakMapの根本的な違いは、列挙系メソッドの有無です:

// Map: 列挙系メソッドが利用可能
const map = new Map();
map.set(obj, 'value');
map.keys();     // キーの一覧を取得可能
map.values();   // 値の一覧を取得可能
map.entries();  // キー・値ペアの一覧を取得可能

// WeakMap: 列挙系メソッドが存在しない
const weakMap = new WeakMap();
weakMap.set(obj, 'value');
// weakMap.keys();     // ❌ このメソッドは存在しない
// weakMap.values();   // ❌ このメソッドは存在しない
// weakMap.entries();  // ❌ このメソッドは存在しない

アクセス手段の制限

WeakMapは以下の4つのメソッドしか提供しません:

  • set(key, value)
  • get(key)
  • has(key)
  • delete(key)

これにより、キーオブジェクトへの別途アクセス手段がない場合、そのキーで保存された値にアクセスできなくなります

ガベージコレクションの正当化

本では以下のように説明されています:

WeakMapのキーとなったオブジェクトにアクセスする手段を別途で持っていない場合、WeakMapからそのキーで保存された値を取り出す手段はもはやなく、WeakMap内にそのオブジェクトをキーとする値があるかどうかはわからなくなります。これにより、WeakMap内で使われているキーをガベージコレクトすることが正当化されます。

つまり、アクセスできないキーを保持し続ける理由がないため、ガベージコレクションの対象として扱えるというわけです。

実際のコードで検証

メモリ使用量を測定する関数

function getMemoryUsage() {
    if (typeof process !== 'undefined') {
        const memUsage = process.memoryUsage();
        return {
            rss: Math.round(memUsage.rss / 1024 / 1024), // MB
            heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024), // MB
            heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024) // MB
        };
    }
    return null;
}

WeakMap vs Mapの比較実験

console.log('=== ガベージコレクション テスト ===\n');

// テスト1: WeakMapの場合
console.log('1. WeakMapテスト:');
console.log('初期メモリ:', getMemoryUsage());

// 大きなオブジェクトを作成
let largeObject = {
    data: new Array(1000000).fill('test data'), // 約8MB
    id: 'test-1'
};

let weakMap = new WeakMap();
weakMap.set(largeObject, 'cached data');

console.log('オブジェクト作成後:', getMemoryUsage());

// 参照を削除
largeObject = null;

// ガベージコレクションを強制実行
if (typeof global !== 'undefined' && global.gc) {
    global.gc();
    console.log('GC実行後:', getMemoryUsage());
}

// テスト2: Mapの場合
console.log('\n2. Mapテスト:');
console.log('初期メモリ:', getMemoryUsage());

let largeObject2 = {
    data: new Array(1000000).fill('test data'), // 約8MB
    id: 'test-2'
};

let map = new Map();
map.set(largeObject2, 'cached data');

console.log('オブジェクト作成後:', getMemoryUsage());

// 参照を削除
largeObject2 = null;

// ガベージコレクションを強制実行
if (typeof global !== 'undefined' && global.gc) {
    global.gc();
    console.log('GC実行後:', getMemoryUsage());
}

実験結果

Node.jsで--expose-gcフラグを付けて実行した結果:

=== ガベージコレクション テスト ===

1. WeakMapテスト:
初期メモリ: { rss: 41, heapUsed: 4, heapTotal: 6 }
オブジェクト作成後: { rss: 49, heapUsed: 12, heapTotal: 13 }
GC実行後: { rss: 42, heapUsed: 4, heapTotal: 7 }

2. Mapテスト:
初期メモリ: { rss: 42, heapUsed: 4, heapTotal: 7 }
オブジェクト作成後: { rss: 50, heapUsed: 11, heapTotal: 14 }
GC実行後: { rss: 50, heapUsed: 11, heapTotal: 16 }

結果の分析

WeakMapの場合

  • 初期: heapUsed: 4MB
  • オブジェクト作成後: heapUsed: 12MB (+8MB)
  • GC実行後: heapUsed: 4MB (-8MB) ← メモリが解放された!

Mapの場合

  • 初期: heapUsed: 4MB
  • オブジェクト作成後: heapUsed: 11MB (+7MB)
  • GC実行後: heapUsed: 11MB (変化なし) ← メモリが解放されなかった!

重要な発見

1. largeObject = nullの重要性

実験中に気づいた重要なポイント:largeObject = nullで参照を削除しないと、WeakMapでもガベージコレクションされません

// これが必要
largeObject = null;

// ガベージコレクション実行
global.gc();

2. メモリ使用量の指標

  • rss: 物理メモリ使用量(実際にRAMに割り当てられているメモリ)
  • heapUsed: JavaScriptヒープの使用量(実際に使用されているメモリ)
  • heapTotal: JavaScriptヒープの総割り当て量(使用中 + 未使用)

実践的な使用例

キャッシュシステムでの活用

// 良い例: WeakMapを使用
class UserCache {
    private cache = new WeakMap();
    
    setUser(user: User, data: any) {
        this.cache.set(user, data);
    }
    
    getUser(user: User) {
        return this.cache.get(user);
    }
}

// ユーザーがログアウトしたら自動的にキャッシュから削除される
let user = new User();
cache.setUser(user, 'user data');
user = null; // ガベージコレクションでキャッシュも自動削除

イベントリスナーの管理

// 良い例: WeakMapを使用
class EventManager {
    private listeners = new WeakMap();
    
    addListener(element: HTMLElement, handler: Function) {
        this.listeners.set(element, handler);
    }
    
    removeListener(element: HTMLElement) {
        this.listeners.delete(element);
    }
}

// DOM要素が削除されたら自動的にリスナーも削除される
let element = document.getElementById('button');
eventManager.addListener(element, () => console.log('clicked'));
element.remove(); // ガベージコレクションでリスナーも自動削除

まとめ

TypeScriptのブルーベリー本で学んだWeakMap/WeakSetの特徴を実際に検証してみて、以下のことが分かりました:

1. 列挙系メソッドの廃止の効果

  • WeakMap/WeakSetは列挙系メソッドを持たない
  • これにより、アクセスできないキーを保持し続ける理由がない
  • ガベージコレクションの対象として扱える

2. 使い分けの指針

  • Map/Set: キーの一覧が必要、長期間保持したい場合
  • WeakMap/WeakSet: オブジェクトのライフサイクルに連動させたい場合

3. 実装時の注意点

  • 明示的に参照を削除する必要がある(object = null
  • 列挙メソッドがないため、キーへのアクセス方法を考慮する

実際にコードで検証することで、本で読んだ内容がより深く理解できました。特にメモリ使用量の変化を数値で確認できたのは、とても勉強になりました。

参考資料

Discussion

junerjuner
// 大きなオブジェクトを作成
let largeObject = {
    data: new Array(1000000).fill('test data'), // 約8MB
    id: 'test-1'
};

let weakMap = new WeakMap();
weakMap.set(largeObject, 'cached data');

console.log('オブジェクト作成後:', getMemoryUsage());

// 参照を削除
largeObject = null;

let weakMap
{
// 大きなオブジェクトを作成
let largeObject = {
    data: new Array(1000000).fill('test data'), // 約8MB
    id: 'test-1'
};

weakMap = new WeakMap();
weakMap.set(largeObject, 'cached data');

console.log('オブジェクト作成後:', getMemoryUsage());
}

にすればよかったのでは……?(変数が削除されればいいので

キョーヤキョーヤ

コメントありがとうございます!
ご指摘の通り、スコープを抜けて変数への参照がなくなれば、WeakMapのキーもガベージコレクションの対象になるはずです。
実際にjunerさんが提示してくださったスコープで囲んだパターンも試してみましたが、私の環境ではすぐにメモリが解放される様子は観測できませんでした。
理論上は参照が消えればGCの対象になるものの、GCのタイミングやエンジンの最適化によって、すぐにメモリが解放されない場合もあるようです。

junerjuner

エンジンまかせですもんね。確かに。(実装上は var で アクセスできないようにしているだけの可能性もありますね。そういう意味では function scope とかでやれば解放される可能性もあります。

let weakMap
(() => {
// 大きなオブジェクトを作成
let largeObject = {
    data: new Array(1000000).fill('test data'), // 約8MB
    id: 'test-1'
};

weakMap = new WeakMap();
weakMap.set(largeObject, 'cached data');

console.log('オブジェクト作成後:', getMemoryUsage());
})();