מבוא
אז, אנחנו יודעים שיש שרשורים ב-Java, עליהם תוכלו לקרוא בסקירה " You Can't Spoil Java with a Thread: Part I - Threads ". יש צורך בחוטים כדי לבצע עבודה בו זמנית. לכן, סביר מאוד שהחוטים יתקשרו איכשהו זה עם זה. בואו נבין איך זה קורה ואילו בקרות בסיסיות יש לנו.תְשׁוּאָה
שיטת Thread.yield() מסתורית וממעטת להשתמש בה. ישנן וריאציות רבות לתיאור שלה באינטרנט. עד כדי כך שחלק כותבים על איזה תור של שרשורים, שהשרשור יעבור למטה תוך התחשבות בסדר העדיפויות שלהם. מישהו כותב שהשרשור ישנה את הסטטוס שלו מהפעלה לניתנת להרצה (למרות שאין חלוקה לסטטוסים האלה, וג'אווה לא מבחינה ביניהם). אבל במציאות הכל הרבה יותר לא ידוע ובמובן מסוים פשוט יותר. בנושא תיעוד השיטה,yield
יש באג " JDK-6416721: (שרשור מפרט) Fix Thread.yield() javadoc ". אם תקראו אותו, ברור שלמעשה השיטה yield
רק מעבירה איזו המלצה למתזמן ה-Java Threads שניתן לתת לשרשור הזה פחות זמן ביצוע. אבל מה באמת יקרה, האם המתזמן ישמע את ההמלצה ומה הוא יעשה באופן כללי תלוי ביישום ה-JVM ומערכת ההפעלה. או אולי מגורמים אחרים. כל הבלבול נבע ככל הנראה מחשיבה מחודשת של ריבוי שרשורים במהלך פיתוח שפת ג'אווה. אתה יכול לקרוא עוד בסקירה " מבוא קצר ל-Java Thread.yield() ".
שינה - חוט הירדמות
חוט עלול להירדם במהלך ביצועו. זהו הסוג הפשוט ביותר של אינטראקציה עם שרשורים אחרים. למערכת ההפעלה שעליה מותקנת המכונה הוירטואלית Java, שבה מבוצע קוד Java, יש מתזמן שרשור משלה, הנקרא Thread Scheduler. הוא זה שמחליט איזה שרשור להפעיל מתי. המתכנת לא יכול לקיים אינטראקציה עם מתזמן זה ישירות מקוד Java, אבל הוא יכול, דרך ה-JVM, לבקש מהמתזמן להשהות את השרשור לזמן מה, כדי "להרדים אותו". אתה יכול לקרוא עוד במאמרים " Thread.sleep() " ו" כיצד פועל ריבוי פתילים ". יתר על כן, אתה יכול לגלות כיצד שרשורים עובדים במערכת ההפעלה של Windows: " פנימיות של שרשור Windows ". עכשיו נראה את זה במו עינינו. בואו נשמור את הקוד הבא בקובץHelloWorldApp.java
:
class HelloWorldApp {
public static void main(String []args) {
Runnable task = () -> {
try {
int secToWait = 1000 * 60;
Thread.currentThread().sleep(secToWait);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread thread = new Thread(task);
thread.start();
}
}
כפי שאתה יכול לראות, יש לנו משימה שמחכה 60 שניות, ולאחר מכן התוכנית מסתיימת. אנחנו קומפילים javac HelloWorldApp.java
ומריצים java HelloWorldApp
. עדיף להפעיל בחלון נפרד. לדוגמה, ב-Windows זה יהיה כך: start java HelloWorldApp
. באמצעות הפקודה jps, אנו מגלים את ה-PID של התהליך ופותחים את רשימת השרשורים באמצעות jvisualvm --openpid pidПроцесса
: כפי שניתן לראות, השרשור שלנו נכנס למצב שינה. למעשה, השינה של השרשור הנוכחי יכולה להיעשות בצורה יפה יותר:
try {
TimeUnit.SECONDS.sleep(60);
System.out.println("Waked up");
} catch (InterruptedException e) {
e.printStackTrace();
}
בטח שמתם לב שאנחנו מעבדים בכל מקום InterruptedException
? בואו נבין למה.
הפסקת שרשור או Thread.interrupt
העניין הוא שבזמן שהשרשור ממתין בחלום, אולי מישהו ירצה להפריע להמתנה הזו. במקרה זה, אנו מטפלים בחריג כזה. זה נעשה לאחר שהשיטהThread.stop
הוכרזה כבלתי מבוטלת, כלומר. מיושן ולא רצוי לשימוש. הסיבה לכך הייתה שכאשר השיטה נקראה, stop
השרשור פשוט "נהרג", וזה היה מאוד בלתי צפוי. לא יכולנו לדעת מתי תיעצר הזרימה, לא יכולנו להבטיח את עקביות הנתונים. תאר לעצמך שאתה כותב נתונים לקובץ ואז הזרם נהרס. לכן החלטנו שיותר הגיוני לא להרוג את הזרימה, אלא להודיע לה שיש להפריע לה. איך להגיב לזה תלוי בזרימה עצמה. פרטים נוספים ניתן למצוא ב"מדוע Thread.stop הוצא משימוש? " בואו נסתכל על דוגמה:
public static void main(String []args) {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(60);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
בדוגמה זו, לא נחכה 60 שניות, אלא מיד נדפיס 'מופסק'. הסיבה לכך היא שקראנו לשיטת השרשור interrupt
. שיטה זו מגדירה "דגל פנימי הנקרא סטטוס פסיקה". כלומר, לכל שרשור יש דגל פנימי שאינו נגיש ישירות. אבל יש לנו שיטות מקוריות לאינטראקציה עם הדגל הזה. אבל זו לא הדרך היחידה. שרשור יכול להיות בתהליך של ביצוע, לא לחכות למשהו, אלא פשוט לבצע פעולות. אבל זה יכול לספק שהם ירצו להשלים אותו בשלב מסוים בעבודתו. לדוגמה:
public static void main(String []args) {
Runnable task = () -> {
while(!Thread.currentThread().isInterrupted()) {
//Do some work
}
System.out.println("Finished");
};
Thread thread = new Thread(task);
thread.start();
thread.interrupt();
}
בדוגמה למעלה, אתה יכול לראות שהלולאה while
תפעל עד שהחוט יופסק חיצונית. הדבר שחשוב לדעת על הדגל isInterrupted הוא שאם נתפוס אותו InterruptedException
, הדגל isInterrupted
מתאפס, ואז isInterrupted
הוא יחזור שקר. ישנה גם שיטה סטטית במחלקה Thread שחלה רק על השרשור הנוכחי - Thread.interrupted() , אבל שיטה זו מאפסת את הדגל ל-false! תוכל לקרוא עוד בפרק " הפרעת שרשור ".
הצטרף - ממתין להשלמת שרשור נוסף
הסוג הפשוט ביותר של המתנה הוא המתנה להשלמת שרשור נוסף.public static void main(String []args) throws InterruptedException {
Runnable task = () -> {
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
System.out.println("Interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
thread.join();
System.out.println("Finished");
}
בדוגמה זו, השרשור החדש יישן למשך 5 שניות. יחד עם זאת, החוט הראשי ימתין עד שחוט השינה יתעורר ויסיים את עבודתו. אם תסתכל דרך JVisualVM, מצב השרשור ייראה כך: הודות לכלי ניטור, תוכל לראות מה קורה עם השרשור. השיטה join
די פשוטה, כי זו פשוט שיטה עם קוד ג'אווה שפועלת wait
בזמן שה-thread עליו היא נקראת חי. ברגע שהפתיל מת (בסיומו), ההמתנה נפסקת. זה כל הקסם של השיטה join
. לכן, בואו נעבור לחלק המעניין ביותר.
קונספט מוניטור
ב-multithreading יש דבר כזה מוניטור. באופן כללי, המילה מוניטור מתורגמת מלטינית כ"משגיח" או "משגיח". במסגרת מאמר זה ננסה לזכור את המהות, ולמי שרוצה אני מבקש לצלול לחומר מהקישורים לפרטים. בואו נתחיל את המסע שלנו עם מפרט שפת Java, כלומר עם JLS: " 17.1. סינכרון ". זה אומר את הדברים הבאים: מסתבר שלצורך סנכרון בין שרשורים, Java משתמשת במנגנון מסוים שנקרא "מוניטור". לכל אובייקט יש צג המשויך אליו, ופתילים יכולים לנעול אותו או לפתוח אותו. לאחר מכן, נמצא מדריך הדרכה באתר אורקל: " נעילות פנימיות וסנכרון ". מדריך זה מסביר שהסנכרון ב-Java בנוי סביב ישות פנימית המכונה נעילה פנימית או נעילת צג. לעתים קרובות מנעול כזה נקרא פשוט "צג". אנו גם רואים שוב שלכל אובייקט בג'אווה יש נעילה פנימית הקשורה אליו. אתה יכול לקרוא " Java - מנעולים פנימיים וסנכרון ". לאחר מכן, חשוב להבין כיצד ניתן לשייך אובייקט ב-Java למוניטור. לכל אובייקט בג'אווה יש כותרת - מעין מטא-נתונים פנימיים שאינם זמינים למתכנת מהקוד, אלא שהמכונה הוירטואלית צריכה לעבוד עם אובייקטים בצורה נכונה. כותרת האובייקט כוללת MarkWord שנראית כך:https://edu.netbeans.org/contrib/slides/java-overview-and-java-se6.pdf
public class HelloWorld{
public static void main(String []args){
Object object = new Object();
synchronized(object) {
System.out.println("Hello World");
}
}
}
לכן, באמצעות מילת המפתח, synchronized
השרשור הנוכחי (בו שורות הקוד הללו מבוצעות) מנסה להשתמש במוניטור המשויך לאובייקט object
ו"לקבל נעילה" או "ללכוד את הצג" (האפשרות השנייה אפילו עדיפה). אם אין ויכוח על המוניטור (כלומר אף אחד אחר לא רוצה לסנכרן על אותו אובייקט), ג'אווה יכולה לנסות לבצע אופטימיזציה שנקראת "נעילה מוטה". הכותרת של האובייקט ב-Mark Word תכיל את התג המתאים ורשומה לאיזה חוט מחובר המוניטור. זה מפחית את התקורה בעת לכידת הצג. אם הצג כבר נקשר לחוט אחר בעבר, אז הנעילה הזו לא מספיקה. ה-JVM עובר לסוג הנעילה הבא - נעילה בסיסית. הוא משתמש בפעולות השוואה והחלפה (CAS). יחד עם זאת, הכותרת ב-Mark Word כבר לא מאחסנת את Mark Word עצמו, אלא משתנה קישור לאחסון שלו + התגית כך שה-JVM יבין שאנחנו משתמשים בנעילה בסיסית. אם יש ויכוח על המוניטור של מספר שרשורים (אחד תפס את המוניטור, והשני ממתין לשחרור המוניטור), אז התגית ב-Mark Word משתנה, ו-Mark Word מתחיל לאחסן הפניה למוניטור בתור אובייקט - ישות פנימית כלשהי של ה-JVM. כפי שצוין ב-JEP, במקרה זה, נדרש מקום באזור הזיכרון Native Heap לאחסון ישות זו. הקישור למיקום האחסון של ישות פנימית זו ימוקם באובייקט Mark Word. לפיכך, כפי שאנו רואים, המוניטור הוא באמת מנגנון להבטחת סנכרון של גישה של שרשורים מרובים למשאבים משותפים. ישנם מספר יישומים של מנגנון זה שה-JVM עובר ביניהם. לכן, לשם הפשטות, כשמדברים על מוניטור, אנחנו בעצם מדברים על מנעולים.
מסונכרן וממתין לפי נעילה
המושג של מוניטור, כפי שראינו בעבר, קשור קשר הדוק למושג "גוש סנכרון" (או, כפי שהוא נקרא גם, קטע קריטי). בואו נסתכל על דוגמה:public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
Runnable task = () -> {
synchronized (lock) {
System.out.println("thread");
}
};
Thread th1 = new Thread(task);
th1.start();
synchronized (lock) {
for (int i = 0; i < 8; i++) {
Thread.currentThread().sleep(1000);
System.out.print(" " + i);
}
System.out.println(" ...");
}
}
כאן, השרשור הראשי שולח תחילה את המשימה לשרשור חדש, ולאחר מכן "לוכד" מיד את המנעול ומבצע איתו פעולה ארוכה (8 שניות). כל הזמן הזה, המשימה לא יכולה להיכנס לבלוק לביצוע שלה synchronized
, כי המנעול כבר תפוס. אם חוט לא יכול להשיג נעילה, הוא ימתין לו ליד הצג. ברגע שהוא יקבל אותו, הוא ימשיך בביצוע. כאשר חוט יוצא מהצג, הוא משחרר את הנעילה. ב-JVisualVM זה ייראה כך: כפי שאתה יכול לראות, הסטטוס ב-JVisualVM נקרא "מוניטור" מכיוון שהשרשור חסום ואינו יכול לתפוס את המוניטור. אתה יכול גם לברר את מצב השרשור בקוד, אך השם של מצב זה אינו עולה בקנה אחד עם מונחי JVisualVM, למרות שהם דומים. במקרה זה, th1.getState()
הלולאה for
תחזיר BLOCKED , כי בזמן שהלולאה פועלת, הצג lock
תפוס main
על ידי החוט, והחוט th1
חסום ואינו יכול להמשיך לעבוד עד שהנעילה תוחזר. בנוסף לחסימות סנכרון, ניתן לסנכרן שיטה שלמה. לדוגמה, שיטה מהמחלקה HashTable
:
public synchronized int size() {
return count;
}
ביחידת זמן אחת, שיטה זו תתבצע על ידי חוט אחד בלבד. אבל אנחנו צריכים מנעול, נכון? כן אני צריך את זה. במקרה של שיטות אובייקט, המנעול יהיה this
. יש דיון מעניין בנושא זה: " האם יש יתרון להשתמש בשיטה סינכרונית במקום בלוק מסונכרן? ". אם המתודה היא סטטית, אז הנעילה לא תהיה this
(שכן עבור מתודה סטטית לא יכול להיות this
), אלא אובייקט המחלקה (לדוגמה, Integer.class
).
מחכה ומחכה על המוניטור. השיטות הודע והודיע כל
ל-Thread יש שיטת המתנה נוספת, שמחוברת למוניטור. שלא כמוsleep
ו join
, לא ניתן לקרוא לזה סתם. ושמו הוא wait
. השיטה מבוצעת wait
על האובייקט שעל הצג שלו אנו רוצים להמתין. בוא נראה דוגמה:
public static void main(String []args) throws InterruptedException {
Object lock = new Object();
// task будет ждать, пока его не оповестят через lock
Runnable task = () -> {
synchronized(lock) {
try {
lock.wait();
} catch(InterruptedException e) {
System.out.println("interrupted");
}
}
// После оповещения нас мы будем ждать, пока сможем взять лок
System.out.println("thread");
};
Thread taskThread = new Thread(task);
taskThread.start();
// Ждём и после этого забираем себе лок, оповещаем и отдаём лок
Thread.currentThread().sleep(3000);
System.out.println("main");
synchronized(lock) {
lock.notify();
}
}
ב-JVisualVM זה ייראה כך: כדי להבין איך זה עובד, עליך לזכור ששיטות wait
מתייחסות notify
ל java.lang.Object
. זה נראה מוזר ששיטות הקשורות לשרשור נמצאות ב- Object
. אבל כאן טמונה התשובה. כפי שאנו זוכרים, לכל אובייקט ב-Java יש כותרת. הכותרת מכילה מידע שירותים שונים, כולל מידע על הצג - נתונים על מצב הנעילה. וכזכור, לכל אובייקט (כלומר לכל מופע) יש שיוך עם ישות JVM פנימית הנקראת נעילה פנימית, הנקראת גם מוניטור. בדוגמה שלמעלה, המשימה מתארת שאנו נכנסים לבלוק הסנכרון בצג המשויך ל lock
. אם אפשר להשיג נעילה על צג זה, אז wait
. השרשור שיבצע משימה זו ישחרר את המוניטור lock
, אך יצטרף לתור השרשורים הממתינים להתראה בצג lock
. תור השרשורים הזה נקרא WAIT-SET, מה שמשקף בצורה נכונה יותר את המהות. זה יותר סט מאשר תור. השרשור main
יוצר שרשור חדש עם משימת המשימה, מתחיל אותו וממתין 3 שניות. זה מאפשר, במידה רבה של הסתברות, לשרשור חדש לתפוס את המנעול לפני השרשור main
ולעמוד בתור על הצג. לאחר מכן החוט main
עצמו נכנס לבלוק הסנכרון lock
ומבצע הודעה על השרשור על המסך. לאחר שליחת ההודעה, השרשור main
משחרר את הצג lock
, והשרשור החדש (שהמתין בעבר) lock
ממשיך לפעול לאחר ההמתנה לשחרור הצג. אפשר לשלוח הודעה רק לאחד השרשורים ( notify
) או לכל השרשורים בתור בו זמנית ( notifyAll
). אתה יכול לקרוא עוד ב"הבדל בין notify() ל-notifyAll() ב-Java ". חשוב לציין שסדר ההודעה תלוי ביישום JVM. אתה יכול לקרוא עוד ב"איך לפתור הרעבה עם הודע והודיע הכל? ". ניתן לבצע סנכרון מבלי לציין אובייקט. ניתן לעשות זאת כאשר לא קטע קוד נפרד מסונכרן, אלא שיטה שלמה. לדוגמה, עבור שיטות סטטיות המנעול יהיה אובייקט המחלקה (שהושג באמצעות .class
):
public static synchronized void printA() {
System.out.println("A");
}
public static void printB() {
synchronized(HelloWorld.class) {
System.out.println("B");
}
}
מבחינת שימוש במנעולים, שתי השיטות זהות. אם השיטה אינה סטטית, הסנכרון יתבצע לפי הנוכחי instance
, כלומר לפי this
. אגב, קודם אמרנו שבאמצעות השיטה getState
ניתן לקבל סטטוס של שרשור. אז הנה שרשור שנמצא בתור על ידי המוניטור, המצב יהיה WAITING או TIMED_WAITING אם השיטה wait
ציינה מגבלת זמן המתנה.
מחזור חיים של חוט
כפי שראינו, הזרימה משנה את מעמדו במהלך החיים. בעצם, שינויים אלה הם מחזור החיים של החוט. כאשר שרשור נוצר זה עתה, יש לו את הסטטוס NEW. במצב זה, הוא עדיין לא התחיל ומתזמן ה-Java Threads עדיין לא יודע כלום על השרשור החדש. כדי שמתזמן השרשורים יידע על שרשור, עליך להתקשר ל-thread.start()
. אז השרשור יעבור למצב RUNNABLE. יש הרבה סכימות שגויות באינטרנט שבהן מופרדים מצבי ריצה ומצב ריצה. אבל זו טעות, כי... Java לא מבדילה בין סטטוס "מוכן להפעלה" ו"פועל". כאשר שרשור חי אך אינו פעיל (לא ניתן להרצה), הוא נמצא באחד משני מצבים:
- חסום - ממתין לכניסה לקטע מוגן, כלומר. אל
synchonized
הבלוק. - WAITING - מחכה לשרשור נוסף על סמך תנאי. אם התנאי נכון, מתזמן השרשור מתחיל את השרשור.
getState
. ל-Threads יש גם שיטה isAlive
שמחזירה true אם השרשור לא מסתיים.
LockSupport וחניה חוט
מאז Java 1.6 היה מנגנון מעניין בשם LockSupport . מחלקה זו משייכת "הרשאה" או הרשאה לכל שרשור שמשתמש בה. קריאת השיטהpark
חוזרת מיד אם קיים אישור זמין, תופסת את אותו אישור במהלך השיחה. אחרת הוא חסום. הקריאה לשיטה unpark
מאפשרת היתר אם הוא אינו זמין כבר. יש רק הרשאה אחת. ב-Java API, LockSupport
מסוים Semaphore
. בואו נסתכל על דוגמה פשוטה:
import java.util.concurrent.Semaphore;
public class HelloWorldApp{
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(0);
try {
semaphore.acquire();
} catch (InterruptedException e) {
// Просим разрешение и ждём, пока не получим его
e.printStackTrace();
}
System.out.println("Hello, World!");
}
}
הקוד הזה יחכה לנצח כי לסמפור יש כעת 0 היתר. וכאשר קוראים לקוד acquire
(כלומר, בקש הרשאה), השרשור ממתין עד שהוא מקבל רשות. מכיוון שאנו ממתינים, אנו מחויבים לעבד אותו InterruptedException
. מעניין, סמפור מיישם מצב חוט נפרד. אם נסתכל ב-JVisualVM, נראה שהמדינה שלנו היא לא Wait, אלא Park. בואו נסתכל על דוגמה נוספת:
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
//Запаркуем текущий поток
System.err.println("Will be Parked");
LockSupport.park();
// Как только нас распаркуют - начнём действовать
System.err.println("Unparked");
};
Thread th = new Thread(task);
th.start();
Thread.currentThread().sleep(2000);
System.err.println("Thread state: " + th.getState());
LockSupport.unpark(th);
Thread.currentThread().sleep(2000);
}
סטטוס השרשור יהיה WAITING, אבל JVisualVM מבחין wait
בין מ - synchronized
ו- . למה זה כל כך חשוב ? בואו נפנה שוב ל-Java API ונסתכל על Thread State WAITING . כפי שאתה יכול לראות, יש רק שלוש דרכים להיכנס לזה. 2 דרכים - זה ו . והשלישית היא . מנעולים ב-Java בנויים על אותם עקרונות ומייצגים כלים ברמה גבוהה יותר. בואו ננסה להשתמש באחד. בואו נסתכל, למשל, ב : park
LockSupport
LockSupport
wait
join
LockSupport
LockSupport
ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class HelloWorld{
public static void main(String []args) throws InterruptedException {
Lock lock = new ReentrantLock();
Runnable task = () -> {
lock.lock();
System.out.println("Thread");
lock.unlock();
};
lock.lock();
Thread th = new Thread(task);
th.start();
System.out.println("main");
Thread.currentThread().sleep(2000);
lock.unlock();
}
}
כמו בדוגמאות קודמות, כאן הכל פשוט. lock
מחכה שמישהו ישחרר משאב. אם נסתכל ב-JVisualVM, נראה שהשרשור החדש יוחנה עד main
שהשרשור ייתן לו את הנעילה. אתה יכול לקרוא עוד על מנעולים כאן: " תכנות מרובה הליכי ב-Java 8. חלק שני. סנכרון גישה לאובייקטים הניתנים לשינוי " ו- " Java Lock API. תיאוריה ודוגמה לשימוש ." כדי להבין טוב יותר את היישום של מנעולים, כדאי לקרוא על Phazer בסקירה הכללית " Phaser Class ". ואם כבר מדברים על מסנכרנים שונים, עליך לקרוא את המאמר על Habré " Java.util.concurrent.* Reference Synchronizers ".
GO TO FULL VERSION