Uma introdução à classe Java.util.Hashtable

Uma introdução à classe Java.util.Hashtable

1. Visão geral

*https://docs.oracle.com/javase/8/docs/api/java/util/Hashtable.html [_Hashtable_] é a implementação mais antiga de uma estrutura de dados de tabela de hash em Java.* O _HashMap_ é a segunda implementação, que foi introduzido no JDK 1.2.

Ambas as classes fornecem funcionalidade semelhante, mas também existem pequenas diferenças, que exploraremos neste tutorial.

*2. Quando usar Hashtable *

Digamos que temos um dicionário, onde cada palavra tem sua definição. Além disso, precisamos obter, inserir e remover palavras do dicionário rapidamente.

Portanto, Hashtable (ou HashMap) faz sentido. As palavras serão as chaves no Hashtable, pois devem ser únicas. As definições, por outro lado, serão os valores.

===* 3. Exemplo de uso *

Vamos continuar com o exemplo do dicionário. Modelaremos Word como uma chave:

public class Word {
    private String name;

    public Word(String name) {
        this.name = name;
    }

   //...
}

Digamos que os valores sejam Strings. Agora podemos criar um Hashtable:

Hashtable<Word, String> table = new Hashtable<>();

Primeiro, vamos adicionar uma entrada:

Word word = new Word("cat");
table.put(word, "an animal");

Além disso, para obter uma entrada:

String definition = table.get(word);

Por fim, vamos remover uma entrada:

definition = table.remove(word);

Existem muitos outros métodos na classe, e descreveremos alguns deles mais tarde.

Mas primeiro, vamos falar sobre alguns requisitos para o objeto principal.

===* 4. A importância de _hashCode () _ *

*Para ser usado como chave em um _Hashtable_, o objeto não deve violar o link:/java-hashcode [_hashCode () _ contract.]* Em resumo, objetos iguais devem retornar o mesmo código. Para entender por que vamos ver como a tabela de hash está organizada.

Hashtable usa uma matriz. Cada posição na matriz é um "bloco" que pode ser nulo ou conter um ou mais pares de valores-chave. O índice de cada par é calculado.

Mas por que não armazenar elementos seqüencialmente, adicionando novos elementos ao final da matriz?

O ponto é que encontrar um elemento pelo índice é muito mais rápido do que iterar pelos elementos com a comparação sequencialmente. Portanto, precisamos de uma função que mapeie chaves para índices.

4.1 Tabela de endereços diretos

O exemplo mais simples desse mapeamento é a tabela de endereço direto. Aqui, as chaves são usadas como índices:

index(k)=k,
where k is a key

As chaves são únicas, ou seja, cada bloco contém um par de valores-chave. Essa técnica funciona bem para chaves inteiras quando o intervalo possível delas é razoavelmente pequeno.

Mas temos dois problemas aqui:

  • Primeiro, nossas chaves não são números inteiros, mas objetos Word *Segundo, se fossem números inteiros, ninguém garantiria que eram pequenos. Imagine que as chaves são 1, 2 e 1000000. Teremos uma grande variedade de tamanho 1000000 com apenas três elementos, e o restante será um espaço desperdiçado

O método _hashCode () _ resolve o primeiro problema.

A lógica para manipulação de dados no Hashtable resolve o segundo problema.

Vamos discutir isso em profundidade.

====* 4.2 _hashCode () _ Método *

Qualquer objeto Java herda o método hashCode () _ que retorna um valor _int. Este valor é calculado a partir do endereço de memória interna do objeto. Por padrão, _hashCode () _ retorna números inteiros distintos para objetos distintos.

Portanto,* qualquer objeto-chave pode ser convertido em um número inteiro usando _hashCode () _ *. Mas esse número inteiro pode ser grande.

4.3 Reduzindo o alcance

Os métodos _get () _, _put () _ e _remove () _ contêm o código que resolve o segundo problema - reduzindo o intervalo de possíveis números inteiros.

A fórmula calcula um índice para a chave:

int index = (hash & 0x7FFFFFFF) % tab.length;

Onde tab.length é o tamanho da matriz e hash é um número retornado pelo método _hashCode () _ da chave.

Como podemos ver index é um lembrete da divisão hash pelo tamanho da matriz . Observe que códigos de hash iguais produzem o mesmo índice.

4.4 Colisões

Além disso, mesmo códigos de hash diferentes podem produzir o mesmo índice . Nós nos referimos a isso como uma colisão. Para resolver colisões, o Hashtable armazena um LinkedList de pares de valor-chave.

Essa estrutura de dados é chamada de tabela de hash com encadeamento.

4.5 Fator de carga

É fácil adivinhar que as colisões diminuem a velocidade das operações com elementos. Para obter uma entrada, não basta conhecer seu índice, mas precisamos percorrer a lista e fazer uma comparação com cada item.

Portanto, é importante reduzir o número de colisões. Quanto maior é uma matriz, menor é a chance de uma colisão. O fator de carga determina o equilíbrio entre o tamanho da matriz e o desempenho. Por padrão, é 0,75, o que significa que o tamanho da matriz dobra quando 75% dos buckets ficam vazios. Esta operação é executada pelo método _rehash () _.

Mas vamos voltar para as chaves.

4.6 Substituindo equals () e hashCode ()

Quando colocamos uma entrada em uma Hashtable e a tiramos dela, esperamos que o valor possa ser obtido não apenas com a mesma instância da chave, mas também com uma chave igual:

Word word = new Word("cat");
table.put(word, "an animal");
String extracted = table.get(new Word("cat"));
*Para definir as regras de igualdade, substituímos o método _equals () _ da chave:*
public boolean equals(Object o) {
    if (o == this)
        return true;
    if (!(o instanceof Word))
        return false;

    Word word = (Word) o;
    return word.getName().equals(this.name);
}

Mas se não substituirmos hashCode () _ ao substituir _equals () _, duas chaves iguais podem acabar nos diferentes intervalos porque _Hashtable calcula o índice da chave usando seu código de hash.

Vamos dar uma olhada no exemplo acima. O que acontece se não substituirmos _hashCode () _?

  • Duas instâncias de Word estão envolvidas aqui - a primeira é para colocar a entrada e a segunda é para obter a entrada. Embora essas instâncias sejam iguais, seu método _hashCode () _ retorna números diferentes

  • O índice para cada chave é calculado pela fórmula da seção 4.3. De acordo com esta fórmula, códigos de hash diferentes podem produzir índices diferentes *Isso significa que colocamos a entrada em um balde e tentamos retirá-lo do outro balde. Essa lógica quebra Hashtable

As chaves iguais devem retornar códigos de hash iguais, por isso substituímos o método _hashCode () _:

public int hashCode() {
    return name.hashCode();
}

Observe que* também é recomendável fazer com que chaves não iguais retornem códigos de hash diferentes *, caso contrário, elas terminam no mesmo bloco. Isso afetará o desempenho, portanto, perdendo algumas das vantagens de um Hashtable.

Além disso, observe que não nos importamos com as chaves de String, Integer, Long ou outro tipo de wrapper. Os métodos _equal () _ e _hashCode () _ já foram substituídos nas classes de wrapper.

*5. Iterando Hashtables *

Existem algumas maneiras de iterar Hashtables. Nesta seção, fale bem sobre eles e explique algumas das implicações.

====* 5.1. Fail Fast: Iteration *

A iteração com falha rápida significa que se um Hashtable for modificado depois que seu Iterator for criado, a ConcurrentModificationException será lançada. Vamos demonstrar isso.

Primeiro, criaremos um Hashtable e adicionaremos entradas a ele:

Hashtable<Word, String> table = new Hashtable<Word, String>();
table.put(new Word("cat"), "an animal");
table.put(new Word("dog"), "another animal");

Segundo, criaremos um Iterator:

Iterator<Word> it = table.keySet().iterator();

E terceiro, modificaremos a tabela:

table.remove(new Word("dog"));

Agora, se tentarmos percorrer a tabela, obteremos uma ConcurrentModificationException:

while (it.hasNext()) {
    Word key = it.next();
}
java.util.ConcurrentModificationException
    at java.util.Hashtable$Enumerator.next(Hashtable.java:1378)

ConcurrentModificationException ajuda a encontrar erros e, assim, evita comportamentos imprevisíveis, quando, por exemplo, um thread está iterando pela tabela e outro está tentando modificá-lo ao mesmo tempo.

====* 5.2 Não falha rapidamente: Enumeração *

Enumeração em um Hashtable não é à prova de falhas. Vejamos um exemplo.

Primeiro, vamos criar um Hashtable e adicionar entradas a ele:

Hashtable<Word, String> table = new Hashtable<Word, String>();
table.put(new Word("1"), "one");
table.put(new Word("2"), "two");

Segundo, vamos criar uma Enumeração:

Enumeration<Word> enumKey = table.keys();

Terceiro, vamos modificar a tabela:

table.remove(new Word("1"));

Agora, se percorrermos a tabela, ela não lançará uma exceção:

while (enumKey.hasMoreElements()) {
    Word key = enumKey.nextElement();
}

====* 5.3. Ordem de iteração imprevisível *

Além disso, observe que a ordem de iteração em um Hashtable é imprevisível e não corresponde à ordem em que as entradas foram adicionadas.

Isso é compreensível, pois calcula cada índice usando o código de hash da chave. Além disso, a reformulação ocorre de tempos em tempos, reorganizando a ordem da estrutura de dados.

Portanto, vamos adicionar algumas entradas e verificar a saída:

Hashtable<Word, String> table = new Hashtable<Word, String>();
    table.put(new Word("1"), "one");
    table.put(new Word("2"), "two");
   //...
    table.put(new Word("8"), "eight");

    Iterator<Map.Entry<Word, String>> it = table.entrySet().iterator();
    while (it.hasNext()) {
        Map.Entry<Word, String> entry = it.next();
       //...
    }
}
five
four
three
two
one
eight
seven

===* 6. Hashtable vs. HashMap *

Hashtable e HashMap fornecem funcionalidade muito semelhante.

Ambos fornecem:

  • Iteração rápida

  • Ordem de iteração imprevisível

Mas também existem algumas diferenças:

  • HashMap não fornece nenhuma Enumeração, enquanto Hashtable fornece Enumeration não rápido

  • Hashtable não permite chaves null e valores null, enquanto HashMap permite uma chave null e qualquer número de valores null *Os métodos de _Hashtable_s são sincronizados, enquanto os métodos de _HashMaps_s não são

===* 7. API Hashtable em Java 8 *

O Java 8 introduziu novos métodos que ajudam a tornar nosso código mais limpo. Em particular, podemos nos livrar de alguns blocos if. Vamos demonstrar isso.

====* 7.1 _getOrDefault () _ *

Digamos que precisamos obter a definição da palavra "dog" e atribuí-la à variável, se ela estiver sobre a mesa. Caso contrário, atribua "não encontrado" à variável.

Antes do Java 8:

Word key = new Word("dog");
String definition;

if (table.containsKey(key)) {
     definition = table.get(key);
} else {
     definition = "not found";
}

Após o Java 8:

definition = table.getOrDefault(key, "not found");

====* 7.2 _putIfAbsent () _ *

Digamos que precisamos colocar uma palavra "cat " somente se ela ainda não estiver no dicionário.

Antes do Java 8:

if (!table.containsKey(new Word("cat"))) {
    table.put(new Word("cat"), definition);
}

Após o Java 8:

table.putIfAbsent(new Word("cat"), definition);

====* 7.3. _boolean remove () _ *

Digamos que precisamos remover a palavra "gato", mas apenas se a definição for "um animal".

Antes do Java 8:

if (table.get(new Word("cat")).equals("an animal")) {
    table.remove(new Word("cat"));
}

Após o Java 8:

boolean result = table.remove(new Word("cat"), "an animal");

Finalmente, enquanto o antigo método remove () _ retorna o valor, o novo método retorna _boolean.

====* 7.4. substituir() *

Digamos que precisamos substituir uma definição de "gato", mas apenas se sua definição antiga for "um pequeno mamífero carnívoro domesticado".

Antes do Java 8:

if (table.containsKey(new Word("cat"))
    && table.get(new Word("cat")).equals("a small domesticated carnivorous mammal")) {
    table.put(new Word("cat"), definition);
}

Após o Java 8:

table.replace(new Word("cat"), "a small domesticated carnivorous mammal", definition);

====* 7,5. _computeIfAbsent () _ *

Este método é semelhante a _putIfabsent () _. Mas _putIfabsent () _ assume o valor diretamente e _computeIfAbsent () _ assume uma função de mapeamento. Ele calcula o valor somente após verificar a chave, e isso é mais eficiente, especialmente se o valor for difícil de obter.

table.computeIfAbsent(new Word("cat"), key -> "an animal");

Portanto, a linha acima é equivalente a:

if (!table.containsKey(cat)) {
    String definition = "an animal";//note that calculations take place inside if block
    table.put(new Word("cat"), definition);
}

====* 7.6. _computeIfPresent () _ *

Este método é semelhante ao método replace () _. Mas, novamente, _replace () _ assume o valor diretamente e _computeIfPresent () _ assume uma função de mapeamento. Ele calcula o valor dentro do bloco _if, por isso é mais eficiente.

Digamos que precisamos alterar a definição:

table.computeIfPresent(cat, (key, value) -> key.getName() + " - " + value);

Portanto, a linha acima é equivalente a:

if (table.containsKey(cat)) {
    String concatination=cat.getName() + " - " + table.get(cat);
    table.put(cat, concatination);
}

====* 7.7 calcular() *

Agora vamos resolver outra tarefa. Digamos que temos uma matriz de String, onde os elementos não são exclusivos. Além disso, vamos calcular quantas ocorrências de uma String podemos obter na matriz. Aqui está a matriz:

String[] animals = { "cat", "dog", "dog", "cat", "bird", "mouse", "mouse" };

Além disso, queremos criar um Hashtable que contenha um animal como chave e o número de ocorrências como valor.

Aqui está uma solução:

Hashtable<String, Integer> table = new Hashtable<String, Integer>();

for (String animal : animals) {
    table.compute(animal,
        (key, value) -> (value == null ? 1 : value + 1));
}

Por fim, certifique-se de que a tabela contenha dois gatos, dois cães, um pássaro e dois mouses:

assertThat(table.values(), hasItems(2, 2, 2, 1));

====* 7.8. _merge () _ *

Há outra maneira de resolver a tarefa acima:

for (String animal : animals) {
    table.merge(animal, 1, (oldValue, value) -> (oldValue + value));
}

O segundo argumento, 1, é o valor que é mapeado para a chave se a chave ainda não estiver na tabela. Se a chave já estiver na tabela, calculamos como oldValue + 1.

====* 7,9. para cada() *

Essa é uma nova maneira de percorrer as entradas. Vamos imprimir todas as entradas:

table.forEach((k, v) -> System.out.println(k.getName() + " - " + v)

====* 7.10 substitua tudo() *

Além disso, podemos substituir todos os valores sem iteração:

table.replaceAll((k, v) -> k.getName() + " - " + v);

===* 8. Conclusão*

Neste artigo, descrevemos o objetivo da estrutura da tabela de hash e mostramos como complicar uma estrutura de tabela de endereço direto para obtê-lo.

Além disso, abordamos o que são colisões e o que é um fator de carga em um Hashtable. Além disso, aprendemos por que substituir _equals () _ e _hashCode () _ para objetos principais.

Por fim, falamos sobre as propriedades de _Hashtable_s e a API específica do Java 8.

Como sempre, o código fonte completo está disponível no Github.