HashMap源码分析
Posted 陌路旧梦
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap源码分析相关的知识,希望对你有一定的参考价值。
一、HashMap的数据结构:
在JDK1.8之前,HashMap采用桶+链表实现,本质就是采用数组+单向链表组合型的数据结构。它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap通过key的hashCode来计算hash值,不同的hash值就存在数组中不同的位置,当多个元素的hash值相同时(所谓hash冲突),就采用链表将它们串联起来(链表解决冲突),放置在该hash值所对应的数组位置上。
结构图如下:
在JDK1.8中,HashMap的存储结构已经发生变化,它采用数组+链表+红黑树这种组合型数据结构。当hash值发生冲突时,会采用链表或者红黑树解决冲突。当同一hash值的结点数小于8时,则采用链表,否则,采用红黑树。关于红黑树的理解,可以参考大牛的博客 :
一步一图一代码,一定要让你真正彻底明白红黑树
这个重大改变,主要是提高查询速度。它的结构图如下:
二、HashMap源码分析:
1.继承于AbstractMap;下面是它的基本属性:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;
}
2.插入(put)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
我们可以看到,Hashmap存储底层都是调用putVal函数;下面我们来具体分析一下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;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
* 处理过程:
* 1、(n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中).
* 2、若桶中已经存在元素,则比较桶中第一个元素(数组中的结点)的hash值,key值.
* a).hash值相等,key相等,则将第一个元素赋值给e,用e来记录
* b).hash值不相等,即key不相等;为链表结点,从尾部插入新结点;
* c).若结点数量达到阈值,转化为红黑树。
* 迭代index索引位置,如果该位置处的链表中存在一个一样的key,则替换其value,返回旧值
*/
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;
}
// 判断链表中结点的key值与插入的元素的key值是否相等.相等则跳出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;//用新值替换旧值
afterNodeAccess(e);
return oldValue; // 返回旧值
}
}
//扩容和更改map结构的计数器+1
++modCount;
// 阈值默认0.75*16,实际大小大于阈值就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
注:从源码中我们可以看到,hashmap的put是有返回值的,返回的是前妻value的值;是个很nice的设计!
3.获取(get)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
我们可以看到,底层是调用getNodel函数;下面我们来具体分析一下getNode函数:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 判断合理条件:table已经初始化,长度大于0,根据hash寻找table中的项也不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
/*
* 处理过程:
* 1、桶中第一项(数组元素)hash值相等,key相等;则取第一个的值。
* 2、桶中不止一个结点:
* a).若为红黑树结点,在红黑树中查找。
* b).若为链表结点,在链表中查找。
*/
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;
}
3.红黑树的查找
final TreeNode<K,V> getTreeNode(int h, Object k) {
return ((parent != null) ? root() : this).find(h, k, null);
}
我们可以看到,底层是调用getNodel函数;下面我们来具体分析一下getNode函数:
/**
* 从根节点p开始查找指定hash值和关键字key的结点
* 当第一次使用比较器比较关键字时,参数kc储存了关键字key的比较器类别
* 如果给定哈希值小于当前节点的哈希值,进入左节点
* 如果大于,进入右结点
* 如果哈希值相等,且关键字相等,则返回当前节点
* 如果左节点为空,则进入右结点
* 如果右结点为空,则进入左节点
* 如果不按哈希值排序,而是按照比较器排序,则通过比较器返回值决定进入左右结点
* 如果在右结点中找到该关键字,直接返回
* 进入左节点
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
if ((ph = p.hash) > h)
p = pl;
else if (ph < h)
p = pr;
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
4.扩容的情况:
/**
*初始化或者是将table大小加倍。
*如果为空,则按threshold分配空间,
*否则,加倍后,每个容器中的元素在新table中要么呆在原索引处,要么有一个2的次幂的位移
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 保存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; //容量翻倍,使用左移,效率更高
}
else if (oldThr > 0) //初始容量为阈值threshold
newCap = oldThr;
else { //使用缺省值(如使用HashMap()构造函数,之后再插入一个元素会调用resize函数,会进入这一步)
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 { // 保持原有顺序
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);
/**
* 新表索引:hash & (newCap - 1)---》低x位为Index
* 旧表索引:hash & (oldCap - 1)---》低x-1位为Index
* newCap = oldCap << 1
* 举例说明:resize()之前为低x-1位为Index,resize()之后为低x位为Index
* 则所有Entry中,hash值第x位为0的,不需要哈希到新位置,只需要呆在当前索引下的新位置j
* hash值第x位为1的,需要哈希到新位置,新位置为j+oldCap
*/
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容处理会遍历所有的元素,时间复杂度很高;
经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。
所以,说尽量避免进行扩容处理,也就意味着,遍历元素所带来的坏处大于元素在桶中均匀分布所带来的好处。
结论:进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
5.HashMap并发的情况;
多线程下,要避免使用HashMap,因为从以上HashMap的数据结构我们可以知道:
如果多线程同时操作HashMap的同一个hash值下的链表时,插入和删除都有可能会导致操作丢失。
那多线程想要使用HashMap这种数据结构怎么办呢?后面找到了这个家伙,
ConcurrentHashMap是Java 5中支持高并发、高吞吐量的线程安全HashMap实现。
*(jdk1.5以前是使用Hashtable。我们知道,Hashtable则使用了synchronized,
而synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,安全的背后是巨大的浪费)*
于是,又想看看ConcurrentHashMap的源码,看它是怎么做到的:
代码6千多行,就看一下最主要的吧,其实它就是增加了Segment静态类,来把并发的数组分成多个segment组成,
每一个segment包含了对自己的hashtable的操作,比如get,put,replace等操作,
这些操作发生的时候,对自己的 hashtable进行锁定。
由于每一个segment写操作只锁定自己的hashtable,所以可能存在多个线程同时写的情况,
性能无疑好于只有一个 hashtable锁定的情况。
/**
* Stripped-down version of helper class used in previous version,
* declared for the sake of serialization compatibility
*/
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
参考资料:
以上是关于HashMap源码分析的主要内容,如果未能解决你的问题,请参考以下文章