前言
目前使用的JDK版本基本都是1.8或者以上的,大多数面试的情况下都会问到JDK7和JDK8版本的HashMap有什么不同点,HashMap在JDK1.8的时候将其底层在数组+链表的基础上增加了红黑树,这样做有什么好处呢?本文就简单的对JDK1.8版本的HashMap做一下简单的分析。
一、核心属性
Node节点
HashMap内部存放数据的节点使用的是Node,看一下源码
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...中间的赋值操作不看了
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
从上面的Node节点可以看出HashMap使用的链表是单向的,只有next节点没有上一个节点;HashMap里面存放内容的equals()比较是比较的Key和Value,如果Key和Value一样说明存放的数据是一样的。
属性
同样我们先看一下HashMap的核心属性都是什么意思,下面看源码的时候容易理解。
- DEFAULT_INITIAL_CAPACITY: 初始化容量默认为16,这里可以看一下源码中没有直接写16,而是使用了1<<4来表示的,为啥这样干呢,这是因为作者想告诉我们它的值使用的是2n 来表示的,使用2n 是为了减少hash碰撞的,具体的原理后面会说一下。
- MAXIMUM_CAPACITY: 这个表示HashMap的最大容量,同样使用的2n 来表示的,最大为230 。
- DEFAULT_LOAD_FACTOR: 加载因子,默认值为0.75,为啥使用0.75而不是其他数值呢?这个值代表着啥呢,是我们当前使用了多少,比如16*0.75=12,这时候需要扩容了,怎么算出来的这个问题太深,官方文档只告诉了我们这是对于时间和空间考虑的最优值。
- TREEIFY_THRESHOLD: 转换红黑树的阈值,默认为8,意思是当链表的长度大于8的时候就使用红黑树来存放。
- UNTREEIFY_THRESHOLD: 这个跟上面的相反的,转换为链表的阈值,默认为6,意思是当红黑树上面的数量小于6再转换成链表。
- MIN_TREEIFY_CAPACITY: 这个和转化红黑树的一样,默认值为64,意思是当数组的容量大于64的时候转换为红黑树。
- table: table是个Node类型数组,用来存放Map中的元素的,正常情况下存放个Node元素会根据其key找到table中对应的下标位置放进去,当下标位置有多个数据时,这多个数据就会形成链表存放在下标位置。
- entrySet: 这个就不用说了,Set类型的切片,遍历Map集合时有个切片遍历就是它。
- size: 跟其他集合一样,用来记录Map中键值对的数量。
- modCount: ConcurrentModificationException异常会用到,记录集合操作的,如果变了就CME了。
- threshold: 这个是扩容的实际值,上面0.75为加载因子,这个是16*0.75 = 12,这种具体的值。
- loadFactor: 加载因子,默认为0.75。
一开始HashMap是这样存放数据的,一个数组table用来存放数据,假设依次放入了key分别为12345的5个数据,最后一个存放5的时候下标找到了数组的第一个位置,由于这里已经存放了1,所以1->5形成链表结构存放。上面说的当table数组的长度大于64、链表的长度大于8的时候就换成红黑树来存放了。
二、核心方法
1.构造方法
无参构造
public HashMap() {
//无参构造就是把加载因子赋值了一下
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
传递初始大小和加载因子的有参构造
public HashMap(int initialCapacity, float loadFactor) {
...这是一堆校验传参是否合法
this.loadFactor = loadFactor; //加载因子赋值操作
//计算出阈值的初始值,这里并不是传多少就是多少,而是找到比这个数大的最小2的次方数据,可以自己单元测试试一下
this.threshold = tableSizeFor(initialCapacity);
}
传递Map类型的集合重新生成集合的有参构造
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR; //赋值初始化大小阈值
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size(); //获取传递Map的大小
if (s > 0) {
if (table == null) { //此时还没有创建数组则初始化创建数组
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold) //如果数组创建了,看数组大小是否够,不够resize扩容
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { //切片赋值
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict); //具体put方法后面讲解
}
}
}
resize()调整大小
上面说的构造方法中用到了resize()扩容方法,我们直接在这里看一下实现,包括后面put()增加也涉及到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;
if (oldCap > 0) { //如果原来的数组长度大于0
if (oldCap >= MAXIMUM_CAPACITY) { //原来的数组长度大于最大值了2^30,设置为int最大值2^32
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新的数组长度为old*2,如果小于最大长度并且原来的容量大于默认长度
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; //新的阈值为老的阈值*2
}
else if (oldThr > 0) // 如果老的阈值>0,新的数组长度为老的阈值大小
newCap = oldThr;
else { //初次的时候都加载为初始值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) { //新的阈值为0,上面没操作到阈值修改
float ft = (float)newCap * loadFactor; //新的阈值为数组容量*0.75,下面是判断最大值
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;
...省略了扩容后的重新计算index问题,这个比较简单就是以前放数据根据hash值取的下标位置,扩容后hash对应的位置就不对了,需要把所有数据重新放一遍
return newTab;
}
put()放数据值
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; //n是数组的长度,i是数组的index下标值
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; //e用来暂存数据,k暂存key值
if (p.hash == hash && //如果Hash值和Key都相等的时候,直接赋值
((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 { //如果哈希值和key有一个不相等
for (int binCount = 0; ; ++binCount) { //这是个死循环,通过里面操作跳出循环
//假设p.next不是空,则一直遍历e=p.next直到为空
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null); //直接将值挂在链表后面
if (binCount >= TREEIFY_THRESHOLD - 1) //当链表的长度达到了阈值也就是8转换为树结构
treeifyBin(tab, hash);
break; //跳出循环
}
//如果遍历的过程中遇到Key和hash值都相等的时候就跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; //e=p.next一直往下循环链表使用
}
}
if (e != null) { //最后查找到下标位置存在节点则进行数值替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); //对于Hash没用,留给LinkedHashMap子类实现的方法
return oldValue;
}
}
++modCount; //CME标志
if (++size > threshold)
resize(); //扩容
afterNodeInsertion(evict); //对于Hash没用,留给LinkedHashMap子类实现的方法
return null;
}
其实忽略红黑树和扩容的情况下看put()方法还是比较简单的,大致分为这几步:
- 看数组里面下标位置有没有存放数据,如果没有直接在该下标位置创建新数据。
- 数组下标位置有数据了遍历下标位置的链表,如果遍历完找不到相等的key就直接挂在末尾
- 如果遍历过程中找到相等的key了,把这个node记录下来
- 把找到相等key的node的值替换了。
下面再看一下put里面涉及到的红黑树操作,首先是我们取出来的p节点是个树节点的情况下把值挂在树上
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//下面是具体方法
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false; //树的搜索标志
//获取root节点,上面操作的节点p的父节点是空的话,那么p节点就是root节点
TreeNode<K,V> root = (parent != null) ? root() : this;
//从根节点开始循环遍历节点
for (TreeNode<K,V> p = root;;) {
//dir是遍历的方向、ph是p的hash值,pk是p的key值
int dir, ph; K pk;
if ((ph = p.hash) > h) //如果当前节点的hash值大于给的hash值,往左放
dir = -1; //-1代表左
else if (ph < h)
dir = 1; //1代表右
else if ((pk = p.key) == k || (k != null && k.equals(pk))) //如果当前节点的key与给的key一样
return p; //直接返回当前节点p
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
searched = true; //遍历完成修改遍历标识
if (((ch = p.left) != null && //一直遍历find查找相同key的节点
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q; //找到后返回查找的节点
}
//此时没有找到对应key的节点,比较一下两个key的大小确定方向
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
//上面重新判断了方向了,开始往该方向上挂新的节点了
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next; //当前节点的下一个节点
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); //创建一个新的节点
if (dir <= 0) //挂在左边
xp.left = x;
else
xp.right = x; //挂在右边
xp.next = x; //挂在链表的下一个节点
x.parent = x.prev = xp; //新的节点的父节点以及他的上一个节点都挂上当前节点
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//这是个红黑树重新旋转平衡的操作
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
其实梳理下来代码逻辑也是很简单的,看着复杂是判断条件太多了:
- 获取出root节点来,开始遍历树。
- 遍历树的过程中查找key相等的树节点,如果找到了直接返回。
- 没有找到,确定下当前节点与给的节点的方向。
- 确定完方向往对应的方向挂上新的节点。
- 红黑树进行自平衡操作。
还有一个方法是如果链表的长度到达了转化成树的阈值将链表转换为红黑树:
treeifyBin(tab, hash);
//看一下具体的转换过程
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)
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); //开始将双向链表构建成树结构了
}
}
看了上面的代码,前面部分主要是将原来的单向链表构建成了树节点的双向链表。最后通过treeify()再把双向链表构建成树结构。
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null; //root节点
//这里用头结点的点的方法,this代表的双向链表的头结点,开始遍历双向链表
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next; //取出下一个节点
x.left = x.right = null; //先将当前节点左右子节点清空
//第一次遍历没有root的,直接将当前节点设置为root
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}else { //第二次遍历开始挂树节点了
K k = x.key; //获取当前节点的key和hash值
int h = x.hash;
Class<?> kc = null;
//当前节点开始循环
for (TreeNode<K,V> p = root;;) {
//同样dir表示方向,下面的两个pHash和pKey
int dir, ph;
K pk = p.key;
//根据hash值判断是属于左子节点还是右子节点
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
//hash值相等再比较key和class同样判断出来方向
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); //红黑树的自平衡
}
大体思路就是从链表的第一个节点开始进行循环,把第一个节点设置为root,通过比较hash、key、class等信息确定好方向然后往下面开始插入挂节点。此时的root是我们随意指定的,挂完之后再重新自平衡一下找到真正的root把红黑树改为规范状态。
上面对HashMap进行了简单的分析,主要重点是put方法的整个逻辑,不需要知道具体每一步咋干的,只需要了解到大致的逻辑就可以了。看上面的代码分析要明白代码逻辑的重要性,我们写一个复杂代码的情况下最好先写好第一步干啥第二步干啥这样的步骤,然后去填充代码,这样代码才不会乱,如果太复杂最好每一步写一个方法,保证代码的可读性。
HashMap后面的get()方法就不分析了,因为在put()的时候已经涉及到get()操作了。
JDK7和JDK8中HashMap的区别
- 首先肯定是底层结构,JDK7用的数组+链表,当hash冲突太多的时候对应位置存放的链表太长了影响了后面的查询效率,所以在JDK8的时候引入了红黑树,链表达到一定数量就使用红黑树,当然红黑树也是有缺点的,就是新增的时候操作太复杂包括自平衡啥的,红黑树中新增效率低,面试的话主要围绕这个说说就可以了。
- 其实JDK7的时候HashMap中有一个漏洞,就是链表插入的时候是头部插入的,在一定条件(尤其多线程扩容)下会造成死循环,JDK8的时候修改成了尾部插入。