JDKの時間計測まわりのコードを読んでみる

ぐぬぬ。。。せっかくのご指名ですが。。。

JVMがOSごとにどの計時関数を呼び出すのかすら、自分はろくに知らないのです…無念だ。

でも、「ネタを振られたら全力で撃ち返せ」ってじっちゃが言ってた。

というわけで、最適化よりもずっと手前の話題、JVMの時間取得まわりのコードを眺めてみようと思います。

Systemクラスのソースコードを見ると

public static native long currentTimeMillis();

と、native宣言されている。ここから先はネイティブの世界。VM実装依存の世界でもある。

Javaパフォーマンス計測 そんなタイマーで大丈夫か? - プログラマーの脳みそ

そですね。では、その世界を確認してみましょう。

ゴール

Javaで時間計測を行った時に各OSで最終的に呼ばれるAPIとその精度について、JDKソースコードおよびドキュメントを元に把握する。

合言葉は、"それどこコード? それどこコードよーー"

プラットフォームで共通のコード

まずは入り口です。上でちょろっと引用している、java.lang.Systemですね。

    /* First thing---register the natives */
    private static native void registerNatives();
    static {
        registerNatives();
    }
    
    // (中略)
    
    public static native long currentTimeMillis();

    // (中略)

    public static native long nanoTime();

時間を取得するcurrentTimeMillisとnanoTimeが、ネイティブメソッドとして宣言されています。

Systemクラスでは、ネイティブメソッドのロードとリンクは、staticイニシャライザでregisterNatives関数を呼ぶことによって行われています。

Javaアプリケーションは、ネイティブメソッドを実行する際に、(1)ネイティブメソッドの実装を含むネイティブのライブラリをロードし、(2)ネイティブメソッドの実装をリンクしますが、すでに読み込まれたネイティブライブラリから必要なネイティブメソッドを探す代わりに、手動でリンクすることもできます。ここではそうしているわけですね。

次に、ネイティブコード側の該当箇所を確認します。ここから先で引用するソースコードは、jdk-6u23に含まれています。

j2se/src/share/native/java/lang/System.c

#define OBJ "Ljava/lang/Object;"

/* Only register the performance-critical methods */
static JNINativeMethod methods[] = {
    {"currentTimeMillis", "()J",              (void *)&JVM_CurrentTimeMillis},
    {"nanoTime",          "()J",              (void *)&JVM_NanoTime},
    {"arraycopy",     "(" OBJ "I" OBJ "II)V", (void *)&JVM_ArrayCopy},
};

#undef OBJ

JNIEXPORT void JNICALL
Java_java_lang_System_registerNatives(JNIEnv *env, jclass cls)
{
    (*env)->RegisterNatives(env, cls,
			    methods, sizeof(methods)/sizeof(methods[0]));
}

ありました。先ほどの2つのメソッドは、JVM_CurrentTimeMillisやJVM_NanoTimeという関数に対応するようです。JVMのコードから、これらの関数定義を探せばよいことが分かります。

「()J」は、JVMのタイプシグネチャです。括弧の中に何も書かれてないということは、引数がないことを表しています。Jは、Javaのデータ型のlongに対応し、戻り値がlongであることを表しています。このあたりはJNIの仕様に書いてあります。

さて、これらの関数定義が書かれているヘッダファイルを特定します。

j2se/src/share/javavm/export/jvm.h

/*
 * java.lang.System
 */
JNIEXPORT jlong JNICALL
JVM_CurrentTimeMillis(JNIEnv *env, jclass ignored);

JNIEXPORT jlong JNICALL
JVM_NanoTime(JNIEnv *env, jclass ignored);

ネイティブライブラリのための関数定義です。

1つ目の引数はインタフェースポインタ、2つ目の引数はオブジェクトへのポインタです。もし3つ目以降の引数があれば、それはJava側のメソッドで渡される引数です。今見ているメソッドには存在しません。

ここまでのコードは、実行環境のプラットフォームによらず、共通して使われるコードです。しかし、時間を取得するには、もちろんCPUに依存したシステム関数が使用されます。そのあたりの紐付けを知りたい。というわけで、grepと気合いで見つけます。

hotspot/src/share/vm/prims/jvm.cpp

JVM_LEAF(jlong, JVM_CurrentTimeMillis(JNIEnv *env, jclass ignored))
  JVMWrapper("JVM_CurrentTimeMillis");
  return os::javaTimeMillis();
JVM_END

JVM_LEAF(jlong, JVM_NanoTime(JNIEnv *env, jclass ignored))
  JVMWrapper("JVM_NanoTime");
  return os::javaTimeNanos();
JVM_END

おそらくこれでしょうか。JVM_LEAFとかJVM_ENDとかいう構文がよく分からないため、ちょっと自信がないのですが(なんですかコレ? マクロ?)、仮にこれであれば、各OSごとに定義されたos::javaTimeMillis()とos::javaTimeNanos()を確認すればよいことになります。

というわけで、OSごとに実装を見ていきます。

hotspot
 └src
   ├closed
   ├cpu
   ├os
   │ ├linux  ★
   │ ├solaris ★
   │ └windows ★
   │
   ├os_cpu
   └share ☆

ここまでは、☆マークのついたディレクトリに格納されているコードを読んでいました。ここから、★マークのついたディレクトリを1つずつ見ます。

Windowsの場合

javaTimeMillis

hotspot/src/os/windows/vm/os_windows.cpp

jlong os::javaTimeMillis() {
  if (UseFakeTimers) {
    return fake_time++;
  } else {
    FILETIME wt;
    GetSystemTimeAsFileTime(&wt);
    return windows_to_java_time(wt);
  }
}

UseFakeTimersは、Java6の-XXオプションです。developフラグがついてます。明示的に与えない限りは、デフォルトのfalseが入り、処理はelseブロックを通るでしょう。

GetSystemTimeAsFileTimeは、Windowsのシステム関数です。UTC形式で現在の日付とシステム時刻を取得します。

戻り値のところで呼び出されているwindows_to_java_time関数は、同じファイルに定義されています。

jlong windows_to_java_time(FILETIME wt) {
  jlong a = jlong_from(wt.dwHighDateTime, wt.dwLowDateTime);
  return (a - offset()) / 10000;
}

jlong_from関数(j2se/src/windows/native/com/sun/management/OperatingSystem_md.cで定義)を使ってビット演算と論理演算で得た値から、offset関数が表す1970年1月1日という紀元の値を減じています。

分からないところもあるけれど、大体そんな感じです。

javaTimeNanos

同じく、hotspot/src/os/windows/vm/os_windows.cppです。

#define NANOS_PER_SEC         CONST64(1000000000)
#define NANOS_PER_MILLISEC    1000000
jlong os::javaTimeNanos() {
  if (!has_performance_count) {
    return javaTimeMillis() * NANOS_PER_MILLISEC; // the best we can do.
  } else {
    LARGE_INTEGER current_count;
    QueryPerformanceCounter(&current_count);
    double current = as_long(current_count);
    double freq = performance_frequency;
    jlong time = (jlong)((current/freq) * NANOS_PER_SEC);
    return time;
  }
}

has_performance_countは、ファイル内で宣言されたstatic変数で、0で初期化されています。パフォーマンスカウンタの初期化関数(initialize_performance_counter)で、QueryPerformanceFrequencyの呼び出しに成功すれば、1が代入されます。

QueryPerformanceFrequencyがない場合は、先ほど追いかけたjavaTimeMillis関数を呼び出し、単純に100万を乗じて戻り値を作成しています。これが、いわゆる「System.nanoTime()を使用しても、プラットフォームが対応していなければミリ秒の精度の値しか取れない」というやつですね。

QueryPerformanceCounterは、「インストール先のハードウェアが高分解能パフォーマンスカウンタをサポートしている場合、0 以外の値が返」ります。

手元のマシンがWindowsなので、ついでにこれらが含まれるかを確認してみます。WinBase.hの中身を確認します。

//
// Performance counter API's
//

WINBASEAPI
BOOL
WINAPI
QueryPerformanceCounter(
    __out LARGE_INTEGER *lpPerformanceCount
    );

WINBASEAPI
BOOL
WINAPI
QueryPerformanceFrequency(
    __out LARGE_INTEGER *lpFrequency
    );

ライブラリも念のため確認します。

>dumpbin /EXPORTS kernel32.lib

# 確認方法はorangecloverさんに教えてもらいました。(DUMPBIN Reference | Microsoft Docs

kernel32.lib

Microsoft (R) COFF/PE Dumper Version 10.00.30319.01
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file kernel32.lib

File Type: LIBRARY

     Exports

       ordinal    name
(略)
                  _QueryPerformanceCounter@4
                  _QueryPerformanceFrequency@4
(略)

ありますね。

QueryPerformanceCounterの分解能はシステムによって異なります。QueryPerformanceCounterで取得した2つの値の差をQueryPerformanceFrequencyで割って、経過時間を特定します。この方法によって、そのシステムで最も分解能の高い時間を計測することはできますが、ナノ秒精度の区間時間を取得できるかは、やはりシステム次第なんでしょうか。

Microsoftのサポートページには、QueryPerformanceCounterを2回呼び出して、API呼び出しにかかるオーバヘッドを算出し、計測時間から差し引くというサンプルコードが提示されています。

上記の場合は、API を呼び出すだけのオーバーヘッドが約 19 マイクロ秒です。他のコードの時間を計測するときは、次のようにこのオーバーヘッドを差し引く必要があります。

Acquiring high-resolution time stamps - Windows applications | Microsoft Docs

Linuxの場合

javaTimeMillis

hotspot/src/os/linux/vm/os_linux.cpp

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "linux error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
}

gettimeofday関数が使われています。秒単位の経過時間を表すtv_secに、tv_secからのマイクロ秒単位の経過時間を表すtv_usecを足して、現在時刻として返しています。

このとき得られる時刻の精度は、マイクロ秒です。

javaTimeNanos

同じくhotspot/src/os/linux/vm/os_linux.cppのコードです。

jlong os::javaTimeNanos() {
  if (Linux::supports_monotonic_clock()) {
    struct timespec tp;
    int status = Linux::clock_gettime(CLOCK_MONOTONIC, &tp);
    assert(status == 0, "gettime error");
    jlong result = jlong(tp.tv_sec) * (1000 * 1000 * 1000) + jlong(tp.tv_nsec);
    return result;
  } else {
    timeval time;
    int status = gettimeofday(&time, NULL);
    assert(status != -1, "linux error");
    jlong usecs = jlong(time.tv_sec) * (1000 * 1000) + jlong(time.tv_usec);
    return 1000 * usecs;
  }
}

supports_monotonic_clockの結果が真である場合には、clock_gettimeが使われます。戻り値は、秒とナノ秒の合算です。

clock_gettimeの1つ目の引数(CLOCK_MONOTONIC)は、クロックを指定しています。次のページによると、この場合、取得できる時間の精度はCPUのHZに依存するようです。

HZとは、linux/jiffies.hで定義されるカーネル定数で、タイマー割り込みの時間間隔を表します。マニュアルによると、カーネルのバージョンによって、精度が異なるようです。

ソフトウェアクロック, HZ, Jiffy

多くのシステムコールとタイムスタンプの精度は、 ソフトウェアクロックの分解能に制限される。 ソフトウェアクロックはカーネルによって管理され、 時間を jiffy 単位で計る。 jiffy の大きさはカーネル定数 HZ の値で決定される。 HZ の値はカーネルのバージョンとハードウェアプラットフォームで異なる。 x86 の場合は以下の通りである: 2.4.x とそれより前のカーネルでは、HZ は 100 であったので、 jiffy の値は 0.01 秒になっていた。 2.6.0 以降では、HZ は 1000 に増やされたので、jiffy の値は 0.001 秒である。 カーネル 2.6.13 以降では、HZ の値はカーネル設定パラメータになり、 100, 250 (デフォルト), 1000 という値にできる。 それぞれ jiffy の値は 0.01, 0.004, 0.001 秒になる。 カーネル 2.6.20 以降では、300 も利用できるようになっている。 300 は一般的な映像フレームレートの公倍数である (PAL, 25HZ; NTSC, 30HZ)。

Linux Certif - Man time(7)

supports_monotonic_clockの結果が偽である場合には、gettimeofday関数が使われます。戻り値は、秒とマイクロ秒の合算です。ナノ秒の精度は取得できません。

ちなみに、supports_monotonic_clockは、os_linux.hppの中で定義されています。

  static bool supports_monotonic_clock() {
    return _clock_gettime != NULL;
  }

_clock_gettimeには、os_linux.cpp内のos::Linux::clock_initの中で、clock_gettime_funcという変数の値が代入されます。

void os::Linux::clock_init() {
  // (略)
  if (handle) {
    // (略)
    if (clock_getres_func && clock_gettime_func) {
      struct timespec res;
      struct timespec tp;
      if (clock_getres_func (CLOCK_MONOTONIC, &res) == 0 &&
          clock_gettime_func(CLOCK_MONOTONIC, &tp)  == 0) {
        // yes, monotonic clock is supported
        _clock_gettime = clock_gettime_func;
      } else {
      // (後略)

MONOTONICクロックがサポートされていたら値が入ります。

Solarisの場合

javaTimeMillis

hotspot/src/os/solaris/vm/os_solaris.cpp

// Must return millis since Jan 1 1970 for JVM_CurrentTimeMillis
jlong os::javaTimeMillis() {
  timeval t;
  if (gettimeofday( &t, NULL) == -1)
    fatal(err_msg("os::javaTimeMillis: gettimeofday (%s)", strerror(errno)));
  return jlong(t.tv_sec) * 1000  +  jlong(t.tv_usec) / 1000;
}

システム時刻は、gettimeofday関数を使用して取得されます。timeval構造体のtv_usecが使われているとおり、ここでの精度はマイクロ秒です。

javaTimeNanos

同じくhotspot/src/os/solaris/vm/os_solaris.cppのコードです。

jlong os::javaTimeNanos() {
  return (jlong)getTimeNanos();
}

getTimeNanos関数も、同じファイルにあります。

inline hrtime_t getTimeNanos() {
  if (VM_Version::supports_cx8()) {
    const hrtime_t now = gethrtime();
    // Use atomic long load since 32-bit x86 uses 2 registers to keep long.
    const hrtime_t prev = Atomic::load((volatile jlong*)&max_hrtime);
    if (now <= prev)  return prev;   // same or retrograde time;
    const hrtime_t obsv = Atomic::cmpxchg(now, (volatile jlong*)&max_hrtime, prev);
    assert(obsv >= prev, "invariant");   // Monotonicity
    // (中略)
    return (prev == obsv) ? now : obsv ;
  } else {
    return oldgetTimeNanos();
  }
}

gethrtimeというシステムコール関数が呼ばれています。

gethrtime関数は、hrestimeというカーネル変数を読み込みます(high-resolution time変数=高解像度の時間)。hrestimeには、プロセッサのクロックごとにインクリメントされるレジスタの値をクロック割り込みハンドラによって読み込み、ナノ秒単位時間に変換したものが入っているそうです(ただし、UltraSPARCベースのシステムの場合)。

gethrtimeは、高速システムコールとして実装されています。カーネルモードになる前に状態保存を行わず、レジスタやメモリ領域を読み込んだら即座にユーザモードに復帰するので、通常のシステムコール関数よりも呼出し時間が短くなります。参照したマニュアルには、Solaris7、300MHHzのマシンで計測した場合の呼び出し時間として、320ナノ秒、413ナノ秒などと書かれていました。

さて、分岐して呼び出されているoldgetTimeNanos関数も、同じファイルにあります。

inline hrtime_t oldgetTimeNanos() {
  int gotlock = LOCK_INVALID;
  hrtime_t newtime = gethrtime();

  for (;;) {
// grab lock for max_hrtime
    int curlock = max_hrtime_lock;
    if (curlock & LOCK_BUSY)  continue;
    if (gotlock = Atomic::cmpxchg(LOCK_BUSY, &max_hrtime_lock, LOCK_FREE) != LOCK_FREE) continue;
    if (newtime > max_hrtime) {
      max_hrtime = newtime;
    } else {
      newtime = max_hrtime;
    }
    // release lock
    max_hrtime_lock = LOCK_FREE;
    return newtime;
  }
}

ここでも、結局使用されるシステム関数はgethrtimeです。

ではなぜ処理が分かれているのかというと、gethrtimeがシングルCPUから値を読むときと、複数の異なるCPUから値を読むときとで、Solarisのバージョンによって動作が異なるためのようです。「CAS on 64bit jlong」がサポートされていないシステムでは、oldgetTimeNanos関数が使われる、とコメントにありました。CAS(Compare-And-Swap)の実装については、面白そうなことがコメントに長々と書かれているのですが、今回はスルーします。

Solarisカーネルナノ秒を扱える高解像度タイマを機構として持っている、実際の精度はCPU依存、ということですね。

まとめ

というわけで、JDK-6u23が時間計測に使用しているプラットフォーム依存の関数をまとめると、次のようになります。

Windows Linux Solaris
カレンダー時間 GetSystemTimeAsFileTime gettimeofday gettimeofday
CPU時間 (※1)QueryPerformanceCounter (※2)GetSystemTimeAsFileTime (※3)clock_gettime (※4)gettimeofday gethrtime

(※1)QueryPerformanceFrequencyがサポートされるとき
(※2)QueryPerformanceFrequencyがサポートされないとき
(※3)MONOTONICクロックがサポートされるとき
(※4)MONOTONICクロックがサポートされないとき

これでやっと、なぎせさんのパフォーマンス計測連載第1回の話についていく準備ができたかも?って感じですネ。

じつは、次のページにまとめられている情報をコード上で確かめたいという動機があったのですが、前提知識が足りないせいで周辺のことも色々調べる羽目になり、勉強になりました。ひでぶ