المشكلة: الخيوط التقليدية مكلفة
كل خيط تقليدي في Java (يُسمى الآن خيط منصّة platform thread) يقابل خيط نظام تشغيل حقيقيًا: يستهلك ميغابايتات من الذاكرة للـ stack، وإنشاؤه مكلف نسبيًا. لهذا لا يمكن فتح 100,000 خيط تقليدي بأمان — النظام ينهار قبل ذلك.
هذا يتعارض مع نمط شائع جدًا في الخوادم: "خيط واحد لكل طلب" (thread-per-request)، حيث معظم وقت الخيط يذهب في الانتظار (قاعدة بيانات، طلب شبكة) لا في حساب فعلي.
ما هو الخيط الافتراضي؟
منذ Java 21 (JEP 444)، الخيط الافتراضي (virtual thread) هو خيط خفيف يديره الـ JVM نفسه بدل نظام التشغيل. آلاف — بل ملايين — الخيوط الافتراضية يمكن أن "تركب" على عدد صغير من خيوط المنصّة الحقيقية (تُسمى الخيوط الحاملة carrier threads).
عندما يُحجب خيط افتراضي بعملية إدخال/إخراج (مثل قراءة من الشبكة)، الـ JVM "ينزله" عن خيط المنصّة تلقائيًا ويُشغّل خيطًا افتراضيًا آخر عليه — بدل أن يبقى خيط المنصّة كاملًا معطّلًا بلا فائدة.
💡 الخيوط الافتراضية هي نفس النوع
java.lang.Threadتمامًا من ناحية الـ API — الفرق في التنفيذ الداخلي فقط، وليس في الكود الذي تكتبه.
إنشاء خيط افتراضي
// إنشاء وتشغيل مباشر:
Thread t = Thread.ofVirtual().start(() -> {
System.out.println("أعمل على خيط افتراضي: " + Thread.currentThread());
});
t.join();
// بدون Builder، مباشرة:
Thread.startVirtualThread(() -> {
System.out.println("خيط افتراضي سريع");
});
// بتسمية مخصصة:
Thread named = Thread.ofVirtual().name("worker-").start(() -> {
System.out.println("اسمي: " + Thread.currentThread().getName());
});
ExecutorService بخيوط افتراضية
الطريقة الأشيع في الخوادم — خيط افتراضي جديد لكل مهمة، بلا تجميع (pooling):
import java.util.concurrent.*;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int taskId = i;
executor.submit(() -> {
// كل مهمة تحصل على خيط افتراضي خاص بها
System.out.println("مهمة " + taskId + " على " + Thread.currentThread());
});
}
} // يغلق تلقائيًا وينتظر انتهاء كل المهام (try-with-resources)
10,000 خيط منصّة تقليدي سيُنهك أي جهاز — أما 10,000 خيط افتراضي فعملية عادية.
متى تفيد الخيوط الافتراضية؟
| الحالة | الخيوط الافتراضية |
|---|---|
| آلاف الطلبات المتزامنة المحجوبة على شبكة/قاعدة بيانات (I/O-bound) | ✅ مثالية |
| مهام حسابية ثقيلة (CPU-bound) بدون انتظار | ❌ لا فائدة إضافية — نفس عدد الأنوية |
| تطبيق بحمل خفيف وعدد قليل من المهام المتزامنة | ❌ التعقيد الإضافي غير مبرر |
| خادم بنمط "خيط لكل طلب" | ✅ الحالة المصممة لأجلها تحديدًا |
نقطة مهمة: الخيوط الافتراضية تزيد الإنتاجية (throughput) — عدد المهام المتزامنة الممكنة — لا السرعة (latency) لكل مهمة بمفردها. لا تجعل عملية حسابية واحدة أسرع.
فخ الـ pinning مع synchronized
قبل Java 24، تشغيل كود داخل كتلة synchronized أثناء عملية حجب طويلة كان "يُثبّت" (pin) الخيط الافتراضي على خيط المنصّة الحامل له — فيفقد الميزة الأساسية (تحرير خيط المنصّة أثناء الانتظار):
// قد يُثبّت الخيط الافتراضي على خيط المنصّة قبل Java 24:
synchronized (lock) {
slowNetworkCall(); // حجب طويل داخل synchronized
}
// البديل الأأمن عبر الإصدارات:
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
slowNetworkCall();
} finally {
lock.unlock();
}
منذ Java 24 (JEP 491)، تم حل مشكلة الـ pinning مع synchronized من جذورها في الـ JVM نفسه — لكن ReentrantLock يبقى خيارًا آمنًا عبر كل الإصدارات.
متى نستخدم multithreading التقليدي؟
- مهام حسابية مكثّفة (CPU-bound): استخدم
Executors.newFixedThreadPool()بحجم قريب من عدد الأنوية. - مهام I/O مكثّفة بعدد كبير من المتزامن: الخيوط الافتراضية هي الخيار الأنسب.
- كود قديم يعتمد على thread pools محدودة الحجم عمدًا (لتقييد الحمل على خدمة خارجية): استخدم
Semaphoreبدل تقليل عدد الخيوط الافتراضية نفسها.
🎯 التالي: خلاصة مسار Java.