لمحة موجزة عن تفاصيل كيفية تفاعل المواضيع. لقد نظرنا سابقًا في كيفية مزامنة الخيوط مع بعضها البعض. هذه المرة سوف نتعمق في المشاكل التي قد تنشأ أثناء تفاعل المواضيع، وسنتحدث عن كيفية تجنبها. وسنقدم أيضًا بعض الروابط المفيدة لمزيد من الدراسة المتعمقة.
يمكنك مشاهدة مثال رائع هنا: Java - Thread Starvation and Fairness
. يوضح هذا المثال ما يحدث مع الخيوط أثناء المجاعة وكيف يتيح لك التغيير البسيط من
مقدمة
لذلك، نحن نعلم أن جافا لديها المواضيع. يمكنك أن تقرأ عن ذلك في المراجعة التي تحمل عنوان Better Together: Java and the Thread class. الجزء الأول – خيوط التنفيذ . وقد اكتشفنا حقيقة أن الخيوط يمكن أن تتزامن مع بعضها البعض في المراجعة التي تحمل عنوان Better Together: Java and the Thread class. الجزء الثاني – التزامن . حان الوقت للحديث عن كيفية تفاعل المواضيع مع بعضها البعض. كيف يتشاركون الموارد المشتركة؟ ما هي المشاكل التي قد تنشأ هنا؟طريق مسدود
المشكلة الأكثر رعبا على الإطلاق هي الجمود. الجمود هو عندما ينتظر خيطان أو أكثر الآخر إلى الأبد. سنأخذ مثالاً من صفحة ويب Oracle التي تصف حالة الجمود :public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend gaston = new Friend("Gaston");
new Thread(() -> alphonse.bow(gaston)).start();
new Thread(() -> gaston.bow(alphonse)).start();
}
}
قد لا يحدث حالة توقف تام هنا في المرة الأولى، ولكن إذا توقف برنامجك، فقد حان وقت التشغيل jvisualvm
: مع تثبيت المكون الإضافي JVisualVM (عبر الأدوات -> المكونات الإضافية)، يمكننا معرفة مكان حدوث حالة توقف تام:
"Thread-1" - Thread t@12
java.lang.Thread.State: BLOCKED
at Deadlock$Friend.bowBack(Deadlock.java:16)
- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
الخيط 1 ينتظر القفل من الخيط 0. لماذا يحدث ذلك؟ Thread-1
يبدأ التشغيل وينفذ Friend#bow
الطريقة. تم تمييزه بالكلمة synchronized
الأساسية، مما يعني أننا نحصل على الشاشة لـ this
(الكائن الحالي). كان إدخال الطريقة بمثابة إشارة إلى Friend
الكائن الآخر. الآن، Thread-1
يريد تنفيذ الطريقة على الآخر Friend
، ويجب عليه الحصول على القفل الخاص به للقيام بذلك. ولكن إذا تمكن الخيط الآخر (في هذه الحالة Thread-0
) من الدخول إلى bow()
الطريقة، فهذا يعني أن القفل قد تم الحصول عليه بالفعل وينتظر Thread-1
، Thread-0
والعكس صحيح. هذا الطريق المسدود غير قابل للحل، ونحن نسميه الطريق المسدود. مثل قبضة الموت التي لا يمكن تحريرها، الجمود هو انسداد متبادل لا يمكن كسره. للحصول على شرح آخر للجمود يمكنك مشاهدة هذا الفيديو: شرح Deadlock و Livelock
.
لايفلوك
إذا كان هناك طريق مسدود، فهل هناك أيضًا طريق مسدود؟ نعم، هناك :) Livelock يحدث عندما تبدو الخيوط حية ظاهريًا، لكنها غير قادرة على فعل أي شيء، لأنه لا يمكن استيفاء الشرط (الشروط) المطلوبة لمواصلة عملها. في الأساس، Livelock يشبه حالة توقف تام، لكن الخيوط لا "تتعطل" في انتظار الشاشة. وبدلاً من ذلك، فإنهم يفعلون شيئًا ما إلى الأبد. على سبيل المثال:import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class App {
public static final String ANSI_BLUE = "\u001B[34m";
public static final String ANSI_PURPLE = "\u001B[35m";
public static void log(String text) {
String name = Thread.currentThread().getName(); // Like "Thread-1" or "Thread-0"
String color = ANSI_BLUE;
int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
if (val != 0) {
color = ANSI_PURPLE;
}
System.out.println(color + name + ": " + text + color);
try {
System.out.println(color + name + ": wait for " + val + " sec" + color);
Thread.currentThread().sleep(val * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Lock first = new ReentrantLock();
Lock second = new ReentrantLock();
Runnable locker = () -> {
boolean firstLocked = false;
boolean secondLocked = false;
try {
while (!firstLocked || !secondLocked) {
firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
log("First Locked: " + firstLocked);
secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
log("Second Locked: " + secondLocked);
}
first.unlock();
second.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(locker).start();
new Thread(locker).start();
}
}
يعتمد نجاح هذا الرمز على الترتيب الذي يبدأ به برنامج جدولة سلاسل المحادثات Java. إذا Thead-1
بدأ أولاً، فسنحصل على قفل حي:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
كما ترون من المثال، يحاول كلا الخيطين الحصول على كلا القفلين بدورهما، لكنهما يفشلان. لكنهم ليسوا في طريق مسدود. ظاهريًا، كل شيء على ما يرام وهم يقومون بعملهم. وفقًا لـ JVisualVM، نرى فترات من السكون وفترة من التوقف (وهذا عندما يحاول الخيط الحصول على قفل - فهو يدخل في حالة الإيقاف، كما ناقشنا سابقًا عندما تحدثنا عن مزامنة الخيط )
. يمكنك مشاهدة مثال على القفل المباشر هنا: Java - Thread Livelock
.
مجاعة
بالإضافة إلى الجمود والقفل المباشر، هناك مشكلة أخرى يمكن أن تحدث أثناء تعدد العمليات: المجاعة. تختلف هذه الظاهرة عن أشكال الحجب السابقة من حيث أن المواضيع غير محظورة - فهي ببساطة لا تملك الموارد الكافية. ونتيجة لذلك، في حين أن بعض المواضيع تأخذ كل وقت التنفيذ، فإن بعضها الآخر غير قادر على التشغيل:https://www.logicbig.com/
Thread.sleep()
إلى Thread.wait()
توزيع الحمل بالتساوي.
شروط السباق
في تعدد مؤشرات الترابط، هناك شيء مثل "حالة السباق". تحدث هذه الظاهرة عندما تشارك سلاسل الرسائل موردًا ما، ولكن يتم كتابة الكود بطريقة لا تضمن المشاركة الصحيحة. ألق نظرة على مثال:public class App {
public static int value = 0;
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
int oldValue = value;
int newValue = ++value;
if (oldValue + 1 != newValue) {
throw new IllegalStateException(oldValue + " + 1 = " + newValue);
}
}
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
}
}
قد لا يؤدي هذا الرمز إلى إنشاء خطأ في المرة الأولى. عندما يحدث ذلك، قد يبدو مثل هذا:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
at App.lambda$main$0(App.java:13)
at java.lang.Thread.run(Thread.java:745)
كما ترون، حدث خطأ ما أثناء newValue
تعيين قيمة. newValue
هو كبير جدا. بسبب حالة السباق، تمكن أحد المواضيع من تغيير المتغير value
بين العبارتين. اتضح أن هناك سباق بين المواضيع. فكر الآن في مدى أهمية عدم ارتكاب أخطاء مماثلة في المعاملات النقدية... يمكن أيضًا رؤية الأمثلة والرسوم البيانية هنا: رمز لمحاكاة حالة السباق في موضوع Java
.
متقلب
عند الحديث عن تفاعل المواضيع، فإنvolatile
الكلمة الرئيسية تستحق الذكر. دعونا نلقي نظرة على مثال بسيط:
public class App {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Runnable whileFlagFalse = () -> {
while(!flag) {
}
System.out.println("Flag is now TRUE");
};
new Thread(whileFlagFalse).start();
Thread.sleep(1000);
flag = true;
}
}
والأمر الأكثر إثارة للاهتمام هو أن هذا من المحتمل جدًا ألا ينجح. لن يرى الموضوع الجديد التغيير في flag
الحقل. لإصلاح هذا الحقل flag
، نحتاج إلى استخدام volatile
الكلمة الأساسية. كيف ولماذا؟ يقوم المعالج بتنفيذ جميع الإجراءات. ولكن يجب تخزين نتائج الحسابات في مكان ما. لهذا، هناك الذاكرة الرئيسية وهناك ذاكرة التخزين المؤقت للمعالج. تشبه ذاكرة التخزين المؤقت للمعالج قطعة صغيرة من الذاكرة تُستخدم للوصول إلى البيانات بسرعة أكبر من الوصول إلى الذاكرة الرئيسية. ولكن لكل شيء جانب سلبي: قد لا تكون البيانات الموجودة في ذاكرة التخزين المؤقت محدثة (كما في المثال أعلاه، عندما لم يتم تحديث قيمة حقل العلامة). لذلك، الكلمة volatile
الأساسية تخبر JVM أننا لا نريد تخزين المتغير الخاص بنا مؤقتًا. وهذا يسمح برؤية النتيجة المحدثة على كافة المواضيع. وهذا تفسير مبسط للغاية. أما بالنسبة للكلمة volatile
الرئيسية، أوصي بشدة بقراءة هذا المقال
. لمزيد من المعلومات، أنصحك أيضًا بقراءة Java Memory Model
و Java Volatile Keyword
. بالإضافة إلى ذلك، من المهم أن نتذكر أن الأمر volatile
يتعلق بالرؤية وليس بذرية التغييرات. بالنظر إلى الكود في قسم "ظروف السباق"، سنرى تلميح أداة في IntelliJ IDEA: تمت إضافة هذا الفحص إلى IntelliJ IDEA كجزء من الإصدار IDEA-61117
، والذي تم إدراجه في ملاحظات الإصدار
في عام 2010.
الذرية
العمليات الذرية هي عمليات لا يمكن تقسيمها. على سبيل المثال، يجب أن تكون عملية إسناد قيمة إلى متغير ذرية. لسوء الحظ، عملية الزيادة ليست ذرية، لأن الزيادة تتطلب ما يصل إلى ثلاث عمليات لوحدة المعالجة المركزية: احصل على القيمة القديمة، وأضف واحدة إليها، ثم احفظ القيمة. لماذا تعتبر الذرية مهمة؟ مع عملية الزيادة، إذا كانت هناك حالة سباق، فقد يتغير المورد المشترك (أي القيمة المشتركة) فجأة في أي وقت. بالإضافة إلى ذلك، العمليات التي تتضمن بنيات 64 بت، على سبيل المثالlong
و double
، ليست ذرية. يمكن قراءة المزيد من التفاصيل هنا: التأكد من الذرية عند قراءة وكتابة قيم 64 بت
. يمكن رؤية المشاكل المتعلقة بالذرية في هذا المثال:
public class App {
public static int value = 0;
public static AtomicInteger atomic = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
value++;
atomic.incrementAndGet();
}
};
for (int i = 0; i < 3; i++) {
new Thread(task).start();
}
Thread.sleep(300);
System.out.println(value);
System.out.println(atomic.get());
}
}
AtomicInteger
ستمنحنا الفئة الخاصة دائمًا 30 ألفًا، لكن هذا المبلغ value
سيتغير من وقت لآخر. هناك نظرة عامة قصيرة على هذا الموضوع: مقدمة للمتغيرات الذرية في جافا
. تقع خوارزمية "المقارنة والمبادلة" في قلب الطبقات الذرية. يمكنك قراءة المزيد عنها هنا في مقارنة الخوارزميات الخالية من القفل - CAS وFAA في مثال JDK 7 و8
أو في مقالة المقارنة والمبادلة
على ويكيبيديا.
http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-Java.html
يحدث-قبل
هناك مفهوم مثير للاهتمام وغامض يسمى "يحدث من قبل". كجزء من دراستك للخيوط، يجب أن تقرأ عنها. تُظهر العلاقة "يحدث قبل" الترتيب الذي سيتم به رؤية الإجراءات بين سلاسل الرسائل. هناك العديد من التفسيرات والتعليقات. فيما يلي أحد العروض التقديمية الأحدث حول هذا الموضوع: علاقات Java "يحدث من قبل" .ملخص
في هذه المراجعة، استكشفنا بعض تفاصيل كيفية تفاعل سلاسل الرسائل. ناقشنا المشاكل التي قد تنشأ، وكذلك طرق التعرف عليها والقضاء عليها. قائمة المواد الإضافية حول الموضوع:- قفل مزدوج التحقق
- الأسئلة المتداولة حول JSR 133 (نموذج ذاكرة جافا).
- سؤال الذكاء 35: كيف نتجنب الجمود؟
- مفاهيم التزامن في جافا بقلم دوغلاس هوكينز (2017)
GO TO FULL VERSION