# Nota. Cuidado com operações atômicas no ConcurrentHashMap



Desde tempos imemoriais, o Java teve uma maravilhosa interface do Mapa e sua implementação, em particular, o HashMap . E a partir do Java 5, também há ConcurrentHashMap . Considere essas duas implementações, sua evolução e o que essa evolução pode levar a desenvolvedores desatentos.

Aviso: este artigo usa citações do código-fonte do OpenJDK 8, distribuído sob a GNU General Public License versão 2.

Vezes antes do Java 8


Aqueles que encontraram longos e longos tempos de espera, primeiro o Java 7 e depois o Java 8 (não como agora, a cada seis meses uma nova versão), lembram quais operações com o Map eram as mais populares. Isto é:


Se você precisar colocar um valor na coleção, usamos o primeiro método e, para obter o valor existente, use o segundo.

Mas e se a inicialização lenta for necessária? Então, um código desse tipo apareceu:

String getOrPut(String key) { String result = map.get(key); //(1) if (result == null) { //(2) result = createValue(key); //(3) map.put(key, result); //(4) } return result; } 

  1. obtemos o valor por chave
  2. verifique se o valor desejado é encontrado
  3. se nenhum valor for encontrado, crie-o
  4. agregar valor à coleção por chave

Acontece um pouco complicado, não é? Além disso, no caso em que um HashMap simples é usado, esse código é apenas inconveniente de ler, porque ele não está enfiado. Porém, no caso do ConcurrentHashMap, um recurso adicional aparece: o método createValue (2) pode ser chamado várias vezes se vários threads conseguirem verificar a condição (1) antes que um deles grave o valor na coleção (3). Esse comportamento geralmente pode levar a consequências indesejáveis.

Antes do Java 8, simplesmente não havia opções elegantes. Se você precisou evitar várias criação de valor, teve que usar bloqueios adicionais.
O Java 8 tornou as coisas mais fáceis. Parece ...

O Java 8 chega até nós ...


Qual é o recurso mais esperado que nos veio com o Java 8? Isso mesmo, lambda. E não apenas lhamas, mas seu suporte em toda a variedade de APIs da biblioteca padrão. As estruturas de dados do mapa não foram ignoradas. Em particular, apareceram métodos como:


Devido a esses métodos, é possível reescrever o código fornecido anteriormente é muito mais simples:

 String getOrPut(String key) { return map.computeIfAbsent(key, this::createValue); } 

É claro que ninguém desistirá da oportunidade de simplificar seu código. Além disso, no caso de ConcurrentHashMap, o método computeIfAbsent também é executado atomicamente. I.e. createValue será chamado exatamente uma vez e somente se o valor desejado estiver ausente.

O IDE também não passou. Portanto, o IntelliJ IDEA oferece a substituição automática da versão antiga pela nova:




É claro que a simplificação de código e as dicas de IDE incentivam os desenvolvedores a usar essa nova API. Como resultado, o mesmo computeIfAbsent começou a aparecer em muitos lugares no código.
Tchau ...

De repente!


Até chegar a hora do próximo teste de carga. E então uma coisa terrível apareceu:



O aplicativo funcionou na seguinte versão do Java:

 versão openjdk "1.8.0_222"
 OpenJDK Runtime Environment (compilação 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 VM do servidor OpenJDK de 64 bits (compilação 25.222-b10, modo misto)


Para aqueles que não estão familiarizados com uma ferramenta tão maravilhosa como o YourKit .

Na captura de tela, linhas largas horizontais mostram a operação dos threads do aplicativo no tempo. Dependendo do estado do fluxo em um momento específico, a tira é pintada na cor correspondente:

  • amarelo - o fluxo está ocioso, aguardando trabalho;
  • verde - o encadeamento está sendo executado, executando o código do programa;
  • vermelho - este tópico está bloqueado por outro tópico.

Ou seja, acontece que quase todos os threads (e de fato havia muito mais do que o que é mostrado na captura de tela) estão quase o tempo todo em um estado bloqueado. E, para todos, o bloqueio está no mesmo computeIfAbsent do ConcurrentHashMap! E isso apesar do fato de que, devido às especificidades desse teste de carga específico, não podem ser armazenados mais que 6-8 valores nessa coleção. I.e. quase todas as operações em um determinado local são exclusivamente uma leitura dos valores existentes.

Mas espere, como? De fato, mesmo na documentação do método sobre bloqueio, é dito apenas no aplicativo de atualização:
"Se a chave especificada ainda não estiver associada a um valor, tentará calcular seu valor usando a função de mapeamento fornecida e inseri-lo nesse mapa, a menos que seja nulo. Como toda a invocação do método é realizada atomicamente, a função é aplicada no máximo uma vez por chave. Algumas tentativas de operações de atualização neste mapa por outros encadeamentos podem ser bloqueadas enquanto a computação está em andamento; portanto, a computação deve ser curta e simples e não deve tentar atualizar nenhum outro mapeamento deste mapa. ”

De fato, nem tudo é bem assim. Se você observar o código-fonte desse método, verifica-se que ele contém dois blocos de sincronização muito espessos:

Implementação do ConcurrentHashMap.computeIfAbsent
 public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { if (key == null || mappingFunction == null) throw new NullPointerException(); int h = spread(key.hashCode()); V val = null; int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { Node<K,V> r = new ReservationNode<K,V>(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K,V> node = null; try { if ((val = mappingFunction.apply(key)) != null) node = new Node<K,V>(h, key, val, null); } finally { setTabAt(tab, i, node); } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { boolean added = false; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; V ev; if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { val = e.val; break; } Node<K,V> pred = e; if ((e = e.next) == null) { if ((val = mappingFunction.apply(key)) != null) { added = true; pred.next = new Node<K,V>(h, key, val, null); } break; } } } else if (f instanceof TreeBin) { binCount = 2; TreeBin<K,V> t = (TreeBin<K,V>)f; TreeNode<K,V> r, p; if ((r = t.root) != null && (p = r.findTreeNode(h, key, null)) != null) val = p.val; else if ((val = mappingFunction.apply(key)) != null) { added = true; t.putTreeVal(h, key, val); } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (!added) return val; break; } } } if (val != null) addCount(1L, binCount); return val; } 


A partir do exemplo acima, pode-se ver que o resultado pode ser formado apenas em seis pontos, e quase todos esses locais estão dentro de blocos de sincronização. Inesperadamente. Além disso, um simples get não contém sincronização:

Implementação do ConcurrentHashMap.get
 public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; } 


Então o que fazer? De fato, existem apenas duas opções: retornar ao código original ou usá-lo, mas em uma versão ligeiramente modificada:

 String getOrPut(String key) { String result = map.get(key); return (result != null) ? result : map.computeIfAbsent(key, this::createValue); } 

Conclusão


Em geral, essas conseqüências fatais da refatoração aparentemente banal acabaram sendo muito inesperadas. A situação foi salva apenas pela presença de um teste de estresse, que revelou com êxito a degradação.

Felizmente, as versões mais recentes do Java corrigiram esse problema: JDK-8161372 .

Portanto, tenha cuidado, não confie nas dicas tentadoras e faça testes. Especialmente estressante.

Java para todos!

UPD1: conforme observado corretamente pelo vento frio , o problema é conhecido: JDK-8161372 . E, ao que parece, foi corrigido para o Java 9. Mas no momento da publicação do artigo em Java 8, Java 11 e até Java 13, esse método permaneceu inalterado.

UPD2: vkovalchuk me pegou no descuido. De fato, para Java 9 e mais recente, o problema é corrigido adicionando outra condição com o retorno do resultado sem bloquear:

  else if (fh == h // check first node without acquiring lock && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null) return fv; 


Inicialmente, encontrei uma situação na próxima versão do Java:

 versão openjdk "1.8.0_222"
 OpenJDK Runtime Environment (compilação 1.8.0_222-8u222-b10-1ubuntu1 ~ 18.04.1-b10)
 VM do servidor OpenJDK de 64 bits (compilação 25.222-b10, modo misto)


E quando eu olhei para as fontes de versões posteriores, sinceramente perdi essas linhas, o que me levou a se desviar.

Portanto, por uma questão de justiça, corrigi o texto principal do artigo.

Source: https://habr.com/ru/post/pt479778/


All Articles