1. Общ преглед
Картите естествено са един от най-широко използваните стилове на колекцията на Java.
И, което е важно, HashMap не е безопасно изпълнение на нишки, докато Hashtable осигурява безопасност на нишки чрез синхронизиране на операциите.
Въпреки че Hashtable е безопасен за нишки, той не е много ефективен. Друга напълно синхронизирана карта, Collections.synchronizedMap, също не показва голяма ефективност. Ако искаме безопасност на нишките с висока производителност при висока паралелност, тези внедрения не са начинът.
За да разреши проблема, Java Collections Framework представи ConcurrentMap в Java 1.5 .
Следващите дискусии са базирани на Java 1.8 .
2. ConcurrentMap
ConcurrentMap е разширение на интерфейса на Map . Целта му е да предостави структура и насоки за решаване на проблема за съгласуване на пропускателната способност с безопасността на нишките.
Чрез замяна на няколко метода по подразбиране на интерфейса, ConcurrentMap дава насоки за валидни реализации, за да осигури атомни операции, свързани с безопасността на потоците и паметта.
Няколко реализации по подразбиране са заменени, като деактивират поддръжката на нулев ключ / стойност:
- getOrDefault
- за всеки
- replaceAll
- computeIfAbsent
- computeIfPresent
- изчисли
- сливане
Следните API също са заменени, за да поддържат атомност, без изпълнение на интерфейс по подразбиране:
- putIfAbsent
- Премахване
- замени (ключ, oldValue, newValue)
- замени (ключ, стойност)
Останалите действия се наследяват директно с основно в съответствие с Map .
3. ConcurrentHashMap
ConcurrentHashMap е готовата реализация на ConcurrentMap .
За по-добра производителност той се състои от масив от възли като сегменти на таблици (използвани като сегменти на таблици преди Java 8 ) под капака и използва предимно CAS операции по време на актуализирането.
Кофите на таблицата се инициализират лениво при първото вмъкване. Всяка кофа може да бъде заключена независимо чрез заключване на първия възел в кофата. Операциите за четене не се блокират и съдържанието на актуализации е сведено до минимум.
Броят на необходимите сегменти е спрямо броя на нишките, които имат достъп до таблицата, така че текущата актуализация на сегмент ще бъде не повече от една през повечето време.
Преди Java 8 , броят на „сегментите“, който се изискваше, беше по отношение на броя нишки, които имат достъп до таблицата, така че текущата актуализация за всеки сегмент ще бъде не повече от една през повечето време.
Ето защо конструкторите, в сравнение с HashMap , предоставят допълнителния аргумент concurrencyLevel за контрол на броя на очакваните нишки, които да се използват:
public ConcurrentHashMap(
public ConcurrentHashMap( int initialCapacity, float loadFactor, int concurrencyLevel)
Другите два аргумента: InitiCapacity и loadFactor са работили по същия начин като HashMap .
Въпреки това, тъй като Java 8 , конструкторите присъстват само за обратна съвместимост: параметрите могат да повлияят само на първоначалния размер на картата .
3.1. Безопасност на резбата
ConcurrentMap гарантира последователност на паметта при операции ключ / стойност в среда с много нишки.
Действия в нишка преди поставяне на обект в ConcurrentMap като ключ или стойност се случват преди действия след достъп или премахване на този обект в друга нишка.
За да потвърдим, нека да разгледаме несъвместим случай в паметта:
@Test public void givenHashMap_whenSumParallel_thenError() throws Exception { Map map = new HashMap(); List sumList = parallelSum100(map, 100); assertNotEquals(1, sumList .stream() .distinct() .count()); long wrongResultCount = sumList .stream() .filter(num -> num != 100) .count(); assertTrue(wrongResultCount > 0); } private List parallelSum100(Map map, int executionTimes) throws InterruptedException { List sumList = new ArrayList(1000); for (int i = 0; i < executionTimes; i++) { map.put("test", 0); ExecutorService executorService = Executors.newFixedThreadPool(4); for (int j = 0; j { for (int k = 0; k value + 1 ); }); } executorService.shutdown(); executorService.awaitTermination(5, TimeUnit.SECONDS); sumList.add(map.get("test")); } return sumList; }
За всяко действие map.computeIfPresent успоредно, HashMap не предоставя последователен изглед на това, което трябва да бъде настоящата целочислена стойност, което води до противоречиви и нежелани резултати.
Що се отнася до ConcurrentHashMap , можем да получим последователен и правилен резултат:
@Test public void givenConcurrentMap_whenSumParallel_thenCorrect() throws Exception { Map map = new ConcurrentHashMap(); List sumList = parallelSum100(map, 1000); assertEquals(1, sumList .stream() .distinct() .count()); long wrongResultCount = sumList .stream() .filter(num -> num != 100) .count(); assertEquals(0, wrongResultCount); }
3.2. Нулев ключ / стойност
Повечето API , предоставени от ConcurrentMap , не позволяват нулев ключ или стойност, например:
@Test(expected = NullPointerException.class) public void givenConcurrentHashMap_whenPutWithNullKey_thenThrowsNPE() { concurrentMap.put(null, new Object()); } @Test(expected = NullPointerException.class) public void givenConcurrentHashMap_whenPutNullValue_thenThrowsNPE() { concurrentMap.put("test", null); }
Въпреки това, за изчисляване * и обединяване действия, изчислената стойност може да бъде нула , което показва, че съпоставянето ключ-стойност е премахнато, ако е налице, или остава отсъстващо, ако преди това липсва .
@Test public void givenKeyPresent_whenComputeRemappingNull_thenMappingRemoved() { Object oldValue = new Object(); concurrentMap.put("test", oldValue); concurrentMap.compute("test", (s, o) -> null); assertNull(concurrentMap.get("test")); }
3.3. Поддръжка на поточно предаване
Java 8 осигурява поддръжка на поток и в ConcurrentHashMap .
За разлика от повечето методи на потока, груповите (последователни и паралелни) операции позволяват безопасна едновременна модификация. ConcurrentModificationException няма да бъде изхвърлен, което се отнася и за неговите итератори. Подходящи за потоците, добавени са и няколко метода forEach * , търсене и намаляване * , които поддържат по-богато обхождане и операции за намаляване на картите.
3.4. производителност
Под капака ConcurrentHashMap е донякъде подобен на HashMap , с достъп и актуализация на данни въз основа на хеш таблица (макар и по-сложна).
И разбира се, ConcurrentHashMap трябва да осигури много по-добра производителност в повечето едновременни случаи за извличане и актуализиране на данни.
Нека напишем бърз микро-бенчмарк за получаване и поставяне на производителност и да го сравним с Hashtable и Collections.synchronizedMap , изпълнявайки и двете операции 500 000 пъти в 4 нишки.
@Test public void givenMaps_whenGetPut500KTimes_thenConcurrentMapFaster() throws Exception { Map hashtable = new Hashtable(); Map synchronizedHashMap = Collections.synchronizedMap(new HashMap()); Map concurrentHashMap = new ConcurrentHashMap(); long hashtableAvgRuntime = timeElapseForGetPut(hashtable); long syncHashMapAvgRuntime = timeElapseForGetPut(synchronizedHashMap); long concurrentHashMapAvgRuntime = timeElapseForGetPut(concurrentHashMap); assertTrue(hashtableAvgRuntime > concurrentHashMapAvgRuntime); assertTrue(syncHashMapAvgRuntime > concurrentHashMapAvgRuntime); } private long timeElapseForGetPut(Map map) throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(4); long startTime = System.nanoTime(); for (int i = 0; i { for (int j = 0; j < 500_000; j++) { int value = ThreadLocalRandom .current() .nextInt(10000); String key = String.valueOf(value); map.put(key, value); map.get(key); } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); return (System.nanoTime() - startTime) / 500_000; }
Имайте предвид, че микро-бенчмарковете разглеждат само един сценарий и не винаги са добро отражение на представянето в реалния свят.
Като се има предвид това, в система OS X със средна система за разработчици виждаме среден пример за 100 последователни пробега (за наносекунди):
Hashtable: 1142.45 SynchronizedHashMap: 1273.89 ConcurrentHashMap: 230.2
In a multi-threading environment, where multiple threads are expected to access a common Map, the ConcurrentHashMap is clearly preferable.
However, when the Map is only accessible to a single thread, HashMap can be a better choice for its simplicity and solid performance.
3.5. Pitfalls
Retrieval operations generally do not block in ConcurrentHashMap and could overlap with update operations. So for better performance, they only reflect the results of the most recently completed update operations, as stated in the official Javadoc.
There are several other facts to bear in mind:
- results of aggregate status methods including size, isEmpty, and containsValue are typically useful only when a map is not undergoing concurrent updates in other threads:
@Test public void givenConcurrentMap_whenUpdatingAndGetSize_thenError() throws InterruptedException { Runnable collectMapSizes = () -> { for (int i = 0; i { for (int i = 0; i < MAX_SIZE; i++) { concurrentMap.put(String.valueOf(i), i); } }; executorService.execute(updateMapData); executorService.execute(collectMapSizes); executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); assertNotEquals(MAX_SIZE, mapSizes.get(MAX_SIZE - 1).intValue()); assertEquals(MAX_SIZE, concurrentMap.size()); }
If concurrent updates are under strict control, aggregate status would still be reliable.
Although these aggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposes.
Note that usage of size() of ConcurrentHashMap should be replaced by mappingCount(), for the latter method returns a long count, although deep down they are based on the same estimation.
- hashCode matters: note that using many keys with exactly the same hashCode() is a sure way to slow down a performance of any hash table.
To ameliorate impact when keys are Comparable, ConcurrentHashMap may use comparison order among keys to help break ties. Still, we should avoid using the same hashCode() as much as we can.
- iterators are only designed to use in a single thread as they provide weak consistency rather than fast-fail traversal, and they will never throw ConcurrentModificationException.
- the default initial table capacity is 16, and it's adjusted by the specified concurrency level:
public ConcurrentHashMap( int initialCapacity, float loadFactor, int concurrencyLevel) { //... if (initialCapacity < concurrencyLevel) { initialCapacity = concurrencyLevel; } //... }
- caution on remapping functions: though we can do remapping operations with provided compute and merge* methods, we should keep them fast, short and simple, and focus on the current mapping to avoid unexpected blocking.
- keys in ConcurrentHashMap are not in sorted order, so for cases when ordering is required, ConcurrentSkipListMap is a suitable choice.
4. ConcurrentNavigableMap
For cases when ordering of keys is required, we can use ConcurrentSkipListMap, a concurrent version of TreeMap.
As a supplement for ConcurrentMap, ConcurrentNavigableMap supports total ordering of its keys (in ascending order by default) and is concurrently navigable. Methods that return views of the map are overridden for concurrency compatibility:
- subMap
- headMap
- tailMap
- subMap
- headMap
- tailMap
- descendingMap
keySet() views' iterators and spliterators are enhanced with weak-memory-consistency:
- navigableKeySet
- keySet
- descendingKeySet
5. ConcurrentSkipListMap
Previously, we have covered NavigableMap interface and its implementation TreeMap. ConcurrentSkipListMap can be seen a scalable concurrent version of TreeMap.
In practice, there's no concurrent implementation of the red-black tree in Java. A concurrent variant of SkipLists is implemented in ConcurrentSkipListMap, providing an expected average log(n) time cost for the containsKey, get, put and remove operations and their variants.
In addition to TreeMap‘s features, key insertion, removal, update and access operations are guaranteed with thread-safety. Here's a comparison to TreeMap when navigating concurrently:
@Test public void givenSkipListMap_whenNavConcurrently_thenCountCorrect() throws InterruptedException { NavigableMap skipListMap = new ConcurrentSkipListMap(); int count = countMapElementByPollingFirstEntry(skipListMap, 10000, 4); assertEquals(10000 * 4, count); } @Test public void givenTreeMap_whenNavConcurrently_thenCountError() throws InterruptedException { NavigableMap treeMap = new TreeMap(); int count = countMapElementByPollingFirstEntry(treeMap, 10000, 4); assertNotEquals(10000 * 4, count); } private int countMapElementByPollingFirstEntry( NavigableMap navigableMap, int elementCount, int concurrencyLevel) throws InterruptedException { for (int i = 0; i < elementCount * concurrencyLevel; i++) { navigableMap.put(i, i); } AtomicInteger counter = new AtomicInteger(0); ExecutorService executorService = Executors.newFixedThreadPool(concurrencyLevel); for (int j = 0; j { for (int i = 0; i < elementCount; i++) { if (navigableMap.pollFirstEntry() != null) { counter.incrementAndGet(); } } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); return counter.get(); }
Пълно обяснение на проблемите на изпълнението зад кулисите е извън обхвата на тази статия. Подробностите могат да бъдат намерени в Javadoc на ConcurrentSkipListMap , който се намира под java / util / concurrent във файла src.zip .
6. Заключение
В тази статия въведохме основно интерфейса ConcurrentMap и характеристиките на ConcurrentHashMap и обхванати от ConcurrentNavigableMap, като се изисква подреждане на ключове.
Пълният изходен код за всички примери, използвани в тази статия, може да бъде намерен в проекта GitHub.