Was this article helpful?
to mark as helpful
Enjoyed this article?
Get more engineering insights delivered weekly.
Comments
to join the discussion
to mark as helpful
Get more engineering insights delivered weekly.
to join the discussion
Jagdish Salgotra
Aug 17, 2025 · 38 min read · Project Loom
Ensure your virtual thread applications are production-ready. Master essential monitoring strategies, debugging techniques, JFR integration, and pinning detection to maintain reliable and high-performance Java services.
Your article assistant
Ask me anything about this article. I'll provide answers with relevant sources.
Try asking:
Note This series uses Java 21 as the baseline. Virtual threads are stable in Java 21 (JEP 444). Structured concurrency snippets in this part (
StructuredTaskScope, JEP 453) use preview APIs and require--enable-preview.
Virtual thread incidents are harder when old dashboards look normal while latency climbs.
The tooling that worked for platform threads often misses what matters here: pinning, carrier utilization, and scope behavior under stress.
Here's what most production monitoring looks like when virtual threads enter the picture:
// From JvmMonitoringService
private static String generateJvmInfo() {
StringBuilder info = new StringBuilder();
info.append("JVM Information:\n");
info.append("================\n");
info.append("Java Version: ").append(System.getProperty("java.version")).append("\n");
info.append("JVM Name: ").append(runtimeBean.getVmName()).append("\n");
info.append("JVM Version: ").append(runtimeBean.getVmVersion()).append("\n");
info.append("Uptime: ").append(runtimeBean.getUptime() / 1000).append(" seconds\n");
info.append("Available Processors: ").append(Runtime.getRuntime().availableProcessors()).append("\n");
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
info.append("Heap Usage: ").append(heapUsage.getUsed() / 1024 / 1024).append("MB / ")
.append(heapUsage.getMax() / 1024 / 1024).append("MB\n");
return info.toString();
}
Common monitoring blind spots:
Virtual thread observability needs explicit visibility into the relationship between virtual threads and carriers, with pinning as a first-class signal.
public class VirtualThreadMonitor {
private final AtomicLong taskCount = new AtomicLong(0);
private final AtomicLong successCount = new AtomicLong(0);
private final AtomicLong errorCount = new AtomicLong(0);
private final AtomicLong totalExecutionTime = new AtomicLong(0);
private final Map<String, TaskMetrics> taskMetrics = new ConcurrentHashMap<>();
private final Map<String, Long> errorCounts = new ConcurrentHashMap<>();
private final ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
private final Runtime runtime = Runtime.getRuntime();
private ScheduledExecutorService scheduler;
private volatile boolean monitoring = false;
public void startMonitoring() {
monitoring = true;
scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::collectMetrics, 0, 1, TimeUnit.SECONDS);
}
public <T> T trackTask(String taskName, Callable<T> task) throws Exception {
long startTime = System.nanoTime();
long startMemory = runtime.totalMemory() - runtime.freeMemory();
taskCount.incrementAndGet();
try {
T result = task.call();
long executionTime = System.nanoTime() - startTime;
long memoryUsed = (runtime.totalMemory() - runtime.freeMemory()) - startMemory;
successCount.incrementAndGet();
totalExecutionTime.addAndGet(executionTime);
taskMetrics.compute(taskName, (key, metrics) -> {
if (metrics == null) metrics = new TaskMetrics();
metrics.addExecution(executionTime, memoryUsed, true);
return metrics;
});
return result;
} catch (Exception e) {
long executionTime = System.nanoTime() - startTime;
errorCount.incrementAndGet();
totalExecutionTime.addAndGet(executionTime);
errorCounts.merge(e.getClass().getSimpleName(), 1L, Long::sum);
throw e;
}
}
private void collectMetrics() {
if (!monitoring) return;
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
int threadCount = threadBean.getThreadCount();
System.out.printf("Threads: %d, Memory: %.2fMB, Tasks: %d (Success: %d, Errors: %d)%n",
threadCount, usedMemory / 1024.0 / 1024.0,
taskCount.get(), successCount.get(), errorCount.get());
}
}
What this monitor provides:
private static void startJFRProfiling() {
logger.info("JFR Profiling enabled. Use JVM args: -XX:+FlightRecorder -XX:StartFlightRecording=duration=60s,filename=app.jfr");
}
static void main(String[] args) throws Exception {
startJFRProfiling();
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/metrics", exchange -> {
String metrics = generatePrometheusMetrics();
sendResponse(exchange, metrics);
});
server.start();
}
public class VirtualThreadPinningDetector {
private static final Logger logger = LoggerFactory.getLogger(VirtualThreadPinningDetector.class);
private static final Object SYNC_LOCK = new Object();
private static final ReentrantLock REENTRANT_LOCK = new ReentrantLock();
static void main(String[] args) throws InterruptedException {
logger.info(" Virtual Thread Pinning Detection Demo");
logger.info("Run with: -Djdk.tracePinnedThreads=full");
logger.info(" Test 1: Synchronized blocks (CAUSES PINNING)");
testSynchronizedPinning();
logger.info(" Test 2: ReentrantLock (NO PINNING)");
testReentrantLockNoPinning();
logger.info(" Test 3: Heavy synchronized load (CAUSES PINNING)");
testHeavySynchronizedLoad();
logger.info(" Test 4: Optimized with ReentrantLock (NO PINNING)");
testOptimizedWithReentrantLock();
}
private static void testSynchronizedPinning() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
final int taskId = i;
Thread.startVirtualThread(() -> {
try {
synchronizedWork(taskId);
} finally {
latch.countDown();
}
});
}
latch.await();
logger.info("Synchronized test completed");
}
private static void testReentrantLockNoPinning() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
final int taskId = i;
Thread.startVirtualThread(() -> {
try {
reentrantLockWork(taskId);
} finally {
latch.countDown();
}
});
}
latch.await();
logger.info("ReentrantLock test completed");
}
}
Note Run with
-Djdk.tracePinnedThreads=fullto see stack traces in console/logs.
Pinning detection in production:
// Metrics comparison from VirtualThreadPinningDetector
private static void testPinningMetrics() throws InterruptedException {
PinningMetrics metrics = new PinningMetrics();
CountDownLatch latch = new CountDownLatch(50);
for (int i = 0; i < 25; i++) {
final int taskId = i;
Thread.startVirtualThread(() -> {
try {
metrics.trackOperation("synchronized", () -> synchronizedWork(taskId));
} finally {
latch.countDown();
}
});
}
for (int i = 0; i < 25; i++) {
final int taskId = i;
Thread.startVirtualThread(() -> {
try {
metrics.trackOperation("reentrant", () -> reentrantLockWork(taskId));
} finally {
latch.countDown();
}
});
}
latch.await();
metrics.printReport();
}
Pinning detection helps surface lock hot paths where virtual threads remain mounted on carriers longer than expected.
jdk.VirtualThreadPinned events for duration/threshold alerts.This is a complete monitoring service example for virtual-thread workloads.
public class JvmMonitoringService {
public static final int PORT = 8083;
static void main(String[] args) throws Exception {
startJFRProfiling();
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/metrics", exchange -> {
String metrics = generatePrometheusMetrics();
sendResponse(exchange, metrics);
});
server.createContext("/jvm-info", exchange -> {
String info = generateJvmInfo();
sendResponse(exchange, info);
});
server.start();
}
}
These reporting techniques help during production debugging.
public void printDetailedReport() {
Duration totalTime = Duration.between(monitoringStartTime.get(), Instant.now());
System.out.printf("Monitoring Duration: %s%n", formatDuration(totalTime));
System.out.printf("Total Tasks: %d%n", taskCount.get());
System.out.printf("Successful Tasks: %d (%.1f%%)%n",
successCount.get(), (successCount.get() * 100.0) / taskCount.get());
System.out.printf("Failed Tasks: %d (%.1f%%)%n",
errorCount.get(), (errorCount.get() * 100.0) / taskCount.get());
if (taskCount.get() > 0) {
System.out.printf("Average Execution Time: %.2fms%n",
(totalExecutionTime.get() / 1_000_000.0) / taskCount.get());
}
System.out.printf("Current Thread Count: %d%n", threadBean.getThreadCount());
System.out.printf("Peak Thread Count: %d%n", threadBean.getPeakThreadCount());
System.out.printf("Total Started Threads: %d%n", threadBean.getTotalStartedThreadCount());
}
These reports are useful for narrowing down pinning and latency regressions.
These JVM parameters are commonly used when tuning virtual-thread services.
# Production JVM configuration for virtual threads
-XX:+UseG1GC # G1 works well with virtual threads
-XX:+UnlockExperimentalVMOptions # Required for some virtual thread features
-XX:+UseTransparentHugePages # Memory optimization
-Djdk.virtualThreadScheduler.parallelism=16 # Match your CPU cores
-Djdk.virtualThreadScheduler.maxPoolSize=256 # Reasonable carrier thread limit
-Djdk.tracePinnedThreads=full # CRITICAL: Enable pinning detection
-XX:+FlightRecorder # Enable JFR
-XX:StartFlightRecording=duration=300s,filename=production-vt.jfr
These code patterns reduce pinning risk in hot paths.
public class PinningOptimizationPatterns {
private static final Object syncLock = new Object();
private static final ReentrantLock reentrantLock = new ReentrantLock();
private static final Map<String, String> synchronizedCache = new ConcurrentHashMap<>();
private static final Map<String, String> optimizedCache = new ConcurrentHashMap<>();
private static void synchronizedIncrement() {
synchronized (syncLock) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private static void reentrantLockIncrement() {
reentrantLock.lock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
reentrantLock.unlock();
}
}
private static void synchronizedCacheOperation(int taskId) {
synchronized (syncLock) {
try {
synchronizedCache.put("key-" + taskId, "value-" + taskId);
Thread.sleep(2);
synchronizedCache.get("key-" + taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private static void concurrentCacheOperation(int taskId) {
try {
optimizedCache.put("key-" + taskId, "value-" + taskId);
Thread.sleep(2);
optimizedCache.get("key-" + taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
This checklist captures a baseline monitoring setup.
public class ProductionMonitoringChecklist {
public void setupProductionMetrics() throws Exception {
startJFRProfiling();
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/metrics", exchange -> {
String metrics = generatePrometheusMetrics();
sendResponse(exchange, metrics);
});
server.createContext("/jvm-info", exchange -> {
String info = generateJvmInfo();
sendResponse(exchange, info);
});
server.start();
}
}
The practical goal is to detect pinning and carrier saturation before latency degrades.
Checklist items:
-Djdk.tracePinnedThreads=full) in test and staging runs/metrics, /jvm-info) and collect them centrallyjdk.VirtualThreadPinned eventsStart with -Djdk.tracePinnedThreads=full in non-production load environments so early stack traces are available during incident simulation.
Start by collecting baseline virtual-thread and carrier metrics.
// Week 1-2: Basic virtual thread visibility
public class BaselineVirtualThreadMonitoring {
public void establishBaseline() {
VirtualThreadMonitor monitor = new VirtualThreadMonitor();
monitor.startMonitoring();
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
for (int i = 0; i < 20; i++) {
final int taskId = i;
scope.fork(() -> monitor.trackTask("structured-task-" + taskId, () -> {
Thread.sleep(50 + ThreadLocalRandom.current().nextInt(100));
return "Structured task " + taskId + " completed";
}));
}
scope.join();
scope.throwIfFailed();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Then identify and reduce pinning in hot paths.
// Week 3-4: Pinning elimination
public class PinningEliminationPhase {
public void eliminatePinning() {
compareBasicLocking();
compareCacheImplementations();
compareReaderWriterLocks();
}
private void compareBasicLocking() throws InterruptedException {
final int TASKS = 1000;
for (int i = 0; i < TASKS; i++) {
Thread.startVirtualThread(() -> synchronizedIncrement());
}
for (int i = 0; i < TASKS; i++) {
Thread.startVirtualThread(() -> reentrantLockIncrement());
}
}
}
Finally, expand into full production observability.
// Week 5+: Production monitoring
public class ProductionObservabilitySetup {
public void setupProductionMonitoring() {
startJFRProfiling();
HttpServer server = HttpServer.create(new InetSocketAddress(PORT), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/metrics", exchange -> {
String metrics = generatePrometheusMetrics();
sendResponse(exchange, metrics);
});
server.createContext("/jvm-info", exchange -> {
String info = generateJvmInfo();
sendResponse(exchange, info);
});
}
}
jdk.VirtualThreadPinned events in the same test windowMonitor these in production:
Common production JVM setup:
# Core JVM arguments for visibility
-Djdk.tracePinnedThreads=full # Enable pinned-thread stack trace logging
-XX:+FlightRecorder # Deep observability
-Djdk.virtualThreadScheduler.parallelism=<CPU_CORES> # Match hardware
-XX:+UseG1GC # Best GC for virtual threads
Don't make these production mistakes:
A practical sequence teams often follow:
Teams that adopt observability early usually resolve pinning and latency issues faster.
In Part 8, we'll explore The Future of Java Concurrency, including Scoped Values, Foreign Function API integration, and ecosystem roadmap implications.
We'll cover:
Part 7 complete. This one is about operating virtual threads safely once real traffic is on them.
Series Navigation:
If you deploy virtual-thread monitoring, compare old and new dashboards during induced failure tests.