Javaエンジニアへの第一歩!Stringクラスの内部構造を解き明かす

こんにちは。ゆうせいです。

みなさんは、JavaでプログラミングをしていてStringを使わない日はありますか?おそらく、一行も書かない日は皆無と言ってもいいでしょう。それほど身近なStringクラスですが、その中身がどうなっているか、じっくり覗いたことはありますか?

「ただの文字の集まりでしょ?」と思ったら大間違いです。実は、StringクラスにはJavaの設計思想がぎっしりと詰まっています。この研修を通して、魔法の箱の中身を一緒に解剖していきましょう。

今回は、全5章構成でStringクラスの真実に迫ります。

本講座の構成

  1. 第1章:Stringの正体と不変性の秘密
  2. 第2章:メモリの節約術!文字列リテラルとプール
  3. 第3章:最新Javaの工夫!byte配列への進化
  4. 第4章:比較の罠!等価性と同一性の完全理解
  5. 第5章:最強の使い分け!StringBuilderとの共存

第1章:Stringの正体と不変性の秘密

概要

この章では、Stringクラスがデータをどのように保持しているのか、そしてなぜ一度作った文字列を変更できない「不変(イミュータブル)」という性質を持っているのかを理解します。

コード抜粋

JDKの内部では、Stringは基本的につぎのような構造で定義されています(説明のために簡略化しています)。

public final class String {

	private final byte[] value;

	private int hash;

}

逐次解説

1行目:public final class String

クラスにfinalがついていることに注目してください!これは、他のクラスがStringを継承して新しい「勝手なString」を作れないようにするためです。

2行目:private final byte[] value;

ここがデータの本体です。文字データは、このvalueという名前の配列の中に格納されます。ここにもfinalがついていますね。一度配列を割り当てたら、二度と中身を入れ替えることはできません。

3行目:private int hash;

文字列のハッシュ値を保存しておく変数です。一度計算したら使い回せるようにキャッシュされています。

図解イメージ

みなさん、文字列を「ホワイトボード」だと思っていませんか?実はJavaのStringは「刻印された石板」です。

ホワイトボードであれば、書いた文字を消して書き直せます。しかし、Stringという石板は、一度文字を刻んだら最後、削ることも上書きすることも不可能です。もし「Hello」を「Hello!」に変えたいなら、元の石板を加工するのではなく、新しい石板をもう一枚用意して「Hello!」と刻み直すしかないのです。

よくある誤解

「s = s + !; と書けば中身は変わるじゃないか」という反論が聞こえてきそうです。

しかし、これは変数sが指し示す「石板(インスタンス)」を新しいものに取り替えているだけであって、元の石板自体が書き換わったわけではありません。古い石板はそのまま放置され、やがてゴミ箱(ガベージコレクション)へ運ばれていきます。

実務での重要性

なぜ、わざわざこんな面倒な「不変」という性質にしているのでしょうか?

最大の理由はセキュリティとスレッドセーフです。

例えば、データベースのパスワードをStringで保持しているとしましょう。もし誰かがその中身を後から勝手に書き換えられたら、認証システムは崩壊してしまいます。また、複数の処理が同時に一つの文字列を参照していても、中身が変わらない保証があれば、安心して使い回すことができます。

メリットとデメリット

メリット:

  • どこから参照されても中身が変わらないため、安全性が極めて高い。
  • キャッシュ(文字列プール)が利用できるため、メモリ効率が良い。

デメリット:

  • 文字列の結合を繰り返すと、そのたびに新しいインスタンスが生成されるため、メモリを大量に消費し、動作が重くなる原因になる。

演習問題

問1:Stringクラスにfinal修飾子がついている理由として、適切なものを選んでください。

A) 実行速度を速くするため

B) 継承による予期せぬ動作変更を防ぎ、安全性を保つため

C) 文字数を制限するため

問2:String型の変数 str に Hello という値を代入した後、str = str + World; を実行しました。このとき、メモリ内部では何が起きていますか?


第1章、お疲れ様でした!Stringが「頑固な石板」であることがイメージできましたか?

第2章:メモリの節約術!文字列リテラルとプール

第1章では、Stringが「一度作ったら変えられない石板」であることを学びましたね。でも、ちょっと考えてみてください。プログラムの中で「こんにちは」という文字列を100回使ったら、石板が100枚も作られてしまうのでしょうか?

そんなことをしたら、あっという間にメモリがいっぱいになってしまいますよね。そこでJavaが用意した賢い仕組みが「文字列プール」です。さあ、メモリの裏側を覗いてみましょう!

概要

この章では、同じ内容の文字列を効率よく再利用するための仕組み「Constant Pool(定数プール)」と、ダブルクォーテーションで囲んだ「リテラル」の挙動を理解します。

コード抜粋

つぎのコードを実行したとき、内部で何が起きているでしょうか。

String s1 = "Java";

String s2 = "Java";

String s3 = new String("Java");

逐次解説

1行目:String s1 = "Java";

ダブルクォーテーションで書かれた文字列(リテラル)が登場すると、Javaはまず「文字列プール」という専用の保管庫を探します。まだ「Java」がなければ、そこに新しい石板を作って保管します。

2行目:String s2 = "Java";

ここがポイントです!Javaは再びプールを探し、「あ、もう『Java』という石板があるぞ」と見つけ出します。そして、新しく作るのをやめて、s1と同じ石板を指し示すようにします。

3行目:String s3 = new String("Java");

newという命令は「強制的に新しいインスタンスを作れ」という合図です。プールに同じ文字があっても無視して、メモリの別の場所に新しい石板を無理やり作ります。

図解イメージ

大きな「共有倉庫(プール)」を想像してください。

リテラルで文字列を作ると、職人さんはまず倉庫を確認します。「Java」という看板がすでに棚にあれば、それを指差すだけで作業終了です。これが「リテラルの再利用」です。

一方で、newを使った場合は、職人さんに「倉庫にあるのは知ってるけど、俺専用の新しい看板を今すぐ削り出してくれ!」と特注しているようなものです。これではメモリの無駄遣いになってしまいますね。

よくある誤解

「プールにある文字列はずっと消えないの?」という疑問を持つ方がいます。

昔のJavaではプールが特別な領域にありましたが、今のJavaでは通常のヒープ領域(ゴミ箱が掃除してくれる場所)に含まれています。使われなくなった文字列は、最終的にはガベージコレクションによって適切に片付けられるので安心してください。

実務での重要性

実務では、特別な理由がない限り必ずリテラル("...")を使い、new String() は避けるのが鉄則です。

なぜなら、リテラルを使うだけでメモリ消費を抑え、パフォーマンスを向上させることができるからです。また、後で解説する「==」による比較の結果が変わってしまう原因にもなるため、この違いを知っておくことはバグを防ぐために不可欠です。

メリットとデメリット

メリット:

  • 同じ内容の文字列を一つのインスタンスに集約できるため、メモリを大幅に節約できる。
  • 比較の際、内部的な処理が高速化される可能性がある。

デメリット:

  • 意図的に別のインスタンスにしたい場合に、リテラル表記では制御できない(通常はその必要はありません)。

演習問題

問1:String a = "Apple"; String b = "Apple"; と定義したとき、a と b はメモリ上の同じインスタンスを指していますか?

問2:大量のループの中で String s = new String("data"); と記述した場合、パフォーマンスにどのような悪影響を与えると考えられますか?


いかがでしたか?Javaがいかにメモリを大切に扱おうとしているかが見えてきましたね。

第3章:最新Javaの工夫!byte配列への進化

第2章では、Javaがメモリを節約するために「文字列プール」という倉庫を使っていることを学びましたね。実は、Javaの進化はそれだけにとどまりません。Java 9というバージョンを境に、Stringの中身が劇的に作り替えられたのをご存知でしょうか?

かつて、Stringの内部はchar型の配列で文字を保持していました。しかし、現代のJavaはもっと賢い方法を選んでいます。それが「Compact Strings(コンパクト・ストリング)」という仕組みです。

概要

この章では、Stringの内部データ保持形式がなぜchar[]からbyte[]に変更されたのか、その背景にあるメモリ効率の追求と「エンコーディング」の概念を理解します。

コード抜粋

現在のJDK(Java 9以降)におけるStringクラスの内部構造を改めて見てみましょう。

public final class String {

	private final byte[] value;

	private final byte coder;

}

逐次解説

1行目:private final byte[] value;

以前はchar[]型でしたが、現在はbyte[]型になっています。1つの要素が占めるサイズが、16ビット(2バイト)から8ビット(1バイト)へと半分になりました。

2行目:private final byte coder;

これが新しい司令塔です。この変数には「この文字列は1バイトで表現できる英数字だけか?(LATIN1)」それとも「2バイト必要な日本語などが含まれているか?(UTF16)」という情報が記録されます。

図解イメージ

引越しを想像してみてください。

これまでは、中身が「ABC」という英字3文字だけでも、わざわざ特大サイズの段ボール(1文字2バイトのchar)を3つ用意して運んでいました。中身はスカスカなのに、場所だけは取っていたわけです。

新しいJavaは、「英数字だけなら小さい箱(1バイトのbyte)で十分だよね」と判断します。これにより、欧米言語が中心のシステムでは、文字列が占めるメモリ使用量を一気に半分近くまで減らすことに成功しました。日本語が含まれるときだけ、自動的に大きな箱に切り替えて対応する、非常にハイブリッドな設計になっています。

よくある誤解

「byte[]になったら、日本語が正しく扱えなくなるのでは?」という心配をされる方がいます。

安心してください!プログラマがStringを使う際の手触りは一切変わりません。内部でcoder変数が「これは日本語入りのUTF16モードだぞ」と管理し、必要に応じて適切に処理を切り替えてくれるからです。私たちは中身が何バイトかなんて気にせず、今まで通り文字列として扱えば良いのです。

実務での重要性

この変更を知っておくことは、大規模なシステムにおいてメモリ使用量(ヒープメモリ)を見積もる際に役立ちます。

「Java 8からJava 11へバージョンアップしたら、メモリに余裕ができた!」という現象が起きることがありますが、それはまさにこのStringの進化のおかげであることが多いのです。最新の言語仕様が、いかにリソースを最適化しようとしているかを感じ取ってください。

メリットとデメリット

メリット:

  • 英数字のみの文字列(IDやコード、URLなど)において、メモリ消費量を約50%削減できる。
  • メモリ使用量が減ることで、ガベージコレクションの回数が減り、システム全体のパフォーマンスが向上する。

デメリット:

  • 内部的に「どちらの形式か」を判定する処理(coderのチェック)がわずかに追加されるが、メモリ削減のメリットに比べれば無視できるレベルである。

演習問題

問1:Java 9以降、Stringの内部データが char[] から byte[] に変更された主な目的は何ですか?

問2:coder という変数は、具体的にどのような役割を果たしていますか?


いかがでしたか?「たかが文字列」と思っていても、その裏側では1バイトでも無駄にしないための工夫が凝らされているんですね。

第4章:比較の罠!等価性と同一性の完全理解

「プログラムが思った通りに動かない!」という新人の相談を受けてコードを見ると、かなりの確率でこの第4章の内容が原因だったりします。Javaにおいて、文字列を「比べる」という行為には、実は2つの異なる意味があるのです。

この違いをあいまいにしたままだと、本番環境で予期せぬバグを引き起こす「時限爆弾」を抱えることになりますよ。さあ、スッキリ整理していきましょう!

概要

この章では、演算子 ==(同一性)と、メソッド equals(等価性)の決定的な違いを理解します。なぜStringの比較に == を使ってはいけないのか、その理由をメモリ構造から紐解きます。

コード抜粋

つぎの3つの比較結果がどうなるか、予想してみてください。

String a = "Java";

String b = "Java";

String c = new String("Java");

System.out.println(a == b); // 結果1

System.out.println(a == c); // 結果2

System.out.println(a.equals(c)); // 結果3

逐次解説

1行目:a == b

結果は true です。第2章で学んだ「文字列プール」を思い出してください。aとbはメモリ上の「全く同じ石板」を指しているため、== で比較しても一致します。

2行目:a == c

結果は false です!ここが最大の落とし穴です。内容はどちらも「Java」ですが、cは new によって作られた「別の石板」です。== は「同じ石板かどうか(参照先)」を見ているため、別物と判定されます。

3行目:a.equals(c)

結果は true です。equals メソッドは、石板が別物であっても「刻まれている文字の内容」が同じであれば OK と判定してくれます。

図解イメージ

双子の兄弟を想像してください。

「==」による比較は、「あなたは本人ですか?」という確認です。指紋やマイナンバーをチェックしているようなものですね。aとbは同じプール内の「本人」を指していますが、cは別の場所にいる「他人のそっくりさん」なので、本人確認(==)では偽になります。

「equals」による比較は、「あなたはどんな服を着ていますか?」という確認です。中身(データ)が同じ「Java」という服を着ていれば、別人であっても「同じだね!」と認めてくれるわけです。

よくある誤解

「テストコードでは == でも動いたから大丈夫」という考えは非常に危険です。

リテラルだけで書いているうちは、文字列プールのおかげで偶然 == でも true になることがあります。しかし、外部ファイルから読み込んだ文字列や、ユーザーが入力した文字列は new されたのと同じ状態(別の石板)になるため、== では突然動かなくなります。

実務での重要性

Javaの規約として、文字列の比較には 100% equals メソッドを使うべきです。

たとえプールされることが分かっていても、equals を使うのが Java エンジニアとしての「共通言語」です。また、変数側が null の場合に備えて "Java".equals(str) のようにリテラル側から呼び出す工夫も、実務ではよく使われるテクニックですよ。

メリットとデメリット

メリット(equals使用時):

  • インスタンスがメモリのどこにあろうと、文字の内容が同じなら正しく比較できる。
  • バグが混入するリスクを劇的に減らせる。

デメリット(==使用時):

  • 内容が同じでも false になる可能性があり、動作が不安定になる。
  • 可読性が下がり、他のエンジニアに「基礎を知らない」と思われてしまう。

演習問題

問1:String s1 = "ABC"; String s2 = new String("ABC"); としたとき、s1.equals(s2) が true を返す理由を説明してください。

問2:if (name == "admin") というコードが、環境によって正しく動いたり動かなかったりするのはなぜですか?


「==」と「equals」の違い、もうバッチリですね!

第5章:最強の使い分け!StringBuilderとの共存

ついに最終章まで辿り着きましたね!これまでの章で、Stringが「不変の石板」であること、そしてメモリを節約するための様々な工夫を見てきました。

最後にお話しするのは、実践で最もパフォーマンスに差が出る「文字列の結合」についてです。大量のデータを扱うとき、Stringをそのまま使い続けると、プログラムがカメのように遅くなってしまうかもしれません。その救世主となるのが StringBuilder です!

概要

この章では、Stringによる連結の罠を理解し、なぜ大量の結合には StringBuilder や StringBuffer が必要なのか、その仕組みと使い分けをマスターします。

コード抜粋

つぎの2つの処理は、結果こそ同じですが、裏側の動きは天と地ほどの差があります。

		//パターンA:Stringで結合
		String result = "";
		for (int i = 0; i < 10000; i++) {
			result += "data";
		}

		//パターンB:StringBuilderで結合
		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < 10000; i++) {
			sb.append("data");
		}
		String result = sb.toString();

逐次解説

パターンA(1〜4行目):

ループの中で += を使って結合しています。第1章で学んだ通り、Stringは不変です。つまり、1回足すごとに「新しい石板」が作られ、古い石板は捨てられます。1万回のループなら、1万枚もの新しい石板がメモリを埋め尽くしてしまいます!

パターンB(7〜11行目):

StringBuilder という「書き換え可能なノート」を用意します。append メソッドを使うと、同じノートの続きに文字を書き足していきます。新しい石板を作る必要がないため、メモリも時間も大幅に節約できます。

11行目:sb.toString();

最後に、書き溜めたノートの内容をガチャンと固めて、一つの String(石板)として出力します。

図解イメージ

「1万ページの物語」を作るときの作業を想像してみてください。

Stringによる結合は、1ページ書き足すごとに、それまでの全ページを新しい紙に書き写し直す作業です。1000ページ目には、999ページ分を書き写す羽目になります。気が遠くなりますよね?

対して StringBuilder は、ルーズリーフにどんどんページを足していくようなものです。最後に一冊の本(String)にまとめるだけなので、作業量は圧倒的に少なくて済みます。

よくある誤解

「最近のJavaは += でも内部で StringBuilder に変換してくれるから大丈夫じゃないの?」

鋭い指摘です!実は、単発の a + b + c のような記述ならコンパイラが自動で最適化してくれます。しかし、今回のような「ループ内での結合」は、コンパイラもうまく最適化できないことが多いのです。ループの中では、必ず明示的に StringBuilder を使う癖をつけてください。

実務での重要性

Webアプリケーションなどで、データベースから取得した数千件のデータをCSV形式の文字列にするような処理では、この使い分け一つで処理時間が数秒から数ミリ秒へと劇的に変わります。

また、マルチスレッド(複数の処理が同時に動く環境)で安全に書き換えを行いたい場合は、動きが少し重い代わりに安全な StringBuffer を使うという選択肢もありますが、通常の開発では高速な StringBuilder で十分です。

メリットとデメリット

メリット:

  • 大量の文字列結合において、メモリ消費を抑え、実行速度を劇的に向上させられる。
  • 内部のバッファサイズを調整できるため、あらかじめ大きなデータが来るとわかっている場合に効率的。

デメリット:

  • 単純な結合(1、2回程度)であれば、コードが冗長になり可読性が落ちる。
  • 最終的に String に戻す手間(toString)が発生する。

演習問題

問1:ループの中で文字列を1000回結合する場合、String ではなく StringBuilder を使うべき最大の理由は何ですか?

問2:StringBuilder と StringBuffer の主な違いは何ですか?(ヒント:スレッド安全性)


研修のまとめと今後の学習指針

全5章、本当にお疲れ様でした!

身近な String クラスを通して、Javaがいかに「安全性(不変性)」と「効率性(プールやbyte配列)」のバランスを極限まで追求しているかが伝わったでしょうか。

今後の学習アドバイス

  1. APIドキュメントを読もう:Stringには split や replace, substring など便利なメソッドがたくさんあります。一度公式ドキュメント(JavaDoc)を隅々まで眺めてみてください。
  2. メモリを意識しよう:VisualVMなどのツールを使って、実際に String がどれくらいメモリを消費しているか観察してみるのも面白いですよ。
  3. 他の基本クラスも覗こう:Integer や Boolean などのラッパークラスも、String と似た「不変性」や「キャッシュ」の仕組みを持っています。

Stringを制する者は、Javaのメモリ管理を制します。今回の知識を武器に、ぜひ現場でパフォーマンスの高い、美しいコードを書いてくださいね!

セイ・コンサルティング・グループでは新人エンジニア研修のアシスタント講師を募集しています。

投稿者プロフィール

山崎講師
山崎講師代表取締役
セイ・コンサルティング・グループ株式会社代表取締役。
岐阜県出身。
2000年創業、2004年会社設立。
IT企業向け人材育成研修歴業界歴20年以上。
すべての無駄を省いた費用対効果の高い「筋肉質」な研修を提供します!
この記事に間違い等ありましたらぜひお知らせください。

学生時代は趣味と実益を兼ねてリゾートバイトにいそしむ。長野県白馬村に始まり、志賀高原でのスキーインストラクター、沖縄石垣島、北海道トマム。高じてオーストラリアのゴールドコーストでツアーガイドなど。現在は野菜作りにはまっている。