שיטות עבודה מומלצות למולטי-ת'רדינג בפועל: מהדורת Java — מוסכמות עידן התהליכונים הווירטואליים
· Go Komura · ריבוי תהליכונים, Java, יישומים עסקיים, חקירת תקלות, תכנון
«רוצים להקביל משימת אצווה עסקית ב-Java.» «המטמון המשותף באפליקציית ה-Spring שלנו מתקלקל מדי פעם.» «ירשנו אפליקציית Swing ישנה שמלאה ב-new Thread.» — ל-Java יש מולטי-ת’רדינג מובנה בשפה מאז JDK 1.0, היא הבשילה לתיבת הכלים java.util.concurrent, ועם תהליכונים וירטואליים ב-JDK 21 כתבה מחדש שוב את חוכמת התכנות התחרותי. דווקא כי ערכת הכלים כל כך עשירה, הכלי שאתם בוחרים הופך לאיכות התכנון עצמה.
מאמר זה הוא מהדורת Java של סדרת המולטי-ת’רדינג בפועל. הוא מיועד למפתחים שכותבים מערכות עסקיות, משימות אצווה ויישומי שרת ב-Java, ממפה את עקרונות תכנון המולטי-ת’רדינג — לעולם לא ליצור תהליכונים ישירות, להפחית מצב משותף ניתן לשינוי, משמעת נעילות, לתכנן איך עוצרים לפני כל דבר אחר — לכלים של Java (בעיקר מהדורת LTS, JDK 21 ואילך), ואוסף את הבחירות של עידן התהליכונים הווירטואליים ואת המלכודות הייחודיות ל-Java, על בסיס מקורות ראשוניים נכון לאוגוסט 2026. הוא כתוב כדי להיקרא בפני עצמו. אותם עקרונות כפי שהוחלו על שפות אחרות מכוסים במאמרי הלוויין «מהדורת .NET», «מהדורת C++» ו«מהדורת C».
1. המסקנה קודם
- אל תכתבו
new Threadבקוד עסקי — אותה כלל חל גם ב-Java. מסרו משימות ל-ExecutorServiceותנו לספרייה לנהל את מחזורי החיים של התהליכונים.1 - נתבו משימות שממתינות בעיקר ל-I/O לתהליכונים וירטואליים. תהליכונים וירטואליים, שהפכו רשמיים ב-JDK 21, משמשים אחד לכל משימה ואסור לשים אותם במאגר לעולם. הגבילו מקביליות ב-
Semaphore, לא בגודל מאגר.23 - תהליכונים וירטואליים הם כלי תפוקה, לא כלי להאצת חישוב. הקבלה CPU-bound עדיין עבודתם של תהליכוני פלטפורמה בגודל של בערך מספר הליבות — מאגר קבוע או parallel stream, כמו קודם.3
- נעלו על אובייקט נעילה
private final, או עלReentrantLockייעודי.synchronized(this)ונעילה על אובייקט חשוף לציבור יכולים להתנגש עם קוד חיצוני. אם צריך רכישה עם מגבלת זמן (tryLock), השתמשו ב-ReentrantLock. - ב-JDK 21-23 הייתה בעיה שחסימה בתוך בלוק
synchronizedהצמידה תהליכונים וירטואליים; JDK 24 (JEP 491) תיקן זאת. הבדילו בין אזהרות ישנות למציאות הנוכחית.34 volatileמבטיח נראות וסדר, לא אטומיות. השתמשו ב-AtomicInteger/LongAdderלמונים, ובנעילות למצב מורכב.5- עצירה שיתופית באמצעות interruption היא הדרך הנכונה היחידה לעצור תהליכון.
Thread.stop/suspend/resumeזורקים כעתUnsupportedOperationException. אל תבלעוInterruptedException— או שחזרו את הסטטוס או זרקו שוב.6 - עצרו
ExecutorServiceבתבנית הדו-שלבית: shutdown → awaitTermination → shutdownNow.shutdownNowהוא best-effort (המימוש הסטנדרטי עובד דרך interruption), ולכן מניח שהמשימות מגיבות ל-interruption.1 - ממשק Swing שייך בלעדית ל-EDT (Event Dispatch Thread). בקשו עדכונים מתהליכונים אחרים דרך
SwingUtilities.invokeLater.7
2. למה מולטי-ת’רדינג קשה — תנאי מרוץ, קיפאון ומודל הזיכרון
אם מרתיחים את הבעיות שמולטי-ת’רדינג מכניס, בלי קשר לשפה, יש שני סוגים.
תנאי מרוץ הוא באג שבו התוצאה תלויה בסדר שבו כמה תהליכונים מגיעים לקטע קוד מסוים. הדוגמה הקלאסית היא מונה משותף: הביטוי הבודד count++ מתפרק בפועל לשלושה צעדים — קרא, הוסף, כתוב בחזרה. אם שני תהליכונים נכנסים לשלושת הצעדים האלה באותו זמן, ההגדלה של תהליכון אחד נדרסת ואובדת כשהאחר כותב בחזרה. התוצאה משתנה בכל הרצה, ואין דרך לחזות איזו תוצאה תקבלו.
sequenceDiagram
participant A as תהליכון A
participant M as משתנה משותף count
participant B as תהליכון B
Note over M: count = 10
A->>M: קריאה (10)
B->>M: קריאה (10)
A->>A: חיבור מקומי (11)
B->>B: חיבור מקומי (11)
A->>M: כתיבה בחזרה (11)
B->>M: כתיבה בחזרה (11)
Note over M: שתי הגדלות קרו,<br/>אבל count = 11 — ההגדלה של תהליכון A אבדה
איור 1: תנאי מרוץ קלאסי שבו הגדלה על מונה משותף הולכת לאיבוד. אם תהליכון אחר נשזר במהלך שלושת הצעדים של count++, הכתיבה האחרונה דורסת את האחרת
קיפאון הוא מצב שבו שני תהליכונים ממתינים כל אחד לנעילה שהשני מחזיק, כך שאף אחד אינו יכול להתקדם. תהליכון A מחזיק נעילה 1 וממתין לנעילה 2; תהליכון B מחזיק נעילה 2 וממתין לנעילה 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.
flowchart LR
A["תהליכון A<br/>מחזיק נעילה 1"] -->|"ממתין לשחרור נעילה 2"| B["תהליכון B<br/>מחזיק נעילה 2"]
B -->|"ממתין לשחרור נעילה 1"| A
איור 2: ההמתנה המעגלית של קיפאון. ברגע שחצי ההמתנה יוצרים לולאה, כל תהליכון בתוך הלולאה ההיא נעצר לנצח
שניהם תלויי-תזמון. שזירה שמופיעה פעם בעשרות אלפי הרצות במכונת פיתוח יכולה לקרות כל יום בשרת ייצור עם מספר ליבות ועומס שונים. «זה מפסיק להשתחזר ברגע שמחברים מנפה» ו«זה נעלם כשהוספתי רישום» שניהם כי התצפית משנה את התזמון — התנהגות קלאסית לבאג מרוץ. בדיוק לכן כל עיקרון במאמר הזה מצביע לכיוון הפחתת המקומות שצריכים סנכרון, לפני שדואגים לסנכרן נכון.
2.1. הנחת יסוד ייחודית ל-Java — מודל הזיכרון ו-happens-before
מעל זה, מה שמיוחד ל-Java הוא שאיך נראים נתונים משותפים מוגדר על ידי יחסי happens-before במודל הזיכרון של Java (JMM).
קריאה וכתיבה של משתנה משותף בלי סנכרון אינן הופכות ל«התנהגות לא מוגדרת» בסגנון C++, אבל יכולות לגרום באופן לגיטימי לכך שערכים ישנים ממשיכים להיראות, או שכתיבות מופיעות שלא בסדר. שגיאת עקביות זיכרון שבה «לולאה צופה בדגל boolean, אבל הערך שתהליכון אחר שינה אינו נראה לעולם» היא התנהגות שה-JMM מתיר, לא באג ב-JVM.5 הכלים שמגנים מפני זה הם המנגנונים שיוצרים יחסי happens-before — synchronized, volatile, והמחלקות ב-java.util.concurrent. השתמשו באוספים תחרותיים נכון והספרייה מבטיחה לכם «יחס happens-before בין פעולת עדכון לשליפה עוקבת».8
במילים אחרות, ההנחיה המעשית של Java מסתכמת כך: אל תתחכמו עם משתנה משותף גולמי. השתמשו בכלים של java.util.concurrent לשיתוף, ותנו לספרייה ליצור את יחסי happens-before.
3. איך ליצור תהליכונים — ExecutorService ותהליכונים וירטואליים
3.1. הפרדת המשימה מאופן ההרצה
ExecutorService הוא מה שנושא את עקרון «אל תיצרו תהליכונים בעצמכם» ב-Java. הוא מפריד את העבודה (Runnable / Callable) מאופן הביצוע — כמה תהליכונים, איזה תור — ומשאיר יצירה, שימוש חוזר והשלכה של תהליכונים לספרייה.1
מ-JDK 21 ואילך, בחירת אופן ההרצה הפכה לבחירה בינארית פשוטה.23
flowchart TB
S["משימה שרוצים להריץ במקביל"] --> Q1{"מה מניע את המשימה?"}
Q1 -->|"בעיקר המתנה ל-I/O<br/>קריאות HTTP, מסד, קבצים"| VT["תהליכונים וירטואליים<br/>Executors.newVirtualThreadPerTaskExecutor<br/>אחד לכל משימה, לעולם לא במאגר"]
Q1 -->|"חישוב CPU-bound"| PT["מאגר קבוע של תהליכוני פלטפורמה<br/>Executors.newFixedThreadPool - בערך כמספר הליבות<br/>או parallel stream"]
VT --> LIMIT["הגבילו מקביליות לשירותים חיצוניים<br/>ב-Semaphore, לא בגודל מאגר"]
איור 3: בחירת אופן ביצוע העבודה מ-JDK 21 ואילך. ציירו קודם את הקו — «שנו איך ממתינים ל-I/O, הקבילו למעבד» — ואז מסרו עבודה I/O-bound לתהליכונים וירטואליים ועבודה CPU-bound למאגר מקובל
יש הסתייגות אחת בצד ה-CPU-bound. Executors.newFixedThreadPool מגביל את מספר התהליכונים, אבל התור שלו אינו חסום. בשירות ארוך-חיים שבו ההגשות ממשיכות להקדים את העיבוד, רק התהליכונים מוגבלים למספר הליבות — המשימות שנערמות בתור, והנתונים שלהן, ממשיכות לאכול זיכרון. בסוג כזה של התקנה, או השתמשו ב-ThreadPoolExecutor ישירות כדי להגדיר תור חסום פלוס מדיניות דחייה, או שימו בקרת כניסה כמו Semaphore בצד המגיש כדי שתוכלו להפעיל לחץ-נגדי (אותו עיקרון כמו דיון התורים בסעיף 4).
3.2. אל תשתמשו לרעה בתהליכונים וירטואליים
תהליכונים וירטואליים הם תהליכונים קלים המנותקים מתהליכוני מערכת ההפעלה: במהלך פעולת חסימה ב-JDK (I/O, נעילה, שינה וכדומה בספרייה הסטנדרטית) הם משחררים את תהליכון מערכת ההפעלה שלהם, ולכן JVM אחת יכולה להריץ מיליונים מהם. עם זאת, הם לא משחררים אותו לכל סוג חסימה. אם תהליכון וירטואלי נחסם בזמן שהוא מריץ קוד ילידי (JNI) או פונקציה זרה, הוא נשאר מוצמד לתהליכון הנושא שלו. מה ש-JDK 24 (JEP 491, להלן) תיקן הוא הצמדה שנגרמה מ-synchronized; הצמדה בגבול ילידי נשארת, ולכן טעינת מספר גדול של תהליכונים וירטואליים בפעולות שנחסמות זמן רב דרך מנהל JNI או API של התקן תמצה את תהליכוני הנושא. אבל כפי שהמדריך הרשמי מדגיש, הם לא «תהליכונים מהירים יותר». מהירות ביצוע הקוד אינה משתנה — מה שהם מספקים הוא קנה מידה (תפוקה).3
יש שלוש משמעויות לשימוש בהם.3
- אל תשימו אותם במאגר. תהליכונים וירטואליים זולים וחד-פעמיים; «מספר המשימות = מספר התהליכונים הווירטואליים» הוא המצב הנכון. הכנסת תהליכונים וירטואליים ל-
newFixedThreadPoolהיא טעות — השתמשו בצורהtry (var executor = Executors.newVirtualThreadPerTaskExecutor()). - הגבילו מקביליות ב-
Semaphore. בטאו אילוץ כמו «לכל היותר 10 חיבורים מקבילים ל-API חיצוני» בסמפור, לא בגודל מאגר. - אל תשתמשו בהם לעבודה CPU-bound. תהליכוני פלטפורמה בגודל של בערך מספר הליבות נשארים הכלי הנכון להקבלת חישוב, כמו קודם.
שימו לב שמה שרץ בתוך תהליכון וירטואלי הוא קוד סינכרוני רגיל. במקום לשכתב את הקוד כמו ש-async/await של .NET עושה, פילוסופיית התכנון מאחורי תהליכונים וירטואליים היא לאפשר לכם להריץ קוד ישר «תהליכון אחד לבקשה», בלי שינוי, בקנה מידה עצום.2
3.3. אי-הבנה נפוצה — «אין צורך במאגר» חל רק על תהליכונים וירטואליים
אל תקראו את משמעת «אל תשימו אותם במאגר» כאילו «ב-Java אין דבר כזה מאגר תהליכונים (או שהוא לא יעיל)». המציאות הפוכה: מאגרי Java הם חלק בשל של הספרייה הסטנדרטית מאז JDK 5 (2004). המאגר הכללי הניתן לכוונון עדין ThreadPoolExecutor (נוצר דרך מפעלי Executors השונים), ForkJoinPool של גניבת עבודה (המופע המשותף שלו, commonPool(), הוא יעד הביצוע ברירת המחדל לזרמים מקבילים ול-CompletableFuture), ו-ScheduledThreadPoolExecutor לביצוע מחזורי — אלה עדיין השחקנים המובילים לעבודה CPU-bound.
מאגר הוא ביסודו אופטימיזציה המיוסדת על «יצירה והחזקה של תהליכון מערכת הפעלה יקרות, לכן השתמשו בו שוב». תהליכונים וירטואליים מבטלים את ההנחה הזו בכך שהם הופכים את עלות היצירה לקרובה לאפס, כך שאין עוד סיבה להשתמש בהם שוב — ההבנה המדויקת אינה שהמאגר הפך ללא יעיל, אלא שהתהליכונים הפכו קלים מספיק כדי שאופטימיזציית המאגר תהיה מיותרת. ומתחת לתהליכונים וירטואליים, המתזמן של ה-JDK מריץ קבוצת תהליכוני נושא (תהליכוני מערכת הפעלה) שמספרם בערך מספר הליבות, כ-ForkJoinPool של גניבת עבודה.2 במילים אחרות, הצורה «לטפל בכמות עצומה של עבודה מקבילית במאגר קטן של תהליכוני מערכת הפעלה» נשמרת; רק ניהול המאגר עבר מידי המפתח ל-JVM. הוגן לומר ש-Java מגיעה לאותו יעד כמו async/await של .NET, שמחזיר את התהליכון שלו למאגר בנקודת await, בלי לשנות את צורת הקוד.
4. הפחתת מצב משותף ניתן לשינוי — חלוקה, אי-שינוי, אוספים תחרותיים ותורים
תחרות נוצרת רק כשקיימים יחד «כמה תהליכונים» ו«נתונים משותפים ניתנים לשינוי». מספר התהליכונים נקבע לפי דרישות, ולכן מה שהתכנון יכול לחתוך הוא החלק המשותף. יש שלוש משפחות של טכניקות — חלוקה, הפיכה לבלתי-ניתן-לשינוי, ומסירת נתונים — והנה איך כותבים אותן ב-Java.
חלקו. לצבירה מקבילית, במקום שכל תהליכון יכתוב למשתנה סכום משותף, תנו לכל תהליכון לבנות תוצאה חלקית ואחדו אותן בסוף. reduce / collect של זרם מקבילי מספקים בדיוק את הצורה הזו כמסגרת, ו-LongAdder, שנדון בהמשך, הוא גם מימוש של אסטרטגיית החלוקה — מפצל פנימית לתאים כדי לפזר תחרות, ומסכם אותם בקריאה. להפחית כמה פעמים כותבים למצב משותף בא לפני כתיבת סנכרון נכון.
הפכו לבלתי-ניתן-לשינוי. בנו נתונים עם record ואוספים בלתי-ניתנים-לשינוי (List.copyOf / Map.copyOf) שלעולם אינם נכתבים מחדש אחרי הבנייה, ותוכלו לשתף אותם בלי סנכרון. לתצורה ולנתוני אב, התבנית הסטנדרטית: כשצריך להחליף, בנו אובייקט חדש והחליפו הפניה volatile. עם זאת, «נראה לקריאה בלבד» ו«הוא בלתי-ניתן-לשינוי» הם דברים שונים. הגישות של record מחזירות את ההפניות הגולמיות של הרכיבים, והעתק של List.copyOf גם הוא רדוד (אינו משכפל את אובייקטי האיברים), ולכן אם האיברים ניתנים לשינוי, כל מי שמחזיק כינוי יכול לכתוב מחדש את התוכן, והתחרות נשארת. בטוח לשתף בלי סנכרון רק כשגרף האובייקטים כולו — כולל האיברים — בלתי-ניתן-לשינוי. אם מעורבים איברים ניתנים לשינוי, או העבירו העתק עמוק או דחפו גם את האיברים לכיוון record / טיפוסים בלתי-ניתנים-לשינוי.
השתמשו בפעולות המורכבות על אוספים תחרותיים. לתבנית «צור אם חסר, ואז הכנס» של ConcurrentHashMap השתמשו ב-computeIfAbsent. השיטה הזו מבצעת את כל הקריאה באטומיות, ואם המפתח חסר, פונקציית המיפוי נקראת בדיוק פעם אחת בתוך אותה קריאה בודדת.8 האחריות שונה מ-ConcurrentDictionary.GetOrAdd של .NET (שהמפעל שלו יכול לרוץ יותר מפעם אחת תחת תחרות) — נקודה שאנשים שעוברים בין שתי השפות מבלבלים בקלות. זה, עם זאת, אינו «בדיוק פעם אחת לאורך חיי המפתח». אם הפונקציה מחזירה null או זורקת, לא נרשם מיפוי, והפונקציה רצה שוב בקריאה עוקבת (אותו דבר אם הרשומה הוסרה אחרי הרישום). אם האתחול שלכם אינו סובל תופעות לוואי כפולות, תכננו כך שהפונקציה תצליח ותחזיר לא-null. כמחיר של להיות אטומית, חלק מהעדכונים מתהליכונים אחרים נחסמים בזמן שהחישוב רץ, ולכן שמרו על פונקציית המיפוי קצרה, ואל תעדכנו את אותה מפה מתוך הפונקציה (עדכון רקורסיבי שזוהה יכול לזרוק IllegalStateException).8
// התבנית הסטנדרטית למונה תדירות: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
מסרו נתונים דרך תור. נתבו את זרימת הנתונים בין תהליכונים דרך BlockingQueue. עם ArrayBlockingQueue שקיבל קיבולת, put נחסם כשהוא מלא, ונותן לחץ-נגדי טבעי — אותה צורה כמו הערוץ החסום במהדורת .NET. גם בעידן התהליכונים הווירטואליים, התכנון הזה של ציור גבול ברור בין יצרן לצרכן נשאר יעיל.
5. משמעת נעילות — synchronized ו-ReentrantLock
5.1. על מה לנעול, ומה לא לעשות בזמן שמחזיקים נעילה
חשבו על יחידת הנעילה לא כ«קטע קוד» אלא כ«נתונים». ממפו אובייקט נעילה אחד לכל קבוצת נתונים ניתנים לשינוי שרוצים להגן עליהם, וקחו את אותה נעילה בכל מקום שנוגע בנתונים האלה — המציאות של באג מרוץ היא בדרך כלל שהמיפוי הזה התפרק איפשהו. הימנעו מ-synchronized(this) ומ-synchronized(SomeClass.class), כי קוד חיצוני יכול לנעול את אותו אובייקט; במקום זאת, צמדו את הנתונים שרוצים להגן עליהם אחד-לאחד ל-private final Object lock = new Object(); שלעולם אינו נחשף החוצה.
עוד שתי משמעויות מעל זה. ראשית, אל תעשו שום דבר איטי, ושום דבר שנוגע בעולם החיצוני, בזמן שמחזיקים נעילה. I/O, קריאה למאזינים, או הרצת קוד לא ידוע בזמן שעדיין מחזיקים את הנעילה גם מאריכים כמה זמן מחזיקים וגם מסכנים שהנקרא ינסה לקחת נעילה אחרת, וליצור את ההמתנה המעגלית מאיור 2. שנית, קבעו את סדר הרכישה לנעילות מרובות. במקום שלוקחים שתי נעילות או יותר, עשו כלל שכל תהליכון לוקח אותן באותו סדר, ולמקומות שבהם אי אפשר להבטיח את הסדר הכינו נתיב «וותר ונסה שוב אם לא הצלחת» עם tryLock(timeout), שנדון בהמשך.
synchronized מספיק ל«הדרה קצרה ופשוטה». עברו ל-ReentrantLock ברגע שצריך את הדברים הבאים.
- רכישה עם מגבלת זמן דרך
tryLock(timeout)(הפיכת תקיעה קבועה לכשל שאפשר לרשום ולטפל בו) - מדיניות הוגנות, כמה
Condition, או כשרוצים לפצל רכישה ושחרור של נעילה בין שיטות שונות
כשמשתמשים ב-ReentrantLock, לעולם אל תשברו את התבנית של try מיד אחרי lock() ו-unlock() בבלוק finally (ל-Java אין מקבילה ל-RAII של C++, ולכן התבנית הזו היא כל המשמעת).
5.2. תהליכונים וירטואליים והצמדה — מה השתנה ב-JDK 24
כשתהליכונים וירטואליים הוצגו לראשונה (JDK 21-23), היה אילוץ שבו חסימה בתוך בלוק synchronized הצמידה את התהליכון הווירטואלי לתהליכון מערכת ההפעלה שלו (הוא לא יכול היה לשחרר את תהליכון מערכת ההפעלה, ואיבד את יתרון קנה המידה), והומלץ להחליף נקודות חסימה תכופות או ארוכות ב-ReentrantLock.3 האילוץ הזה נפתר כש-JEP 491 של JDK 24 כתב מחדש את מימוש המוניטור, ו-synchronized כבר אינו מצמיד תהליכונים וירטואליים.4 אם אתם ב-JDK 24 ואילך, החלפה מכנית של synchronized כנגד הצמדה כבר אינה נחוצה. שווה לבדוק אם ההנחיות הישנות יותר בארגון שלכם עדיין תקועות באזהרת עידן JDK 21.
5.3. איפה האטומיים ו-volatile נכנסים
עדכונים אטומיים למשתנה בודד מטופלים על ידי AtomicInteger / AtomicLong / AtomicReference (או, לסטטיסטיקה שמגדילים רק בתדירות גבוהה, LongAdder העמיד בפני תחרות). volatile מבטיח נראות וסדר (happens-before), לא אטומיות לפעולות מורכבות.5 אותה מסקנה כמו במהדורות .NET ו-C++ מחזיקה גם ב-Java: השתמשו באטומיים לדגלים ולערכים בודדים, בנעילות למצב מורכב, ואל תנסו לגרום ל-volatile לעשות זאת לבד.
6. תכנון איך עוצרים — interruption כשפה משותפת
6.1. כללי הנימוס של interrupt
עצירה וביטול ב-Java מאוחדים סביב interruption. t.interrupt() מגדיר את סטטוס ה-interrupt של תהליכון היעד, ואם היעד חסום ב-sleep / wait / join או דומה, הוא זורק InterruptedException כדי להעיר אותו מיד (ואז סטטוס ה-interrupt מנוקה).6 Thread.stop / suspend / resume, המנגנונים הכפויים של העבר, אינם בטוחים ביסודם, ולכן קריאה אליהם כעת מסתיימת ב-UnsupportedOperationException.6
flowchart TB
OWNER["הקורא עוצר אותו - קורא ל-t.interrupt"] --> ST["סטטוס ה-interrupt מוגדר"]
ST --> A["תהליכון שמחשב:<br/>בודק Thread.interrupted בלולאה"]
ST --> B["חסום ב-sleep / wait / join:<br/>InterruptedException נורה ומעיר מיד<br/>הסטטוס מנוקה"]
A --> E["מנקה ומסיים בעצמו"]
B --> C{"מה ה-catch עושה?"}
C -->|"יכול לסיים בעצמו"| E
C -->|"לא יכול לסיים - למשל בתוך ספרייה"| R["Thread.currentThread.interrupt<br/>משחזר את הסטטוס ומשאיר את האות"]
R --> E
איור 4: עצירה שיתופית באמצעות interruption. בליעת InterruptedException גורמת לאות העצירה להיעלם — ברגע שתפסתם אותו, הבחירה היא «לסיים» או «לשחזר»
יש משמעת אחת בלבד שצריך לזכור בפועל: אל תכתבו קוד שתופס InterruptedException ואינו עושה דבר. אם אפשר לסיים באחריות שלכם, סיימו שם; אם לא, שחזרו את הסטטוס ב-Thread.currentThread().interrupt() והעבירו את האות לקורא (ראו את שאלות הנפוצות).
6.2. הכיבוי הדו-שלבי של ExecutorService
ממשקי הכיבוי של ExecutorService יושבים מעל מודל ה-interruption. shutdown() מפסיק לקבל משימות חדשות ונותן למשימות שכבר הוגשו לרוץ עד השלמה; shutdownNow() מנסה לעצור משימות רצות. כמפרט ממשק זה best-effort, ומתועד במפורש שמימוש סטנדרטי (כמו ThreadPoolExecutor) מבטל בדרך כלל דרך Thread.interrupt() — כלומר משימה שאינה מגיבה ל-interruption לא תיעצר גם עם shutdownNow, ואם משתמשים במימוש Executor מותאם צריך לבדוק בתיעוד שלו איך הוא מבטל (האם הוא שולח interrupt בכלל).1 תבנית העצירה הסטנדרטית שמוצגת בתיעוד הרשמי היא התבנית הדו-שלבית הבאה.1
/** אמת כשהכיבוי הושלם. אל תמשיכו לשחרור משאבים משותפים כל עוד זה שקר. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // שלב 1: להפסיק לקבל משימות חדשות ולהמתין להשלמה
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
// שלב 2: לבקש ביטול. משימות שהורדו לפני שהרצו
// מוחזרות, לכן סמנו את ה-Future שלהן כמבוטלות כדי להעיר ממתינים ל-get()
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate");
return false; // הכיבוי לא הושלם. דווחו בצורה שניתן להבחין בה מהצלחה
}
}
return true;
} catch (InterruptedException ex) {
pool.shutdownNow().forEach(r -> {
if (r instanceof Future<?> f) f.cancel(false);
});
Thread.currentThread().interrupt(); // שחזרו גם את סטטוס ה-interrupt של התהליכון הזה
return false; // גם הנתיב הזה עלול להשאיר את הכיבוי חלקי
}
}
flowchart TB
S["shutdown<br/>מפסיק לקבל משימות חדשות"] --> W1{"awaitTermination<br/>ממתין להשלמה"}
W1 -->|"מסיים בתוך המועד"| DONE["הכיבוי הושלם"]
W1 -->|"פג הזמן"| NOW["shutdownNow<br/>שולח interrupt למשימות רצות<br/>האם יגיב תלוי במשימה"]
NOW --> W2{"awaitTermination<br/>ממתין שוב"}
W2 -->|"משלים"| DONE
W2 -->|"עדיין לא סיים"| LOG["רשמו כחריגה<br/>חשדו במשימה שמתעלמת מ-interrupt"]
איור 5: הכיבוי הדו-שלבי של ExecutorService. תכנון מדורג של «המתן בנימוס → בקש דרך interruption → אם עדיין לא סיים, צפה בזה כחריגה»
יש מגבלה אחת בביטול ה-Runnable ש-shutdownNow() מחזיר. מה שחוזר הוא האובייקט שישב בתור הביצוע — ל-submit פשוט זה בדיוק ה-FutureTask שנמסר לקורא, אבל למשימות שהוגשו דרך עטיפה כמו ExecutorCompletionService זה העטיפה שבתוך התור, אובייקט שונה מ-Future של הקורא. בתצורה הזו, הביטול למעלה לא ישלים את ה-Future של הקורא, לכן תכננו לשמור רשימת Future משלכם בזמן ההגשה ולבטל אותם בכיבוי (או להחזיר את המשימות שהורדו לבעליהן).
close() (AutoCloseable), זמין מ-JDK 19 ואילך, אורז «קרא ל-shutdown והמתן להשלמה» לצורה שאפשר לכתוב עם try-with-resources, ו-try (var executor = ...) בשילוב עם newVirtualThreadPerTaskExecutor של תהליכונים וירטואליים הוא הצורה הבסיסית המודרנית.1 עם זאת, close() אינו תחליף לתבנית הדו-שלבית למעלה. כי הוא ממתין להשלמה בלי מגבלת זמן, אם אפילו משימה אחת אינה מגיבה ל-interruption או לעולם אינה מסיימת, התהליכון שמנסה לסגור אותו נחסם לנצח. זה כלי שמתאים להיקפים שבהם המשימות סופיות ומבטיחות לרוץ עד השלמה (הגישו שם, המתינו שם); למקומות כמו נתיב הכיבוי של יישום, שבהם רוצים שתמיד יסתיים בתוך זמן חסום, השתמשו בתבנית הדו-שלבית עם מגבלת זמן במקום. ביטול משימה בודדת נעשה גם כן דרך interruption, עם Future.cancel(true).
7. תהליכון הממשק — ה-EDT של Swing
יישומי שולחן עבודה, בלי קשר לשפה או למסגרת, פועלים לפי הכלל שממשק המשתמש שייך בלעדית לתהליכון שמנהל אותו. ב-Swing, התהליכון הבלעדי הזה הוא Event Dispatch Thread (EDT): שיטות רכיבי Swing, ככלל, אינן בטוחות לתהליכונים, ונגיעה בהן מכמה תהליכונים מזמינה הפרעת תהליכונים ושגיאות עקביות זיכרון. בקשו עדכוני מסך מתהליכונים אחרים דרך SwingUtilities.invokeLater אל ה-EDT, ולהפך, כי הרצת פעולות ארוכות על ה-EDT מקפיאה את הממשק, דחפו עבודה כבדה לתהליכון עובד דרך SwingWorker או דומה.7 JavaFX פועל באותה צורה: עדכוני ממשק מתבקשים על תהליכון היישום דרך Platform.runLater.
8. אימות וניפוי — dumps של תהליכונים כנשק
אי אפשר לצפות שבדיקות ימצאו באגי מרוץ. בדיקות רגילות סופרות כהצלחה הרצה שבה במקרה לא קרתה תחרות. חשבו על ההגנה בשלוש שכבות.
קו ההגנה הראשון הוא תכנון. בסקירה, בדקו בטבלה: אילו נתונים ניתנים לשינוי משותפים, איזו נעילה מגנה על כל פריט (המיפוי מסעיף 5.1), האם סדר רכישת הנעילות ייחודי, האם בלוק catch כלשהו בולע InterruptedException, והאם נתיב העצירה (shutdown/interruption) מגיע לכל משימה.
שנית, נצלו היטב dumps של תהליכונים. ל-Java יש כלי סטנדרטי ללכידת «מצב כל תהליכון ברגע הקפוא הזה»: jstack (או jcmd <pid> Thread.print) מדפיס עקבות מחסנית, והאפשרות -l מוסיפה גם מידע על נעילות.9 שימו לב שפורמט ה-dump המסורתי הזה הוא לתהליכוני פלטפורמה; הוא אינו כולל את התהליכונים הווירטואליים של היישום שלכם. כשעוקבים אחרי בקשה חסומה בתצורה שמשתמשת בתהליכונים וירטואליים (סעיף 3), השתמשו ב-jcmd <pid> Thread.dump_to_file -format=json <file>, שיכול לדלוף גם תהליכונים וירטואליים.2 ההליך הבסיסי לחקירת תקיעה הוא לקחת שני או שלושה dumps בהפרש של כמה שניות ולהצליב על איזו נעילה כל תהליכון בטל ממתין, ומי מחזיק בנעילה הזו. אם תרשמו פקיעות זמן של tryLock(timeout) (סעיף 5.2), תוכלו אפילו לאוטומט את הטריגר ללקיחת dump.
שלישית, נערו תחת עומס. בדיקת עומס — הרצה ארוכה עם מקביליות גדולה ממספר הליבות, ערבוב סדר עיבוד, הזרקת השהיות מלאכותיות — היא דרך מעשית להעלות את הסיכוי למשוך את «הפגיעה» של תנאי מרוץ במכונת פיתוח. הריצו לפחות בדיקה אחת בנפח נתונים ומספר תהליכונים של קנה מידה ייצור לפני שחרור.
9. לאן הולכת התחרותיות של Java — Structured Concurrency
מבט מהיר חצי צעד קדימה, לסיום. בנוי על הנחת התהליכונים הווירטואליים, Structured Concurrency (StructuredTaskScope) — שמתייחס לכמה משימות-משנה כיחידת עבודה אחת ומבנה הפצת כשל וביטול — בפיתוח, ונכון לאוגוסט 2026 עדיין תכונת תצוגה מקדימה. הוא עודכן לצורה מבוססת StructuredTaskScope.open() בתצוגה המקדימה החמישית של JDK 25 (JEP 505), וממשיך לתצוגה מקדימה שישית (JEP 525) ב-JDK 26 הנוכחי.1011 בינתיים Scoped Values, שיתוף הקשר בלתי-ניתן-לשינוי שפותר את הבעיות של ThreadLocal, הושלם ב-JDK 25.12 עקרונות המאמר הזה — גבולות משימה ברורים, שיתוף בלתי-ניתן-לשינוי, עצירה שיתופית — מיושרים גם עם הכיוון שאליו ה-API החדשים האלה הולכים.
10. סיכום — רשימת הבדיקה של Java
- האם נשאר
new Threadכלשהו בקוד עסקי (האם הוא בנוי עלExecutorService/ תהליכונים וירטואליים)? - האם עבודה I/O-bound ועבודה CPU-bound מנותבות למנגנוני ביצוע שונים (הענף באיור 3)?
- האם תהליכונים וירטואליים אינם במאגר, והאם מקביליות מוגבלת ב-
Semaphore? - האם נתונים משותפים בלתי-ניתנים-לשינוי (
record/List.copyOf), או בנויים על הכלים שלjava.util.concurrent? - האם אין
synchronized(this)או נעילה על אובייקט חשוף לציבור? - האם משתמשים בפעולות המורכבות של
ConcurrentHashMap(computeIfAbsentוכו’) ושומרים על פונקציית המיפוי קצרה? - האם לא מצפים לאטומיות מ-
volatile(האם מונים משתמשים במחלקות Atomic /LongAdder)? - האם אין אפילו בלוק catch אחד שבלע
InterruptedException? - האם עצירת
ExecutorServiceעוקבת אחרי התבנית הדו-שלבית עם מגבלת זמן (והאם מקומות שמשתמשים ב-close()מוגבלים להיקפים שבהם השלמת משימות מובטחת)? - האם עדכוני ממשק Swing/JavaFX מרוכזים על ה-EDT / תהליכון היישום?
Java היא אחת השפות המצוידות ביותר בכלי תחרותיות, והגעת התהליכונים הווירטואליים פתחה נתיב להרחבת קוד סינכרוני ישר בלי לשכתב אותו. בדיוק לכן תפיסה נכונה של חלוקת העבודה בין הכלים — איזה לתפוקה, איזה להדרה, ומה מסמן עצירה — היא מהות תכנון המולטי-ת’רדינג ב-Java.
מאמרים קשורים
- שיטות עבודה מומלצות למולטי-ת’רדינג בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים עוד תהליכונים
- שיטות עבודה מומלצות למולטי-ת’רדינג בפועל: מהדורת C++ — ביטול תאונות במבנה עם RAII ו-jthread
- שיטות עבודה מומלצות למולטי-ת’רדינג בפועל: מהדורת C — כתיבה בטוחה בדרך Win32 API
- טבלת החלטה ל-async/await ב-C# — Task.Run ו-ConfigureAwait
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בסקירות תכנון מולטי-ת’רדינג למערכות עסקיות ולעיבוד אצווה ב-Java, בחקירת שורש של פגמי תחרותיות כמו קלקול מצב משותף ותסמיני «לפעמים לא נעצר / נתקע» (ניתוח dumps של תהליכונים), ובייעוץ טכני על אימוץ תהליכונים וירטואליים.
מקורות
-
Oracle, ExecutorService (Java SE 21 & JDK 21 API). על כך ש-shutdown() נותן למשימות שכבר הוגשו לרוץ עד השלמה תוך עצירת הגשות חדשות; על כך ש-shutdownNow() מנסה לעצור משימות רצות ומחזיר רשימת משימות שהמתינו לביצוע, אף שהמימוש הטיפוסי מבטל דרך Thread.interrupt(), בלי אחריות מעבר ל-best-effort, כך שמשימה שאינה מגיבה ל-interruption לא תסתיים; על היכולת להמתין להשלמה עם awaitTermination; על כך ש-close() (AutoCloseable, מ-Java 19 ואילך) קורא ל-shutdown וממתין להשלמה, שמיש עם try-with-resources; ועל כך שהכיבוי הדו-שלבי shutdown → awaitTermination → shutdownNow מוצג כדוגמת שימוש. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
OpenJDK, JEP 444: Virtual Threads. על כך שתהליכונים וירטואליים הפכו לתכונה רשמית ב-JDK 21; על היותם תהליכונים קלים שמפחיתים דרמטית את המאמץ לכתוב, לתחזק ולצפות ביישומים תחרותיים בעלי תפוקה גבוהה; על פילוסופיית התכנון להרחיב קוד סינכרוני ישר «בקשה אחת, תהליכון אחד» בלי שינוי; על כך שמתזמן התהליכונים הווירטואליים של ה-JDK הוא ForkJoinPool של גניבת עבודה הפועל במצב FIFO, עם מקביליות ברירת מחדל השווה למספר המעבדים הזמינים; ועל כך שנוסף פורמט dump תהליכונים חדש שכולל תהליכונים וירטואליים בשם jcmd Thread.dump_to_file (בטקסט פשוט וב-JSON), בעוד dumps תהליכונים מסורתיים אינם כוללים תהליכונים וירטואליים. ↩ ↩2 ↩3 ↩4 ↩5
-
Oracle Java SE Core Libraries, Virtual Threads. על כך שתהליכונים וירטואליים הם תהליכונים קלים שמממש זמן הריצה של Java ומשחררים את תהליכון מערכת ההפעלה שלהם במהלך I/O חוסם; על היותם תכונה לקנה מידה (תפוקה) ולא למהירות (השהיה), ואינם מתאימים לעיבוד עתיר מעבד; על כך שלעולם אין לשים תהליכונים וירטואליים במאגר ומשתמשים באחד לכל משימה (newVirtualThreadPerTaskExecutor); על שימוש ב-Semaphore ולא במאגר תהליכונים להגבלת מקביליות; על כך שחסימה בתוך synchronized נכון ל-JDK 21 גרמה להצמדה לתהליכון מערכת ההפעלה, שלשמה הומלץ להחליף נקודות תכופות או ארוכות ב-ReentrantLock; ועל היכולת לזהות הצמדה עם -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. על כך שמימוש המוניטור של ה-JVM נכתב מחדש ל-JDK 24 כדי לתמוך בתהליכונים וירטואליים, כך שחסימה בתוך בלוק או שיטת synchronized כבר אינה מצמידה תהליכון וירטואלי לתהליכון הנושא שלו; ועל כך שמשמעות הדבר היא שנגד עידן JDK 21-23 של «החלפת synchronized ב-ReentrantLock» אינו, בעיקרון, נחוץ עוד. ↩ ↩2
-
Oracle, The Java Tutorials, Memory Consistency Errors. על כך ששגיאות עקביות זיכרון נוצרות כשלכמה תהליכונים יש תצוגות לא עקביות של אותם נתונים; על כך שהמפתח להימנעות מהן הוא יחס happens-before (אחריות שכתיבת זיכרון של הוראה אחת נראית להוראה אחרת); ועל כך ש-synchronized, volatile ו-Thread.start / join, בין השאר, יוצרים יחסי happens-before. ↩ ↩2 ↩3
-
Oracle, Thread (Java SE 21 & JDK 21 API). על כך ש-Thread.stop / suspend / resume אינם בטוחים ביסודם (נעילות משוחררות במצב לא עקבי ואובייקטים שבורים נראים; suspend יכול להזמין קיפאון), מה שהפך אותם ל-deprecated לקראת הסרה, וכעת הם זורקים UnsupportedOperationException כשקוראים להם; על כך ש-interrupt() מגדיר את סטטוס ה-interrupt ומעיר תהליכון חסום ב-sleep / wait / join בזריקת InterruptedException (שמנקה את סטטוס ה-interrupt); ועל ההבדל באיך interrupted() ו-isInterrupted() מתייחסים לסטטוס הזה. ↩ ↩2 ↩3
-
Oracle, The Java Tutorials, The Event Dispatch Thread. על כך שקוד הטיפול באירועים של Swing רץ על Event Dispatch Thread (EDT); על כך שרוב שיטות אובייקטי Swing אינן בטוחות לתהליכונים, כך שקריאה אליהן מכמה תהליכונים מזמינה הפרעת תהליכונים ושגיאות עקביות זיכרון, כלומר גישה לרכיבי Swing צריכה, ככלל, להיעשות על ה-EDT; על בקשת משימות על ה-EDT מתהליכונים אחרים דרך SwingUtilities.invokeLater / invokeAndWait; ועל כך שמשימות שרצות על ה-EDT צריכות להסתיים במהירות. ↩ ↩2
-
Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). על כך שכל קריאת השיטה computeIfAbsent מבוצעת באטומיות, עם פונקציית המיפוי שנקראת בדיוק פעם אחת כשהמפתח חסר; על כך שחלק מפעולות העדכון מתהליכונים אחרים נחסמות במהלך החישוב, ולכן יש לשמור אותה קצרה ופשוטה; על כך שאסור לפונקציית המיפוי לשנות את המפה הזו עצמה, ועדכון רקורסיבי שזוהה מסתיים ב-IllegalStateException; ועל כך שפעולת שליפה (get) אינה חוסמת, עם יחס happens-before שחל בין עדכון למפתח נתון לשליפה עוקבת. ↩ ↩2 ↩3
-
Oracle, The jstack Command (Java SE 21 Tools Reference). על כך ש-jstack מדפיס את עקבות המחסנית (שם מחלקה, שם שיטה, מספר שורה) של כל תהליכון בתהליך Java שצוין; על כך שהאפשרות -l מאפשרת תצוגה מפורטת שכוללת מידע נעילות נוסף; ועל כך שהוא משמש לצד כלי אבחון אחרים כמו jcmd. ↩
-
OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). על כך ש-API של structured concurrency מתייחס לקבוצת משימות-משנה קשורות כיחידת עבודה אחת ומבנה הפצת שגיאות וביטול; על כך ש-StructuredTaskScope עודכן לצורה שנפתחת דרך שיטת מפעל סטטית (open); ועל כך שהוא, נכון ל-JDK 25, תצוגה מקדימה חמישית ועדיין לא תכונה סופית. ↩
-
OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). על כך ש-structured concurrency ממשיך כתצוגה מקדימה שישית גם ב-JDK 26 — כלומר, נכון לאוגוסט 2026, עדיין תכונת תצוגה מקדימה ב-JDK הנוכחי, שדורשת הפעלת תכונות תצוגה מקדימה כדי להשתמש בה. ↩
-
OpenJDK, JEP 506: Scoped Values. על כך ש-Scoped Values הושלם ב-JDK 25; ועל היותו מנגנון לשיתוף נתוני הקשר בלתי-ניתנים-לשינוי בבטחה וביעילות בתוך תהליכונים וביניהם, המספק פתרון לבעיות של ThreadLocal (שינוי, ניהול מחזור חיים ועלות ירושה). ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
פרקטיקות מומלצות לריבוי תהליכונים: מהדורת C — כתיבה בטוחה בדרך של Win32 API
הגישה המבוססת לריבוי תהליכונים ב-C עם Win32 היא יצירת תהליכונים דרך _beginthreadex, מנעולי SRW ומשתני תנאי, פונקציות Interlocked, ועיצוב ...
שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת C++ — ביטול תאונות במבנה עם RAII ו-jthread
ב-C++, רב־תהליכוניות היא עולם שבו מרוץ נתונים הוא התנהגות לא מוגדרת. המאמר עובר על מלכודת המפרק של std::thread, תכנון עצירה עם jthread ו-...
יישומים שנשברים בחזרה משינה — איך אירועי צריכת החשמל של Windows עובדים ואיך לבנות יישומים עסקיים ששורדים אותם
פתחתם את המחשב הנייד והחיבורים של היישום העסקי היו מתים — הסיבה היא תכנון שלא חשב על שינה. המאמר מכסה את זרימת ההודעות WM_POWERBROADCAST,...
DllMain ונעילת הטוען — הסיבה האמיתית שאומרים לכם "לא לעשות כלום באתחול DLL"
למה אסור לקרוא ל-LoadLibrary או להסתנכרן עם תהליכונים אחרים מתוך DllMain. המאמר מסביר ממקורות ראשוניים איך נעילת הטוען מסדרת כל הודעת DLL...
מה "לא מגיב" באמת — איך Windows מחליט שיישום נתקע, ואיך לתכנן יישומים שלא
"לא מגיב" של Windows הוא מנגנון שבו מערכת ההפעלה קובעת שחלון לא שלף הודעה במשך 5 שניות ומחליפה אותו בחלון רפאים. המאמר מכסה את פנים השיפו...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אם יש עכשיו תהליכונים וירטואליים, האם כבר לא צריך מאגר תהליכונים (ExecutorService)?
- זה תלוי בתרחיש. תהליכונים וירטואליים הם מנגנון להרצת מספר עצום של משימות שבהן שולטת המתנה ל-I/O; הם לא מאיצים את הקוד, הם מעלים תפוקה. לעבודה I/O-bound השתמשו בתהליכון וירטואלי אחד לכל משימה (Executors.newVirtualThreadPerTaskExecutor) ואל תשימו תהליכונים וירטואליים במאגר לעולם. לעומת זאת, להקבלה של חישוב שממצה את המעבד, מאגר של תהליכוני פלטפורמה המוגבל בערך למספר הליבות (או parallel stream) עדיין הכלי הנכון, כמו קודם. ואם רוצים להגביל את מספר החיבורים המקבילים לשירות חיצוני, ההמלצה בעידן התהליכונים הווירטואליים היא להגביל ב-Semaphore ולא בגודל מאגר.
- במה להשתמש, synchronized או ReentrantLock?
- להדרה קצרה ופשוטה synchronized מספיק, והקוד נשאר תמציתי. בחרו ReentrantLock כשצריך יכולות כמו רכישה עם מגבלת זמן דרך tryLock, מדיניות הוגנות, או כמה Condition. יש הסתייגות היסטורית בשילוב עם תהליכונים וירטואליים: ב-JDK 21-23 הייתה בעיה שחסימה בתוך בלוק synchronized הצמידה את התהליכון הווירטואלי לתהליכון מערכת ההפעלה שלו, ולכן הומלץ להחליף נקודות חסימה תכופות או ארוכות ב-ReentrantLock. JDK 24 (JEP 491) כתב מחדש את מימוש המוניטור ופתר את האילוץ הזה. מ-JDK 24 ואילך אין צורך להחליף synchronized מסיבות הצמדה.
- האם הוספת volatile הופכת משהו לבטוח לתהליכונים?
- לא. volatile של Java יוצר יחס happens-before בין כתיבה למשתנה הזה לקריאה ממנו, ומבטיח נראות (שהכתיבה האחרונה נראית לתהליכונים אחרים) וסדר, אבל אינו מבטיח אטומיות לפעולה מורכבת כמו "קרא, חשב, כתוב בחזרה". הגדילו מונה volatile int ב-++ מכמה תהליכונים והחיבורים הולכים לאיבוד. השתמשו ב-AtomicInteger / AtomicLong (או LongAdder לצבירה בתדירות גבוהה) למונים, ובנעילה כשמגינים על כמה משתנים יחד. volatile מתאים כמעט רק במצבים כמו דגל מצב פשוט — שבהם תהליכון אחד כותב והאחרים רק קוראים.
- מותר לתפוס InterruptedException ולהתעלם ממנו?
- לא. interruption הוא אות העצירה והביטול הסטנדרטי של Java, ובליעתו יוצרת תהליכון שלא ייעצר. ברגע ש-InterruptedException נזרק סטטוס ה-interrupt כבר נוקה, ולכן אם אינכם יכולים לסיים את העבודה בעצמכם, או שחזר את הסטטוס ב-Thread.currentThread().interrupt() כדי להשאיר את האות לקורא, או זרקו את החריגה כפי שהיא. בלוק catch ריק שאינו עושה דבר הוא סיבה קלאסית לבאגים שבהם כיבוי אינו נכנס לתוקף או ש-shutdownNow מתעלמים ממנו.
- אי אפשר לעצור תהליכון ב-Thread.stop?
- לא, אי אפשר. Thread.stop אינו בטוח ביסודו (הוא משחרר נעילות ומשאיר אותן במצב לא עקבי, וחושף אובייקטים שבורים לתהליכונים אחרים), ולכן הוא deprecated זה זמן רב, וקריאה אליו ב-Java הנוכחית זורקת UnsupportedOperationException. אותו דבר חל על Thread.suspend / resume. הדרך הלגיטימית היחידה לעצור תהליכון היא עצירה שיתופית באמצעות interruption. אם משתמשים ב-ExecutorService, shutdown רק מפסיק לקבל משימות חדשות וממתין להשלמה — הוא אינו שולח interrupt למשימות רצות. מי שמנסה לעצור משימות רצות הוא shutdownNow, וזה best-effort (המימוש הסטנדרטי עובד דרך interruption).