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

投稿者: | 2022年8月4日

JNIの仕組みについて

前回でJVMのプロセス情報がファイルシステム上に格納されていて、それを解析することでJpsは色々情報を出力していることが分かりました。これでJVMのプロセス情報はどこを見れば良いのかというのが分かりました。

今回は、JVMがglibcなどのネイティブなライブラリを呼び出す仕組みについて調査していこうと思います。

JNIとは?

JNIは、Java Native Interfaceの略です。これは、JavaからCなどのネイティブコードを呼び出すための仕様、ネイティブ側からJavaを呼び出す際(JNIEnvとして渡ってくる引数)の仕様となっています。Oracleの文書はこちらです。Wikipediaの文書も親切で良い感じでした。

また、同種のJavaのネイティブコードを呼ぶ仕組みとしては、JNAというものもあります。
この技術は、libffiを利用してネイティブなコードをJavaから呼び出す技術になっています。これはJavaのコードを準備するだけで手軽にネイティブコードを呼び出すことが出来て便利そうです。ただ、 com.sun.java というパッケージにあるので昔は標準機能だったようですが、今は適宜インストールして使う形式になっているようです。

さらに、panamaというプロジェクトがあってネイティブコードの呼び出しを簡単にしようとしているようです。

いろいろネイティブなコードを呼び出す手段はあるようですが、基本的にJVMの内部でのネイティブコードの呼び出しはJNIを利用しているようです。なので、今回はJNIを利用した関数呼び出しがJVM上でどのように実現されているかを見ていきます。

ライブラリの読み込み

まず、呼び出す相手となる外部のライブラリ(.so)を読み込むところから見ていきます。
System.loadLibrary 関数で読むようです。ここから読んで行きます。

JNIを使っているところを除いたら順番に読んでいくことで、実際のネイティブコードまで辿りつくことが出来ました。

ネイティブコードの実行の流れ

実際にJNIを実行してみる

ここまでで、DLLのロードするまでの流れは把握できました。次は実際に関数を呼ぶところを見ていきましょう。
せっかくなので、実際に自分でJNIを実行してみましょう。

こういうJavaのコードを書いて、private native void hello(); という部分がnative関数ということですね。

public class JniJikken {
  private native void hello();
	public static void main(String[] args) {
		System.loadLibrary("JniJikken");

		System.out.println("Hello, Java World!");
		JniJikken me = new JniJikken();
		me.hello();
	}
}

次に、Cのソースコードを書いていきます。
javac -h ./ JniJikken でヘッダファイルは生成することが出来ます。

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class JniJikken */

#ifndef _Included_JniJikken
#define _Included_JniJikken
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     JniJikken
 * Method:    hello
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_JniJikken_hello
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

実装は、以下のような感じです。Hello, C World と出力する関数を実装してみます。

#include "stdio.h"
#include "JniJikken.h"

JNIEXPORT
void JNICALL Java_JniJikken_hello
  (JNIEnv *env, jobject me)
{
  printf("Hello, C World\n");
}

実行は以下のような感じで実行します。

$ javac -h ./ JniJikken.java  
$gcc -fPIC -shared -I ~/.sdkman/candidates/java/current/include -I ~/.sdkman/candidates/java/current/include/linux JniJikken.c -o libJniJikken.so
$ java -Djava.library.path=./CHeader JniJikken
Hello, Java World!
Hello, C World

無事実行できました。なんとなくJNIのやりかたは分かりました。javac -h を使ってヘッダが自動で作られるのは驚きでした。同時にJVMでもC言語側のヘッダはされていて名前などに一定のルールがありそうですね。

JVMのコード上でJNIに関係がある部分

JVMがJavaコードを実際に実行しはじめる、JavaMain から順番に読んでいくのは道のりが遠すぎるので、今回は関係がある箇所を何箇所か把握しておくに留めることにします。

  • ライブラリから関数を見つけている箇所
  • ネイティブの関数を実行するためのエントリ関数

ライブラリから関数を見つけている箇所

  • 以下の関数が、ネイティブな関数のための事前準備を行なっている関数です。
  • そして、NativeLookup::lookup で関数を探しにいきます
  • つぎに、NativeLookup::lookup_base を実行します
  • NativeLookup::lookup_entry を実行して関数を探しはじめます
    • /src/hotspot/share/prims/nativeLookup.cpp#L317
    • JNI short style、JNI long style、JNI short style without os prefix/suffix、JNI long style without os prefix/suffix
    • この4種類の名前で関数を探しに行きます。prefixとかsuffixはWindows環境だと変化があるようです。
    • JNIを自分で試した時に自動で生成された名前はこれを意識したもののようですね。
  • NativeLookup::lookup_styleos::dll_lookup などが実行されるようです
    • まず、システムクラスだと、lookup_special_native で専用の検索関数が準備されているようです
    • それで見つからない場合は、os::dll_lookup で探しにいくようです
    • 通常のユーザのクラスの場合は、ClassLoader のインスタンスを取得して、Java側のコードを呼び出して探すようです
      • vmClasses::ClassLoader_klass などはマクロで色々実現されていますが、java_lang_ClassLoader を取得するコードに展開されます
      • vmSymbols::classloader_string_long_signature()(Ljava/lang/ClassLoader;Ljava/lang/String;)J になります
      • vmSymbols::findNative_name()findName になります
      • JavaCalls::call_static はここでは、ClassLoader.findName()を実行することになります。
    • ここまでで見つからなければ、os::dll_lookup を使って探しにいきます
  • os::dll_lookup の実装は dlsym を呼ぶだけになっています
  • この結果を methodHandleset_native_function を使って このMethodオブジェクトに対するエントリポイントとして指定します
  • ここまでで、ネイティブ関数のポインタが取得できています、最後に SignatureHandler というABIなどを良い感じに調整してくれるハンドラーの登録を実施する必要があって以下の関数で実施します

ネイティブの関数を実行するためのエントリ関数

ここまでで、Java側の Method オブジェクトに関数ポインタなどの設定は完了しています。
ここでは、それを実際に呼び出すための準備のコードを見ていこうと思います。

以下のコードがネイティブの関数を実行する際に呼びだされる関数となります。
/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp#L791
完全にアセンブラですね。正直読むのは辛いので精読はしません。

ざっと見てみると、signature handlerが呼ばれていたり
/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp#L940
call が呼ばれているので、ここで実際に関数が呼びだされるのでしょう
/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp#L966
この辺りで返り値を処理するための、return handlerに対する処理もありそうです
/src/hotspot/cpu/x86/templateInterpreterGenerator_x86.cpp#L970

なお、上記の関数と実際の関数は、InstanceKlass::link_class_impl -> InstanceKlass::link_methods -> Method::link_method という流れで登録されるようです。
InstanceKlass::link_class_impl はここから辿っていけます。
/src/hotspot/share/oops/instanceKlass.cpp#L787

また、Interpreter::entry_for_method を経由して、Method::link_method の中で代入が行なわれています。
これは、Interpreterを作成する際に構築されます。

TemplateInterpreterGenerator::generate_all という関数の
/src/hotspot/share/interpreter/templateInterpreterGenerator.cpp#L56
このあたりで登録されます
/src/hotspot/share/interpreter/templateInterpreterGenerator.cpp#L211

まとめ

なんとなく、JNIの仕組みが分かった気がしました。
ここまで読みすすめてくると、最初の記事に書いていた分かってないうちのいくつかは分かった気がします。

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

このうち以下の2つはまぁある程度理解できたと思います。

  • バイトコードからシステムコールなどのネイティブな世界にどうやってアクセスしているのか
  • jpsなどでJavaの情報が収集出来る仕組みがあるがどうやっているのか

残りの2つ

  • classpathの取り扱い
  • JVM自体がバイトコードをどのように解釈して実行しているのか

このあたりは、これからという感じですね。classpathの取り扱いを理解するには、ClassLoaderの動作などを眺めていく必要がありそうです。
また、JVMがバイトコードを解釈する仕組みの一端は今回のJNIの中で出てきたTemplateInterpreterなどが中心的な存在のようでした。が、その詳細までは追えていません。今後読みすすめていけばもう少しは理解できてくると思います。

参考になったサイト

以下のサイトがとても参考になりました。
http://hsmemo.github.io/articles/noNisy_uNv.html

コメントを残す

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