ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) string で発生するガベージを抑える (String Interning)

C# においてstringは参照型で、そのインスタンスはヒープメモリに生成されます。 当然、参照されなくなったstringは次回のガベージコレクションで回収されます。

少しでもガベージは減らしたいものですが、文字列は生成するたびに同じ内容でも別インスタンスを生成してしまい無駄になります。

一度生成した同一の内容のstringを有効活用する方法として String Interning という方法が .NET では用意されています。

String Interning

String Interning は、インターンプールという場所に生成した文字列を格納しておくことで、それ以降同じ内容の文字列を生成する時、インターンプール内に登録されていれば、登録されている文字列を取得することで新たなインスタンスの生成をしない、というものです。 String Interning という仕組み自体は、C# 固有のものではなくJava, Python, PHP などメジャーな言語でも同様にサポートされています。

String Interning - Wikipedia

インターンプールは内部的には文字列のハッシュテーブルになっており、.NETが管理しています。文字列の登録のみが可能で、削除等インターンプール内部を直接触ることはできません。登録された文字列はランタイムの終了時までインターンプールに保持され続けるためガベージコレクションの対象にもなりません。

(これは裏を返すと、巨大な文字列を登録すると終了時まで解放されずメモリを占有し続けるということです)

頻繁に登場する比較的小さな文字列が大量に生成され、ガベージコレクションに捨てられるのを防ぐというのがよい使い方と思います。

C# で文字列をインターンプールに登録するには

string a;

/* ここで a に何らかの文字列が代入される */

// a をインターンプールに登録し登録した文字列を取得
// a が登録済み (つまり a が既にプール内文字列)の場合は何も起こらない
a = string.Intern(a);

// これ以降 a と同じ文字列を生成しようとした場合、インスタンスの新規生成はされない

で登録できます。

リテラル文字列における String Interning

ソースコード中にハードコーディングされているリテラル文字列はランタイムから特殊扱いされ、初めからインターンプールに登録済みの文字列として .NET に保持されます。

// a と b のアドレスは同一(インターンプール内の同一インスタンスを指す)
string a = "hoge";
string b = "hoge";

上記の二つのstringインスタンスはともにリテラルですが、この場合初めからインターンプールに登録済みの文字列となり、同一のインスタンスを指しています。unsafe で二つのアドレスを見ると、同一であることが確認できます。

で、気になるのが最初からインターンプールに登録済みとなる条件は何かということです。

私、気になります。

nameof$文字で文字列を挿入された文字列 (string interpolation) などはどうなるのでしょうか?

結果は以下の通り。

「文字列」がソースコード中の記述、「出力」が実際に生成される文字列、「Interned」は「出力」の文字列が最初からインターンプルに登録済みの文字列として生成されるか、を表しています。

文字列 出力 Interned 備考
"hoge" "hoge"
"hoge" + "piyo" "hogepiyo" ×
nameof(Piyo) "Piyo"
$"hoge{"piyo"}" "hogepiyo"
$"hoge{nameof(Piyo)}" "hogePiyo"
4.ToString() "4" ×
"hoge" + 4.ToString() "hoge4" ×
$"hoge{4.ToString()}" "hoge4" ×
$"hoge{a}" "hogepiyo" × var a = "piyo"; // (a は Interned)
$"hoge{$"pi{"foo"}yo"}" "hogepifooyo"
$"hoge{$"pi{4.ToString()}yo"}" "hogepi4yo" ×

(その他)

// a はプール内文字列
var a = "1234";

// ここで新たに "1234" が確保される
var b = 1234.ToString();

// c は a と同一インスタンス
var c = string.Intern(b);

// a と b は別アドレス
var refEqualsAB = object.ReferenceEquals(a, b);
Console.WriteLine(refEqualsAB);    // False

// a と c は同じアドレス
var refEqualsAC = object.ReferenceEquals(a, c);
Console.WriteLine(refEqualsAC);    // True

結論

  • リテラルは最初から Interned な文字列 (プール内インスタンス) である
  • nameofリテラル扱いを受ける (値の解決はコンパイル時に行われる。IL上ではリテラルに置き換わっている)
  • $文字列も中身にリテラル (nameofを含む) しか入っていない場合はInterned。$文字列が複合的に存在しても再帰的に条件に当てはまっていればInternedになる。
  • $文字列内に非リテラルが存在すると Interned にはならない。中の非リテラルが Internedかどうかは無関係

余談

インターンプール内の文字列を参照している場合、同一のインスタンスを参照しているため unsafe で書き換えると悪さができてしまいます。ダメ絶対

// a と b は同一インスタンス
var a = "hoge";
var b = "hoge";

unsafe
{
    fixed(char* ptr = a)
    {
        ptr[0] = 'A';
    }
}

Console.WriteLine(a);    // "Aoge"
// a と同一インスタンスなので b も変わる
Console.WriteLine(b);    // "Aoge"

参考文献

C# における String Interning の日本語記事が以下のページぐらいしかヒットしなくて、んーーっと思ったので本記事を書きました。

まあ過度にメモリを気にするゲーム用途とかシビアな状況でしか必要にならないし、仕方ないかなとも思いますけどね。(下のページも Unity の省メモリ化についての解説ですし)

engineering.grani.jp