HashMap应该是每个Java程序员日常开发都很熟悉的集合类型,使用也很简单,不赘述。
import java.util.HashMap;
public class HashMapTest {
public static void main(String[] args) {
HashMap user = new HashMap<>();
//往HashMap里面存放数据
user.put(1,"张三");
user.put(2,"李四");
user.put(3,"王五");
//从HashMap里面取数据
String name = user.get(1);
System.out.println(name);
}
}
点进put方法里面看看HashMap都做了啥。
这里有两点需要关注的,一个是HashMap的put方法原来是有返回值的 。上面的注解告诉我们,返回值是key关联的前一个值,怎么理解呢?来一个测试代码测了一下。
import java.util.HashMap;
public class HashMapTest {
public static void main(String[] args) {
HashMap user = new HashMap<>();
//往HashMap里面存放数据
System.out.println(user.put(1, "张三"));
System.out.println(user.put(1, "李四"));
System.out.println(user.put(1, "王五"));
System.out.println(user.get(1));
}
}
输出结果:
null
张三
李四
王五
我put了三个同样的key,第一次输出结果是null,第二次输出的是第一次put进去的value,第三次输出第二次put进去的value。结论:如果put进去的key跟HashMap原有的Key发生重复,那么对应的值会被替换,被替换的值会在返回值中返回。
第二个关注点是hash(key)这个方法。这里是一个三目运算符,如果key为null返回0,否则返回(key的哈希值h) XOR (h逻辑右移16位)。Java中的<< 和 >> 和 >>> 详细分析 这里计算出来的hash用来确定新put进来的对象要放到哪个位置,这个后面会讲。
那么来看看key的hash算法是怎么算的吧~然后一脸期待地点进去发现,你就给我看这个??
所以这个算法估计是在更底层实现的,已经不是java这层关心的事情的,那就先跳过吧,知道这里是个哈希算法就行了。然后回过头来看看key的哈希值有了,那么怎么把value的值放到HashMap里面呢?点进putVal()里面:
到这里就能先画个图了,首先这是个数组tab,然后数组tab里面装的Node是链表。就这酱紫啦!但是这个图并不完整,实际上当链表的长度超过8时,链表会变身成为红黑树。
实际上应该是这样子的。
继续往下,如果tab是空的话,就会调resize()这个方法去初始化数组。resize()这个方法特别关键。
你一定很好奇为啥我们能往Map里面放很多很多数据,是因为这个数组一开始就设置了很大的数值吗?还是链表可以允许无限长呢?很明显都不是,读懂resize(),你就会发现这里的设计有多巧妙。
tab数组的初始化并不是在创建HashMap的时候完成的,而是在往HashMap里面Put第一个值的时候调用resize()方法进行初始化的。这样做的好处就是减少不必要的空间浪费,因为每次new一个对象就要从堆中开辟一块新的空间。
这里有几个重要的概念:
1.capacity(容量):记录数组的长度,其值一定是2的n次幂,初始化时数组容量为DEFAULT_INITIAL_CAPACITY=16
2.loadfactor(负载因子):控制数组到达某个长度进行扩容,默认值是DEFAULT_LOAD_FACTOR = 0.75f,默认情况下,数组到达0.75*16=12时,数组扩容为原来的2倍,即32;到达0.75*32=24时会再次扩容为64,以此类推。可以在有参构造函数public HashMap(int initialCapacity, float loadFactor)传入负载因子的值。负载因子巧妙地控制着数组的伸缩,从而达到空间合理分配的目的。
3.threshold(阈值):前面说到数组达到某个临界值会自动进行扩容,threshold就是这个临界值。threshold=capacity*loadfactor。
上面这段主要是处理扩容之后把原数组的数据拷贝到新数组,这里的算法也特别巧妙。要看懂这里,需要回到putVal()方法理解前面说的hash值是怎么跟数组的位置关联起来的。
i = (n - 1) & hash计算数组的下标。举个例子,假设前面经过resize()初始化数组,当前数组长度是16,转换成二进制就是10000,那么n-1=1111,假设hash=110110,i的计算如下图:
计算结果其实就是取二进制hash值的后面4位,运算结果i能取得的最大值是1111[二进制]=15[十进制], 这样做的目的有2个:
1.控制数组下标不越界,因为数组长度是10000[二进制],计算结果i只取hash后四位,i绝对会落在数组长度范围内。
2.位运算效率更高。上面的算法用取模运算同样可以实现,但是效率没有位运算高。
明白了hash和数组下标的关联关系,再来理解扩容时原数组是怎么拷贝到新数组的。还是刚才的例子,因为新数组的长度变为了原来的2倍,newCap.length=100000[二进制],计算下标i取的是hash的后5位,这就导致数组下标的位置会有可能发生改变,但是无非就2种情况:
情况1:hash值的右边第5位是0,那么新数组下标i与原数组计算结果一致,也就是该节点还是放在原来的位置。
情况2:hash值的右边第5位是1,那么新数组下标i就是原数组下标值+原数组长度。
上面是推理过程,结果其实没那么复杂。简单来说就是,数组的下标就是取哈希值的后N位得来的,扩容需要向前多取一位,这一位的值是0还是1,决定了该节点拷贝到新数组的下标是否发生改变。是0下标不变,是1下标变为原下标+oldCap(原数组容量)。
总的再来看一下putVal()这个方法。
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; //resize初始化数组
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); //把put进来的对象放到数组某个位置(这个位置跟二进制hash的后N位有关)
else { //数组对应下标已经有节点
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) //原来这个位置上节点的key刚好跟当前要放进去对象的key一样
e = p; //新put节点替换原来节点
else if (p instanceof TreeNode) //原来这个位置是一棵树
e = ((TreeNode)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) // 链表长度大于8,把链表转换成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) //key相同替换节点
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue; //替换相同key的节点之后返回旧节点的值,一开始写的那段代码测put方法的返回值,根源在这里
}
}
++modCount; //修改次数计数器
if (++size > threshold) //大于阈值需要扩容
resize();
afterNodeInsertion(evict); //插入后的回调,用于扩展,例如自己写了一种Map继承了HashMap,需要在出完插入之后做一些其他操作,重写这个方法即可。官方在LikedHashMap也用到了。
return null;
}
【注:本篇全程是按照阅读顺序以一种流水账的方式写的,没有进行梳理和归纳,适合打开源码跟读。红黑树也没有展开说,就写到这吧,后面梳理一下再写一篇完整的】