转载

Java容器之HashMap倾力详解 - 使用得最频繁,但你真的懂吗?

前言

学习情况记录

  • 学习情况记录

    • 时间:week 3
    • SMART子目标 :Java 容器

记录在学习Java容器 知识点中,关于 HashMap 的需要重点记录的知识点。

知识点概览:

  • 一、hashCode()
  • 二、HashMap 底层实现

    1. 简介
    2. 存储结构
    3. 重要属性
    4. 增加元素操作

      Q: HashMap 的长度为什么默认初始长度是16,并且每次resize()的时候,长度必须是2的幂次方?

    5. HashMap 扩容

      Q: HashMap 死链问题

    6. Java 8 与 Java 7对比
    7. 为什么要使用红黑树?
  • 三、结语

一、hashCode()

在Object 类中,hashCode()方法是一个被native修饰的类,JavaDoc中描述的是返回该对象的哈希值。

那么哈希值这个返回值是有什么作用呢?

主要是保证基于散列的集合,如HashSet、HashMap以及HashTable等,在插入元素时保证元素不可重复,同时为了提高元素的插入删除便利效率而设计;主要是为了查找的便捷性而存在。

拿Set进行举例,

众所周知,Set集合是不能重复,如果每次添加数据都拿新元素去和集合内部元素进行逐一地equal()比较,那么插入十万条数据的效率可以说是非常低的。

所以在添加数据的时候就出现了哈希表的应用,哈希算法也称之为散列算法,当添加一个值的时候,先去计算出它的哈希值,根据算出的哈希值将数据插入指定位置。这样的话就避免了一直去使用equal()比较的效率问题。

具体表现在:

  • 如果指定位置为空,则直接添加
  • 如果指定位置不为空,调用equal() 判断两个元素是否相同,如果相同则不存储

上述第二种情况中,如果两个元素不相同,但是hashCode()相同,那就是发生了我们所谓的哈希碰撞。

哈希碰撞的概率取决于hashCode()计算方式和空间容量的大小。

这种情况下,会在相同的位置,创建一个链表,把key值相同的元素存放到链表中。

Java容器之HashMap倾力详解 - 使用得最频繁,但你真的懂吗?

在HashMap中就是使用拉链法来解决hashCode冲突。

总结

hashCode是一个对象的标识,Java中对象的hashCode是一个int类型值。通过hashCode来指定数组的索引可以快速定位到要找的对象在数组中的位置,之后再遍历链表找到对应值,理想情况下时间复杂度为O(1),并且不同对象可以拥有相同的hashCode。

二、HashMap 底层实现

0. 简介

  1. HashMap 基于哈希表的Map接口实现的,是以Key-Value存储形式存在;
  2. 非线程安全;
  3. key value都可以为null;
  4. HashMap中的映射不是有序的;
  5. 在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构;
  6. 当一个哈希桶存储的链表长度大于8 会将链表转换成红黑树,小于6时则从红黑树转换成链表;
  7. 1.8之前和1.8及以后的源码,差别较大

1. 存储结构

在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构。

通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储 ,但是这样如果链表过长来的话,HashMap会把这个链表转换成红黑树来存储,阈值为8。

下面是HashMap的结构图:

Java容器之HashMap倾力详解 - 使用得最频繁,但你真的懂吗?

2. 重要属性

2.1 table

/**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

在JDK1.8中我们了解到HashMap是由数组加链表加红黑树来组成的结构其中table就是HashMap中的数组。

2.2 size

/**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

HashMap中 键值对存储数量。

2.3 loadFactor

/**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;

负载因子。 负载因子是权衡资源利用率与分配空间的系数 。当 元素总量 > 数组长度 * 负载因子 时会进行扩容操作。

2.4 threshold

/**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold;

扩容阈值。 threshold = 数组长度 * 负载因子 。超过后执行扩容操作。

2.5 TREEIFY_THRESHOLD/UNTREEIFY_THRESHOLD

/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     */
    static final int UNTREEIFY_THRESHOLD = 6;

树形化阈值。当一个哈希桶存储的链表长度大于8 会将链表转换成红黑树,小于6时则从红黑树转换成链表。

3. 增加元素

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

3.1 hash()

可以看到实际执行添加元素的是putVal()操作,在执行putVal()之前,先是对key执行了hash()方法,让我们看下里面做了什么

static final int hash(Object key) {
        int h;
        // key.hashCode():返回散列值也就是hashcode
        // ^ :按位异或
        // >>>:无符号右移,忽略符号位,空位都以0补齐
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

key==null 说明,HashMap中是支持key为null的情况的。

同样的方法在Hashstable中是直接用key来获取hashCode,没有 key==null 的判断,所以Hashstable是不支持key为null的。

再回来说这个hash()方法。 这个方法用专业术语来称呼就叫做 扰动函数

使用hash()也就是扰动函数,是 为了防止一些实现比较差的hashCode()方法 。换句话来说, 就是为了减少哈希碰撞

JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。我们再看下JDK1.7中是怎么做的。

// code in JDK1.7
        static int hash(int h) {
            // This function ensures that hashCodes that differ only by
            // constant multiples at each bit position have a bounded
            // number of collisions (approximately 8 at default load factor).
            h ^= (h >>> 20) ^ (h >>> 12);
            return h ^ (h >>> 7) ^ (h >>> 4);
        }

相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。

3.2 putVal()

再来看真正执行增加元素操作的putVal()方法,

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 当数组为空或长度为0,初始化数组容量(resize() 方法是初始化或者扩容用的)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 计算数组下标 i = (n-1) & hash
        // 如果这个位置没有元素,则直接创建Node并存值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 这个位置已有元素
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // hash值、key值相等,用e变量获取到当前位置这个元素的引用,后面用于替换已有的值
                e = p;
            else if (p instanceof TreeNode)
                // 当前是以红黑树方式存储,执行其特有的putVal方法 -- putTreeVal
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 当前是以链表方式存储,开始遍历链表
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        // 这里是插入到链表尾部!
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            // 超过阈值,存储方式转化成红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    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)
                    // onlyIfAbsent 如果为true - 不覆盖已存在的值
                    // 把新值赋值进去
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 记录修改次数
        ++modCount;
        // 判断元素数量是否超过阈值 超过则扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

3.3 HashMap 的长度为什么默认初始长度是16,并且每次resize()的时候,长度必须是2的幂次方?

这是一个常见的面试题。这个问题描述的设计,实际上为了服务于从Key映射到数组下标index的Hash算法。

前面提到了,我们为了让HashMap存储高效,应该尽量减少哈希碰撞,也就是说,应该让元素分配得尽可能均匀。

Hash 值的范围值 -21474836482147483647 ,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。

所以才需要一个映射的算法。这个计算方式就是3.2中有出现的 (n - 1) & hash

我们来进一步演示一下这个算法:

  1. 假设有一个 key="book"
  2. 计算 book 的hashCode值,结果为十进制的3029737,二进制的101110001110101110 1001。
  3. 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
  4. 把以上两个结果做 与运算 ,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。

通过这种 与运算 的方式,能够和取模运算一样的效果 hashCode % length ,在上述例子中就是 3029737 % 16=9

并且通过位运算的方式大大提高了性能。

可能到这里,你还是不知道为什么长度必须是2的幂次方,也是因为这种位运算的方法。

长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。如果HashMap的长度不是2的幂次方,会出现某些index永远不会出现的情况,这个显然不符合均匀分布的原则和期望。所以在源码里面一直都在强调 power-of-two expansionsize must be power of two

另外,HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

4. HashMap 扩容

接下来我们来讲讲HashMap扩容相关的知识。

4.1 扩容

HashMap的初始长度是16,假设HashMap中的键值对一直在增加,但是table数组容量一直不变,那么就会发生哈希碰撞,查找的效率肯定会越来越低。所以当键值对数量超过某个阈值的时候,HashMap就会执行扩容操作。

那么扩容的阈值是怎么计算的呢?

阈值 = 数组长度 * 负载因子

threshold = capacity * loadFactor

每次扩容后,threshold 加倍

上述计算就出现在resize()方法中。下面会详细解析这个方法。我们先继续往下讲。

loadFactor这个参数,我们之前提到过, 负载因子是权衡资源利用率与分配空间的系数 。至于为什么是0.75呢?这个实际上就是一个作者认为比较好的权衡,当然你也可以通过构造方法手动设置负载因子 。 public HashMap(int initialCapacity, float loadFactor) {...)

接下去再来到这里的主角resize()方法

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;
            }
            // newCap 变成原来的 两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                // 执行扩容操作,新阈值 = 旧阈值 * 2
                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);
        }
        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引用的数组
        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
                        // 链表的处理  这个链表处理实际上非常的巧妙
                        // 定义了两条链
                        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 {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

上述代码红黑树和链表的处理不知道大家看懂了没有,我反正在第一次看的时候有点晕乎。但是理解了之后有感觉非常的巧妙。

拿链表处理打比方,它干的就是把在遍历旧的table数组的时候,把该位置的链表分成high链表和low链表。具体是什么意思呢?看下下面的举例。

  1. 有一个size为16的HashMap。有A/B/C/D/E/F六个元素,其中A/B/C的Hash值为5,D/E/F的Hash值为21,我们知道计算数组下标的方法是 与运算(效果相当于取模运算) ,这样计算出来, A/B/C/D/E/F的index = 5 ,都会被存在index=5的位置上中。
  2. 假设它们是依次插入,那么在index为5的位置上,就会有 A->B->C->D->E->F 这样一个链表。
  3. 当这个HashMap要进行扩容的时候,此时我们有旧数组oldTable[],容量为16,新数组newTable[],容量为32(扩容数组容量加倍)。
  4. 当遍历到旧数组index=5的位置的时候,进入到上面提到的链表处理的代码段中,对链表上的元素进行 Hash & oldCapacity 的操作,Hash值为5的A/B/C计算之后为0,被分到了low链表,Hash为21的D/E/F被分到了high链表。
  5. 然后把low链表放入新数组的index=5的位置,把high链表放入到新数组的index=5+16=21的位置。

红黑树相关的操作虽然代码不同,但是实际上要干的事情是一样的。就是把 相同位置的不同Hash大小的链表元素在新table数组中进行分离 。希望讲到这里你能听懂。

4.2 HashMap 死链问题

Java7的HashMap会存在死循环的问题,主要原因就在于,Java7中,HashMap扩容转移后 ,前后链表顺序倒置,在转移过程中其他线程修改了原来链表中节点的引用关系,导致在某Hash桶位置形成了环形链表,此时get(key),如果key不存在于这个HashMap且key的Hash结果等于那个形成了循环链表的Hash位置,那么程序就会进入死循环

Java8在同样的前提下并不会引起死循环,原因是 Java8扩容转移后前后链表顺序不变,保持之前节点的引用关系

具体的图像演示请看这篇。 漫画:高并发下的HashMap

5. Java 8 与 Java 7对比

  1. 发生hash冲突时, Java7会在链表头部插入,Java8会在链表尾部插入
  2. 扩容后转移数据, Java7转移前后链表顺序会倒置,Java8还是保持原来的顺序
  3. 引入红黑树的Java8极大程度地优化了HashMap的性能‘
  4. put 操作达到阈值时,Java7中是先扩容再新增元素,Java8是先新增元素再扩容;

6. 为什么要使用红黑树?

很多人可能都会答上一句,为了提高查找性能,但更确切地来说的话,采用红黑树的方法是为了提高 在极端哈希冲突的情况 下提高HashMap的性能。

下面是一段测试HashMap性能的基准测试代码:

import com.google.caliper.Param;
import com.google.caliper.Runner;
import com.google.caliper.SimpleBenchmark;
public class MapBenchmark extends SimpleBenchmark {
     private HashMap < Key, Integer > map;
     @Param
     private int mapSize;
     @Override
     protected void setUp() throws Exception {
          map = new HashMap < > (mapSize);
          for (int i = 0; i < mapSize; ++i) {
           map.put(Keys.of(i), i);
          }
     }
     public void timeMapGet(int reps) {
          for (int i = 0; i < reps; i++) {
           map.get(Keys.of(i % mapSize));
          }
     }
}
class Key implements Comparable < Key > {
    private final int value;
    Key(int value) {
        this.value = value;
    }
    @Override
    public int compareTo(Key o) {
        return Integer.compare(this.value, o.value);
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass())
            return false;
        Key key = (Key) o;
        return value == key.value;
    }
    // @Override
    // public int hashCode() {
    //  return value;
    // }

    @Override
    public int hashCode() {
        // key 返回的hash值都相同
        return 0;
    }
}
public class Keys {
     public static final int MAX_KEY = 10 _000_000;
     private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
     static {
          for (int i = 0; i < MAX_KEY; ++i) {
           KEYS_CACHE[i] = new Key(i);
          }
     }
     public static Key of (int value) {
          return KEYS_CACHE[value];
     }
}

可以看到,Key对象返回的hash值被修改成了,相同,也就是我们说的极端哈希冲突的情况下,此时,去测量Java7和Java8版本的HashMap的查询性能差距。

Java容器之HashMap倾力详解 - 使用得最频繁,但你真的懂吗?

Java 7的结果是可以预期的。 HashMap.get()的性能损耗与HashMap本身的大小成比例增长。 由于所有键值对都在一个巨大的链表中的同一个桶中,查找一个条目需要平均遍历一半这样的列表(大小为n)。 因此O(n)复杂性在图上可视化。

与此相对的是Java8,性能提高了很多,发生灾难性哈希冲突的情况下,在JDK 8上执行的相同基准测试会产生O(logn)最差情况下的性能。

关于此处的算法优化实际上在 JEP-180 中有描述到,

另外如果Key对象如果不是Comparable的话,那么发生重大哈希冲突时,不会有任何的性能提升。(因为底层实现时红黑树,需要通过compare方法去确定顺序)

又可能会有人说了,哪有这么极端的哈希冲突?

这个实际上是一个安全性的考虑,虽然在正常情况下很少有可能发生很多冲突。但是想象一下,如果Key来自不受信任的来源(例如从客户端收到的HTTP头名称),那么就有可能收到伪造key值,并且这种做法不难,因为哈希算法是大家都知道的,假设有人有心去伪造相同的哈希值的key值,那么你的HashMap中就会出现上述这种极端哈希冲突的情况。 现在,如果你去对这个HashMap执行多次的查询请求,就会发现程序执行查询的效率会变得很慢,cpu占用率很高,程序甚至会拒绝对外提供服务。

三、结语

HashMap的相关知识就介绍到这里,篇幅非常的长,本人也写了很久。

整个学习过程下来的感觉就是,知识的学习应该是相互穿插并且印证,HashMap实际上和其他的Map有很多交叉的实现原理,比如ConcurrentHashMap大致原理和HashMap相同,只是前者使用分段锁确保线程安全,Hashstable和HashMap底层原理也很相似,只是Hashstable使用synchronized做同步,并且官方在Hashstable出来之后就没有再去更新过,属于过时的类;HashMap和TreeMap底层都涉及到了红黑树。当你互相对照地去学习时,你会发现学懂了其中一个,另外几个就懂了差不多了,因为很多原理都是穿插应用的。

下一章会涉及到其他的Map类型,并且对他们进行对比。

参考

  1. 《码处高效》
  2. https://dzone.com/articles/ha...
  3. https://stackoverflow.com/que...
  4. 漫画:高并发下的HashMap
  5. https://mp.weixin.qq.com/s/RI...
原文  https://segmentfault.com/a/1190000019916420
正文到此结束
Loading...