Руководство по ConcurrentMap

Руководство по 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.