O Java HashMap sob o capô
1. Visão geral
Neste artigo, vamos explorar a implementação mais popular da interfaceMap do Java Collections Framework em mais detalhes, continuando de onde nosso artigointro parou.
Antes de começarmos com a implementação, é importante ressaltar que as interfaces de coleção primáriasListeSet estendemCollection, masMap não.
Simplificando, oHashMap armazena valores por chave e fornece APIs para adicionar, recuperar e manipular dados armazenados de várias maneiras. A implementação ébased on the the principles of a hashtable, o que parece um pouco complexo no início, mas na verdade é muito fácil de entender.
Os pares de valores-chave são armazenados no que é conhecido como buckets, que juntos formam o que é chamado de tabela, que na verdade é uma matriz interna.
Assim que soubermos a chave sob a qual um objeto é armazenado ou deve ser armazenado,storage and retrieval operations occur in constant time,O(1) em um mapa hash bem dimensionado.
Para entender como os mapas hash funcionam nos bastidores, é necessário entender o mecanismo de armazenamento e recuperação empregado peloHashMap.. Vamos nos concentrar muito nisso.
Finalmente,HashMap related questions are quite common in interviews, então esta é uma maneira sólida de preparar uma entrevista ou se preparar para ela.
2. A APIput()
Para armazenar um valor em um mapa hash, chamamos a APIput, que usa dois parâmetros; uma chave e o valor correspondente:
V put(K key, V value);
Quando um valor é adicionado ao mapa em uma chave, a APIhashCode() do objeto da chave é chamada para recuperar o que é conhecido como valor de hash inicial.
Para ver isso em ação, vamos criar um objeto que atuará como uma chave. Criaremos apenas um único atributo para usar como um código de hash para simular a primeira fase do hash:
public class MyKey {
private int id;
@Override
public int hashCode() {
System.out.println("Calling hashCode()");
return id;
}
// constructor, setters and getters
}
Agora podemos usar esse objeto para mapear um valor no mapa de hash:
@Test
public void whenHashCodeIsCalledOnPut_thenCorrect() {
MyKey key = new MyKey(1);
Map map = new HashMap<>();
map.put(key, "val");
}
Nada de muito acontecendo no código acima, mas preste atenção à saída do console. Na verdade, o métodohashCode é invocado:
Calling hashCode()
Em seguida, a APIhash() do hash map é chamada internamente para calcular o valor hash final usando o valor hash inicial.
Esse valor final de hash se resume a um índice na matriz interna ou ao que chamamos de local do depósito.
A funçãohash deHashMap se parece com isto:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
O que devemos observar aqui é apenas o uso do código de hash do objeto-chave para calcular um valor final de hash.
Enquanto estiver dentro da funçãoput, o valor hash final é usado assim:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
Observe que uma funçãoputVal interna é chamada e recebe o valor hash final como o primeiro parâmetro.
Pode-se perguntar por que a chave é usada novamente dentro dessa função, pois já a usamos para calcular o valor do hash.
O motivo é quehash maps store both key and value in the bucket location as a Map.Entry object.
Conforme discutido antes, todas as interfaces de estrutura de coleções Java estendem a interfaceCollection, masMap não. Compare a declaração da interface do Mapa que vimos anteriormente com a da interfaceSet:
public interface Set extends Collection
O motivo é quemaps do not exactly store single elements as do other collections but rather a collection of key-value pairs.
Portanto, os métodos genéricos da interfaceCollection, comoadd,toArray, não fazem sentido quando se trata deMap.
O conceito que cobrimos nos últimos três parágrafos representa um dosmost popular Java Collections Framework interview questions. Então, vale a pena entender.
Um atributo especial com o mapa hash é que ele aceita valoresnull e chaves nulas:
@Test
public void givenNullKeyAndVal_whenAccepts_thenCorrect(){
Map map = new HashMap<>();
map.put(null, null);
}
When a null key is encountered during a put operation, it is automatically assigned a final hash value of 0, o que significa que se torna o primeiro elemento da matriz subjacente.
Isso também significa que, quando a chave é nula, não há operação de hash e, portanto, a APIhashCode da chave não é chamada, evitando, em última análise, uma exceção de ponteiro nulo.
Durante uma operaçãoput, quando usamos uma chave que já foi usada anteriormente para armazenar um valor, ela retorna o valor anterior associado à chave:
@Test
public void givenExistingKey_whenPutReturnsPrevValue_thenCorrect() {
Map map = new HashMap<>();
map.put("key1", "val1");
String rtnVal = map.put("key1", "val2");
assertEquals("val1", rtnVal);
}
caso contrário, retornanull:
@Test
public void givenNewKey_whenPutReturnsNull_thenCorrect() {
Map map = new HashMap<>();
String rtnVal = map.put("key1", "val1");
assertNull(rtnVal);
}
Quandoput retorna nulo, também pode significar que o valor anterior associado à chave é nulo, não necessariamente que seja um novo mapeamento de valor-chave:
@Test
public void givenNullVal_whenPutReturnsNull_thenCorrect() {
Map map = new HashMap<>();
String rtnVal = map.put("key1", null);
assertNull(rtnVal);
}
A APIcontainsKey pode ser usada para distinguir entre esses cenários, como veremos na próxima subseção.
3. A APIget
Para recuperar um objeto já armazenado no mapa de hash, precisamos conhecer a chave sob a qual ele foi armazenado. Chamamos a APIget e passamos a ela o objeto-chave:
@Test
public void whenGetWorks_thenCorrect() {
Map map = new HashMap<>();
map.put("key", "val");
String val = map.get("key");
assertEquals("val", val);
}
Internamente, o mesmo princípio de hash é usado. The hashCode() API do objeto-chave é chamada para obter o valor de hash inicial:
@Test
public void whenHashCodeIsCalledOnGet_thenCorrect() {
MyKey key = new MyKey(1);
Map map = new HashMap<>();
map.put(key, "val");
map.get(key);
}
Desta vez, a APIhashCode deMyKey é chamada duas vezes; uma vez parapute uma vez paraget:
Calling hashCode()
Calling hashCode()
Esse valor é então refeito chamando a APIhash() interna para obter o valor de hash final.
Como vimos na seção anterior, esse valor final de hash se resume a um local de bucket ou a um índice da matriz interna.
O objeto de valor armazenado nesse local é recuperado e retornado à função de chamada.
Quando o valor retornado é nulo, isso pode significar que o objeto-chave não está associado a nenhum valor no mapa de hash:
@Test
public void givenUnmappedKey_whenGetReturnsNull_thenCorrect() {
Map map = new HashMap<>();
String rtnVal = map.get("key1");
assertNull(rtnVal);
}
Ou pode simplesmente significar que a chave foi mapeada explicitamente para uma instância nula:
@Test
public void givenNullVal_whenRetrieves_thenCorrect() {
Map map = new HashMap<>();
map.put("key", null);
String val=map.get("key");
assertNull(val);
}
Para distinguir entre os dois cenários, podemos usar a APIcontainsKey, para a qual passamos a chave e ela retorna verdadeiro se e somente se um mapeamento foi criado para a chave especificada no mapa hash:
@Test
public void whenContainsDistinguishesNullValues_thenCorrect() {
Map map = new HashMap<>();
String val1 = map.get("key");
boolean valPresent = map.containsKey("key");
assertNull(val1);
assertFalse(valPresent);
map.put("key", null);
String val = map.get("key");
valPresent = map.containsKey("key");
assertNull(val);
assertTrue(valPresent);
}
Para ambos os casos no teste acima, o valor de retorno da chamada de APIget é nulo, mas podemos distinguir qual é qual.
4. Visualizações da coleção emHashMap
HashMap oferece três visualizações que nos permitem tratar suas chaves e valores como outra coleção. Podemos obter um conjunto de todos oskeys of the map:
@Test
public void givenHashMap_whenRetrievesKeyset_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
Set keys = map.keySet();
assertEquals(2, keys.size());
assertTrue(keys.contains("name"));
assertTrue(keys.contains("type"));
}
O conjunto é apoiado pelo próprio mapa. Portanto,any change made to the set is reflected in the map:
@Test
public void givenKeySet_whenChangeReflectsInMap_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
assertEquals(2, map.size());
Set keys = map.keySet();
keys.remove("name");
assertEquals(1, map.size());
}
Também podemos obter umcollection view of the values:
@Test
public void givenHashMap_whenRetrievesValues_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
Collection values = map.values();
assertEquals(2, values.size());
assertTrue(values.contains("example"));
assertTrue(values.contains("blog"));
}
Assim como o conjunto de chaves, qualquerchanges made in this collection will be reflected in the underlying map.
Finalmente, podemos obter umset view of all entries no mapa:
@Test
public void givenHashMap_whenRetrievesEntries_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
Set> entries = map.entrySet();
assertEquals(2, entries.size());
for (Entry e : entries) {
String key = e.getKey();
String val = e.getValue();
assertTrue(key.equals("name") || key.equals("type"));
assertTrue(val.equals("example") || val.equals("blog"));
}
}
Lembre-se de que um mapa hash contém especificamente elementos não ordenados, portanto, assumimos qualquer ordem ao testar as chaves e os valores das entradas no loopfor each.
Muitas vezes, você usará as visualizações de coleção em um loop como no último exemplo e, mais especificamente, usando seus iteradores.
Lembre-se de queiterators for all the above views are fail-fast.
Se qualquer modificação estrutural for feita no mapa, após a criação do iterador, uma exceção de modificação simultânea será lançada:
@Test(expected = ConcurrentModificationException.class)
public void givenIterator_whenFailsFastOnModification_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
Set keys = map.keySet();
Iterator it = keys.iterator();
map.remove("type");
while (it.hasNext()) {
String key = it.next();
}
}
A única operaçãoallowed structural modification is a remove realizada por meio do próprio iterador:
public void givenIterator_whenRemoveWorks_thenCorrect() {
Map map = new HashMap<>();
map.put("name", "example");
map.put("type", "blog");
Set keys = map.keySet();
Iterator it = keys.iterator();
while (it.hasNext()) {
it.next();
it.remove();
}
assertEquals(0, map.size());
}
A última coisa a lembrar sobre essas visualizações de coleção é o desempenho das iterações. É aqui que um mapa de hash apresenta um desempenho muito ruim em comparação com seus mapas de hash e mapa de árvore vinculados homólogos.
A iteração em um mapa hash acontece no pior casoO(n), onde n é a soma de sua capacidade e o número de entradas.
5. Desempenho de HashMap
O desempenho de um mapa hash é afetado por dois parâmetros:Initial CapacityeLoad Factor. A capacidade é o número de buckets ou o comprimento da matriz subjacente e a capacidade inicial é simplesmente a capacidade durante a criação.
O fator de carga ou LF, em suma, é uma medida de quão cheio o mapa de hash deve estar após adicionar alguns valores antes de ser redimensionado.
A capacidade inicial padrão é16e o fator de carga padrão é0.75. Podemos criar um mapa de hash com valores personalizados para capacidade inicial e LF:
Map hashMapWithCapacity=new HashMap<>(32);
Map hashMapWithCapacityAndLF=new HashMap<>(32, 0.5f);
Os valores padrão definidos pela equipe Java são bem otimizados para a maioria dos casos. No entanto, se você precisar usar seus próprios valores, o que é muito bom, você precisará entender as implicações de desempenho para saber o que está fazendo.
Quando o número de entradas do mapa hash excede o produto de LF e capacidade, entãorehashing ocorre, ou seja, another internal array is created with twice the size of the initial one and all entries are moved over to new bucket locations in the new array.
Alow initial capacity reduz o custo de espaço, masincreases the frequency of rehashing. Rehashing é obviamente um processo muito caro. Portanto, como regra geral, se você antecipar muitas entradas, defina uma capacidade inicial consideravelmente alta.
Por outro lado, se você definir a capacidade inicial muito alta, pagará o custo no tempo de iteração. Como vimos na seção anterior.
Portanto,a high initial capacity is good for a large number of entries coupled with little to no iteration.
Alow initial capacity is good for few entries with a lot of iteration.
6. Colisões emHashMap
Uma colisão, ou mais especificamente, uma colisão de código hash emHashMap, é uma situação em quetwo or more key objects produce the same final hash valuee aponta para o mesmo local de depósito ou índice de matriz.
Este cenário pode ocorrer porque de acordo com o contratoequalsehashCode,two unequal objects in Java can have the same hash code.
Isso também pode acontecer devido ao tamanho finito da matriz subjacente, ou seja, antes do redimensionamento. Quanto menor essa matriz, maiores as chances de colisão.
Dito isso, vale a pena mencionar que Java implementa uma técnica de resolução de colisão de código hash que veremos usando um exemplo.
Lembre-se de que é o valor hash da chave que determina o intervalo em que o objeto será armazenado. E assim, se os códigos hash de quaisquer duas chaves colidirem, suas entradas ainda serão armazenadas no mesmo depósito.
E, por padrão, a implementação usa uma lista vinculada como a implementação do bucket.
O tempo inicialmente constanteO(1)puteget operações ocorrerão em tempo linearO(n) no caso de uma colisão. Isso ocorre porque, após encontrar o local do depósito com o valor hash final, cada uma das chaves neste local será comparada com o objeto de chave fornecido usando a APIequals.
Para simular essa técnica de resolução de colisão, vamos modificar um pouco nosso objeto-chave anterior:
public class MyKey {
private String name;
private int id;
public MyKey(int id, String name) {
this.id = id;
this.name = name;
}
// standard getters and setters
@Override
public int hashCode() {
System.out.println("Calling hashCode()");
return id;
}
// toString override for pretty logging
@Override
public boolean equals(Object obj) {
System.out.println("Calling equals() for key: " + obj);
// generated implementation
}
}
Observe como estamos simplesmente retornando o atributoid como o código hash - e assim forçamos a ocorrência de uma colisão.
Além disso, observe que adicionamos instruções de log em nossas implementaçõesequalsehashCode - para que possamos saber exatamente quando a lógica é chamada.
Vamos agora armazenar e recuperar alguns objetos que colidem em algum ponto:
@Test
public void whenCallsEqualsOnCollision_thenCorrect() {
HashMap map = new HashMap<>();
MyKey k1 = new MyKey(1, "firstKey");
MyKey k2 = new MyKey(2, "secondKey");
MyKey k3 = new MyKey(2, "thirdKey");
System.out.println("storing value for k1");
map.put(k1, "firstValue");
System.out.println("storing value for k2");
map.put(k2, "secondValue");
System.out.println("storing value for k3");
map.put(k3, "thirdValue");
System.out.println("retrieving value for k1");
String v1 = map.get(k1);
System.out.println("retrieving value for k2");
String v2 = map.get(k2);
System.out.println("retrieving value for k3");
String v3 = map.get(k3);
assertEquals("firstValue", v1);
assertEquals("secondValue", v2);
assertEquals("thirdValue", v3);
}
No teste acima, criamos três chaves diferentes - uma tem umid exclusivo e as outras duas têm o mesmoid. Como usamosid como o valor de hash inicial,there will definitely be a collision durante o armazenamento e a recuperação de dados com essas chaves.
Além disso, graças à técnica de resolução de colisões que vimos anteriormente, esperamos que cada um dos nossos valores armazenados sejam recuperados corretamente, daí as asserções nas últimas três linhas.
Quando executamos o teste, ele deve passar, indicando que as colisões foram resolvidas e usaremos o log produzido para confirmar que as colisões realmente ocorreram:
storing value for k1
Calling hashCode()
storing value for k2
Calling hashCode()
storing value for k3
Calling hashCode()
Calling equals() for key: MyKey [name=secondKey, id=2]
retrieving value for k1
Calling hashCode()
retrieving value for k2
Calling hashCode()
retrieving value for k3
Calling hashCode()
Calling equals() for key: MyKey [name=secondKey, id=2]
Observe que durante as operações de armazenamento,k1 ek2 foram mapeados com êxito para seus valores usando apenas o código hash.
No entanto, o armazenamento dek3 não era tão simples, o sistema detectou que sua localização de bucket já continha um mapeamento parak2. Portanto, a comparaçãoequals foi usada para distingui-los e uma lista vinculada foi criada para conter os dois mapeamentos.
Qualquer outro mapeamento subsequente cujos hashes de chave para o mesmo local de depósito seguirá a mesma rota e acabará substituindo um dos nós na lista vinculada ou será adicionado ao topo da lista se a comparaçãoequals retornar falso para todos nós.
Da mesma forma, durante a recuperação,k3 ek2 foramequals comparados para identificar a chave correta cujo valor deve ser recuperado.
Em uma nota final, do Java 8, as listas vinculadas são substituídas dinamicamente por árvores de pesquisa binária equilibrada na resolução de colisões após o número de colisões em um determinado local do bucket exceder um determinado limite.
Esta mudança oferece um aumento de desempenho, uma vez que, em caso de colisão, o armazenamento e a recuperação acontecem emO(log n).
Esta seção évery common in technical interviews,, especialmente após as questões básicas de armazenamento e recuperação.
7. Conclusão
Neste artigo, exploramos a implementaçãoHashMap da interface JavaMap.
O código-fonte completo para todos os exemplos usados neste artigo pode ser encontrado emGitHub project.