从源码角度简析 Hashtable、HashMap 和 LinkedHashMap

注意:此文原文均摘自 Sun jdk

Hashtable 与 HashMap

不同点

先看类的定义——

这里写图片描述

这里写图片描述

除了接口的实现是相同的,我们可以看到继承的类是不同的,我们不妨打开 Dictionary 抽象类看一下

这里写图片描述

我们可以看到红色箭头指向的地方,大致翻译一下就是 —— 注意:这个类已经过时了,新的实现应该去实现 Map 接口,而不是继承这个类。所以事实上继承或不继承这个类并没有多大影响,Hashtable 实现了 Map 接口 ——

这里写图片描述

这里写图片描述

我们可以看到,Dictionary 类中的方法在 Map 接口中是有相同意义的方法的。接下来就是 HashMap 的父类 AbstractMap 抽象类 ——

这里写图片描述

翻译过来就是:

此类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作。

要实现不可修改的映射,编程人员只需扩展此类并提供 `entrySet` 方法的实现即可,该方法将返回映射的映射关系 set 视图。通常,返回的 set 将依次在 `AbstractSet` 上实现。此 set 不支持 `add` 或 `remove` 方法,其迭代器也不支持 `remove` 方法。

要实现可修改的映射,编程人员必须另外重写此类的 `put` 方法(否则将抛出 `UnsupportedOperationException`),`entrySet().iterator()` 返回的迭代器也必须另外实现其 `remove` 方法。

按照 `Map` 接口规范中的建议,编程人员通常应该提供一个 void(无参数)构造方法和 map 构造方法。

此类中每个非抽象方法的文档详细描述了其实现。如果要实现的映射允许更有效的实现,则可以重写所有这些方法。

此类是 Java Collections Framework 的成员。

所以 AbstractMap 就是实现了一些 Map 接口的方法,方便子类复用。

所以说,HashtableHashMap 在类结构上是基本没有任何差异的,那么具体的实现呢?
其一:Hashtable 的键值都不可为空,而 HashMap 键值对皆可为空 ——

这里写图片描述

这里写图片描述

其二:Hashtable 相比于 HashMap 线程更安全,因为它所有的方法都添加了 synchronized 关键字(这里笔者想提到一点就是,线程安全并不意味着在高并发的情况下就能够得到正确的结果,毕竟它只是能保证任一时刻只有一个线程访问而不是保证线程访问的顺序)。这里笔者就不截图了,大家可以戳一下 Hashtable 源码查看一下。

其三:初始容量不同,假设我们并未在初始化 HashMapHashtable 指定自定义容量,那么它们的初始化容量是多少呢?

这里写图片描述

这里写图片描述

Hashtable 初始化容量为11,而 HashMap 初始化容量为16(虽然 HashMap 无参构造函数中并未明显显示出来,但是注释中已经透露)。

其四:扩容机制不同——这里所指的扩容机制不同是指其扩容后的大小与原大小的比例不同,但是它们的触发条件都是一样的,当当前元素个数超过原大小的0.75倍时,将会扩容当前数组大小,源码笔者在这里就不展示了,我们可以通过以下 demo 来看到扩容效果 ——

HashMap 部分:

public static void main(String[] args) {
    Map<String, String> hashMap = new HashMap<>();
    Class<? extends Map> hashMapClass = hashMap.getClass();
    try {
        Field count = hashMapClass.getDeclaredField("table");
        count.setAccessible(true);
        hashMap.put("1", "1");
        hashMap.put("2", "1");
        hashMap.put("3", "1");
        hashMap.put("4", "1");
        hashMap.put("5", "1");
        hashMap.put("6", "1");
        hashMap.put("7", "1");
        hashMap.put("8", "1");
        hashMap.put("9", "1");
        hashMap.put("10", "1");
        hashMap.put("11", "1");
        hashMap.put("12", "1");
        System.out.println(((Object[]) count.get(hashMap)).length);
        hashMap.put("13", "1");
        System.out.println(((Object[]) count.get(hashMap)).length);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}

输出:
>> 16
>> 32

Hashtable 部分:

public static void main(String[] args) {
    Map<String, String> hashTable = new Hashtable<>();
    Class<? extends Map> hashTableClass = hashTable.getClass();
    try {
        Field count = hashTableClass.getDeclaredField("table");
        count.setAccessible(true);
        hashTable.put("1", "1");
        hashTable.put("2", "1");
        hashTable.put("3", "1");
        hashTable.put("4", "1");
        hashTable.put("5", "1");
        hashTable.put("6", "1");
        hashTable.put("7", "1");
        hashTable.put("8", "1");
        System.out.println(((Object[]) count.get(hashTable)).length);
        hashTable.put("9", "1");
        System.out.println(((Object[]) count.get(hashTable)).length);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}

输出:
>> 11
>> 23

其五:索引算法不同。我们知道,对于基于 Hash 算法的数据结构,索引算法是一道关键点,采用好的索引算法不仅能够快速计算出索引位置,而且能够避免 Hash 冲突——

HashMap 部分首先是将 key 的 hash 值进行再 hash ——

int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

接着,其索引算法基于上述 hash 值 index = (n - 1) & hash

Hashtabl 部分是 (key.hashCode & 0x7FFFFFFF) % tab.length

相同点

数据结构

解决完不同点,我们来看看相同点,共同实现的几个接口就没有什么好介绍的了,我们来看看它们的数据结构,HashMapHashtable 都是基于哈希表实现的,那么什么是哈希表?

这里写图片描述
这里写图片描述

这里写图片描述
这里写图片描述

打开 HashMapHashtable 类我们都能看到一个数组,这两个数组的实质是一样的,画一张图会更清晰 ——

这里写图片描述

HashMapHashtable 就是基于这样的数组实现的。数组中的每个值实际上都是一个单链表,这个链表中的每个元素的 hash 值是相同的!hash 值也就对应数组中的索引!新进入的结点会放在表头的位置!

这样说可能还有点抽象,我们来举个例子,假如我们 HashMapHashtable 的 hash 值计算方法就是元素的 key 对数组长度求余(事实上肯定不是这样的),即 hash == key % arrays.length(),数组初始长度为5,现在我们需要插入5个键值对,代码如下:

Map<Integer, String> hashtable = new Hashtable<Integer, String>();
hashtable.put(1, "张");
hashtable.put(2, "李");
hashtable.put(3, "王");
hashtable.put(4, "刘");
hashtable.put(5, "赵");

那么 hashtable 会怎么做呢?我们不妨查看一下 Hashtableput() 方法 ——

这里写图片描述
这里写图片描述
这里写图片描述

put() 方法有两个英文注释已经解释得很清楚了,第一步是确保 value 不为空,否则抛出空指针异常;第二步是假设这个 key 已经存在 hashtable 中了,那么就替换原来的 value;第三步就是 value 不为空,并且是一个新的 key,那么就添加到 hashtable 中我们可以看到 // Creates the new entry 下面的那四行代码,可以看出来,先将数组索引位置的 entry 赋给了一个新的值,然后又创建一个指向该值的新的 entry,故新值就是新的表头了。

对于键值对 (1, "张)",我们求出该键值对的 hash 值等于 1 % 5 等于4,同理 (2, "李")(3, "王")(4, "刘")(5, "赵)" 的 hash 值分别是3,2,1,0,它们经过的都是上述的第三步,最后得到的 hash 表如下 ——

这里写图片描述

那现在加入我们再插入一个 (6, "周") 的键值对会怎样呢?老规矩——先计算 hash 值,发现 hash 值为1,于是对数组索引为1的那个单链表进行遍历,但是发现没有 key 相等的键值对,于是也是进入第三步,插在了包含键值对为 (4, "刘") 的那条单链表的头部,如下图 ——

这里写图片描述

HashMapHashtableput() 方法同理,此处就不再扩展了。

索引算法

此处不做扩展,可见hashmap和hash算法研究

LinkedHashMap

首先查看类的定义 ——

这里写图片描述

LinkedHashMap 是继承自 HashMap 的。我们再看看官方文档的描述 ——

这里写图片描述
这里写图片描述

注意:以上截图只是节选部分重要文档。

大致翻译如下:

`Map` 接口的哈希表和链接列表实现,具有可预知的迭代顺序。此实现与 `HashMap` 的不同之处在于,后者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是将键插入到映射中的顺序(插入顺序)。注意,如果在映射中重新插入 键,则插入顺序不受影响。(如果在调用 `m.put(k, v)` 前 `m.containsKey(k)` 返回了 true,则调用时会将键 k 重新插入到映射 m 中。)
提供特殊的构造方法来创建链接哈希映射,该哈希映射的迭代顺序就是最后访问其条目的顺序,从近期访问最少到近期访问最多的顺序(访问顺序)。这种映射很适合构建 LRU 缓存。调用 `put` 或 `get` 方法将会访问相应的条目(假定调用完成后它还存在)。`putAll` 方法以指定映射的条目集迭代器提供的键-值映射关系的顺序,为指定映射的每个映射关系生成一个条目访问。任何其他方法均不生成条目访问。特别是,collection 视图上的操作不影响底层映射的迭代顺序。
可以重写 `removeEldestEntry(Map.Entry)` 方法来实施策略,以便在将新映射关系添加到映射时自动移除旧的映射关系。

从以上文字我们节选出以下几条信息:

  • LinkedHashMap 内部维护的是双链表,且此链表定义了插入的顺序
  • LinkedHashMap 通过特殊的构造方法,可以将输出的值从按照插入的顺序改成符合 LRU 的顺序

LinkedHashMap 内部维护的是双链表,且此链表定义了插入的顺序

打开 LinkedHashMap 类,我们查看它的结点类型,如图——

这里写图片描述

我们可以看到,LinkedHashMap 的结点是继承自 HashMap 结点的,我们再看下 HashMap 的结点 ——

这里写图片描述

所以对于 LinkedHashMap 的结点来说,它是有三个指针的,而 LinkedHashMap 又没有复写父类的 put() 方法,所以说,相比于 HashMap 来说,LinkedHashMap 就是元素多了两个指针,分别指向插入时前一个元素,和插入时后一个元素。当然,口说无凭,我们继续查看 LinkedHashMap 类,类中有一个方法的名字看起来就很特别 ——

这里写图片描述

源代码很简单我此处就不做扩展了。那么何时会调用该方法呢?其实解决这个问题很简单,自己对对象进行 debug 就可以了 ——

这里写图片描述

此处为了方便我是对第二个进行 put 的元素进行 debug 查看流程,接下来:

这里写图片描述

进入父类的 put() 方法,再进入 putVal() 方法,再第二个 if 分支中进入 newNode() 方法中 ——

这里写图片描述

关键时刻到了,此时由于 LinkedHashMap 重写了父类 newNode() 方法,所以调用的就是 LinkedHashMapnewNode() 方法,也就是 java 中的多态。看到这里也就不向下扩展了,我们可以看到第四行就是我们想要的答案 —— 调用了 linkNodeLast() 方法!有一句话叫做“一图胜千言”,抽象理解完了 LinkedHashMap 的数据结构,我再上一张图来加深印象,上图之前也要配合代码,代码如下 ——

Map<Integer, String> map = new LinkedHashMap<Integer, String>();
map.put(1, "张");
map.put(2, "李");
map.put(3, "王");
map.put(4, "刘");
map.put(5, "赵");

这里依然为了简单起见,我们假设 hashCode() 方法所求的 hash 值就是 key 对数组的长度求余,也就是 keyhash == key % arrays.length()。数据结构图如下 ——

这里写图片描述

如果在此之后再插入一个键值对(6, 周) 又会怎样呢?

这里写图片描述

比起插入前,有以下几点更改 ——

  • 第二个双向链表的表头变成了 (6, 周) 这个键值对
  • tail 尾结点指针指向了 (6, 周)
  • 原尾结点 (5, 赵) 的 after 指针从指向 null 变成了指向 (6, 周)

HashMap 通过特殊的构造方法,可以将输出的值从按照插入的顺序改成符合 LRU 的顺序

这里写图片描述

LinkedHashMap 共有五个构造函数,那么上述中特殊的构造函数到底是指哪一个呢?答案就是最后一个,看构造函数上方的注释,对于参数 accessOrder 的解释如下:

the ordering mode   - <tt>true</tt> for access-order, <tt>false</tt> for insertion-order

翻译成中文就是:

排序的模式,如果是 true 的话就是访问顺序,如果是 false 的话就是插入顺序。

注释已经说得很清楚了,那么我们就不妨上代码试验一下,如果设置成 true 会是什么样的呢?代码如下:

public class Test extends Inner {
    public static void main(String[] args) {
        Map<String, String> map = new LinkedHashMap<String, String>(16, .75F, true);
        map.put("1", "1");
        map.put("2", "2");
        map.put("3", "3");
        map.put("4", "4");
        map.forEach((k, v) -> System.out.println(k + "    " + v));
        System.out.println("--------------------");

        map.get("4");
        map.get("2");
        map.get("3");
        map.forEach((k, v) -> System.out.println(k + "    " + v));
    }
}

打印结果如下:

这里写图片描述

我们可以发现,再调用了 get() 方法后,map 的输出顺序发生了改变,越是后调用的值越是越后输出,我们不妨看一下 LinkedHashMapget() 方法源码 ——

这里写图片描述

在第五行,如果 accessOrder 的值为 true 的话,我们就会进入 afterNodeAccess() 方法,继续跟踪进入该方法,源码如下 ——

这里写图片描述

我们可以很清楚的看到,原有尾结点会被设成 e 结点的 before 结点,而 e 结点又会被复制给 tail 结点变成新的尾结点,同时 e 结点的 after 结点赋空,所以最新使用的结点会在双链表的最末端,而最久未使用的那个结点会存在双链表的始端,所以如果在空间不足的情况下,就可以删除前面的结点了,这就是 LRU 算法的思想。

发布了41 篇原创文章 · 获赞 81 · 访问量 15万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章