מדריך ל- ConcurrentMap

1. סקירה כללית

מפות הם באופן טבעי אחד הסגנון הנפוץ ביותר של אוסף Java.

וחשוב מכך, מפת גיבוב אינו יישום בטוח לשרשור טבלת גיבוב אכן מספק בטיחות חוט על ידי סנכרון פעולות.

למרות ש טבלת גיבוב הוא בטוח בחוט, הוא לא יעיל במיוחד. עוד מסונכרן לחלוטין מַפָּה,Collections.synchronizedMap, גם אינו מפגין יעילות רבה. אם אנו רוצים בטיחות חוטים עם תפוקה גבוהה תחת מקביליות גבוהה, יישומים אלה אינם הדרך ללכת.

כדי לפתור את הבעיה, ה מסגרת אוספי ג'אווההציג ConcurrentMap ב ג'אווה 1.5.

הדיונים הבאים מבוססים על ג'אווה 1.8.

2. ConcurrentMap

ConcurrentMap הוא הרחבה של מַפָּה מִמְשָׁק. מטרתו לספק מבנה והדרכה לפתרון בעיית התאמת התפוקה לבטיחות הברגה.

על ידי עקיפת מספר שיטות ברירת מחדל של ממשק, ConcurrentMap נותן הנחיות ליישומים תקפים כדי לספק פעולות אטומיות של בטיחות חוטים ועקבי זיכרון.

מספר יישומי ברירת מחדל נעקפים, ומשביתים את ריק תמיכה במפתח / ערך:

  • getOrDefault
  • לכל אחד
  • החלף הכל
  • computeIfAbsent
  • computeIfPresent
  • לְחַשֵׁב
  • לְמַזֵג

הבאים ממשקי API נעקפים גם לתמיכה באטומיות, ללא יישום ממשק ברירת מחדל:

  • putIfAbsent
  • לְהַסִיר
  • החלף (מפתח, oldValue, newValue)
  • החלף (מפתח, ערך)

שאר הפעולות עוברות בירושה באופן ישיר עם עקביות עם מַפָּה.

3. ConcurrentHashMap

ConcurrentHashMap הוא מחוץ לקופסה מוכן ConcurrentMap יישום.

לקבלת ביצועים טובים יותר, הוא מורכב ממערך צמתים כדלי שולחן (בעבר היו פלחי טבלה לפני ג'אווה 8) מתחת למכסה המנוע, ומשתמש בעיקר בפעולות CAS במהלך העדכון.

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

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

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

זו הסיבה שבונים, לעומת מפת גיבוב, מספק את התוספת concurrency רמת טיעון לבקרת מספר הנושאים המשוערים לשימוש:

ConcurrentHashMap ציבורי (
ציבורי ConcurrentHashMap (int initialCapacity, float loadFactor, int concurrencyLevel)

שני הטיעונים האחרים: initialCapacity ו loadFactor עבד בדיוק כמו מפת גיבוב.

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

3.1. חוט-בטיחות

ConcurrentMap מבטיח עקביות זיכרון בפעולות מפתח / ערך בסביבה מרובת שרשור.

פעולות בשרשור לפני הכנסת אובייקט לתוך ConcurrentMap כמפתח או ערך לקרות לפני פעולות הבאות לגישה או להסרה של אותו אובייקט בשרשור אחר.

כדי לאשר, בואו נסתכל על מקרה לא עקבי בזיכרון:

@Test הציבור בטל givenHashMap_whenSumParallel_thenError () זורק חריג {Map map = HashMap חדש (); רשימת sumList = parallelSum100 (מפה, 100); assertNotEquals (1, sumList .stream () .distinct () .count ()); long wrongResultCount = sumList .stream () .filter (num -> num! = 100) .count (); assertTrue (wrongResultCount> 0); } רשימה פרטית parallelSum100 (מפת מפה, int executTimes) זורק InterruptedException {List sumList = ArrayList new (1000); עבור (int i = 0; i <executionTimes; i ++) {map.put ("test", 0); ExecutorService executorService = Executors.newFixedThreadPool (4); עבור (int j = 0; j {for (int k = 0; k ערך + 1);}); } executorService.shutdown (); executorService.awaitTermination (5, TimeUnit.SECONDS); sumList.add (map.get ("test")); } החזר sumList; }

לכל אחד map.computeIfPresent פעולה במקביל, מפת גיבוב אינו מספק תצוגה עקבית של מה שצריך להיות הערך המספר השלם הנוכחי, מה שמוביל לתוצאות לא עקביות ולא רצויות.

בנוגע ל ConcurrentHashMap, אנו יכולים להשיג תוצאה עקבית ונכונה:

@Test הציבור בטל givenConcurrentMap_whenSumParallel_thenCorrect () זורק חריג {מפה מפה = חדש ConcurrentHashMap (); רשימת sumList = parallelSum100 (מפה, 1000); assertEquals (1, sumList .stream () .distinct () .count ()); long wrongResultCount = sumList .stream () .filter (num -> num! = 100) .count (); assertEquals (0, wrongResultCount); }

3.2. ריק ערך מפתח

רוב ממשק APIמסופק על ידי ConcurrentMap לא מאפשר ריק מפתח או ערך, למשל:

@Test (צפוי = NullPointerException.class) חלל ציבורי givenConcurrentHashMap_whenPutWithNullKey_thenThrowsNPE () {concurrentMap.put (null, אובייקט חדש ()); } @Test (צפוי = NullPointerException.class) חלל ציבורי givenConcurrentHashMap_whenPutNullValue_thenThrowsNPE () {concurrentMap.put ("test", null); }

למרות זאת, ל לְחַשֵׁב* ו לְמַזֵג פעולות, הערך המחושב יכול להיות ריק, המציין כי מיפוי ערכי המפתח מוסר אם קיים או נשאר נעדר אם נעדר בעבר.

@Test הציבור בטל שניתןKeyPresent_whenComputeRemappingNull_thenMappingRemoved () {Object oldValue = אובייקט חדש (); concurrentMap.put ("test", oldValue); concurrentMap.compute ("test", (s, o) -> null); assertNull (concurrentMap.get ("מבחן")); }

3.3. תמיכה בזרם

ג'אווה 8 מספק זרם תמיכה ב ConcurrentHashMap גם כן.

בניגוד לרוב שיטות הזרם, פעולות בתפזורת (רציפות ומקבילות) מאפשרות שינוי במקביל בבטחה. ConcurrentModificationException לא ייזרק, מה שמתייחס גם לאיטרטים שלו. רלוונטי לזרמים, כמה לכל אחד*, לחפש, ו לְהַפחִית* שיטות מתווספות גם לתמיכה במעבר עשיר יותר ובצמצום מפות.

3.4. ביצועים

מתחת למכסת המנוע, ConcurrentHashMap דומה במקצת ל מפת גיבוב, עם גישה לעדכון נתונים המבוססים על טבלת hash (אם כי מורכבת יותר).

וכמובן, ה ConcurrentHashMap אמור להניב ביצועים טובים בהרבה במקרים בו זמנית לאחזור נתונים ועדכון.

בואו נכתוב מיקרו-אמת מידה מהירה עבור לקבל ו לָשִׂים ביצועים והשווה את זה ל טבלת גיבוב ו Collections.synchronizedMap, מריץ את שתי הפעולות 500,000 פעמים בארבעה שרשורים.

@Test ציבורי בטל givenMaps_whenGetPut500KTimes_thenConcurrentMapFaster () זורק חריג {Map hashtable = חדש Hashtable (); מפה synchronizedHashMap = Collections.synchronizedMap (HashMap חדש ()); מפה concurrentHashMap = ConcurrentHashMap חדש (); hashtableAvgRuntime ארוך = timeElapseForGetPut (hashtable); ארוך syncHashMapAvgRuntime = timeElapseForGetPut (synchronizedHashMap); long concurrentHashMapAvgRuntime = timeElapseForGetPut (concurrentHashMap); assertTrue (hashtableAvgRuntime> concurrentHashMapAvgRuntime); assertTrue (syncHashMapAvgRuntime> concurrentHashMapAvgRuntime); } זמן ארוך פרטיElapseForGetPut (מפת המפה) זורק את InterruptedException {ExecutorService executorService = Executors.newFixedThreadPool (4); זמן התחלה ארוך = System.nanoTime (); עבור (int i = 0; i {for (int j = 0; j <500_000; j ++) {int value = ThreadLocalRandom .current () .nextInt (10000); מפתח מחרוזת = String.valueOf (value); map.put (מפתח, ערך); map.get (מפתח);}}); } executorService.shutdown (); executorService.awaitTermination (1, TimeUnit.MINUTES); החזרה (System.nanoTime () - startTime) / 500_000; }

זכור מיקרו-אמות מידה מסתכלות רק על תרחיש יחיד ולא תמיד משקפות טוב את הביצועים בעולם האמיתי.

עם זאת, במערכת OS X עם מערכת dev ממוצעת אנו רואים תוצאת מדגם ממוצעת במשך 100 ריצות רצופות (בשניות ננו):

Hashtable: 1142.45 מסונכרן Hash Map: 1273.89 במקביל Hash Map: 230.2

בסביבת ריבוי השחלות, בה צפויים מספר אשכולות לגשת למשותף מַפָּה, ה ConcurrentHashMap ברור שעדיף.

עם זאת, כאשר מַפָּה נגיש רק לשרשור אחד, מפת גיבוב יכול להיות בחירה טובה יותר בגלל הפשטות והביצועים האיתנים שלה.

3.5. מלכודות

פעולות אחזור בדרך כלל אינן נחסמות ConcurrentHashMap ויכולה לחפוף עם פעולות העדכון. כדי לקבל ביצועים טובים יותר, הם משקפים רק את תוצאות פעולות העדכונים שהושלמו לאחרונה, כאמור ב- Javadoc הרשמי.

יש לזכור כמה עובדות אחרות:

  • תוצאות של שיטות מצב מצטברות כולל גודל, זה ריק, ו containValue בדרך כלל שימושיים רק כאשר מפה אינה עוברת עדכונים מקבילים בשרשורים אחרים:
@Test ציבורי בטל givenConcurrentMap_whenUpdatingAndGetSize_thenError () זורק InterruptedException {Runnable collectMapSizes = () -> {for (int i = 0; i {for (int i = 0; i <MAX_SIZE; i ++) {concurrentMap.put (String ivalueO) ), i);}}; executorService.execute (updateMapData); executorService.execute (collectMapSizes); executorService.shutdown (); executorService.awaitTermination (1, TimeUnit.MINUTES); assertNotEquals (MAX_SIZE, mapSizes.get (MAX_SIZE) ) .intValue ()); assertEquals (MAX_SIZE, concurrentMap.size ());}

אם עדכונים מקבילים נמצאים בשליטה קפדנית, הסטטוס המצרפי עדיין יהיה אמין.

אם כי אלה שיטות מצב מצטבר אינן מבטיחות את הדיוק בזמן אמת, הן עשויות להיות נאותות למטרות ניטור או הערכה.

שים לב שהשימוש ב גודל() שֶׁל ConcurrentHashMap צריך להיות מוחלף ב mappingCount (), עבור השיטה האחרונה מחזירה א ארוך לספור, אם כי עמוק בפנים הם מבוססים על אותה הערכה.

  • hashCode עניינים: שימו לב כי שימוש במקשים רבים בדיוק זהים hashCode () היא דרך בטוחה להאט ביצועים של כל טבלת חשיש.

כדי לשפר את ההשפעה כאשר המקשים נמצאים ניתן להשוות, ConcurrentHashMap עשוי להשתמש בסדר השוואה בין המקשים כדי לסייע בשבירת קשרים. ובכל זאת, עלינו להימנע משימוש בו hashCode () כמה שאנחנו יכולים.

  • איטרטורים נועדו לשימוש רק בשרשור יחיד מכיוון שהם מספקים עקביות חלשה ולא מעבר כושל מהיר, והם לעולם לא יזרקו ConcurrentModificationException.
  • קיבולת הטבלה הראשונית המוגדרת כברירת מחדל היא 16, והיא מותאמת לפי רמת המקביליות שצוינה:
ציבורי ConcurrentHashMap (int initialCapacity, float loadFactor, int concurrencyLevel) {// ... if (initialCapacity <concurrencyLevel) {initialCapacity = concurrencyLevel; } // ...}
  • זהירות לגבי פונקציות מיפוי מחדש: אם כי אנו יכולים לבצע פעולות מיפוי מחדש בתנאי לְחַשֵׁב ו לְמַזֵג* בשיטות, עלינו לשמור עליהם מהירים, קצרים ופשוטים, ולהתמקד במיפוי הנוכחי כדי למנוע חסימה בלתי צפויה.
  • מקשים פנימה ConcurrentHashMap אינם מסודרים, ולכן במקרים בהם נדרשת הזמנה, ConcurrentSkipListMap היא בחירה מתאימה.

4. ConcurrentNavigableMap

במקרים בהם נדרשת הזמנת מפתחות נוכל להשתמש בה ConcurrentSkipListMap, גרסה מקבילה של TreeMap.

כתוספת עבור ConcurrentMap, ConcurrentNavigableMap תומך בסדר כולל של מקשיו (בסדר עולה כברירת מחדל) והוא ניווט במקביל. שיטות שמחזירות תצוגות של המפה נעקפות לתאימות במקביל:

  • תת-מפה
  • headMap
  • זנב מפה
  • תת-מפה
  • headMap
  • זנב מפה
  • יורד מפה

סט מפתחות() איטרטורים ומפצלים של תצוגות משופרים עם עקביות של זיכרון חלש:

  • navigableKeySet
  • סט מפתחות
  • יורד KeySet

5. ConcurrentSkipListMap

בעבר סיקרנו NavigableMap ממשק ויישומו TreeMap. ConcurrentSkipListMap ניתן לראות גרסה בו זמנית ניתנת להרחבה של TreeMap.

בפועל, אין יישום מקביל של העץ האדום-שחור בג'אווה. גרסה בו זמנית של SkipLists מיושם ב ConcurrentSkipListMap, מתן עלות זמן יומן (n) ממוצעת צפויה עבור containKey, לקבל, לָשִׂים ו לְהַסִיר פעולות וריאציותיהן.

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

@Test הציבור בטל givenSkipListMap_whenNavConcurrently_thenCountCorrect () זורק InterruptedException {NavigableMap skipListMap = חדש ConcurrentSkipListMap (); int count = countMapElementByPollingFirstEntry (skipListMap, 10000, 4); assertEquals (10000 * 4, ספירה); } @Test הריק ציבורי givenTreeMap_whenNavConcurrently_thenCountError () זורק InterruptedException {NavigableMap treeMap = חדש TreeMap (); int count = countMapElementByPollingFirstEntry (treeMap, 10000, 4); assertNotEquals (10000 * 4, ספירה); } פרטי int countMapElementByPollingFirstEntry (NavigableMap navigableMap, int elementCount, int concurrencyLevel) זורק InterruptedException {עבור (int i = 0; i <elementCount * concurrencyLevel; i ++) {navigableMap.put (i, i); } מונה AtomicInteger = חדש AtomicInteger (0); ExecutorService executorService = Executors.newFixedThreadPool (concurrencyLevel); עבור (int j = 0; j {for (int i = 0; i <elementCount; i ++) {if (navigableMap.pollFirstEntry ()! = null) {counter.incrementAndGet ();}}}); } executorService.shutdown (); executorService.awaitTermination (1, TimeUnit.MINUTES); counter counter.get (); }

הסבר מלא על חששות ההופעה מאחורי הקלעים הוא מעבר לתחום המאמר. ניתן למצוא את הפרטים ב ConcurrentSkipListMap's Javadoc, אשר ממוקם תחת java / util / במקביל בתוך ה src.zip קוֹבֶץ.

6. מסקנה

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

קוד המקור המלא לכל הדוגמאות המשמשות במאמר זה ניתן למצוא בפרויקט GitHub.


$config[zx-auto] not found$config[zx-overlay] not found