Java源码分析之HashMap
本文基于Java 8
HashMap使我们在开发过程中经常用到的数据结构,在面试过程中也会经常问到,本篇博文就基于JDK1.8具体分析一下HashMap的实现。
首先看一下HashMap中的静态变量和一些类变量:
// 默认最大容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// HashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转换为树型结构的临界值:
static final int TREEIFY_THRESHOLD = 8;
// Node数组,保存着链表或者树的头结点,每个Index位置称为一个桶
transient Node<K,V>[] table;
// Node的Set
transient Set<Map.Entry<K,V>> entrySet;
// HashMap的size
transient int size;
// 修改次数
transient int modCount;
// 扩容的阈值
int threshold;
// 加载因子
final float loadFactor;
接下来看一下table中的元素,也就是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;
}
//....
}
节点构成了HashMap的存储单元,每个节点都保存有该节点的key,key的hash值,value和next节点。
我们来回顾一下日常开发中经常用到的HashMap的方法:
put()
get(Object key)
entrySet()
remove(Object key)
首先从无参构造方法说起
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
默认构造方法很简单,只是初始化了一个loadFactor的值,这个值就是HashMap的负载因子,默认为0.75f
static final float DEFAULT_LOAD_FACTOR = 0.75f;
具体这个值怎么用,我们接下来再讲。
接下来看往HashMap里放数据的put()
方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put()
方法又调用了putVal()
方法,我们继续跟踪下去:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab: Node数组 i:根据hash值计算的要put的数据所在的桶的位置 p: talbe[i]桶上的头结点 n: table的长度
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果table为null的话,调用resize()方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果table[i]的值为空,直接new出来一个节点传入key和value并赋给table[i],put操作完成
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 如果p不为空并且p与要put的数据hash值和key值都相同,使用节点e来保存p,此时e不为空
e = p;
else if (p instanceof TreeNode)
// 如果p是树节点,调用putTreeVal()方法向树中put数据
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果p不是树节点,则向链表中put数据
for (int binCount = 0; ; ++binCount) {
// 遍历至链表的尾结点,此时e为空
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表中node超过 TREEIFY_THRESHOLD 个,则将该链表转化为树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 遍历过程中遇到hash值和key均和要put的数据相同的情况,直接跳出循环,此时e不为空
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// e不为空时将e节点的value替换为新值,并返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent参数为false或者e的当前value为空时进行替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//只有 e为空,也就是产生新的节点情况下size才会++,如果size > threshold 则对HashMap进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
通过分析putVal()
方法我们可以得到如下结论:
- HashMap的数组创建是在我们put第一个元素时调用
resize()
方法完成的 - HashMap只有在put一个新key的情况下才会增加节点,否则的话只是替换key所在节点的value
- HashMap的数组中,一开始是链表结构,在大于某个临界值时,会转化为树结构
那么 node在table数组中的下标是怎么确定的呢?
我们可以想一个最简单的方案,也就是key的hash值对table的length取余 即 hash % n,而在HashMap的实现实际上更精妙,它的做法是(n - 1) & hash,这个值运算后的结果就是 hash % n。举个例子,假设n = 8 hash = 11,那么hash转化为二进制是1011,n - 1转化为二进制是0111 两者进行&操作后值为0011,就是十进制的3,而 11 % 8的值也是3,这里用位运算其实是为了更高的执行效率。
细心的同学会发现,在putVal()
末尾执行了afterNodeInsertion()
方法, 这个是干什么用的呢?这里先不讨论,我们后面讲LinkedHashMap时再讨论。
接下来看resize()
方法:
final Node<K,V>[] resize() {
// 旧table
Node<K,V>[] oldTab = table;
// 旧tab的length
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧的 临界值
int oldThr = threshold;
// 新的最大容量,临界值
int newCap, newThr = 0;
if (oldCap > 0) {
// 超出HashMap设置的最大容量,直接设置为int的最大值
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 {
//无参构造HashMap时会走到这里,newCap的值为16 newThr的值为16 * 0.75
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 以上几个判断条件都没有对newThr进行赋值时会走到这里
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数组
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;// 将oldTab[j]的值赋给e 并将oldTab[j]位置置为null
if (e.next == null)
//如果e只有单一一个节点,直接找到e在newTab的下标并赋值
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 遍历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;
}
其他地方都好理解,但是我们看代码的54 和 55行,
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
这里有点绕,它定义了两个head和两个tail,这是干什么用的呢,我们来分析一下,假设旧数组的size为8,而扩容都是双倍扩容,那么新的容量就是16,而从上面put()
方法的分析可知,Node寻找下标位置都是通过hash对size取余,那么我们再假设旧数组下标在0位置上的几个Node他们的Hash值分别为0、8、16、24、32、40,那么对新的size 16取余后分别为0、0、8、0、8,因此可以得出如下结论:
数组扩容后,旧数组nidex上的链表只会出现在新数组的index位置和index+oldCap位置。所以定义两个头尾节点,分别管理低位和高位的链表。理解了这一部分之后,下面的这部分就好理解了。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
接下来我们分析HashMap的遍历。
一般情况下我们遍历HashMap都是用这种方式:
Iterator<Map.Entry<String, String>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, String> entry = it.next();
System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}
我们就一步步分析,首先看entrySet()
方法:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
这个就比较简单了,返回了一个EntrySet的对象,我们在生成迭代器时,就是调用的这个对象的迭代器方法,那么我们就看一下EntrySet这个类:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() {
return size;
}
public final void clear() {
HashMap.this.clear();
}
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
//。。。
}
内容有点多,我们先把其他的忽略掉,看一下iterator()
方法,也很简单,返回了一个EntryIterator对象,我们继续跟到EntryIterator类:
final class EntryIterator extends HashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
好吧,我们在遍历时是用的next()
方法找到了,调用了父类HashIterator的nextNode()
方法,继续跟下去:
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
这里也比较简单,就是返回了next这个节点,那么这个节点在哪里初始化的呢?我们看一下HashIterator的构造方法:
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
我们可以看到,next这个节点的值,在初始化的时候,指向了HashMap的第一个不为空的节点,在外部调用next时,nextNode()
方法会找寻下一个不为空的节点并返回。
接下来我们分析remove()
方法:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
remove()
方法调用了removeNode()
方法并返回要删除节点的vaule,那么就继续跟踪removeNode()
方法:
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
//tab: table n: table长度 index:删除节点的数组下标 p:要删除的节点
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 找到了key 和 hash均相同的节点
node = p;
//否则继续往下一个节点找
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 找到的节点刚好是某个桶的头结点,则将头节点赋给下一个节点
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
这个方法逻辑不太复杂,注释已经说的比较清楚了,这里不多做说明。
get(Object key)
方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
接下来看getNode(hash(key), key))
方法:
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;
}
同样这个方法比较简单,只是找到要查找的key所在的桶,遍历并寻找元素即可。
至此,HashMap的CURD和遍历都已分析完毕,下面我们继续分析HashMap的树形结构和转换的实现。
树形桶的节点结构
static final class TreeNode<K,V> extends LinkedHashMapEntry<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);
}
// 代码省略
}
TreeNode是树形结构的基本节点,它继承自LinkedHashMapEntry
类,我们看一下具体的实现
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
从以上代码我们可以看到,TreeNode是个红黑树的节点,Entry是个双向链表的节点,由此我们我们可以大胆猜测一下,如果一个桶的结构是树形结构,那么它应该是红黑树+双向链表的集合体,下面我们就来分析+验证这个猜测。
树形桶的生成
我们看一下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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
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 (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我们从第19行开始看
这一句的判断条件的意思是 当某个桶内的元素大于等于TREEIFY_THRESHOLD
这个值时,会把桶的链表结构转换为树形结构,我们继续分析treeifyBin()
方法:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// table的长度大于MIN_TREEIFY_CAPACITY时才会有执行树化操作,否则只是扩容
resize();
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);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
我们看第3行代码,从这一行代码的判断条件可以得出结论:只有在桶的个数大于等于MIN_TREEIFY_CAPACITY
个时,才会执行树化操作,否则只是扩容,由此我们可以得出如下结论:
HashMap将链表结构转化为树形结构有两个条件:
- 单个桶中节点个数大于等于
TREEIFY_THRESHOLD
(默认值为8) - table长度大于等于
MIN_TREEIFY_CAPACITY
(默认值为64)
继续分析treeifyBin(Node<K,V>[] tab, int hash)
方法,我们可以看到在接下来的do-while循环只是将目标桶内的单向链表结构转化为了双向链表结构,那么真正转为树形结构的操作在哪里呢?答案很明显,就是在hd.treeify(tab);
这一行,我们继续跟下去。
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
从代码可以看出,这其实就是把链表转化为红黑树的逻辑,关于红黑树我们后面再讲,现在只需要知道红黑树是一个平衡二叉查找树即可,而且综合之前的分析,我们可以看到其实TreeNode既是一个红黑树结构,又是一个双链表结构。
既然有treeify()
方法,那么相对的就会有unTreeify()
方法,我们来看一下:
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
方法的实现很简单,就是遍历红黑树把TreeNode节点转化为Node节点,也就是把红黑树+双向链表的数据结构转化为单向链表结构。
那么这个unTreeify()
方法是在哪里调用的呢?有两个地方,一个是removeTreeNode()
方法,一个是split()
方法,我们首先看removeTreeNode()
方法:
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) {
int n;
if (tab == null || (n = tab.length) == 0)
return;
int index = (n - 1) & hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
if (pred == null)
tab[index] = first = succ;
else
pred.next = succ;
if (succ != null)
succ.prev = pred;
if (first == null)
return;
if (root.parent != null)
root = root.root();
if (root == null || (movable && (root.right == null
|| (rl = root.left) == null || rl.left == null))) {
tab[index] = first.untreeify(map); // too small
return;
}
// 代码省略
}
代码很长,且大部分和红黑树的节点删除有关,我们把无关的代码省略,重点看一下untreeify()
执行的条件
if (root == null || (movable && (root.right == null
|| (rl = root.left) == null || rl.left == null))) {
tab[index] = first.untreeify(map); // too small
return;
}
我们来分析分析上面这个逻辑,进入这个untreeify()
的要求是,root == null, root.right ==null, root.left==null, root.left.left==null
四种情况,我们以7个节点的红黑树来分析,A为root节点。
所以进入这个方法主要有以下几种情况,我们一种一种的来分析当满足要求时,节点的个数。(这里默认认为知道红黑树的5个特点,主要是黑平衡)
当这四种情况都满足时,我们可以看出最多节点有如上图所示个数,可以为7个。(大于8个不考虑,因为大于8会变成红黑树)。
-
最多节点情况:当我们删除节点D时,只满足
root.left.left==null
这个条件,这棵树仍可以维持红黑树的特点,这时的最大节点数为6. -
最少节点情况:当EFG不存在时,在A,B,C,D中删除任意一个节点,都会满足上述四种规则中的一种。则存在最少节点情况,有3个节点。
以上情况都是会将树转化成链表,此时的节点是 3<= nodes <=6
,由此可以看出,当节点数在小于6时,是可能转化成链表,但不是绝对情况, 所以使用定义的变量(固定数量6)也不正确。只好通过判断去动态获取节点数。
节点数量原因分析
为什么在小于6的时候可能转换成链表,而在大于8的时候转化成红黑树?
主要通过时间查询节点分析,红黑树的平均查询时间为 log(n), 而链表是O(n),平均是O(n)/2。
当节点数为8时,红黑树查询时间3,链表查询时间是4, 可以看出来当红黑树查询效率大于了链表。(两个函数曲线问题,当节点更多是,比红黑树需要的时间更多)
当节点数为6时,为什么转换成链表,我认为主要时因为节点数太少,如果还是用红黑树,为了维持红黑树的特点,则需要翻转,左旋,右旋,等,更消耗性能。
为什么不是7是转化?
主要是为了给一个过渡,防止频繁转化。 也如上图,7个节点可能正好是一个满二叉树。
接下来看split()
方法:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
这个方法的调用是在resize()
方法中,结合我们上一篇讲过的链表结构resize的操作,这里的逻辑就不难理解了,其实就是把一个桶的树形结构拆分成高低位两个桶,如果低位的桶不适用红黑树的结构了,就调用unTreeify()
方法转化为链表结构。
以上分析了HashMap中的一些主要方法和它的一些实现方式,但是并没有分析为什么要用这种方式的实现,比如:为什么JDK8的HashMap扩容要使用双倍扩容?为什么最大容量要设置成2的整数幂?俗话说,知其然还要知其所以然,本篇就重点从设计思想上分析HashMap的实现,直面大师们的内心世界。
为什么hash算法得到的结果可以做到散列均匀分布?
首先看一下HashMap的putVal()
方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// 代码省略
}
第一个参数hash是调用HashMap的hash()
方法得到的,
我们看一下这个方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这段代码叫做“扰动函数”
大家都知道上面代码里的key.hashCode()
函数调用的是key键值类型自带的哈希函数,返回int型散列值。
理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-2147483648到2147483648。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。
但问题是一个40亿长度的数组,内存是放不下的。你想,HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。结合我们前两篇文章的分析,取模的运算是使用位运算h & (length-1)来实现的。
顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为2的整数幂用二级制表示一定是100···这种形式,因此2的整数幂减1一定是11111····这种形式, 这样(数组长度-1)正好相当于一个“低位掩码”, “与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。
10100101 11000100 00100101
& 00000000 00000000 00001111
——————————————————————————————
00000000 00000000 00000101 //高位全部归零,只保留末四位
但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。
这时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
由此我们可以得出结论:
hash()
方法是对hashcode又一次进行了散列,增加值的随机性。
但是读到这里,我们依然没有看到key.hashCode()
方法是如何做到散列的。我们追踪key的hashCode()
方法:
public native int hashCode();
这是个native的方法,我们看一下这个native方法的实现:
static inline intptr_t get_next_hash(Thread * Self, oop obj) {
intptr_t value = 0 ;
if (hashCode == 0) {
// 根据Park-Miller伪随机数生成器生成的随机数
value = os::random() ;
} else
if (hashCode == 1) {
// 此类方案将对象的内存地址,做移位运算后与一个随机数进行异或得到结果
intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3 ;
value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom ;
} else
if (hashCode == 2) {
value = 1 ; // 返回固定的1
} else
if (hashCode == 3) {
value = ++GVars.hcSequence ; // 返回一个自增序列的当前值
} else
if (hashCode == 4) {
value = cast_from_oop<intptr_t>(obj) ; // 对象地址
} else {
// 通过和当前线程有关的一个随机数+三个确定值
unsigned t = Self->_hashStateX ;
t ^= (t << 11) ;
Self->_hashStateX = Self->_hashStateY ;
Self->_hashStateY = Self->_hashStateZ ;
Self->_hashStateZ = Self->_hashStateW ;
unsigned v = Self->_hashStateW ;
v = (v ^ (v >> 19)) ^ (t ^ (t >> 8)) ;
Self->_hashStateW = v ;
value = v ;
}
value &= markOopDesc::hash_mask;
if (value == 0) value = 0xBAD ;
assert (value != markOopDesc::no_hash, "invariant") ;
TEVENT (hashCode: GENERATE) ;
return value;
}
Java8默认是通过和当前线程有关的一个随机数+三个确定值,运用Marsaglia’s xorshift scheme随机数算法得到的一个随机数。
具体源码分析可以参考:
从以上的分析我们可以得出如下结论:
hashCode()
方法通过随机数+内存地址进行一系列操作得到一个随机值作为hashcode,这个随机值是均匀的
HashMap()的hash()
方法将hashcode和hashcode右移16位后进行异或运算,进一步增加随机性。
equals()
方法和hashcode()
的关系是什么?
这一点又是一个长篇大论,下面直接说结论:
- 若重写了
equals(Object obj)
方法,则有必要重写hashCode()
方法。 - 若两个对象
equals(Object obj)
返回true,则hashCode()
有必要也返回相同的int数。 - 若两个对象
equals(Object obj)
返回false,则hashCode()
不一定返回不同的int数。 - 若两个对象
hashCode()
返回相同int数,则equals(Object obj)
不一定返回true。 - 若两个对象
hashCode()
返回不同int数,则equals(Object obj)
一定返回false。 - 同一对象在执行期间若已经存储在集合中,则不能修改影响hashCode值的相关信息,否则会导致内存泄露问题。
具体的分析可以参考下面的文章:
HashMap 的容量不是2的整数幂有什么问题么?
我们再看看刚刚说的那个根据hash计算下标的方法:
tab[(n - 1) & hash];
其中 n 是数组的长度。其实该算法的结果和模运算的结果是相同的。但是,对于现代的处理器来说,除法和求余数(模运算)是最慢的动作。
上面情况下和模运算相同
a % b == (b-1) & a ,当b是2的指数时,等式成立。
我们说 & 与运算的定义:与运算 第一个操作数的的第n位于第二个操作数的第n位如果都是1,那么结果的第n为也为1,否则为0;
当 n 为 16 时,与运算 101010100101001001101 时,也就是
1111 & 101010100101001001000 结果:1000 = 8
1111 & 101000101101001001001 结果:1001 = 9
1111 & 101010101101101001010 结果: 1010 = 10
1111 & 101100100111001101100 结果: 1100 = 12
可以看到,当 n 为 2 的幂次方的时候,减一之后就会得到 1111···· 的数字,这个数字正好可以掩码。并且得到的结果取决于 hash 值。因为 hash 值是1,那么最终的结果也是1 ,hash 值是0,最终的结果也是0。
我们说,hash 算法的目的是为了让hash值均匀的分布在桶中那么,如何做到呢?试想一下,如果不使用 2 的幂次方作为数组的长度会怎么样?
假设我们的数组长度是10,还是上面的公式:
1010 & 101010100101001001000 结果:1000 = 8
1010 & 101000101101001001001 结果:1000 = 8
1010 & 101010101101101001010 结果: 1010 = 10
1010 & 101100100111001101100 结果: 1000 = 8
看到结果我们惊呆了,这种散列结果,会导致这些不同的key值几乎全部进入到相同的插槽中,形成链表,性能急剧下降。
所以说,我们一定要保证 & 中的二进制位全为 1,才能最大限度的利用 hash 值,并更好的散列,只有全是1 ,才能有更多的散列结果。如果是 1010,有的散列结果是永远都不会出现的,比如 0111,0101,1111,1110…,只要 & 之前的数有 0, 对应的 1 肯定就不会出现(因为只有都是1才会为1)。大大限制了散列的范围。
Java源码分析之LinkedHashMap
前面主要分析了HashMap的实现,再来继续分析LinkedHashMap的实现,LinkedHashMap继承自HashMap,也就是它是HashMap功能的扩展,它和HashMap的区别在于内部维护了一个双向链表来维护数据的读取顺序。下面我们结合源码来看一下。
我们还是从构造方法看起
public LinkedHashMap() {
super();
accessOrder = false;
}
这里调用了父类也就是HashMap类的构造方法,并且将accessOrder值置为false,这个accessOrder值在LinkedHashMap中有很重要的作用,接下来我们会讲。
我们回顾一下HashMap的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;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
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 (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
我们看这段代码的第7行和第18行,这个newNode()
在LinkedHashMap类中被重写了,我们来看一下重写的实现:
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;
}
它和HashMap的newNode()
方法实现有两个区别
- LinkedHashMap会生成LinkedHashMapEntry对象作为节点,而HashMap生成的是Node的对象
- LinkedHashMap相比HashMap执行了
linkNodeLast()
方法
我们看一下linkNodeLast()
方法:
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维持的双向链表的尾部,这就是LinkedHashMap可以保持元素插入顺序的关键点所在了!
还记得之前分析HashMap的putVal()
方法时,当时我提到在put()
方法的最后执行了afterNodeInsertion(evict)
方法,这个方法在HashMap类中并没有具体的实现,而是交给了LinkedHashMap去实现,HashMap中的afterNodeInsertion()
如下所示:
// Callbacks to allow LinkedHashMp post-actions
void afterNodeAccess(Node<K, V> p){}
void afterNodeInsertion(boolean evict){}
void afterNodeRemoval(Node<K, V> p){}
它具体的实现在LinkedHashMap类中,我们跟踪进去看一下:
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMapEntry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
这里我们看到,如果所有判断条件均成立的话,会删除首节点,也就是第一个进入的节点,这个特性在哪里会被用到呢?我想很多人都能猜得到,就我们日常开发中经常使用的LRUCache!
我们接着看putVal()
方法的第33行,这里执行了afterNodeAccess(e)
方法,通过前几篇的分析我们知道,这段代码的执行条件是当要put的节点的key值在HashMap中存在,那么这个方法的作用是什么呢,我们跟踪进去看一下:
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMapEntry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMapEntry<K,V> p =
(LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
p.after = null;
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.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
我们看到,在afterNodeAccess()
方法中,当accessOrder为true且尾结点不为目标节点时,会把目标节点放置到双向链表的队尾,我们思考一下,把最近访问的元素放到队尾,这个特性在哪里可以用到呢? 答案还是LRUCache!
我们看一下java中LRUCache类的实现:
public class LruCache<K, V> {
private final LinkedHashMap<K, V> map;
//代码省略
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
//代码省略
}
这里在构造LinkedHashMap对象时调用的LinkedHashMap的三参构造方法,我们看一下这个构造方法:
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
我们看到,第三个参数LRUCache设置为true,结合我们之前的分析,我们可以得出结论:
只有当accessOrder这个参数为true时才会对节点按照访问顺序重新排序,也就是只有accessOrder为true时LinkedHashMap才能实现LRUCache的特性。
LinkedHashMap对元素的有序遍历
LinkedHashMap对节点的遍历和HashMap类似,也是调用entrySet()
得到节点的集合并调用iterator()
方法得到迭代器。我们来看一下LinedHashMap的遍历实现:
首先看entrySet()
方法:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es;
}
代码很简单,返回了一个LinkedEntrySet的对象,接着看LinedEntrySet类:
final class LinkedEntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() {
return size;
}
public final void clear() {
LinkedHashMap.this.clear();
}
public final Iterator<Map.Entry<K,V>> iterator() {
return new LinkedEntryIterator();
}
// 代码省略
}
还是只看迭代器相关,看一下iterator()
方法,返回一个LinkedEntryterator类,看下这个类:
final class LinkedEntryIterator extends LinkedHashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
看下nextNode()
方法:
final LinkedHashMapEntry<K,V> nextNode() {
LinkedHashMapEntry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
这里很简单了,看逻辑就知道是取链表的下一个节点,那么这个next节点是在哪里初始化的呢?看一下LinkedHashIterator的构造方法:
LinkedHashIterator() {
next = head;
expectedModCount = modCount;
current = null;
}
在这里吧next指向了双向链表的头结点。
至此LinkedHashMap的遍历逻辑就很清晰了,其实就是对其内部持有和维护的双向链表的遍历。
Java源码分析之HashSet
我们先提出几个问题:
- HashSet怎么保证添加元素不重复?
- HashSet是否允许null元素?
- HashSet是有序的吗?
- HashSet是同步的吗?
我们都知道Set是一个内部元素不会重复的集合,那么HashSet是如何做到这一点的呢?我们结合源码看一下
先看一下HashSet类的成员变量:
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
很简单,就两个变量,map是一个HashMap的对象,map的key用来储存HashSet中的元素,PRESENT用来作为map的value值
再看一下构造方法:
public HashSet() {
map = new HashMap<>();
}
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
可以看到,HashSet所有的构造方法都执行了对map的初始化。
再看一下add()
方法:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
代码也很简单,就是把要添加的元素作为key,PRESENT作为value往map中put数据。
再看一下iterator()
方法:
public Iterator<E> iterator() {
return map.keySet().iterator();
}
也很简单,就是返回map的keySet的迭代器。
至此,我们就可以回答本篇开头提出的问题:
- HashSet内部是用HashMap的key来保存元素,因为HashMap的特性,key是不可能重复的,故HashSet也不可能有重复数据。
- HashMap可以以null作为key,所以HashSet可以有null值
- HashMap不是有序的,故HashSet也不是有序的
- HashMap不是线程安全的,故HashSet也不是线程安全的。
因为HashSet其实就是对HashMap的封装,代码比较简单,源码暂时先分析到这里。
原文链接:
参考文章: