Best practices לריבוי threads בפועל: Java — המוסכמות בעידן ה-virtual threads

· עודכן בתאריך: · · multithreading, Java, יישומים עסקיים, חקירת תקלות, תכנון

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 2 Aug 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22175928)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). Best practices לריבוי threads בפועל: Java — המוסכמות בעידן ה-virtual threads. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22175928 https://comcomponent.com/he/blog/multithreading-best-practices-java/

DOI (הגרסה האחרונה)
10.5281/zenodo.22175928
DOI (הגרסה הזו)
10.5281/zenodo.22175929

«רוצים להקביל משימת batch עסקית ב-Java.» «ה-cache המשותף באפליקציית ה-Spring שלנו מתקלקל מדי פעם.» «ירשנו אפליקציית Swing ישנה שמלאה ב-new Thread.» — ל-Java יש multithreading מובנה בשפה מאז JDK 1.0, היא הבשילה לתיבת הכלים java.util.concurrent, ועם virtual threads ב-JDK 21 כתבה מחדש שוב את חוכמת התכנות התחרותי. דווקא כי ערכת הכלים כל כך עשירה, הכלי שאתם בוחרים הופך לאיכות התכנון עצמה.

מאמר זה הוא מהדורת Java של סדרת ה-multithreading בפועל. הוא מיועד למפתחים שכותבים מערכות עסקיות, משימות batch ויישומי שרת ב-Java, ממפה את עקרונות תכנון ה-multithreading — לעולם לא ליצור threads ישירות, להפחית מצב mutable משותף, משמעת locks, לתכנן איך עוצרים לפני כל דבר אחר — לכלים של Java (בעיקר מהדורת LTS, JDK 21 ואילך), ואוסף את הבחירות של עידן ה-virtual threads ואת ה-pitfalls הייחודיים ל-Java, על בסיס מקורות ראשוניים נכון לאוגוסט 2026. הוא כתוב כדי להיקרא בפני עצמו. אותם עקרונות כפי שהוחלו על שפות אחרות מכוסים במאמרי הלוויין «מהדורת .NET», «מהדורת C++» ו«מהדורת C».

1. המסקנה קודם

  • אל תכתבו new Thread בקוד עסקי — אותה כלל חל גם ב-Java. מסרו משימות ל-ExecutorService ותנו לספרייה לנהל את מחזורי החיים של ה-threads.1
  • נתבו משימות שממתינות בעיקר ל-I/O ל-virtual threads. virtual threads, שהפכו רשמיים ב-JDK 21, משמשים אחד לכל משימה ואסור לשים אותם ב-pool לעולם. הגבילו מקביליות ב-Semaphore, לא בגודל pool.23
  • virtual threads הם כלי throughput, לא כלי להאצת חישוב. הקבלה CPU-bound עדיין עבודתם של platform threads בגודל של בערך מספר הליבות — pool קבוע או parallel stream, כמו קודם.3
  • נעלו על אובייקט lock private final, או על ReentrantLock ייעודי. synchronized(this) ונעילה על אובייקט חשוף לציבור יכולים להתנגש עם קוד חיצוני. אם צריך רכישה עם timeout (tryLock), השתמשו ב-ReentrantLock.
  • ב-JDK 21-23 הייתה בעיה שחסימה בתוך בלוק synchronized עשתה pin ל-virtual threads; JDK 24 (JEP 491) תיקן זאת. הבדילו בין אזהרות ישנות למציאות הנוכחית.34
  • volatile מבטיח visibility וסדר, לא אטומיות. השתמשו ב-AtomicInteger / LongAdder למונים, וב-locks למצב מורכב.5
  • עצירה שיתופית באמצעות interruption היא הדרך הנכונה היחידה לעצור thread. Thread.stop / suspend / resume זורקים כעת UnsupportedOperationException. אל תבלעו InterruptedException — או שחזרו את הסטטוס או זרקו שוב.6
  • עצרו ExecutorService בתבנית הדו-שלבית: shutdown → awaitTermination → shutdownNow. shutdownNow הוא best-effort (המימוש הסטנדרטי עובד דרך interruption), ולכן מניח שהמשימות מגיבות ל-interruption.1
  • ממשק Swing שייך בלעדית ל-EDT (Event Dispatch Thread). בקשו עדכונים מ-threads אחרים דרך SwingUtilities.invokeLater.7

2. למה multithreading קשה — race condition, deadlock ו-memory model

אם מרתיחים את הבעיות ש-multithreading מכניס, בלי קשר לשפה, יש שני סוגים.

race condition הוא באג שבו התוצאה תלויה בסדר שבו כמה threads מגיעים לקטע קוד מסוים. הדוגמה הקלאסית היא מונה משותף: הביטוי הבודד count++ מתפרק בפועל לשלושה צעדים — קרא, הוסף, כתוב בחזרה. אם שני threads נכנסים לשלושת הצעדים האלה באותו זמן, ההגדלה של thread אחד נדרסת ואובדת כשהאחר כותב בחזרה. התוצאה משתנה בכל הרצה, ואין דרך לחזות איזו תוצאה תקבלו.

race condition קלאסי על מונה משותףתרשים שמראה ששני threads קוראים count=10, מוסיפים מקומית ל-11, וכותבים בחזרה 11, כך ששתי הגדלות קרו אבל count נשאר 11.thread Bמשתנה משותף countthread Athread Bמשתנה משותף countthread Acount = 10שתי הגדלות קרו,אבל count = 11 — ההגדלה של thread A אבדהקריאה (10)קריאה (10)חיבור מקומי (11)חיבור מקומי (11)כתיבה בחזרה (11)כתיבה בחזרה (11)

איור 1: race condition קלאסי שבו הגדלה על מונה משותף הולכת לאיבוד. אם thread אחר נשזר במהלך שלושת הצעדים של count++, הכתיבה האחרונה דורסת את האחרת

deadlock הוא מצב שבו שני threads ממתינים כל אחד ל-lock שהשני מחזיק, כך שאף אחד אינו יכול להתקדם. thread A מחזיק lock 1 וממתין ל-lock 2; thread B מחזיק lock 2 וממתין ל-lock 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.

המתנה מעגלית של deadlockתרשים שמראה thread A שמחזיק lock 1 וממתין ל-lock 2, ו-thread B שמחזיק lock 2 וממתין ל-lock 1.ממתין לשחרור lock 2ממתין לשחרור lock 1thread Aמחזיק lock 1thread Bמחזיק lock 2

איור 2: ההמתנה המעגלית של deadlock. ברגע שחצי ההמתנה יוצרים לולאה, כל thread בתוך הלולאה ההיא נעצר לנצח

שניהם תלויי-תזמון. שזירה שמופיעה פעם בעשרות אלפי הרצות במכונת פיתוח יכולה לקרות כל יום בשרת ייצור עם מספר ליבות ועומס שונים. «זה מפסיק להשתחזר ברגע שמחברים debugger» ו«זה נעלם כשהוספתי לוג» שניהם כי התצפית משנה את התזמון — התנהגות קלאסית לבאג race. בדיוק לכן כל עיקרון במאמר הזה מצביע לכיוון הפחתת המקומות שצריכים סנכרון, לפני שדואגים לסנכרן נכון.

2.1. הנחת יסוד ייחודית ל-Java —‏ memory model ו-happens-before

מעל זה, מה שמיוחד ל-Java הוא שאיך נראים נתונים משותפים מוגדר על ידי יחסי happens-before ב-Java Memory Model (JMM).

קריאה וכתיבה של משתנה משותף בלי סנכרון אינן הופכות ל«undefined behavior» בסגנון C++, אבל יכולות לגרום באופן לגיטימי לכך שערכים ישנים ממשיכים להיראות, או שכתיבות מופיעות שלא בסדר. שגיאת memory consistency שבה «לולאה צופה בדגל boolean, אבל הערך ש-thread אחר שינה אינו נראה לעולם» היא התנהגות שה-JMM מתיר, לא באג ב-JVM.5 הכלים שמגנים מפני זה הם המנגנונים שיוצרים יחסי happens-before — synchronized, volatile, והמחלקות ב-java.util.concurrent. השתמשו ב-concurrent collections נכון והספרייה מבטיחה לכם «יחס happens-before בין פעולת עדכון לשליפה עוקבת».8

במילים אחרות, ההנחיה המעשית של Java מסתכמת כך: אל תתחכמו עם משתנה משותף גולמי. השתמשו בכלים של java.util.concurrent לשיתוף, ותנו לספרייה ליצור את יחסי happens-before.

3. איך ליצור threads —‏ ExecutorService ו-virtual threads

3.1. הפרדת המשימה מאופן ההרצה

ExecutorService הוא מה שנושא את עקרון «אל תיצרו threads בעצמכם» ב-Java. הוא מפריד את העבודה (Runnable / Callable) מאופן הביצוע — כמה threads, איזה queue — ומשאיר יצירה, שימוש חוזר והשלכה של threads לספרייה.1

מ-JDK 21 ואילך, בחירת אופן ההרצה הפכה לבחירה בינארית פשוטה.23

בחירת אופן ביצוע מ-JDK 21 ואילךתרשים שמראה שמשימה I/O-bound הולכת ל-virtual threads אחד לכל משימה בלי pool, ומשימה CPU-bound הולכת ל-pool קבוע של platform threads או parallel stream, עם הגבלת מקביליות חיצונית ב-Semaphore.בעיקר המתנה ל-I/Oקריאות HTTP, DB, קבציםחישוב CPU-boundמשימה שרוצים להריץ במקבילמה מניע את המשימה?virtual threadsExecutors.newVirtualThreadPerTaskExecutorאחד לכל משימה, לעולם לא ב-poolpool קבוע של platform threadsExecutors.newFixedThreadPool - בערך כמספר הליבותאו parallel streamהגבילו מקביליות לשירותים חיצונייםב-Semaphore, לא בגודל pool

איור 3: בחירת אופן ביצוע העבודה מ-JDK 21 ואילך. ציירו קודם את הקו — «שנו איך ממתינים ל-I/O, הקבילו למעבד» — ואז מסרו עבודה I/O-bound ל-virtual threads ועבודה CPU-bound ל-pool מקובל

יש הסתייגות אחת בצד ה-CPU-bound. Executors.newFixedThreadPool מגביל את מספר ה-threads, אבל ה-queue שלו אינו חסום. בשירות ארוך-חיים שבו ההגשות ממשיכות להקדים את העיבוד, רק ה-threads מוגבלים למספר הליבות — המשימות שנערמות ב-queue, והנתונים שלהן, ממשיכות לאכול זיכרון. בסוג כזה של התקנה, או השתמשו ב-ThreadPoolExecutor ישירות כדי להגדיר bounded queue פלוס מדיניות דחייה, או שימו בקרת כניסה כמו Semaphore בצד המגיש כדי שתוכלו להפעיל backpressure (אותו עיקרון כמו דיון ה-queues בסעיף 4).

3.2. אל תשתמשו לרעה ב-virtual threads

virtual threads הם threads קלים המנותקים מ-OS threads: במהלך פעולת blocking ב-JDK (I/O, lock, sleep וכדומה בספרייה הסטנדרטית) הם משחררים את ה-OS thread שלהם, ולכן JVM אחת יכולה להריץ מיליונים מהם. עם זאת, הם לא משחררים אותו לכל סוג חסימה. אם virtual thread נחסם בזמן שהוא מריץ קוד native (JNI) או foreign function, הוא נשאר pinned ל-carrier thread שלו. מה ש-JDK 24 (JEP 491, להלן) תיקן הוא pinning שנגרם מ-synchronized; pinning בגבול native נשאר, ולכן טעינת מספר גדול של virtual threads בפעולות שנחסמות זמן רב דרך JNI driver או API של התקן תמצה את ה-carrier threads. אבל כפי שהמדריך הרשמי מדגיש, הם לא «threads מהירים יותר». מהירות ביצוע הקוד אינה משתנה — מה שהם מספקים הוא scale (throughput).3

יש שלוש משמעויות לשימוש בהם.3

  1. אל תשימו אותם ב-pool. virtual threads זולים וחד-פעמיים; «מספר המשימות = מספר ה-virtual threads» הוא המצב הנכון. הכנסת virtual threads ל-newFixedThreadPool היא טעות — השתמשו בצורה try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. הגבילו מקביליות ב-Semaphore. בטאו אילוץ כמו «לכל היותר 10 חיבורים מקבילים ל-API חיצוני» ב-Semaphore, לא בגודל pool.
  3. אל תשתמשו בהם לעבודה CPU-bound. platform threads בגודל של בערך מספר הליבות נשארים הכלי הנכון להקבלת חישוב, כמו קודם.

שימו לב שמה שרץ בתוך virtual thread הוא קוד סינכרוני רגיל. במקום לשכתב את הקוד כמו ש-async/await של .NET עושה, פילוסופיית התכנון מאחורי virtual threads היא לאפשר לכם להריץ קוד ישר «thread אחד לבקשה», בלי שינוי, בקנה מידה עצום.2

3.3. אי-הבנה נפוצה — «אין צורך ב-pool» חל רק על virtual threads

אל תקראו את משמעת «אל תשימו אותם ב-pool» כאילו «ב-Java אין דבר כזה thread pool (או שהוא לא יעיל)». המציאות הפוכה: ה-pools של Java הם חלק בשל של הספרייה הסטנדרטית מאז JDK 5 (2004). ה-pool הכללי הניתן לכוונון עדין ThreadPoolExecutor (נוצר דרך מפעלי Executors השונים), ForkJoinPool של work-stealing (המופע המשותף שלו, commonPool(), הוא יעד הביצוע ברירת המחדל ל-parallel streams ול-CompletableFuture), ו-ScheduledThreadPoolExecutor לביצוע מחזורי — אלה עדיין השחקנים המובילים לעבודה CPU-bound.

pool הוא ביסודו אופטימיזציה המיוסדת על «יצירה והחזקה של OS thread יקרות, לכן השתמשו בו שוב». virtual threads מבטלים את ההנחה הזו בכך שהם הופכים את עלות היצירה לקרובה לאפס, כך שאין עוד סיבה להשתמש בהם שוב — ההבנה המדויקת אינה שה-pool הפך ללא יעיל, אלא שה-threads הפכו קלים מספיק כדי שאופטימיזציית ה-pool תהיה מיותרת. ומתחת ל-virtual threads, ה-scheduler של ה-JDK מריץ קבוצת carrier threads (OS threads) שמספרם בערך מספר הליבות, כ-ForkJoinPool של work-stealing.2 במילים אחרות, הצורה «לטפל בכמות עצומה של עבודה מקבילית ב-pool קטן של OS threads» נשמרת; רק ניהול ה-pool עבר מידי המפתח ל-JVM. הוגן לומר ש-Java מגיעה לאותו יעד כמו async/await של .NET, שמחזיר את ה-thread שלו ל-pool בנקודת await, בלי לשנות את צורת הקוד.

4. הפחתת מצב mutable משותף — חלוקה, immutability, concurrent collections ו-queues

תחרות נוצרת רק כשקיימים יחד «כמה threads» ו«נתונים משותפים mutable». מספר ה-threads נקבע לפי דרישות, ולכן מה שהתכנון יכול לחתוך הוא החלק המשותף. יש שלוש משפחות של טכניקות — חלוקה, הפיכה ל-immutable, ומסירת נתונים — והנה איך כותבים אותן ב-Java.

חלקו. לצבירה מקבילית, במקום שכל thread יכתוב למשתנה סכום משותף, תנו לכל thread לבנות תוצאה חלקית ואחדו אותן בסוף. reduce / collect של parallel stream מספקים בדיוק את הצורה הזו כמסגרת, ו-LongAdder, שנדון בהמשך, הוא גם מימוש של אסטרטגיית החלוקה — מפצל פנימית לתאים כדי לפזר תחרות, ומסכם אותם בקריאה. להפחית כמה פעמים כותבים למצב משותף בא לפני כתיבת סנכרון נכון.

הפכו ל-immutable. בנו נתונים עם record ו-immutable collections (List.copyOf / Map.copyOf) שלעולם אינם נכתבים מחדש אחרי הבנייה, ותוכלו לשתף אותם בלי סנכרון. לתצורה ולנתוני master, התבנית הסטנדרטית: כשצריך להחליף, בנו אובייקט חדש והחליפו הפניה volatile. עם זאת, «נראה לקריאה בלבד» ו«הוא immutable» הם דברים שונים. הגישות של record מחזירות את ההפניות הגולמיות של הרכיבים, והעתק של List.copyOf גם הוא shallow (אינו משכפל את אובייקטי האיברים), ולכן אם האיברים mutable, כל מי שמחזיק alias יכול לכתוב מחדש את התוכן, והתחרות נשארת. בטוח לשתף בלי סנכרון רק כשגרף האובייקטים כולו — כולל האיברים — immutable. אם מעורבים איברים mutable, או העבירו deep copy או דחפו גם את האיברים לכיוון record / טיפוסים immutable.

השתמשו בפעולות המורכבות על concurrent collections. לתבנית «צור אם חסר, ואז הכנס» של ConcurrentHashMap השתמשו ב-computeIfAbsent. השיטה הזו מבצעת את כל הקריאה באטומיות, ואם המפתח חסר, פונקציית המיפוי נקראת בדיוק פעם אחת בתוך אותה קריאה בודדת.8 האחריות שונה מ-ConcurrentDictionary.GetOrAdd של .NET (שה-factory שלו יכול לרוץ יותר מפעם אחת תחת תחרות) — נקודה שאנשים שעוברים בין שתי השפות מבלבלים בקלות. זה, עם זאת, אינו «בדיוק פעם אחת לאורך חיי המפתח». אם הפונקציה מחזירה null או זורקת, לא נרשם מיפוי, והפונקציה רצה שוב בקריאה עוקבת (אותו דבר אם הרשומה הוסרה אחרי הרישום). אם האתחול שלכם אינו סובל תופעות לוואי כפולות, תכננו כך שהפונקציה תצליח ותחזיר לא-null. כמחיר של להיות אטומית, חלק מהעדכונים מ-threads אחרים נחסמים בזמן שהחישוב רץ, ולכן שמרו על פונקציית המיפוי קצרה, ואל תעדכנו את אותה map מתוך הפונקציה (עדכון רקורסיבי שזוהה יכול לזרוק IllegalStateException).8

// התבנית הסטנדרטית למונה תדירות: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

מסרו נתונים דרך queue. נתבו את זרימת הנתונים בין threads דרך BlockingQueue. עם ArrayBlockingQueue שקיבל קיבולת, put נחסם כשהוא מלא, ונותן backpressure טבעי — אותה צורה כמו ה-bounded channel במהדורת .NET. גם בעידן ה-virtual threads, התכנון הזה של ציור גבול ברור בין producer ל-consumer נשאר יעיל.

5. משמעת locks —‏ synchronized ו-ReentrantLock

5.1. על מה לנעול, ומה לא לעשות בזמן שמחזיקים lock

חשבו על יחידת ה-lock לא כ«קטע קוד» אלא כ«נתונים». ממפו אובייקט lock אחד לכל קבוצת נתונים mutable שרוצים להגן עליהם, וקחו את אותו lock בכל מקום שנוגע בנתונים האלה — המציאות של באג race היא בדרך כלל שהמיפוי הזה התפרק איפשהו. הימנעו מ-synchronized(this) ומ-synchronized(SomeClass.class), כי קוד חיצוני יכול לנעול את אותו אובייקט; במקום זאת, צמדו את הנתונים שרוצים להגן עליהם אחד-לאחד ל-private final Object lock = new Object(); שלעולם אינו נחשף החוצה.

עוד שתי משמעויות מעל זה. ראשית, אל תעשו שום דבר איטי, ושום דבר שנוגע בעולם החיצוני, בזמן שמחזיקים lock. I/O, קריאה ל-listeners, או הרצת קוד לא ידוע בזמן שעדיין מחזיקים את ה-lock גם מאריכים כמה זמן מחזיקים וגם מסכנים שהנקרא ינסה לקחת lock אחר, וליצור את ההמתנה המעגלית מאיור 2. שנית, קבעו את סדר הרכישה ל-locks מרובים. במקום שלוקחים שני locks או יותר, עשו כלל שכל thread לוקח אותם באותו סדר, ולמקומות שבהם אי אפשר להבטיח את הסדר הכינו נתיב «וותר ונסה שוב אם לא הצלחת» עם tryLock(timeout), שנדון בהמשך.

synchronized מספיק ל«exclusion קצר ופשוט». עברו ל-ReentrantLock ברגע שצריך את הדברים הבאים.

  • רכישה עם timeout דרך tryLock(timeout) (הפיכת hang קבוע לכשל שאפשר לרשום ולטפל בו)
  • מדיניות fairness, כמה Condition, או כשרוצים לפצל רכישה ושחרור של lock בין שיטות שונות

כשמשתמשים ב-ReentrantLock, לעולם אל תשברו את התבנית של try מיד אחרי lock() ו-unlock() בבלוק finally (ל-Java אין מקבילה ל-RAII של C++, ולכן התבנית הזו היא כל המשמעת).

5.2. virtual threads ו-pinning — מה השתנה ב-JDK 24

כש-virtual threads הוצגו לראשונה (JDK 21-23), היה אילוץ שבו חסימה בתוך בלוק synchronized עשתה pin ל-virtual thread על ה-OS thread שלו (הוא לא יכול היה לשחרר את ה-OS thread, ואיבד את יתרון ה-scale), והומלץ להחליף נקודות חסימה תכופות או ארוכות ב-ReentrantLock.3 האילוץ הזה נפתר כש-JEP 491 של JDK 24 כתב מחדש את מימוש ה-monitor, ו-synchronized כבר אינו עושה pin ל-virtual threads.4 אם אתם ב-JDK 24 ואילך, החלפה מכנית של synchronized כנגד pinning כבר אינה נחוצה. שווה לבדוק אם ההנחיות הישנות יותר בארגון שלכם עדיין תקועות באזהרת עידן JDK 21.

5.3. איפה האטומיים ו-volatile נכנסים

עדכונים אטומיים למשתנה בודד מטופלים על ידי AtomicInteger / AtomicLong / AtomicReference (או, לסטטיסטיקה שמגדילים רק בתדירות גבוהה, LongAdder העמיד בפני תחרות). volatile מבטיח visibility וסדר (happens-before), לא אטומיות לפעולות מורכבות.5 אותה מסקנה כמו במהדורות .NET ו-C++ מחזיקה גם ב-Java: השתמשו באטומיים לדגלים ולערכים בודדים, ב-locks למצב מורכב, ואל תנסו לגרום ל-volatile לעשות זאת לבד.

6. תכנון איך עוצרים — interruption כשפה משותפת

6.1. כללי הנימוס של interrupt

עצירה וביטול ב-Java מאוחדים סביב interruption. t.interrupt() מגדיר את סטטוס ה-interrupt של ה-thread היעד, ואם היעד חסום ב-sleep / wait / join או דומה, הוא זורק InterruptedException כדי להעיר אותו מיד (ואז סטטוס ה-interrupt מנוקה).6 Thread.stop / suspend / resume, המנגנונים הכפויים של העבר, אינם בטוחים ביסודם, ולכן קריאה אליהם כעת מסתיימת ב-UnsupportedOperationException.6

עצירה שיתופית באמצעות interruptionתרשים שמראה שקריאה ל-t.interrupt מגדירה את סטטוס ה-interrupt, thread שמחשב בודק Thread.interrupted בלולאה, ו-thread חסום ב-sleep/wait/join מתעורר עם InterruptedException; אחרי catch הבחירה היא לסיים או לשחזר את הסטטוס.יכול לסיים בעצמולא יכול לסיים - למשל בתוך ספרייההקורא עוצר אותו - קורא ל-t.interruptסטטוס ה-interrupt מוגדרthread שמחשב:בודק Thread.interrupted בלולאהחסום ב-sleep / wait / join:InterruptedException נורה ומעיר מידהסטטוס מנוקהמנקה ומסיים בעצמומה ה-catch עושה?Thread.currentThread.interruptמשחזר את הסטטוס ומשאיר את האות

איור 4: עצירה שיתופית באמצעות interruption. בליעת InterruptedException גורמת לאות העצירה להיעלם — ברגע שתפסתם אותו, הבחירה היא «לסיים» או «לשחזר»

יש משמעת אחת בלבד שצריך לזכור בפועל: אל תכתבו קוד שתופס InterruptedException ואינו עושה דבר. אם אפשר לסיים באחריות שלכם, סיימו שם; אם לא, שחזרו את הסטטוס ב-Thread.currentThread().interrupt() והעבירו את האות לקורא (ראו את שאלות הנפוצות).

6.2. ה-shutdown הדו-שלבי של ExecutorService

ממשקי ה-shutdown של ExecutorService יושבים מעל מודל ה-interruption. shutdown() מפסיק לקבל משימות חדשות ונותן למשימות שכבר הוגשו לרוץ עד השלמה; shutdownNow() מנסה לעצור משימות רצות. כמפרט ממשק זה best-effort, ומתועד במפורש שמימוש סטנדרטי (כמו ThreadPoolExecutor) מבטל בדרך כלל דרך Thread.interrupt() — כלומר משימה שאינה מגיבה ל-interruption לא תיעצר גם עם shutdownNow, ואם משתמשים במימוש Executor מותאם צריך לבדוק בתיעוד שלו איך הוא מבטל (האם הוא שולח interrupt בכלל).1 תבנית העצירה הסטנדרטית שמוצגת בתיעוד הרשמי היא התבנית הדו-שלבית הבאה.1

/** אמת כשה-shutdown הושלם. אל תמשיכו לשחרור משאבים משותפים כל עוד זה שקר. */
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;           // ה-shutdown לא הושלם. דווחו בצורה שניתן להבחין בה מהצלחה
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // שחזרו גם את סטטוס ה-interrupt של ה-thread הזה
        return false;                   // גם הנתיב הזה עלול להשאיר את ה-shutdown חלקי
    }
}
shutdown דו-שלבי של ExecutorServiceתרשים שמראה shutdown שמפסיק לקבל משימות, המתנה ב-awaitTermination, ואם פג הזמן shutdownNow ששולח interrupt, ואז המתנה נוספת או רישום כחריגה.מסיים בתוך המועדפג הזמןמשליםעדיין לא סייםshutdownמפסיק לקבל משימות חדשותawaitTerminationממתין להשלמהה-shutdown הושלםshutdownNowשולח interrupt למשימות רצותהאם יגיב תלוי במשימהawaitTerminationממתין שוברשמו כחריגהחשדו במשימה שמתעלמת מ-interrupt

איור 5: ה-shutdown הדו-שלבי של ExecutorService. תכנון מדורג של «המתן בנימוס → בקש דרך interruption → אם עדיין לא סיים, צפה בזה כחריגה»

יש מגבלה אחת בביטול ה-Runnable ש-shutdownNow() מחזיר. מה שחוזר הוא האובייקט שישב ב-queue הביצוע — ל-submit פשוט זה בדיוק ה-FutureTask שנמסר לקורא, אבל למשימות שהוגשו דרך wrapper כמו ExecutorCompletionService זה ה-wrapper שבתוך ה-queue, אובייקט שונה מ-Future של הקורא. בתצורה הזו, הביטול למעלה לא ישלים את ה-Future של הקורא, לכן תכננו לשמור רשימת Future משלכם בזמן ההגשה ולבטל אותם ב-shutdown (או להחזיר את המשימות שהורדו לבעליהן).

close() (AutoCloseable), זמין מ-JDK 19 ואילך, אורז «קרא ל-shutdown והמתן להשלמה» לצורה שאפשר לכתוב עם try-with-resources, ו-try (var executor = ...) בשילוב עם newVirtualThreadPerTaskExecutor של virtual threads הוא הצורה הבסיסית המודרנית.1 עם זאת, close() אינו תחליף לתבנית הדו-שלבית למעלה. כי הוא ממתין להשלמה בלי timeout, אם אפילו משימה אחת אינה מגיבה ל-interruption או לעולם אינה מסיימת, ה-thread שמנסה לסגור אותו נחסם לנצח. זה כלי שמתאים להיקפים שבהם המשימות סופיות ומבטיחות לרוץ עד השלמה (הגישו שם, המתינו שם); למקומות כמו נתיב ה-shutdown של יישום, שבהם רוצים שתמיד יסתיים בתוך זמן חסום, השתמשו בתבנית הדו-שלבית עם timeout במקום. ביטול משימה בודדת נעשה גם כן דרך interruption, עם Future.cancel(true).

7. ה-UI thread — ה-EDT של Swing

יישומי שולחן עבודה, בלי קשר לשפה או למסגרת, פועלים לפי הכלל שממשק המשתמש שייך בלעדית ל-thread שמנהל אותו. ב-Swing, ה-thread הבלעדי הזה הוא Event Dispatch Thread (EDT): שיטות רכיבי Swing, ככלל, אינן thread-safe, ונגיעה בהן מכמה threads מזמינה thread interference ושגיאות memory consistency. בקשו עדכוני מסך מ-threads אחרים דרך SwingUtilities.invokeLater אל ה-EDT, ולהפך, כי הרצת פעולות ארוכות על ה-EDT מקפיאה את הממשק, דחפו עבודה כבדה ל-worker thread דרך SwingWorker או דומה.7 JavaFX פועל באותה צורה: עדכוני UI מתבקשים על ה-application thread דרך Platform.runLater.

8. אימות וניפוי — thread dumps כנשק

אי אפשר לצפות שבדיקות ימצאו באגי race. בדיקות רגילות סופרות כהצלחה הרצה שבה במקרה לא קרתה תחרות. חשבו על ההגנה בשלוש שכבות.

קו ההגנה הראשון הוא תכנון. ב-review, בדקו בטבלה: אילו נתונים mutable משותפים, איזה lock מגן על כל פריט (המיפוי מסעיף 5.1), האם סדר רכישת ה-locks ייחודי, האם בלוק catch כלשהו בולע InterruptedException, והאם נתיב העצירה (shutdown/interruption) מגיע לכל משימה.

שנית, נצלו היטב thread dumps. ל-Java יש כלי סטנדרטי ללכידת «מצב כל thread ברגע הקפוא הזה»: jstack (או jcmd <pid> Thread.print) מדפיס stack traces, והאפשרות -l מוסיפה גם מידע על locks.9 שימו לב שפורמט ה-dump המסורתי הזה הוא ל-platform threads; הוא אינו כולל את ה-virtual threads של היישום שלכם. כשעוקבים אחרי בקשה חסומה בתצורה שמשתמשת ב-virtual threads (סעיף 3), השתמשו ב-jcmd <pid> Thread.dump_to_file -format=json <file>, שיכול לדלוף גם virtual threads.2 ההליך הבסיסי לחקירת hang הוא לקחת שני או שלושה dumps בהפרש של כמה שניות ולהצליב על איזה lock כל thread בטל ממתין, ומי מחזיק ב-lock הזה. אם תרשמו timeouts של tryLock(timeout) (סעיף 5.2), תוכלו אפילו לאוטומט את הטריגר ללקיחת dump.

שלישית, נערו תחת עומס. בדיקת עומס — הרצה ארוכה עם מקביליות גדולה ממספר הליבות, ערבוב סדר עיבוד, הזרקת השהיות מלאכותיות — היא דרך מעשית להעלות את הסיכוי למשוך את «הפגיעה» של race condition במכונת פיתוח. הריצו לפחות בדיקה אחת בנפח נתונים ומספר threads של קנה מידה ייצור לפני שחרור.

9. לאן הולכת התחרותיות של Java —‏ Structured Concurrency

מבט מהיר חצי צעד קדימה, לסיום. בנוי על הנחת ה-virtual threads, Structured Concurrency (StructuredTaskScope) — שמתייחס לכמה משימות-משנה כיחידת עבודה אחת ומבנה הפצת כשל וביטול — בפיתוח, ונכון לאוגוסט 2026 עדיין תכונת preview. הוא עודכן לצורה מבוססת StructuredTaskScope.open() ב-preview החמישית של JDK 25 (JEP 505), וממשיך ל-preview שישית (JEP 525) ב-JDK 26 הנוכחי.1011 בינתיים Scoped Values, שיתוף הקשר immutable שפותר את הבעיות של ThreadLocal, הושלם ב-JDK 25.12 עקרונות המאמר הזה — גבולות משימה ברורים, שיתוף immutable, עצירה שיתופית — מיושרים גם עם הכיוון שאליו ה-API החדשים האלה הולכים.

10. סיכום — רשימת הבדיקה של Java

  1. האם נשאר new Thread כלשהו בקוד עסקי (האם הוא בנוי על ExecutorService / virtual threads)?
  2. האם עבודה I/O-bound ועבודה CPU-bound מנותבות למנגנוני ביצוע שונים (הענף באיור 3)?
  3. האם virtual threads אינם ב-pool, והאם מקביליות מוגבלת ב-Semaphore?
  4. האם נתונים משותפים immutable (record / List.copyOf), או בנויים על הכלים של java.util.concurrent?
  5. האם אין synchronized(this) או נעילה על אובייקט חשוף לציבור?
  6. האם משתמשים בפעולות המורכבות של ConcurrentHashMap (computeIfAbsent וכו’) ושומרים על פונקציית המיפוי קצרה?
  7. האם לא מצפים לאטומיות מ-volatile (האם מונים משתמשים במחלקות Atomic / LongAdder)?
  8. האם אין אפילו בלוק catch אחד שבלע InterruptedException?
  9. האם עצירת ExecutorService עוקבת אחרי התבנית הדו-שלבית עם timeout (והאם מקומות שמשתמשים ב-close() מוגבלים להיקפים שבהם השלמת משימות מובטחת)?
  10. האם עדכוני UI של Swing/JavaFX מרוכזים על ה-EDT / application thread?

Java היא אחת השפות המצוידות ביותר בכלי concurrency, והגעת ה-virtual threads פתחה נתיב להרחבת קוד סינכרוני ישר בלי לשכתב אותו. בדיוק לכן תפיסה נכונה של חלוקת העבודה בין הכלים — איזה ל-throughput, איזה ל-exclusion, ומה מסמן עצירה — היא מהות תכנון ה-multithreading ב-Java.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC מטפלת ב-design reviews של multithreading למערכות עסקיות ולעיבוד batch ב-Java, בחקירת שורש של פגמי concurrency כמו קלקול מצב משותף ותסמיני «לפעמים לא נעצר / נתקע» (ניתוח thread dumps), ובייעוץ טכני על אימוץ virtual threads.

מקורות

  1. 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 הדו-שלבי shutdown → awaitTermination → shutdownNow מוצג כדוגמת שימוש. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. OpenJDK, JEP 444: Virtual Threads. על כך ש-virtual threads הפכו לתכונה רשמית ב-JDK 21; על היותם threads קלים שמפחיתים דרמטית את המאמץ לכתוב, לתחזק ולצפות ביישומים תחרותיים בעלי throughput גבוה; על פילוסופיית התכנון להרחיב קוד סינכרוני ישר «בקשה אחת, thread אחד» בלי שינוי; על כך שמתזמן ה-virtual threads של ה-JDK הוא ForkJoinPool של work-stealing הפועל במצב FIFO, עם מקביליות ברירת מחדל השווה למספר המעבדים הזמינים; ועל כך שנוסף פורמט thread dump חדש שכולל virtual threads בשם jcmd Thread.dump_to_file (בטקסט פשוט וב-JSON), בעוד dumps מסורתיים אינם כוללים virtual threads. ↩ ↩2 ↩3 ↩4 ↩5

  3. Oracle Java SE Core Libraries, Virtual Threads. על כך ש-virtual threads הם threads קלים שמממש זמן הריצה של Java ומשחררים את ה-OS thread שלהם במהלך I/O חוסם; על היותם תכונה ל-scale (throughput) ולא למהירות (latency), ואינם מתאימים לעיבוד עתיר מעבד; על כך שלעולם אין לשים virtual threads ב-pool ומשתמשים באחד לכל משימה (newVirtualThreadPerTaskExecutor); על שימוש ב-Semaphore ולא ב-thread pool להגבלת מקביליות; על כך שחסימה בתוך synchronized נכון ל-JDK 21 גרמה ל-pinning ל-OS thread, שלשמה הומלץ להחליף נקודות תכופות או ארוכות ב-ReentrantLock; ועל היכולת לזהות pinning עם -Djdk.tracePinnedThreads. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. על כך שמימוש ה-monitor של ה-JVM נכתב מחדש ל-JDK 24 כדי לתמוך ב-virtual threads, כך שחסימה בתוך בלוק או שיטת synchronized כבר אינה עושה pin ל-virtual thread על ה-carrier thread שלו; ועל כך שמשמעות הדבר היא שנגד עידן JDK 21-23 של «החלפת synchronized ב-ReentrantLock» אינו, בעיקרון, נחוץ עוד. ↩ ↩2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. על כך ששגיאות memory consistency נוצרות כשלכמה threads יש תצוגות לא עקביות של אותם נתונים; על כך שהמפתח להימנעות מהן הוא יחס happens-before (אחריות שכתיבת זיכרון של הוראה אחת נראית להוראה אחרת); ועל כך ש-synchronized, volatile ו-Thread.start / join, בין השאר, יוצרים יחסי happens-before. ↩ ↩2 ↩3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). על כך ש-Thread.stop / suspend / resume אינם בטוחים ביסודם (locks משוחררות במצב לא עקבי ואובייקטים שבורים נראים; suspend יכול להזמין deadlock), מה שהפך אותם ל-deprecated לקראת הסרה, וכעת הם זורקים UnsupportedOperationException כשקוראים להם; על כך ש-interrupt() מגדיר את סטטוס ה-interrupt ומעיר thread חסום ב-sleep / wait / join בזריקת InterruptedException (שמנקה את סטטוס ה-interrupt); ועל ההבדל באיך interrupted() ו-isInterrupted() מתייחסים לסטטוס הזה. ↩ ↩2 ↩3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. על כך שקוד הטיפול באירועים של Swing רץ על Event Dispatch Thread (EDT); על כך שרוב שיטות אובייקטי Swing אינן thread-safe, כך שקריאה אליהן מכמה threads מזמינה thread interference ושגיאות memory consistency, כלומר גישה לרכיבי Swing צריכה, ככלל, להיעשות על ה-EDT; על בקשת משימות על ה-EDT מ-threads אחרים דרך SwingUtilities.invokeLater / invokeAndWait; ועל כך שמשימות שרצות על ה-EDT צריכות להסתיים במהירות. ↩ ↩2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). על כך שכל קריאת השיטה computeIfAbsent מבוצעת באטומיות, עם פונקציית המיפוי שנקראת בדיוק פעם אחת כשהמפתח חסר; על כך שחלק מפעולות העדכון מ-threads אחרים נחסמות במהלך החישוב, ולכן יש לשמור אותה קצרה ופשוטה; על כך שאסור לפונקציית המיפוי לשנות את ה-map הזו עצמה, ועדכון רקורסיבי שזוהה מסתיים ב-IllegalStateException; ועל כך שפעולת שליפה (get) אינה חוסמת, עם יחס happens-before שחל בין עדכון למפתח נתון לשליפה עוקבת. ↩ ↩2 ↩3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). על כך ש-jstack מדפיס את ה-stack traces (שם מחלקה, שם שיטה, מספר שורה) של כל thread בתהליך Java שצוין; על כך שהאפשרות -l מאפשרת תצוגה מפורטת שכוללת מידע locks נוסף; ועל כך שהוא משמש לצד כלי אבחון אחרים כמו jcmd. ↩

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). על כך ש-API של structured concurrency מתייחס לקבוצת משימות-משנה קשורות כיחידת עבודה אחת ומבנה הפצת שגיאות וביטול; על כך ש-StructuredTaskScope עודכן לצורה שנפתחת דרך שיטת מפעל סטטית (open); ועל כך שהוא, נכון ל-JDK 25, preview חמישית ועדיין לא תכונה סופית. ↩

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). על כך ש-structured concurrency ממשיך כ-preview שישית גם ב-JDK 26 — כלומר, נכון לאוגוסט 2026, עדיין תכונת preview ב-JDK הנוכחי, שדורשת הפעלת preview features כדי להשתמש בה. ↩

  12. OpenJDK, JEP 506: Scoped Values. על כך ש-Scoped Values הושלם ב-JDK 25; ועל היותו מנגנון לשיתוף נתוני הקשר immutable בבטחה וביעילות בתוך threads וביניהם, המספק פתרון לבעיות של ThreadLocal (mutability, ניהול מחזור חיים ועלות ירושה). ↩

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

אם יש עכשיו virtual threads, האם כבר לא צריך thread pool (ExecutorService)?
זה תלוי בתרחיש. virtual threads הם מנגנון להרצת מספר עצום של משימות שבהן שולטת המתנה ל-I/O; הם לא מאיצים את הקוד, הם מעלים throughput. לעבודה I/O-bound השתמשו ב-virtual thread אחד לכל משימה (Executors.newVirtualThreadPerTaskExecutor) ואל תשימו virtual threads ב-pool לעולם. לעומת זאת, להקבלה של חישוב שממצה את המעבד, pool של platform threads המוגבל בערך למספר הליבות (או parallel stream) עדיין הכלי הנכון, כמו קודם. ואם רוצים להגביל את מספר החיבורים המקבילים לשירות חיצוני, ההמלצה בעידן ה-virtual threads היא להגביל ב-Semaphore ולא בגודל pool.
במה להשתמש, synchronized או ReentrantLock?
ל-exclusion קצר ופשוט synchronized מספיק, והקוד נשאר תמציתי. בחרו ReentrantLock כשצריך יכולות כמו רכישה עם timeout דרך tryLock, מדיניות fairness, או כמה Condition. יש הסתייגות היסטורית בשילוב עם virtual threads: ב-JDK 21-23 הייתה בעיה שחסימה בתוך בלוק synchronized הצמידה (pin) את ה-virtual thread ל-OS thread שלו, ולכן הומלץ להחליף נקודות חסימה תכופות או ארוכות ב-ReentrantLock. JDK 24 (JEP 491) כתב מחדש את מימוש ה-monitor ופתר את האילוץ הזה. מ-JDK 24 ואילך אין צורך להחליף synchronized מסיבות pinning.
האם הוספת volatile הופכת משהו ל-thread-safe?
לא. volatile של Java יוצר יחס happens-before בין כתיבה למשתנה הזה לקריאה ממנו, ומבטיח visibility (שהכתיבה האחרונה נראית ל-threads אחרים) וסדר, אבל אינו מבטיח אטומיות לפעולה מורכבת כמו "קרא, חשב, כתוב בחזרה". הגדילו מונה volatile int ב-++ מכמה threads והחיבורים הולכים לאיבוד. השתמשו ב-AtomicInteger / AtomicLong (או LongAdder לצבירה בתדירות גבוהה) למונים, וב-lock כשמגינים על כמה משתנים יחד. volatile מתאים כמעט רק במצבים כמו דגל מצב פשוט — שבהם thread אחד כותב והאחרים רק קוראים.
מותר לתפוס InterruptedException ולהתעלם ממנו?
לא. interruption הוא אות העצירה והביטול הסטנדרטי של Java, ובליעתו יוצר thread שלא ייעצר. ברגע ש-InterruptedException נזרק סטטוס ה-interrupt כבר נוקה, ולכן אם אינכם יכולים לסיים את העבודה בעצמכם, או שחזר את הסטטוס ב-Thread.currentThread().interrupt() כדי להשאיר את האות לקורא, או זרקו את החריגה כפי שהיא. בלוק catch ריק שאינו עושה דבר הוא סיבה קלאסית לבאגים שבהם shutdown אינו נכנס לתוקף או ש-shutdownNow מתעלמים ממנו.
אי אפשר לעצור thread ב-Thread.stop?
לא, אי אפשר. Thread.stop אינו בטוח ביסודו (הוא משחרר locks ומשאיר אותן במצב לא עקבי, וחושף אובייקטים שבורים ל-threads אחרים), ולכן הוא deprecated זה זמן רב, וקריאה אליו ב-Java הנוכחית זורקת UnsupportedOperationException. אותו דבר חל על Thread.suspend / resume. הדרך הלגיטימית היחידה לעצור thread היא עצירה שיתופית באמצעות interruption. אם משתמשים ב-ExecutorService, shutdown רק מפסיק לקבל משימות חדשות וממתין להשלמה — הוא אינו שולח interrupt למשימות רצות. מי שמנסה לעצור משימות רצות הוא shutdownNow, וזה best-effort (המימוש הסטנדרטי עובד דרך interruption).

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג