首先进入HashMap这个类
- 这是一个入口但是这里有一个小bug(注意哦这可是源码那为什么会有这个bug呢?)来跟我看一下:注意一下AbstractMap这个类
- HashMap继承了这个类那么来看一下这个类:
看清楚哦它实现了Map接口
- 再往下看:
吃惊啊,HashMap竟然也实现了这个接口,这就是个小bug但是它不影响我们使用。
总结一下:其实这个bug是当初写这个代码的人不小心留下来的,后来才被发现,出于面子他不承认这是个小bug,也不想在去修改了,因为不影响使用所以在后来的维护人员中就把它保留下来了。其实跟你们说这一点就是为了丰富自己的知识,让自己在写代码中有一定的规范意识。
接下来进入正题(逐行解析)
- serialVersionUID:序列化版本号(其实就是给java虚拟机看的。它有手动和自动设置之分,如果感兴趣可以自行搜索)
- 数组默认的初始容量它必须是2的n次幂(<< :有符号右移位,将运算数的二进制整体右移指定位数,整数高位用0补齐,负数高位用1补齐(保持负数符号不变)。简化就是2的n次幂)
注意:面试题来了:为什么一定要是2的n次幂?
- 其实很简单。当往HashMap中添加一个元素时,需要根据key算出HashCode然后在根据HashCode算出hash值,最后与数组长度取余计算索引的位置(底层不是用取余的方法进行的这个点后面在具体讲解),HahMap为了存区高效率,就要尽量的减少hash碰撞(hash值相同即为hash碰撞)那就要求数据分配均匀保证数组的每个位置被用的概率都相等。如果读到这你还不懂那我在给您举个例子。
2的n次方实际就是1后面n个0,2的n次方-1实际就是n个1,这句话就是问题的关键(因为都是1的情况下它与不同的hash值进行按位与运算才能保证每个数组位置被分配到的概率相同,要是不明白这句话可以见下图)
hashmap的底层是这样计算索引值的(n - 1) & hash
举例
说明:按位与运算:相同的二进制数位上都是1的时候结果为1,否则为0。
如果是2的n次幂:
长度为8,那么n-1就是7对应的二进制为0111,假设有两个hash值分别是2和3,那么:
3&(8-1)=3;2&(8-1)=2,则不会产生碰撞
如果不是2的n次幂:
长度为9,那么n-1对应的二进制为1000,假设有两个hash值分别是2和3,那么:
3&(9-1)=0;2&(9-1)=0,则会产生碰撞
总结:如果数组长度不是2的n次幂,计算出来的索引特别容易相同,及其容易发生hash碰撞。导致其余数组空间很大程度上并没有存储数据,还会增大某个位置出现链表和红黑树的概率,降低性能和效率。
hash%table.length和hash&table.length-1有什么不同?
其实它俩的功能是一样的都是计算索引位置其结果也是一样,但是为什么底层用的是第二种呢,这是因为后者的效率比较高因为前面的十进制数要转成二进制数,而后者直接操作的就是二进制数。
注意:当不考虑效率的时候采用直接取余的形式是不要求数组的长度必须是2的次方的。
- 设置一个最大的容量没什么好讲的。
- 默认的加载因子
注意:面试题来了:为什么要是0.75?
0.75是根据大量的测试得来的,它是根据数组的利用率和产生hash碰撞的概率得出来的。
举例
假如加载因子是0.5,数组长度为12,那么扩容得条件为0.512=6,也就是说当数组存得数据大于6得时候就会扩容,那么会产生一个为原来2倍的数组,你会发现数组的大部分空间都没有利用到,造成了资源的浪费,在循环查询数组时导致查询效率低。
假如加载因子是0.9,数组长度仍为12,那么扩容的条件欸0.912=10.8,也就是说存的数据大于11时就会扩容,那么就会产生一个问题:由于数组长度为12,到11的时候才会进行扩容那么就会增大hash的碰撞概率。(为什么会增大碰撞概率呢?因为数组的位置数目是一定的,当放入的数据越多但是这时候还没有达到扩容的条件并且数组已经快满的时候,就必然会在某些位置产生碰撞。)
以上要是还没有听懂,那我也没办法了。
- TREEIFY_THRESHOLD产生红黑树的条件之一
- 红黑树自动变成链表的条件
- 链表转为红黑树的另一个条件
硬货来了
- put方法的讲解:对应的是putVal方法,先讲hash()方法:
注意:面试题来了:为什么hashmap要把hashcode的值进行了操作才去和table.length-1进行按位与运算?
- 首先来理解一下操作到底是干了什么?(把hashcode的值先进行无符号右移16位然后和原来的值进行异或运算)。
举例当进行了该操作时并且当数组长度为16时计算的索引值如下
当不进行该操作时并且数组长度也为16时计算的索引值如下
现在可能看不出区别,但是当你看完下面的骚操作你就能明白为什么要计算hash值了
在存储一个key还是不进行操作直接进行按位与运算得到索引值如下
** 小总结:当采用直接用hashcode值计算索引值的形式时会出现当hashcode值的高位变化很大,而低位变换很小或者没用变,那么如果直接和数组长度进行&运算会很容易造成计算的结果一样,从而导致hash碰撞**
在存储一个key进行了操作计算出的索引值如下
你会发现当高位改变同时又进行了操作但是得到的索引值还是5,你千万不要以为这也没区别啊,你要是感觉没区别就说明你没有真正的理解,这里给你留个思考,区别到底在哪,在文章的最后我会给出答案(1)。
接下来我把以上四张的截图放在一张图中,方便对比分析
大总结:为什么要这样操作呢?(如果直接和hashCode做按位与运算,实际上只是使用了数组长度减1的低位按照上面的例子数组是16,那么减1后就是15对应的二进制就是0000 0000 0000 0000 0000 0000 0000 1111,如果当hashCode的高位变化很大,低位变化很小,这样的话高位就没有利用起来就很容易产生hash冲突,那么进行了该操作后高位也就利用起来了,那么就大大降低了冲突)
重头戏来了,前面都是过度
- 这是putVal的方法:上述内容大概可以分一些几步:
- 先通过hash值计算出key映射到哪个桶
- 如果桶上没有碰撞冲突,则直接插入
- 如果出现了碰撞,则需要处理冲突
a. 如果没有该桶使用的时红黑树处理冲突,则调用红黑树的方法插入数据
b. 否则采用传统的链表方式插入,如果链表的长度达到临界值并且数组的长度大于64,那么链表就会转成红黑树 - 如果桶中已经存在相同的key,则新put进去的会把原来的覆盖
- 如果size大于阈值threshold,则会扩容。
仔细阅读上述的内容不难发现扩容的条件(1)数组实际容量大于数组长度*0.75(2)链表长度大于8但数组长度没有达到64
注意要想真正了解put的方法还需要自己去看源码,这里只是给你讲一个大致流程。里面的细节需要自己去理解
既然刚才聊到了扩容,那接下来看一下到底是怎么扩容的
- 首先扩容是非常消耗时间的,所以在一开始的设计中就要尽可能的降低扩容的概率
- 扩容分两个版本(1)jdk1.8之前(2)jdk1.7之后
(1) 在此之前扩容是要重新计算hash值的
(2)在此之后是不需要重新计算hash值的 - jdk1.7扩容时在旧链表迁移到新链表的过程中若在新表的数组索引位置相同,则链表元素就会倒置。那么当多线程进入时就会发生死循环。
为什么会倒置呢?
因为jdk1.7解决hash冲突采用的是头插法当多个线程同时进行访问时就会发生倒置和死循环具体讲解如下
-
(e1,next1代表线程1),(e2,next2代表线程2)
-
假设上面的都能正常执行完后,也就是说e1e2现在指向的是3这个位置,next1和next2现在指向的是2这个位置
-
假设线程2突然阻塞,不继续执行,而线程1正常执行,那么得到的结果如下:
这时候e2和next2可不是指向上图中的位置了哦,因为数据已经被线程1移到新的数组内了, -
现在的e2和next2指向的数据如下:
-
假设线程2现在又不阻塞了,正常运行了,那么它也会走之前数据迁移的代码,走的过程中会出现死循环如下图:
附上jdk1.7数据迁移的代码可以按照代码自行画图理解
jdk1.8对这一现象进行了优化,把原本的头插法改成了尾插法这样虽然解决了死循环的问题但是当多个线程进行扩容是会发生数据覆盖。
重点了解一下jdk1.8对于扩容到底是怎么优化的。主要分两点上面已经讲解了一个点,接下来就谈谈第二点
- 扩容机制:1.7是采用重新计算hash值的,之前已经提过一次了,现在具体了解一下优化在哪如下图:
例子总结图:
总结:jdk1.8hashMap扩容时不需要重现计算hash值,只需要看原来的hash值新增的那个bit位是1还是0就可以了,是0的话索引位置没变,是1的话索引位置就是原索引位置+旧容量,要是还不理解可以在看看16扩充到32的resize示意图
附上1.8扩容源码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 初始化节点,第一次为null,第二次16
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 第二次16
int oldThr = threshold; // 第一次12
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) { // 容量大于等于最大容量扩容为最大
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY) // 阀值扩容 *2
newThr = oldThr << 1; // double threshold 左移1位(*2)
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;// 第一次初始化默认容量16
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];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 获取数组中的元素
oldTab[j] = null; // gc
if (e.next == null) // 若下个节点等于null直接存入数组
newTab[e.hash & (newCap - 1)] = e; // 放入新的数组中 若不减1 超过数组最大容量 高位
else if (e instanceof TreeNode) // 红黑树
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order 双向列表
Node<K,V> loHead = null, loTail = null; // 低位:loHead头链 loTail尾链
Node<K,V> hiHead = null, hiTail = null; // 高位
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 原下标位置
if (loTail == null) // 低位尾节点为null
loHead = e; // 元素放在头节点
else
loTail.next = e; // 放在尾节点的下个节点 12->13
loTail = e; // 首次头节点=尾节点
}
else {
if (hiTail == null) // 高位尾节点不存在,首次进来尾节点=首节点
hiHead = e; // 放在尾节点 hiHead=1 hiTail=1
else
hiTail.next = e; // 1->2
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) { // 低位尾节点不为null
loTail.next = null; // 首次头节点=尾节点 尾节点gc回收
newTab[j] = loHead; // 放入数组
}
if (hiTail != null) { // 高位插入在原下标+j的位置
hiTail.next = null; // 第一次进来是 1-value
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
扩容就讲到这,想深入了解的话自行百度
来一道面试题吧:为什么要引进红黑树
总结:在链表长度很小的时候,遍历速度还是很快的,但是当链表长度不断变长,链表的查询性能就不行了。这时候就需要转成红黑树了。当有n个元素红黑树的查询为O(lgn),链表的查询为O(n)
几种常见的构造方法
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;
this.threshold = tableSizeFor(initialCapacity);
}
- 自己设置数组容量和加载因子
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 自己设置容量
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 使用默认的容量16和加载因子0.75
当自己设置容量时会发生什么呢?会到以下这个方法中,为什么会有这个方法呢,因为默认的容量是2的n次方
static final int tableSizeFor(int cap) {
int n = cap - 1;
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;
}
举例说明:
假如自己设置容量为10,会先减1然后无符号右移最后进行或运算。为什么会先减1呢?后面会给出答案(2)
得到的结果其实就是把原来的二进制数经过一顿骚操作变成全是1的形式然后返回n+1,其实就是为了得到2的n次幂减少hash碰撞。这个函数的目的就是寻找比该数大于的最小的2次方
get方法详解
remove方法讲解
几种遍历hashmap的方法
(1)
//获取所有的key
Set<String> keys = hashMap1.keySet();
for (String key : keys) {
System.out.println(key);
}
//获取所有的value
Collection<String> values = hashMap1.values();
for (String value : values) {
System.out.println(value);
}
}
(2)
//使用迭代器
Set<Map.Entry<String,String>> entries = hashMap1.entrySet();//获取数组里的所有entry对象
for (Iterator<Map.Entry<String,String>> it = entries.iterator();it.hasNext();) {
Map.Entry<String,String> entry = it.next();
System.out.println(entry.getKey()+"----"+entry.getValue());
}
(3)
//通过key的方式,不建议使用,进行了两次迭代效率低
Set<String> keys = hashMap1.keySet();
for (String key : keys) {
String value = (String )hashMap1.get(key);
System.out.println(value);
}
(4) jdk1.8新增的方法
hashMap1.forEach((key, Value)->{
System.out.println(key+"----"+Value);
});
如果提前已经知道要往数组里存放多少个数据阿里建议要设置数组,但是不知道要设多少啊。
其实一个公式就解决了,默认的加载因子是0.75,反推一下得出:(需要存储的元素个数 / 负载因子)+ 1 即可得出。这样做可以尽量减少扩容或保证不会扩容。(为什么会减少呢?读到这里你要是还不知道原因请重头在来一遍)要保证把我们的数据全存储起来后也不会发生扩容。
最后总结一下1.7和1.8在hashmap上的区别
- 1.7采用头插法会导致死循环,1.8采用尾插法解决死循环问题但数据可能会被覆盖(对于多线程而言)
- 1.8在存储结构上采用了红黑树
- 1.7在创建对象时是在构造方法中默认创建一个长度为16的Entry[] table数组用来存储键值对数据,1.8是在第一次put方法时创建Node[] table数组用来存储键值对数据
先暂时就这么多
答案(1)
答案(2)
可以用反推法:假设没有减1且传入的值就是2的次方为8会发生什么呢?会直接进行一顿骚操作,直接把1000=8变成了1111=16造成资源浪费,会有很多空间没有被利用到查询效率也会变低。