Javaが分からなすぎるので、JVMについて勉強する(1)

投稿者: | 2022年8月1日

背景とか目的とか

最近、Clojure をちょっと真面目に使おうと考えています。が、自分はJavaの経験が圧倒的に不足しています。お仕事で使ったことはありますが、それでもJavaの動作の仕組みなどについては曖昧なままで過ごしていました。そういう状態だと、Clojure でトラブった時に勘所が分からなくて困るということがあります。(既に何回か遭遇しています)

あと、直近の業務でもJVMのことを知っていないと色々困ることがあるなぁと感じることが少なからずあるので観念してJVMについて勉強を始めようかと思いました。

今の自分は、JavaはJava仮想マシン(JVM)で実行されており、JVMは独自のアセンブラ(バイトコード)を持つスタックマシンのような仮想マシンです。javaのソースコードは、javacによってバイトコードに変換され、それをJVMが実行することで動作しています。というような認識を持っています。この理解の精度を高めたいというのが目的です。

理解が曖昧になっているところを書いておきます

  • classpathの取り扱い
  • バイトコードからシステムコールなどのネイティブな世界にどうやってアクセスしているのか
  • JVM自体がバイトコードをどのように解釈して実行しているのか
  • jpsなどでJavaの情報が収集出来る仕組みがあるがどうやっているのか

今回の調査で扱わないこと

  • GCの詳細
  • コンパイラの詳細

環境など

ホストOSはArch Linuxです。特にバージョンとかはないです。利用しているjavaのバージョンは以下の出力のとおりです。 sdkmanで、18-open を指定してインストールしたものとなっています。

$ java -version 
openjdk version "18" 2022-03-22
OpenJDK Runtime Environment (build 18+36-2087)
OpenJDK 64-Bit Server VM (build 18+36-2087, mixed mode, sharing)

class ファイルについて

classファイルには1つのクラスしか定義できない仕様のようです。内部クラスなどにも対応してクラスファイルを作る必要があり、単一のソースコードから複数のclassファイルが出来ることもあるようです。

This chapter describes the class file format of the Java Virtual Machine.
Each class file contains the definition of a single class, interface, or module.

https://docs.oracle.com/javase/specs/jvms/se18/html/jvms-4.html#jvms-4.1
import java.io.Serializable;
public class TestInnerOuterClass {
    class TestInnerChild{
    }
    Serializable annoymousTest = new Serializable() {
    };
}

上記のようなソースコードをコンパイルすると、javac は複数のファイルを出力します。
具体的には、以下の3つのファイルが出力されます。

'TestInnerOuterClass$1.class'  
'TestInnerOuterClass$TestInnerChild.class'   
TestInnerOuterClass.class   

つまり、内部クラスに関しても1つのクラスとしてclass ファイルが生成されます。
C言語などのコンパイラでは、翻訳単位(1つのc言語ソースファイルとかヘッダー)に対して1オブジェクトファイルが出来ますが、javaでは必ずしもそうでなく1つの翻訳単位に対して複数のファイルが生成されるということですね。また、クラスファイルにある $ はクラス名と内部クラスの名前を分離するためのセパレータ的なもののようです。

javap コマンドを利用すると、classファイルの中身を読むことができます。

javap  TestInnerOuterClass.class
Compiled from "TestInnerOuterClass.java"
public class TestInnerOuterClass {
  java.io.Serializable annoymousTest;
  public TestInnerOuterClass();
}

さらに、-v オプションで詳細を読むことも出来ます。

$ javap -v TestInnerOuterClass.class
Classfile /home/sakura/sandbox/java-test/TestInnerOuterClass.class
  Last modified 2022/07/28; size 473 bytes
  MD5 checksum 9127bb6529da480495471b41a42aedf2
  Compiled from "TestInnerOuterClass.java"
public class TestInnerOuterClass
  minor version: 0
  major version: 62
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // TestInnerOuterClass$1
   #8 = Utf8               TestInnerOuterClass$1
   #9 = Methodref          #7.#10         // TestInnerOuterClass$1."<init>":(LTestInnerOuterClass;)V
  #10 = NameAndType        #5:#11         // "<init>":(LTestInnerOuterClass;)V
  #11 = Utf8               (LTestInnerOuterClass;)V
  #12 = Fieldref           #13.#14        // TestInnerOuterClass.annoymousTest:Ljava/io/Serializable;
  #13 = Class              #15            // TestInnerOuterClass
  #14 = NameAndType        #16:#17        // annoymousTest:Ljava/io/Serializable;
  #15 = Utf8               TestInnerOuterClass
  #16 = Utf8               annoymousTest
  #17 = Utf8               Ljava/io/Serializable;
  #18 = Utf8               Code
  #19 = Utf8               LineNumberTable
  #20 = Utf8               SourceFile
  #21 = Utf8               TestInnerOuterClass.java
  #22 = Utf8               NestMembers
  #23 = Class              #24            // TestInnerOuterClass$TestInnerChild
  #24 = Utf8               TestInnerOuterClass$TestInnerChild
  #25 = Utf8               InnerClasses
  #26 = Utf8               TestInnerChild
{
  java.io.Serializable annoymousTest;
    descriptor: Ljava/io/Serializable;
    flags:

  public TestInnerOuterClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: new           #7                  // class TestInnerOuterClass$1
         8: dup
         9: aload_0
        10: invokespecial #9                  // Method TestInnerOuterClass$1."<init>":(LTestInnerOuterClass;)V
        13: putfield      #12                 // Field annoymousTest:Ljava/io/Serializable;
        16: return
      LineNumberTable:
        line 3: 0
        line 8: 4
}
SourceFile: "TestInnerOuterClass.java"
Error: unknown attribute
  NestMembers: length = 0x6
   00 02 00 17 00 07
InnerClasses:
     #7; //class TestInnerOuterClass$1
     #26= #23 of #13; //TestInnerChild=class TestInnerOuterClass$TestInnerChild of class TestInnerOuterClass

class ファイルに関しては一旦ここまでで、次に気になっているクラスのローダーについて調べていきます。

クラスローダについて

次に、Javaが実行時に class ファイルを読み込む仕組みであるクラスローダについて次は見ていきます。Javaは実行時に読むclassファイルが3種類あるようです。

Bootstrap classes: ランタイムが使うクラス(rt.jar)や、i18n.jarなど。jre/lib/rt.jarjre/lib/ にあるファイル
Installed extensions: <JAVA_HOME>/jre/lib/ext のファイル
The class path: 環境変数やコマンドライン引数などで与えられた、classpathから読みとるクラス


ただし、これらの区分けは参考にした資料が古く実情と合ってない気がするので追加で調査が必要そうです。
参考にしたリンクなどは以下です。

https://qiita.com/ysk24ok/items/57daed08592f7f8d1bea
https://docs.oracle.com/javase/8/docs/technotes/tools/findingclasses.html
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-5.html

jarファイルの扱いについて

classファイルをまとめたものです。zip形式で unzip などで展開できます。
MANIFEST.MFResourceBundle などの情報もまとめることが出来る。以下のように仕様が定められています。

https://docs.oracle.com/javase/7/docs/technotes/guides/jar/jar.html
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ResourceBundle.html


java -classpath foo.jar で読むときと java -jar foo.jar で動作が異なることに注意が必要です。jarファイルを読むと、classpathにjarファイル内部のclassfilesがclasspathに追加されるような動作になるようです。
-jar オプションを使った場合は、MANIFEST.MF などを読みmainの場所などを特定して実行してくれます。また、java -jarの時は、特別な動作をするようです。MANIFEST.MFを読みに行って、そこに書いてあるメタデータを使って諸々の設定を行なうようです。ここに Class-Path といった値を書くことが出来ます。一方で、-jar を利用する場合は -cp が無視されます。.properties ファイルに関する仕組みもこの辺りの仕様を抑えておく必要があります。

jarファイルの作りかた
https://www.ne.jp/asahi/hishidama/home/tech/java/jar.html

独自のクラスローダー

ClassLoaderは自分で作ることも出来て、実行時に自由にクラスを呼んで動作を変更したりといったことも実現出来るようです。URLClassLoader を継承して作るのが良いようですね。
https://docs.oracle.com/javase/jp/18/docs/api/java.base/java/net/URLClassLoader.html

Java標準のクラス達の在処

<JAVA_HOME>/lib の下を確認してみると、jarファイルやclassファイルが無く、soファイルばかりがありました。
いろいろ調べてみると、modules というファイルがあり、これに格納されているようです。jimage extract modulesすることで展開でき、この中に起動時に読み込むclassファイルたちを確認できました。この辺はjdkのバージョンなどによっても違うようです。

パッケージとディレクトリなどについて

Javaだとパッケージの区分けとしてディレクトリを使うことが一般的です。
javac としては特にソースコードがパッケージの名前とディレクトリが一致していなくてもコンパイル出来たりします。(-d を付けるとディレクトリと一致するようにclass ファイルが生成されるようです)

package jp.other;

public class OtherClass {
	static public void hello() {
		System.out.println("Hello, Other world.");
	}
}

このようなソースコードがあった場合、javac OtherClass.java を実行すると

$ javac OtherClass.java 
$ ls -l    
合計 8K
-rw-r--r-- 1 sakura sakura 418  8月  1 13:08 OtherClass.class
-rw-r--r-- 1 sakura sakura 125  8月  1 12:55 OtherClass.java

このようにカレントディレクトリに、class ファイルは生成されます。
一方、-dを付けて実行してみると

$ javac -d target OtherClass.java 
$ tree 
.
├── OtherClass.java
└── target
    └── jp
        └── other
            └── OtherClass.class

3 directories, 2 files

上記のように、パッケージ名に合わせてディレクトリが作られてコンパイル結果が保存されます。

また、依存関係のある複数ファイルのソースコードをコンパイルする場合であっても、javac コマンドに両方のファイルを渡してしまえば上手くコンパイルしてくれます。

class HelloWorld {
	public static void main(String[] args) {
    jp.other.OtherClass.hello(); // 別パッケージのメソッドを呼びだす
		System.out.println("Hello, world.");
	}
}
package jp.other; // 全然違うパッケージを指定
public class OtherClass {
	static public void hello() {
		System.out.println("Hello, Other world.");
	}
}

この2つのファイルを javac HelloWorld.java OtherClass.java としてまとめてコンパイルするとコンパイル出来ます。HelloWorld.java 単体だとコンパイルに失敗します。

しかし、実行を行なう際にはデフォルトのクラスローダは指定したclasspathとパッケージ名のprefixからclass ファイルを見つける動作をします。従って、結局パッケージの名前に合わせたディレクトリを作成し、そこにclasssファイルをコピーするといった作業が必要になります。

このあたりの動作は以下のリンクに少し言及があります。ただ、この動作が必ずJavaの仕様かというとそうでもなさそうです(Typicallyですし)

Typically, a class or interface will be represented using a file in a hierarchical file system, and the name of the class
or interface will be encoded in the pathname of the file to aid in locating it.

https://docs.oracle.com/javase/specs/jvms/se18/html/jvms-5.html#jvms-5.3.1

パッケージ名と全く関係無いディレクトリ構成でソースを管理することは不可能ではなさそうですが、深い理由が無い限りはパッケージ名と一致した形のディレクトリ管理を行なう方が良さそうです。

長くなったので続きは次回に……。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です