HashMap你想要的全在这了

1. HashMap

1.1 什么是HashMap

HashMap的本质是一个“Map”,因为它实现了Map接口。那么什么是Map呢?

An object that maps keys to values. A map cannot contain duplicate keys; each key can map to at most one value.

Map表示的是使key和value二者形成映射关系,从而组成一组键值对的对象。key-value键值对在Map中用Entry表示;Map需要保证Key是不重复的。(如何保证key不重复?重复了会怎么样?)

HashMap是基于哈希表(hash table,本质是一个Entry数组)来实现Map的,它只允许有一个key为null,但接受多个value为null(key为null如何处理?)。HashMap不保证元素的顺序,也就是说,遍历HashMap中的元素时,遍历的顺序不保证与元素插入的顺序相同。

在HashMap中有几个参数需要注意:

  1. capacity:容量。表示hash桶的个数,即Entry数组的长度(length),默认值为16。数组中的一个格子在这里叫做一个hash桶(bucket / bin)。
  2. size:表示HashMap中存在多少个键值对,即已存在多少个Entry。
  3. load factor:负载因子,表示在哈希表发生扩容之前,可以存放的键值对个数与容量的比例。该数值默认0.75,是结合性能和容量权衡而得。
  4. threshold:threshold = capacity * loadFactor。表示当HashMap中键值对的个数超过该数值后,会发生扩容(数组长度扩大为原来的两倍),并将键值对的位置重新排布,以上称为rehash

1.2 构造HashMap

构造HashMap时,常用的构造函数是无参构造函数和指定capacity的构造函数。loadFactor建议保持默认值0.75不动。

在HashMap中,capacity必须是2的幂,若指定的数值不是2的幂,那么会取大于该数值且最接近该数值的2的幂数作为初始容量。做这件事的,是构造函数中调用的tableSizeFor(initialCapacity)方法。

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
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;
  this.threshold = tableSizeFor(initialCapacity);
}

在创建HashMap实例时,并没有将hash表创建出来。这一步被推迟到了第一次调用put()方法的时候。

  • JDK 1.7

在这里插入图片描述
在这里插入图片描述

  • JDK 1.8

在这里插入图片描述

在这里插入图片描述

2. 向HashMap中存放数据

向HashMap中存放数据,调用的方法是HashMap的put()方法。HashMap使用key计算出一个hash值,然后和hash表长度,即Entry[]数组长度取模,得到存放该数据的hash桶的下标index。

2.1 为啥capacity的值要是2的幂?

2.1.1 性能提升

capacity的值为啥是2的幂,这个事的背景源于上面提到的取模操作。

取模操作很简单,常规的写法如下:

int result = num1 % num2;

但是JDK这帮人比较追求极致,要把取模这事的性能优化一波,而位运算是性能最diao的,于是提出了用按位与&这种操作来搞定这件事。

那么,按位与就等同于取模了吗?那不可能。它有个前提,就是只有当对 2 n 2^n 2n取模时才等效于对 2 n − 1 2^n - 1 2n1做按位与。

X % 8;
等价于
X ^ (8 - 1);

为啥呢?

正常的取模操作,都是先做除法,假设X是13,那么13 % 8​第一步就是要13 / 8,而13 / 8在二进制角度来看,等价于13 >>> 3,即右移动3位。那么被移出来的这3位正好是取模的结果。

在这里插入图片描述

被移出来的三位101转成十进制整数得5,正好是13%8的结果。

上面提到,13 % 8 等价于 13 ^ (8 - 1),从二进制角度来看:

在这里插入图片描述

从上图就可以比较清晰的看出其中的秘密了。

JDK 1.7的代码看着清晰一点:

在这里插入图片描述

JDK 1.8代码也是同样的方式:

在这里插入图片描述

2.1.2 干掉负数

public native int hashCode();

hashCode()方法返回的是一个int,它可没说是正整数啊,所以是能得到负数的。那 负数 % n 得到的还是个负数,hash表可没有index为负数的位置。

那么在计算index前,得干掉负数。

最容易想到的,取hashCode()绝对值。但这个不可行,如果hashCode()得到的是Integer.MIN_VALUE,那一取绝对值,溢出了,因为int的取值范围是 − 2 31 -2^{31} 231 2 31 − 1 2 ^ {31} -1 2311

在二进制中,第一位代表符号位,0是正,1是负。那把符号位改成0就行了。按位与操作,由于相与的数是一个整数,那它的符号位是0,即使hash值是负的,按位与后符号位也变0了.

比如,-13 ^ (8 - 1)

在这里插入图片描述

2.2 HashMap中的hash()函数

上面提到,想计算hash桶的位置,要用key的hash值和capacity - 1按位与。那么,key的hash值是怎么计算的?

很容易想到,Object类中提供了hashCode()方法,直接用key.hashCode()就完事儿了。实际上HashMap中也是这么做的,只不过它在这个基础上,又做了些骚操作,并将这一坨操作封装到一个hash()方法中。

  • JDK 1.7 hash()

    final int hash(Object k) {
      int h = hashSeed;
      if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
      }
    
      h ^= k.hashCode();
    
      // 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);
    }
    
  • JDK 1.8 hash()

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

这是想干啥?

还得回到那个按位与操作来看。

hash值是一个int类型整数,int是4字节,32位。从中间来一刀,前16位我们称为高位,后面16位为低位。

我们调用"hello".hashCode()得到99162322,用二进制表示就是00000101_11101001_00011000_11010010。

在这里插入图片描述
假设现在HashMap的capacity是16,想看看"hello"应该放在哪个桶,就要计算hashCode & (16 - 1),即99162322 & 15:

在这里插入图片描述

从上图可以发现,这个按位与基本上都是低16位在那忙活,高16位没它啥事儿啊。。。

假设有一连串key-value过来要放到HashMap中,恰好这一串key的hashCode()计算出来的数值,它们的高16位不同,但低16位相同,那他们几个与capacity - 1按位与之后,得到的结果是相同的,这就发生了碰撞,它们将放到同一个hash桶中。这不是我们想看到的结果,我们希望元素在hash表中越分散越好。那咋办?

HashMap的hash()函数中引入的一套操作就是为解决这个问题而生,称为“扰动”。刚才不是高16位没啥事么,那想办法让高16位也参与进来,那碰撞的概率不就小了么。

1.7 和 1.8版本的HashMap的初衷都是一样的,但1.8中的实现方式更简单,即让高16位与hashCode()得到的值做按位异或,得到新的值作为最终参与按位与的hash值。

还用刚才"hello"的例子结合1.8版hash()方法来看:

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

h就是"hello".hashCode(),h >>> 16得到高16位。

在这里插入图片描述
然后再按位异或:

在这里插入图片描述

这样我们拿新的hash值再去做&,比之前直接用hashCode()的值去做&,冲突的概率会降低。

2.3 特殊处理

2.3.1 key为null

当key为null的时候,HashMap的hash函数该key分配的hash值是0,因此key为null的键值对,会被放入0号位hash桶中。

需要注意的是,key=null的键值对总是会放进0号位hash桶,但不代表0号位hash桶只能放key=null的键值对,任何取模为0的键值对都会放进去。

2.3.2 hash冲突

当1个以上的key被分配在同一个hash桶中时,称为hash冲突。HashMap中解决的办法是让这些在同一个桶中的键值对自成单链表。

在这里插入图片描述

如图,B、C、D被分配在3号桶,它们自成一个单链表。

在冲突严重的情况下,链表会越来越长,查询效率也会收到影响,JDK1.8中对此采取了优化。当链表长度超过8个节点时,会转为红黑树。同样,当一个hash桶中的节点数小于6时,会从红黑树再回到链表。

// 一个hash桶中的节点数超过该值 链表 -> 红黑树
static final int TREEIFY_THRESHOLD = 8;
// 一个hash桶中的节点数小于该值 红黑树 -> 链表
static final int UNTREEIFY_THRESHOLD = 6;

需要注意的是,一个良好的hash算法+合适的扩容机制,可以很好的将键值对分散地分布在hash表中,在某一个hash桶上,hash冲突的概率符合泊松分布。

Ideally, under random hashCodes, the frequency of
  * nodes in bins follows a Poisson distribution
  * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
                                       * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

3. 扩容机制

当hash表中key-value键值对数量达到一定值后,hash表会扩大其长度,并将已有的键值对重新分布在新的hash表中。扩容 + 键值对的重排 = rehash。

扩容的阈值即threshold = capacity * loadFactor;

每次扩容,新的容量 newCapacity = oldCapacity * 2;

3.1 扩容的时机

达到阈值或超过阈值后,一定会扩容吗?这在JDK 1.7和JDK 1.8中的实现有所不同。

  • JDK 1.7 —— 节选自put()和addEntry()方法

    public V put(K key, V value) {
            if (table == EMPTY_TABLE) {
                inflateTable(threshold);
            }
            if (key == null)
                return putForNullKey(value);
            int hash = hash(key);
            int i = indexFor(hash, table.length);
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    
            modCount++;
            addEntry(hash, key, value, i);
            return null;
        }
    
    void addEntry(int hash, K key, V value, int bucketIndex) {
      if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
      }
    
      createEntry(hash, key, value, bucketIndex);
    }
    

JDK 1.8 —— 节选自putVal()方法 640~651和662~663行

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 (++size > threshold)
  resize();

有啥不同呢?

  • 扩容时机不同

    在1.7中,会先算出key的hash值,求得hash桶号,然后准备插入键值对。在插入之前,判断当前hash表中键值对个数是否已经>=阈值。若这个条件满足,还会进一步判断待插入的键值对所分配的hash桶是否为空,如果是空桶,则该元素直接放入桶中,此时并不会扩容。

    在1.8中,在计算hash桶号之后,会直接将键值对插入到对应的桶中,之后再判断当前hash表中键值对个数是否超过了阈值,若超过阈值则发生扩容。

  • 是否重复计算hash值

    在1.7中,若插入元素前,满足扩容条件,则hash表发生扩容,并将已有键值对重新分布。之后会再次计算带插入key的hash值,并分配hash桶。也就是说,待插入的key前后被计算了两次hash值。

    在1.8中,由于元素先插入,再扩容,因此只会计算一次hash值。

3.2 键值对重排

hash表扩容后,对现有键值对的位置重排的策略在1.7和1.8中也有不同的实现。

1.7中比较简单,会拿Entry对象中保存的hash值再与当前hash表长度取模,计算新的桶位,然后将其插入到相应的位置。

由于每次扩容,新容量 = 原容量 * 2,再加上 hash & (capacity - 1)这种取模手法,新的桶位与原来的桶位相比,有一个规律:

新桶位 = 原桶位 + 原hash表容量,即 index = oldIndex + oldCapacity;或者还是原来的位置不动。

为啥?回想一下hash & (capacity - 1)。

假如oldCapacity = 8,8 - 1 = 7即00000111。

扩容后,newCapacity = oldCapacity * 2 = 16,16 -1 = 15 即00001111。

假如hash值是13,扩容前、扩容后取模过程如下:

在这里插入图片描述

假如hash值是5,扩容前、扩容后取模过程如下:

在这里插入图片描述

注意看有颜色的一列,扩容后,蓝色部分0变1,与hash值相应的那一位相与,若结果也0变1,则新桶位 = 原桶位 + 原hash表容量;若保持0不变,则新桶位 = 原桶位。

介于这一点,在1.8中,会先遍历原桶中的所有元素,然后与oldCapicity按位与,若结果为0,则表示扩容后位置不变;结果不为0,则扩容后新桶位 = 原桶位 + 原hash表容量。

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

4. 并发问题

HashMap不是线程安全的,这在1.7和1.8中有不同的表现。

4.1 1.7的环形链表

1.7中,表现为并发场景下,rehash时可能出现环形链表。原因是,当发生hash冲突时,HashMap将被分配在同一个桶中的节点形成一个链表。在1.7中,采用的是头插法,即后来的元素从链表的头部插入。

如分别put()三个值A、B、C,且它们三个hash冲突,分配在同一个桶中,则它们在桶中的链表是C -> B -> A -> null。

当rehash时,会从头节点开始,依次遍历链表中的元素(即取出C,再取出B,再取出A),计算新的桶位,并继续按头插法将节点插入到新的桶位中。

假设扩容后A、B、C依然冲突,那么它们在重新分配后,正确的顺序应该由C -> B -> A -> null变为 A -> B -> C -> null。前后的顺序颠倒,导致在并发时,有可能形成一个环。

4.2 1.8的节点丢失

1.8中,将插入顺序改为了尾插法,即后来的元素从链表尾部插入。不会颠倒元素顺序。但在并发场景下,可能发生节点丢失。

即put()三个值A、B、C,且它们三个hash冲突,分配在同一个桶中,则它们在桶中的链表是A -> B -> C -> null。

假设现在HashMap中已经有了A,之后一个线程put(B),另一个线程put©,它们此时拿到的当前节点都是A,都要一个执行A.next = B,一个执行A.next = C。在并发时,它们其中一个的操作会被另一个覆盖掉,即发生了节点丢失。

5. 遍历顺序

5.1 HashMap不保证有序

HashMap不保证遍历时的顺序与插入元素时的顺序相同。HashMap在遍历键值对时,使用的是其内部实现的迭代器,其迭代的顺序是从hash表0号桶开始,向后找到第一个非空的桶,然后按链表的顺序,依次遍历元素。

如图:

在这里插入图片描述

5.2 LinkedHashMap保证有序

LinkedHashMap能够保证遍历顺序与插入顺序相同;还能按元素访问的频率的从小到大的顺序遍历。

5.2.1 保证遍历与插入顺序相同

LinkedHashMap继承了HashMap,主要扩展了Node节点,将节点与节点之间,会按插入顺序,使用双向链表联系起来。迭代器遍历顺序也不是从0号桶开始向后遍历,而是从双向链表的头节点开始,向后遍历;先插入的在前,后插入的在后。

5.2.2 按访问频率遍历

LinkedHashMap有一个构造器,可以指定一个accessOrder的boolean值,默认为false。当指定为true时,每次get()操作后,会将目标节点设为双向链表的尾节点。因此,越是最近访问的节点,越靠后;相反,访问频率低的节点,会慢慢被移到链表前端。

在遍历时,迭代器从头节点一次向后遍历,即保证了遍历顺序等同于节点的访问频次,从小到大排列。

在此基础上,LinkedHashMap可以作为LRU缓存的实现。

在创建LinkedHashMap实例时,重写它的removeEldestEntry()方法,该方法会在插入节点后调用。若它返回true,则会在插入新节点后,同步删除掉头指针指向的节点。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
  LinkedHashMap.Entry<K,V> first;
  if (evict && (first = head) != null && removeEldestEntry(first)) {
    K key = first.key;
    removeNode(hash(key), key, null, false, true);
  }
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
  return false;
}

下面是MyBatis中,使用LinkedHashMap实现LRU缓存的例子:

public class LruCache implements Cache {

  private final Cache delegate;
  private Map<Object, Object> keyMap;
  private Object eldestKey;

  public void setSize(final int size) {
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;

      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          eldestKey = eldest.getKey();
        }
        return tooBig;
      }
    };
  }
}

参考资料

https://www.hollischuang.com/archives/2091 hash函数分析

https://blog.csdn.net/yimi099/article/details/62043566 hashmap扩容理解

https://coolshell.cn/articles/9606.html 1.7 环形链表死锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值