Skip to content

Commit 09e26cd

Browse files
authored
VirtualThreadSchedulerMXBean metrics (#6104)
Adds virtual thread metrics based on the newly introduced MBean in Java 24.
1 parent 169f38a commit 09e26cd

File tree

5 files changed

+219
-14
lines changed

5 files changed

+219
-14
lines changed

Diff for: .circleci/config.yml

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ jobs:
7676
executor: circle-jdk-executor
7777
steps:
7878
- gradlew-build
79+
- run: ./gradlew jdk24Test
7980
- run: ./gradlew shenandoahTest
8081
- run: ./gradlew zgcTest
8182
- run: ./gradlew zgcGenerationalTest

Diff for: docs/modules/ROOT/pages/reference/jvm.adoc

+33-2
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,42 @@ To use the following `ExecutorService` instances, `--add-opens java.base/java.ut
5454

5555
== Java 21 Metrics
5656

57-
Micrometer provides support for https://openjdk.org/jeps/444[virtual threads] released in Java 21. In order to utilize it, you need to add the `io.micrometer:micrometer-java21` dependency to your classpath to use the binder:
57+
=== Virtual Threads
58+
59+
Micrometer provides metrics for https://openjdk.org/jeps/444[virtual threads] released in Java 21. In order to utilize it, you need to add the `io.micrometer:micrometer-java21` dependency to your classpath to use the binder:
5860

5961
[source, java]
6062
----
6163
new VirtualThreadMetrics().bindTo(registry);
6264
----
6365

64-
The binder measures the duration (and counts the number of events) of virtual threads being pinned; also counts the number of events when starting or unparking a virtual thread failed.
66+
The binder measures the duration (and counts the number of events) of virtual threads being pinned; it also counts the number of events when starting or unparking a virtual thread failed.
67+
68+
If you are running your application with Java 24 or later on a JVM that has `jdk.management.VirtualThreadSchedulerMXBean` provided as a platform MXBean, the following additional virtual thread metrics will be provided.
69+
70+
71+
|===
72+
|Meter name | Type | Tag(s) | Description
73+
74+
|`jvm.threads.virtual.parallelism`
75+
|Gauge
76+
|
77+
|Virtual thread scheduler's target parallelism
78+
79+
|`jvm.threads.virtual.pool.size`
80+
|Gauge
81+
|
82+
|Current number of platform threads that the scheduler has started but have not terminated; -1 if not known.
83+
84+
|`jvm.threads.virtual.live`
85+
|Gauge
86+
|`scheduling.status` = `mounted`
87+
|Approximate current number of virtual threads that are unfinished and mounted to a platform thread by the scheduler
88+
89+
|`jvm.threads.virtual.live`
90+
|Gauge
91+
|`scheduling.status` = `queued`
92+
|Approximate current number of virtual threads that are unfinished and queued waiting to be scheduled
93+
|===
94+
95+
Note that aggregating the values of `jvm.threads.virtual.live` across the different tags gives the total number of virtual threads started but not ended.

Diff for: micrometer-java21/build.gradle

+14-4
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,22 @@ java {
1717
}
1818

1919
tasks.withType(JavaCompile).configureEach {
20-
sourceCompatibility = JavaVersion.VERSION_21
21-
targetCompatibility = JavaVersion.VERSION_21
20+
sourceCompatibility = 21
21+
targetCompatibility = 21
2222
options.release = 21
2323
}
2424

25-
task reflectiveTests(type: Test) {
25+
tasks.register('jdk24Test', Test) {
26+
useJUnitPlatform {
27+
includeTags 'jdk24'
28+
}
29+
30+
javaLauncher = javaToolchains.launcherFor {
31+
languageVersion = JavaLanguageVersion.of(24)
32+
}
33+
}
34+
35+
tasks.register('reflectiveTests', Test) {
2636
useJUnitPlatform {
2737
includeTags 'reflective'
2838
}
@@ -39,6 +49,6 @@ task reflectiveTests(type: Test) {
3949
test {
4050
dependsOn reflectiveTests
4151
useJUnitPlatform {
42-
excludeTags 'reflective'
52+
excludeTags 'reflective', 'jdk24'
4353
}
4454
}

Diff for: micrometer-java21/src/main/java/io/micrometer/java21/instrument/binder/jdk/VirtualThreadMetrics.java

+69-8
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,28 @@
1515
*/
1616
package io.micrometer.java21.instrument.binder.jdk;
1717

18-
import io.micrometer.core.instrument.Counter;
19-
import io.micrometer.core.instrument.MeterRegistry;
20-
import io.micrometer.core.instrument.Tag;
21-
import io.micrometer.core.instrument.Timer;
18+
import io.micrometer.core.instrument.*;
19+
import io.micrometer.core.instrument.binder.BaseUnits;
2220
import io.micrometer.core.instrument.binder.MeterBinder;
2321
import jdk.jfr.consumer.RecordingStream;
2422

2523
import java.io.Closeable;
24+
import java.lang.invoke.MethodHandle;
25+
import java.lang.invoke.MethodHandles;
26+
import java.lang.invoke.MethodType;
27+
import java.lang.management.ManagementFactory;
28+
import java.lang.management.PlatformManagedObject;
2629
import java.time.Duration;
2730
import java.util.Objects;
2831

2932
import static java.util.Collections.emptyList;
3033

3134
/**
32-
* Instrumentation support for Virtual Threads, see:
33-
* https://openjdk.org/jeps/425#JDK-Flight-Recorder-JFR
35+
* Metrics instrumentation for Java Virtual Threads.
3436
*
3537
* @author Artyom Gabeev
38+
* @see <a href="https://openjdk.org/jeps/425#JDK-Flight-Recorder-JFR">JEP 425</a>
39+
* @see VirtualThreadSchedulerMXBean
3640
* @since 1.14.0
3741
*/
3842
public class VirtualThreadMetrics implements MeterBinder, Closeable {
@@ -41,6 +45,10 @@ public class VirtualThreadMetrics implements MeterBinder, Closeable {
4145

4246
private static final String SUBMIT_FAILED_EVENT = "jdk.VirtualThreadSubmitFailed";
4347

48+
private static final String LIVE_THREADS_DESCRIPTION = "Approximate current number of virtual threads that are unfinished";
49+
50+
private static final String METER_NAME_PREFIX = "jvm.threads.virtual.";
51+
4452
private final RecordingStream recordingStream;
4553

4654
private final Iterable<Tag> tags;
@@ -60,18 +68,71 @@ private VirtualThreadMetrics(RecordingConfig config, Iterable<Tag> tags) {
6068

6169
@Override
6270
public void bindTo(MeterRegistry registry) {
63-
Timer pinnedTimer = Timer.builder("jvm.threads.virtual.pinned")
71+
Timer pinnedTimer = Timer.builder(METER_NAME_PREFIX + "pinned")
6472
.description("The duration while the virtual thread was pinned without releasing its platform thread")
6573
.tags(tags)
6674
.register(registry);
6775

68-
Counter submitFailedCounter = Counter.builder("jvm.threads.virtual.submit.failed")
76+
Counter submitFailedCounter = Counter.builder(METER_NAME_PREFIX + "submit.failed")
6977
.description("The number of events when starting or unparking a virtual thread failed")
7078
.tags(tags)
7179
.register(registry);
7280

7381
recordingStream.onEvent(PINNED_EVENT, event -> pinnedTimer.record(event.getDuration()));
7482
recordingStream.onEvent(SUBMIT_FAILED_EVENT, event -> submitFailedCounter.increment());
83+
84+
bindVirtualThreadSchedulerMXBean(registry);
85+
}
86+
87+
private void bindVirtualThreadSchedulerMXBean(MeterRegistry registry) {
88+
try {
89+
Class clazz = Class.forName("jdk.management.VirtualThreadSchedulerMXBean");
90+
PlatformManagedObject platformMXBean = ManagementFactory.getPlatformMXBean(clazz);
91+
Object ignored = clazz.cast(platformMXBean);
92+
93+
MethodHandle getParallelism = MethodHandles.publicLookup()
94+
.findVirtual(clazz, "getParallelism", MethodType.methodType(int.class));
95+
MethodHandle getPoolSize = MethodHandles.publicLookup()
96+
.findVirtual(clazz, "getPoolSize", MethodType.methodType(int.class));
97+
MethodHandle getMountedVirtualThreadCount = MethodHandles.publicLookup()
98+
.findVirtual(clazz, "getMountedVirtualThreadCount", MethodType.methodType(int.class));
99+
MethodHandle getQueuedVirtualThreadCount = MethodHandles.publicLookup()
100+
.findVirtual(clazz, "getQueuedVirtualThreadCount", MethodType.methodType(long.class));
101+
102+
Gauge.builder(METER_NAME_PREFIX + "parallelism", platformMXBean, o -> invoke(getParallelism, o))
103+
.description("Virtual thread scheduler's target parallelism")
104+
.register(registry);
105+
106+
Gauge.builder(METER_NAME_PREFIX + "pool.size", platformMXBean, o -> invoke(getPoolSize, o))
107+
.baseUnit(BaseUnits.THREADS)
108+
.description(
109+
"Current number of platform threads that the scheduler has started but have not terminated; -1 if not known.")
110+
.register(registry);
111+
112+
Gauge.builder(METER_NAME_PREFIX + "live", platformMXBean, o -> invoke(getMountedVirtualThreadCount, o))
113+
.tag("scheduling.status", "mounted")
114+
.baseUnit(BaseUnits.THREADS)
115+
.description(LIVE_THREADS_DESCRIPTION)
116+
.register(registry);
117+
118+
Gauge.builder(METER_NAME_PREFIX + "live", platformMXBean, o -> invoke(getQueuedVirtualThreadCount, o))
119+
.tag("scheduling.status", "queued")
120+
.baseUnit(BaseUnits.THREADS)
121+
.description(LIVE_THREADS_DESCRIPTION)
122+
.register(registry);
123+
}
124+
catch (ClassNotFoundException | ClassCastException | NoSuchMethodException | IllegalAccessException ignored) {
125+
// cannot instrument VirtualThreadSchedulerMXBean
126+
}
127+
}
128+
129+
private <T> double invoke(MethodHandle methodHandle, T arg) {
130+
try {
131+
return (double) methodHandle.invoke(arg);
132+
}
133+
catch (Throwable t) {
134+
throw new RuntimeException(t);
135+
}
75136
}
76137

77138
private RecordingStream createRecordingStream(RecordingConfig config) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2025 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.java21.instrument.binder.jdk;
17+
18+
import io.micrometer.core.instrument.MeterRegistry;
19+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
20+
import org.junit.jupiter.api.AfterEach;
21+
import org.junit.jupiter.api.BeforeEach;
22+
import org.junit.jupiter.api.Tag;
23+
import org.junit.jupiter.api.Test;
24+
25+
import java.time.Duration;
26+
import java.util.concurrent.*;
27+
import java.util.concurrent.atomic.AtomicBoolean;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
31+
@Tag("jdk24")
32+
class VirtualThreadMetricsJdk24Tests {
33+
34+
MeterRegistry registry = new SimpleMeterRegistry();
35+
36+
VirtualThreadMetrics virtualThreadMetrics = new VirtualThreadMetrics();
37+
38+
@BeforeEach
39+
void setUp() {
40+
virtualThreadMetrics.bindTo(registry);
41+
}
42+
43+
@AfterEach
44+
void tearDown() {
45+
virtualThreadMetrics.close();
46+
}
47+
48+
@Test
49+
void parallelism() {
50+
int expectedParallelism = Runtime.getRuntime().availableProcessors();
51+
assertThat(registry.get("jvm.threads.virtual.parallelism").gauge().value()).isEqualTo(expectedParallelism);
52+
}
53+
54+
@Test
55+
void poolSize() throws InterruptedException {
56+
assertThat(registry.get("jvm.threads.virtual.pool.size").gauge().value()).isGreaterThanOrEqualTo(0);
57+
Thread.ofVirtual()
58+
.start(() -> assertThat(registry.get("jvm.threads.virtual.pool.size").gauge().value())
59+
.isGreaterThanOrEqualTo(1))
60+
.join(Duration.ofMillis(100));
61+
}
62+
63+
@Test
64+
void mountedThreads() throws InterruptedException {
65+
Thread.ofVirtual()
66+
.start(() -> assertThat(
67+
registry.get("jvm.threads.virtual.live").tag("scheduling.status", "mounted").gauge().value())
68+
.isEqualTo(1d))
69+
.join(Duration.ofMillis(100));
70+
}
71+
72+
@Test
73+
void queuedThreads() throws InterruptedException {
74+
AtomicBoolean spin = new AtomicBoolean(true);
75+
Runnable spinWait = () -> {
76+
while (spin.get()) {
77+
Thread.onSpinWait();
78+
}
79+
};
80+
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
81+
int parallelism = Runtime.getRuntime().availableProcessors();
82+
try {
83+
for (int i = 0; i < parallelism; i++) {
84+
executorService.submit(spinWait);
85+
}
86+
int expectedQueuedThreads = 7;
87+
for (int i = 0; i < expectedQueuedThreads; i++) {
88+
executorService.submit(() -> {
89+
});
90+
}
91+
assertThat(registry.get("jvm.threads.virtual.live").tag("scheduling.status", "queued").gauge().value())
92+
.isGreaterThanOrEqualTo(expectedQueuedThreads);
93+
}
94+
finally {
95+
spin.set(false);
96+
executorService.shutdown();
97+
executorService.awaitTermination(100, TimeUnit.MILLISECONDS);
98+
}
99+
}
100+
}
101+
102+
}

0 commit comments

Comments
 (0)