25
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

8.1 クラスローダーの仕組みとClassクラス~新Java Advanced編 / AI対応版

25
Last updated at Posted at 2023-02-04

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、約30年にわたって企業システムの開発に携わってきました。現在も現役のアーキテクトとして、重要システムの技術設計やソリューションの選定を担当しています。直近では、システム開発へのAI適用(AI駆動開発)という全社的な施策でリーダーを務めています。
セミナーでの登壇や雑誌への技術記事の寄稿を行ってきたほか、以下の書籍も執筆しています。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『新 Java Advanced編 / AI対応版』の一部の範囲をカバーしたものです。『新 Java Advanced編 / AI対応版』はこちらのリンクから購入できます。

この講座は、以下のような皆様にお薦めします。

  • Javaの基礎を学び終え、設計とレビューができるエンジニアを目指す方
  • AIが書いた並行処理や入出力のコードに潜むリスクを、仕組みから見抜けるようになりたい方
  • フレームワークの裏で使われるリフレクションやアノテーションの仕組みを理解したい方
  • 「Oracle認定Javaプログラマ」Goldの範囲を、仕組みから理解したい方

AIとチャットできるオールインワンの学習環境をリリースしました

Udemy講座の演習を動かすための学習環境として、Windows向けのデスクトップアプリ「Codinable(コーディナブル)」をリリースしました。
エディタ、実行結果、テスト結果、SQL、Webブラウザ、そしてAIチャットを1つの画面にまとめた、オールインワンの学習環境です。

Codinableの画面構成

学習に必要なものは、すべてCodinableの中で完結します。

  • 演習の実行環境(Java 25、Gradle、Bashなど)が一式同梱されており、JDKのインストールや環境変数の設定は不要です
  • インストールして「新規コース開始・再開」からコースを選ぶだけで、講座のすべての演習がそのまま動きます
  • 書き換えて動かなくなっても、「初期化」で配布時の状態に戻せます
  • 開いている演習のファイルと実行結果を踏まえて、AIに質問したり(Askモード)、ファイルの書き換えを指示したり(Agentモード)できます

AIに書き換えを指示し、差分を確かめてから実行する、というAI駆動開発の基本の流れを、Codinableの中だけで安全に体験できます。

AIチャットに書き換えを指示し、差分を確かめて実行する

AIチャットは、受講者ご自身が生成AIサービス(Anthropic、OpenAI、Googleのいずれか)のAPIキーを登録して使うBYOK(Bring Your Own Key)方式です。利用料金は各社から直接請求されます。APIキーを登録しなくても、AIチャット以外の機能はすべて使えます。

Codinableのインストーラは、以下のGitHubのReleasesページからダウンロードできます(64ビット版のWindows 10/11、管理者権限は不要)。

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

8.1 クラスローダーの仕組みとClassクラス

チャプターの概要

本チャプターでは、Javaランタイムがクラスファイルを見つけてメモリーへ読み込む仕組みであるクラスローダーと、読み込まれたクラスのメタ情報を表すClassクラスを取り上げます。Classオブジェクトは、チャプター8.2のリフレクションと8.3のアノテーションの起点です。クラスがいつ・どのクラスローダーで読み込まれるかを図で理解し、リソースの読み込みの考慮漏れや、実行環境によって結果が変わるコードを見抜けるようになることを目指します。

8.1.1 クラスのロードとライフサイクル

本レッスンでは、クラスがいつメモリーに読み込まれ、いつ初期化されるかを確かめ、スタティック初期化子を含むコードの動きを判断できるようにします。

クラスのライフサイクル

クラスローダーは、モジュールパスまたはクラスパス(チャプター2.1参照)からクラスファイルを探し、クラス情報をスタティック領域に置く仕組みです。同じクラスローダーが同じクラスを2回読み込むことはありません。

クラスファイルからスタティック領域、ヒープ領域へと進むライフサイクルを、次の図に示します。

クラスのライフサイクルとメモリー領域

  • 1.ロードでは、クラスローダーがクラスファイル(.class)を読み込み、スタティック領域にクラス情報を置く
  • 2.インスタンス生成では、スタティック領域のクラス情報をもとに、ヒープ領域にインスタンスが作られる
  • 3.廃棄では、ヒープ領域のインスタンスがガベージコレクションによって破棄される

ロードと初期化が起きる契機

クラスは、最初に必要になった時点で読み込まれます。読み込まれたクラスは、スタティックなメンバーへのアクセス、newによるインスタンス生成、Class.forName()の呼び出しなどを契機に初期化され、このときスタティック初期化子(static { ... })が実行されます。一方、クラス名.classというクラスリテラルでClassオブジェクトを取得しただけでは、初期化は起きません。また、static final int MAX = 10;のようなコンパイル時定数は、利用する側のクラスファイルに値が埋め込まれるため、参照してもそのクラスは初期化されません。

操作ごとに、スタティック初期化子が実行されるかどうかを図で比較します。

操作ごとのスタティック初期化子の実行の有無

  • 左に4つの操作「Greeting.class(クラスリテラル)」「Class.forName("…Greeting")」「new Greeting()」「Greeting.MAX(コンパイル時定数)」が縦に並ぶ
  • 中央の「Greetingクラスの初期化(static { … } の実行)」へ、「Class.forName」と「new Greeting()」から実線の矢印が伸びる
  • 「Greeting.class」からの矢印は「Classオブジェクトの取得のみ。初期化しない」の箱で止まる
  • 「Greeting.MAX」からの矢印は「値は利用側に埋め込み済み。初期化しない」の箱で止まる
  • 右端に「初期化はクラスローダーごとに1回だけ」というラベルがある

サンプルのGreetingクラスには、現在時刻を表示するスタティック初期化子があります。

  • 08_reflection_annotation: pro.kensait.java.advanced.lsn_8_1_2.Greeting
public class Greeting {
    // getYes()、getNo()、getHello()は省略
    static Date now;
    static {
        now = new Date();
        System.out.println(now); //【1】
    }
}
  • 【1】このクラスが初期化されたときに1回だけ、現在時刻が表示される
  • lsn_8_1_2.Mainを実行すると、Greeting.classを取得するsnippet_1では時刻は表示されず、new Greeting()のsnippet_2で初めて表示される

レビューの観点

  • スタティック初期化子で、ファイルやネットワークへのアクセスなど、失敗し得る重い処理をしていないか。初期化で例外が出ると、そのクラスはそれ以降使えなくなる
  • 「クラスリテラルを書いたので初期化される」といった、初期化の時点を誤解した前提のコードになっていないか
  • コンパイル時定数の値を変えたとき、利用する側のクラスも再コンパイルされる構成になっているか

8.1.2 クラスローダーの階層と親への委譲

本レッスンでは、どのクラスがどのクラスローダーで読み込まれるかを確かめ、「同じ名前なのに別のクラス」になる状況を判断できるようにします。

組み込みクラスローダーの階層

Javaランタイムには3つの組み込みクラスローダーがあり、親子の階層を成しています。クラスローダーは、ロードを求められると、まず親に委譲し、親が見つけられなかった場合に限って自分でロードします。この仕組みを親への委譲と呼びます。

【表】組み込みクラスローダー

クラスローダー ロードする主なクラス
ブートストラップ・クラスローダー java.lang.Stringなど、Java SEの中核のクラス
プラットフォーム・クラスローダー java.net.http.HttpClientなど、Java SEのクラスライブラリのうちブートストラップ以外のもの
システム・クラスローダー(アプリケーション・クラスローダー) 開発者が作成したクラスや、クラスパス・モジュールパスに置いたライブラリのクラス

ロードの要求が、システム・クラスローダーから上へ委譲される様子を示します。

クラスローダーの階層構造と親への委譲

  • クラスローダーは、上からブートストラップ・クラスローダー、プラットフォーム・クラスローダー、システム・クラスローダー(アプリケーション・クラスローダー)の階層を成す
  • ロードの要求は、システム・クラスローダーからプラットフォーム・クラスローダー、ブートストラップ・クラスローダーへと委譲される
  • アプリケーションサーバーがWARファイルごとに用意する独自のクラスローダーは、システム・クラスローダーへ委譲する
  • どのクラスローダーも、親に無ければ自分でロードする

組み込みクラスローダーを取得し、それぞれの親を調べるコードを以下に示します。

  • 08_reflection_annotation: pro.kensait.java.advanced.lsn_8_1_1.Main_1
ClassLoader classLoader1 = ClassLoader.getPlatformClassLoader(); //【1】
ClassLoader classLoader2 = ClassLoader.getSystemClassLoader(); //【2】
ClassLoader parent1 = classLoader1.getParent(); //【3】
ClassLoader parent2 = classLoader2.getParent(); //【4】
System.out.println("プラットフォーム・クラスローダーの本体 => " + classLoader1);
System.out.println("プラットフォーム・クラスローダーの親 => " + parent1);
System.out.println("システム・クラスローダーの本体 => " + classLoader2);
System.out.println("システム・クラスローダーの親 => "  + parent2);
  • 【1】プラットフォーム・クラスローダーを取得する
  • 【2】システム・クラスローダーを取得する
  • 【3】プラットフォーム・クラスローダーの親はブートストラップ・クラスローダーで、Javaのオブジェクトとしては存在しないためnullが返る
  • 【4】システム・クラスローダーの親として、プラットフォーム・クラスローダーが返る。実行結果では、1行目と4行目の@の後ろの値が一致し、親子の関係を確かめられる(@の後ろの値は実行のたびに変わる)

親への委譲が中核のクラスを守る

開発者がjava.lang.Stringという名前で偽のクラスを作っても、ロードの要求は最上位のブートストラップ・クラスローダーまで委譲され、そこで正式なStringクラスが見つかります。そのため、偽のクラスが読み込まれることはありません。親への委譲は、中核のクラスがすり替えられることを防ぐ役割も担っています。

偽のStringクラスのロードが要求されたときの流れを、図で示します。

偽のStringクラスのロードを親への委譲が防ぐ流れ

  • 1番目に「アプリケーション」が「java.lang.String をロードしたい」とシステム・クラスローダーに求める
  • 2番目に、システム・クラスローダーからプラットフォーム・クラスローダー、ブートストラップ・クラスローダーへ「委譲」の矢印が上へ伸びる
  • 3番目に、ブートストラップ・クラスローダーが「Java SEの正式なString」を見つけて返す
  • クラスパス上の「偽のjava.lang.String」には矢印が届かず、「読み込まれない」というラベルがある

クラスの同一性はFQCNとクラスローダーの組

実行時のクラスは、FQCNだけでなく、それを読み込んだクラスローダーとの組で区別されます。アプリケーションサーバーのように、アプリケーションごとに独自のクラスローダーを用意する環境では、同じFQCNのクラスが、別のクラスローダーで2回読み込まれることがあります。それらは別のクラスとして扱われるため、一方のインスタンスを他方の型へキャストすると、名前は同じなのにClassCastExceptionになります。

同じFQCNのクラスが、2つのクラスローダーで別のクラスになる様子を、図で示します。

FQCNとクラスローダーの組によるクラスの区別

  • 左右に「クラスローダーA(アプリケーション1)」と「クラスローダーB(アプリケーション2)」の2つの箱がある
  • どちらの箱にも「pro.kensait.Person」というFQCNのクラス情報がある
  • 2つのクラス情報の間に「≠ 別のクラス」というラベルがある
  • クラスローダーAのPersonのインスタンスから、クラスローダーBのPersonへのキャストの矢印に「ClassCastException」の×印がある
  • 下に「実行時のクラス = FQCN + 読み込んだクラスローダー」という帯がある

レビューの観点

  • 独自のクラスローダーを作って親への委譲を飛ばすコードになっていないか(中核のクラスや共通ライブラリが二重に読み込まれる原因になる)
  • 「同じ名前の型へのキャストでClassCastException」という不具合の原因を、型の誤りと決め付けていないか。2つのクラスローダーで読み込まれていないかを疑う
  • getParent()やgetClassLoader()の戻り値がnull(ブートストラップ・クラスローダー)になる場合を考慮しているか

8.1.3 リソースの読み込みとコンテキスト・クラスローダー

本レッスンでは、クラスパス上のリソースを読み込むコードで、どのクラスローダーを使うべきか、リソースが無い場合をどう扱うべきかを判断できるようにします。

どのクラスローダーでリソースを探すか

開発者がクラスローダーを明示的に使う代表的な場面が、プロパティファイルなどのリソースの読み込みです。アプリケーションサーバーでは、アプリケーションのリソースはアプリケーションごとのクラスローダーが読み込むため、ClassLoader.getSystemClassLoader()では見つからないことがあります。そこで使うのが、スレッドごとに設定されたコンテキスト・クラスローダーです。アプリケーションサーバーは、処理を呼び出す前に、そのアプリケーションのクラスローダーをここに設定します。

アプリケーションサーバーの中で、2つのクラスローダーがリソースを探す範囲を図で比較します。

システム・クラスローダーとコンテキスト・クラスローダーの探索範囲

  • 外枠は「アプリケーションサーバー(1つのJavaランタイム)」で、中に「アプリケーション1のクラスローダー」と「アプリケーション2のクラスローダー」がある
  • 「アプリケーション1のクラスローダー」の中に「config/MyResource.properties」がある
  • 「getSystemClassLoader()」からの矢印は、アプリケーションの外の「システム・クラスローダー」に向かい、「見つからない(null)」というラベルがある
  • 「Thread.currentThread().getContextClassLoader()」からの矢印は、アプリケーション1のクラスローダーに向かい、「見つかる」というラベルがある
  • 下に「コンテキスト・クラスローダーは、処理中のアプリケーションのクラスローダーを指す」という帯がある

見つからないリソースは例外ではなくnullになる

コンテキスト・クラスローダーでプロパティファイルを読み込むコードを以下に示します。

  • 08_reflection_annotation: pro.kensait.java.advanced.lsn_8_1_1.Main_2
Properties props = new Properties();
ClassLoader classloader = Thread.currentThread().getContextClassLoader(); //【1】
try (InputStream is = classloader
        .getResourceAsStream("config/MyResource.properties")) { //【2】
    props.load(new InputStreamReader(is, StandardCharsets.UTF_8)); //【3】
    String name = props.getProperty("name");
    int age = Integer.parseInt(props.getProperty("age"));
    String address = props.getProperty("address");
    System.out.println(name + ", " + age + ", " + address);
} catch (IOException ioe) {
    throw new RuntimeException(ioe);
}
  • 【1】現在のスレッドのコンテキスト・クラスローダーを取得する
  • 【2】クラスパス上のパスを渡してInputStreamを取得する。区切り文字はOSに関わらず/で、リソースが見つからない場合は例外ではなくnullが返る
  • 【3】文字コードにUTF-8を指定して読み込む。【2】がnullの場合、ここでNullPointerExceptionになり、原因がリソースの欠落だと分かりにくい

リソースが見つからない場合に、何が起きるかを図で比較します。

リソースが見つからない場合の2つの書き方

  • 左の「問題のある書き方」は「getResourceAsStream()」「null」「new InputStreamReader(null, …)」「NullPointerException(原因が分からない)」の順に進む
  • 右の「良い書き方」は「getResourceAsStream()」「nullかを確かめる」「例外を送出("config/MyResource.properties が見つからない")」の順に進む
  • 右の流れでは、例外のメッセージにリソースのパスが含まれることが強調されている
  • 2つの流れの上に「getResourceAsStream()は見つからなくても例外を送出しない」という共通のラベルがある

レビューの観点

  • getResourceAsStream()の戻り値がnullの場合を扱っているか。扱う場合は、リソースのパスを含むメッセージで例外を送出しているか
  • アプリケーションサーバーなどで動くコードで、getSystemClassLoader()でリソースを探していないか
  • リソースのパスを、ファイルシステムのパス(C:\...や\区切り)として扱っていないか
  • プロパティファイルを読み込む文字コードを明示しているか

8.1.4 Classクラス

本レッスンでは、Classオブジェクトがどのように作られ、どのように取得できるのかを確かめ、リフレクションの起点となる型の情報を正しく扱えるようにします。

1つのクラスに1つのClassオブジェクト

java.lang.Classクラスは、クラスのメタ情報(名前、読み込んだクラスローダー、フィールド・メソッド・コンストラクタなど)を表します。Classオブジェクトは、クラスがロードされたときにJavaランタイムが作り、1つのクラス(クラスローダーごと)に1つだけ存在し、newでは作れません。取得する方法は、クラスリテラル、getClass()、Class.forName()の3つです。

Classオブジェクトを取得する3つの方法を、図で示します。

Classオブジェクトを取得する3つの方法

  • 中央に、スタティック領域にある「Greetingの Classオブジェクト(1つだけ)」がある
  • 「Greeting.class(クラスリテラル)」から中央への矢印に「コンパイル時に型が決まる。初期化しない」というラベルがある
  • 「greeting.getClass()」から中央への矢印に「インスタンスの実際の型を返す」というラベルがある
  • 「Class.forName("pro.kensait…Greeting")」から中央への矢印に「文字列で指定。初期化する」というラベルがある
  • 3本の矢印がすべて同じ1つのClassオブジェクトを指している

変数の型と実際の型

getClass()が返すのは、変数の型ではなく、インスタンスの実際の型です。たとえばObject obj = Integer.valueOf(100);のとき、obj.getClass()はIntegerのClassオブジェクトを返します。そのため、getClass()の戻り値は、型パラメータをワイルドカードにしたClass<?>で受けます。

主なAPIを、表にまとめます。

【表】Classクラスの主なAPI

API(メソッド) 説明
String getSimpleName() 単純名を返す(例 Greeting)
String getPackageName() パッケージ名を返す
String getCanonicalName() 正規名を返す。入れ子でないクラスではFQCNと同じ
ClassLoader getClassLoader() このクラスを読み込んだクラスローダーを返す(ブートストラップ・クラスローダーの場合はnull)
T cast(Object) 指定したオブジェクトをこの型にキャストする。キャストできなければClassCastException

変数の型と、getClass()が返す実際の型の違いを、図で示します。

変数の型とgetClass()が返す実際の型

  • 左のスタックに「Object obj」という変数があり、ヒープの「Integerのインスタンス(100)」を参照している
  • 変数の横に「変数の型:Object(コンパイル時)」というラベルがある
  • インスタンスから「IntegerのClassオブジェクト」への矢印に「obj.getClass()が返すもの」というラベルがある
  • 下に「Integer.class.cast(obj) は、実際の型がIntegerなので成功する」という帯がある

レビューの観点

  • Class.forName()に渡すクラス名を、外部からの入力から組み立てていないか(読み込むクラスを利用者が選べてしまう)
  • getClass()で型を判定し、サブクラスのインスタンスを想定外に除外していないか(instanceofとの違いを確かめる)
  • getCanonicalName()がnullを返す場合(匿名クラスなど)を考慮しているか

このチャプターで学んだこと

本チャプターでは、以下を取り上げました。

  1. クラスは最初に必要になった時点でクラスローダーが読み込み、スタティック初期化子は初期化の契機(new、Class.forName()など)で1回だけ実行され、クラスリテラルでは実行されないこと
  2. 組み込みクラスローダーは親子の階層を成し、親への委譲によって中核のクラスのすり替えを防いでいること
  3. 実行時のクラスはFQCNと読み込んだクラスローダーの組で区別され、同じ名前でも別のクラスになり得ること
  4. リソースはコンテキスト・クラスローダーで読み込み、見つからない場合のnullをパスの分かるメッセージの例外として扱うこと
  5. Classオブジェクトは1つのクラスに1つで、クラスリテラル・getClass()・Class.forName()で取得でき、getClass()は実際の型を返すこと
25
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?