之前有分析过1.8之前的HashMap源码,今天再来看看1.8中HashMap,LinkedHashMap的比较有意思的代码及设计思路。新版HashMap相较于老版的数组+链表,多了红黑树这样的结构。
需要注意下,并不是说红黑树查找效率就一定比链表的效率高,而是与数组长度和链表长度有关系的。代码里的体现是链表的长度不小于8且数组的长度不小于64的时候才会将链表转化为红黑树。我们就进入正题。
列下着重分析的点:
- HashMap,LinkedHashMap的关系
- 加载因子的作用和默认的加载因子为什么是0.75f
- 为什么哈希表的容量(数组长度)是2的整数次幂
- put操作的实现源码及设计思路
- get操作的实现源码及设计思路
- 怎么进行扩容
- HashMap是怎么解决hash冲突的
(一)HashMap,LinkedHashMap的关系
1.LinkedHashMap是继承自HashMap,用源码说话
HashMap的类头是这样的
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
LinkedHashMap的类头是这样的
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
2.HashMap插入顺序与迭代顺序是不一致的,LinkedHashMap相比于HashMap,多维护一个双向链表结构,用于解决顺序的问题。这个点常用于LRU缓存的实现。
(二)加载因子的作用和默认的加载因子为什么是0.75f
我们知道,加载因子主要是在时间成本和空间成本上寻求一种最优解。
如果加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但是Hash碰撞的几率会变高,相应的链表长度会增加,造成的结果激素增加了查询时间成本;
如果加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,会增加扩容操作的次数,扩容操作也是很费时间和cpu资源的;
那么为什么选择0.75f呢?主要是泊松分布,0.75f的话碰撞几率最小。关于泊松分布,可以自行查阅百度百科。这里主要想表达的就是这个0.75f是有出处的。
(三)为什么哈希表的容量(数组长度)是2的整数次幂
length为2的整数次幂,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即进行&运算后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性。
而假设length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。
(四)put操作的实现源码及设计思路
HashMap中put
设计思路:通过hash值找到数组下标,然后分三条线:
- 第一条是对数组操作,数组当前下标没有值,就直接赋值;
- 第二条线是对链表操作,这里又分两条线:第一条,当前链表不存在一样的key,找到末尾的元素,对末尾元素的next赋值;第二条,存在一样的key,进行值value替换。
- 第三条是对树结构操作,这里也是分两条线:第一条,当前树中不存在一样的key,找到对应位置,进行赋值;第二条,存在一样的key,则返回当前。
公开的方法是这样的
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
里面又调用了方法 putVal(int hash, K key, V value, boolean onlyIfAbsent, 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;
//判断数组是否存在,如果不存在说明是第一次put数据,调用resize()方法
if ((tab = table) == null || (n = tab.length) == 0)
//resize方法负责tab数组的创建和扩容,这里new出新的tab数组,并把length赋给n
n = (tab = resize()).length;
//经过前面的判断,这里用hash&leng-1,计算出i,再去tab相应的下标查找,如果为null,
//说明数组这个位置没有被占用
if ((p = tab[i = (n - 1) & hash]) == null)
//直接new一个元素,放到tab的这个位置
tab[i] = newNode(hash, key, value, null);
else {//进入到这里,说明hash值碰撞了,需要采用链表存取数据了
Node<K,V> e; K k;
//这里是对数组中下标为i的链表头进行比较,若hash和key都一样,就把p赋给e
//说明存在相同的key,下面会进行value替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//进入到这里,说明链表表头不是同一个元素,要去链表里面操作
//这个if判断是否为TreeNode,因为TreeNode继承Node,且只有两种结构,
//所以先判断子类,如果不是就走Node结构的操作,不会遗漏
else if (p instanceof TreeNode)
//执行TreeNode的put方法
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//binCount带有计数器的功能,代表链表的长度
for (int binCount = 0; ; ++binCount) {
//这个if做了两件事:把p.next赋给e;判断p.next是否为null
if ((e = p.next) == null) {
//如果为null,将新put的元素链接到p的next元素
p.next = newNode(hash, key, value, null);
//这里拿到链表的长度,如果符合条件,就转换成树结构
//static final int TREEIFY_THRESHOLD = 8;
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//这里和前面一样,代表的是有同样的Key存在,下面会进行value替换
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//进入到这里,说明有同样的key存在,要进行value替换,return回去的是oldValue
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//这是个空方法,具体实现在LinkedHashMap
return oldValue;
}
}
//进入到这里,说明是添加新的元素,需要递增modCount和size
++modCount;
if (++size > threshold)//判断容量,如果大于临界值,进行扩容操作
resize();
afterNodeInsertion(evict);//这是个空方法,具体实现在LinkedHashMap
return null;//当put新元素的时候,return的值为null
}
这里提供下空方法的证据,源码如下
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
下面分析树结构的put方法TreeNode.putTreeVal方法
/**
* Tree version of putVal.
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;//k的类型
boolean searched = false;//是否遍历过树了
TreeNode<K,V> root = (parent != null) ? root() : this;//树的根节点
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;//dir树的左右方向,ph当前节点的hash,pk当前节点的key值
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
//根据当前节点key值、hash和传进来要插入的key值、hash一般都能得到一个左或右方向
//或者当前节点key值、hash和传进来要插入的key值、hash相等或equals
//但是还有一种情况就是hash相等key不equals
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
//走到这里说明:指定key没有实现comparable接口
//或者实现了comparable接口并且和当前节点的键对象比较之后相等
/**searched 标识是否已经对比过当前节点的左右子节点了
* 如果还没有遍历过,那么就递归遍历对比,看是否能够得到那个键对象equals相等的节点
* 如果得到了键的equals相等的的节点就返回
* 如果还是没有键的equals相等的节点,那说明应该创建一个新节点了
* find(h, k, kc)递归、while遍历ch的所有节点直到找到与k equals的节点否则就返回空
*/
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q;
}
// 走到这里就说明,遍历了所有子节点也没有找到和当前键equals相等的节点
dir = tieBreakOrder(k, pk);//把两个key的object的hashcode()方法生成的hashcode比较决定方向
}
TreeNode<K,V> xp = p; // 定义xp指向当前节点
/*
* 如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
* 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
* 如果以上两条当中有一个子节点不为空,这个if中还做了一件事,那就是把p已经指向了对应的不为空的子节点,开始下一轮的比较
*/
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 如果恰好要添加的方向上的子节点为空,此时节点p已经指向了这个空的子节点
Node<K,V> xpn = xp.next; // 获取当前节点的next节点
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); // 创建一个新的树节点
if (dir <= 0)
xp.left = x; // 左孩子指向到这个新的树节点
else
xp.right = x; // 右孩子指向到这个新的树节点
xp.next = x; // 链表中的next节点指向到这个新的树节点
x.parent = x.prev = xp; // 这个新的树节点的父节点、前节点均设置为 当前的树节点
if (xpn != null) // 如果原来的next节点不为空
((TreeNode<K,V>)xpn).prev = x; // 那么原来的next节点的前节点指向到新的树节点
moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡,以及新的根节点置顶
return null; // 返回空,意味着产生了一个新节点
}
}
}
下面分析链表转树结构的方法treeifyBin(tab, hash)
/**
* tab:元素数组,
* hash:hash值(要增加的键值对的key的hash值)
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
/*
* 如果元素数组为空 或者 数组长度小于 树结构化的最小限制
* MIN_TREEIFY_CAPACITY 默认值64,对于这个值可以理解为:如果元素数组长度小于这个值,没有必要去进行结构转换
* 当一个数组位置上集中了多个键值对,那是因为这些key的hash值和数组长度取模之后结果相同。(并不是因为这些key的hash值相同)
* 因为hash值相同的概率不高,所以可以通过扩容的方式,来使得最终这些key的hash值在和新的数组长度取模之后,拆分到多个数组位置上。
*/
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); // 扩容,可参见resize方法解析
// 如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,那么就有必要进行结构转换了
// 根据hash值和数组长度进行取模运算后,得到链表的首节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null; // 定义首、尾节点
do {
TreeNode<K,V> p = replacementTreeNode(e, null); // 将该节点转换为 树节点
if (tl == null) // 如果尾节点为空,说明还没有根节点
hd = p; // 首节点(根节点)指向 当前节点
else { // 尾节点不为空,以下两行是一个双向链表结构
p.prev = tl; // 当前树节点的 前一个节点指向 尾节点
tl.next = p; // 尾节点的 后一个节点指向 当前节点
}
tl = p; // 把当前节点设为尾节点
} while ((e = e.next) != null); // 继续遍历链表
// 到目前为止 也只是把Node对象转换成了TreeNode对象,把单向链表转换成了双向链表
// 把转换后的双向链表,替换原来位置上的单向链表
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
LinkedHashMap中put
LinkedHashMap延用的是父类HashMap中的put方法,大体思路是一样的,需要注意的地方有三个:
- 重写了newNode方法
- HashMap中未实现的方法afterNodeAccess
- HashMap中未实现的方法afterNodeInsertion
1.下面先看下newNode方法的源码:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMapEntry<K,V> p =
new LinkedHashMapEntry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
// link at the end of list
private void linkNodeLast(LinkedHashMapEntry<K,V> p) {
LinkedHashMapEntry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
在这个方法中,LinkedHashMap 创建了 Entry
,并通过 linkNodeLast
方法将 Entry 接在双向链表的尾部,实现了双向链表的建立。双向链表建立之后,我们就可以按照插入顺序去遍历 LinkedHashMap。
2.下面来看下LinkedHashMap中实现的afterNodeAccess方法,这里的变量accessOrder需要注意,是在构造函数中进行赋值的,默认是false,代表按插入顺序进行排序,如果为ture,代表按访问顺序进行排序。
这个方法在put操作中进行,意义并不是很大,因为我们正常put操作,就是在尾端进行追加元素的,主要意义是在get操作时,将要取出的元素排到尾端,这正是LRU缓存基于LinkedHashMap实现的原因之一。
设计思路主要分三种情况:
- 当前节点没有前驱,没有后继时,链表的head,tail都指向当前节点;
- 当前节点只有前驱,没有后继时,位置并没有移动;
- 当前节点有前驱,有后继时,从链表中,把当前节点拿出来,然后对当前节点的前后节点进行链接,然后把当前节点指向链表的末端。
// LinkedHashMap 中重写
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
// 如果 b 为空,表面 p 为头节点
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
// 将 p 接在链表的最后
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
3.下面来看下LinkedHashMap中实现的afterNodeInsertion方法,可以看到,removeEldestEntry默认返回false,但是当我们重写这个方法,根据我们自己的策略返回true时,就可以从head节点进行删除操作了。这正是LRU缓存基于LinkedHashMap实现的另一个原因。
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;
}
(五)get操作的实现源码及设计思路
HashMap中get
设计思路:跟前面put的思路刚好相反。
通过hash值找到数组下标,然后分三条线:
- 第一条是对数组操作,取出数组当前下标值,进行key判断,如果相同直接return;
- 第二条线是对链表操作,这里又分两条线:第一条,当前链表不存在一样的key,返回null;第二条,存在一样的key,return。
- 第三条是对树结构操作,这里也是分两条线:第一条,当前树中不存在一样的key,返回null;第二条,存在一样的key,return。
方法源码如下所示:
public V get(Object key) {
Node<K,V> e;//这里主要做判空操作,主要逻辑在下面的方法
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
LinkedHashMap中get
这里和父类的get基本一样,只有当查找的节点不为null且accessOrder为true时,多了排序的操作。
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
(六)怎么进行扩容
这里HashMap,LinkedHashMap是一样的。
设计思路:先计算 新的hash表容量和新的容量阀值,然后初始化一个新的hash表,将旧的键值对重新映射在新的hash表里。如果在旧的hash表里涉及到红黑树,那么在映射到新的hash表中还涉及到红黑树的拆分。
源码如下:
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
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 = 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;
}
(七)HashMap是怎么解决hash冲突的
我们就先来看看HashMap中,是如何计算hash值的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
代码是超级简单,hash值其实就是通过hashcode与16异或计算的来的,为什么要使用异或运算呢?因为相较于与,或运算,通过异或运算能够是的计算出来的hash比较均匀,不容易出现冲突。但是偏偏出现了冲突现象,这时候该如何去解决呢?在数据结构中,我们处理hash冲突常使用的方法有:开发定址法、再哈希法、链地址法、建立公共溢出区。而hashMap中处理hash冲突的方法就是链地址法。这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。正好与HashMap底层的数据结构相呼应。
哦了,上面列的7点就全都分析完了。以上是个人观点,如果有同行觉得哪里有问题,请在下方留言,我们一起进步啊!