Redis的学习暂时告一段落了,应该还算比较完整,后续再想到其他知识点再补充吧。今天开始就进入Java集合的学习和总结了,我是这样打算的:集合三巨头——HashMap、ConcurrentHashMap和ArrayList会详细介绍(源码+细节),其它比如HashTable、Vector、HashSet、TreeSet等,会根据面试点介绍,必要时也会介绍部分源码。那么下面我们开始介绍集合第一篇文章吧。
ps:HashMap的知识点还是挺多的,所以要分多次介绍,本节就先着重介绍HashMap JDK1.8版本前后的变化。
一、HashMap的作者
开始学习之前,我们先介绍下HashMap的4位作者:
Doug Lea:Java并发大神、设计并实现了java.util.concurrent并发包,也参与了部分集合的源码编写,比如HashMap、TreeMap。
Josh Bloch:Java集合创办人,《Effective Java》一书作者,前Google首席Java架构师。
Arthur van Hoff:JVM的设计者之一,还参与了Java核心类库的设计与开发,比如集合类(HashMap、HashTable等)、I/O类(InputStream、FileInputStream等)等。
Neal Gafter:增强for和泛型特性的主要设计者之一,还参与了包括对集合框架的改进和其它API的设计。
毫无疑问,个顶个的大神,不然怎么能设计出如此精妙的HashMap。
二、HashMap的数据结构
ok,进入正题。
首先说下HashMap的特点:
继承抽象类AbstractMap,并实现了Map接口。
非线程安全。
key和value都允许为null。
默认初始容量16,扩容的话会扩为原来的2倍。
在Java中,数组和链表是保存数据比较简单的数据结构,数组的特点是寻址容易,插入和删除困难,链表是寻址困难,插入和删除容易。
HashMap在JDK1.8之前的存储结构是数组和链表,如下图:
在JDK1.8中为了解决hash冲突导致某个链表长度过长,影响put和get的效率,引入了红黑树,如下图:
三、节点变化
在JDK 1.7版本中,每个桶中的元素都需要一个单独的Entry对象:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
//...剩余代码省略
}
在JDK 1.8版本中,用Node代替了旧版本的Entry,hash字段也变成了final,并且为了支持红黑树还引入了TreeNode:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//...剩余代码省略
}
TreeNode:
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;
//...剩余代码省略
}
四、扩容时的头插法和尾插法
想必大家也知道,JDK 1.7版本之前,HashMap扩容的时候,会将元素插入链表头部,即头插法,那么在并发扩容的时候,会产生循环引用的问题。不过在JDK 1.8的时候改用了尾插法来解决了这个问题,那么JDK 1.7版本之前采用尾插法的原因是什么?尾插法是如何解决的呢?
4.1 JDK 1.7尾插法
4.1.1 采用头插法的原因
如下图(不够严谨,仅简单表明意思),原来是A->B->C,扩容后就会变为C->B->A(也就是反转链表了):
采用头插法的原因:
因为JDK的开发者认为,后插入的数据使用的概率更高,所以就通过头插法把它们放到队列头部,这样就可以提高查询效率。
源码如下:
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K, V> e = src[j];
if (e != nul1) {
src[j] = null;
do {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity);
//节点直接作为新链表的根节点
e.next = newTable[il;
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
4.1.2 产生并发的原因
假设现在有两个线程进行扩容,线程1执行到了Entry<K, V> next = e.next;的时候被卡住了,如下图:
此时线程2开始执行,当线程2完成扩容后:
此时线程1恢复,开始执行:e.next = newTable[il;newTable[i] = e;e = next;,会变为如下图:
接着,进入下一次循环,继续执行:e.next = newTable[il;newTable[i] = e;e = next;,如下图:
因为此时e!=null且e.next==null,执行最后一次循环,如下图:
可以看到A和B已经循环引用了,当下次get数据的时候,如果get不到,则会一直在A、B之间循环遍历,导致CPU飙升。
4.2 JDK 1.8的尾插法
前面产生循环引用的问题,在JDK 1.8时采用尾插法修复了。本身头插法的本质就是反转原链表,才有出现循环引用的问题,改为尾插法,原来是什么顺序,扩容后就还是什么顺序,就不会出现循环引用的问题。
ps:关于JDK 1.8扩容源码的分析会放到后续的文章中。
五、hash方法
先看源码。
JDK 1.7的hash方法:
final int hash(object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof string) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions(approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK 1.8:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到在JDK 1.7的时候,采用了很多位运算和异或运算,主要是为了增加结果的散列性,从而减少碰撞的概率。
JDK 1.8的时候,在保持速度的前提下,提高了hashCode的质量,以减少哈希冲突和提高数据分布的均匀性。
ps:1.8版本的hash方法会在put源码分析时详细介绍。
六、扩容机制
JDK 1.8前后的扩容机制也有很大变化:
在JDK 1.7的时候,扩容是通过resize和transfer两个方法相互配合完成的
在JDK 1.8的时候,把transfer的逻辑放到了resize中,所以1.8版本的resize方法变得很长,主要是因为引入了红黑树,需要考虑树化的过程。
还有就是元素扩容后的位置有变化:
JDK 1.7:扩容时,重新计算位置。
JDK 1.8:扩容是,需要看原来的hash值新增的bit位是1还是0,0的话位置不变,1的话重新计算(原数组下标位置加上原数组容量)。
End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。