Почему планировщик Java демонстрирует значительный временной сдвиг в Windows?

У меня есть служба Java, работающая в Windows 7, которая запускается один раз в день на SingleThreadScheduledExecutor. Я никогда не давал это много, хотя это и не критично, но недавно посмотрел на цифры и увидел, что сервис дрейфовал примерно 15 минут в день, что звучит слишком много, так что откопал.

Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
   long drift = (System.currentTimeMillis() - lastTimeStamp - seconds * 1000);
   lastTimeStamp = System.currentTimeMillis();
}, 0, 10, TimeUnit.SECONDS);

Этот метод довольно последовательно дрейфует +110ms за каждые 10 секунд. Если я запускаю его с интервалом в 1 секунду, дрейф усредняется +11ms.

Интересно, что если я сделаю то же самое для Timer() значения будут вполне соответствовать среднему дрейфу меньше, чем полная миллисекунда.

new Timer().schedule(new TimerTask() {
    @Override
    public void run() {
        long drift = (System.currentTimeMillis() - lastTimeStamp - seconds * 1000);
        lastTimeStamp = System.currentTimeMillis();
    }
}, 0, seconds * 1000);

Linux: не дрейфует (ни с Executor, ни с Timer)
Windows: с Executor сходит с ума, а с Timer - нет

Протестировано с Java8 и Java11.

Интересно, что если вы допустите дрейф 11 мс в секунду, вы получите дрейф 950400 мс в день, что составляет 15.84 minutes в день. Так что это довольно последовательно.

Вопрос в том, почему?
Почему это происходит с SingleThreadExecutor, но не с таймером.

Обновление 1: после комментария Slaw я пробовал на нескольких разных аппаратных средствах. Я обнаружил, что эта проблема не проявляется ни на одном персональном оборудовании. Только на компанию одну. На оборудовании компании это также проявляется на Win10, хотя на порядок меньше.

Ответ 1

Как указано в комментариях, ScheduledThreadPoolExecutor основывает свои вычисления на System.nanoTime(). Что бы там ни было, старый API Timer предшествовал nanoTime() и поэтому использует System.currentTimeMillis().

Разница здесь может показаться незначительной, но она более значительна, чем можно было ожидать. Вопреки распространенному мнению, nanoTime() - это не просто "более точная версия" currentTimeMillis(). Миллис привязан к системному времени, а нанос - нет. Или, как сказано в документации:

Этот метод может использоваться только для измерения прошедшего времени и не связан с каким-либо другим понятием системного или настенного времени. [...] Значения, возвращаемые этим методом, становятся значимыми, только когда вычисляется разница между двумя такими значениями, полученными в одном и том же экземпляре виртуальной машины Java.

В вашем примере вы не следуете этим указаниям, чтобы значения были "значимыми" - понятно, потому что ScheduledThreadPoolExecutor использует только nanoTime() в качестве детали реализации. Но конечный результат тот же, что вы не можете гарантировать, что он будет синхронизирован с системными часами.

Но почему нет? Секунды - это секунды, верно, поэтому они должны оставаться синхронизированными с определенной известной точки?

Ну по идее да. Но на практике, вероятно, нет.

Взгляните на соответствующий нативный код на окнах:

LARGE_INTEGER current_count;
QueryPerformanceCounter(&current_count);
double current = as_long(current_count);
double freq = performance_frequency;
jlong time = (jlong)((current/freq) * NANOSECS_PER_SEC);
return time;

Мы видим, что nanos() использует API QueryPerformanceCounter, который работает с помощью QueryPerformanceCounter получая "тики" частоты, определенной QueryPerformanceFrequency. Эта частота будет оставаться идентичной, но таймер, на котором она основана, и его алгоритм синхронизации, который использует Windows, различаются в зависимости от конфигурации, ОС и аппаратного обеспечения. Даже игнорируя вышеупомянутое, он никогда не будет близок к 100% точности (он основан на достаточно дешевом кварцевом генераторе где-то на плате, а не на стандарте времени Цезия!), Поэтому он будет дрейфовать вместе с системным временем, поскольку NTP сохраняет его синхронно с реальностью.

В частности, эта ссылка дает некоторую полезную информацию и усиливает вышеприведенный вывод:

Если вам нужны метки времени с разрешением 1 микросекунда или более, и вам не нужно синхронизировать метки времени с внешним эталоном времени, выберите QueryPerformanceCounter.

(Болдинг мой.)

Для вашего конкретного случая плохой работы Windows 7 обратите внимание, что в Windows 8+ алгоритм синхронизации TSC был улучшен, и QueryPerformanceCounter всегда основывался на TSC (в QueryPerformanceCounter от Windows 7, где это может быть TSC, HPET или Таймер ACPI PM - последний из которых особенно неточен. Я подозреваю, что это наиболее вероятная причина того, что ситуация в Windows 10 значительно улучшается.

При этом вышеперечисленные факторы по-прежнему означают, что вы не можете полагаться на ScheduledThreadPoolExecutor чтобы идти в ногу с "реальным" временем - оно всегда будет дрейфовать. Если этот дрейф является проблемой, то это не решение, на которое вы можете положиться в этом контексте.

Примечание: в Windows 8+ есть функция GetSystemTimePreciseAsFileTime которая предлагает высокое разрешение QueryPerformanceCounter сочетании с точностью системного времени. Если бы Windows 7 была отброшена как поддерживаемая платформа, теоретически это можно было бы использовать для предоставления метода System.getCurrentTimeNanos() или аналогичного, предполагая, что существуют другие аналогичные собственные функции для других поддерживаемых платформ.