一、HashMap的顶部注释

既然是从头剖析HashMap那么就研究的更加的深入和彻底一些,我们不放过任何细小的地方进行源码的解析,所以首先我们先来看看HashMap的顶部注释,看看官方是如何对于HashMap做的注解

  1. /* Hash table based implementation of the <tt>Map</tt> interface. This
  2. * implementation provides all of the optional map operations, and permits
  3. * <tt>null</tt> values and the <tt>null</tt> key. (The <tt>HashMap</tt>
  4. * class is roughly equivalent to <tt>Hashtable</tt>, except that it is
  5. * unsynchronized and permits nulls.) This class makes no guarantees as to
  6. * the order of the map; in particular, it does not guarantee that the order
  7. * will remain constant over time.
  8. /
  • 基于哈希表的Map接口实现。 该实现提供了所有的可选的映射操作,并且允许key和value为空
  • HashMap对象的key、value值均可为nullHahTable对象的key、value值均不可为null。 且两者的的key值均不能重复,若添加key相同的键值对,后面的value会自动覆盖前面的value,但不会报错
  • 还有一个不同点就是在同步上,HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的

1.1 为什么HashMap的key值允许为空?但是HashTable的Key值不允许为空?

HashMap从源码分析,HashMap的put方法是实现如下:
image.pngimage.png
HashMap在put的时候会调用hash()方法来计算key的hashcode值,可以从hash算法中看出当key==null时返回的值为0。因此key为null时,hash算法返回值为0,不会调用key的hashcode方法。

HashTable从源码分析:
image.png
上面可以看出当Hashtable存入的value为null时,抛出NullPointerException异常。如果value不为null,而key为空,在执行到int hash = key.hashCode()时同样会抛出NullPointerException异常
**
总结:

  • HashMap在put的时候会调用hash()方法来计算key的hashcode值,可以从hash算法中看出当key==null时返回的值为0。因此key为null时,hash算法返回值为0,不会调用key的hashcode方法。
  • 当Hashtable存入的value为null时,抛出NullPointerException异常。如果value不为null,而key为空,在执行到int hash = key.hashCode()时同样会抛出NullPointerException异常

1.2 什么Hashtable是线程安全的,而HashMap是线程不安全的?

Hashtable解析
Hashtable在jdk1.1就有了,那么它是怎样实现线程安全的呢?主要看put、remove、get方法猜它肯定进行的同步控制的。于是看源码:

  1. //get它搞成了同步方法,保证了get的安全性
  2. public synchronized V get(Object key) {
  3. ……
  4. }
  5. //synchronized,同样
  6. public synchronized V put(K key, V value) {
  7. ……
  8. }
  9. //也是搞成了同步方法
  10. public synchronized V remove(Object key) {
  11. ……
  12. }

每个操作数据的方法都进行同步控制之后,由此带来的问题任何一个时刻只能有一个线程可以操纵Hashtable,所以其效率比较低

二、HashMap的数据结构

image.png

JDK1.8之前的HashMap都采用上图的结构,都是基于一个数组和多个单链表,hash值冲突的时候,就将对应节点以链表形式存储。如果在一个链表中查找一个节点时,将会花费O(n)的查找时间,会有很大的性能损失。到了JDK1.8,当同一个Hash值的节点数不小于8时,不再采用单链表形式存储,而是采用红黑树。

三、HashMap成员变量

  1. // 默认 桶(数组) 容量 16
  2. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  3. // 最大容量 2的31次方
  4. static final int MAXIMUM_CAPACITY = 1 << 30;
  5. // 默认负载因子
  6. static final float DEFAULT_LOAD_FACTOR = 0.75f;
  7. // 链表转树 大小
  8. // 当添加一个元素被添加到有至少TREEIFY_THRESHOLD个结点的桶中,桶中的链表转化为树形结构
  9. static final int TREEIFY_THRESHOLD = 8;
  10. // 树转链表 大小
  11. static final int UNTREEIFY_THRESHOLD = 6;
  12. // 最小转红黑树容量
  13. static final int MIN_TREEIFY_CAPACITY = 64;
  14. // 存储数据节点
  15. static class Node<K,V> implements Map.Entry<K,V>
  16. // 节点数组
  17. transient Node<K,V>[] table;
  18. // 数据容量
  19. transient int size;
  20. // 操作次数
  21. transient int modCount;
  22. // 在HashMap中,threshold = loadFactor * capacity。
  23. int threshold;

对比于JDK8之前的HashMap ,成员变量主要的区别在于多了红黑树的相关变量,用于标示我们在什么时候进行 list -> Tree 的转换。
再来看一下HashMap的一个内部类Node:

  1. static class Node<K,V> implements Map.Entry<K,V> {
  2. final int hash;
  3. final K key;
  4. V value;
  5. // 链表结构,存储下一个元素
  6. Node<K,V> next;
  7. Node(int hash, K key, V value, Node<K,V> next) {
  8. this.hash = hash;
  9. this.key = key;
  10. this.value = value;
  11. this.next = next;
  12. }
  13. public final K getKey() { return key; }
  14. public final V getValue() { return value; }
  15. public final String toString() { return key + "=" + value; }
  16. public final int hashCode() {
  17. return Objects.hashCode(key) ^ Objects.hashCode(value);
  18. }
  19. public final V setValue(V newValue) {
  20. V oldValue = value;
  21. value = newValue;
  22. return oldValue;
  23. }
  24. public final boolean equals(Object o) {
  25. if (o == this)
  26. return true;
  27. if (o instanceof Map.Entry) {
  28. Map.Entry<?,?> e = (Map.Entry<?,?>)o;
  29. if (Objects.equals(key, e.getKey()) &&
  30. Objects.equals(value, e.getValue()))
  31. return true;
  32. }
  33. return false;
  34. }
  35. }

四、HashMap 构造函数

HashMap 提供了四种构造函数:

  • HashMap():默认构造函数,参数均使用默认大小
  • HashMap(int initialCapacity):指定初始数组大小
  • HashMap(int initialCapacity, float loadFactor):指定初始数组大小,加载因子
  • HashMap(Map<? extends K, ? extends V> m)

其中我们最常用的构造函数看:

  1. public HashMap(int initialCapacity, float loadFactor) {
  2. // 初始容量不能小于0
  3. if (initialCapacity < 0)
  4. throw new IllegalArgumentException("Illegal initial capacity: " +
  5. initialCapacity);
  6. // 初始容量不能大于2^31
  7. if (initialCapacity > MAXIMUM_CAPACITY)
  8. initialCapacity = MAXIMUM_CAPACITY;
  9. // 负载因子=表中的记录数/哈希表的长度 不能小于0
  10. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  11. throw new IllegalArgumentException("Illegal load factor: " +
  12. // 初始化加载因子 loadFactor);
  13. this.loadFactor = loadFactor;
  14. // 设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行自动扩容操作
  15. this.threshold = tableSizeFor(initialCapacity);
  16. }
  17. /**
  18. * Returns a power of two size for the given target capacity.
  19. * tableSizeFor的功能(不考虑大于最大容量的情况)是返回大于输入参数且最接近的2的整数次幂的数。比如10,则返回16。
  20. */
  21. static final int tableSizeFor(int cap) {
  22. int n = cap - 1;
  23. n |= n >>> 1;
  24. n |= n >>> 2;
  25. n |= n >>> 4;
  26. n |= n >>> 8;
  27. n |= n >>> 16;
  28. return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
  29. }
  30. public HashMap(int initialCapacity) {
  31. this(initialCapacity, DEFAULT_LOAD_FACTOR);
  32. }
  33. public HashMap() {
  34. this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
  35. }
  36. public HashMap(Map<? extends K, ? extends V> m) {
  37. this.loadFactor = DEFAULT_LOAD_FACTOR;
  38. putMapEntries(m, false);
  39. }
  • 初始容量不能小于0,小于0的时候回跑出异常出来。初始容量不能大于2^31,当初始的容量大于2^31的时候就会设置最大的也是2^31
  • tableSizeFor算法返回大于输入参数且最接近的2的整数次幂的数。比如10,则返回16。

4.1 tableSizeFor算法

先来分析有关n位操作部分:先来假设n的二进制为01xxx…xxx。接着

  • 对n右移1位:001xx…xxx,再位或:011xx…xxx
  • 对n右移2为:00011…xxx,再位或:01111…xxx
  • 此时前面已经有四个1了,再右移4位且位或可得8个1
  • 同理,有8个1,右移8位肯定会让后八位也为1。

综上可得,该算法让最高位的1后面的位全变为1。最后再让结果n+1,即得到了2的整数次幂的值了。

现在回来看看第一条语句:
int n = cap - 1;

让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。

3、resize 如何实现的, 记住已经没有 rehash 了!!!(答:拉链 entry 根据高位 bit 散列到当前位置 i 和 size+i 位置)

4、为什么获取下标时用按位与 &,而不是取模 %? (答:不只是 &速度更快哦, 我觉得你能答上来便真正理解 hashmap 了)

五、put()方法

put方法可以说是HashMap的核心,我们来看看一个总的介绍图5、什么时机执行 resize?

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

上述方法是我们在开发过程中最常使用到的方法,但是却很少人知道,其实内部真正调用的方法是这个putVal(hash(key), key, value, false, true)方法。这里稍微介绍一下这几个参数:

  • hash 值,用于确定存储位置
  • key:存入键值
  • value:存入数据
  • onlyIfAbsent:是否覆盖原本数据,如果为true 则不覆盖
  • evict:table 是否处于创建模式

5.1 key的hash运算实现原理

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

5.1 HashMap什么时候执行resize,是如何执行resize的

面试题

1、HashMap在使用的时候需要注意什么

需要设置初始化容量的大小,而且最好是2的指数次方幂,需要设置初始化容量的大小是,因为如果默认使用的是初始值16的化,后面map中存放了大量的数据,就需要进行扩容的操作,每一次扩容都是需要进行rehash()的。为什么需要设置的是值是2的指数次方幂的大小呢?因为会在第一次进行put方法操作的时候,把当前的容量capacity设置为2的指数次方幂的大小,如果你自己设置的就是2的指数次方的值,那么就会减少计算的发生。那么为什么需要是2的指数次幂,

2、扩容为什么可能会出现死锁呢

2、为什么加载因子是0.75呢

2、HashMap的工作原理是什么?

HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node
HashMap源码 - 图5
以下是具体的put过程(JDK1.8版)

1、对Key求Hash值,然后再计算下标 2、如果没有碰撞,直接放入桶中(碰撞的意思是计算得到的Hash值相同,需要放到同一个bucket中) 3、如果碰撞了,以链表的方式链接到后面 4、如果链表长度超过阀值( TREEIFY THRESHOLD==8),就把链表转成红黑树,链表长度低于6,就把红黑树转回链表 5、如果节点已经存在就替换旧值 6、如果桶满了(容量16*加载因子0.75),就需要 resize(扩容2倍后重排)

以下是具体get过程(考虑特殊情况如果两个键的hashcode相同,你如何获取值对象?)

  • 当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,
  • 找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。

HashMap源码 - 图6

3、有什么方法可以减少碰撞?

扰动函数可以减少碰撞,原理是如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这就意味着存链表结构减小,这样取值的话就不会频繁调用equal方法,这样就能提高HashMap的性能。(扰动即Hash方法内部的算法实现,目的是让不同对象返回不同hashcode。)
使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?因为String是final的,而且已经重写了equals()和hashCode()方法了。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。

4、HashMap中hash函数怎么是是实现的?

我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式,我们来看看JDK1.8的源码是怎么做的:
static final int hash(Object key) {if (key == null){ return 0; } int h; h=key.hashCode();返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 //其中n是数组的长度,即Map的数组部分初始化长度 return (n-1)&(h ^ (h >>> 16));}

HashMap源码 - 图7
简单来说就是

1、高16bt不变,低16bit和高16bit做了一个异或(得到的HASHCODE转化为32位的二进制,前16位和后16位低16bit和高16bit做了一个异或) 2、(n·1)&hash=->得到下标

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

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

6、说说你对红黑树的见解?

HashMap源码 - 图8

1、每个节点非红即黑 2、根节点总是黑色的 3、如果节点是红色的,则它的子节点必须是黑色的(反之不一定) 4、每个叶子节点都是黑色的空节点(NIL节点) 5、从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

7、解决hash 碰撞还有那些办法?

开放定址法。

当冲突发生时,使用某种探查技术在散列表中形成一个探查(测)序列。沿此序列逐个单元地查找,直到找到给定的地址。 按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。 下面给一个线性探查法的例子   问题:已知一组关键字为(26,36,41,38,44,15,68,12,06,51),用除余法构造散列函数,用线性探查法解决冲突构造这组关键字的散列表。 解答:为了减少冲突,通常令装填因子α由除余法因子是13的散列函数计算出的上述关键字序列的散列地址为(0,10,2,12,5,2,3,12,6,12)。 前5个关键字插入时,其相应的地址均为开放地址,故将它们直接插入T[0],T[10),T[2],T[12]和T[5]中。 当插入第6个关键字15时,其散列地址2(即h(15)=15%13=2)已被关键字41(15和41互为同义词)占用。故探查h1=(2+1)%13=3,此地址开放,所以将15放入T[3]中。 当插入第7个关键字68时,其散列地址3已被非同义词15先占用,故将其插入到T[4]中。 当插入第8个关键字12时,散列地址12已被同义词38占用,故探查hl=(12+1)%13=0,而T[0]亦被26占用,再探查h2=(12+2)%13=1,此地址开放,可将12插入其中。 类似地,第9个关键字06直接插入T[6]中;而最后一个关键字51插人时,因探查的地址12,0,1,…,6均非空,故51插入T[7]中。

8、如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组来重新调整map的大小,并将原来的对象放入新的bucket数组中。
这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置

9:为什么String, Interger这样的wrapper类适合作为键?

String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。