Tenho o hábito de folhear o log de commit do OpenJDK a cada poucas semanas. Muitos commits são complexos demais para eu entender no tempo limitado que reservei para isso… passatempo especial. Mas de vez em quando algo chama minha atenção.
Na semana passada, este commit me interrompeu no meio da rolagem:
858d2e434dd 8372584: [Linux]: Replace reading proc to get thread CPU
time with clock_gettime
O difstat foi interessante: +96 insertions, -54 deletions. O conjunto de alterações adiciona um benchmark JMH de 55 linhas, o que significa que o próprio código de produção é realmente reduzido.
Aqui está o que foi removido os_linux.cpp:
static jlong user_thread_cpu_time(Thread *thread) CPUCLOCK_VIRT;
Esta foi a implementação por trás ThreadMXBean.getCurrentThreadUserTime(). Para obter o tempo de CPU do usuário do thread atual, o código antigo era:
- Formatando um caminho para
/proc/self/task//stat - Abrindo esse arquivo
- Lendo em um buffer de pilha
- Análise através de um formato hostil onde o nome do comando pode conter parênteses (daí o
strrchrpara o último)) - Correndo
sscanfpara extrair os campos 13 e 14 - Convertendo tiques do relógio em nanossegundos
Para comparação, aqui está o que getCurrentThreadCpuTime() faz e sempre fez:
jlong os::current_thread_cpu_time() CPUCLOCK_VIRT;
jlong os::Linux::thread_cpu_time(clockid_t clockid) CPUCLOCK_VIRT;
Apenas um único clock_gettime() chamar. Não há E/S de arquivo, análise complexa e buffer para gerenciar.
O relatório de bug original, arquivado em 2018, quantificou a diferença:
“getCurrentThreadUserTime é 30x-400x mais lento que getCurrentThreadCpuTime”
A lacuna aumenta sob a concorrência. Por que é clock_gettime() muito mais rápido? Ambas as abordagens requerem entrada no kernel, mas a diferença está no que acontece a seguir.
O /proc caminho:
open()chamada de sistema- Despacho VFS + pesquisa dentry
- procfs sintetiza o conteúdo do arquivo em tempo de leitura
- kernel formata string em buffer
read()syscall, copie para o espaço do usuário- espaço do usuário
sscanf()análise close()chamada de sistema
O clock_gettime(CLOCK_THREAD_CPUTIME_ID) caminho:
- chamada de sistema única →
posix_cpu_clock_get()→cpu_clock_sample()→task_sched_runtime()→ lê diretamente desched_entity
O /proc path envolve vários syscalls, máquinas VFS, formatação de string no lado do kernel e análise no lado do espaço do usuário. O clock_gettime() path é um syscall com uma cadeia de chamadas de função direta.
Sob carga simultânea, o /proc abordagem também sofre de contenção de bloqueio do kernel. O relatório do bug observa:
“A leitura do proc é lenta (daí porque este procedimento é colocado sob o método slow_thread_cpu_time (…)) e pode levar a picos perceptíveis em caso de contenção de recursos do kernel.”
Então por que não getCurrentThreadUserTime() basta usar clock_gettime() desde o início?
A resposta é (provavelmente) POSIX. A norma determina que CLOCK_THREAD_CPUTIME_ID retorna o tempo total de CPU (usuário + sistema). Não existe uma maneira portátil de solicitar apenas o tempo do usuário. Daí o /procimplementação baseada em.
A porta Linux do OpenJDK não está limitada ao que o POSIX define, ela pode usar recursos específicos do Linux. Vamos ver como.
Os kernels Linux desde 2.6.12 (lançado em 2005) codificam informações do tipo de relógio diretamente no clockid_t valor. Quando você liga pthread_getcpuclockid()você recebe de volta um clockid com um padrão de bits específico:
Bit 2: Thread vs process clock
Bits 1-0: Clock type
00 = PROF
01 = VIRT (user time only)
10 = SCHED (user + system, POSIX-compliant)
11 = FD
Os bits restantes codificam o PID/TID alvo. Voltaremos a isso na seção de bônus.
Compatível com POSIX pthread_getcpuclockid() retorna um clockid com bits 10 (PROGRAMADO). Mas se você virar esses bits baixos para 01 (VIRTA), clock_gettime() retornará apenas o tempo do usuário.
A nova implementação:
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) {
// Thread may have terminated
assert_status(rc == ESRCH, rc, "pthread_getcpuclockid failed");
return false;
}
if (!total) {
// Flip to CPUCLOCK_VIRT for user-time-only
*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;
}
E é isso. A nova versão não possui E/S de arquivo, buffer e certamente não sscanf() com treze especificadores de formato.
Vamos dar uma olhada em como ele funciona na prática. Para este exercício estou fazendo o teste JMH incluso na correção, a única mudança é que aumentei o número de threads de 1 para 16 e adicionei um main() método para execução simples de um 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; // To avoid dead-code elimination
@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();
}
}
Além: este é um benchmark pouco científico, tenho outros processos em execução na minha área de trabalho, etc. De qualquer forma, aqui está a configuração: Ryzen 9950X, ramificação principal do JDK no commit 8ab7d3b89f656e5c. Para o caso “antes”, reverti a correção em vez de verificar uma revisão mais antiga.
Aqui está o resultado:
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
Podemos ver que uma única invocação demorou em média 11 microssegundos e a mediana foi de cerca de 10 microssegundos por invocação.
O perfil da CPU é assim:
O perfil da CPU confirma que cada invocação de getCurrentThreadUserTime() faz vários syscalls. Na verdade, a maior parte do tempo da CPU é gasta em syscalls. Podemos ver arquivos sendo abertos e fechados. Fechar sozinho resulta em vários syscalls, incluindo bloqueios futex.
Vamos ver o resultado do benchmark com a correção aplicada:
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
A média caiu de 11 microssegundos para 279 nanos. Isso significa que a latência da versão fixa é 40x menor que a versão antiga. Embora esta não seja uma melhoria de 400x, está na faixa de 30x a 400x do relatório original. Provavelmente, o delta seria maior com uma configuração diferente. Vamos dar uma olhada no novo perfil:
O perfil é muito mais limpo. Existe apenas um único syscall. Se o perfil for confiável, a maior parte do tempo será gasto na JVM, fora do kernel.
Por muito pouco. A codificação de bits é estável. Não mudou em 20 anos, mas você não encontrará isso no clock_gettime(2) página de manual. A coisa mais próxima da documentação oficial é a própria fonte do kernel, em kernel/time/posix-cpu-timers.c e o CPUCLOCK_* macros.
A política do kernel é clara: não quebre o espaço do usuário.
Minha opinião: se a glibc depender disso, não vai desaparecer.
Ao observar os dados do criador de perfil da execução ‘depois’, descobri uma oportunidade adicional de otimização: uma boa parte do syscall restante é gasta em uma pesquisa de árvore radix. Dê uma olhada:
Quando a JVM chama pthread_getcpuclockid()ele recebe um clockid que codifica o ID do thread. Quando isso clockid é passado para clock_gettime()o kernel extrai o ID do thread e executa uma pesquisa na árvore radix para encontrar o pid estrutura associada a esse ID.
No entanto, o kernel do Linux possui um caminho rápido. Se o PID codificado no clockid for 0, o kernel interpreta isso como “o thread atual” e ignora completamente a pesquisa da árvore raiz, saltando diretamente para a estrutura da tarefa atual.
A correção do OpenJDK atualmente obtém o TID específico, inverte os bits e o passa para clock_gettime(). Isso força o kernel a seguir o “caminho generalizado” (a pesquisa da árvore raiz).
O código fonte é assim:
/*
* Functions for validating access to tasks.
*/
static struct pid *pid_for_clock(const clockid_t clock, bool gettime)
{
[...]
/*
* If the encoded PID is 0, then the timer is targeted at current
* or the process to which current belongs.
*/
if (upid == 0)
// the fast path: current task lookup, cheap
return thread ? task_pid(current) : task_tgid(current);
// the generalized path: radix tree lookup, more expensive
pid = find_vpid(upid);
[...]
Se a JVM construiu todo o clockid manualmente com PID = 0 codificado (em vez de obter o clockid através de pthread_getcpuclockid()), o kernel poderia seguir o caminho rápido e evitar completamente a pesquisa da árvore radix. A JVM já coloca bits no clockidportanto, construí-lo inteiramente do zero não seria um grande salto em termos de compatibilidade.
Vamos tentar!
Primeiro, uma atualização sobre o clockid codificação. O clockid é construído assim:
clockid for TID=42, user-time-only:
1111_1111_1111_1111_1111_1110_1010_1101
└───────────────~42────────────────┘│└┘
│ └─ 01 = VIRT (user time only)
└─── 1 = per-thread
Para o thread atual, queremos a codificação PID = 0, o que fornece ~0 nas partes superiores:
1111_1111_1111_1111_1111_1111_1111_1101
└─────────────── ~0 ───────────────┘│└┘
│ └─ 01 = VIRT (user time only)
└─── 1 = per-thread
Podemos traduzir isso para C++ da seguinte maneira:
// Linux Kernel internal bit encoding for dynamic CPU clocks:
// [31:3] : Bitwise NOT of the PID or TID (~0 for current thread)
// [2] : 1 = Per-thread clock, 0 = Per-process clock
// [1:0] : Clock type (0 = PROF, 1 = VIRT/User-only, 2 = SCHED)
static_assert(sizeof(clockid_t) == 4, "Linux clockid_t must be 32-bit");
constexpr clockid_t CLOCK_CURRENT_THREAD_USERTIME = static_cast
(~0u << 3 | 4 | 1);
E então faça uma pequena mudança 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);
}
A alteração acima é suficiente para fazer getCurrentThreadUserTime() use o caminho rápido no kernel.
Dado que já estamos no território dos nanossegundos, ajustamos um pouco o teste:
- Aumente a iteração e a contagem de bifurcações
- Use apenas um único thread para minimizar o ruído
- Mudar para nanos
As alterações no benchmark têm como objetivo eliminar o ruído do resto do meu sistema e obter uma medição mais precisa do pequeno delta que esperamos:
@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; // To avoid dead-code elimination
@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();
}
}
A versão atualmente no branch principal do JDK fornece:
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
Com o manual clockid construção, que usa o caminho rápido do kernel, obtemos:
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
A média caiu de 81,7 ns para 70,8 ns, ou seja, uma melhoria de cerca de 13%. As melhorias também são visíveis em todos os percentis. Vale a pena perder a clareza na construção do clockid manualmente em vez de usar pthread_getcpuclockid()? Não tenho certeza. O ganho absoluto é pequeno e faz suposições adicionais sobre os componentes internos do kernel, incluindo o tamanho do clockid_t. Por outro lado, ainda é um ganho sem qualquer desvantagem na prática. (famosas últimas palavras…)
É por isso que gosto de navegar em commits de grandes projetos de código aberto. Uma exclusão de 40 linhas eliminou uma lacuna de desempenho de 400x. A correção não exigiu novos recursos do kernel, apenas o conhecimento de um detalhe estável, mas obscuro, da ABI do Linux.
As lições:
Leia a fonte do kernel. POSIX informa o que é portátil. O código-fonte do kernel informa o que é possível. Às vezes há uma diferença de 400x entre os dois. Se vale a pena explorar é uma questão diferente.
Verifique as antigas suposições. O /proc A abordagem de análise sintática fazia sentido quando foi escrita, antes que alguém percebesse que poderia ser explorada dessa maneira. As suposições são incorporadas ao código. Revisitá-los ocasionalmente compensa.
A mudança ocorreu em 3 de dezembro de 2025. Apenas um dia antes do congelamento dos recursos do JDK 26. Se você estiver usando ThreadMXBean.getCurrentThreadUserTime()JDK 26 (lançado em março de 2026) oferece uma aceleração gratuita de 30-400x!
Fonte: theverge

