Concurrency · همزمانی سنیورSenior ~55 دقیقه مطالعه~48 min read
نخهای مجازی (Loom) و همزمانی ساختیافتهVirtual Threads (Project Loom) & Structured Concurrency
یاد میگیری چطور نخهای مجازی جاوا ۲۱ مدل سادهی «یک نخ برای هر درخواست» را تا میلیونها مقیاس میدهند، pinning کجا خرابت میکند، و چطور StructuredTaskScope و scoped values همزمانیِ درست و لغوپذیر میسازند.You will learn how Java 21 virtual threads make the simple "one thread per request" model scale to millions, where pinning bites, and how StructuredTaskScope and scoped values give you correct, cancellable concurrency.
پیشنیاز:Prerequisites: نخها، Runnable/Callable و ExecutorهاThreads, Runnable/Callable & Executors
تصور کن یک رستوران پرمشتری داری و برای هر مشتری یک گارسون اختصاصی میگذاری. زیبا و ساده است — هر گارسون فقط مراقب میز خودش است. اما گارسون گران است؛ نمیتوانی صدهزار گارسون استخدام کنی. کل داستان این فصل، همین است: چطور جاوا کاری کرد که «یک گارسون برای هر مشتری» ناگهان ارزان شد و تا میلیونها مقیاس گرفت. این یکی از مهمترین تغییرات دههٔ اخیر جاواست و در مصاحبههای ارشد تقریباً همیشه میآید.
اول میفهمیم چه مشکلی را حل میکنیم (thread-per-request و هزینهاش). بعد نخ مجازی را از صفر میسازیم و مفهوم mount/unmount را باز میکنیم. سپس سه قاعدهٔ طلایی، بعد خطرناکترین تله یعنی pinning و تغییر مهم نسخهایاش (JEP 491 در JDK 24). بعد میگوییم کجا نباید از آنها استفاده کنی. سپس دو همراهِ Loom را یاد میگیریم: همزمانی ساختیافته (StructuredTaskScope) و scoped values جانشین ThreadLocal. آخر هم مقایسه با reactive، دامهای رایج و ۱۴ سؤال مصاحبه با پاسخ کامل.
بخش ۰ — واژههایی که باید بشناسی
قبل از هر چیز چند واژه را با تشبیه بسازیم تا بعداً هیچجا سرد و بیتوضیح رها نشوند.
- نخ (thread): یک خط اجرای مستقل در برنامه. مثل یک کارگر که میتواند یک رشتهکار را از بالا تا پایین دنبال کند.
- نخ سیستمعامل (OS thread): کارگری که خودِ سیستمعامل استخدام و زمانبندی میکند. واقعی، گران و محدود است.
- نخ پلتفرم (platform thread): نخ کلاسیک جاوا. در واقع یک پوشش نازک دور یک نخ سیستمعامل — نسبت ۱:۱. وقتی میگوییم
new Thread(...)، تا امروز همین بوده. - مسدود شدن / blocking: وقتی نخ به دیوار میخورد و منتظر میماند — منتظر جواب دیتابیس، پاسخ شبکه، خواندن فایل. در این مدت هیچ کاری نمیکند، فقط ایستاده.
- I/O: ورودی/خروجی — هر کاری که بیرون از CPU اتفاق میافتد: شبکه، دیسک، دیتابیس. تقریباً همیشه کند و همراه با انتظار.
- پشتهٔ فراخوانی (call stack): دفترچهای که میگوید نخ الان کجای کد است و چه متدهایی روی هم صدا زده شدهاند. عمق و متغیرهای محلی هر متد اینجاست.
- context switch: وقتی سیستمعامل کارگر را از یک کار برمیدارد و روی کار دیگری میگذارد. مثل تعویض بازیکن؛ رایگان نیست، وقت میگیرد.
- throughput در برابر latency: throughput یعنی «چند درخواست در ثانیه میتوانی سرویس بدهی»؛ latency یعنی «یک درخواست چقدر طول میکشد». این دو فرق دارند و در مصاحبه قاطیشدنشان خطای رایج است.
یک گارسون = یک نخ. میز = یک درخواست. وقتی گارسون کنار میز ایستاده و منتظر است تا آشپزخانه غذا را آماده کند (I/O)، هیچ میز دیگری را سرویس نمیدهد. اگر گارسون گران باشد و فقط ۲۰ تا داشته باشی، همزمان فقط ۲۰ میز میتوانی بگیری — حتی اگر آشپزخانه ظرفیت ۲۰۰ سفارش داشته باشد. گلوگاه، تعداد گارسون است نه آشپزخانه. این دقیقاً مشکل thread-per-request است.
واقعاً چه مشکلی را حل میکنیم؟
بیست سال، معماری غالب سرورهای جاوا thread-per-request بود: یک نخ سیستمعامل به هر درخواست بده، کد مسدودکنندهٔ ساده بنویس (socket.read()، jdbc.executeQuery()) و بگذار نخ در حین تکمیل I/O پارک شود.
چرا این سبک اینقدر دوستداشتنی است؟ چون طبیعی است. پشتهٔ فراخوانی همان درخواست است — نگاه کنی به stack میفهمی این درخواست کجاست. استثناها بهطور طبیعی propagate میشوند (یعنی خطا در زنجیرهٔ فراخوانی بالا میرود تا کسی بگیردش). thread-localها زمینه (context) مثل «کاربر جاری» را بیدردسر حمل میکنند. نوشتن، خواندن، دیباگ و پروفایلش راحت است.
مشکل، هزینه است. یک نخ پلتفرم پوششی نازک روی نخ سیستمعامل است و نخهای سیستمعامل گراناند: هرکدام پشتهٔ بزرگی رزرو میکنند (بهطور معمول حدود ۱ مگابایت فضای آدرس)، زمانبندیشان با کرنل است و context switch نسبتاً سنگین است. روی یک ماشین معمول واقعبینانه فقط چند هزار از آنها را میتوانی اجرا کنی.
حالا نتیجه را ببین: اگر هر درخواست بیشتر عمرش را روی I/O مسدود باشد، throughput تو نه با CPU و نه با سرویس پاییندستی، بلکه با تعداد نخهایی که توان مالیاش را داری محدود میشود. به همین دلیل thread pool اختراع شد (چند گارسون را دوباره و دوباره استفاده کن)، و به همین دلیل صنعت بهسمت برنامهنویسی reactive/async رفت (CompletableFuture، Reactor، RxJava): این سبکها اجازه میدهند چند نخ دهها هزار درخواست همزمان را با «هرگز مسدود نشدن» سرویس دهند.
اما reactive یک هزینهٔ سنگین دارد: ارگونومی توسعهدهنده. پشته را از دست میدهی (stack دیگر داستان درخواست را نمیگوید)، دیباگ آسان از بین میرود، و منطقت در callbackها یا زنجیرهٔ operatorها تکهتکه میشود.
نخهای مجازی (virtual threads / Project Loom) پاسخ JDK به این معضلاند: مدل برنامهنویسی مسدودکنندهٔ ساده را نگه دار، اما نخها را چنان ارزان کن که «یک نخ برای هر درخواست» تا میلیونها مقیاس بگیرد. یعنی throughput مدل async را با ارگونومی کد مسدودکننده میگیری — بهترینِ هر دو دنیا.
نخهای مجازی در JDK 19 (JEP 425) و JDK 20 (JEP 436) بهصورت preview آمدند و در JDK 21 (JEP 444، سپتامبر ۲۰۲۳) بهعنوان قابلیت دائمی نهایی شدند — دیگر به --enable-preview نیاز نداری. این تاریخ را حفظ کن؛ در مصاحبه دقتِ نسخهای مهم است.
نخ پلتفرم در برابر نخ مجازی
بگذار تفاوت را در یک جدول کوتاه ببینی و بعد باز کنیم:
نخ پلتفرم نخ مجازی
───────────────── ──────────────────────────────
۱:۱ با نخ سیستمعامل many:few (M:N روی carrierها)
پشتهٔ ثابت ~۱MB پشتهٔ کوچک و تغییرپذیر روی heap
زمانبندی توسط کرنل زمانبندی توسط JDK (ForkJoinPool)
حداکثر چند هزار میلیونها
ساخت گران ارزان؛ یکبارمصرف، نه pool
نکتهٔ غافلگیرکننده این است: یک نخ مجازی هم یک java.lang.Thread است — همان کلاس، همان API. فرقش در این است که چه کسی اجرایش را مدیریت میکند: نه سیستمعامل، بلکه خودِ JDK.
بیایید مکانیزم را با یک تشبیه بسازیم، چون قلب کل موضوع همین است.
تصور کن یک هتل شیک با فقط چند پارکبان (valet) داری، اما هزاران مهمان. هر مهمان که میرسد، ماشینش را به یک پارکبان میدهد؛ پارکبان سوار میشود و ماشین را میراند. اما وقتی مهمان میرود داخل هتل و کاری طول میکشد، پارکبان که نمیماند بیکار پشت فرمان بنشیند! پیاده میشود، ماشین را همانجا پارک میکند و میرود سراغ مهمان بعدی. وقتی آن مهمان اول برگشت، یک پارکبان آزاد دوباره سوار ماشینش میشود و ادامه میدهد — لزوماً همان پارکبان قبلی هم نیست.
اینجا: مهمان/ماشین = نخ مجازی، پارکبان = نخ حامل (carrier)، سوار شدن = mount، پیاده شدن و پارک = unmount. چند پارکبان محدود، هزاران ماشین را میچرخانند چون هیچکس بیکار پشت فرمان نمیماند.
حالا با زبان فنی: وقتی یک نخ مجازی اجرا میشود، JDK آن را روی یک نخ حامل (carrier thread) سوار (mount) میکند — نخی پلتفرمی از یک ForkJoinPool اختصاصی که بهطور پیشفرض به تعداد هستههای CPU است. وقتی نخ مجازی به عملیات مسدودکنندهای میرسد که Loom میشناسد (بیشتر I/O مسدودکنندهٔ java.*، LockSupport.park، Thread.sleep، BlockingQueue، NIO و…)، JDK آن را پیاده (unmount) میکند: ادامهٔ اجرا (continuation، یعنی همان پشته) روی heap کپی میشود و carrier آزاد میشود تا نخ مجازی دیگری را اجرا کند. وقتی I/O تمام شد، نخ مجازی دوباره سوار میشود (شاید روی carrier دیگری) و دقیقاً از همانجا که مانده بود ادامه میدهد.
مسدود کردن یک نخ مجازی، نخ سیستمعامل را مسدود نمیکند. یک میلیون نخ مجازی که در read() پارک شدهاند، هزینهشان یک میلیون پشتهٔ کوچک روی heap است و صفر carrier مسدود. همین یک جمله دلیل وجود کل Loom است.
ساختن آنها
// ۱. کارخانهٔ مستقیم — یک نخ مجازی که بلافاصله شروع میشود
Thread vt = Thread.ofVirtual().start(() -> System.out.println("سلام از " + Thread.currentThread()));
vt.join();
// ۲. شروعنشده یا با نام
Thread t = Thread.ofVirtual().name("worker-", 0).unstarted(task);
// ۳. الگوی اصطلاحی سرور: executor که برای هر task یک نخ مجازی جدید میسازد.
// این pool نیست — چیزی برای تعیین اندازه وجود ندارد.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // نخ مجازی را مسدود میکند، نه carrier را
return fetchFromDb();
});
}
} // close() منتظر همهٔ taskها میماند — try-with-resources = عمر ساختیافته
سه نکته را دقت کن. اول، Thread.ofVirtual() سازندهٔ نوع جدید است و Thread.ofPlatform() نوع قدیمی را صریحاً میسازد. دوم، Thread.currentThread().isVirtual() به تو میگوید همین الان روی کدام نوع نخ هستی. سوم و مهمترین: به کامنت مورد ۳ نگاه کن — newVirtualThreadPerTaskExecutor() یک pool نیست. برای هر taskی که submit میکنی یک نخ مجازیِ کاملاً تازه میسازد و بعد دورش میاندازد. اینجا حلقهٔ یک میلیونتایی نوشتیم و اصلاً نگران نبودیم؛ در دنیای نخ پلتفرم این کد ماشین را زانو میزد.
سه قاعدهٔ طلایی که مستقیم از طراحی میآیند
هر سه، نتیجهٔ همان اصلِ «ساخت ارزان است، مسدود شدن رایگان است»:
۱. هرگز نخ مجازی را pool نکن. poolها برای مستهلککردن هزینهٔ ساخت گران وجود دارند. نخ مجازی ارزان است و برای یکبارمصرف بودن طراحی شده — یکی برای هر task. از newVirtualThreadPerTaskExecutor() استفاده کن، نه pool ثابت. pool کردن نخ مجازی مثل این است که برای دستمالکاغذی یکبارمصرف، دستگاه ضدعفونی و بازیافت بخری.
۲. برای محدودکردن همزمانی، نخ پلتفرم را مسدود نکن — از semaphore استفاده کن. اگر پاییندستی فقط ۱۰ اتصال همزمان میپذیرد، pool ۱۰تایی از نخ پلتفرم نساز؛ نخ مجازی نامحدود اجرا کن و منبع کمیاب را با new Semaphore(10) کنترل کن. (semaphore مثل یک جعبهٔ ۱۰ کلیدی است: هرکس میخواهد وارد اتاق شود باید یک کلید بردارد و موقع خروج پس بدهد؛ نفر یازدهم پشت در منتظر میماند.)
۳. اشیای گران را در thread-local کش نکن. با میلیونها نخ، یک کش بهازای هر نخ فاجعهٔ حافظه است. thread-localها هنوز کار میکنند (و روش انتقال زمینهاند)، اما دیگر آنها را بهعنوان object pool بهازای هر نخ استفاده نکن.
اگر فقط یک چیز از این فصل بهخاطر بسپاری: نخ مجازی را pool نکن. این رایجترین اشتباهی است که مهندسها هنگام مهاجرت مرتکب میشوند — کد قدیمی یک Executors.newFixedThreadPool(200) دارد و کسی آن را به pool مجازی «تبدیل» میکند. کار اشتباه است؛ باید به newVirtualThreadPerTaskExecutor() برسی و کران را (اگر لازم است) با Semaphore بگذاری.
Pinning — تنها چیزی که میتواند نابودت کند
تا اینجا گفتیم پیادهکردن (unmount) جادوی Loom است. اما این جادو یک شرط دارد: JDK باید بتواند پشته را از روی carrier بردارد و روی heap بگذارد. در دو حالت این کار ممکن نیست، و نخ مجازی حتی در حین مسدود بودن به carrier خود سنجاق (pinned) میماند — یعنی واقعاً یک نخ پلتفرم را اشغال میکند و همان محدودیت مقیاسی که برای فرار از آن آمدیم را برمیگرداند.
یادت هست پارکبان که پیاده میشد و میرفت سراغ مهمان بعدی؟ حالا تصور کن قفل ماشین خراب است و کلید توی سوییچ گیر کرده — پارکبان نمیتواند پیاده شود. مجبور است همانجا بنشیند و منتظر بماند. اگر چند ماشین اینطور بشوند، همهٔ پارکبانهای محدودت پشت فرمانِ ماشینهای بیکار قفل میشوند و مهمانهای تازهرسیده هیچ پارکبانی پیدا نمیکنند. این دقیقاً pinning است.
دو حالتی که سنجاق رخ میدهد:
۱. مسدود شدن داخل بلوک/متد synchronized (روی JDK 21–23).
۲. مسدود شدن داخل فریم native (فراخوانی JNI یا برخی downcallهای FFM).
حالت ۱ بدنام است. پیش از JDK 24، اگر یک نخ مجازی monitor را از طریق synchronized نگه میداشت و سپس روی I/O مسدود میشد، carrier خود را سنجاق میکرد. («monitor» همان قفل نامرئی است که هر شیء در جاوا دارد و synchronized از آن استفاده میکند.) با تعداد کافی نخ مجازی سنجاقشده، pool carrierها را تمام میکنی → throughput فرومیپاشد و در بدترین حالت گرسنگی (starvation) یا بنبست (deadlock) میگیری: هیچ carrier آزادی نیست تا نخی را اجرا کند که قرار است قفل را آزاد کند، و همه منتظر همان قفل میمانند.
private final Object lock = new Object();
// بد روی JDK 21-23: read مسدودکننده در حین نگهداشتن monitor اجرا میشود → PIN
synchronized void badWrite(Socket s) throws IOException {
s.getOutputStream().write(payload); // در حال pinned مسدود میشود
}
// خوب همهجا: از j.u.c.Lock استفاده کن. ReentrantLock سنجاق نمیکند —
// Loom آن را میفهمد و میتواند نخ مجازی را در حین انتظار پیاده کند.
private final ReentrantLock lock = new ReentrantLock();
void goodWrite(Socket s) throws IOException {
lock.lock();
try { s.getOutputStream().write(payload); }
finally { lock.unlock(); }
}
تفاوت ظریف اما حیاتی است: synchronized یک ساختار زبانی و سطحپایین است که Loom نمیتواند وسطش پشته را جابهجا کند، اما ReentrantLock (از بستهٔ java.util.concurrent.locks) کدِ خالص جاواست که Loom میشناسدش و میتواند نخ مجازی را در حین انتظار روی قفل، پیاده کند.
JEP 491 (JDK 24) سنجاقشدن برای synchronized را حذف کرد. حالا مالکیت monitor بهازای هر نخ مجازی پیگیری میشود نه بهازای carrier، پس نخ مجازی که داخل متد/بلوک synchronized یا Object.wait() مسدود میشود، carrier خود را بهطور عادی آزاد میکند. روی JDK 24 به بعد، synchronized دیگر سنجاق نمیکند. اما سنجاق فریم native همچنان وجود دارد. خلاصه:
- روی JDK 21–23: بهدنبال
synchronizedدور I/O مسدودکننده بگرد و مسیرهای داغ را بهReentrantLockمهاجرت بده. - روی JDK 24 به بعد: این کل دستهٔ مشکل تقریباً محو میشود؛ فقط فریمهای native/FFM سنجاق میکنند.
چطور سنجاق را پیدا کنی؟ با اجرا با پرچم -Djdk.tracePinnedThreads=full (روی JDK 21) یا بهتر، یک ضبط JFR (Java Flight Recorder) — رویداد jdk.VirtualThreadPinned روی هر سنجاق با stack trace شلیک میشود، پس دقیقاً میبینی کدام خط مقصر است. و اگر سنجاق native واقعی داری که نمیتوانی حذفش کنی، میتوانی pool حامل را با -Djdk.virtualThreadScheduler.parallelism بزرگتر کنی تا carrier بیشتری داشته باشی.
کجا نباید از نخ مجازی استفاده کرد
نخهای مجازی برای مقیاسدادن همزمانیِ taskهای مسدودکننده و I/O-محور ساخته شدهاند. اگر این را نفهمی، ممکن است همهجا از آنها استفاده کنی و تعجب کنی چرا سود نکردی. ابزار اشتباهیاند وقتی:
- کار CPU-محور است. نخ مجازی برای محاسبهٔ خالص چیزی به تو نمیدهد — هنوز به یک carrier بهازای هر هستهٔ مشغول نیاز داری و ایدهآل یک pool کراندار به اندازهٔ هستههاست (مثلاً
ForkJoinPool). یادت باشد: Loom انتظار را بهبود میدهد، نه محاسبه را. اگر نخ اصلاً مسدود نمیشود، چیزی برای unmount کردن نیست. - taskها بهندرت مسدود میشوند. اگر taskها فوری برمیگردند، ماشینآلات mount/unmount سربار محض است؛ pool نخ پلتفرم سادهتر و سریعتر است.
- برای throttling به معناشناسی thread-pool وابستهای. اگر معماریات از «pool اندازهٔ N» بهعنوان مکانیزم back-pressure استفاده میکند، رفتن به نخ مجازی نامحدود میتواند پاییندستی را غرق کند. pool را با
Semaphoreصریح یا rate limiter جایگزین کن — فقط کران را حذف نکن. - کد قدیمی سنگین سنجاق میکند و روی JDK ≤ 23 با
synchronizedای که نمیتوانی تغییرش دهی گیر کردهای.
از خودت بپرس: «این task بیشتر عمرش را منتظر است یا مشغولِ محاسبه؟» اگر منتظر است (شبکه، دیتابیس، دیسک) → نخ مجازی عالی است. اگر مشغول است (رمزنگاری، پردازش تصویر، حلقهٔ ریاضی سنگین) → نخ مجازی کمکی نمیکند و باید pool به اندازهٔ هستهها بزنی.
همزمانی ساختیافته — هماهنگکردن این فراوانی
نخهای مجازی نخ ارزان به تو میدهند؛ اما وقتی یک درخواست به ده زیرتسک همزمان تقسیم میشود، چطور مطمئن شوی همهشان درست تمام یا لغو میشوند؟ اینجاست که همزمانی ساختیافته (structured concurrency) وارد میشود.
تصور کن یک سرپرست تور با ده گردشگر. قانون طلایی: هیچکس تنها برنمیگردد و هیچکس جا نمیماند. اگر یکی گم شد، سرپرست بقیه را جمع میکند و همه با هم تصمیم میگیرند. اتوبوس تا وقتی همه سوار نشوند حرکت نمیکند. این «درختِ تمیزِ عمرها» است.
حالا مقایسه کن با مدل بیساختار: ده نفر را جلوی موزه پیاده میکنی و میگویی «هرکس کارش تمام شد خودش برگردد». یکی گم میشود و تو نمیفهمی، یکی زودتر میرود، و تو ساعتها منتظر یک نفری میمانی که اصلاً نیامده. این دنیای Future خام است.
ایدهٔ اصلی: اگر یک task به زیرتسکهای همزمان تقسیم میشود، آن زیرتسکها باید داخل یک scope واژگانی — مثل بلوک try — زندگی و مرگ داشته باشند، تا عمرها یک درخت تمیز تشکیل دهند نه گرهای از futureهای fire-and-forget («بینداز و فراموش کن»).
دنیای بیساختار ExecutorService/Future را دقیقتر ببین: دو future submit میکنی و اگر یکی شکست بخورد باید یادت باشد دیگری را لغو کنی؛ اگر فراخوان interrupt شود، futureها نشت میکنند (یتیم میمانند و منابع را نگه میدارند)؛ خطاها از طریق ExecutionException دور از محل fork ظاهر میشوند. نشت نخ و کار یتیمشده، حالت شکستِ پیشفرضِ این مدل است.
StructuredTaskScope این انضباط را اجبار میکند: همهٔ زیرتسکهای forkشده در یک scope باید پیش از بستهشدن scope تمام شوند. اگر والد لغو شود، زیرتسکها لغو میشوند؛ اگر زیرتسکی شکست بخورد، خواهرها میتوانند لغو شوند. خطا و لغو در طول درخت task منتشر میشوند و کل ماجرا بهصورت یک درخت منسجم در دیباگر و در thread dump دیده میشود.
همزمانی ساختیافته هنوز قابلیت preview است — در JDK 19/20 incubate شد، اولین preview در JDK 21 (JEP 453) و تا JDK 25 همچنان preview (JEP 505 پنجمین preview بود). باید با --enable-preview اجرا کنی و API در JDK 25 بهطور اساسی تغییر کرد. پس بسته به JDK هدفت، مثال درست را انتخاب کن.
API preview جاوا ۲۱ (پرکاربردترین امروز)
// «همه را اجرا کن، سریع شکست بخور»: هر دو فراخوان را همزمان اجرا کن؛
// اگر یکی throw کرد، دیگری را لغو و propagate کن.
Response handle(long userId, long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> findUser(userId)); // هرکدام یک نخ مجازی
Subtask<Order> order = scope.fork(() -> fetchOrder(orderId));
scope.join() // منتظر هر دو تا تمام یا یکی شکست بخورد
.throwIfFailed(); // اولین شکست را دوباره پرتاب کن، بقیه را لغو کرده
return new Response(user.get(), order.get()); // امن: هر دو موفق بودند
} // close() تضمین میکند هیچ زیرتسکی از این بلوک عمر بیشتری ندارد
}
قدمبهقدم بخوانش: fork یک زیرتسک را روی یک نخ مجازیِ تازه اجرا میکند و یک Subtask برمیگرداند (شبیه Future). join() منتظر میماند تا هر دو تمام شوند یا یکی شکست بخورد. throwIfFailed() اگر شکستی بوده آن را دوباره پرتاب میکند — و چون سیاست ShutdownOnFailure است، اولین شکست بقیهٔ خواهرها را لغو میکند. فقط اگر به return رسیدی، مطمئنی هر دو موفق بودهاند و .get() امن است.
ShutdownOnSuccess دوگانِ آن است: چند منبع را با هم مسابقه بده و اولین موفقیت را بگیر، بازندهها را لغو کن. (کاربردش: فراخوانهای افزونه/hedged — همان داده را از سه سرور بپرس و هرکه زودتر جواب داد را بردار.)
بازطراحی جاوا ۲۵ (JEP 505 — بدان که وجود دارد)
JEP 505 سازندهها را با کارخانهٔ ایستای StructuredTaskScope.open(...) جایگزین کرد و سیاست تکمیل را به رابط قابلجایگزینِ Joiner منتقل کرد (فلسفه: ترکیب بر ارثبری). زیرکلاسکردن ShutdownOnFailure/ShutdownOnSuccess حذف شده:
// شکل preview جاوا ۲۵ — Joiner سیاست را کد میکند، join() نتیجه را برمیگرداند.
try (var scope = StructuredTaskScope.open(Joiner.<Response>allSuccessfulOrThrow())) {
Subtask<User> user = scope.fork(() -> findUser(userId));
Subtask<Order> order = scope.fork(() -> fetchOrder(orderId));
scope.join(); // اگر زیرتسکی شکست بخورد throw میکند؛ وگرنه همه تمام شدهاند
return new Response(user.get(), order.get());
}
معناشناسی یکسان است — عمر کراندار، لغوِ منتشرشده — اما سطح API فرق دارد، پس مثالهایت را به JDK هدف واقعی قفل کن.
Scoped values — جانشین ThreadLocal
انتقال زمینه (کاربر جاری، tenant، شناسهٔ درخواست) بدون عبور دادنش از هر امضای متد، بهطور سنتی یعنی ThreadLocal. اما thread-localها سه مشکل دارند: تغییرپذیرند، عمرشان نامحدود است (فراموشکردن remove() آسان است → نشت حافظه) و ارثبریشان به میلیونها نخ مجازی گران است.
ThreadLocal مثل یک یادداشت چسبانِ (sticky note) روی میز هر کارگر است: میتواند هر لحظه پاکش کند یا عوضش کند، و اگر یادش برود بردارد، برای همیشه آنجا میماند. Scoped value مثل تابلوی راهنمای موقتی است که سرِ ورودی یک راهرو نصب میکنی: تا وقتی داخل راهرویی همه میبینندش، و لحظهای که بیرون آمدی خودبهخود جمع میشود. نه دستکاری، نه فراموشی، نه نشت.
Scoped values جایگزینی تغییرناپذیر و ساختیافتهاند: یک مقدار برای گسترهٔ پویای یک run/call bind میشود، برای همهٔ calleeها (متدهایی که در آن گستره صدا زده میشوند) روی آن نخ دیده میشود — و به زیرتسکهای فرزند در StructuredTaskScope ارث میرسد — و هنگام خروج از بلوک بهطور خودکار unbind میشود. بدون تغییر، بدون نشت، ارثبری ارزان.
scoped values از JDK 21 (JEP 446) بهصورت preview بودند و در JDK 25 (JEP 506) نهایی شدند — تنها تغییر رفتاری هنگام نهاییشدن این است که ScopedValue.orElse دیگر null نمیپذیرد. روی JDK 21–24 هنوز previewاند.
final static ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(Request req) {
User u = authenticate(req);
// برای scope پویای این فراخوان bind کن؛ هنگام return خودکار unbind میشود.
ScopedValue.where(CURRENT_USER, u).run(() -> processRequest(req));
}
void processRequest(Request req) {
// بدون عبور دادن پارامتر، بدون ThreadLocal.remove() — فقط بخوان.
User u = CURRENT_USER.get(); // مقداری که بالا bind شد
audit(u.id());
}
نکتهٔ ظریف: rebind کردن، scopeدار است نه تغییر. ScopedValue.where(CURRENT_USER, other).run(...) یک binding جدید فقط برای گسترهٔ تودرتو میسازد؛ binding بیرونی دستنخورده میماند. این دقیقاً همان چیزی است که آن را ایمن میکند — هیچکس نمیتواند مقدارِ دیگری را از راه دور خراب کند.
نخ مجازی در برابر reactive: انتخاب
حالا که هر دو دنیا را میشناسی، سؤال عملی این است: کِی کدام؟
| بُعد | نخ مجازی | reactive (Reactor/RxJava) |
|---|---|---|
| مدل برنامهنویسی | مسدودکنندهٔ ساده، امری | callback / زنجیرهٔ operator |
| قابلیت دیباگ | پشتهٔ واقعی، breakpoint عادی | تکهتکه، پشتهٔ دشوار |
| مقیاسدهی I/O | عالی | عالی |
| Backpressure | دستی (Semaphore/صف) | داخلی و درجهیک |
| streaming / ترکیب | ضعیفتر | operatorهای غنی |
| کار CPU-محور | بدون سود | بدون سود |
| هزینهٔ مهاجرت | کم (کد مسدودکننده بماند) | زیاد (بازنویسی) |
نگاه صادقانه: برای بار کاری کلاسیک «مدیریت درخواستهای مسدودکنندهٔ فراوان»، نخهای مجازی throughput همطراز reactive را با کدِ بهطرز چشمگیری سادهتر میدهند و پیشفرض جدید برای سرویسهای تازه روی JDK 21+ هستند. reactive هنوز جایی برنده است که به ترکیب پیچیدهٔ stream و backpressure درجهیک در مرزهای async نیاز داری. («backpressure» یعنی وقتی مصرفکننده کندتر از تولیدکننده است، سیستم خودش سرعت را کنترل کند تا صف منفجر نشود.) فریمورکها در حال تطبیقاند: Spring Boot 3.2 به بعد میتواند کل پشتهٔ servlet را با spring.threads.virtual.enabled=true روی نخ مجازی بگذارد.
دامها و نکات ظریف رایج
- pool کردن نخهای مجازی — ضدالگوی شمارهٔ یک. هرگز نکن.
synchronizedدور I/O مسدودکننده روی JDK ≤ 23 — سنجاق خاموش و بیصدا؛ بهReentrantLockمهاجرت کن یا به JDK 24+ ارتقا بده.ThreadLocalبهعنوان کش — میلیونها نخ × شیء کششده = OOM (کمبود حافظه). برای زمینه از scoped values استفاده کن؛ کشهای واقعی را مشترک نگه دار.- فرض تسریع کار CPU — نمیشود؛ هنوز pool به اندازهٔ هستهها لازم داری.
- حذف کران thread-pool بدون افزودن Semaphore — به دیتابیس/پاییندستیِ خودت DoS میزنی.
- حذف
--enable-previewو انتظار کامپایلStructuredTaskScope— تا JDK 25 هنوز preview است؛ خودِ نخهای مجازی preview نیستند (JDK 21+). - انتظار بهبود زمان/تأخیر — Loom throughput/مقیاس را بهبود میدهد نه تأخیر هر درخواست. یک فراخوان مسدودکنندهٔ منفرد سریعتر نمیشود.
بهترین شیوهها
- سرویسهای جدید روی JDK 21+: برای مدیریت درخواست پیشفرض را نخ مجازی بگذار؛ اما پیش از فرض، اندازهگیری کن.
- از
Executors.newVirtualThreadPerTaskExecutor()داخل try-with-resources استفاده کن؛ هرگز pool ثابت. - پاییندستیهای کمیاب را با
Semaphoreکنترل کن، نه با اندازهٔ thread-pool. - روی مسیرهای داغِ مسدودکننده
java.util.concurrent.locks.Lockرا برsynchronizedترجیح بده (JDK ≤ 23) و در هر محیط مانیتورینگ JFRjdk.VirtualThreadPinnedرا فعال کن. - زیرتسکهای همزمان را با
StructuredTaskScopeهماهنگ کن (پرچم preview را بپذیر) نه با دستکاری خامFuture؛ لغو را propagate کن. - انتقال زمینه را روی JDK 25 از
ThreadLocalبه scoped values مهاجرت بده.
سؤالات مصاحبه
حالا بیایید همهچیز را در قالب سؤالهای واقعی مصاحبه جمع کنیم. هر جواب را بلند بخوان و بعد سعی کن با کلمات خودت بازگو کنی.
نخ مجازی نمونهای از java.lang.Thread است که JDK زمانبندیاش میکند نه سیستمعامل. نخهای مجازی بسیار (M) روی carrierهای پلتفرمی کم (N) مالتیپلکس میشوند. پشتهٔ کوچک و تغییرپذیر روی heap دارد نه پشتهٔ ثابت ~۱MB سیستمعامل، ساختش ارزان است و برای یکبارمصرف بودن (یکی برای هر task) طراحی شده. نخ پلتفرم پوشش ۱:۱ روی نخ سیستمعامل است، کرنلزمانبند و گران.
وقتی نخ مجازی اجرا میشود، JDK آن را روی carrier (نخ پلتفرمی ForkJoinPool) سوار میکند. وقتی به نقطهٔ مسدودکنندهٔ Loom-آگاه میرسد (بیشتر I/O مسدودکنندهٔ java.*، LockSupport.park، Thread.sleep، انتظار ReentrantLock، NIO)، JDK ادامهٔ اجرایش را روی heap میگیرد و پیاده میکند و carrier را آزاد میکند تا نخ مجازی دیگری را اجرا کند. هنگام تکمیل دوباره سوار میشود — شاید روی carrier متفاوت — و ادامه میدهد. کل هدف: مسدود کردن نخ مجازی هرگز نخ سیستمعامل را مسدود نمیکند.
سنجاق یعنی نخ مجازی مسدود میشود ولی نمیتواند پیاده شود، پس carrier خود را نگه میدارد. رخ میدهد (الف) داخل بلوک/متد synchronized روی JDK 21–23 و (ب) داخل فریم native/JNI. خطر: نخهای مجازی سنجاقشده carrierها را مصرف میکنند؛ تعداد کافی از آنها pool carrier را تمام میکند، throughput فرومیپاشد و احتمالاً starvation یا deadlock میشود. رفع: بهجای synchronized از ReentrantLock استفاده کن، یا به JDK 24 ارتقا بده که JEP 491 باعث شد synchronized دیگر سنجاق نکند (مالکیت monitor بهازای هر نخ مجازی پیگیری میشود). سنجاق native همچنان وجود دارد.
synchronized void write(OutputStream out, byte[] data) throws IOException {
out.write(data); // I/O مسدودکننده داخل synchronized
}
نه — روی JDK 24 به بعد نه. JEP 491 سنجاق synchronized را حذف کرد. روی JDK 21–23 سنجاق میکرد. این تلهٔ کلاسیکِ حساس به نسخه است؛ پاسخ درست باید JDK را ذکر کند، وگرنه ناقص است.
poolها ساخت گران را مستهلک و همزمانی را کراندار میکنند. نخ مجازی ارزان است و برای یکبارمصرف بودن طراحی شده — یکی برای هر task با newVirtualThreadPerTaskExecutor(). pool کردن آنها کرانی مصنوعی برمیگرداند، مدل را شکست میدهد و حتی میتواند deadlock کند. برای محدودکردن همزمانی روی منبع کمیاب از Semaphore استفاده کن، نه pool.
pool ۲۰تایی دو کار میکرد: اجرای task و throttle کردن. نخ مجازی نامحدود است، پس throttle ناپدید شد و حالا دیتابیس را با هزاران کوئری همزمان میکوبی. رفع: نخ مجازی نامحدود برای مدیریت درخواست نگه دار اما دیتابیس را با new Semaphore(20) محافظت کن (یا connection pool درستاندازهای که روی checkout مسدود میشود). همزمانی را از throttling جدا کن.
برای کار CPU-محور (بدون سود؛ هنوز pool به اندازهٔ هستهها لازم داری)، برای taskهایی که بهندرت مسدود میشوند (mount/unmount سربار است) و وقتی به اندازهٔ pool بهعنوان تنها throttle خود، بدون جایگزینی با semaphore، وابستهای. آنها انتظار را بهینه میکنند نه محاسبه را و تأخیر یک درخواست را کم نمیکنند.
futureهای بیساختار نشت میکنند: اگر زیرتسکی شکست بخورد باید دستی خواهرها را لغو کنی؛ اگر والد interrupt شود زیرتسکها یتیم میشوند؛ خطاها دور از fork ظاهر میشوند. StructuredTaskScope عمر زیرتسک را به بلوک واژگانی میبندد — همهٔ forkها پیش از بستهشدن scope تمام میشوند، شکست خواهرها را لغو میکند، لغو در درخت منتشر میشود و thread dump درخت منسجم والد/فرزند نشان میدهد.
ShutdownOnFailure: N زیرتسک fork کن، join() سپس throwIfFailed() — فقط اگر همه موفق شدند ادامه بده؛ اولین شکست بقیه را لغو میکند (fan-out که هر نتیجه لازم است). ShutdownOnSuccess: N زیرتسک مسابقه بده و اولین موفقیت را بگیر، بازندهها را لغو کن (فراخوانهای افزونه/hedged). توجه: JEP 505 در JDK 25 این زیرکلاسها را با Joiner قابلجایگزین که به StructuredTaskScope.open(...) پاس میشود جایگزین کرد.
نخ مجازی: نهایی در JDK 21 (JEP 444) — بدون پرچم preview. همزمانی ساختیافته: هنوز preview (اولین preview JEP 453 در JDK 21، پنجمین preview JEP 505 در JDK 25) — نیاز به --enable-preview. Scoped values: از JDK 21 preview (JEP 446)، نهایی در JDK 25 (JEP 506). قاطیکردن اینها رایجترین خطای سطح ارشد است.
scoped values تغییرناپذیر و کراندارند: برای گسترهٔ پویای یک run/call bind میشوند و هنگام خروج خودکار unbind، پس بدون remove()، بدون نشت و بدون تغییر تصادفی. ارثبری به زیرتسکهای فرزند (از طریق StructuredTaskScope) ارزان و صریح است، که وقتی میلیونها نخ مجازی داری اهمیت دارد — ارثرساندن thread-localهای تغییرپذیر به همه، خطر حافظه و صحت است.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Row>> futures = new ArrayList<>();
for (Long id : ids) futures.add(executor.submit(() -> queryDb(id)));
return futures.stream().map(f -> f.get()).toList(); // <-- ؟
}
دو مشکل. اول، Future.get() استثنای checked پرتاب میکند (InterruptedException، ExecutionException)، پس این کد داخل lambda بدون مدیریت کامپایل نمیشود. دوم و مهمتر: اگر ids صد هزار عضو داشته باشد و queryDb به دیتابیسی با connection pool کوچک بزند، صد هزار کوئری همزمان spawn کردهای و دیتابیس را تمام/غرق میکنی — هیچ Semaphoreای منبع کمیاب را throttle نمیکند. همچنین یک get() شکستخورده بقیهٔ taskهای در حال اجرا را روشن میگذارد (بدون لغو هماهنگ) — کاری برای StructuredTaskScope بهجای جمعآوری دستی future.
پرچم trace (JDK 21) و رویداد JFR VirtualThreadPinned هر رخداد سنجاق را با stack trace گزارش میدهند، پس فریم synchronized/native مقصر را مییابی. parallelism زمانبند (پیشفرض = هستههای CPU) را فقط وقتی بالا میبری که سنجاق native اجتنابناپذیر داری و carrier بیشتری لازم است تا نخهای سنجاقشده pool را گرسنه نکنند — یک تسکین، نه درمان.
نه. Loom throughput و مقیاس را بهبود میدهد — چند درخواست مسدودشدهٔ همزمان را میتوانی سرویس دهی — نه تأخیر هر درخواست. یک فراخوان دیتابیس مسدودکنندهٔ منفرد دقیقاً همانقدر طول میکشد. اگر گلوگاهت یک وابستگی سنکرون کند است، نخ مجازی به آن درخواست کمکی نمیکند؛ کمک میکند تا چنین درخواستهای فراوانی را بدون تمامکردن نخهای سیستمعامل سرویس دهی.
نکاتِ سنیور و موارد پیشرفته
تا اینجا مدل ذهنی درست را ساختی. حالا برویم سراغ چیزهایی که فقط وقتی Loom را در production فشار میدهی به آنها برمیخوری — همانهایی که یک مصاحبهگر سنیور با آنها سطحِ واقعیات را میسنجد. اینجا هیچکدام از مطالب فصل را تکرار نمیکنیم؛ فقط لایهٔ عمیقتر را اضافه میکنیم.
۱) زمانبندِ واقعی زیر پوستت (parallelism در برابر maxPoolSize=256 و compensation). ۲) زمانبندی «تعاونی» و اینکه چطور یک نخ CPU-bound همسایههایش را گرسنه میکند. ۳) هر blocking یکسان نیست: تفاوت compensation، pinning و I/O فایل. ۴) منابعِ pinning که هیچکس هشدارش را نمیدهد (لاگ، درایور، PrintStream). ۵) شکستنِ ThreadLocal/MDC و فرضِ «نخِ نامدار». ۶) رصد (observability) با یک میلیون نخ. ۷) backpressure و admission control. ۸) حافظه و GC. ۹) StructuredTaskScope: مهلت (deadline)، نتیجهٔ جزئی و Joinerِ دستساز.
زمانبندِ واقعی زیر پوستت: parallelism در برابر maxPoolSize
فصل گفت carrierها یک ForkJoinPool به تعداد هستهها هستند. اما این نصفِ ماجراست. آن عدد، parallelism است (حالتِ پایدار). یک عددِ دومی هم هست: maxPoolSize که پیشفرضش ۲۵۶ است (-Djdk.virtualThreadScheduler.maxPoolSize). چرا دو عدد؟ بهخاطرِ سازوکاری به نامِ compensation (جبرانسازی).
parallelism یعنی «۸ آشپزِ ثابت داریم». اما وقتی یک آشپز مجبور میشود کاری کند که نمیشود از آن کنار کشید (مثلاً باید دستی برود انبار و منتظر بماند)، مدیر موقتاً یک آشپزِ ذخیره صدا میزند تا آشپزخانه از کار نیفتد — تا سقفِ ۲۵۶ نفر. وقتی آن آشپز برگشت، ذخیره مرخص میشود. این «آشپزِ ذخیره» همان compensation است.
وقتی یک نخ مجازی روی عملیاتی گیر میکند که Loom میتواند unmount کند (سوکت، sleep، قفل j.u.c) هیچ carrierای مسدود نمیشود و compensation لازم نیست. اما بعضی blockingها carrier را واقعاً اشغال میکنند؛ آنجا JDK موقتاً یک carrierِ اضافه به pool تزریق میکند تا throughput نخوابد. پس در thread-dumpِ production ممکن است بیش از «تعدادِ هسته» نخِ carrier ببینی و این طبیعی است.
سازوکارِ compensation فقط برای blockingِ «قابلِ جبران» فعال میشود (مثل I/O فایل). برای pinning (بلاک شدن داخلِ synchronized روی JDK ۲۱–۲۳، یا فریمِ native/JNI) زمانبند هیچ carrierِ جبرانی اضافه نمیکند. دقیقاً به همین دلیل pinning اینقدر خطرناکتر از یک I/O کند است: نهتنها carrier را میگیرد، بلکه سیستم هم آن را جبران نمیکند و مستقیم به سمتِ گرسنگی (starvation) میروی.
زمانبندی «تعاونی» است، نه «پیشدستانه» — و این یک تله است
نخهای پلتفرم را کرنل پیشدستانه (preemptive) زمانبندی میکند: هر چند میلیثانیه، چه بخواهی چه نخواهی، از CPU کنار گذاشته میشوی. نخهای مجازی اینطور نیستند. یک نخ مجازی فقط در نقاطِ blocking (یا Thread.yield()) از carrier پیاده میشود. تا وقتی به چنین نقطهای نرسیده، carrier مالِ خودش است.
نتیجهٔ مستقیم: یک نخ مجازیِ CPU-bound که در یک حلقهٔ فشرده بدونِ هیچ blocking میچرخد، هرگز unmount نمیشود و آن carrier را برای مدتِ طولانی قُرُق میکند. اگر چند تا از اینها داشته باشی، میتوانند تمامِ carrierها را بگیرند و نخهای مجازیِ I/O-bond که آمادهاند اجرا شوند، پشتِ صف گیر میکنند — بدونِ آنکه هیچ pinningای در کار باشد.
// این نخ مجازی هیچوقت داوطلبانه پیاده نمیشود → carrier را قُرُق میکند
Thread.ofVirtual().start(() -> {
long x = 0;
for (long i = 0; i < 50_000_000_000L; i++) x += i * 31; // نه I/O، نه yield
sink(x);
});
Loom «انتظار» را ارزان میکند، نه «محاسبه» را. کارِ CPU-bound را نگذار روی نخِ مجازی رها شود؛ یا آن را به یک pool بهاندازهٔ هستهها بسپار، یا اگر مجبوری، در حلقههای طولانی بهصورت دورهای Thread.yield() بزن تا امکانِ عدالت بدهی. در مصاحبه اگر بگویی «نخِ مجازیِ CPU-bound بهخاطرِ زمانبندیِ تعاونی میتواند همسایههایش را گرسنه کند»، نشان میدهی که مدل را از داخل فهمیدهای.
هر blocking یکسان نیست: I/O سوکت در برابر I/O فایل
یک تفاوتِ ظریف که خیلیها نمیدانند: I/O شبکه/سوکت واقعاً non-blocking پیادهسازی شده و نخِ مجازی را تمیز unmount میکند. اما I/O فایلِ سیستمِ فایل روی اغلبِ سیستمعاملها ذاتاً non-blocking نیست (خواندنِ یک فایل روی دیسک را نمیشود مثلِ سوکت به epoll سپرد). پس در JDK ۲۱–۲۳ عملیاتِ فایل، carrier را بلاک میکرد و JDK با compensation (carrierِ اضافه) سرِپا نگهش میداشت — نه با unmount. یعنی یک بار-کارِ سنگینِ فایل، مثلِ یک بار-کارِ سوکت مقیاس نمیگیرد.
این جزئیاتِ compensation در نسخههای مختلف تغییر کرده و بهبود یافته؛ روی هر JDK که مستقر میشوی با یک بار-کارِ فایلِ واقعی اندازه بگیر، نه اینکه فرض کنی «فایل هم مثلِ سوکت مقیاس میگیرد». پیامِ پایدار این است: پیشفرض نگیر که هر blockingای معادلِ unmountِ مجانی است.
منابعِ pinning که هیچکس در آموزشها نمیگوید
فصل، synchronized و native را بهعنوان دو منبعِ pinning گفت. اما در production خطر معمولاً از کدِ خودت نیست، از کتابخانههای شخصِ ثالث است که deep در مسیرِ داغ synchronized دارند و تو خبر نداری:
- فریمورکهای لاگ: appenderهای همگام (مثلاً Logback کلاسیک) درونِ
synchronizedروی نوشتنِ فایل/کنسول بلاک میشوند. یک برنامهٔ پرلاگ روی JDK ۲۱–۲۳ میتواند بهشکلِ نامرئی همهجا pin شود. System.out/PrintStreamوBufferedOutputStream: متدهایشانsynchronizedاند؛ لاگکردن باSystem.out.printlnدر مسیرِ داغ = pinning.- درایورهای قدیمیِ JDBC و بعضی connection-poolها که در قفلِ داخلیشان
synchronized+ I/O دارند.
کلاسیکترین اتفاقِ production این است: مهاجرت میکنی، تست لوکال سبز است، اما زیرِ بار throughput نمیرود بالا. علت: یک کتابخانهٔ لاگ یا درایور در مسیرِ داغ pin میکند و carrierها را قفل میکند. رفعش: روی JDK ۲۴+ برو (JEP 491 مشکلِ synchronized را از ریشه حل کرد)، و در هر محیط رویدادِ JFR jdk.VirtualThreadPinned را روشن نگهدار تا خطِ دقیقِ مقصر را ببینی — چه در کدِ خودت، چه در کدِ کتابخانه.
ThreadLocal، MDC و فرضِ «نخِ نامدار» میشکنند
سه فرضِ قدیمی که با نخِ مجازی زمین میخورند:
۱) ارثبریِ ThreadLocal پیشفرض خاموش است. Thread.ofVirtual() مقادیرِ InheritableThreadLocal را از نخِ والد به ارث نمیبرد مگر صراحتاً بخواهی. کدِ قدیمیای که به «فرزند، context والد را دارد» تکیه کرده بود، بیصدا null میگیرد. (سازندهٔ نخ حتی میتواند با .allowSetThreadLocals(false) کلاً ThreadLocal را قدغن کند تا مصرفِ حافظه صفر شود.)
۲) MDCِ لاگ روی ThreadLocal سوار است. پس با نخِ مجازی هنوز کار میکند، اما اگر فریمورک MDC را از استخری از نخها کپی میکرد، آن مسیرِ کپی دیگر معنا ندارد. راهِ درستِ مدرن برای انتشارِ context (کاربر/tenant/traceId) در دنیای Loom همان scoped values است که فصل معرفی کرد.
۳) نخِ مجازی پیشفرض بینام است. هر متریک یا لاگی که به نامِ نخ (worker-7) تکیه میکرد، حالا رشتهٔ خالی میبیند. کدِ observabilityای که «نخ را با نامش گروهبندی میکرد» باید بازنویسی شود.
رصد با یک میلیون نخ: ابزارهایت عوض میشوند
jstack کلاسیک برای میلیونها نخ بیفایده (و کند) است. Loom یک thread-dumpِ جدید آورد که مخصوصِ همین است:
# دامپِ گروهبندیشده و ماشینخوان (JSON) که درختِ StructuredTaskScope را نشان میدهد
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
این دامپِ جدید نخهای مجازی را گروهبندیشده و در قالبِ درختِ scopeها نشان میدهد — دقیقاً همان چیزی که structured concurrency را قابلِ دیباگ میکند. در JFR هم بدان: jdk.VirtualThreadStart/VirtualThreadEnd بهخاطرِ حجمِ عظیم پیشفرض خاموشاند، اما jdk.VirtualThreadPinned و jdk.VirtualThreadSubmitFailed روشناند و دقیقاً همانهاییاند که در production باید تماشا کنی.
با نخِ پلتفرم، «تعدادِ نخ» یک متریکِ سلامت بود. با Loom این عدد بیمعناست (میلیونها نخ = عادی). بهجایش اینها را رصد کن: تعدادِ carrierهای مشغول، نرخِ رویدادِ pinning، و مصرفِ heap (چون پشتهٔ نخهای پارکشده روی heap است). اگر مصاحبهگر بپرسد «چطور میفهمی برنامهٔ Loomت سالم است؟» جواب همین سهتاست، نه «چند نخ داری».
backpressure: استخر، «دربان»ات هم بود
فصل اشاره کرد که pool، هم اجرا میکرد هم throttle. اما یک لایهٔ بالاتر هم هست: در مدلِ قدیمی، محدودیتِ نخها یک admission control طبیعی در لبهٔ سیستم بود — وقتی همه نخها مشغول بودند، اتصالِ جدید در صفِ accept منتظر میماند و همین از overload جلوگیری میکرد. با نخِ مجازی میتوانی بینهایت اتصال بپذیری؛ یعنی آن دربانِ طبیعی حذف شده و در یک اسپایک، میلیونها درخواست همزمان وارد میشوند و حافظه/پاییندست را میترکانند.
حذفِ محدودیتِ نخها بدونِ گذاشتنِ یک سقفِ صریح در لبه = خودت را DoS کردن. یک Semaphore، یک rate limiter، یا یک صفِ محدود در ورودی بگذار. «Loom مقیاس میگیرد» به این معنا نیست که «باید بینهایت کار را همزمان قبول کنی».
حافظه و GC: حالا heap را میشماری، نه نخ را
پشتهٔ یک نخِ مجازیِ پارکشده (continuationاش) روی heap زندگی میکند. پس یک میلیون نخ با پشتههای عمیق = فشارِ واقعی روی heap و GC. مصرفِ حافظه حالا با عمقِ پشته × تعدادِ نخِ فعال میرود بالا، نه با یک megabyteِ ثابت per thread. نتیجهٔ عملی: پروفایلت عوض میشود؛ بهجای «address space برای پشتهها»، نگرانِ «heap و مکثِ GC» باش. نخِ عمیقِ بازگشتی (deep recursion) در میلیونها نسخه میتواند بهشکلِ غیرمنتظره OOM بدهد.
StructuredTaskScope در دنیای واقعی: مهلت، نتیجهٔ جزئی، Joinerِ دستساز
فصل ShutdownOnFailure/ShutdownOnSuccess را گفت. سه چیزِ عملی که در production لازم میشوی:
- مهلت (deadline): بهجای
join()بیانتها، ازjoinUntil(Instant)استفاده کن تا اگر subtaskها تا یک ددلاین تمام نشدند، همه با هم لغو شوند. این همان الگوی «کلِ درخواست باید در ۲۰۰ms جواب بدهد وگرنه fallback» است. - نتیجهٔ جزئی (partial results): گاهی نمیخواهی با اولین شکست همهچیز بمیرد؛ میخواهی «هر چه موفق شد را جمع کن و شکستها را نادیده بگیر» (مثلِ fan-out به ۵ منبع که ۳ تای موفق کافی است). در API نسلِ ۲۵ این با یک
Joinerِ سفارشی (یاallUntil(predicate)) بیان میشود؛ در نسلِ ۲۱ خودت رویSubtask.state()حلقه میزنی. - جهتِ لغو (cancellation) بهسمتِ پایین است: لغو از والد به فرزند propagate میشود، اما فرزند نمیتواند والد را لغو کند. این یکطرفهبودن همان چیزی است که درخت را «امن» میکند.
// مهلتِ کلِ درخواست: هر دو subtask تا ددلاین یا لغو
Instant deadline = Instant.now().plusMillis(200);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> findUser(id));
var order = scope.fork(() -> fetchOrder(id));
scope.joinUntil(deadline); // اگر تا ددلاین تمام نشد → TimeoutException و لغوِ همه
scope.throwIfFailed();
return new Response(user.get(), order.get());
}
نمودار: بلاک شدنِ نخِ مجازی چه سرنوشتی دارد؟
flowchart TD
B[Virtual thread blocks] --> Q{Loom-aware unmount possible?}
Q -- "socket I/O, sleep, j.u.c lock" --> U[Unmount: stack to heap, carrier freed]
Q -- "no" --> P{Why not?}
P -- "file I/O, Object.wait pre-24" --> C[Compensate: add carrier up to maxPoolSize 256]
P -- "synchronized JDK 21-23, or JNI/native" --> PIN[PINNED: carrier held, NO compensation]
U --> OK[Scales to millions]
C --> OK2[Scales, but bounded by 256 carriers]
PIN --> BAD[Throughput collapse, risk of starvation/deadlock]
سؤالات مصاحبهٔ سنیور (سخت)
هر دو حالتیاند که carrier بلاک میشود، اما پاسخِ زمانبند فرق دارد. در blockingِ قابلِ جبران (مثلِ I/O فایل یا Object.wait پیش از JDK ۲۴)، زمانبند موقتاً یک carrierِ اضافه به pool تزریق میکند (تا سقفِ maxPoolSize=۲۵۶) تا throughput حفظ شود؛ نخِ مجازی همچنان کارش را میکند و سیستم سرِپا میماند. اما pinning (بلاک داخلِ synchronized روی JDK ۲۱–۲۳ یا فریمِ native/JNI) هیچ carrierِ جبرانی نمیگیرد: carrier بیجبران قفل میشود و اگر تعدادِ pinها زیاد شود مستقیم به starvation/deadlock میرسی. پس pinning بدتر از یک I/O کند است، چون سیستم آن را نمیبیند و جبران نمیکند.
بهاحتمالِ زیاد یک بار-کارِ CPU-bound روی نخِ مجازی داری. زمانبندیِ نخِ مجازی تعاونی است، نه پیشدستانه؛ یعنی نخ فقط در نقاطِ blocking (یا Thread.yield()) از carrier پیاده میشود. یک حلقهٔ محاسباتیِ فشرده هیچ نقطهٔ blocking ندارد، پس هرگز unmount نمیشود و carrier را قُرُق میکند. چند تای اینها همهٔ carrierها را میگیرند و نخهای I/O-boundِ آماده پشتِ صف میمانند — بدونِ هیچ pinning. راهحل: کارِ محاسباتی را ببر روی یک pool بهاندازهٔ هستهها، یا در حلقههای طولانی دورهای yield بزن.
نه لزوماً. I/O شبکه/سوکت واقعاً non-blocking پیاده شده و نخِ مجازی را تمیز unmount میکند، پس تا میلیونها اتصالِ منتظر مقیاس میگیرد. اما I/O سیستمِ فایل روی اغلبِ OSها ذاتاً non-blocking نیست؛ در نسخههای اولیه (JDK ۲۱–۲۳) عملیاتِ فایل carrier را بلاک میکرد و JDK با compensation (افزودنِ carrier، نه unmount) سرِپا نگهش میداشت. نتیجه اینکه بار-کارِ سنگینِ فایل با سقفِ carrierها (۲۵۶) محدود میشود و مثلِ سوکت به میلیونها نمیرسد. درسِ کلی: فرض نکن هر blockingای = unmountِ مجانی؛ روی نسخهٔ هدفت اندازه بگیر.
منبعِ pinning تقریباً همیشه کدِ شخصِ ثالث در مسیرِ داغ است، نه کدِ خودت: appenderِ همگامِ یک فریمورکِ لاگ، متدهای synchronizedِ PrintStream/BufferedOutputStream (مثلاً System.out.println در hot path)، یا یک درایورِ JDBC/connection-poolِ قدیمی. برای پیداکردنش رویدادِ JFR jdk.VirtualThreadPinned را روشن کن — این رویداد با هر pin، خطِ دقیق و carrier را میدهد. رفعِ ریشهای: به JDK ۲۴+ برو که JEP 491 مشکلِ synchronized را حذف کرد؛ فقط pinningِ native باقی میماند.
Thread.ofVirtual() بهطورِ پیشفرض InheritableThreadLocal را از والد به ارث نمیبرد (این یک انتخابِ عمدی برای جلوگیری از انفجارِ حافظه در میلیونها نخ است). پس هر منطقی که فرض میکرد «فرزند بهصورتِ خودکار context والد را دارد» میشکند. راهِ درستِ مدرن این نیست که ارثبری را زورکی روشن کنی، بلکه انتشارِ context را به scoped values ببری: مقدار در دامنهٔ اجرا bind میشود، بهشکلِ ارزان به subtaskهای داخلِ StructuredTaskScope به ارث میرسد، و خودکار unbind میشود — بدونِ نشتی و بدونِ فراموشیِ remove().
با نخِ پلتفرم «تعدادِ نخ» یک متریکِ سلامت بود؛ با Loom بیمعناست (میلیونها نخ = عادی). سه چیز را رصد کن: (۱) تعدادِ carrierهای مشغول و نرخِ compensation؛ (۲) نرخِ رویدادِ pinning (jdk.VirtualThreadPinned)؛ (۳) مصرفِ heap و مکثِ GC، چون پشتهٔ نخهای پارکشده روی heap است. برای دیباگ از thread-dumpِ جدید استفاده کن: jcmd <pid> Thread.dump_to_file -format=json که نخها را در قالبِ درختِ scope گروهبندی میکند. jstackِ کلاسیک برای میلیونها نخ نه مقیاس دارد نه ساختارِ درخت را نشان میدهد.
از مهلت استفاده کن: بهجای join()، scope.joinUntil(Instant.now().plusMillis(200)) بزن. اگر تا ددلاین همهٔ subtaskها تمام نشدند، TimeoutException پرتاب میشود و چون در scope هستی، تمامِ subtaskهای در حالِ اجرا با هم لغو میشوند (لغو از والد به فرزند propagate میشود). این خیلی تمیزتر از مدیریتِ دستیِ Future.get(timeout) روی چند future است، چون آنجا timeoutِ یکی، بقیه را خودکار لغو نمیکند و leak میدهی. نکته: لغو یکطرفه است — فرزند نمیتواند والد را لغو کند، و همین درخت را امن نگه میدارد.
دو نقطه. اول، maxPoolSize (پیشفرض ۲۵۶) سقفِ carrierهاست، نه سقفِ concurrency تو؛ دستکاریاش برای کنترلِ نرخِ کارِ برنامه ابزارِ غلطی است و میتواند به رفتارِ compensation آسیب بزند. برای throttleِ کارِ برنامه، Semaphore (لایهٔ منطقی) درست است، نه پارامترِ زمانبند. دوم، باید مطمئن شد این Semaphore دارد منبعِ کمیابِ واقعی را گیت میکند (مثلِ اتصالِ DB) و عددش با ظرفیتِ آن منبع همتراز است؛ اگر connection-poolِ DB خودش سقفِ ۲۰ دارد، همان pool عملاً Semaphoreِ توست و گذاشتنِ یک Semaphoreِ ۲۵۶ رویش فقط توهمِ کنترل میدهد. جداکردنِ «سقفِ carrier (زیرساخت)» از «سقفِ concurrency منطقی (Semaphore)» از «سقفِ منبع (poolِ DB)» نشانهٔ فهمِ سنیور است.
- زمانبند دو عدد دارد: parallelism (هستهها، حالتِ پایدار) و maxPoolSize=۲۵۶ (سقفِ carrier با compensation).
- compensation فقط برای blockingِ قابلِ جبران (مثلِ I/O فایل) کار میکند؛ برای pinning هیچ جبرانی نیست — برای همین pinning خطرناکتر است.
- زمانبندی تعاونی است: نخِ مجازیِ CPU-bound بدونِ blocking هرگز unmount نمیشود و carrier را قُرُق میکند.
- I/O سوکت واقعاً unmount میکند؛ I/O فایل جبران میشود (پس محدود به ۲۵۶ carrier) — یکسان مقیاس نمیگیرند.
- منابعِ واقعیِ pinning در production معمولاً کتابخانهاند (لاگ،
PrintStream، درایورِ قدیمی) — با JFRjdk.VirtualThreadPinnedپیدایش کن، با JDK ۲۴+ ریشهکنش کن. - ThreadLocal بهطورِ پیشفرض به فرزند ارث نمیرسد؛ نخِ مجازی بینام است؛ observabilityِ ناممحور میشکند — به scoped values و thread-dumpِ JSON برو.
- متریکِ سلامت دیگر «تعدادِ نخ» نیست، بلکه carrierهای مشغول، نرخِ pinning و heap/GC است.
- StructuredTaskScope:
joinUntilبرای ددلاین، لغوِ یکطرفهٔ والد→فرزند، و Joinerِ سفارشی برای نتیجهٔ جزئی.
- نخ مجازی یک
java.lang.Threadاست که JDK زمانبندی میکند، نه سیستمعامل؛ M نخ مجازی روی N نخ حامل (carrier) مالتیپلکس میشوند. - جادوی اصلی mount/unmount است: وقتی نخ مجازی روی I/O مسدود میشود، پشتهاش روی heap میرود و carrier آزاد میشود. مسدود کردن نخ مجازی، نخ سیستمعامل را مسدود نمیکند.
- سه قاعدهٔ طلایی: pool نکن (از
newVirtualThreadPerTaskExecutor()استفاده کن)، برای کران از Semaphore استفاده کن نه اندازهٔ pool، و thread-local را کش نکن. - Pinning خطرناکترین تله است:
synchronized+I/O روی JDK 21–23 (رفع:ReentrantLock) و فریم native. JEP 491 در JDK 24 سنجاقsynchronizedرا حذف کرد. با-Djdk.tracePinnedThreadsیا JFRjdk.VirtualThreadPinnedتشخیص بده. - برای کار CPU-محور یا taskهای بیمسدودی، سود ندارند؛ Loom انتظار را بهبود میدهد نه محاسبه را و latency را کم نمیکند، فقط throughput را.
- StructuredTaskScope عمر زیرتسکها را به یک بلوک میبندد و لغو را propagate میکند — هنوز preview (JEP 453 در JDK 21 تا JEP 505 در JDK 25، با API تازه).
- Scoped values جانشین تغییرناپذیر و کراندارِ ThreadLocalاند — preview از JDK 21 (JEP 446)، نهایی در JDK 25 (JEP 506).
- نسخهها را قاطی نکن: نخ مجازی نهایی JDK 21 (JEP 444)، همزمانی ساختیافته هنوز preview، scoped values نهایی JDK 25.
Imagine you run a busy restaurant and you assign one dedicated waiter to every single customer. It's beautiful and simple — each waiter watches only their own table. But waiters are expensive; you can't hire a hundred thousand of them. That is the whole story of this chapter: how Java made "one waiter per customer" suddenly cheap enough to scale to millions. It's one of the biggest shifts in Java of the last decade, and it shows up in nearly every senior interview.
First we'll understand the problem (thread-per-request and its cost). Then we'll build a virtual thread from scratch and unpack mount/unmount. Next the three golden rules, then the scariest trap — pinning — and its critical version change (JEP 491 in JDK 24). Then when not to use them. After that, Loom's two companions: structured concurrency (StructuredTaskScope) and scoped values, the successor to ThreadLocal. Finally a comparison with reactive, common pitfalls, and 14 interview questions with full answers.
Part 0 — words you must know
Let's build a few terms with analogies first, so none of them are ever dropped cold later.
- Thread: an independent line of execution in your program. Like one worker who can follow a task from top to bottom.
- OS thread: a worker the operating system itself hires and schedules. Real, expensive, and limited.
- Platform thread: the classic Java thread. Really just a thin wrapper around one OS thread — a 1:1 relationship. When you write
new Thread(...), this is what you've always gotten. - Blocking: when a thread hits a wall and waits — for a database reply, a network response, a file read. During that time it does nothing but stand there.
- I/O: input/output — anything that happens outside the CPU: network, disk, database. Almost always slow and full of waiting.
- Call stack: the notebook that says exactly where in the code the thread is and which methods called which. Each method's depth and local variables live here.
- Context switch: when the OS takes a worker off one job and puts them on another. Like substituting a player; it isn't free, it costs time.
- Throughput vs latency: throughput is "how many requests per second can you serve"; latency is "how long does one request take." These are different, and confusing them is a classic interview mistake.
One waiter = one thread. A table = one request. When the waiter is standing by the table waiting for the kitchen to finish the food (I/O), they serve no other table. If waiters are expensive and you only have 20, you can serve only 20 tables at once — even if the kitchen could handle 200 orders. The bottleneck is the number of waiters, not the kitchen. That is exactly the thread-per-request problem.
What problem are we actually solving?
For twenty years the dominant Java server architecture was thread-per-request: dedicate one OS thread to a request, write plain blocking code (socket.read(), jdbc.executeQuery()), and let the thread park while I/O completes.
Why is this style so beloved? Because it's natural. The call stack is the request — glance at the stack and you know where the request is. Exceptions propagate naturally (an error travels up the call chain until someone catches it). Thread-locals carry context like "current user" with zero fuss. It's easy to write, read, debug, and profile.
The problem is cost. A platform thread is a thin wrapper over an OS thread, and OS threads are expensive: each reserves a large stack (typically ~1 MB of address space by default), scheduling is done by the kernel, and context switches are relatively heavy. On a typical machine you can realistically run a few thousand of them.
Now look at the consequence: if each request blocks on I/O for most of its lifetime, your throughput is capped not by CPU or by the downstream service, but by how many threads you can afford. This is why we invented thread pools (reuse a few waiters over and over), and why the industry drifted toward reactive/async programming (CompletableFuture, Reactor, RxJava): those styles let a handful of threads service tens of thousands of concurrent requests by never blocking.
But reactive charges a heavy price: developer ergonomics. You lose the stack (it no longer tells the request's story), you lose easy debugging, and your logic fragments into callbacks or operator chains.
Virtual threads (Project Loom) are the JDK's answer to this dilemma: keep the simple blocking programming model, but make threads so cheap that "one thread per request" scales to millions. You get the throughput of async with the ergonomics of blocking code — the best of both worlds.
Virtual threads were previewed in JDK 19 (JEP 425) and JDK 20 (JEP 436), and finalized as a permanent feature in JDK 21 (JEP 444, September 2023) — no --enable-preview needed. Memorize that date; version precision matters in interviews.
Platform threads vs virtual threads
Let's see the difference in a short table, then unpack it:
Platform thread Virtual thread
───────────────── ──────────────────────────────
1:1 with OS thread many:few (M:N over carriers)
~1 MB stack (fixed) small, resizable heap stack
kernel-scheduled JDK-scheduled (ForkJoinPool)
~few thousand max millions
expensive to create cheap; disposable, not pooled
Here's the surprising part: a virtual thread is also a java.lang.Thread — same class, same API. The difference is who manages its execution: not the OS, but the JDK itself.
Let's build the mechanism with an analogy, because it's the heart of the whole topic.
Picture a fancy hotel with only a few valets but thousands of guests. Each arriving guest hands their car to a valet; the valet gets in and drives it. But when the guest goes inside and something takes a while, the valet doesn't just sit idle behind the wheel! They step out, park the car right there, and go help the next guest. When that first guest returns, some free valet gets back into their car and continues — not necessarily the same valet as before.
Here: guest/car = virtual thread, valet = carrier thread, getting in = mount, stepping out and parking = unmount. A few limited valets keep thousands of cars flowing because nobody sits idle behind a wheel.
Now in technical language: when a virtual thread runs, the JDK mounts it onto a carrier thread (a platform thread from a dedicated ForkJoinPool, by default sized to the number of CPU cores). When the virtual thread hits a blocking operation that Loom understands (most java.* blocking I/O, LockSupport.park, Thread.sleep, BlockingQueue, NIO, etc.), the JDK unmounts it: its continuation (the stack) is copied off to the heap and the carrier is freed to run some other virtual thread. When the I/O completes, the virtual thread is re-mounted (possibly onto a different carrier) and resumes exactly where it left off.
Blocking a virtual thread does not block an OS thread. A million virtual threads parked in read() cost you a million small heap stacks and zero blocked carriers. This single sentence is the reason all of Loom exists.
Creating them
// 1. Direct factory — a virtual thread, started immediately
Thread vt = Thread.ofVirtual().start(() -> System.out.println("hi from " + Thread.currentThread()));
vt.join();
// 2. Unstarted, or with a name
Thread t = Thread.ofVirtual().name("worker-", 0).unstarted(task);
// 3. The idiomatic server pattern: an executor that spawns a NEW virtual
// thread PER TASK. This is NOT a pool — there is nothing to size.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocks the VT, not a carrier
return fetchFromDb();
});
}
} // close() waits for all tasks — try-with-resources = structured lifetime
Notice three things. First, Thread.ofVirtual() builds the new kind and Thread.ofPlatform() builds the old kind explicitly. Second, Thread.currentThread().isVirtual() tells you which kind you're on right now. Third and most important: look at the comment on #3 — newVirtualThreadPerTaskExecutor() is not a pool. For every task you submit it creates a brand-new virtual thread and then throws it away. We wrote a million-iteration loop here without worry; in the platform-thread world that code would bring the machine to its knees.
Three golden rules that flow straight from the design
All three are consequences of the same principle: "creation is cheap, blocking is free."
Never pool virtual threads. Pools exist to amortize expensive resource creation. Virtual threads are cheap to create and meant to be disposable — one per task. Use
newVirtualThreadPerTaskExecutor(), not a fixed pool. Pooling virtual threads is like buying a sterilizing-and-recycling machine for disposable paper napkins.Don't block a platform thread to limit concurrency — use a semaphore. If a downstream can only take 10 concurrent connections, don't create a pool of 10 platform threads; run unlimited virtual threads and gate the scarce resource with
new Semaphore(10). (A semaphore is like a box of 10 keys: anyone entering the room must take a key and return it on the way out; the 11th person waits at the door.)Don't cache expensive objects in thread-locals. With millions of threads, a per-thread cache is a memory disaster. Thread-locals still work (and are how context is propagated), but stop using them as a per-thread object pool.
If you remember only one thing from this chapter: do not pool virtual threads. This is the most common mistake engineers make when migrating — legacy code has an Executors.newFixedThreadPool(200) and someone "converts" it to a pool of virtual threads. Wrong; you should land on newVirtualThreadPerTaskExecutor() and apply the bound (if needed) with a Semaphore.
Pinning — the one thing that can wreck you
So far we've said unmounting is Loom's magic. But that magic has a condition: the JDK must be able to lift the stack off the carrier and put it on the heap. In two situations it can't, and the virtual thread stays pinned to its carrier even while blocked — meaning it does tie up a platform thread, reintroducing the very scaling limit we came here to escape.
Remember the valet who steps out and goes to the next guest? Now imagine the car's lock is broken and the key is jammed in the ignition — the valet can't get out. They're forced to sit there and wait. If several cars get like this, all your limited valets end up locked behind the wheels of idle cars, and newly arriving guests find no valet at all. That is exactly pinning.
The two situations where pinning happens:
- Blocking inside a
synchronizedblock/method (on JDK 21–23). - Blocking inside a native frame (a JNI call, or certain FFM downcalls).
Case 1 is the notorious one. Pre-JDK 24, if a virtual thread held a monitor via synchronized and then blocked on I/O, it pinned its carrier. (A "monitor" is the invisible lock every Java object carries and that synchronized uses.) With enough pinned virtual threads you exhaust the carrier pool → throughput collapses, and in the worst case you get starvation or deadlock: no carrier is free to run the thread that would release the lock, and everyone waits on that same lock.
private final Object lock = new Object();
// BAD on JDK 21-23: the blocking read runs while holding the monitor → PIN
synchronized void badWrite(Socket s) throws IOException {
s.getOutputStream().write(payload); // blocks WHILE pinned
}
// GOOD everywhere: use a j.u.c.Lock. ReentrantLock does NOT pin — Loom
// understands it and can unmount the virtual thread while it waits.
private final ReentrantLock lock = new ReentrantLock();
void goodWrite(Socket s) throws IOException {
lock.lock();
try { s.getOutputStream().write(payload); }
finally { lock.unlock(); }
}
The difference is subtle but crucial: synchronized is a low-level language construct that Loom can't unwind the stack across, whereas ReentrantLock (from java.util.concurrent.locks) is pure Java code that Loom understands and can unmount a virtual thread waiting on.
JEP 491 (JDK 24) removed pinning for synchronized. Monitor ownership is now tracked per virtual thread instead of per carrier, so a virtual thread blocking inside a synchronized method, block, or Object.wait() releases its carrier normally. On JDK 24+, synchronized no longer pins. Native-frame pinning still exists. So:
- On JDK 21–23: hunt down
synchronized-around-blocking-I/O and migrate hot paths toReentrantLock. - On JDK 24+: this whole class of problem largely evaporates; only native/FFM frames pin.
How do you find pinning? Run with -Djdk.tracePinnedThreads=full (on JDK 21) or, better, a JFR (Java Flight Recorder) recording — the jdk.VirtualThreadPinned event fires on every pin with a stack trace, so you see exactly which line is guilty. And if you have genuine native pinning you can't remove, you can raise the carrier pool with -Djdk.virtualThreadScheduler.parallelism to get more carriers.
When NOT to use virtual threads
Virtual threads are built for scaling concurrency of blocking, I/O-bound tasks. If you miss that, you might use them everywhere and wonder why you gained nothing. They are the wrong tool when:
- The work is CPU-bound. A virtual thread gives you nothing for pure computation — you still need one carrier per busy core, and the ideal is a bounded pool sized to cores (e.g. a
ForkJoinPool). Remember: Loom improves waiting, not computing. If a thread never blocks, there's nothing to unmount. - Tasks rarely block. If tasks return instantly, the mount/unmount machinery is pure overhead; a platform-thread pool is simpler and faster.
- You depend on thread-pool semantics for throttling. If your architecture uses "pool of size N" as a back-pressure mechanism, moving to unbounded virtual threads can flood a downstream. Replace the pool with an explicit
Semaphore/ rate limiter — don't just delete the bound. - Legacy code pins heavily and you're stuck on JDK ≤ 23 with
synchronizedyou can't change.
Ask yourself: "Does this task spend most of its life waiting or computing?" If waiting (network, DB, disk) → virtual threads are excellent. If computing (encryption, image processing, a heavy math loop) → virtual threads won't help, and you want a pool sized to cores.
Structured concurrency — coordinating the abundance
Virtual threads hand you cheap threads; but when a request splits into ten concurrent subtasks, how do you guarantee they all finish or cancel correctly? That's where structured concurrency comes in.
Picture a tour guide with ten tourists. The golden rule: nobody returns alone and nobody gets left behind. If one goes missing, the guide gathers everyone and they decide together. The bus doesn't leave until all are aboard. This is the "clean tree of lifetimes."
Now contrast the unstructured model: you drop ten people at the museum and say "come back on your own when you're done." One gets lost and you don't know, one leaves early, and you wait for hours for someone who never came. That is the raw Future world.
The core idea: if a task splits into concurrent subtasks, those subtasks must live and die within a lexical scope — like a try block — so that lifetimes form a clean tree instead of a tangle of fire-and-forget futures.
Look more closely at the unstructured ExecutorService/Future world: submit two futures, and if one fails you must remember to cancel the other; if the caller is interrupted, the futures leak (orphaned, holding resources); errors surface via ExecutionException far from the fork. Thread leaks and orphaned work are the default failure mode of that model.
StructuredTaskScope enforces the discipline: all subtasks forked in a scope must finish before the scope closes. If the parent is cancelled, subtasks are cancelled; if a subtask fails, siblings can be cancelled. Errors and cancellation propagate along the task tree, and the whole thing shows up as a coherent tree in the debugger and in thread dumps.
Structured concurrency is still a preview feature — incubated in JDK 19/20, first preview in JDK 21 (JEP 453), and still in preview through JDK 25 (JEP 505 was the fifth preview). You must run with --enable-preview, and the API changed materially in JDK 25. So pick the right example for the JDK you target.
The JDK 21 preview API (most widely deployed today)
// "Invoke all, fail fast": run both calls concurrently; if either throws,
// cancel the other and propagate.
Response handle(long userId, long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> findUser(userId)); // one VT each
Subtask<Order> order = scope.fork(() -> fetchOrder(orderId));
scope.join() // wait for BOTH to finish or one to fail
.throwIfFailed(); // rethrow the first failure, having cancelled the rest
return new Response(user.get(), order.get()); // safe: both succeeded
} // close() guarantees no subtask outlives this block
}
Read it step by step: fork runs a subtask on a fresh virtual thread and returns a Subtask (like a Future). join() waits until both finish or one fails. throwIfFailed() rethrows the failure if there was one — and because the policy is ShutdownOnFailure, the first failure cancels the remaining siblings. Only if you reach return are you sure both succeeded, so .get() is safe.
ShutdownOnSuccess is the dual: race several sources and take the first success, cancelling the losers. (Its use: redundant/hedged calls — ask the same data from three servers and take whoever answers first.)
The JDK 25 redesign (JEP 505 — know this exists)
JEP 505 replaced constructors with a static factory StructuredTaskScope.open(...) and moved the completion policy into a pluggable Joiner interface (philosophy: composition over inheritance). ShutdownOnFailure/ShutdownOnSuccess subclassing is gone:
// JDK 25 preview shape — Joiner encodes the policy, join() returns the result.
try (var scope = StructuredTaskScope.open(Joiner.<Response>allSuccessfulOrThrow())) {
Subtask<User> user = scope.fork(() -> findUser(userId));
Subtask<Order> order = scope.fork(() -> fetchOrder(orderId));
scope.join(); // throws if any subtask failed; otherwise all are done
return new Response(user.get(), order.get());
}
The semantics are the same — bounded lifetime, propagated cancellation — but the surface differs, so pin your examples to the JDK you actually target.
Scoped values — the successor to ThreadLocal
Passing context (current user, tenant, request ID) without threading it through every method signature traditionally meant ThreadLocal. But thread-locals have three problems: they are mutable, unbounded in lifetime (easy to forget remove() → leaks), and expensive to inherit into millions of virtual threads.
ThreadLocal is like a sticky note on each worker's desk: they can erase or change it any moment, and if they forget to remove it, it stays forever. A scoped value is like a temporary signpost you hang at the entrance of a hallway: everyone inside the hallway sees it, and the moment you step out it's taken down automatically. No mutation, no forgetting, no leaks.
Scoped values are an immutable, structured alternative: a value is bound for the dynamic extent of a run/call, visible to all callees (methods invoked within that extent) on that thread — and inherited by child subtasks in a StructuredTaskScope — and automatically unbound when the block exits. No mutation, no leaks, cheap inheritance.
Scoped values were previewed from JDK 21 (JEP 446) and finalized in JDK 25 (JEP 506) — the one behavioral change on finalization is that ScopedValue.orElse no longer accepts null. On JDK 21–24 they are preview.
final static ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(Request req) {
User u = authenticate(req);
// Bind for the dynamic scope of this call; auto-unbound on return.
ScopedValue.where(CURRENT_USER, u).run(() -> processRequest(req));
}
void processRequest(Request req) {
// No parameter threading, no ThreadLocal.remove() — just read it.
User u = CURRENT_USER.get(); // the value bound above
audit(u.id());
}
A subtle point: rebinding is scoped, not mutation. ScopedValue.where(CURRENT_USER, other).run(...) creates a new binding for a nested extent only; the outer binding is untouched. That's exactly what makes it safe — nobody can corrupt someone else's value from a distance.
Virtual threads vs reactive: choosing
Now that you know both worlds, the practical question is: when which?
| Dimension | Virtual threads | Reactive (Reactor/RxJava) |
|---|---|---|
| Programming model | Plain blocking, imperative | Callbacks / operator chains |
| Debuggability | Real stacks, normal breakpoints | Fragmented, hard stacks |
| Scaling I/O | Excellent | Excellent |
| Backpressure | Manual (Semaphore/queues) | Built in, first-class |
| Streaming / composition | Weaker | Rich operators |
| CPU-bound work | No benefit | No benefit |
| Migration cost | Low (keep blocking code) | High (rewrite) |
The honest take: for the classic "handle many blocking requests" workload, virtual threads give you reactive-grade throughput with dramatically simpler code, and they are the new default for new services on JDK 21+. Reactive still wins where you need sophisticated stream composition and first-class backpressure across async boundaries. ("Backpressure" means: when the consumer is slower than the producer, the system throttles itself so the queue doesn't explode.) Frameworks are adapting: Spring Boot 3.2+ can put the whole servlet stack on virtual threads with spring.threads.virtual.enabled=true.
Common pitfalls & gotchas
- Pooling virtual threads — the #1 anti-pattern. Never do it.
synchronizedaround blocking I/O on JDK ≤ 23 — silent pinning; migrate toReentrantLockor upgrade to JDK 24+.ThreadLocalas a cache — millions of threads × cached object = OOM (out of memory). Use scoped values for context; keep genuine caches shared.- Assuming CPU work speeds up — it doesn't; you still need a cores-sized pool.
- Removing thread-pool bounds without adding a Semaphore — you'll DoS your own database/downstream.
- Deleting
--enable-previewand expectingStructuredTaskScopeto compile — it's still preview through JDK 25; virtual threads themselves are not preview (JDK 21+). - Expecting timing/latency wins — Loom improves throughput/scalability, not per-request latency. A single blocking call is no faster.
Best practices
- New services on JDK 21+: default to virtual threads for request handling; measure before assuming.
- Use
Executors.newVirtualThreadPerTaskExecutor()inside try-with-resources; never a fixed pool. - Gate scarce downstreams with
Semaphore, not with thread-pool size. - Prefer
java.util.concurrent.locks.Lockoversynchronizedon hot blocking paths (JDK ≤ 23), and enable JFRjdk.VirtualThreadPinnedmonitoring in every environment. - Coordinate concurrent subtasks with
StructuredTaskScope(accept the preview flag) rather than rawFuturejuggling; propagate cancellation. - Migrate context propagation from
ThreadLocalto scoped values on JDK 25.
Interview Questions
Now let's gather everything into real interview questions. Read each answer out loud, then try to say it back in your own words.
A virtual thread is an instance of java.lang.Thread scheduled by the JDK rather than the OS. Many virtual threads (M) are multiplexed over few carrier platform threads (N). It carries a small, resizable heap-based stack instead of a fixed ~1 MB OS stack, is cheap to create, and is meant to be disposable (one per task). A platform thread is a 1:1 wrapper over an OS thread, kernel-scheduled and expensive.
When a virtual thread runs, the JDK mounts it on a carrier (a ForkJoinPool platform thread). When it hits a Loom-aware blocking point (most java.* blocking I/O, LockSupport.park, Thread.sleep, ReentrantLock waits, NIO), the JDK captures its continuation onto the heap and unmounts it, freeing the carrier to run another virtual thread. On completion it re-mounts — possibly on a different carrier — and resumes. The whole point: blocking a virtual thread never blocks an OS thread.
Pinning is when a virtual thread blocks but cannot be unmounted, so it holds onto its carrier. It happens (a) inside a synchronized block/method on JDK 21–23, and (b) inside a native/JNI frame. Danger: pinned virtual threads consume carriers; enough of them exhaust the carrier pool, collapsing throughput and potentially causing starvation or deadlock. Fix: use ReentrantLock instead of synchronized, or upgrade to JDK 24 where JEP 491 made synchronized no longer pin (monitor ownership is tracked per virtual thread). Native pinning still exists.
synchronized void write(OutputStream out, byte[] data) throws IOException {
out.write(data); // blocking I/O inside synchronized
}
No — not on JDK 24+. JEP 491 removed synchronized pinning. On JDK 21–23 it would pin. This is a classic version-sensitive trap; the correct answer must state the JDK, otherwise it's incomplete.
Pools amortize expensive creation and cap concurrency. Virtual threads are cheap to create and designed to be disposable — one per task via newVirtualThreadPerTaskExecutor(). Pooling them reintroduces an artificial bound, defeats the model, and can even deadlock. To limit concurrency against a scarce resource, use a Semaphore, not a pool.
The pool of 20 was doing double duty: running tasks and throttling. Virtual threads are unbounded, so the throttle vanished and you now hammer the DB with thousands of concurrent queries. Fix: keep unlimited virtual threads for the request-handling but guard the DB with new Semaphore(20) (or a properly sized connection pool that blocks on checkout). Separate concurrency from throttling.
For CPU-bound work (no benefit; you still need a cores-sized pool), for tasks that rarely block (mount/unmount is overhead), and when you rely on pool size as your only throttle without replacing it with a semaphore. They optimize waiting, not computing, and don't reduce single-request latency.
Unstructured futures leak: if one subtask fails you must manually cancel siblings; if the parent is interrupted, subtasks orphan; errors surface far from the fork. StructuredTaskScope binds subtask lifetimes to a lexical block — all forks complete before the scope closes, failure cancels siblings, cancellation propagates down the tree, and thread dumps show a coherent parent/child tree.
ShutdownOnFailure: fork N subtasks, join() then throwIfFailed() — proceed only if all succeed; the first failure cancels the rest (fan-out where you need every result). ShutdownOnSuccess: race N subtasks and take the first success, cancelling the losers (redundant/hedged calls). Note: JDK 25's JEP 505 replaced these subclasses with a pluggable Joiner passed to StructuredTaskScope.open(...).
Virtual threads: final in JDK 21 (JEP 444) — no preview flag. Structured concurrency: still preview (first preview JEP 453 in JDK 21, fifth preview JEP 505 in JDK 25) — needs --enable-preview. Scoped values: preview from JDK 21 (JEP 446), finalized in JDK 25 (JEP 506). Conflating these is the most common senior-level error.
Scoped values are immutable and bounded: bound for the dynamic extent of a run/call and auto-unbound on exit, so no remove(), no leaks, and no accidental mutation. Inheritance into child subtasks (via StructuredTaskScope) is cheap and explicit, which matters when you have millions of virtual threads — inheriting mutable thread-locals into all of them would be a memory and correctness hazard.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Row>> futures = new ArrayList<>();
for (Long id : ids) futures.add(executor.submit(() -> queryDb(id)));
return futures.stream().map(f -> f.get()).toList(); // <-- ?
}
Two issues. First, Future.get() throws checked exceptions (InterruptedException, ExecutionException) so this won't compile as written inside a lambda without handling. Second and more important: if ids has 100k entries and queryDb hits a DB with a small connection pool, you've spawned 100k concurrent queries and will exhaust/overwhelm the DB — there's no Semaphore throttling the scarce resource. Also, one failing get() leaves the other in-flight tasks running (no coordinated cancellation) — a job for StructuredTaskScope instead of manual future collection.
The trace flag (JDK 21) and the JFR VirtualThreadPinned event report each pinning occurrence with a stack trace, so you can find the offending synchronized/native frame. You'd raise scheduler parallelism (default = CPU cores) only when you have unavoidable native pinning and need more carriers so pinned threads don't starve the pool — a mitigation, not a cure.
No. Loom improves throughput and scalability — how many concurrent blocked requests you can service — not per-request latency. A single blocking DB call takes exactly as long. If your bottleneck is one slow synchronous dependency, virtual threads won't help that request; they help you serve many such requests without exhausting OS threads.
Senior notes & advanced edge cases
You now have the right mental model. Let's go where Loom actually bites once you push it in production — the things a senior interviewer uses to find out whether you've only read about virtual threads or actually run them under load. None of the chapter's material is repeated here; this is only the deeper layer.
- The scheduler you didn't know you were running (parallelism vs maxPoolSize=256 and compensation). 2) Scheduling is cooperative, and how a CPU-bound virtual thread starves its neighbours. 3) Not all blocking is equal: compensation vs pinning vs file I/O. 4) The pinning sources nobody warns you about (logging, drivers, PrintStream). 5) ThreadLocal/MDC and the "named-thread" assumption break. 6) Observability at a million threads. 7) Backpressure & admission control. 8) Memory & GC. 9) StructuredTaskScope: deadlines, partial results, and the Joiner you write yourself.
The scheduler under your skin: parallelism vs maxPoolSize
The chapter said carriers are a ForkJoinPool sized to the cores. That's only half the picture. That number is parallelism (the steady state). There is a second number: maxPoolSize, which defaults to 256 (-Djdk.virtualThreadScheduler.maxPoolSize). Why two numbers? Because of a mechanism called compensation.
parallelism means "we keep 8 cooks on the line." But when one cook is forced into something they can't step away from (say, they must physically walk to the storeroom and wait), the manager temporarily calls in an on-call cook so the line doesn't stall — up to a ceiling of 256. When the first cook returns, the on-call cook is dismissed. That on-call cook is compensation.
When a virtual thread parks on something Loom can unmount (socket, sleep, a j.u.c lock), no carrier is blocked and no compensation is needed. But some blocking genuinely holds the carrier; there the JDK temporarily injects an extra carrier into the pool so throughput doesn't die. So in a production thread dump you may legitimately see more carriers than you have cores — that's normal.
Compensation only kicks in for "compensatable" blocking (such as file I/O). For pinning (blocking inside synchronized on JDK 21–23, or a native/JNI frame) the scheduler adds no compensating carrier at all. That is precisely why pinning is far more dangerous than a slow I/O call: it not only holds the carrier, the system also refuses to compensate for it, so you head straight toward starvation.
Scheduling is cooperative, not preemptive — and that's a trap
Platform threads are scheduled preemptively by the kernel: every few milliseconds you're taken off the CPU whether you like it or not. Virtual threads are not like that. A virtual thread only leaves its carrier at a blocking point (or an explicit Thread.yield()). Until it reaches one, the carrier is its own.
The direct consequence: a CPU-bound virtual thread spinning in a tight loop with no blocking point never unmounts and monopolises that carrier for a long time. Have a few of them and they can seize every carrier, leaving I/O-bound virtual threads that are ready to run stuck in the queue — with zero pinning involved.
// This virtual thread never voluntarily yields → it hogs a carrier
Thread.ofVirtual().start(() -> {
long x = 0;
for (long i = 0; i < 50_000_000_000L; i++) x += i * 31; // no I/O, no yield
sink(x);
});
Loom makes waiting cheap, not computing. Don't leave CPU-bound work on a virtual thread; hand it to a cores-sized pool, or if you must, sprinkle a periodic Thread.yield() in long loops to give others a fair chance. In an interview, saying "a CPU-bound virtual thread can starve its neighbours because scheduling is cooperative" proves you understand the model from the inside, not just the slogan.
Not all blocking is equal: socket I/O vs file I/O
A subtle distinction many people miss: network/socket I/O is genuinely implemented as non-blocking and unmounts the virtual thread cleanly. But filesystem file I/O is not inherently non-blocking on most operating systems (you can't hand a disk read to epoll the way you can a socket). So on JDK 21–23, file operations blocked the carrier and the JDK kept the line alive with compensation (an extra carrier) — not by unmounting. Meaning a heavy file workload does not scale the way a socket workload does.
These compensation details have shifted and improved across releases; on whatever JDK you deploy, measure with a real file workload rather than assuming "files scale like sockets." The durable message: don't assume every blocking call equals a free unmount.
The pinning sources no tutorial mentions
The chapter named synchronized and native frames as the two pinning sources. But in production the danger usually isn't your code — it's third-party libraries that hold synchronized deep on the hot path without you knowing:
- Logging frameworks: synchronous appenders (e.g. classic Logback) block inside
synchronizedwhile writing to file/console. A log-heavy app on JDK 21–23 can silently pin all over the place. System.out/PrintStreamandBufferedOutputStream: their methods aresynchronized; logging withSystem.out.printlnon a hot path = pinning.- Old JDBC drivers and some connection pools that hold
synchronized+ I/O in their internal locking.
The classic production story: you migrate, local tests are green, but under load throughput won't climb. Cause: a logging library or driver pins on the hot path and locks up carriers. The fix: move to JDK 24+ (JEP 491 fixed synchronized at the root), and in every environment keep the JFR jdk.VirtualThreadPinned event on so you can see the exact offending line — whether it's your code or a library's.
ThreadLocal, MDC, and the "named-thread" assumption break
Three old assumptions that fall over with virtual threads:
ThreadLocal inheritance is OFF by default.
Thread.ofVirtual()does not inheritInheritableThreadLocalvalues from the parent unless you explicitly ask. Legacy code that relied on "the child sees the parent's context" silently readsnull. (The thread builder can even forbid thread-locals entirely with.allowSetThreadLocals(false)to drive per-thread memory to zero.)Logging MDC rides on ThreadLocal. So it still works with virtual threads, but if a framework copied MDC from a thread pool, that copy path no longer makes sense. The modern, correct way to propagate context (user/tenant/traceId) in the Loom world is the scoped values the chapter introduced.
A virtual thread is unnamed by default. Any metric or log that keyed off the thread name (
worker-7) now sees an empty string. Observability code that "grouped by thread name" must be rewritten.
Observability at a million threads: your tools change
The classic jstack is useless (and slow) for millions of threads. Loom shipped a new thread dump built for exactly this:
# Grouped, machine-readable (JSON) dump that shows the StructuredTaskScope tree
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
This new dump shows virtual threads grouped as a tree of scopes — precisely what makes structured concurrency debuggable. In JFR, know this: jdk.VirtualThreadStart/VirtualThreadEnd are disabled by default because of the sheer volume, but jdk.VirtualThreadPinned and jdk.VirtualThreadSubmitFailed are enabled and are exactly the ones you watch in production.
With platform threads, "number of threads" was a health metric. With Loom that number is meaningless (millions = normal). Watch these instead: the number of busy carriers, the pinning event rate, and heap usage (because parked threads' stacks live on the heap). If an interviewer asks "how do you know your Loom app is healthy?", that's the answer — not "how many threads."
Backpressure: the pool was also your bouncer
The chapter noted a pool both ran and throttled work. But there's a layer above that too: in the old model, the thread limit was a natural admission control at the edge of the system — when all threads were busy, a new connection waited in the accept queue, which prevented overload. With virtual threads you can accept unbounded connections; that natural bouncer is gone, and in a spike millions of requests arrive at once and blow up memory / the downstream.
Removing the thread limit without adding an explicit ceiling at the edge = DoSing yourself. Put in a Semaphore, a rate limiter, or a bounded intake queue. "Loom scales" does not mean "you should accept unbounded work concurrently."
Memory & GC: you now count heap, not threads
A parked virtual thread's stack (its continuation) lives on the heap. So a million threads with deep stacks = real pressure on heap and GC. Memory now grows with stack depth × number of live threads, not with a fixed megabyte per thread. Practical result: your profile shifts; instead of "address space for stacks," worry about "heap and GC pauses." A deeply recursive thread, in a million copies, can OOM you in ways platform-thread intuition wouldn't predict.
StructuredTaskScope in the real world: deadlines, partial results, a hand-rolled Joiner
The chapter covered ShutdownOnFailure/ShutdownOnSuccess. Three practical things you'll need in production:
- Deadlines: instead of an unbounded
join(), usejoinUntil(Instant)so that if the subtasks haven't finished by a deadline, they're all cancelled together. This is the "the whole request must answer within 200ms or fall back" pattern. - Partial results: sometimes you don't want the first failure to kill everything; you want "collect whatever succeeded and ignore the failures" (e.g. fan-out to 5 sources where 3 successes suffice). In the JDK 25 API this is a custom
Joiner(orallUntil(predicate)); in the JDK 21 API you loop overSubtask.state()yourself. - Cancellation flows downward: cancellation propagates parent→child, but a child cannot cancel its parent. That one-directionality is exactly what keeps the tree safe.
// Whole-request deadline: both subtasks finish by the deadline or all get cancelled
Instant deadline = Instant.now().plusMillis(200);
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> findUser(id));
var order = scope.fork(() -> fetchOrder(id));
scope.joinUntil(deadline); // not done by the deadline → TimeoutException + cancel all
scope.throwIfFailed();
return new Response(user.get(), order.get());
}
Diagram: what happens when a virtual thread blocks?
flowchart TD
B[Virtual thread blocks] --> Q{Loom-aware unmount possible?}
Q -- "socket I/O, sleep, j.u.c lock" --> U[Unmount: stack to heap, carrier freed]
Q -- "no" --> P{Why not?}
P -- "file I/O, Object.wait pre-24" --> C[Compensate: add carrier up to maxPoolSize 256]
P -- "synchronized JDK 21-23, or JNI/native" --> PIN[PINNED: carrier held, NO compensation]
U --> OK[Scales to millions]
C --> OK2[Scales, but bounded by 256 carriers]
PIN --> BAD[Throughput collapse, risk of starvation/deadlock]
Senior interview questions (hard)
Both are cases where a carrier is blocked, but the scheduler's response differs. For compensatable blocking (e.g. file I/O, or Object.wait before JDK 24) the scheduler temporarily injects an extra carrier into the pool (up to maxPoolSize=256) to preserve throughput; the virtual thread still does its work and the system stays alive. But pinning (blocking inside synchronized on JDK 21–23, or a native/JNI frame) gets no compensating carrier: the carrier is locked with no relief, and enough pins lead straight to starvation/deadlock. So pinning is worse than a slow I/O call, because the system doesn't see it and doesn't compensate for it.
Almost certainly you have CPU-bound work on a virtual thread. Virtual-thread scheduling is cooperative, not preemptive; a thread only leaves its carrier at a blocking point (or Thread.yield()). A tight compute loop has no blocking point, so it never unmounts and monopolises the carrier. A few of these seize all the carriers and the ready I/O-bound virtual threads sit queued — with no pinning at all. Fix: move the compute work to a cores-sized pool, or periodically yield in long loops.
Not necessarily. Network/socket I/O is genuinely implemented as non-blocking and unmounts the virtual thread cleanly, so it scales to millions of waiting connections. But filesystem I/O is not inherently non-blocking on most OSes; in early releases (JDK 21–23) file operations blocked the carrier and the JDK kept the line alive with compensation (adding a carrier, not unmounting). The upshot is that heavy file workloads are bounded by the carrier ceiling (256) and don't reach millions the way sockets do. General lesson: don't assume every blocking call is a free unmount — measure on your target release.
The pinning source is almost always third-party code on the hot path, not yours: a logging framework's synchronous appender, PrintStream/BufferedOutputStream's synchronized methods (e.g. System.out.println in a hot path), or an old JDBC driver / connection pool. To find it, enable the JFR jdk.VirtualThreadPinned event — it reports, on every pin, the exact frame and the carrier. Root fix: move to JDK 24+, where JEP 491 removed synchronized pinning; only native pinning remains.
Thread.ofVirtual() does not inherit InheritableThreadLocal from the parent by default (a deliberate choice to avoid a memory explosion across millions of threads). So any logic that assumed "the child automatically has the parent's context" breaks. The modern fix is not to force inheritance back on, but to move context propagation to scoped values: the value is bound for a dynamic extent, is cheaply inherited by subtasks inside a StructuredTaskScope, and is auto-unbound — no leaks and no forgotten remove().
With platform threads, "thread count" was a health metric; with Loom it's meaningless (millions = normal). Watch three things: (1) the number of busy carriers and the compensation rate; (2) the pinning event rate (jdk.VirtualThreadPinned); (3) heap usage and GC pauses, since parked threads' stacks live on the heap. To debug, use the new thread dump: jcmd <pid> Thread.dump_to_file -format=json, which groups threads as a tree of scopes. Classic jstack neither scales to millions of threads nor shows the tree structure.
Use a deadline: instead of join(), call scope.joinUntil(Instant.now().plusMillis(200)). If the subtasks aren't all done by the deadline, it throws TimeoutException, and because you're in a scope, every in-flight subtask is cancelled together (cancellation propagates parent→child). This is far cleaner than juggling Future.get(timeout) on several futures manually, because there one future's timeout doesn't auto-cancel the others and you leak them. Note: cancellation is one-directional — a child can't cancel the parent — which is what keeps the tree safe.
Two places. First, maxPoolSize (default 256) is the ceiling on carriers, not on your application concurrency; tuning it to throttle app work is the wrong knob and can hurt compensation behaviour. To throttle application work, a Semaphore (a logical layer) is right — not a scheduler parameter. Second, make sure the Semaphore is actually gating the real scarce resource (like DB connections) and that its number matches that resource's capacity; if the DB connection pool already caps at 20, that pool is your semaphore, and slapping a Semaphore(256) on top only gives the illusion of control. Separating "carrier ceiling (infrastructure)" from "logical concurrency ceiling (Semaphore)" from "resource ceiling (DB pool)" is the mark of senior understanding.
- The scheduler has two numbers: parallelism (cores, steady state) and maxPoolSize=256 (carrier ceiling under compensation).
- Compensation only works for compensatable blocking (like file I/O); for pinning there is no relief — which is why pinning is more dangerous.
- Scheduling is cooperative: a CPU-bound virtual thread with no blocking point never unmounts and hogs a carrier.
- Socket I/O truly unmounts; file I/O is compensated (so bounded by 256 carriers) — they don't scale the same.
- Real production pinning sources are usually libraries (logging,
PrintStream, old drivers) — find them with JFRjdk.VirtualThreadPinned, root-fix with JDK 24+. - ThreadLocal isn't inherited by children by default; virtual threads are unnamed; name-based observability breaks — move to scoped values and the JSON thread dump.
- The health metric is no longer "thread count" but busy carriers, pinning rate, and heap/GC.
- StructuredTaskScope:
joinUntilfor deadlines, one-directional parent→child cancellation, and a custom Joiner for partial results.
- A virtual thread is a
java.lang.Threadscheduled by the JDK, not the OS; M virtual threads multiplex over N carrier threads. - The core magic is mount/unmount: when a virtual thread blocks on I/O, its stack goes to the heap and the carrier is freed. Blocking a virtual thread does not block an OS thread.
- Three golden rules: don't pool (use
newVirtualThreadPerTaskExecutor()), bound with a Semaphore not pool size, and don't cache in thread-locals. - Pinning is the most dangerous trap:
synchronized+I/O on JDK 21–23 (fix:ReentrantLock) and native frames. JEP 491 in JDK 24 removedsynchronizedpinning. Diagnose with-Djdk.tracePinnedThreadsor JFRjdk.VirtualThreadPinned. - For CPU-bound or rarely-blocking tasks they give no benefit; Loom improves waiting, not computing, and doesn't reduce latency, only throughput.
- StructuredTaskScope binds subtask lifetimes to a block and propagates cancellation — still preview (JEP 453 in JDK 21 through JEP 505 in JDK 25, with a new API).
- Scoped values are the immutable, bounded successor to ThreadLocal — preview from JDK 21 (JEP 446), finalized in JDK 25 (JEP 506).
- Don't conflate versions: virtual threads final in JDK 21 (JEP 444), structured concurrency still preview, scoped values final in JDK 25.