本文的思维导图如下:
JDK1.8 HashMap源码一步一图(万字)解析
数据结构
原理说明
jdk1.8中的hashmap采用了数组+链表+红黑树进行数据的存储.
原理图
引入红黑数的好处
- 引入原因:提升hashmap的性能:
解决1.7发生hash碰撞后链表过长而导致索引效率慢的问题.
利用红黑树快速增删改查的特点将 时间复杂度由原来的O(n)变为O(logn)
应用场景:在链表长度>8的时候,将链表转换为红黑树. 在扩容时桶中元素小于等于6 的时候由红黑树转换为链表。
选择6和8,中间有个差值7可以有效防止链表和树频繁转换
何时存数组,何时存链表,何时存树?
1).无冲突时,存放在数组.
2).有冲突 & 链表长度小于8的时候:存放在单链表
3).冲突&链表长度>8时:存放在红黑树;
关于红黑树的了解:https://www.jianshu.com/p/e136ec79235c
1.1.红黑树的数据结构
①定义:
一种特殊的二叉查找树
②特点:
1)每个节点的颜色 排列规则是 根节点和空叶子节点是黑色 其他节点是红色.
2)父子节点必须是不同颜色
3)从一个节点到该节点的所有子孙节点的所有路径上包含相同数量的黑色节点(如上图第二行红色节点下面的黑色节点都是四个黑色节点)
红黑树相对接近平衡二叉树,因为他保证了没有一条路径会比其他路径长出2倍.
4)时间复杂度o(logn)
为何使用红黑树,不适用二叉查找树.
二叉查找树的时间复杂度在子节点过长形成链表时为o(n).
基础属性
size
集合map的元素数量
loadFactor
负载因子,默认是0.75,可以通过构造函数进行修改,不建议修改.该值是经过数学运算得出的最适合扩容的一个值.
为什么负载因子的大小默认为0.75而不是1呢.
这涉及到一个数学运算.
根据HashMap的扩容机制,他会保证capacity的值永远都是2的幂。
那么,为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。
如果我们把负载因子设置成1,容量使用默认初始值16,那么表示一个HashMap需要在"满了"之后才会进行扩容,显然会发生更多的hash碰撞.
总结:负载因子默认为0.75是最适合减少hash碰撞又能保证 loadFactor*capacity的结果是一个整数的一个值
threshold
集合的容量,初始构造方法可传入,如果不传,默认为0(这里跟jdk1.7不一样.jdk1.7是默认在初始化的时候赋值为16的.)
jdk1.8是在第一次调用putval方法的resize扩容的时候初始化为12的.
table
散列表(Hash table,也叫哈希表)
transient Node<K,V>[] ,table为hashmap存储的node数组.可以看到数组中存储的元素就是Node. 每个node中都有next指针用于指向下一个node节点.
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
modCount(了解)
简而言之就是为了记录hashmap的结构修改的次数(rehash的次数).具体用处在hashmap中用不到,但是在其他地方用的到.跟一个异常有关系ConcurentModificationException.该异常一般在迭代器遍历的时候有用处.任何一个拥有迭代器的集合都会在用迭代器遍历时出现并发情况下的线程并发修改集合的时候抛出该异常.
迭代器在初始化时会将迭代器中的modCount指向集合中的modCount,那么在遍历迭代器的时候每次都会去进行比较迭代器中的modCount和集合中的modCount是否一致,不一致就抛出异常(代表其他线程对集合进行修改了.此时集合的modCount跟迭代器中的是不一样的.).
HashMap内部类
链表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) { this.hash = hash; this.key = key; this.value = value; this.next = next; } .... }
- node节点包含了四个属性.其中next属性是维护的自己节点的下一个节点元素.从而组成了一个单向链表.
- 这里node节点的四个属性值是通过构造方法传过来的.这里做个记录.后面entry类和treenode类都要通过该node的构造函数进行赋值.
红黑树TreeNode
/** * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn * extends Node) so can be used as extension of either regular or * linked node. */ static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links 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); } …… }
- 因为红黑树继承了entry,而entry继承了node.所以红黑树的属性其实包含了entry的两个属性,也就是前节点和后节点.还有node的四个属性.
-
- 也就是如下:
final int hash; final K key; V value; Node<K,V> next; 两个entry属性在红黑树中没有使用 Entry<K,V> before; Entry<K,V> after; TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red;
- 从上面的链表源码我们知道其实treenode和entryd的构造方法其实都调用了super(hash, key, val, next);也就是node的构造方法
-
- 所以一开始的treenode节点的初始化其实就已经包含了node节点的属性了.然后在put值到红黑树树化后继续加入元素.
- 其实每个treenode节点既包含了node几点的四个属性值hash,key,value,Node,又包含了自己的五个属性值parent,left,right,prev,red.这也就是为什么说每个treenode节点是原始节点的大小的两倍(下面有提到该特征的影响)
- 红黑树包含的实际使用的这九个属性.其中.
-
- prev+next属性为了构造链表使用
- parent,left,right属性是为了构造红黑树.
方法解析
基础构造方法
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //1 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
- 可以看到第二个构造方法跟1,7的构造方法是不一样的.只是初始化了负载因子.默认的threshold是没有被初始化的.
- 那么具体啥时候才初始化呢?
-
- 其实是在第一次putvalue时resize扩容的时候.如果只是构造一下.默认threshold是0或者传入的初始值.
put方法
代码如下:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
hash(key)计算hash值
- 代码如下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
hash寻址算法相较于jdk1.7的变化还是很大的.
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
- 解析如下
- 假设key.hashCode()方法获得的h变量值如下:
1111 1111 1111 1111 1010 0111 0111 1000
- h >>> 16 就变成了下面所示
0000 0000 0000 0000 1111 1111 1111 1111
(h = key.hashCode()) ^ (h >>> 16)上面两个值进行异或操作如下
如果异或是指要异或的a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0
原始值hashCode : 1111 1111 1111 1111 1010 0111 0111 1000
右移16位之后的值: 0000 0000 0000 0000 1111 1111 1111 1111
得到的结果为: 1111 1111 1111 1111 0101 1000 1000 0111
- 可以看到得出的值是高16为和低16为共同参与运算后得出的值.
- 总结:
-
- jdk1.7中hash值计算hash值时只是低16位参与运算.而jdk1.8中则先将高16位移到低16位,然后用原先的hashcode值的低16和通过移动高位后的低16(也就是原先的高16位)进行异或操作.这样的话可以保证hash值的低16为内,可以同时保留原先高16位和低16位的特征.
- 原先jdk1.7的hash计算得出的hash值,会导致一定的hash冲突.而通过让新的hash值的低16位包含hashcode高低16位的特征.可以尽可能的减少hash冲突的问题.让元素尽可能的留在数组上.
真正存元素数据的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; //1 if ((tab