Java: currentTimeMillis() と nanoTime() は混ぜると危険

前回書いた、ScheduledThreadPoolExecutor だとズレが生じる現象についてだけど、どうやら時間の精度が問題だったらしい。
java.lang.System には、現在日時をミリ秒単位で取得する currentTimeMillis() と、ナノ秒単位で取得する nanoTime() があるけど、この 2 つは単位が違うだけじゃなくて、取得元が違うために一致しないことがあるらしい。

結論

いまの時点でわかってることを 3 行でまとめると、

Windows で時刻同期を行うと、currentTimeMillis() と nanoTime() の結果は一致しなくなる
時刻同期以外でもズレることがあるかもしれないけど不明
他の OS でどうなるかも不明

以下おおざっぱな説明。

取得元の違い

それぞれのメソッドがどうやって現在日時を取得しているかについては、下記のページにわかりやすくまとめられている。Windows の場合、currentTimeMillis() は Win32 API の GetSystemTimeAsFileTime を使用していて、nanoTime() は (利用可能であれば) QueryPerformanceCounter を使用しているという。

時間を計測する
http://www.02.246.ne.jp/~torutk/javahow2/time.html

じゃぁ Windows がどうやって現在日時を取得しているか、ということになるんだけど、そもそもこの 2 つの Win32 API 関数が返す時間は、一致しなくても当たり前らしい。

Windows が時刻を管理する仕組み

コンピュータのハードウェアの中には時計 (リアルタイムクロック。略して RTC) が入ってて、問い合わせると現在日時を教えてくれるんだけど、必要になるたびに聞きにいくと大変なので、Windows では起動後に 1 回現在日時を取得したら、後は RTC を見ない。その代わりに、一定時間ごとにハードウェアからクロック割り込みが行われるので、その度に Windows が覚えている時刻をカウントアップしていく。
例えば、起動したのが ある日の 0:00:00 丁度で、以降 1 秒ごとにクロック割り込みが行われるとすると、Windows は起動直後に RTC を 1 度だけ見て、あとはタイマ割り込みが行われる度に心の中で「0:00:01、0:00:02、0:00:03……」とカウントアップしていくわけだ。実際には、だいたい 10 or 16 ミリ秒間隔で割り込まれるので、カウントアップする単位もその単位になる。
Win32 API の QueryPerformanceCounter が返すのはクロック割り込みの回数。1 秒間に何回割り込まれるかを取得するための QueryPerformanceFrequency という API もあるので、組み合わせると「起動してからどれだけ時間が進んだか」を知ることができる。Java でいうと nanoTime() が返す値がこっち。一方、同じ Win32 API でも GetSystemTimeAsFileTime (が内部で呼び出している GetSystemTime) は、カウントアップした結果の現在日時を返す。Java でいうと currentTimeMillis() はこっち。
……なんだけど、実際にはもうちょっとルールが細かくて、その結果、QueryPerformanceCounter を使って経過時間を計算した場合と、GetSystemTimeAsFileTime で経過時間を計算した場合で、ズレが生じてしまう。

  • Windows には、時間の進み方を自動調整する機能がある。例えば、10 ミリ秒間隔でタイマー割り込みが行われるなら内部で保持している現在日時にも 10 ミリ秒を加算すればいいはずなんだけど、「10 ミリ秒間隔でタイマー割り込みが発生するけど、Windows が保持している現在日時には 11 ミリ秒を加算する」みたいな設定ができるようになっている。例えば、Windows Time サービスを使って時刻同期を行った場合に、この機能が使われることがある。すると、クロック割り込みの回数×間隔=経過時間という式が成り立たなくなる。この機能が有効になっているかどうかは、Win32 API の GetSystemTimeAdjustment で調べることができる。
  • Windows は、1 時間に 1 回 RTC を見て答え合わせをするらしい。Windows 側で保持している日時が 3 分以上ズレている場合、RTC に合わせる。このとき割り込み回数のカウントは特に補正されないので、RTC に合わせた分だけ QueryPerformanceCounter と GetSystemTimeAsFileTime がズレることになる。
  • CPU が複数載ってて、ドライバにバグがある場合、CPU 間でうまく同期がとれず、QueryPerformanceCounter の返す値がおかしくなることがあるらしい。

Java に戻って……

currentTimeMillis() は Windows が内部で保持してカウントアップしている方の現在日時なので、自動調整が加味されている。一方、nanoTime() は、QueryPerformanceCounter を使っているので、単純に「タイマ割り込みの回数に基づく経過時間」を返し、自動調整が加味されない。java.util.Timer は currentTimeMillis() を使っていて、java.util.concurrent.ScheduledThreadPoolExecutor は nanoTime() を使っているので、Windows で自動調整が有効になっていると、両者の「時間」はズレてしまうことになる。
前回の記事で、自宅の Vista 環境ではズレが見られなかったと書いたけど、「日付と時刻のプロパティ」から時刻同期を実行してから再度試したところ、ズレが発生することを確認できた。Win32 API の GetSystemTimeAdjustment を呼んでみたところ、時刻同期を行う前は自動調整が無効だったのに、行った後は自動調整が有効になっていたので、どうも犯人はこれだったっぽい。
1 時間ごとの答え合わせの方はまだ試してないのでわからないけど、やっぱりおかしくなるのかな。その場合は、java.util.Timer の動作がおかしくなりそう。