Um guia para o ConcurrentMap
1. Visão geral
Maps são naturalmente um dos estilos de coleção Java mais amplamente utilizados.
E, mais importante,HashMap não é uma implementação thread-safe, enquantoHashtable fornece thread-safety por meio de operações de sincronização.
Mesmo queHashtable seja thread-safe, não é muito eficiente. OutroMap,Collections.synchronizedMap, totalmente sincronizado também não exibe grande eficiência. Se quisermos thread-safety com alto rendimento sob alta simultaneidade, essas implementações não são o caminho a percorrer.
Para resolver o problema, oJava Collections Frameworkintroduced ConcurrentMap in Java 1.5.
As discussões a seguir são baseadas emJava 1.8.
2. ConcurrentMap
ConcurrentMap é uma extensão da interfaceMap. Ele tem como objetivo fornecer uma estrutura e orientação para resolver o problema de reconciliar o rendimento com a segurança da thread.
Substituindo vários métodos padrão de interface,ConcurrentMap fornece diretrizes para implementações válidas para fornecer segurança de thread e operações atômicas consistentes com a memória.
Várias implementações padrão são substituídas, desativando o suporte de chave / valornull:
-
getOrDefault
-
para cada
-
substitua tudo
-
computeIfAbsent
-
computeIfPresent
-
calcular
-
fundir
Os seguintesAPIs também são substituídos para oferecer suporte à atomicidade, sem uma implementação de interface padrão:
-
putIfAbsent
-
retirar
-
substituir (chave, oldValue, newValue)
-
substituir (chave, valor)
O resto das ações são herdadas diretamente com consistência basicamente comMap.
3. ConcurrentHashMap
ConcurrentHashMap é a implementação prontaConcurrentMap pronta para uso.
Para melhor desempenho, ele consiste em uma matriz de nós como depósitos de tabelas (costumavam ser segmentos de tabela antes deJava 8) subjacentes e, principalmente, usa operaçõesCAS durante a atualização.
Os baldes de mesa são inicializados preguiçosamente, após a primeira inserção. Cada depósito pode ser bloqueado independentemente, bloqueando o primeiro nó no depósito. As operações de leitura não são bloqueadas e as contenções de atualização são minimizadas.
O número de segmentos necessários é relativo ao número de threads que acessam a tabela, de forma que a atualização em andamento por segmento não passasse de uma na maioria das vezes.
Antes deJava 8, o número de “segmentos” necessários era relativo ao número de threads acessando a tabela, de forma que a atualização em andamento por segmento não seria mais do que uma na maioria das vezes.
É por isso que os construtores, em comparação comHashMap, fornecem o argumentoconcurrencyLevel extra para controlar o número de threads estimados para usar:
public ConcurrentHashMap(
public ConcurrentHashMap(
int initialCapacity, float loadFactor, int concurrencyLevel)
Os outros dois argumentos:initialCapacity eloadFactor funcionaram exatamente da mesma formaas 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. Segurança de Thread
ConcurrentMap garante consistência de memória em operações de chave / valor em um ambiente multi-threading.
Ações em um encadeamento antes de colocar um objeto emConcurrentMap como uma chave ou valorhappen-before ações subsequentes ao acesso ou remoção desse objeto em outro encadeamento.
Para confirmar, vamos dar uma olhada em um caso de inconsistência de memória:
@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;
}
Para cada açãomap.computeIfPresent em paralelo,HashMap não fornece uma visão consistente de qual deveria ser o valor inteiro atual, levando a resultados inconsistentes e indesejáveis.
Quanto aConcurrentHashMap, podemos obter um resultado consistente e correto:
@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 Chave / valor
A maioria dosAPIs fornecidos porConcurrentMap não permite a chave ou valornull, por exemplo:
@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);
}
No entanto,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. Suporte de fluxo
Java 8 fornece suporteStream emConcurrentHashMap também.
Diferentemente da maioria dos métodos de fluxo, as operações em massa (sequenciais e paralelas) permitem modificações simultâneas com segurança. ConcurrentModificationException não será lançado, o que também se aplica a seus iteradores. Relevantes para fluxos, vários métodosforEach*,search ereduce* também são adicionados para suportar operações mais ricas de travessia e redução de mapa.
3.4. atuação
Under the hood, ConcurrentHashMap is somewhat similar to HashMap, com acesso a dados e atualização com base em uma tabela hash (embora mais complexa).
E, claro, oConcurrentHashMap deve produzir um desempenho muito melhor na maioria dos casos simultâneos para recuperação e atualização de dados.
Vamos escrever um micro-benchmark rápido para o desempenho degeteput e compará-lo comHashtableeCollections.synchronizedMap, executando ambas as operações por 500.000 vezes em 4 threads.
@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;
}
Tenha em mente que os micro-benchmarks olham apenas para um único cenário e nem sempre são um bom reflexo do desempenho no mundo real.
Dito isso, em um sistema OS X com um sistema dev médio, estamos vendo um resultado de amostra médio para 100 execuções consecutivas (em nanossegundos):
Hashtable: 1142.45
SynchronizedHashMap: 1273.89
ConcurrentHashMap: 230.2
Em um ambiente multi-threading, onde se espera que vários threads acessem umMap comum, oConcurrentHashMap é claramente preferível.
No entanto, quandoMap é acessível apenas para um único thread,HashMap pode ser uma escolha melhor por sua simplicidade e desempenho sólido.
3.5. Armadilhas
As operações de recuperação geralmente não bloqueiam emConcurrentHashMape podem se sobrepor às operações de atualização. Portanto, para um melhor desempenho, eles refletem apenas os resultados das operações de atualização concluídas mais recentemente, conforme declarado emofficial Javadoc.
Há vários outros fatos a serem lembrados:
-
resultados de métodos de status agregado incluindosize,isEmpty econtainsValue são normalmente úteis apenas quando um mapa não está passando por atualizações simultâneas em outros threads:
@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());
}
Se as atualizações simultâneas estiverem sob controle estrito, o status agregado ainda será confiável.
Embora essesaggregate status methods do not guarantee the real-time accuracy, they may be adequate for monitoring or estimation purposes.
Observe que o uso desize() deConcurrentHashMap deve ser substituído pormappingCount(), pois o último método retorna uma contagem delong, embora no fundo eles sejam baseados na mesma estimativa.
-
hashCode matters: observe que usar muitas chaves com exatamente o mesmohashCode() é uma maneira segura de diminuir o desempenho de qualquer tabela hash.
Para amenizar o impacto quando as chaves sãoComparable,ConcurrentHashMap pode usar a ordem de comparação entre as chaves para ajudar a quebrar os empates. Ainda assim, devemos evitar usar o mesmohashCode() tanto quanto pudermos.
-
Os iteradores são projetados para uso apenas em um único thread, pois fornecem consistência fraca em vez de travessia com falha rápida, e eles nunca irão lançarConcurrentModificationException.
-
a capacidade inicial padrão da tabela é 16 e é ajustada pelo nível de simultaneidade especificado:
public ConcurrentHashMap(
int initialCapacity, float loadFactor, int concurrencyLevel) {
//...
if (initialCapacity < concurrencyLevel) {
initialCapacity = concurrencyLevel;
}
//...
}
-
cuidado com funções de remapeamento: embora possamos fazer operações de remapeamento com os métodoscomputeemerge* fornecidos, devemos mantê-los rápidos, curtos e simples, e focar no mapeamento atual para evitar bloqueios inesperados.
-
as chaves emConcurrentHashMap não estão em ordem de classificação, portanto, para os casos em que a ordem é necessária,ConcurrentSkipListMap é uma escolha adequada.
4. ConcurrentNavigableMap
Para os casos em que a ordem das chaves é necessária, podemos usarConcurrentSkipListMap, uma versão simultânea deTreeMap.
Como um suplemento paraConcurrentMap,ConcurrentNavigableMap suporta a ordenação total de suas chaves (em ordem crescente por padrão) e é navegável simultaneamente. Os métodos que retornam visualizações do mapa são substituídos para compatibilidade com simultaneidade:
-
submapa
-
headMap
-
tailMap
-
submapa
-
headMap
-
tailMap
-
descendingMap
Os iteradores e divisores de visualizaçõeskeySet() são aprimorados com consistência de memória fraca:
-
navigableKeySet
-
conjunto de chaves
-
descendingKeySet
5. ConcurrentSkipListMap
Anteriormente, cobrimos a interfaceNavigableMap e sua implementaçãoTreeMap. ConcurrentSkipListMap pode ser visto como uma versão concorrente escalonável deTreeMap.
Na prática, não há implementação simultânea da árvore vermelha e preta em Java. Uma variante simultânea deSkipLists é implementada emConcurrentSkipListMap, fornecendo um custo médio de tempo log (n) esperado paracontainsKey,get,puteremove operações e suas variantes.
Além dos recursos deTreeMap, as operações de inserção, remoção, atualização e acesso de chave são garantidas com segurança de rosca. Aqui está uma comparação comTreeMap ao navegar simultaneamente:
@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();
}
Uma explicação completa das preocupações com o desempenho nos bastidores está além do escopo deste artigo. Os detalhes podem ser encontrados emConcurrentSkipListMap’s Javadoc, localizado emjava/util/concurrent no arquivosrc.zip.
6. Conclusão
Neste artigo, apresentamos principalmente a interfaceConcurrentMap e os recursos deConcurrentHashMape abordamosConcurrentNavigableMap como a ordenação de chaves é necessária.
O código-fonte completo para todos os exemplos usados neste artigo pode ser encontradoin the GitHub project.