Руководство по ConcurrentMap
1. обзор
Maps, естественно, являются одним из наиболее распространенных стилей коллекций Java.
И, что важно,HashMap не является поточно-ориентированной реализацией, тогда какHashtable действительно обеспечивает поточно-безопасность за счет синхронизации операций.
Несмотря на то, чтоHashtable является потокобезопасным, он не очень эффективен. Другой полностью синхронизированныйMap,Collections.synchronizedMap, также не показывает большой эффективности. Если нам нужна потокобезопасность с высокой пропускной способностью при высоком уровне параллелизма, эти реализации не подходят.
Для решения проблемыJava Collections Frameworkintroduced ConcurrentMap in Java 1.5.
Следующие ниже обсуждения основаны наJava 1.8.
2. ConcurrentMapс
ConcurrentMap - это расширение интерфейсаMap. Он призван предоставить структуру и руководство для решения проблемы согласования пропускной способности с безопасностью потоков.
Переопределяя несколько методов интерфейса по умолчанию,ConcurrentMap дает рекомендации по допустимым реализациям для обеспечения безопасности потоков и согласованных с памятью атомарных операций.
Некоторые реализации по умолчанию переопределяются, отключая поддержку ключа / значенияnull:
-
getOrDefault
-
для каждого
-
заменить все
-
computeIfAbsent
-
computeIfPresent
-
вычисление
-
сливаться
СледующиеAPIs также переопределены для поддержки атомарности без реализации интерфейса по умолчанию:
-
putIfAbsent
-
Удалить
-
заменить (ключ, старое значение, новое значение)
-
заменить (ключ, значение)
Остальные действия наследуются напрямую, в основном в соответствии сMap.
3. ConcurrentHashMapс
ConcurrentHashMap - это готовая "из коробки" реализацияConcurrentMap.
Для повышения производительности он состоит из массива узлов в виде сегментов таблицы (раньше это были сегменты таблицы доJava 8) под капотом и в основном использует операцииCAS во время обновления.
Ведра стола инициализируются лениво после первой вставки. Каждое ведро может быть независимо заблокировано путем блокировки самого первого узла в ведре. Операции чтения не блокируются, а количество обновлений сводится к минимуму.
Количество требуемых сегментов зависит от количества потоков, обращающихся к таблице, так что выполняемое обновление для сегмента будет составлять не более одного большую часть времени.
ДоJava 8 количество требуемых «сегментов» зависело от количества потоков, обращающихся к таблице, так что выполняемое обновление для каждого сегмента большую часть времени было не более одного.
Вот почему конструкторы, по сравнению сHashMap, предоставляют дополнительный аргументconcurrencyLevel для управления количеством предполагаемых потоков для использования:
public ConcurrentHashMap(
public ConcurrentHashMap(
int initialCapacity, float loadFactor, int concurrencyLevel)
Два других аргумента:initialCapacity иloadFactor работали точно так же, какas HashMap.
However, since Java 8, the constructors are only present for backward compatibility: the parameters can only affect the initial size of the map.
3.1. Потокобезопасность
ConcurrentMap гарантирует согласованность памяти при операциях «ключ-значение» в многопоточной среде.
Действия в потоке перед помещением объекта вConcurrentMap в качестве ключа или значенияhappen-before действия после доступа или удаления этого объекта в другом потоке.
Чтобы убедиться в этом, давайте рассмотрим случай несогласованности с памятью:
@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 < 10; j++) {
executorService.execute(() -> {
for (int k = 0; k < 10; k++)
map.computeIfPresent(
"test",
(key, value) -> value + 1
);
});
}
executorService.shutdown();
executorService.awaitTermination(5, TimeUnit.SECONDS);
sumList.add(map.get("test"));
}
return sumList;
}
Для каждого параллельного действияmap.computeIfPresentHashMap не дает согласованного представления о том, каким должно быть текущее целочисленное значение, что приводит к противоречивым и нежелательным результатам.
Что касается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. Null Ключ / значение
БольшинствоAPIs, предоставляемыхConcurrentMap, не допускают ключ или значениеnull, например:
@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);
}
Однакоfor compute* and merge actions, the computed value can be null, which indicates the key-value mapping is removed if present or remains absent if previously absent.
@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 также обеспечивает поддержкуStream вConcurrentHashMap.
В отличие от большинства потоковых методов, объемные (последовательные и параллельные) операции позволяют безопасно выполнять параллельное изменение. ConcurrentModificationException не будет брошен, что также относится к его итераторам. Относительно потоков, несколько методовforEach*,search иreduce* также добавлены для поддержки более обширных операций обхода и сокращения карты.
3.4. Спектакль
Under the hood, ConcurrentHashMap is somewhat similar to HashMap, с доступом к данным и обновлением на основе хеш-таблицы (хотя и более сложной).
И, конечно же,ConcurrentHashMap должен дать гораздо лучшую производительность в большинстве одновременных случаев для получения и обновления данных.
Давайте напишем быстрый микротест для производительностиget иput и сравним его с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 < 4; i++) {
executorService.execute(() -> {
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
В многопоточной среде, где ожидается, что несколько потоков будут обращаться к общемуMap, явно предпочтительнееConcurrentHashMap.
Однако, когдаMap доступен только для одного потока,HashMap может быть лучшим выбором из-за его простоты и высокой производительности.
3.5. Ловушки
Операции получения обычно не блокируются вConcurrentHashMap и могут перекрываться с операциями обновления. Таким образом, для повышения производительности они отражают только результаты последних завершенных операций обновления, как указано вofficial Javadoc.
Есть несколько других фактов, которые следует иметь в виду:
-
результаты методов агрегированного состояния, включаяsize,isEmpty иcontainsValue, обычно полезны только тогда, когда карта не подвергается одновременным обновлениям в других потоках:
@Test
public void givenConcurrentMap_whenUpdatingAndGetSize_thenError()
throws InterruptedException {
Runnable collectMapSizes = () -> {
for (int i = 0; i < MAX_SIZE; i++) {
mapSizes.add(concurrentMap.size());
}
};
Runnable updateMapData = () -> {
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());
}
Если одновременные обновления находятся под строгим контролем, совокупный статус все еще будет надежным.
Хотя этиaggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposes.
Обратите внимание, что использованиеsize() изConcurrentHashMap следует заменить наmappingCount(), поскольку последний метод возвращает счетlong, хотя в глубине души они основаны на той же оценке.
-
hashCode matters: обратите внимание, что использование множества ключей с одинаковымиhashCode() - верный способ снизить производительность любой хеш-таблицы.
Чтобы улучшить воздействие, когда ключи -Comparable,ConcurrentHashMap может использовать порядок сравнения между ключами, чтобы помочь разорвать связи. Тем не менее, мы должны избегать использования тех жеhashCode(), насколько это возможно.
-
итераторы предназначены только для использования в одном потоке, так как они обеспечивают слабую согласованность, а не быстрый обход ошибок, и они никогда не будут вызыватьConcurrentModificationException.
-
начальная емкость таблицы по умолчанию - 16, и она регулируется указанным уровнем параллелизма:
public ConcurrentHashMap(
int initialCapacity, float loadFactor, int concurrencyLevel) {
//...
if (initialCapacity < concurrencyLevel) {
initialCapacity = concurrencyLevel;
}
//...
}
-
Предостережение относительно функций переназначения: хотя мы можем выполнять операции переназначения с помощью предоставленных методовcompute иmerge*, мы должны делать их быстрыми, короткими и простыми и сосредоточиться на текущем отображении, чтобы избежать неожиданной блокировки.
-
ключи вConcurrentHashMap не отсортированы, поэтому для случаев, когда требуется упорядочение, подходитConcurrentSkipListMap.
4. ConcurrentNavigableMapс
В случаях, когда требуется упорядочение ключей, мы можем использоватьConcurrentSkipListMap, параллельную версиюTreeMap.
В качестве дополнения кConcurrentMap,ConcurrentNavigableMap поддерживает полное упорядочение своих ключей (по умолчанию в порядке возрастания) и по нему можно одновременно перемещаться. Методы, которые возвращают представления карты, переопределяются для совместимости параллелизма:
-
subMap
-
headMap
-
tailMap
-
subMap
-
headMap
-
tailMap
-
по убываниюКарта
Итераторы и сплитераторы представленийkeySet() улучшены за счет слабой согласованности с памятью:
-
navigableKeySet
-
Keyset
-
спускающийсяKeySet
5. ConcurrentSkipListMapс
Ранее мы рассмотрели интерфейсNavigableMap и его реализациюTreeMap. ConcurrentSkipListMap можно увидеть как масштабируемую параллельную версиюTreeMap.
На практике в Java не существует параллельной реализации красно-черного дерева. Параллельный вариантSkipLists реализован вConcurrentSkipListMap, обеспечивая ожидаемые средние log (n) временные затраты дляcontainsKey,get,put иremove и их варианты.
В дополнение к функциямTreeMap, операции вставки, удаления, обновления и доступа ключей гарантируются с потоковой безопасностью. Вот сравнение сTreeMap при одновременной навигации:
@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 < concurrencyLevel; j++) {
executorService.execute(() -> {
for (int i = 0; i < elementCount; i++) {
if (navigableMap.pollFirstEntry() != null) {
counter.incrementAndGet();
}
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
return counter.get();
}
Полное объяснение проблем производительности за кулисами выходит за рамки этой статьи. Подробности можно найти вConcurrentSkipListMap’s Javadoc, который находится подjava/util/concurrent в файлеsrc.zip.
6. Заключение
В этой статье мы в основном представили интерфейсConcurrentMap и особенностиConcurrentHashMap, а также рассмотрели необходимость упорядочивания ключей дляConcurrentNavigableMap.
Полный исходный код всех примеров, использованных в этой статье, можно найти вin the GitHub project.