深入理解 HashMap

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_38495686/article/details/79955524

每篇一句:一个不能保护自己的男孩,长大之后什么东西都保护不了。  ——《追风筝的人》

HashMap 概述

Java 中的最基本的数据存储方式有两种, 数组 链表 。它们的优缺点也很明显:

\ 数组 链表
优点 访问速度快 插入和删除较快
缺点 插入和删除较慢 访问速度慢

那么有没有一种数据结构来综合一下数组和链表来发挥各自的优势呢?

首先,先来看一下数组,我们可以使用 array[key.hashCode() % length] 类似的操作来实现数组的快速寻址。但是这样会有一个问题,就是取余之后的数有可能重复,这时再去存储数据,就会产生冲突。

在 HashMap 中采用的就是 数组+链表+红黑树(JDK1.8增加了红黑树部分) 结合的方式,也就是 哈希表 去解决它。它的存储方式如下图所示:

简单的说,哈希表由数组和链表组成,而数组的每个元素都是一个单链表的头节点,链表是被用来解决冲突的,如果不同的 key 映射到了数组的同一位置处,就将其放入单链表中,如果链表长度大于 8 时就转换为 红黑树

这里先对 HashMap 的特点 做一下说明,然后通过源码来分析它的内部实现。

HashMap 根据键的 hashCode 值来存储数据,大多数情况下可以直接定位它的值,因而访问速度很快。它最多允许一条记录的键为 null,允许多条记录的值为 null。它是线程非安全的,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据不一致。如果需要线程安全,可以使用 Collections 的 synchronizedMap 方法使HashMap具有线程安全的能力,或使用 ConcurrentHashMap。

内部实现

我们从存储结构-字段和功能实现-方法两个方面来认识 HashMap。

存储结构-字段

通过前面的存储方式我们已经知道 HashMap 是由 数组+链表+红黑树(JDK1.8新增) 实现的。那么它的数据底层具体是如何存储的?这样做有什么优缺点呢?

0). 从源码可知,HashMap 中有一个非常重要的字段,即 Node

//这里还可以容忍长度为零以允许自举机制,有兴趣可以自行了解
transient Node<K,V>[] table;

那么这个 Node 是什么呢?

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   //用来定位数组索引位置
    final K key;      //键
    V value;          //值
    Node<K,V> next;   //下一个结点

    Node(int hash, K key, V value, Node<K,V> next) { ··· }
    public final K getKey()        { ··· }
    public final V getValue()      { ··· }
    public final String toString() { ··· }
    public final int hashCode()    { ··· }
    public final V setValue(V newValue) { ··· }
    public final boolean equals(Object o) { ··· }
}

Node 是 HashMap 的一个内部类,实现了 Map.Entry 接口,本质上就是一个映射(键值对)。上图中的每个黑色结点就是一个 Node 对象

数组中每一个元素存储的都是链表(不太准确),在 JDK1.8 中当链表长度超过(阈值)时,将链表转换为红黑树存储,大大提高了查找的效率。下面是 TreeNode :

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 父节点
    TreeNode<K,V> left;    //左子树
    TreeNode<K,V> right;   //右子树
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;      //颜色属性
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    // 返回当前节点的根节点
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }
    ······
}

2). HashMap 使用哈希表来进行存储。为解决冲突问题,可以采用 开放地址法和链地址法 等解决,关于开放地址法和链地址法可以参考https://blog.csdn.net/w_fenghui/article/details/2010387,在 Java 中 HashMap 采用了链地址法,就是前面图中的方式。例如程序执行如下代码:

map.put("Timber", "HashMap");

系统将调用 “Timber” 这个 key 的 hashCode 方法得到其 hashCode 值(每种 Java 对象类型都有其对应方法),然后通过 Hash 算法类似取余的方法来定位该键值对的存储位置,如果两个 key 定位到相同位置,表示发生了碰撞。

如果 Hash 算法计算结果越分散均匀,Hash 碰撞的概率就越小,Map 的存取效率就越高。如果哈希桶数组很大,即使较差的 Hash 算法也会比较分散;如果哈希桶数组很小,即使好的 Hash 算法也会出现较多碰撞,所以就需要在空间和时间成本之间权衡,如何确定哈希桶数组的大小,并设计出好的 Hash 算法。那么如何解决这个问题呢?那就是好的 Hash算法 和 扩容机制

在理解 Hash 算法和扩容机制之前,先了解一下 HashMap 的几个静态常量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  //默认初始容量为16
static final int MAXIMUM_CAPACITY = 1 << 30;        //最大容量为 2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f;     //默认负载因子为0.75
static final int TREEIFY_THRESHOLD = 8;             //默认桶中元素大于 8 时转为红黑树
static final int UNTREEIFY_THRESHOLD = 6;    //扩容时,桶中元素小于这个值就会还原为链表

然后是几个字段:

transient int size;      //map 中包含的 key-value 对数量
transient int modCount;  //HashMap 内部结构发生变化的次数
int threshold;           //map 的极限容量,扩容临界点(容量和加载因子的乘积)
final float loadFactor;  //hash table 的负载因子

首先,threshold 就是 HashMap 能容纳的最大键值对个数。threshold = capacity * load factor; 就是说,在数组长度定义好之后,负载因子越大,所能容纳键值对越多。如果存储元素数大于 threshold,就要进行扩容。

modCount 主要用来记录 HashMap 内部结构发生变化的次数,主要用于使迭代器对 HashMap 的集合视图快速失效。强调一点,内部结构发生变化指的是结构发生变化,例如 改变键值对数量或者扩容,但是 put新键值对而某个 key 的 value 值被覆盖不属于结构变化

一般来说,哈希桶数组大小为素数,因为素数导致冲突的概率要小。而在 Java 的 HashMap 中采用非常规设计,数组 length 大小是 2 的 n 次方,这样做主要是为了在取模和扩容是做优化,同时为了减少冲突,HashMap 定位哈希桶索引位置时,也加入了高位参与运算

还有它的四个构造器

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
//构造一个默认初始容量(16)和默认加载因子(0.75)的空 HashMap

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
//构造一个指定的初始容量和默认加载因子(0.75)的空 HashMap

public HashMap(int initialCapacity, float loadFactor) {
    ······
    //初始容量非负
    //指定容量大于最大容量,则置为最大容量
    //填充比为正
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
//构造一个指定的初始容量和加载因子的空 HashMap

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
//构建一个映射关系与指定 Map 相同的新 HashMap

功能实现-方法

HashMap 内部功能实现很多,这里主要从根据 key 定位哈希桶数组位置. put 和 get 方法详细执行. 扩容过程. 桶的树形化 四个点进行分析

1.获取哈希桶数组索引位置

不管增删改查,定位到哈希桶数组位置都很重要,这也直接决定了 hash 方法的离散性能。先看源码

static final int hash(Object key) {
    int h;
    //h = key.hashCode() 第一步 取 HashCode 值
    // h ^ (n >>> 16) 第二步 高位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//JDK1.8 中没有了这个方法,而是在 putVal() 方法中直接包含进去
static int indexFor(int h, int length){
    return h & (length - 1);   //第三步 取模运算
} 

实际上 Hash 算法本质上就是三步: 取 key 的 hashCode 值. 高位运算. 取模运算

对于任意给定的对象,只要 hashCode 返回值相同,那么调用 hash
方法的 Hash 码就相同。如果对这个值取模,这样一来,元素分布相对均匀,但取模消耗较大。在 HashMap 中有一个很巧妙的方法,通过 h & (table.length - 1) 得到该对象的保存位,而 HashMap 底层数组的长度总是 2 的 n 次方,当 length 总是 2 的 n
次方时,h & (table.length - 1) 就相当于对 length 取模,但是效率更高。

在 JDK1.8 中优化了高位运算的算法,通过 hashCode 的高 16 位异或低 16 位实现。下面举例说明,n 为 table 的长度:

2.分析 HashMap 的 put 和 get 方法

注意,HashMap 中 key 和 value 允许为 null。HashMap 的 put 方法可以通过下图理解

通过 HashMap 的 put 方法源码我们来看一下

public V put(K key, V value) {
    //对 key 的 hashCode 值做 hash, 三步
    return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1).如果tab 为空或长度为0,则分配内存
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    ///2).计算 index 值,如果当前没有位置值添加即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3).如果hash 和 key 相等,则更新 p 节点,这里容许 key 和 value 为 null
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4).判断为红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 5).判断为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // key 不存在,新建一个结点
                    p.next = newNode(hash, key, value, null);
                    // 如果长度大于8,则转为红黑树处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //key 已经存在,直接覆盖 value
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //内部结构发生变化
    ++modCount;
    // 如果超过最大容量就扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

下面针对各步骤,详细说明:

1). 判断键值对数组 table[] 是否为空或者长度为0,否则进行 resize() 扩容

2).通过 hash & (table.length - 1) 得到插入的数组索引,如果为空,则新建节点添加,转向 6),否则转向 3)

3).判断 table[i] 的首个元素是否和 key 一样,如果相同则覆盖 value,否则转向 4),这里相同指的是 hashCode 以及 equals

4).判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是,则直接在树中插入键值对

5).table[i] 当前是链表,遍历 table,判断链表长度是否为 8,如果是,转为红黑树,在红黑树中进行插入操作,否则进行链表的插入操作,遍历中若发现 key 已经存在,则直接覆盖即可

6).插入成功后,判断实际存在键值对是否超过了最大容量,如果是,进行扩容

然后来看一下 get 方法:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // hash & (length - 1) 得到对象的保存位
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //检查首元素
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            //红黑树处理
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //链表处理
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

getTreeNode 方法是调用树形节点的 find 方法进行查找

final TreeNode<K,V> getTreeNode(int h, Object k) {
        return ((parent != null) ? root() : this).find(h, k, null);
    }

3.扩容机制

如果不断向 HashMap 对象添加元素,而 HashMap 对象内部数组无法装载更多元素时,就需要扩大数组长度。当然,Java 中数组无法自动扩容,只能是使用一个更大的数组代替已有的小容量数组,并把原先的键值对复制过去(并不是简单的复制,具体后面讨论)

现在分析一下 resize 方法的源码,鉴于 JDK1.8 中加入了红黑树,较复杂,这里先使用 JDK1.7 的代码,1.8后面再说

void resize(int newCapacity) {   
    Entry[] oldTable = table;                  //引用扩容前的 Entry 数组
    int oldCapacity = oldTable.length;         
    if (oldCapacity == MAXIMUM_CAPACITY) {    // 扩容前的数组大小如果已经达到最大 (2^30)
        threshold = Integer.MAX_VALUE;        // 就修改阈值为int的最大值 (2^31-1),以后不再扩容
        return;
    }

    Entry[] newTable = new Entry[newCapacity];  //初始化一个新的 Entry 数组
    transfer(newTable);                         //!!将数据转移到新的 Entry 数组里
    table = newTable;                           //HashMap 的 table 属性引用新的 Entry 数组
    threshold = (int)(newCapacity * loadFactor);//修改阈值
}

这里就是使用一个容量更大的数组来代替已有的容量更小的数组, transfer 方法将原有数组的元素拷贝到新的 Entry 数组里

void transfer(Entry[] newTable) {
    Entry[] src = table;                   //src 引用了旧的 Entry 数组
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) { //遍历旧的 Entry 数组
        Entry<K,V> e = src[j];             //取得旧 Entry 数组的每个元素
        if (e != null) {
            src[j] = null;  //释放旧 Entry 数组的对象引用(for循环后,旧的 Entry 数组不再引用任何对象)
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                e.next = newTable[i]; //标记[1]
                newTable[i] = e;      //将元素放在数组上
                e = next;             //访问下一个Entry链上的元素
            } while (e != null);
        }
    }
}

newTable[i] 的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一个位置上的新元素总会被插入到链表的头部位置(这与 JDK1.8 有区别)。在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置,有可能被放到了新数组的不同位置上

下面举例说明下扩容的过程。假设 hash 算法就是简单的使用 key ( mod 数组的长度)。其中的哈希桶数组 table 的 size = 2,key = 3. 7. 5,所以 put 后顺序为 5. 7. 3,在 (mod 2) 后都在 table[1] 发生冲突。假设 load Foctor = 1,即当键值对实际大小 大于 table 实际大小时进行扩容。这时,数组长度 3 大于 2 需要进行扩容,数组 resize 成 4,然后所有的 Node 重新 rehash 的过程

下面看一下 JDK1.8 中做了哪些优化。由于每次使用的是 2 次幂的扩展(指长度变为2倍),所以元素要么在原位置,要么在原位置移动 2 次幂的位置。看下图,n为 table 的长度,图 (a) 为扩容前的 key1 和 key2 确定索引位置的示例,图 (b) 为扩容后的 key1 和 key2 确定索引位置的示例,其中 key1(hash1) 是 key1 对应的哈希与高位运算的结果

元素在重新计算 hash 后,因为 n 变为 2 倍,那么 n - 1 的 mask 的范围(红色)在高位多 1bit,因此新的 index 就会这样变化

因此,在扩容时,只需看看原来的 hash 值新增的 bit 位是 1 还是 0,如果是 0 ,索引不变,否则变成 “原索引 + oldCapacity”,可以看看下图 16 扩充为 32 的 resize 示意图:

需要注意的是,JDK1.8 中迁移并不会改变链表元素的顺序。

源码如下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果没有超过最大值,就扩充为原来的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //计算新的 resize 的上限
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    // 链表优化重 hash 的代码块
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            //原索引 + oldCap
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到新数组中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //原索引 + oldCap 放到新数组中
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

resize 扩容时,对于旧链表迁移新链表,JDK1.7 中是重新计算元素在数组中的位置,而且元素会发生倒置,而 JDK1.8 中采用了计算 hash & oldCap 来判断是否需要移动,新的位置是 (原始位置 + 原数组长度 ),元素相对位置没有变化。

4.桶的树形化

JDK1.8 中,如果桶的元素个数超过 TREEIFY_THRESHOLD (默认为 8) 阈值,就是用红黑树来替换链表,提高查找速度

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 如果哈希表为空,或者哈希表中元素个数小于进行树形化的阈值,进行新建/扩容
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    // 否则进行树形化
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            //新建树形节点,内容和链表节点一样
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        //让桶中第一个元素指向新建的树形节点
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

线程安全性

在多线程使用场景中,应该避免使用线程不安全的 HashMap,而使用线程安全的 ConcurrentHashMap。下面举个例子说明 HashMap 是线程不安全的,在并发的多线程使用场景中使用有可能造成死循环。例子如下:(为了便于理解,仍然使用 JDK1.7 的环境)

public class HashMapInfiniteLoop {
    private static HashMap<Integer, String> map = new HashMap<Integer, String>(2, 0.75f);
    public static void main(String[] args) {
        map.put(5,  "C");

        new Thread("Thread1") {
            public void run() {
                map.put(7,  "B");
                System.out.println(map);
            }
        }.start();

        new Thread("Thread2") {
            public void run() {
                map.put(3, "A");
                System.out.println(map);
            }
        }.start();
    }
}

其中,map初始化为一个长度为 2 的数组, loadFactor = 0.75, threshold = 2*0.75=1,也就是说当 put 第二个 key 时,map就需要 resize。

通过设置断点让线程 1 和线程 2 同时 debug 到 transfer 方法。注意此时连个线程已经成功添加数据,放开 Thread1 断点至 Entry next = e.next; 这一行,然后放开线程 2 断点,让其进行 resize。结果如图

此时,Thread1 的 e 指向了 key(3), 而 next 指向了 key(7),但是在 Thread2
rehash 后,指向了 Thread2 重组后的链表

Thread1 被调度回来执行,先是执行 newTable[i] = e, 就是让 newTable[i] 指向 key(3)。然后是 e = next, 就是 e 指向了 key(7)

而下一次循环的 next = e.next 导致了 next 指向了 key(3)。也就是下面这样:

而 e.next = newTable[i] 导致 key(3).next 指向了 key(7)。于是形成了环形链表。

当用 Thread1 调用 map.get(1) 时,就出现了死循环

JDK1.8 与 JDK1.7 性能对比

在 HashMap 中,如果 hash 算法很好,即对于 key 经过 hash 算法得出的数组索引位置全部不相同,getKey() 方法的复杂度就是 O(1),如果 Hash 算法极其差,即碰撞非常多,那么得到的数组索引位置全都相同,那样所有的键值对都在一个桶中(链表或者红黑树),时间复杂度为 O(n) 和 O(lgn)。JDK1.8 总体性能优于 JDK1.7。

小结

1.扩容是一个特别耗能的操作,在使用 HashMap 时,估算 map 的大小,初始化时给定一个大致的数值,避免 map 进行频繁的扩容

2.负载因子可以修改,也可以大于1,但是建议不要轻易修改

3.HashMap是线程安全的,不要在并发环境中使用,建议使用 ConcurrentHashMap

4.JDK1.8 引入红黑树很大程度优化了 HashMap 的性能

写在最后

如果有什么不对或建议,欢迎批评指正。

参考文章:
ImportNew:Java8系列之重新认识HashMap
红黑联盟:Java类集框架之HashMap(JDK1.8)源码剖析
CSDN:深入分析hashmap

阅读更多
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页