阅读建议
1. 本篇文章仅用于个人笔记,因此有部分简略表达词汇(hm = HashMap, lhm = LinkedHashMap, chm = ConcurrentHashMap, ht = HashTable)
2. 因为写的比较简略,建议已经对该集合有基本了解后再阅读,如果笔记有问题也欢迎讨论
源码分析
- putMapEntires方法
- 就是把一个已有map加入另一个map中的方法
- 首先检查table是否初始化,没有的话就要进行初步扩容.
- 此处s为实际元素数量,+1是为了保证向上取整.这个操作可能导致另一个问题:"刚好快到阈值,扩容出了不需要的容量".但总好过出现"到了理论阈值却没有扩容"的情况.
- 其实正常来说是不需要做这个+1的,因为当负载因子为0.75,且容量大小一定为2的幂次的情况下,阈值一定是个整数.但是如果是手动输入的负载因子,导致容量大小*负载因子变成个小数,就要用到这个+1.0F了.
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
- put方法(其实调的是putVal方法,传入hash(key), key, value)
- 这个putVal首先对要插入的key调个hash
- hash方法:如果key是null就送到位置0,如果不是就用Object的hashCode方法散列出一个哈希值,用这个哈希值的高低16位进行异或运算得到一个高性能的哈希值.
- 用高低位异或运算是因为不同key的哈希值低位区分度很小,高位区分度很大,而我们在具体散列的时候需要对数组大小进行按位与,基本用不到高位,所以得进行一个高低位异或来减少哈希冲突.
- 到位之后看这个位置有没有元素,没有的话直接插入key,有的话就.equals判断是不是真相等,相等就覆盖,不相等就看里面是树还是链表来选择插入的方式.
- 如果是树的话会调红黑树的插入方法,是链表的话会遍历链表检查是否有相同key,有就覆盖没有就在表尾插入
- 插入完之后检查链表长度有没有超过8,超过了就执行treeifyBin方法.
- treeifyBin不会直接树化,会先检查数组是否>=64,超过了就树化,没超过就给hm扩容
- 考虑插入完是否超过阈值需要扩容.
- jdk1.8之前是没有树化的,只有数组+散列链表,另外之前的hash方法扰动了四次,性能差一些.
- 这个putVal首先对要插入的key调个hash
- resize方法
- resize需要遍历所有hm元素,还要重新进行hash分配,是一个非常耗时的工作,要尽量避免使用resize.
- 具体流程有很多情况,大概就是:
- 拿到原数组大小和阈值,都乘2做新的大小和阈值.
- 链表的最后一个节点直接&(新数组长-1)定位, 作为链表头,所以显然jdk1.8采用的是尾插法(jdk1.7采用的是头插法,在多线程情况下会导致链表循环和结点丢失的问题)
- hash计算是非常耗时的,而重新分配要不就是维持原位要不就是加一个旧数组长定位到新数组位置,因此为了避免重新hash计算的开销,resize方法中把旧元素位置&上oldCap,这个oldCap最高位为1其他为0的数,&之后能够得出来原hash的最高位,是1就定位到加长那段,是0就保持原位.
- 遍历之前的所有hm元素, hash分配到新的数组.
- 根据这个大小和阈值new一个空的新数组.
- 检查到树结构的时候检查子树节点数是否大于UNTREEIFY_THRESHOLD, 是就分成两棵子树,不是就退化成链表.
常见问题
- 为什么可以实现O(1)的查找时间复杂度?
- 因为底层用的数组+散列表/红黑树
- 可以在创建HashMap时指定大小吗?
-
hm有可以指定容量大小和负载因子的构造函数, 但实际上由于hm没有capacity这种字段,输进去容量大小也只会调用tableSizeFor方法把容量设定为最接近给定容量的2的幂次大小,然后暂时赋值给threshold再用resize方法把它赋给newCap,最后用这个newCap新建底层数组.
-
tableSizeFor用于找到与给定数最接近的二的幂次方,在初始化和添加时被大量执行,因此一定要保证效率.
-
tableSizeFor通过反复或自身的右移数来使得低位全为1,再通过+1得到最接近的二的幂次数.
-
- 所以如果预期会存很多数据,最好初始化容量大小,可以避免不断扩容的重插链表的开支.
-
- 为什么大小是2的幂次方?
- 因为Hash值大概有40亿的映射空间,但是我们内存存不下40亿大小的数组,,所以不可能直接用这个散列值当下标,所以就要用这个散列值对数组长度取模来获得最终的映射位置.然而,取余操作在除数等于2的幂次方的时候,他就等价于这个它与上这个除数减一,而位运算当然比数学取余要快得多,所以就把HM的长度定为了2的幂次方.(取余等于除出来整数商之后再用整数商回来算余数或者模,比与运算慢多了)
- 线程安全吗?
- 非线程安全,jdk1.7在resize的时候由于使用头插法,多线程时会导致链表循环指向和结点丢失的问题。
- 虽然jdk1.8用尾插法解决了这个问题,但是put的时候仍然可能产生元素丢失的问题,比如线程Ahash到了空的位置1,但是在A确认插入的时候时间片耗尽了,线程B这时候进来也hash到了空的位置1,并且完成了插入,此时轮回A的时候由于A已经确认过位置是空的,就会直接覆盖掉B的值,导致元素丢失问题。
- 为什么hm可以存null值,而ht和chm都不能?
- put为null:因为他们在put元素的时候都需要对key做hash,而只有hm在put控制的时候做特殊处理,直接返回0而不进行hash计算,而ht和chm会直接进行hash计算导致空指针异常。
- get为null:ht和chm无法判断null值到底是不存在还是key为null。hm因为不并发所以可以通过contain加一重判断做排除,而ht和chm由于用了安全失败机制(fail-safe),contain和get到的东西可能都不是一个东西,所以无法判断null的具体情况。
- hm和ht的区别?
- 实现方式不同:Hashtable 继承了 Dictionary类,而 HashMap 继承的是 AbstractMap 类。
- Dictionary 是 JDK 1.0 添加的,貌似没人用过这个,我也没用过。
- 初始化容量不同:HashMap 的初始容量为:16,Hashtable 初始容量为:11,两者的负载因子默认都是:0.75。
- 扩容机制不同:当现有容量大于总容量 * 负载因子时,HashMap 扩容规则为当前容量翻倍,Hashtable 扩容规则为当前容量翻倍 + 1。
- 迭代器不同:HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的 Enumerator 不是 fail-fast 的。所以,当其他线程改变了HashMap 的结构,如:增加、删除元素,将会抛出ConcurrentModificationException 异常,而 Hashtable 则不会。
- fail-fast?
- 是java集合里的一种机制,在用迭代器遍历一个集合的时候,如果遍历的时候改动了集合的内容,就会抛出Concurrent Modification Exception。
- 具体来说:迭代器会在遍历集合的时候维护一个modCount变量,每次改动集合会同时改动modCount的值,然后每当迭代器用next或者hasNext方法遍历下一个元素之前都会检查一下这个modCount是不是等于exceptModCount,是的话就返回遍历,否则就终止遍历并且抛出异常。
- 不能依赖这个异常抛出机制来进行并发操作,因为如果正好在修改了集合还没来得及更改modCount的时候检查了exceptModCount,异常不会抛出。所以一般这个机制只建议用来检测并发修改的bug。