HashMap集合介绍
HashMap是基于哈希表的Map接口实现,是以Key-Value存储形式存在的,主要是键值对。
对于hash冲突的解决方案
1.8之前
1.8 之前哈希Map是一个数组,计算哈希值之后按照取模运算之后放位置,但是如果位置已经被占用了,那么就会在数组下标的地方变为一个链表,利用链表进行存储。
1.8之后
1.8 之后哈希Map也还是一个数组,并且对于哈希冲突也是用链表,但是如果链表长度大于8并且整个数组大于了64的话,那么就会把数组下标位置的链表变为红黑树,因为红黑树的查找效率比链表要高很多,但是红黑树的插入操作代价非常高,所以如果数组长度小于64或者链表长度小于8的时候都不会考虑红黑树。
特点
- 存储无须
- 键和值都可以是null,但是键值只能是一个null
- 键位置是唯一的,底层数据结构控制的。
- 1.8之前是链表 数组,1.8之后是链表 数组 红黑树
- 阈值>8并且数组大于64,链表才会转换为红黑树。
HashMap的存储过程
- size表示HashMap中的k-v实例的数量,注意,并不等于数组的长度
- threshold(临界值)=capacity(容量)*loadFactor(加载因子)。这个值是当前已占用数组长度的最大值。size超过这个临界值就重写resize(扩容),扩容后的HashMap容量是之前的容量的两倍
HashMap的继承关系
我们打开源码来康康吧
可以看见HashMap里面继承了AbstractMap然后实现了Map和Cloneable和Serializable三个接口,我们点开AbstractMap来看看
??!我们发现这个也实现了Map接口,那么为什么不可以直接让HashMap继承了AbstractMap然后不去实现Map接口就好了呢?
其实这个是一个设计失误,无所谓。。。
HashMap中的DEFAULT_INITIAL_CAPACITY
我们翻阅一下源码我们就可以看见有一个东西
仔细观察一下,上面的意思就是默认的初始容量是 1<<4 其实就是1左移了4位也就是扩大了24倍也就是16,也就是默认的初始容量是16,并且我们看注释上面说的
The default initial capacity - MUST be a power of two.
这个意思就是容量大小必须是2的n次幂,那么为什么会这样呢?
前面我们说过,我们的Java在计算hash的时候,是利用hashCode进行无符号右移,按位异或运算最后按位与运算。我们先来解释一下这个操作的含义,以前我们说过我们对于hashCode计算hash我们可以利用取模运算,你看我们首先进行的右移运算,其实就是控制我们的hashCode的范围,因为一般来说hashCode都非常的大,所以右移其实是把hashCode的二进制长度变得和数组长度一样保证小于数组长度。然后按位进行与运算这个操作其实就是在进行类似取模的运算。其实是hashCode&(tableLength-1) 如果我们的table是2的n次幂这个时候-1的话相当于后面所有的位都是1,这个时候与运算那么后面的值都和hashCode本身是一样的了。所以其实就是相当于是在取模,那么回到问题本身,如果不是2的n次幂,比如是9,那么9-1=8,8的二进制除了第一位后面全是0,这样进行与运算后面1000后面3个0的位置运算下来一定是0所以冲突极有可能发生。其实必须是2的n次幂就是为了进行无符号右移,按位异或运算。按位与运算等。
如果我们自己指定初始容量呢
我们知道我们可以在new 一个HashMap的时候给他初始化一个容量,那么在底层会发生什么呢?
首先先说结果,最终会变成大于等于我们指定容量的2的n次幂,然后我们来看看源码怎么弄的。
首先看到我们调用的构造方法
这个构造方法里面调用了另外一个构造方法,两个参数,一个是容量,另外一个是装载因子。我们继续点进去看
点进去之后我们可以看到啊首先进行了许多的判断首先容量小于0肯定是输入的参数有问题,肯定错了,然后我们看见又对这个初始化容量和最大容量进行比对,如果比最大还大那么就等于最大就好了,然后继续判断这个装载因子,如果这个装载因子小于0或者说这个装载因子不是一个数字那么也会抛出异常。这些都是一些判断,然后看后面,大的要来了,给装载因子赋值之后,然后又要给这个临界值赋值,后面调用了一个==tableSizeFor(initialCapacity)==方法我们点进去看下
终于好戏来了,可以看到啊,里面有点看不懂,首先先让n = cap-1,然后进行了多次的各位与上n的右移,这个在干嘛呢?看一张图片
其实我们仔细思考一下再比对这个图片中的例子,我们可以看到,与运算只有都是0的时候才是0,一个位假如是1,那么右移一位之后这个1跑到了后面的地方了,那么如果现在原数字和右移之后相与那么必然两个位置都是1,说白了就是把后面的也变成1,先右移一位,然后一个1和他后面都成了1,就有两个1相连,然后右移2后面第三和第四也必然成了1,右移4就有八个都是1,从1,2,4,8,16这样数过去会后移32个,不管第一个1在哪里后面所有的操作都必然是1,也就成了2n-1了,非常的巧妙,最后返回的时候有两个三元判断,首先判断这个n是不是小于0了,因为这个32位里面有一个符号位所以可能会有溢出现象,所以如果小于0那么就赋值为1就好了,如果是正数,再判断一下有没有大于最大容量,如果大雨了就等于最大容量,如果小于就 1,为什么要 1呢?我们说过需要让他是2n现在全是1所以是2n-1所以需要 1,那么回到之前的问题,为什么需要先-1呢?因为输入过来的数字可能就是最大的整形里面的2n也就是32位中数字第一位是1,如果先-1那么可能会让它最高位1后移一个位置,这样到最后 1的时候不至于进位导致溢出。
为什么HashMap从链表转换为红黑树的阈值是8
我们说过在HashMap中链表转换为红黑树的阈值是8,我们说过是因为8之前是链表效率高,8之后是红黑树效率高,如果节点数量小于6那么会重新从红黑树转换为链表为什么呢?
空间和概率
这是官方的注释中提到的
在官方的解释中我们可以看到因为红黑树的节点的大小是链表的大约两倍,而且因为在良好的分布情况下,节点数量是泊松分布的,其实也就是说一个bin中含有一个节点的概论大但是bin中节点到了8之后就会非常非常的小。一般不会被用到,所以其实没有必要一开始就用红黑树,浪费空间并且红黑树旋转和改变颜色也会影响效率,但是注释中提到了一点,因为用户可能会去使用一些不好的hashCode算法,分布不均匀就会导致bin中可能含有很多的节点。这种情况下红黑树就是一个保底的操作了。不得不说这些大佬真的太牛了
时间效率解释
这是其他人的一些解释,感觉也是非常的好,直接贴图
装载因子和扩容阈值(threshold)
装载因子的默认值是0.75,初始table容量是16,所以扩容阈值初始值是12
我们之前说过扩容阈值的计算公式是扩容因子 * tableSize,这个装载因子的默认值是0.75是经过大量测试的最好不要去修改,当装载数量大雨了扩容阈值,那么就会进行扩容操作,扩容操作会进行复制开辟空间重新计算hash值这些操作会非常的拖效率所以装载因子的选择非常重要。如果阈值小了,那么会有很多的空间没有被使用,并且会很频繁的进行扩容,影响效率,如果装载因子太大,那么发生冲突的概率就很大,并且很有可能bin中的链表变为了红黑树。所以一旦扩容红黑树进行旋转等操作就会非常复杂。并且元素非常的多。也是不友好的所以0.75这个值最好不要修改。并且因为扩容实在影响效率,如果我们知道大概会有多少元素的时候我们就用带容量的构造方法进行构造避免一直扩容影响效率。
构造方法解析
无参的构造方法
看源码
我们看到了这个方法里面只有一句话就是给这个装载因子初始化位默认的0.75,其实也想得通,因为其实装东西的数组是在put的时候才会进行初始化的,所以现在仅仅只做一个赋值操作就可以了
含初始容量的构造方法
这个之前说过了
传入一个Map的构造方法
可以传入一个Map把里面的东西全部作为值用来初始化HashMap
首先装载因子也还是默认的装载因子,然后就调用了一个方法传入了这个Map和一个false值。点进去看看
首先获取传入的map的大小,如果大于0才进行操作等于0说明他也是空的就不用操作了,然后如果现在的table是空的那么就要重新进行一个初始化容量的操作,因为我们要把m中的所有东西都添加进去所以肯定不会超过阈值,所以我们重新计算的大小一定是当前大小/装载因子,精辟,后面还有一个 1的操作,是什么呢?其实这个是为了防止计算出的大小刚刚好之后我put进去之后立马又开始扩容了。所以 1免得影响效率然后还得判断一下计算出的ft和最大容量谁更大,如果大雨了最大容量,那么就等于最大容量。然后如果这个t现在是大于默认的阈值的肯定就要重新分配一下大小了(因为传入的t可能不是2n,如果t小于默认的阈值那么阈值不变就是了,如果大于了就重新分配一下保证它是2n)如果以前的table是存在的并且s大于了阈值,那么肯定是需要扩容了。然后后面就是挨个的去设置值了。
HashMap的put方法
看一下- 源码
源码中调用了hash方法我们来看看
非常的简洁啊,首先判断这个key是不是null是的话hash就是0,这个比较特殊,印证了key可以是0,然后如果不是0那么就直接yongkey的hashCode值按位异或hashCode右移16位,这个我们之前说过我们计算下标的时候我们首先会把hashCode和其右移的16位相异或,然后再进行与table长度-1按位相与,那么我们说过按位相与其实就类似与我们的取模运算,那么为什么要多此一举呢?想想,假如我的一个HashCode和15相与。得到了一个hash值,但是另外一个HashCode的值和我的不一样,但是它后面的3位和我的一样那么计算出来的下标也是一样的,也就是说现在决定下标的只有地位部分,但是低位相同的概率是比较高的,所以这样hash冲突的概率是很大的,所以为了减小hash冲突我们需要把高位的16位也要利用起来所以才有了右移16位然后按位异或,那么这里只有异或与运算就在后面的putVal方法里面了,我们继续看
首先进来的时候判断了一下当前的table是不是空的或者table的长度是不是0,是的话就需要进行扩容,然后重点来了,后面获取了一下tab的(n-1)&hash的下标的值,这个是不是就是计算下标的位置了,果然是没有问题的。如果这个位置是null说明没有hash冲突所以可以直接让这个地方等于新的节点值。然后如果有冲突,那么就看一下两者之间的key是不是同一个hash值是不是一样的。是的话我们直接看到后面后面用新的值覆盖老旧的值,如果hash值不一样那么说明我们要再这个地方插入红黑树节点或者链表结点了,所以马上开始判断是不是树节点,是就putTreeVal,不是就进行链表插入。和我们之前说的刚好对的上
HashMap将链表转换为红黑树的过程
我们来继续看刚才的put里面,在链表循环到最后位null准备插入的时候里面有一个判断if(binCount >= TREEIRY_THRESHOLD -1)这个TREEIRY_THRESHOLD就是我们的扩容阈值。最后判断如果插入之后将会达到阈值了,那么就会调用转换方法treeifyBin
我们来看看这个转换方法
首先进行了一个判断判断我们的tab是不是为null,又或者tab的长度是不是大雨了MIN_TREEIFY_CAPACITY,我们之前说过一个问题,如果要把链表转换为红黑树那么必须有两个条件第一个是要长度大于8第二个就是我们的tab的长度需要大于等于64才可以,所以执行这个方法只是链表长度大于8了,这里就在判断是否是可以进行转换,如果不可以,那么就不转换直接扩容就完事了。
然后还有一个判断,如果扩容之后重新计算的下标位置的bin没有东西那么就开始执行后面的操作,首先是一个循环,每次循环就利用replacementTreeNode创建一个TreeNode,其实就是把当前的节点传入进去然后重新生成一个数据一致的红黑树节点,然后判断是不是头是头就让hd = p,记录这个节点然后还是像双向链表一样链接父子节点。然后不断循环直到最后一个。执行完毕这个循环之后,链表还是链表但是里面的节点不是链表结点了而是红黑树节点了,所以后面有一个判断判断hd是不是存在的,如果存在就调用 hd.treeify(tab) 这个就是红黑树的左旋右旋然后把这个类似双向链表的数据结构转换为红黑树了
关于扩容问题
扩容机制
扩容时机
- tab中的非空的bin达到阈值了
- 某个bin中链表长度接近8,但是tab的长度小于64
扩容原理
1.8之后的扩容都是变成扩容2倍了基于这点扩容,变得非常的巧妙非常的有特点。
我们来思考一个问题如果扩容是扩容两倍,在底层我们知道index的计算是先用hash = hashCode ^ hashCode >>> 4,然后hash&(n-1)这样计算出hash的值,我们知道扩容以前得到的hash和扩容之后得到的hash肯定是一样的,关键在后面,这个hash =& (n-1)会发生变化,我们来思考会发生什么,首先扩容是扩容2倍,所以n-1的二进制会多一位在前面这样的话,要么计算出的下标index还在原来的位置,要么计算出来的下标index比原来多了一个前面的容量,因为hash虽然不一样但是我们看的是hash的低位部分但是现在容量二进制多了一位所以可能有的下标二进制就会在前面多一个1,这样的话我们其实有两个好处
首先我们不用重新计算hash值,因为只有这两种可能,所以我们只需要看本来的hash值在扩容后的二进制与上(n-1)之后第一位是1还是0,就可以判断是多了一个原容量还是就在原来的地方,
其次我们来看一张图,非常的形象
比如之前容量是16,现在扩容之后是32了,原来index是15的bin里面的各个节点都开始重新分配了,要么还是留在15这个位置要么就跑到31这个位置,可以很容易的吧本来较长的量给均匀的分配到两个bin中而且新的bin一定是空的,这个操作可以极大的使其均匀分布!
只能说大佬是真的大佬
删除问题
直接源码
可以看到里面调用了一个方法removeNode方法,传入了key的hash,然后我们看那个删除方法
首先先判断了一下tab是不是空的或者里面有没有数据并且tab在hash计算下标的位置是不是有bin,如果有才进行后面的操作,然后判断当前bin中的第一个节点是不是就是我们要找的,如果是就让node等于他,不是就进行遍历,首先需要判断下一个节点也就是第二个节点存不存在如果存在那么就判断一下他的类型,如果是红黑树节点的话那么就调用红黑树的方法来获取这个节点,如果不是就用循环来遍历找到我们需要的删除的节点,如果找到了就退出循环让node等于它。最后判断node是不是null是null就代表没有找到,不是null就说明我们找到了要删除的元素了。于是先判断它是哪种节点,红黑树就调用removeTreeNode进行删除,如果不是就需要判断一下是不是头如果是那么直接让tab[index] = node.next就可以了,如果不是那么就需要让它的上一个节点的下一个节点等于下个节点然后就删除完毕了
get问题
其实get和remove很相似,一个找到一个找到顺便删掉而已就看下步骤吧
遍历HashMap的四种方式
package cn.cuit;
import java.util.*;
public class Test {
public static void main(String[] args) {
HashMap<String,Object> map = new HashMap<>();
map.put("CUITBO","周海波");
map.put("CUITS","cuitbo");
map.put("周海波","周硫代硫酸钠");
// method_1(map);
// method_2(map);
// method_3(map);
method_4(map);
}
/**
* 使用forEach
* @param map
*/
private static void method_4(HashMap<String, Object> map) {
map.forEach((key, value) -> {
System.out.println(key "---" value);
});
}
/**
* 使用迭代器
* @param map
*/
private static void method_3(HashMap<String, Object> map) {
Set<Map.Entry<String, Object>> entries = map.entrySet();
Iterator<Map.Entry<String, Object>> iterator = entries.iterator();
while(iterator.hasNext()){
Map.Entry<String, Object> next = iterator.next();
System.out.println(next.getKey() "---" next.getValue());
}
}
/**
* 使用get
* @param map
*/
private static void method_2(HashMap<String, Object> map) {
Set<String> strings = map.keySet();
for(String str : strings) {
System.out.println(str "---" map.get(str));
}
}
/**
* 分别遍历keys和values
* @param map
*/
private static void method_1(HashMap<String, Object> map) {
Set<String> strings = map.keySet();
Collection<Object> values = map.values();
for(String str : strings){
System.out.println(str);
}
for(Object obj : values){
System.out.println(obj);
}
}
}
其中get方法和分别遍历不推荐使用,因为效率比较低
本次学习参考bilibili视频HashMap集合介绍 面试题讲解