סקירה קצרה של הפרטים של אופן האינטראקציה בין שרשורים. בעבר, בדקנו כיצד שרשורים מסונכרנים אחד עם השני. הפעם נצלול לתוך הבעיות שעלולות להתעורר בזמן אינטראקציה בין שרשורים, ונדבר על איך להימנע מהן. אנו נספק גם כמה קישורים שימושיים למחקר מעמיק יותר.
אתה יכול לראות דוגמה על כאן: Java - Thread Starvation and Fairness
. דוגמה זו מראה מה קורה עם חוטים בזמן הרעבה וכיצד שינוי קטן אחד מ-
מבוא
אז, אנחנו יודעים שלג'אווה יש חוטים. אתה יכול לקרוא על כך בסקירה שכותרתה Better together: Java and the Thread class. חלק א' - חוטי הוצאה להורג . וחקרנו את העובדה שרשורים יכולים להסתנכרן אחד עם השני בסקירה שכותרתה Better together: Java and the Thread class. חלק ב' - סנכרון . הגיע הזמן לדבר על איך שרשורים מתקשרים זה עם זה. איך הם חולקים משאבים משותפים? אילו בעיות עלולות להתעורר כאן?מָבוֹי סָתוּם
הבעיה המפחידה מכולן היא מבוי סתום. מבוי סתום הוא כאשר שני חוטים או יותר מחכים לנצח לשני. ניקח דוגמה מדף האינטרנט של אורקל שמתאר מבוי סתום :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
ולהיפך. המבוי הסתום הזה הוא בלתי פתיר, ואנחנו קוראים לזה מבוי סתום. כמו אחיזת מוות שאי אפשר לשחרר, מבוי סתום הוא חסימה הדדית שאי אפשר לשבור. להסבר נוסף על מבוי סתום, אתה יכול לצפות בסרטון זה: מבוי סתום ו-Livelock Explained
.
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
מתחיל קודם, אז נקבל livelock:
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, אנו רואים תקופות שינה ותקופת חנייה (זהו כאשר חוט מנסה לרכוש נעילה - הוא נכנס למצב הפארק, כפי שדיברנו קודם לכן כשדיברנו על סנכרון שרשור )
. אתה יכול לראות דוגמה ל-livelock כאן: Java - Thread Livelock
.
רָעָב
בנוסף למבוי סתום ו-livelock, יש בעיה נוספת שיכולה לקרות במהלך ריבוי הליכי שרשור: הרעבה. תופעה זו שונה מצורות החסימה הקודמות בכך שהשרשורים אינם חסומים - פשוט אין להם מספיק משאבים. כתוצאה מכך, בעוד שרשורים מסוימים לוקחים את כל זמן הביצוע, אחרים אינם מסוגלים לפעול:https://www.logicbig.com/
Thread.sleep()
to Thread.wait()
מאפשר לך לפזר את העומס באופן שווה.
תנאי המירוץ
ב-multithreading, יש דבר כזה "תנאי גזע". תופעה זו מתרחשת כאשר שרשורים חולקים משאב, אך הקוד נכתב באופן שאינו מבטיח שיתוף נכון. תסתכל על דוגמה: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
ו- Java Volatile Keyword
. בנוסף, חשוב לזכור שזה volatile
על הנראות, ולא על האטומיות של שינויים. בהסתכלות על הקוד בסעיף "תנאי המירוץ", נראה הסבר כלים ב-IntelliJ IDEA: בדיקה זו נוספה ל-IntelliJ IDEA כחלק מהגיליון IDEA-61117
, שהיה רשום בהערות השחרור
עוד ב-2010.
אָטוֹמִיוּת
פעולות אטומיות הן פעולות שלא ניתן לחלק. לדוגמה, הפעולה של הקצאת ערך למשתנה חייבת להיות אטומית. למרבה הצער, פעולת ההגדלה אינה אטומית, כי ההגדלה דורשת עד שלוש פעולות CPU: קבל את הערך הישן, הוסף לו אחת ואז שמור את הערך. מדוע האטומיות חשובה? עם פעולת ההגדלה, אם יש מצב מרוץ, אז המשאב המשותף (כלומר הערך המשותף) עשוי להשתנות לפתע בכל עת. בנוסף, פעולות המערבות מבנים של 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,000, אבל השיעור value
ישתנה מעת לעת. יש סקירה קצרה של נושא זה: מבוא למשתנים אטומיים ב-Java
. אלגוריתם ה"השוואה והחלפה" נמצא בלב המעמדות האטומיים. אתה יכול לקרוא עוד על זה כאן בהשוואה של אלגוריתמים ללא נעילה - CAS ו-FAA בדוגמה של JDK 7 ו-8
או במאמר השווה והחלפה
בוויקיפדיה.
http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html
קורה-לפני
יש מושג מעניין ומסתורי שנקרא "קורה לפני". כחלק מהמחקר שלך על שרשורים, כדאי לך לקרוא על זה. הקשר קורה-לפני מראה את הסדר שבו יראו פעולות בין שרשורים. יש הרבה פרשנויות ופירושים. להלן אחת המצגות האחרונות בנושא זה: יחסי ג'אווה "התרחש-לפני" .סיכום
בסקירה זו, חקרנו כמה מהפרטים הספציפיים של האופן שבו שרשורים מתקשרים. דנו בבעיות שעלולות להתעורר, כמו גם בדרכים לזהות ולחסל אותן. רשימת חומרים נוספים בנושא:- נעילה בבדיקה כפולה
- JSR 133 (דגם זיכרון Java) שאלות נפוצות
- IQ 35: איך למנוע מבוי סתום?
- מושגי מקבילות בג'אווה מאת דאגלס הוקינס (2017)
GO TO FULL VERSION