HashMap原理

128 阅读19分钟

HashMap存储结构

JDK1.7及之前:
数组和链表组合存储

JDK1.8之后:
当链表的长度特别长的时候,查询效率将直线下降,查询的时间复杂度为O(n)。因此,JDK1.8把它设计为达到一个特定的阈值之后,就将链表转化为红黑树。

  • 每个节点只有两种颜色:红色或者黑色
  • 根节点必须是黑色
  • 每个叶子节点(NIL)都是黑色的空节点
  • 从根节点到叶子节点,不能出现两个连续的红色节点
  • 从任一节点出发,到它下边的子节点的路径包含的黑色节点数目都相同

红黑树是一个自平衡的二叉搜索树,因此可以使查询的时间复杂度降为O(logn)。

结构示意图:

HashMap属性

在 HashMap源码中,比较重要的常用变量,主要有以下这些。还有两个内部类来表示普通链表的节点和红黑树节点。
//默认的初始化容量为16,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

//最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认的加载因子0.75,乘以数组容量得到的值,用来表示元素个数达到多少时,需要扩容。
//为什么设置 0.75 这个值呢,简单来说就是时间和空间的权衡。
//若小于0.75如0.5,则数组长度达到一半大小就需要扩容,空间使用率大大降低,
//若大于0.75如0.8,则会增大hash冲突的概率,影响查询效率。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//刚才提到了当链表长度过长时,会有一个阈值,超过这个阈值8就会转化为红黑树
static final int TREEIFY_THRESHOLD = 8;

//当红黑树上的元素个数,减少到6个时,就退化为链表
static final int UNTREEIFY_THRESHOLD = 6;

//链表转化为红黑树,除了有阈值的限制,还有另外一个限制,需要数组容量至少达到64,才会树化。
//这是为了避免,数组扩容和树化阈值之间的冲突。
static final int MIN_TREEIFY_CAPACITY = 64;

//存放所有Node节点的数组
transient Node<K,V>[] table;

//存放所有的键值对
transient Set<Map.Entry<K,V>> entrySet;

//map中的实际键值对个数,即数组中元素个数
transient int size;

//每次结构改变时,都会自增,fail-fast机制,这是一种错误检测机制。
//当迭代集合的时候,如果结构发生改变,则会发生 fail-fast,抛出异常。
transient int modCount;

//数组扩容阈值
int threshold;

//加载因子
final float loadFactor;          

//普通单向链表节点类
static class Node<K,V> implements Map.Entry<K,V> {
  //key的hash值,put和get的时候都需要用到它来确定元素在数组中的位置
  final int hash;
  final K key;
  V value;
  //指向单链表的下一个节点
  Node<K,V> next;

  Node(int hash, K key, V value, Node<K,V> next) {
    this.hash = hash;
    this.key = key;
    this.value = value;
    this.next = next;
  }
}

//转化为红黑树的节点类
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);
  }
}  

HashMap 构造函数

HashMap有四个构造函数可供我们使用:

//默认无参构造,指定一个默认的加载因子
public HashMap() {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
}

//可指定容量的有参构造,但是需要注意当前我们指定的容量并不一定就是实际的容量,下面会说
public HashMap(int initialCapacity) {
  //同样使用默认加载因子
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//可指定容量和加载因子,但是笔者不建议自己手动指定非0.75的加载因子
public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
  this.loadFactor = loadFactor;
  //这里就是把我们指定的容量改为一个大于它的的最小的2次幂值,如传过来的容量是14,则返回16
  //注意这里,按理说返回的值应该赋值给 capacity,即保证数组容量总是2的n次幂,为什么这里赋值给了 threshold 呢?
  //先卖个关子,等到 resize 的时候再说
  this.threshold = tableSizeFor(initialCapacity);
}

//可传入一个已有的map
public HashMap(Map<? extends K, ? extends V> m) {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
  putMapEntries(m, false);
}

//把传入的map里边的元素都加载到当前map
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
  int s = m.size();
  if (s > 0) {
    if (table == null) { // pre-size
      float ft = ((float)s / loadFactor) + 1.0F;
      int t = ((ft < (float)MAXIMUM_CAPACITY) ?
           (int)ft : MAXIMUM_CAPACITY);
      if (t > threshold)
        threshold = tableSizeFor(t);
    }
    else if (s > threshold)
      resize();
    for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
      K key = e.getKey();
      V value = e.getValue();
      //put方法的具体实现,后边讲
      putVal(hash(key), key, value, false, evict);
    }
  }
}

tableSizeFor()方法

上边的第三个构造函数中,调用了 tableSizeFor 方法,这个方法是怎么实现的呢?

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

以传入参数为14 来举例,计算这个过程。

首先,14传进去之后先减1,n此时为13。然后是一系列的无符号右移运算。

//13的二进制
0000 0000 0000 0000 0000 0000 0000 1101
//无右移1位,高位补0
0000 0000 0000 0000 0000 0000 0000 0110
//然后把它和原来的13做或运算得到,此时的n值
0000 0000 0000 0000 0000 0000 0000 1111
//再以上边的值,右移2位
0000 0000 0000 0000 0000 0000 0000 0011
//然后和第一次或运算之后的 n 值再做或运算,此时得到的n值
0000 0000 0000 0000 0000 0000 0000 1111
...
//我们会发现,再执行右移 4,8,16位,同样n的值不变
//当n小于0时,返回1,否则判断是否大于最大容量,是的话返回最大容量,否则返回 n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
//很明显我们这里返回的是 n+1 的值,
0000 0000 0000 0000 0000 0000 0000 1111
+                                     1
0000 0000 0000 0000 0000 0000 0001 0000

将它转为十进制,就是 2^4 = 16 。我们会发现一个规律,以上的右移运算,最终会把最低位的值都转化为 1111 这样的结构,然后再加1,就是1 0000 这样的结构,它一定是 2的n次幂。因此,这个方法返回的就是大于当前传入值的最小(最接近当前值)的一个2的n次幂的值

put()方法详解

//put方法,会先调用一个hash()方法,得到当前key的一个hash值,
//用于确定当前key应该存放在数组的哪个下标位置
//这里的 hash方法,我们姑且先认为是key.hashCode(),其实不是的,一会儿细讲
public V put(K key, V value) {
  return putVal(hash(key), key, value, false, true);
}

//把hash值和当前的key,value传入进来
//这里onlyIfAbsent如果为true,表明不能修改已经存在的值,因此我们传入false
//evict只有在方法 afterNodeInsertion(boolean evict) { }用到,可以看到它是一个空实现,因此不用关注这个参数
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
         boolean evict) {
  Node<K,V>[] tab; Node<K,V> p; int n, i;
  //判断table是否为空,如果空的话,会先调用resize扩容
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  //根据当前key的hash值找到它在数组中的下标,判断当前下标位置是否已经存在元素,
  //若没有,则把key、value包装成Node节点,直接添加到此位置。
  // i = (n - 1) & hash 是计算下标位置的,为什么这样算,后边讲
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  else {
    //如果当前位置已经有元素了,分为三种情况。
    Node<K,V> e; K k;
    //1.当前位置元素的hash值等于传过来的hash,并且他们的key值也相等,
    //则把p赋值给e,跳转到①处,后续需要做值的覆盖处理
    if (p.hash == hash &&
      ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    //2.如果当前是红黑树结构,则把它加入到红黑树
    else if (p instanceof TreeNode)
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    else {
    //3.说明此位置已存在元素,并且是普通链表结构,则采用尾插法,把新节点加入到链表尾部
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          //如果头结点的下一个节点为空,则插入新节点
          p.next = newNode(hash, key, value, null);
          //如果在插入的过程中,链表长度超过了8,则转化为红黑树
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          //插入成功之后,跳出循环,跳转到①处
          break;
        }
        //若在链表中找到了相同key的话,直接退出循环,跳转到①处
        if (e.hash == hash &&
          ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        p = e;
      }
    }
    //① 此时e有两种情况
    //1.说明发生了碰撞,e代表的是旧值,因此节点位置不变,但是需要替换为新值
    //2.说明e是插入链表或者红黑树,成功后的新节点
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      //用新值替换旧值,并返回旧值。
      //oldValue为空,说明e是新增的节点或者也有可能旧值本来就是空的,因为hashmap可存空值
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      //看方法名字即可知,这是在node被访问之后需要做的操作。其实此处是一个空实现,
      //只有在 LinkedHashMap才会实现,用于实现根据访问先后顺序对元素进行排序,hashmap不提供排序功能
      // Callbacks to allow LinkedHashMap post-actions
      //void afterNodeAccess(Node<K,V> p) { }
      afterNodeAccess(e);
      return oldValue;
    }
  }
  //fail-fast机制
  ++modCount;
  //如果当前数组中的元素个数超过阈值,则扩容
  if (++size > threshold)
    resize();
  //同样的空实现
  afterNodeInsertion(evict);
  return null;
}

hash()计算原理

前面 put 方法中说到,需要先把当前key进行哈希处理,代码如下:

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

这里,会先判断key是否为空,若为空则返回0。这也说明了hashMap是支持key传null 的。若非空,则先计算key的hashCode值,赋值给h,然后把h右移16位,并与原来的h进行异或处理。这样做有什么好处呢?

我们知道,hashCode()方法继承自父类Object,它返回的是一个 int 类型的数值,可以保证同一个应用单次执行的每次调用,返回结果都是相同的(这个说明可以在hashCode源码上找到),这就保证了hash的确定性。在此基础上,再进行某些固定的运算,肯定结果也是可以确定的。

我随便运行一段程序,把它的 hashCode的二进制打印出来,如下。

public static void main(String[] args) {
    Object o = new Object();
    int hash = o.hashCode();
    System.out.println(hash);//1836019240
    System.out.println(Integer.toBinaryString(hash));//1101101011011110110111000101000

}

然后,进行 (h = key.hashCode()) ^ (h >>> 16) 这一段运算。

//h原来的值
0110 1101 0110 1111 0110 1110 0010 1000
//无符号右移16位,其实相当于把低位16位舍去,只保留高16位
0000 0000 0000 0000 0110 1101 0110 1111
//然后高16位和原 h进行异或运算
0110 1101 0110 1111 0110 1110 0010 1000
^
0000 0000 0000 0000 0110 1101 0110 1111
=
0110 1101 0110 1111 0000 0011 0100 0111

可以看到,其实相当于,我们把高16位值和当前h的低16位进行了混合,这样可以尽量保留高16位的特征,从而降低哈希碰撞的概率。 为什么这样做,我们需要结合 i = (n - 1) & hash 这一段运算来理解。

(n-1) & hash 作用

//这是 put 方法中用来根据hash()值寻找在数组中的下标的逻辑,
//n为数组长度, hash为调用 hash()方法混合处理之后的hash值。
i = (n - 1) & hash

我们知道,如果给定某个数值,去找它在某个数组中的下标位置时,直接用模运算就可以了(假设数组值从0开始递增)。如,我找 14 在数组长度为16的数组中的下标,即为 14 % 16,等于14 。18的位置即为 18%16,等于2。

(n-1) & hash就是取模运算的位运算形式。以18%16为例

//18的二进制
0001 0010
//16 -1 即 15的二进制
0000 1111
//与运算之后的结果为
0000 0010
// 可以看到,上边的结果转化为十进制就是 2 。
//其实我们会发现一个规律,因为n是2的n次幂,因此它的二进制表现形式肯定是类似于
0001 0000
//这样的形式,只有一个位是1,其他位都是0。而它减 1 之后的形式就是类似于
0000 1111
//这样的形式,高位都是0,低位都是1,因此它和任意值进行与运算,结果值肯定在这个区间内
0000 0000  ~  0000 1111
//也就是0到15之间,(以n为16为例)
//因此,这个运算就可以实现取模运算,而且位运算还有个好处,就是速度比较快。

为什么高低位异或运算可以减少哈希碰撞

我们想象一下,假如用 key 原来的hashCode值,直接和 (n-1) 进行与运算来求数组下标,而不进行高低位混合运算,会产生什么样的结果。

//例如我有另外一个h2,和原来的 h相比较,高16位有很大的不同,但是低16位相似度很高,甚至相同的话。
//原h值
0110 1101 0110 1111 0110 1110 0010 1000
//另外一个h2值
0100 0101 1110 1011 0110 0110 0010 1000
// n -1 ,即 15 的二进制
0000 0000 0000 0000 0000 0000 0000 1111
//可以发现 h2 和 h 的高位不相同,但是低位相似度非常高。
//他们分别和 n -1 进行与运算时,得到的结果却是相同的。(此处n假设为16)
//因为 n-1 的高16位都是0,不管 h 的高 16 位是什么,与运算之后,都不影响最终结果,高位一定全是 0
//因此,哈希碰撞的概率就大大增加了,并且 h 的高16 位特征全都丢失了。

为什么选择用异或运算呢,用与、或运算不行吗?我们看一个表格,就能明白了。

可以看到两个值进行与运算,结果会趋向于0;或运算,结果会趋向于1;而只有异或运算,0和1的比例可以达到1:1的平衡状态。

对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

所以,异或运算之后,可以让结果的随机性更大,而随机性大了之后,哈希碰撞的概率当然就更小了。这就是为什么要对一个hash值进行高低位混合,并且选择异或运算来混合的原因。

问题总结

1:HashMap 的数据结构?

A:哈希表结构(链表散列:数组+链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。 transient Node<K,V>[] table;

2:HashMap 的工作原理?

HashMap 底层是 hash 数组和单向链表实现,数组中的每个元素都是链表,由 Node 内部类(实现 Map.Entry接口)实现,HashMap 通过 put & get 方法存储和获取。

存储对象时,将 K/V 键值传给 put() 方法:

①调用 hash(K) 方法计算 K 的 hash 值,然后结合数组长度,计算得数组下标;

②调整数组大小(当容器中的元素个数大于 capacity * loadfactor 时,容器会进行扩容resize 为 2n);

③i.如果 K 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;

ii.如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;

iii. 如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。

(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)

获取对象时,将 K 传给 get() 方法:①、调用 hash(K) 方法(计算 K 的 hash 值)从而获取该键值所在链表的数组下标;②、顺序遍历链表,equals()方法查找相同 Node 链表中 K 值对应的 V 值。

hashCode 是定位的,存储位置;equals是定性的,比较两者是否相等。

3.当两个对象的 hashCode 相同会发生什么?

因为 hashCode 相同,不一定就是相等的(equals方法比较),所以两个对象所在数组的下标相同,"碰撞"就此发生。又因为 HashMap 使用链表存储对象,这个 Node 会存储到链表中。为什么要重写 hashcode 和 equals 方法?推荐看下。

4.你知道 hash 的实现吗?为什么要这样实现?

JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。

5.为什么要用异或运算符?

保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。

6.HashMap 的 table 的容量如何确定?loadFactor 是什么?该容量如何变化?这种变化会带来什么问题?

①table 数组大小是由 capacity 这个参数确定的,默认是16,也可以构造时传入,最大限制是1<<30;

②loadFactor 是装载因子,主要目的是用来确认table 数组是否需要动态扩展,默认值是0.75,比如table 数组大小为 16,装载因子为 0.75 时,threshold 就是12,当 table 的实际大小超过 12 时,table就需要动态扩容;

③扩容时,调用 resize() 方法,将 table 长度变为原来的两倍(注意是 table 长度,而不是 threshold)

④如果数据很大的情况下,扩展时将会带来性能的损失,在性能要求很高的地方,这种损失很可能很致命。

7.HashMap中put方法的过程?

答:“调用哈希函数获取Key对应的hash值,再计算其数组下标;

如果没有出现哈希冲突,则直接放入数组;如果出现哈希冲突,则以链表的方式放在链表后面;

如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表;

如果结点的key已经存在,则替换其value即可;

如果集合中的键值对大于12,调用resize方法进行数组扩容。”

8.拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树?

之所以选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。

而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

9.jdk8中对HashMap做了哪些改变?

在java 1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。(桶的数量必须大于64,小于64的时候只会扩容)

发生hash碰撞时,java 1.7 会在链表的头部插入,而java 1.8会在链表的尾部插入

在java 1.8中,Entry被Node替代(换了一个马甲。