Java.util.Hashtable类简介

2023/06/07

1. 概述

Hashtable是Java中最古老的哈希表数据结构实现。HashMap是JDK 1.2中引入的第二种实现。

这两个类都提供相似的功能,但也存在细微差别,我们将在本教程中探讨这些差别。

2. 何时使用Hashtable

假设我们有一本字典,其中每个单词都有其定义。此外,我们需要快速从字典中获取、插入和删除单词。

因此,Hashtable(或HashMap)是有意义的。单词将是Hashtable中的键,因为它们应该是唯一的。另一方面,定义将是值。

3. 使用示例

让我们继续字典示例。我们将Word建模为键:

public class Word {
    private String name;

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

假设值是String。现在我们可以创建一个哈希表:

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

首先,让我们添加一个条目:

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

另外,要获取条目:

String definition = table.get(word);

最后,让我们删除一个条目:

definition = table.remove(word);

类中还有很多方法,稍后我们将介绍其中的一些方法。

但首先,让我们谈谈对键对象的一些要求。

4. hashCode()的重要性

要用作Hashtable中的键,该对象不得违反hashCode()协定。简而言之,相等的对象必须返回相同的哈希码。为了理解为什么,让我们看看哈希表是如何组织的。

哈希表使用数组。数组中的每个位置都是一个“桶”,可以为空或包含一个或多个键值对。计算每对的索引。

但是为什么不按顺序存储元素,将新元素添加到数组的末尾呢?

关键是通过索引查找元素比通过比较顺序遍历元素要快得多。因此,我们需要一个将键映射到索引的函数。

4.1 直接地址表

这种映射最简单的例子是直接地址表。这里的键用作索引:

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

键是唯一的,即每个桶包含一对键值对。当整数键的可能范围相当小时,此技术适用于整数键。

但是我们这里有两个问题:

  • 首先,我们的键不是整数,而是Word对象
  • 其次,如果它们是整数,没有人会保证它们很小。想象一下键是1、2和1000000。我们将有一个大小为1000000的大数组,只有三个元素,其余的将是浪费的空间

hashCode()方法解决了第一个问题。

Hashtable中的数据操作逻辑解决了第二个问题。

让我们深入讨论一下。

4.2 hashCode()方法

任何Java对象都继承返回int值的hashCode()方法。该值是根据对象的内部内存地址计算得出的。默认情况下,hashCode()为不同的对象返回不同的整数。

因此,可以使用hashCode()将任何键对象转换为整数。但是这个整数可能很大。

4.3 缩小范围

get()、put()和remove()方法包含解决第二个问题的代码-减少可能的整数范围。

该公式计算键的索引:

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

其中tab.length是数组大小,hash是键的hashCode()方法返回的数字。

正如我们所看到的,索引是hash跟数组长度相除的余数。请注意,相同的哈希码会生成相同的索引。

4.4 碰撞

此外,即使是不同的哈希码也可以产生相同的索引,我们称之为碰撞。为了解决冲突,Hashtable存储了键值对的LinkedList。

这种数据结构称为带链表的哈希表。

4.5 负载因子

很容易猜到碰撞会减慢对元素的操作。要获得一个条目,仅仅知道它的索引是不够的,我们还需要遍历列表并对每个条目进行比较。

因此,减少碰撞次数非常重要。数组越大,发生碰撞的几率就越小。负载因子决定了数组大小和性能之间的平衡。默认情况下,它是0.75,这意味着当75%的存储桶变为非空时,数组大小会加倍。该操作由rehash()方法执行。

4.6 覆盖equals()和hashCode()

当我们将一个条目放入哈希表并从中取出时,我们希望该值不仅可以使用相同的键实例获得,还可以使用相等的键获得:

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

要设置相等规则,我们重写键的equals()方法

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);
}

但是,如果我们在重写equals()时不重写hashCode(),那么两个相等的键可能最终会出现在不同的桶中,因为Hashtable使用其哈希码计算键的索引。

让我们仔细看看上面的例子。如果我们不覆盖hashCode()会发生什么?

  • 这里涉及到Word的两个实例-第一个用于放置条目,第二个用于获取条目。虽然这些实例是相等的,但它们的hashCode()方法返回不同的数字
  • 每个键的索引由4.3节中的公式计算得出。根据这个公式,不同的哈希码可能会产生不同的索引
  • 这意味着我们将条目放入一个桶中,然后尝试将其从另一个桶中取出。这样的逻辑破坏了哈希表

相等的键必须返回相等的哈希码,这就是我们重写hashCode()方法的原因

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

请注意,还建议使不相等的键返回不同的哈希码,否则它们最终会在同一个桶中。这将影响性能,因此失去Hashtable的一些优势。

另外请注意,我们不关心String、Integer、Long或其他包装类型的键。equal()和hashCode()方法都已在包装类中被覆盖。

5. 迭代Hashtable

有几种方法可以迭代Hashtable。在本节中,我们将讨论它们并解释其中的一些含义。

5.1 快速失败:迭代

快速失败迭代意味着如果在创建Iterator之后修改Hashtable,则将抛出ConcurrentModificationException。让我们来演示一下。

首先,我们将创建一个哈希表并向其中添加条目:

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

其次,我们将创建一个迭代器:

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

第三,我们将修改哈希表:

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

现在,如果我们尝试遍历哈希表,我们将得到一个ConcurrentModificationException:

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

ConcurrentModificationException有助于发现错误,从而避免不可预知的行为,例如,当一个线程遍历哈希表时,另一个线程同时尝试修改它。

5.2 不快速失败:枚举

哈希表中的枚举不是快速失败的。让我们看一个例子。

首先,让我们创建一个哈希表并向其中添加条目:

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

其次,让我们创建一个Enumeration:

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

第三,让我们修改哈希表:

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

现在,如果我们遍历哈希表,它不会抛出异常:

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

5.3 不可预测的迭代顺序

另外请注意,哈希表中的迭代顺序是不可预测的,并且与添加条目的顺序不匹配。

这是可以理解的,因为它使用键的哈希码计算每个索引。此外,还会不时进行rehash,重新排列数据结构的顺序。

因此,让我们添加一些条目并检查输出:

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与HashMap

Hashtable和HashMap提供非常相似的功能。

它们都提供:

  • 快速失败迭代
  • 不可预测的迭代顺序

但也有一些区别:

  • HashMap不提供任何枚举,而Hashtable不提供快速失败枚举
  • Hashtable不允许空键和空值,而HashMap允许一个空键和任意数量的空值
  • Hashtable的方法是同步的,而HashMap的方法不是

7. Java 8中的Hashtable API

Java 8引入了有助于使我们的代码更简洁的新方法。特别是,我们可以摆脱一些if块。让我们来演示一下。

7.1 getOrDefault()

假设我们需要获取单词“dog”的定义并将其分配给变量(如果它在哈希表中)。否则,将“not found”分配给变量。

在Java 8之前:

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

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

Java 8之后:

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

7.2 putIfAbsent()

假设我们只需要在字典中没有“cat”这个单词的情况下放入它。

在Java 8之前:

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

Java 8之后:

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

7.3 remove()

假设我们需要删除“cat”这个词,但前提是它的定义是“an animal”。

在Java 8之前:

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

Java 8之后:

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

最后,旧的remove()方法返回值,而新方法返回boolean。

7.4 replace()

假设我们需要替换“cat”的定义,但前提是它的旧定义是“a small domesticated carnivorous mammal”。

在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);
}

Java 8之后:

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

7.5 computeIfAbsent()

此方法类似于putIfAbsent()。但是putIfAbsent()直接接收值,而computeIfAbsent()接收一个映射函数。它仅在检查键后才计算值,这样效率更高,尤其是在值难以获取的情况下。

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

因此,上面一行等同于:

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()

此方法类似于replace()方法。但是,replace()直接接收值,而computeIfPresent()采用映射函数。它计算if块内部的值,这就是它更高效的原因。

假设我们需要更改定义:

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

因此,上面的行等效于:

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

7.7 compute()

现在我们将解决另一个任务。假设我们有一个String数组,其中的元素不是唯一的。另外,让我们计算一下我们可以在数组中获得多少个字符串。这是数组:

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

此外,我们要创建一个哈希表,其中包含一个动物作为键,它的出现次数作为值。

这是一个解决方案:

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

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

最后,让我们确保哈希表包含两只猫、两只狗、一只鸟和两只老鼠:

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

7.8 merge()

还有另一种方法可以解决上述任务:

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

第二个参数1是映射到键的值(如果键尚未在哈希表中)。如果键已经在哈希表中,则我们将其计算为oldValue + 1。

7.9 foreach()

这是一种遍历条目的新方法。让我们打印所有条目:

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

7.10 replaceAll()

此外,我们可以在不迭代的情况下替换所有值:

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

8. 总结

在本文中,我们描述了哈希表结构的用途,并展示了如何将直接地址表结构复杂化以获得它。

此外,我们还介绍了哈希表中的碰撞以及加载因子是什么。此外,我们还了解了为什么要为键对象覆盖equals()和hashCode()。

最后,我们讨论了Hashtable的属性和Java 8特定的API。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章