تطوير الويب

Virtual threads Java 25 في الإنتاج: Loom، StructuredTaskScope والـ pinning

7 دقائق للقراءة

دروس السلسلة: Spring Boot 4 + GraalVM Native · JPA Hibernate المتقدم · Spring Cloud Gateway 4.3 · Resilience4j 3

الـ virtual threads (Project Loom) دخلت مرحلة الاستقرار في Java 21 LTS سبتمبر 2023، ثم تحسّنت جوهريًا في Java 25 LTS الصادر سبتمبر 2025 — خاصةً عبر JEP 491 الذي يحلّ عيب الـ pinning على الأقسام synchronized، الذي كان يُعتبر القيد الأكثر دلالةً عمليًا. في 2026، بعد سنتين من ردود الإنتاج، الـ virtual threads أصبحت الافتراضية لأي خدمة Java مُقيَّدة بـ I/O: REST APIs، microservices، ETL، gateways. يستعرض هذا الدرس الاستخدام في الإنتاج: structured concurrency، scope، الملاحظة، فخاخ pinning، والترحيل من pool threads كلاسيكي.

المتطلبات

  • Java 25 LTS مُوصى به (Java 21 LTS كحد أدنى)
  • أساسيات threads، executors، تنفيذ غير متزامن
  • مشروع Spring Boot 3.5+/4.0+ أو Java SE
  • الوقت المُقدَّر: 90 دقيقة

الخطوة 1 — فهم الفرق platform vs virtual thread

platform thread (الـ Thread التاريخي القديم) يستهلك ~1 MB من stack ويُربط 1:1 مع thread OS. على JVM بـ 8 GB، نصل حدًا أعلى ~8000 platform threads متزامنة. virtual thread، أما، يستهلك أقل من 1 KB، يعيش في heap، ويُضرَب على pool صغير من carrier platform threads بواسطة scheduler JVM. ننشئ ملايين بلا مشكلة.

// Platform thread classique
Thread platform = Thread.ofPlatform()
    .name("worker-1")
    .start(() -> System.out.println("hello"));

// Virtual thread (Java 21+)
Thread virtual = Thread.ofVirtual()
    .name("vthread-1")
    .start(() -> System.out.println("hello"));

// Vérifier le type
System.out.println(Thread.currentThread().isVirtual());

// Créer un million de virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 1_000_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return null;
        });
    });
} // attend toutes les tâches

المفتاح المفاهيمي: virtual thread مُعلَّق على I/O (قراءة socket، طلب HTTP) يُحرّر carrier platform thread، الذي يستطيع خدمة virtual thread آخر. هذه الآلية تُتيح لخدمة إدارة 100,000 اتصال متزامن ببضعة عشرات threads OS فقط. الـ API Thread.sleep()، Socket.read()، HttpClient.send() جُعلت متوافقة Loom.

الخطوة 2 — متى نستخدم virtual threads

virtual threads تتألّق للكود I/O-bound: استدعاءات شبكة، قاعدة بيانات، ملفات، انتظار على queues. لا تُقدّم شيئًا للـ CPU-bound حيث تبقى محدودًا بعدد الأنوية الفيزيائية. القاعدة العملية: إذا قضى ملف إنتاجك > 30% من الوقت في Thread.sleep، انتظار شبكة، أو انتظار قاعدة، virtual threads ستُحوّلك.

public List<UserData> agreger(List<String> userIds) throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var futures = userIds.stream()
            .map(id -> executor.submit(() -> {
                // appel HTTP bloquant — libère le carrier !
                return httpClient.send(
                    HttpRequest.newBuilder(URI.create(api + id)).build(),
                    HttpResponse.BodyHandlers.ofString()
                ).body();
            }))
            .toList();

        return futures.stream()
            .map(f -> { try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); } })
            .map(this::parse)
            .toList();
    }
}

Executors.newVirtualThreadPerTaskExecutor() يُنشئ virtual thread لكل مهمة مُقدَّمة، بلا pool ثابت. الـ try-with-resources ينتظر انتهاء كل المهام عند الخروج.

الخطوة 3 — Structured concurrency مع StructuredTaskScope

Java 25 يُسلّم المعاينة الخامسة لـ structured concurrency (JEP 505) عبر صنف StructuredTaskScope، مكافئ TaskGroup في Python. الـ API يبقى في preview مع تثبيت متوقع JDK 27 — الأصناف الفرعية ShutdownOnFailure/ShutdownOnSuccess التاريخية مُستبدَلة بـ Joiner يُمرَّر إلى StructuredTaskScope.open(...).

import java.util.concurrent.StructuredTaskScope;

public AgregatResultat agreger() throws Exception {
    try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
        var profil = scope.fork(() -> userClient.getProfile(userId));
        var commandes = scope.fork(() -> commandesClient.getRecentes(userId));
        var notifs = scope.fork(() -> notifClient.getNonLues(userId));

        scope.join(); // attend tous ; lève FailedException si un fork échoue

        return new AgregatResultat(profil.get(), commandes.get(), notifs.get());
    }
}

ثلاثة انضباطات مفتاحية. Joiner.allSuccessfulOrThrow() يُلغي الأشقاء بمجرد أن ترفع مهمة (قطع دائرة قصيرة على الخطأ). Joiner.anySuccessfulResultOrThrow() يتوقّف عند أول نجاح (مفيد لـ race بين cache ومصدر الحقيقة). scope.join() يُنشر الاستثناء الأول للأطفال عبر FailedException سببها الاستثناء الأصلي (لا حاجة لـ throwIfFailed() منفصل، مُزال في JEP 505).

الخطوة 4 — تفعيل virtual threads في Spring Boot

Spring Boot 3.2+ و4.x يُدمج virtual threads عبر خاصية واحدة. كل طلبات HTTP الواردة، مهام @Async، وjobs scheduled تعمل عندها على virtual threads، دون تغيير الكود التطبيقي.

# application.yml
spring:
  threads:
    virtual:
      enabled: true

# Vérifier dans /actuator/metrics
# - tomcat.threads.busy (devrait être très bas avec vthreads)
# - jvm.threads.live (compte les vthreads aussi)

لـ Tomcat 11+ (افتراضي في Spring Boot 4.0)، هذا يُحوّل الـ Executor إلى VirtualThreadPerTaskExecutor. على حمل حقيقي، الأثر فوري: إذا كانت API تُجري 500 req/s مع 200 platform threads، ترتفع نموذجيًا إلى 2000-5000 req/s مع virtual threads، باستخدام 2-4× أقل RAM.

الخطوة 5 — فخاخ pinning وJEP 491

العيب التاريخي الكبير لـ Loom كان الـ pinning: إذا دخل virtual thread في كتلة synchronized وعُلِّق فيها على I/O، كان يُثبّت carrier platform thread (الذي لا يستطيع خدمة vthreads أخرى).

// AVANT Java 25 : ceci PINNAIT le carrier
public synchronized String getData() {
    return blockingHttpCall(); // bloque le carrier !
}

// Workaround historique : remplacer synchronized par ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public String getData() {
    lock.lock();
    try {
        return blockingHttpCall(); // ne pinne pas
    } finally {
        lock.unlock();
    }
}

// Java 25 (JEP 491) : synchronized ne pinne PLUS
public synchronized String getData() {
    return blockingHttpCall(); // libère le carrier proprement
}

JEP 491، المُسلَّم في JDK 24 (مارس 2025) والحاضر في Java 25 LTS، يقضي على هذه المشكلة. التحسين الذي يُبرّر وحده الترحيل Java 21 → 25 LTS للخدمات بـ I/O عالي.

الخطوة 6 — الملاحظة والتصحيح

virtual threads تظهر في الأدوات القياسية: jstack، jcmd، JFR (Java Flight Recorder)، profilers. JFR هو الطريق المرجعي للتشخيص في الإنتاج.

# Démarrer JFR au runtime
jcmd <pid> JFR.start name=vthread settings=profile duration=60s filename=vthread.jfr

# Événements pertinents :
# - jdk.VirtualThreadStart
# - jdk.VirtualThreadEnd
# - jdk.VirtualThreadPinned (KEY : à zéro idéalement)
# - jdk.VirtualThreadSubmitFailed

# Analyser avec JDK Mission Control
jmc vthread.jfr

الحدث VirtualThreadPinned هو الكناري: يُشير لكل pinning > 20 ms. على Java 25، لا يجب أن ترى أيًا في حمل عادي. على Java 21، يخونون synchronized على I/O لإعادة الهيكلة.

الخطوة 7 — ScopedValue: بديل ThreadLocal

الـ ThreadLocal مكلفة مع virtual threads (دخول في map لكل thread)، ودلالة الوراثة بـ InheritableThreadLocal مكسورة في نموذج Loom. ScopedValue (preview Java 21، مستقرة Java 25) هي البديل الرسمي: قيمة ثابتة، scoped لكتلة، موروثة طبيعيًا من قبل المهام الفرعية.

public class RequestContext {
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}

// Au point d'entrée d'une requête
ScopedValue.where(RequestContext.USER_ID, currentUserId)
    .where(RequestContext.TRACE_ID, traceId)
    .run(() -> serviceMethod());

// Dans le service
public void serviceMethod() {
    var userId = RequestContext.USER_ID.get();
    log.info("Traitement pour user={}", userId);
}

ميزة رئيسية: ScopedValue ثابتة في نطاقها (لا تحوّر عرضي)، مُنظَّفة تلقائيًا عند الخروج (لا تسرّب ذاكرة)، وأسرع 5-10× في القراءة من ThreadLocal في virtual thread.

الخطوة 8 — الترحيل من pool threads

// AVANT : pool fixe avec 50 threads, bottleneck à fort débit
private final ExecutorService pool = Executors.newFixedThreadPool(50);

// APRÈS : virtual threads, illimité, gratuits
private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

// Code appelant identique
public CompletableFuture<String> fetch(String url) {
    return CompletableFuture.supplyAsync(() -> doHttpGet(url), pool);
}

قبل الترحيل، قِس ملف الحمل (CPU vs IO wait) بـ vmstat، top، أو JFR. إذا تجاوز IO wait 30%، virtual threads ستُحرّر throughput مجانًا. إذا كانت الخدمة CPU-bound، التبديل لا يُغيّر شيئًا.

أخطاء شائعة

العَرَض السبب الحل
لا مكسب throughput مع vthreads كود CPU-bound أو synchronized في كل مكان (Java 21) profil بـ JFR؛ الترحيل لـ Java 25
OutOfMemoryError: Java heap space كثير من virtual threads تُنشئ كثيرًا من الكائنات حدّ بـ semaphore أو rate limiter
ThreadLocal مكسور في sub-task InheritableThreadLocal لا يُنقل استخدم ScopedValue
latency غير متوقعة vthreads كثيرة على carriers قليلة زِد jdk.virtualThreadScheduler.parallelism
VirtualThreadPinned كثيف في JFR JDBC driver أو مكتبة قديمة حدّث drivers (HikariCP 5.1+، JDBC 4.4+)

الأسئلة الشائعة

Java 21 أم 25 LTS؟
Java 25 لمشروع جديد أو ترحيل مخطَّط. Java 21 يبقى صالحًا حتى سبتمبر 2026 لتحديثات Oracle المجانية. Java 25 يُصحّح pinning synchronized — مُوصى به بقوة للخدمات بـ I/O.

Virtual threads ومتفاعل (WebFlux، RxJava)؟
Virtual threads تجعل الـ réactif أقل ضرورةً لغالبية الحالات. WebFlux يبقى مفيدًا لـ back-pressure صريح أو تشغيل بيني مع كدسة réactive موجودة.

حدود التوسّع؟
ملايين virtual threads متزامنة على JVM 8 GB (مُختبَر حتى 5M). الـ bottleneck يصبح قاعدة البيانات أو الخدمة المُستدعاة.

التوافق مع Spring/Hibernate؟
Spring Boot 3.2+ ✓، Hibernate 6.3+ ✓، HikariCP 5.1+ ✓، Tomcat 11+ ✓.

كم carriers افتراضيًا؟
عدد أنوية CPU. قابل للضبط عبر -Djdk.virtualThreadScheduler.parallelism=N.

مقالات ذات صلة

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité