У меня есть привычка раз в несколько недель вкратце просматривать лог коммитов OpenJDK. Многие коммиты слишком сложны для того, чтобы я мог разобраться с ними за то ограниченное время, которое я выделил для своего... специфичного хобби. Но иногда мне удаётся найти нечто любопытное.
На прошлой неделе моё внимание привлёк этот коммит:
858d2e434dd 8372584: [Linux]: Замена чтения proc для получения CPUtime потока на clock_gettime
diffstat выглядел интересно: +96 вставок, -54 удалений. В changeset был добавлен бенчмарк JMH из 55 строк, что означало реальное уменьшение кода продакшена.
Удалённый код
Вот, что удалили из os_linux.cpp:
static jlong user_thread_cpu_time(Thread *thread) {
pid_t tid = thread->osthread()->thread_id();
char *s;
char stat[2048];
size_t statlen;
char proc_name[64];
int count;
long sys_time, user_time;
char cdummy;
int idummy;
long ldummy;
FILE *fp;
os::snprintf_checked(proc_name, 64, "/proc/self/task/%d/stat", tid);
fp = os::fopen(proc_name, "r");
if (fp == nullptr) return -1;
statlen = fread(stat, 1, 2047, fp);
stat[statlen] = '\0';
fclose(fp);
// Пропускаем pid и строку команд. Следует понимать, что мы можем иметь дело
// со странными именами команд, например, пользователь может переименовать java launcher
// в "java 1.4.2 :)", тогда файл stat будет выглядеть так:
// 1234 (java 1.4.2 :)) R ... ...
// На самом деле, нам не нужно знать строку команд, достаточно найти последнее
// вхождение ")" и начать парсинг отсюда. См. баг 4726580.
s = strrchr(stat, ')');
if (s == nullptr) return -1;
// Пропускаем пустые символы
do { s++; } while (s && isspace((unsigned char) *s));
count = sscanf(s,"%c %d %d %d %d %d %lu %lu %lu %lu %lu %lu %lu",
&cdummy, &idummy, &idummy, &idummy, &idummy, &idummy,
&ldummy, &ldummy, &ldummy, &ldummy, &ldummy,
&user_time, &sys_time);
if (count != 13) return -1;
return (jlong)user_time * (1000000000 / os::Posix::clock_tics_per_second());
}
Это была реализация, лежащая в основе ThreadMXBean.getCurrentThreadUserTime(). Чтобы получить время CPU пользовательского пространства для текущего потока, старый код выполнял следующее:
Форматировал путь в
/proc/self/task/<tid>/statОткрывал этот файл
Производил чтение в буфер стека
Парсил неудобный формат, в котором имя команды могло содержать круглые скобки (отсюда и
strrchrдля последней))Выполнял
sscanfдля извлечения полей 13 и 14Преобразовывал такты таймера в наносекунды
Для сравнения приведу то, что выполняет и всегда выполнял getCurrentThreadCpuTime():
jlong os::current_thread_cpu_time() {
return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID);
}
jlong os::Linux::thread_cpu_time(clockid_t clockid) {
struct timespec tp;
clock_gettime(clockid, &tp);
return (jlong)(tp.tv_sec * NANOSECS_PER_SEC + tp.tv_nsec);
}
Просто один вызов clock_gettime(). Здесь нет никакого ввода-вывода в файл, никакого сложного парсинга и работы с буфером.
Разрыв производительности
В исходном отчёте о баге, созданном в 2018 году, подсчитана разница:
«
getCurrentThreadUserTimeв 30-400 раз медленнее, чемgetCurrentThreadCpuTime»
Разрыв увеличивается в условиях конкурентности. Почему clock_gettime() настолько быстрее? В обоих случаях требуется точка входа в ядро, но разница заключается в том, что происходит потом.
Путь исполнения /proc:
Системный вызов
open()Диспетчеризация VFS + поиск dentry
procfs синтезирует содержимое файла во время чтения
Ядро форматирует строку в буфер
Системный вызов
read(), копирование в пользовательское пространствоПарсинг
sscanf()в пользовательском пространствеСистемный вызов
close()
Путь исполнения clock_gettime(CLOCK_THREAD_CPUTIME_ID):
Один системный вызов →
posix_cpu_clock_get()→cpu_clock_sample()→task_sched_runtime()→ чтение непосредственно изsched_entity
Путь исполнения /proc содержит множественные системные вызовы, работу с VFS, форматирование строк на стороне ядра и парсинг ан стороне пользовательского пространства. Путь исполнения clock_gettime() — это один системный вызов с прямой цепочкой вызовов функций.
При конкурентной нагрузке путь /proc также страдает от конкуренции за блокировку ядра. В отчёте о баге написано следующее:
«Чтение proc выполняется медленно (поэтому эта процедура помещена в метод
slow_thread_cpu_time(...)) и может привести к заметным пиковым нагрузкам в случае конкуренции за ресурсы ядра».
Зачем нужны две реализации?
Почему getCurrentThreadUserTime() изначально просто не использовал clock_gettime()?
Причина заключается (вероятно) в POSIX. Стандарт гласит, что CLOCK_THREAD_CPUTIME_ID должен возвращать общее время CPU (пользовательское + системное). Не существует портируемого способа запросить только пользовательское время, поэтому и написана реализация на основе /proc.
Порт OpenJDK под Linux не ограничен тем, что определено в POSIX, он может использовать специфичные возможности Linux. Давайте разберёмся, как именно.
Хак с битами Clockid
Ядра Linux с версии 2.6.12 (выпущенной в 2005 году) кодируют информацию о типе таймера непосредственно в значение clockid_t. При вызове pthread_getcpuclockid() мы получаем clockid с конкретным паттерном битов:
Бит 2: Таймер потока или процесса
Биты 1-0: Тип таймера
00 = PROF
01 = VIRT (только для времени пользовательского пространства)
10 = SCHED (пользовательское + системное, совместимое с POSIX)
11 = FD
Оставшиеся биты кодируют PID/TID цели. Мы вернёмся к этому ниже.
Совместимый с POSIX pthread_getcpuclockid() возвращает clockid с битами 10 (SCHED). Но если инвертировать эти младшие биты в 01 (VIRT), то clock_gettime() вернёт только время пользовательского пространства.
Новая реализация:
static bool get_thread_clockid(Thread* thread, clockid_t* clockid, bool total) {
constexpr clockid_t CLOCK_TYPE_MASK = 3;
constexpr clockid_t CPUCLOCK_VIRT = 1;
int rc = pthread_getcpuclockid(thread->osthread()->pthread_id(), clockid);
if (rc != 0) {
// Поток может оказаться завершённым
assert_status(rc == ESRCH, rc, "pthread_getcpuclockid failed");
return false;
}
if (!total) {
// Инвертируем значения в CPUCLOCK_VIRT, чтобы получить время только пользовательского пространства
*clockid = (*clockid & ~CLOCK_TYPE_MASK) | CPUCLOCK_VIRT;
}
return true;
}
static jlong user_thread_cpu_time(Thread *thread) {
clockid_t clockid;
bool success = get_thread_clockid(thread, &clockid, false);
return success ? os::Linux::thread_cpu_time(clockid) : -1;
}
Вот и всё. В новой версии нет ввода-вывода в файл, нет буфера и точно нет sscanf() с тринадцатью указателями формата.
Профилируем!
Давайте исследуем производительность на практике. Для этой задачи я воспользуюсь тестом JMH, включённым в состав исправления, увеличив только количество потоков с 1 до 16 и добавив метод main() для удобства исполнения из IDE:
@State(Scope.Benchmark)
@Warmup(iterations = 2, time = 5)
@Measurement(iterations = 5, time = 5)
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Threads(16)
@Fork(value = 1)
public class ThreadMXBeanBench {
static final ThreadMXBean mxThreadBean = ManagementFactory.getThreadMXBean();
static long user; // Чтобы избежать удаления мёртвого кода
@Benchmark
public void getCurrentThreadUserTime() throws Throwable {
user = mxThreadBean.getCurrentThreadUserTime();
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ThreadMXBeanBench.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
Примечание: это довольно ненаучный бенчмарк, например, на моём десктопе работали и другие процессы. Однако приведу его характеристики: Ryzen 9950X, основная ветвь JDK на коммите 8ab7d3b89f656e5c. Для случая «до» я откатил исправление, а не стал смотреть более старую ревизию.
Вот результат:
Benchmark Mode Cnt Score Error Units
ThreadMXBeanBench.getCurrentThreadUserTime sample 8912714 11.186 ± 0.006 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00 sample 2.000 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50 sample 10.272 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90 sample 17.984 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95 sample 20.832 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99 sample 27.552 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999 sample 56.768 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999 sample 79.709 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00 sample 1179.648 us/op
Мы видим, что один вызов в среднем занимал 11 микросекунд, а медиана составила примерно 10 микросекунд на вызов.
Профилирование CPU выглядит так:

Профилирование CPU подтверждает, что каждый вызов getCurrentThreadUserTime() выполняет множественные системные вызовы. На самом деле, основная часть времени CPU тратится на системные вызовы. Мы видим, как открываются и закрываются файлы. Одно лишь закрытие приводит ко множественным системным вызовам, включая futex-блокировки.
Давайте посмотрим на результаты бенчмарка после применённого исправления:
Benchmark Mode Cnt Score Error Units
ThreadMXBeanBench.getCurrentThreadUserTime sample 11037102 0.279 ± 0.001 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00 sample 0.070 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50 sample 0.310 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90 sample 0.440 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95 sample 0.530 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99 sample 0.610 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999 sample 1.030 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999 sample 3.088 us/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00 sample 1230.848 us/op
Среднее время снизилось с 11 микросекунд до 279 наносекунд. Это значит, что задержки в исправленной версии в 40 раз ниже, чем в старой. Хоть улучшение и не в 400 раз, оно попадает в указанный в отчёте диапазон 30-400 раз. Есть вероятность, что при других характеристиках бенчмарка дельта будет больше. Давайте взглянем на новый профиль:

Профиль гораздо чище. Системный вызов всего один. Если верить профилю, основная часть времени тратится в JVM, то есть снаружи ядра.
Насколько хорошо это задокументировано?
Очень слабо. Битовое кодирование стабильно, за двадцать лет оно не поменялось, поэтому его не найти на странице man clock_gettime(2). Ближе всего к официальной документации исходники самого ядра в kernel/time/posix-cpu-timers.c и макросах CPUCLOCK_*.
Политика ядра чётко гласит: нельзя ломать пользовательское пространство.

Моя точка зрения: если от кодирования зависит glibc, оно никуда не денется.
Дальнейшее развитие
При изучении данных профилировщика в прогоне после применённого изменения я обнаружил возможность дополнительной оптимизации: добрая часть оставшегося системного вызова тратится внутри поиска по базисному дереву. Взгляните сами:

Когда JVM вызывает pthread_getcpuclockid(), то получает clockid , кодирующий ID потока. Когда этого clockid передаётся clock_gettime(), ядро извлекает ID потока и выполняет поиск в базисном дереве, чтобы найти структуру pid, ассоциированную с этим ID.
Однако у ядра Linux есть быстрый путь исполнения кода. Если закодированный в clockid PID равен 0, то ядро интерпретирует это как «текущий поток» и полностью пропускает поиск по базисному дереву, переходя напрямую к структуре текущей задачи.
На данный момент исправление OpenJDK получает конкретный TID, инвертирует биты и передаёт их clock_gettime(). Это заставляет ядро пойти по «обобщённому пути» (с поиском по базисному дереву).
Исходный код выглядит так:
/*
* Функции для валидации доступа к задачам.
*/
static struct pid *pid_for_clock(const clockid_t clock, bool gettime)
{
[...]
/*
* Если закодированный PID равен 0, то таймер указывает на текущий процесс
* или на процесс, к которому принадлежит текущий.
*/
if (upid == 0)
// Быстрый путь исполнения: поиск текущей задачи, малозатратный
return thread ? task_pid(current) : task_tgid(current);
// Обобщённый путь: поиск по базисному дереву, более затратный
pid = find_vpid(upid);
[...]
Если JVM создаёт весь clockid вручную с закодированным PID=0 (вместо того, чтобы получать clockid через pthread_getcpuclockid()), то ядро может пойти по быстрому пути, полностью отказавшись от поиска по базисному дереву. JVM и так манипулирует с битами в clockid, поэтому создание их полностью с нуля не сильно повлияет на производительность.
Давайте попробуем!
Для начала вкратце расскажу о кодировании clockid. Он конструируется следующим образом:
clockid для TID=42, только время пользовательского пространства:
1111_1111_1111_1111_1111_1110_1010_1101
└───────────────~42────────────────┘│└┘
│ └─ 01 = VIRT (только время пользовательского пространства)
└─── 1 = на поток
Для текущего потока мы хотим закодировать PID=0, что даёт нам ~0 в старших битах:
1111_1111_1111_1111_1111_1111_1111_1101
└─────────────── ~0 ───────────────┘│└┘
│ └─ 01 = VIRT (только время пользовательского пространства)
└─── 1 = на поток
На C++ это можно написать следующим образом:
// Внутренняя кодировка битов ядра Linux для динамических таймеров CPU:
// [31:3] : Побитовое NOT для PID или TID (~0 для текущего потока)
// [2] : 1 = таймер потока, 0 = таймер процесса
// [1:0] : Тип таймера (0 = PROF, 1 = VIRT/только пользовательского пространства, 2 = SCHED)
static_assert(sizeof(clockid_t) == 4, "Linux clockid_t must be 32-bit");
constexpr clockid_t CLOCK_CURRENT_THREAD_USERTIME = static_cast<clockid_t>(~0u << 3 | 4 | 1);
И теперь внесём крошечное изменение в user_thread_cpu_time():
jlong os::current_thread_cpu_time(bool user_sys_cpu_time) {
if (user_sys_cpu_time) {
return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID);
} else {
- return user_thread_cpu_time(Thread::current());
+ return os::Linux::thread_cpu_time(CLOCK_CURRENT_THREAD_USERTIME);
} return os::Linux::thread_cpu_time(CLOCK_THREAD_CPUTIME_ID); } else { - return user_thread_cpu_time(Thread::current()); + return os::Linux::thread_cpu_time(CLOCK_CURRENT_THREAD_USERTIME); }
Показанного выше изменения достаточно для того, чтобы заставить getCurrentThreadUserTime() использовать быстрый путь исполнения в ядре.
Учитывая то, что мы и так уже имеем дело с наносекундами, я немного изменил тест:
Увеличил количество итераций и форков
Использую только один поток для минимизации шума
Переключился на наносекунды
Изменения в бенчмарке предназначены для устранения шума от остальной части моей системы и более точных измерений той малой дельты, которую мы ожидаем:
@State(Scope.Benchmark)
@Warmup(iterations = 4, time = 5)
@Measurement(iterations = 10, time = 5)
@BenchmarkMode(Mode.SampleTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Threads(1)
@Fork(value = 3)
public class ThreadMXBeanBench {
static final ThreadMXBean mxThreadBean = ManagementFactory.getThreadMXBean();
static long user; // Чтобы избежать устранения мёртвого кода
@Benchmark
public void getCurrentThreadUserTime() throws Throwable {
user = mxThreadBean.getCurrentThreadUserTime();
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ThreadMXBeanBench.class.getSimpleName())
.build();
new Runner(opt).run();
}
}
Версия кода в основной ветви JDK даёт следующие результаты:
Benchmark Mode Cnt Score Error Units
ThreadMXBeanBench.getCurrentThreadUserTime sample 4347067 81.746 ± 0.510 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00 sample 69.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50 sample 80.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90 sample 90.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95 sample 90.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99 sample 90.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999 sample 230.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999 sample 1980.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00 sample 653312.000 ns/op
С ручным конструированием clockid, использующим быстрый путь ядра, мы получаем следующее:
Benchmark Mode Cnt Score Error Units
ThreadMXBeanBench.getCurrentThreadUserTime sample 5081223 70.813 ± 0.325 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.00 sample 59.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.50 sample 70.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.90 sample 70.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.95 sample 70.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.99 sample 80.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.999 sample 170.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p0.9999 sample 1830.000 ns/op
ThreadMXBeanBench.getCurrentThreadUserTime:p1.00 sample 425472.000 ns/op
Среднее значение снизилось с 81,7 нс до 70,8 нс, то есть ускорение составило примерно 13%. Кроме того, улучшение заметно во всех перцентилях. Оправдывает ли себя снижение понятности кода из-за создания clockid? Точного ответа на этот вопрос у меня нет. Абсолютный выигрыш производительности мал, при этом принимаются дополнительные допущения о внутреннем устройстве ядра, в том числе о размере clockid_t. С другой стороны, это всё равно улучшение, не приносящее на практике никаких минусов.
Ищем сокровища
Именно поэтому я люблю исследовать коммиты больших опенсорсных проектов. Удаление сорока строк повысило производительность в 400 раз. Это исправление не потребовало новых фич ядра, хватило лишь знания стабильной, но малоизвестной подробности ABI Linux.
Какие выводы можно из этого сделать:
Читайте исходники ядра. POSIX сообщает нам, что можно портировать. Исходный код ядра сообщает нам, что возможно сделать. Иногда при этом можно добиться увеличения скорости в 400 раз. Стоит ли использовать такое улучшение — это уже другой вопрос.
Проверяйте старые допущения. Решение с парсингом /proc было логичным, когда его писали и никто не осознавал, что его можно использовать таким образом. Допущения «запечены» в код. Их пересмотр иногда оправдывает себя.
Изменение было одобрено 3 декабря 2025 года, всего за день до заморозки фич JDK 26. Если вы используете ThreadMXBean.getCurrentThreadUserTime(), то JDK 26 (который выпустят в марте 2026 года) без малейших затрат обеспечит вам ускорение в 30-400 раз!
Дополнение: Джонас Норлиндер (автор патча) в беседе на Hacker News поделился собственным глубоким исследованием, опубликованным почти одновременно с моей статьёй. Великие умы мыслят одинаково! Он более дотошно изучил оверхед памяти; я углубился в битовое кодирование и быстрый путь исполнения при PID=0.