סקירה קצרה של התכונות של אינטראקציית חוט. בעבר, בדקנו כיצד שרשורים מסתנכרנים זה עם זה. הפעם נצלול לבעיות שעלולות להתעורר כאשר שרשורים מקיימים אינטראקציה ונדבר כיצד ניתן להימנע מהם. אנו נספק גם כמה קישורים שימושיים ללימוד מעמיק יותר.
דוגמה סופר ניתן למצוא כאן: " Java - Thread Starvation and Fairness ". דוגמה זו מראה כיצד שרשורים עובדים ב-Starvation וכיצד שינוי קטן אחד מ-Thread.sleep ל-Thread.wait יכול לפזר את העומס באופן שווה.
כנראה שעדיף שהסרטון הזה לא יגיד שום דבר על זה. אז אני פשוט אשאיר קישור לסרטון. אתה יכול לקרוא את " Java - Understanding Happens-before relations ".
מבוא
אז, אנחנו יודעים שיש שרשורים ב-Java, עליהם תוכלו לקרוא בסקירה " Thread Can't Spoil Java: Part I - Threads " ושניתנים לסנכרן שרשורים זה עם זה, בהם עסקנו בסקירה " שרשור לא יכול לקלקל את ג'אווה "קלקל: חלק ב' - סנכרון ." הגיע הזמן לדבר על איך שרשורים מתקשרים זה עם זה. איך הם חולקים משאבים משותפים? אילו בעיות יכולות להיות עם זה?מָבוֹי סָתוּם
הבעיה הגרועה ביותר היא Deadlock. כששני חוטים או יותר מחכים זה לזה לנצח, זה נקרא Deadlock. בואו ניקח דוגמה מאתר אורקל מתיאור המושג " מבוי סתום ":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 has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s has 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
ולהיפך. החסימה אינה ניתנת לפתרון, אז היא מתה, כלומר מתה. גם אחיזת מוות (שאי אפשר לשחרר) וגם בלוק מת שממנו לא ניתן לברוח. בנושא מבוי סתום, אתה יכול לצפות בסרטון: " מבוי סתום - במקביל מספר 1 - ג'אווה מתקדמת ".
Livelock
אם יש מבוי סתום, אז יש 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
, נקבל את 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/
מצב Race
כשעובדים עם ריבוי פתילים, יש דבר כזה "תנאי גזע". תופעה זו נעוצה בעובדה שהשרשורים חולקים משאב מסוים בינם לבין עצמם והקוד כתוב בצורה כזו שלא מספקת פעולה נכונה במקרה זה. בואו נסתכל על דוגמה: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 thread ".
נָדִיף
אם כבר מדברים על האינטראקציה של שרשורים, כדאי לשים לב במיוחד למילת המפתח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
מומלץ מאוד לקרוא את התרגום של " JSR 133 (Java Memory Model) FAQ ". אני גם ממליץ לך לקרוא עוד על החומרים " מודל זיכרון Java " ו- " 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());
}
}
שיעור מיוחד לעבודה עם אטומי Integer
תמיד יראה לנו 30000, אבל value
זה ישתנה מעת לעת. יש סקירה קצרה בנושא זה " מבוא למשתנים אטומיים ב-Java ". Atomic מבוסס על אלגוריתם Compare-and-Swap. אתה יכול לקרוא עוד על כך במאמר על Habré " השוואה של אלגוריתמים ללא נעילה - CAS ו-FAA באמצעות הדוגמה של JDK 7 ו-8 " או בוויקיפדיה במאמר על " השוואה עם החלפה ".
http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html
GO TO FULL VERSION