1.前言
HashMap是我们日常编程中常用的数据结构
本文将从以下几个方面讲解HashMap:
1)HashMap中的重要概念(基于1.8)
2)HashMap的源码解析(基于1.8)
3)1.7与1.8HashMap的区别
2.HashMap中的重要概念
HashMap是由数组和链表组成的数据结构,数组里存放的单元叫做Node,每一个Node包含一个key-value键值对。
当调用put函数插入数据时,会根据key的hash值找到数组中对应的index,并把他放入数组中。若该数组的index上已经有数据(key值不相同),则会在该index的位置上形成链表(链表数据的插入是在链表的末尾位置),如下图中的index1、3所示。如果链表上的node数量超过8个,链表会转化成红黑树。
HashMap中的几个重要属性:
1)size 表示HashMap中的Node总数量
2)capacity 指HashMap中数组的容量,默认值为16
3)loaFactor 装载因子,用来衡量HashMap满的程度,默认值为0.75
4)threshold 表示size大于threshold时会执行resize操作(扩容,每次扩容会重新创建一个capacity为原来两倍的Node数组,并且会把原来数组中的所有Node重新hash到新的数组中去),threshold=capacity*loaFactor
了解了HashMap中的几个重要属性后,接下来说一下几个关于HashMap的常见问题。
问1:为什么resize操作需要重新hash?
答:因为index = HashCode(Key) & (Length - 1),可以看到与数组的长度有关,resize后数组的长度变化了,所以需要重新进行hash找到对应的index。
问2:HashMap的capacity默认值为什么为16?
答:在使用2的n次幂时,Length - 1的二进制值每一位都为1,这样计算index的结果就等于HashCode的后几位,只要HashCode本身分布均匀,hash的结果就是分布均匀的。
问3:1.7中链表的插入为头部插入,为什么到1.8中变成了尾部插入,这两种方式有什么区别?
答:这个问题留到讲1.7与1.8HashMap的区别时再进行具体讨论。
3.HashMap的源码解析
首先我们来看HashMap的几个构造方法
//创建一个负载因子为0.75的HashMap对象
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; //默认值为0.75
}
//创建一个初始容量为initialCapacity,负载因子为0.75的HashMap对象
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//创建一个初始容量为initialCapacity,负载因子为loadFactor的HashMap对象
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//最大容量不能超过2的30次方
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//1
}
//依据所给定的map中的内容创建一个内容一样的HashMap
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
//1处调用 返回离传入参数最近的2的n次幂 比如3返回4 5返回8
static final int tableSizeFor(int cap) {
int n = cap - 1;//自减1 防止已经是2的n次幂了
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
对于tableSizeFor方法里的移位运算和与运算,这里做一个简单的例子:
1)比如传入的参数是17 减一后为16 (对应二进制 10000)
2)然后对10000进行左移1位 得到01000 然后将二者进行或操作 得到11000
3)然后继续对11000进行左移2位操作 得到001100 然后将二者进行或操作 得到111100
4)以此类推最终会得到111111,一个不小于传入参数的且离传入参数最近的2的n次幂
从构造方法中我们可以看出,再实例化HashMap的时候,只对loadFactor和threshold赋了值,并没有进行数组的创建。而数组会在第一次调用put方法时被创建,接下来我们来看put方法
public V put(K key, V value) {
//这里通过hash(key)算出key对应的hash值
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
//通过hash值寻找hash桶中的位置会忽略高位,这样做可以综合hashCode的高位和低位,以减少hash碰撞
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//tab为null或者长度为0,则用resize创建一个tab
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//这里是HashMap的初始化操作,创建数组
//找到hash值对应的位置,如果当前位置为null,直接存入数据
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) {//循环直到队列尾部
//循环到队尾还没找到相同的key,新建node插入到链表尾部
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就退出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { //找到了相同的key,把旧值替换掉
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//LinkedHashMap预留方法
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//容量超过threshold,进行扩容
if (++size > threshold)
resize();
//LinkedHashMap预留方法
afterNodeInsertion(evict);
return null;
}
可以看到putVal方法中首先进行了tab是否为null的判断,如果为null就会调用resize方法,resize方法不仅能够进行扩容,还能对node数组进行创建。接下来我们来看看resize方法。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//原来的table不为空
if (oldCap > 0) {
//若大小已经大于等于最大值就不进行扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//对其容量x2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//说明是通过HashMap()以外的三个构造方法创建的,threshold>0,且内容为空,第一次put的时候会调用
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {//说明是通过HashMap()来创建的,threshold=0,且内容为空,第一次put的时候会调用
//给newCap和newThr均赋默认值
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];//创建Node数组
table = newTab;
//旧table不为null,把值移到新table里
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)
//依据hash值重新找到在新table里的位置
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;
// 将同一桶中的元素根据(e.hash & oldCap)是否为0分成两种链表
// 为0位置不变,否则位置移动oldCap个
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;
//将头指针放在新table中,位置没变
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
//将头指针放在新table中,位置增加了oldCap个
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize方法中会对原始table大小进行判断如果不为0,则newCap = oldCap << 1;如果为0 ,会对newCap赋值。然后依据newCap 创建一个新的node数组。如果老的数组里有内容,则会进行重新的hash操作,把值移到新数组里。最后我们来看一下get方法。
public V get(Object key) {
Node<K,V> e;
//找不到key对应的node就返回null
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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 &&
((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);
}
}
//找不到就返回null
return null;
}
4.HashMap1.7与1.8的区别
1)数据结构:1.7为数组+链表,存放的单元叫Entry;1.8为数组+链表+红黑树,存放的单元叫Node,链表length>8会转化成红黑树,红黑树元素个数<=6会转回链表。
2)数据插入方式:1.7为头插法;1.8为尾插法。
3)hash计算方式:1.7扰动处理(4次位运算,5次异或运算);1.8扰动处理(1次位运算,1次异或运算)。
4)扩容后index的计算方式:1.7全部重新计算;1.8原位置或原位置+oldcap。
最后来回答一下一开始的问3。在多线程操作HashMap的时候,头插可能会由于扩容转移数据时,链表前后顺序导致,产生死循环。而尾插扩容转移数据时,链表前后顺序不变,就不会出现死循环的现象。那么这是否意味着1.8的HashMap可以用于多线程当中呢?答案也是不行的,因为通过之前的源码分析,我们看到put和get方法并没有加上同步锁,在多线程进行操作的时候,依然可能出现put的数据和get的不一样的情况,所以在多线程操作的时候,还是需要用到ConcurrentHashMap,以保证线程安全。