JavaRush /בלוג Java /Random-HE /אתה לא יכול לקלקל את ג'אווה עם שרשור: חלק שלישי - אינטראק...
Viacheslav
רָמָה

אתה לא יכול לקלקל את ג'אווה עם שרשור: חלק שלישי - אינטראקציה

פורסם בקבוצה
סקירה קצרה של התכונות של אינטראקציית חוט. בעבר, בדקנו כיצד שרשורים מסתנכרנים זה עם זה. הפעם נצלול לבעיות שעלולות להתעורר כאשר שרשורים מקיימים אינטראקציה ונדבר כיצד ניתן להימנע מהם. אנו נספק גם כמה קישורים שימושיים ללימוד מעמיק יותר. אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 1

מבוא

אז, אנחנו יודעים שיש שרשורים ב-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: אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 2אם תוסף מותקן ב-JVisualVM (באמצעות כלים -> פלאגינים), נוכל לראות היכן התרחשה הקיפאון:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (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
...
כפי שניתן לראות מהדוגמה, שני השרשורים מנסים לסירוגין ללכוד את שני המנעולים, אך הם נכשלים. יתרה מכך, הם לא במבוי סתום, כלומר מבחינה ויזואלית הכל בסדר איתם והם עושים את העבודה שלהם. אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 3לפי JVisualVM, אנו רואים את תקופות השינה ואת תקופת הפארק (זה כאשר שרשור מנסה לכבוש מנעול, הוא נכנס למצב הפארק, כפי שדיברנו קודם כשדיברנו על סנכרון שרשור ). בנושא livelock, אתה יכול לראות דוגמה: " Java - Thread Livelock ".

רָעָב

בנוסף לחסימה (מבוי סתום ו-livelock), יש בעיה נוספת כאשר עובדים עם ריבוי-הליכים - הרעבה, או "הרעבה". תופעה זו שונה מחסימה בכך שהשרשורים אינם חסומים, אך פשוט אין להם מספיק משאבים לכולם. לכן, בעוד שרשורים מסוימים משתלטים על כל זמן הביצוע, אחרים לא ניתנים להורג: אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 4

https://www.logicbig.com/

דוגמה סופר ניתן למצוא כאן: " Java - Thread Starvation and Fairness ". דוגמה זו מראה כיצד שרשורים עובדים ב-Starvation וכיצד שינוי קטן אחד מ-Thread.sleep ל-Thread.wait יכול לפזר את העומס באופן שווה. אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 5

מצב 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: אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 6בדיקה זו (בדיקה) נוספה ל-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 " או בוויקיפדיה במאמר על " השוואה עם החלפה ". אתה לא יכול להרוס את ג'אווה עם שרשור: חלק שלישי - אינטראקציה - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

קורה לפני

יש דבר מעניין ומסתורי - Happens Before. אם כבר מדברים על זרימות, כדאי גם לקרוא על זה. הקשר Happens Before מציין את הסדר שבו יראו פעולות בין שרשורים. יש הרבה פרשנויות ופרשנויות. אחד הדיווחים האחרונים בנושא זה הוא הדו"ח הזה:
כנראה שעדיף שהסרטון הזה לא יגיד שום דבר על זה. אז אני פשוט אשאיר קישור לסרטון. אתה יכול לקרוא את " Java - Understanding Happens-before relations ".

תוצאות

בסקירה זו, בדקנו את התכונות של אינטראקציה עם חוטים. דנו בבעיות שעלולות להתעורר ובדרכים לאיתור ולסילוקן. רשימת חומרים נוספים בנושא: #ויאצ'סלב
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION