HashMap源码分析
在jdk1.8中,HashMap的数据结构如上图所示,是由Node数组+链表/红黑树组成的,每个K-V对保存在一个Node结点中,看一下Node结点的定义,其实就是一个Map.Entry<K,V>的实现类,包括key的hash值,key,value和一个next指针。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
//树节点
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);
}
...
}
HashMap中定义的几个静态变量:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
/**
* HashMap的初始容量为2^4=16,容量表示table[]的长度,也是哈希桶的个数
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* HashMap的最大容量为2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子,当元素数量大于当前容量的0.75倍时,HashMap进行自动扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化阈值,当哈希桶上的链表长度大于等于8时进行扩容或转为红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 和树化阈值相反,树节点数量小于6时红黑树转为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小树化容量,当前容量大于等于64时,桶中链表才会转换为红黑树,否则只是扩容
*/
static final int MIN_TREEIFY_CAPACITY = 64;
...
}
看一下HashMap的put()方法是怎样插入结点的:
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table[]为空,初始化一个table[]
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据key的hash值定义key在table[]中的下标i,p作为结点指针判断下标i位置是否为空
//如果为空,直接在该位置创建一个Node结点就好了,否则进入p指向下标为i的结点进入else
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//进入else,说明要插入的k的hash值已存在,如果有相同的key,则替换,否则,插入链表或红黑树
//判断key是否相同,相同的话用e表示旧结点,后面进行替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果p是树节点,则将新结点插入红黑树中,p指向树的根节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//这个else表示将新结点插入链表,此时p指向链表头节点
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
//尾插法插入新节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度大于等于8,进行扩容,或者树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在链表中找到相同的key,则新value替换旧value
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;
}
总结一下put的流程:首先,判断插入节点的哈希地址是否存在元素,没有的话直接插入节点。有的话,首先判断插入节点和已有节点的哈希值和key值,如果相同,覆盖掉之间的节点。如果不相同,说明应该将这个节点插入红黑树或者链表。如果是插入链表,当链表长度超过阈值8时,先判断数组长度是否大于等于64时,是的话会将链表转换为红黑树结构,否则只是进行扩容。另外,当HashMap中的元素个数大于阈值时,也要进行扩容。
可以发现,HashMap的扩容时机有两个,一是当元素个数大于阈值时,二是当哈希桶的链表长度大于8,并且数组长度小于64时,接下来看看HashMap的扩容机制。
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;
}
//左移一位,新容量为旧容量的2^1倍,新容量不能超过最大容量
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);
}
//在这里计算新的threshold,新容量*负载因子
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;
//表示hash地址只有一个结点
if (e.next == null)
//计算元素新的下标,直接放入
newTab[e.hash & (newCap - 1)] = e;
//如果旧元素是树结点
//split会将树分成两个链表,这是因为扩容后重新计算数组下标
//hash值相同的结点可能会被分配到两个不同的桶中
//然后对两个链表进行处理,根据元素数量转为链表或红黑树
//最后放到新数组中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//这个else表示e后有链表,拆分成两个链表,分别处理,原因同上
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的扩容机制:HashMap的扩容时机有两个,一是当元素个数超过一定阈值时,二是当哈希桶的链表长度大于8,并且table[]数组长度小于64时,会触发扩容。新容量会扩充到原来容量的两倍,并根据新的容量创建一个新数组。接下来,遍历旧数组中的元素,根据元素的hash值重新计算元素在新数组中的下标,并放入对应的位置。如果碰到树或者链表,首先进行节点分裂,分裂成两个链表,这是因为原来在同一个哈希桶中的元素在扩容后可能会被分到两个不同的桶中,然后分别处理将元素放到新的数组中,另外,在这个过程中,如果桶中的元素数量超过8,链表会转换为红黑树,如果元素数量小于6,红黑树会退化成链表。
再看一下树化的方法:
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;
//第一次循环将root设为第一个节点,之后循环走else
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;
//p==null表示找到了插入的位置,将新节点x插入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;
}
}
}
}
//将最新的root节点放到数组的位置
moveRootToFront(tab, root);
}
为什么HashMap要使用红黑树呢?先看一下红黑树的性质:
- 每个节点非红即黑
- 根节点是黑色的
- 中序遍历由小到大
- 每个叶子节点都是黑色的空节点
- 红色节点的子节点一定是黑色的,反之不一定
- 从任意节点到它的叶子节点的路径中,黑色节点的数量相同
红黑树的这些性质保证了红黑树的近似平衡,跟链表相比,当数据量大时,红黑树查询的时间复杂度更低,跟完全平衡树相比,红黑树节省了很多平衡调整操作。因此,红黑树在各种操作下的性能都是比较好的。
另外,注意到在HashMap中提供了这么三个方法,但是并没有实现,这些方法是提供给LinkedHashMap使用的,LinkedHashMap继承了HashMap,并重写了这三个方法,接下来分析一下LinkedHashMap。
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
先看一下LinkedHashMap的数据结构,它是由一个个Entry组成的存储键值对的双向链表。Entry继承了HashMap的Node,有添加了两个前后指针。
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);
}
}
LinkedHashMap插入节点时会插入到链表末尾,因此可以按照插入顺序迭代元素,LinkedHashMap直接使用HashMap的put()方法插入元素,流程是一样的,当调用newNode()方法时,LinkedHashMap进行了重写,通过linkNodeLast()将新元素插入到双向链表的表尾。
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
另外,在put()中还调用了afterNodeAccess()和afterNodeInsertion(),看一下LinkedHashMap的实现。
先看afterNodeAccess(),它的作用是将元素移动到链表尾,触发时机是LinkedHashMap调用get()方法或者调用put()方法修改key的value时,根据这个机制可以想到利用LinkedHashMap来实现LRU缓存,将最近使用的元素移到链表末尾,注意需要设置accessOrder为true才能使用这个方法。
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;
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;
}
}
再来看一下afterNodeInsertion()方法,它的作用是移除链表头的元素,触发时机是添加完一个元素之后。
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);
}
}
不过这个方法需要满足removeEldestEntry(first)这个方法为true,它默认返回false,如果我们想利用它实现LRU缓存,可以这样重写,当元素数量超过缓存的容量了,就删除表头的元素,因为表头是最久没有使用的缓存。
protected boolean removeEldestEntry(Map.Entry < K, V > eldest) {
return size() > capacity;
}
还有一个方法,afterNodeRemoval(),这个也很简单,就是删除元素之后维护一下前后指针。
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
HashMap是线程不安全的,多线程操作HashMap时可能会出现一些线程安全问题,举几个例子:
1、两个线程进行put操作,当同时执行newNode()方法时,会导致第一个线程的元素丢失。
2、一个线程put,一个线程get,当put时恰好需要扩容,扩容过程中会创建一个新数组并赋值,在这个过程中,另一个线程可能get到空值。
若想线程安全的使用HashMap,可以使用ConcurrentHashMap。
在java8之前,ConcurrentHashMap通过分段锁机制来实现线程安全,ConcurrentHashMap采用的是Segment数组加HashEntry数组的结构,每个Segment包含一个HashEntry数组,由于Segment继承了ReentrantLock,它本身就是一个锁。因此只要hash值足够分散,多线程put的时候就会put到不同的Segment中,多个线程不会互相影响,put的时候,当前的Segment会锁住,从而保证线程安全。
在java8之后,ConcurrentHashMap改成和Node数组加链表红黑树的结构,通过对Node数组加锁来实现线程安全。如果添加数据的哈希地址是空的,通过自旋和CAS操作插入,否则对Node结点加锁,然后插入到链表或者红黑树中。
看一下ConcurrentHashMap的put的源码:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//数组为空,初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//f表示数组待插入位置下标的元素
//如果是空的,CAS插入新元素
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果正在扩容,帮忙一起扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//如果不是空的,说明要插入链表或红黑树
else {
V oldVal = null;
//对Node节点加锁,也就是对要插入的桶加锁
synchronized (f) {
if (tabAt(tab, i) == f) {
//如果是链表
if (fh >= 0) {
binCount = 1;
//遍历链表,插入元素
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果key相同,覆盖旧值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//尾插插入新元素
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//新元素插入树中
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//链表长度大于阈值8,转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
这段代码中有一个地方,通过if (fh >= 0)来判断是链表,这是因为ConcurrentHashMap和HashMap对树的存储不太一样,ConcurrentHashMap中树的根节点是一个TreeBin类型的节点,不存储元素,TreeBin节点的Hash值是-2,而HashMap中,树的根节点是直接存储元素的。
标签:分析,Node,hash,HashMap,else,链表,源码,key,null From: https://www.cnblogs.com/Linwei33/p/18409986