jdk8中的HashMap与jdk7相比最大的不同就是引入了红黑树,当链表中的元素大于8个时就会把链表转成红黑树,下面来简单分析下8中的HashMap源码。
一、构造方法
一般情况我们使用HashMap时都会使用无参数的构造方法,先来看这个构造方法
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
这个构造方法中只给loadFactor也就是负载因子赋了一个默认值,0.75,这个数字时和扩容相关的。
再来看下有参数的构造方法
// 这个方法接收两个参数:初始容量,负载因子
public HashMap(int initialCapacity, float loadFactor) {
// 下面是对着俩参数做的一些判断
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// 负载因子赋值
this.loadFactor = loadFactor;
// tableSizeFor方法会找到比initialCapacity大的一个最小的2的幂次方数
// 例如传7就会得到8,传15会得到16,传20会得到32,这个数字会被用作map内部数组的初始容量
// 为什么需要这样一个数作为容量是跟计算一个key对应的数组下标的方法有关的,这个和jdk7中类似
// 数组下标= hash值 & 数组容量-1;
this.threshold = tableSizeFor(initialCapacity);
}
二、put方法和扩容方法
需要注意的是put方法和扩容方法需要放在一起来讲,具体原因后边再来揭晓。
首先看下put方法
public V put(K key, V value) {
// 这个hash(key)方法就是根据传入的key生成了一个hash值,然后调用putVal方法开始put
return putVal(hash(key), key, value, false, true);
}
然后来看下putVal方法,这个方法中的代码风格和jdk7中有较大的区别,需要仔细研究才能理清其中的逻辑。
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是类中一个属性,是map中真正用来存储数据的数组: Node<K,V>[] table
// 先把table赋值给局部变量tab,在判断tab是否为空,如果为空表示是第一次put,需要对table进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
//进到这里表示是第一次put,需要对table数组初始化,在8中数组的初始化是放在扩容方法中的,所以这里调用了
//扩容方法来初始化table。然后把数组的长度赋值给了局部变量n
//这里使用局部变量是为了提高访问速度,访问局部变量相对来说比访问类变量快。
n = (tab = resize()).length;
//这个if先用(n - 1) & hash这种算法计算出当前要put的这个key对应的table数组下标,
// 把下标赋值给局部变量i
// 取出数组中这个这个位置的元素赋值给局部变量p
// 判断p是否是空,也就是判断数组中这个位置是否有元素
if ((p = tab[i = (n - 1) & hash]) == null)
//进到这里表示数组中此下标处没有元素,直接创建一个新的Node元素放在数组的这个下标处。
//这个时候这个位置相当于放了一个只有一个元素的链表。
tab[i] = newNode(hash, key, value, null);
else {
//进到这里表示数组中此下标处已经存在一个链表或者红黑树。p指向的是链表的头元素
//要注意的是数组中某个位置存储的即使是个红黑树,原来的链表也还是存在的,因为红黑树是
//由TreeNode组成的,链表是由 Node组成的,TreeNode是 Node的子类。
//Node组成链表靠的是其中的next属性指向下一个节点;TreeNode组成树是靠其中的左右两个属性指向左右节点;
//Node转成TreeNode并不会丢失其原来的属性。
Node<K,V> e; K k;
//这个if判断链表的头元素的key是否和传入的key相等
//如果相等就把头节点赋值给e,为了后边返回e中的旧value并替换成新value
//这是map的特性,put一个已经存在的key,就会返回旧value,并把新value存进去
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//进到这里表示头节点的key和传进来的不相等,这个时候就需要继续向下遍历其他的元素
//这里是判断这个头节点P是不是一个红黑树
else if (p instanceof TreeNode)
// 如果是红黑树就走红黑树的put逻辑
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//进到这里表示p是链表,所以继续遍历链表,看链表上的元素是否有key和传入的key相等的
for (int binCount = 0; ; ++binCount) {
//这个判断表示已经遍历到了链表的尾节点
//这里会先把p的下一个节点赋值给e,下面会检测e的key是否和传入的相等
if ((e = p.next) == null) {
//jdk8采用的是尾插法,所以创建一个新节点添加到链表的尾部
p.next = newNode(hash, key, value, null);
// 这个判断表示链表中元素已经是8个了就转成红黑树,注意这里的8个不包括上边新加进去的
// 元素,所以这时候转出来的红黑树上是有9个元素的.
// 总结下来就是一个链表中已经有8个元素了,在下一次往这个链表上put元素时会转成红黑树,
// 转成的红黑树上有9个元素。
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) { // existing mapping for key
//进到这个判断表示当前put的key和数组下标处存储的相等,需要返回旧值,替换新值
V oldValue = e.value;
//onlyIfAbsent表示的是put时是否替换旧值,调用map的putIfAbsent方法可以通过参数控制
if (!onlyIfAbsent || oldValue == null)
//进到这里表示需要替换旧值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//这个变量表示map被修改的次数,用来在多个线程同时编辑map的时候抛出异常
if (++size > threshold)
//容量大于阈值需要扩容
resize();
afterNodeInsertion(evict);//map中没有实现
return null;
}
下面再来分析下扩容的方法,扩容的基本原理就是新建一个数组table容量是原来数组的2倍,再把旧数组中每个下标处的链表/红黑树中的每一个节点移动到新的数组中,需要注意的是旧数组中同一个链表或红黑树上的节点移动到新数组中后不一定还在同一个数组下标处,这和根据hash值计算出的数组下标有关,
数组下标的计算方式是 hash & (数组长度-1)。
假设数组旧的容量是16,扩容后的容量是32,
可以看到扩容前跟扩容后参与计算的下标的 (数组长度-1) 对应的二进制只差在了16这一位,所以针对一个key,如果16这一位是1,那么扩容前后算出来的下标就会相差16刚好是就数组的容量,如果key的16这一位是0那么扩容后算出来的下标就和扩容前一样,在map的扩容过程中用到了这一规律。
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是在这个if条件里用左移实现的
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) //这里是说只有旧的容量大于map内部定的初始容量时
// 才会把扩容阈值也加倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
//如果调用有参数的构造方法创建map,第一次put时会进到这里
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 如果调用无参数构造方法创建map,第一次put会进到这里
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"})
//这里是根据newCap创建数组,不管是put时第一次初始化还是给数组扩容时都是在这里创建新数组
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 { // 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;
}
接着再来看下如果旧数组某个位置是一个红黑树,如何转移到新数组中的
要注意的是数组中某个位置存储的即使是个红黑树,原来的链表也还是存在的,因为红黑树是
由TreeNode组成的,链表是由 Node组成的,TreeNode是 Node的子类。
所以底下这个方法是把组成红黑树的这些节点对应的原来的链表分成高位链表和低位链表,然后再分别转移到新数组中。
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
//把链表中的元素分成高位链表和低位链表
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}//这个for循环是把红黑树当做链表在遍历
//转移低位链表
if (loHead != null) {
//这个判断的意思是如果低位链表中的元素小于等于6个就需要把这几个红黑树节点转成链表节点并搬到新数组
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
//先把链表头放到新数组中
tab[index] = loHead;
//如果高位链表是空表示原来红黑树上的节点全部在低位链表上,所以这个红黑树不需要重新生成
if (hiHead != null) // (else is already treeified)
//进到这里表示根据低位链表的节点新生成红黑树
loHead.treeify(tab);
}
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
//高位链表个数小于6,把红黑树节点转成链表
tab[index + bit] = hiHead.untreeify(map);
else {
//先把链表头放到新数组中
tab[index + bit] = hiHead;
//如果低位链表是空的则原来红黑树上的节点都在高位链表上,不需要重新生成红黑树
if (loHead != null)
//进到这里表示根据高位链表的节点新生成红黑树
hiHead.treeify(tab);
}
}
}
以上就是put方法和扩容方法的分析
通过put元素的逻辑可以看出put一个新元素时会先根据hash值来跟链表/红黑树上已有的元素比较,不相等的话就认为要put的key和先有
key不一样;如果hash值相等就会再去调用equals方法比较这俩key对象是否相等。
这就是为什么重写equals时一定要重写hashcode方法,因为在map集合中的这种用法决定了equals方法相等的两个元素hashcode一定要相等,
但hashcode相等的两个元素不一定相等,要继续用equals方法判断。
三、get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
可以看出是先根据key算出hash值然后调用getNode方法
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//这个if判断了table是不是空的,key对应的下标上是不是空的,如果是空不进if直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//看头结点的hash是不是和传进来的key一样,一样就返回first
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 {
//遍历链表返回hash值相等的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//上边都不匹配时返回null
return null;
}
四、线程安全
因为jdk8采用了尾插法,所以不存在死链和丢数据的问题,但多个线程同时操作map时还会有数据覆盖的问题。
简单回顾下这三个问题,在jdk7中死链和丢数据发生在扩容阶段,
多个线程同时扩容时,在把旧数组中某下标处的链表上的元素搬到新数组时,假设原来链表上有1,2,3三个节点,因为采用的是头插法,多个线程同时搬数据时可能会造成把1,2搬过去形成一个循环链表而3没有被搬过去的情况,具体过程可以看下开头引用的另一篇文章。
数据覆盖问题发生在多个线程同时put时,假设线程1和线程2要put的key,key1和key2equals方法返回的是false(保证它俩不是同一个key),但它们计算往map中存的数组下标时产生了hash冲突即会放在数组的同一个下标处,单线程时先把key1放到数组中,在放key2时(因为俩key不相等)会发现下标处已有元素然后就会让key1跟key2组成一个链表结构,这样是没问题的。
但是在多线程时,假设t1和t2都已经计算完下标并且执行完数组下标为空的判断,然后线程上下文切换,t1继续把key1放到数组结束,t2继续put这时因为t2已经执行过非空判断了所以它会直接覆盖下标处理的node元素,并不会组成一个链表结构,所以最终的现象就是key1put的数据被key2的put覆盖了。