深入理解HashMap
- jdk1.7和jdk1.8的HashMap的底层数据结构是什么?
- HashMap初始容量大小和加载因子分别是多少?
- 链表转红黑树的阈值是多少?
- 红黑树转链表的阈值是多少?
- HashMap的哈希函数怎么设计的?
- 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?
- 两个键的hashcode相同,如何存储键值对?
- 有哪些办法解决hash冲突?HashMap是怎么解决hash冲突的?
- 在解决hash冲突的时候,为什么HashMap不直接用红黑树?而是先用链表再用红黑树?
- HashMap在什么条件下扩容?为什么扩容时2的次幂?
- HashMap的get过程?
- HashMap的put过程?
- HashMap1.8版本用的是头插法还是尾插法?
- HashMap1.8版本相对于1.7版本主要做了哪些改进?
- HashMap和HashTable有什么区别?
- HashMap、LinkedHashMap、TreeMap有什么区别?
- HashMap、TreeMap、LinkedHashMap各自的使用场景?
- 为什么HashMap是不安全的?如何规避HashMap的线程不安全?
文章目录
- 深入理解HashMap
- 1. jdk1.7和jdk1.8的HashMap的底层数据结构是什么?
- 2. HashMap初始容量大小和加载因子分别是多少?
- 3. 链表转红黑树的阈值是多少?
- 4. 红黑树转链表的阈值是多少?
- 5. HashMap的哈希函数怎么设计的?
- 6. 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?
- 7. 两个键的hashcode相同,如何存储键值对?
- 8. 有哪些办法解决hash冲突?HashMap是怎么解决hash冲突的?
- 9. 在解决hash冲突的时候,为什么HashMap不直接用红黑树?而是先用链表再用红黑树?
- 10. HashMap在什么条件下扩容?为什么扩容是2的次幂?
- 11. HashMap的get过程?
- 12. HashMap的put过程?
- 13. HashMap1.8版本用的是头插法还是尾插法?
- 14. HashMap1.8版本相对于1.7版本主要做了哪些改进?
- 15. HashMap和HashTable有什么区别?
- 16. HashMap、LinkedHashMap、TreeMap有什么区别?
- 17. HashMap、TreeMap、LinkedHashMap各自的使用场景?
- 18. 为什么HashMap是不安全的?如何规避HashMap的线程不安全?
1. jdk1.7和jdk1.8的HashMap的底层数据结构是什么?
-
jdk1.7底层数据结构: HashMap是数组、链表
-
jdk1.8底层数据结构: HashMap是数组、链表、红黑树
-
数组的特点:查询效率高,插入删除效率低
-
链表的特点:查询效率低,插入删除效率高
-
在HashMap底层使用数组加(链表或红黑树)的结构完美的解决了数组和链表的问题,使得查询和插入和删除效率都很高。引入红黑树解决过长链表效率低的问题。
HashMap1.7的底层数据结构;
HashMap1.8的底层数据结构;
2. HashMap初始容量大小和加载因子分别是多少?
- 初始容量是16,加载因子是0.75。 ThreadLocalMap初始大小为16,加载因子为2/3.
- 加载因子 是衡量哈希表密集程度的一个参数,如果加载因子越大,说明哈希表被装在的越多,出现hash冲突的可能性越大,繁殖,被装载的越少,出现hash冲突的可能性越小,如果过小,内存使用率不高,该值取值应该考虑到内存使用率和hash冲突概率的平衡。
3. 链表转红黑树的阈值是多少?
class HashMap{
...
//链表转红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
//红黑树转链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
...
}
数组长度大于64且同的长度大于8。
关于 8 :
桶中的节点频率遵循泊松分布,从桶长度k的频率表可以看出,桶长度超过8的概率不到千万分之一。
红黑树占用空间比较大,大概是常规链表的两倍,所以超过了8才选择改为红黑树。
关于 64 :
如果数组长度小于64,则会对数组扩容,而不是链表转为红黑树。只有两个条件都满足才会链表转为红黑树。
原因: 如果数组比较小,应该尽量避免红黑树结构。红黑树结构较为复杂,红黑树需要进行左旋、右旋、变色这些操作才能保持平衡。在数组容量较小的情况下,操作数组要比操作红黑树更加节省时间。
我们看到 putValue 方法
put 方法
public V put(K key, V value) {
//先计算key的hash值,然后再调用putVal
return putVal(hash(key), key, value, false, true);
}
putVal 方法
//onlyIfAbsent为false,说明如果已经存在相同(== 、equals)的key,则覆盖并返回旧值。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//没有产生hash碰撞,即table的第i个同还没有元素,直接插入
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//此时第i个同已经存在元素,且p是这个桶的第一个元素
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//先比较第一个元素,如果hash值相等并且(是同一个key|| 两个key equals),直接跳到最后进行旧值覆盖
e = p;
else if (p instanceof TreeNode)
//如果第i个桶是红黑树的话,执行红黑树的插入逻辑
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果第i个桶是一个链表,则遍历整个链表
//利用binCount来计数链表的节点数
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//已经遍历到链表最后,则在尾部添加一个节点
p.next = newNode(hash, key, value, null);
//加入此时链表有8个节点,遍历到第8个节点的时候(此时binCount为7,binCount初始值为0)
//条件成立,则链表转变为红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//遍历的过程中,如果与其中一个节点的key 的hash值相等并且(同一个key || 两个key equals),直接跳到最后旧值覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
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;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
链表转红黑树的 treeifyBin 方法
/**
* 将链表节点转为红黑树节点
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//1. 如果table为空,或者 table的长度小于64,调用热size方法进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//2. 根据hash值计算索引值,将该索引值位置的节点赋给e,从e开始遍历该索引位置的链表
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//3.将链表节点转红黑树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
//4.如果是第一次遍历,将头结点赋值给hd
if (tl == null)//tl为空代表为第一次循环
hd = p;
else {
//5.如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性
p.prev = tl;
tl.next = p;
}
//6.将p节点赋值给tl。用于在下一次循环中作为上一个节点进行一些链表的关键操作(p.prev = tl 和 tl.next = p)
tl = p;
} while ((e = e.next) != null);
//7.将table该索引位置赋值给新转的TreeNode的头结点,如果该节点不为空,则以以头结点hd为根节点,构建红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
4. 红黑树转链表的阈值是多少?
见 3 图:红黑树节点数小于6,红黑树转成链表。
HashMap 红黑树转链表的 split 方法:
resize() --> split()
/** 这个方法在HashMap进行扩容时会调用到: ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
* @param map 代表要扩容的HashMap
* @param tab 代表新创建的数组,用来存放旧数组迁移的数据
* @param index 代表旧数组的索引
* @param bit 代表旧数组的长度,需要配合使用来做按位与运算
*/
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
//做个赋值,因为这里是((TreeNode<K,V>)e)这个对象调用split()方法,所以this就是指(TreeNode<K,V>)e对象,所以才能类型对应赋值
TreeNode<K,V> b = this;
//设置低位首节点和低位尾节点
TreeNode<K,V> loHead = null, loTail = null;
//设置高位首节点和高位尾节点
TreeNode<K,V> hiHead = null, hiTail = null;
//定义两个变量lc和hc,初始值为0,后面比较要用,他们的大小决定了红黑树是否要转回链表
int lc = 0, hc = 0;
//这个for循环就是对从e节点开始对整个红黑树做遍历
for (TreeNode<K,V> e = b, next; e != null; e = next) {
//取e的下一节点赋值给next遍历
next = (TreeNode<K,V>)e.next;
//取好e的下一节点后,把它赋值为空,方便GC回收
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;
}
}
//如果低位链表首节点不为null,说明有这个链表存在
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);
}
}
//如果高位链表首节点不为null,说明有这个链表存在
if (hiHead != null) {
//如果链表下的元素小于等于6
if (hc <= UNTREEIFY_THRESHOLD)
//那就从红黑树转链表了,高位链表,迁移到新数组中的下标 = 【旧数组 + 旧数组长度】
tab[index + bit] = hiHead.untreeify(map);
else {
//高位链表,迁移到新数组中的下标 = 【旧数组 + 旧数组长度】,把高位链表整个拉到这个新下标下,做赋值。
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
5. HashMap的哈希函数怎么设计的?
Hash函数设计: hash 函数是先拿到 key 的 hashcode, 它是一个 32 为的 int 值,然后让 hashcode 的高16位于低16位进行异或操作。
hash函数作用: HashMap 采用 hash 算法来决定每个元素的存储位置(真正找到数组中下标则是通过寻址算法 (n-1) & hash
这也是取模运算的变体)
//Java8的散列值优化函数,也叫扰动函数
static final int hash(Object key) {
int h;
//右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,
//就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
6. 为什么采用hashcode的高16位和低16位异或能降低hash碰撞?
- 32位哈希码的空间范围大,[-2^31 ~ 2^31-1],前后加起来有42亿多的映射空间,哈希码右移16位(32bit的一半),高半区和低半区做异或,混合了原始哈希码的高位和低位,加大了低位的随机性,减少哈希碰撞。
- 混合后的低位掺杂了高位的部分特征,高位的信息也被变相保留了下来。
详细见 https://deep-sea-tramp.blog.csdn.net/article/details/111243211
7. 两个键的hashcode相同,如何存储键值对?
hashcode相同,通过 equals 比较内容是否相同。若相同,则新的 value 覆盖以前的 value。 若不相同,则将新的键值对存储到 HashMap。
8. 有哪些办法解决hash冲突?HashMap是怎么解决hash冲突的?
8.1 发生hash碰撞的条件
**HashMap 中哈希碰撞(冲突)的条件是指不同key被映射到同一个桶。**当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了。
8.2 解决 hash 冲突常用方法
-
链地址法:
将所有哈希地址为 i 的元素构成一个成为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因为查找、插入、删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。HashMap中使用的就是链地址法。
-
建立公共溢出区:
将哈希表分为公共表和溢出表,发生溢出时,将溢出数据存入溢出区。
-
开放地址法:
从发生冲突的单元起,按照一定的顺序从哈希表中找出一个空白单元,然后把冲突元素存入该单元的方法;所需长度>=元素个数;开放地址中解决冲突的方法:线性探测法(ThreadLocal),平方探测法,双散列函数探测法
示例:
-
假设关键字集合为 { 12 , 33 , 4 , 5 , 15 , 25 },表长为 10。我们用散列函数 f(key) = key mod 10 计算地址。key = 15时,发现 f(15) = 5,与 5 所在的位置冲突。我们应用上面的公式 f(15) = (f(15) + 1) mod 10 = 6 将 15 存入下标为 6 的位置
-
-
-
再哈希:
同时构造多个不同的哈希函数,第一个哈希函数冲突,使用第二个,以此类推。
9. 在解决hash冲突的时候,为什么HashMap不直接用红黑树?而是先用链表再用红黑树?
- HashMap 解决 hash 冲突的时候,先用链表,再转红黑树,是为了时间和空间的平衡。
- TreeNodes 占用的空间大小大约是普通 Nodes 的两倍,只有在容器中包含足够的节点保证使用才用它,在节点数比较小的时候,对于红黑树来说,内存上的劣势会超过查找等操作的优势,使用链表更加好。
- 节点数比较多的时候,综合考虑时间和空间,红黑树比链表要好。
HashMap 与红黑树
红黑树需要进行左旋右旋变色等操作来保持平衡,而单链表则不需要。当元素小于8个的时候,做查询操作,链表结构已经能保证查询性能。
当元素大于 8 个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
对 HashMap 的相应位置进行查询的时候,就回去循环遍历这个超级大的链表,性能不好。 java8使用红黑树来代替 8 个节点数的链表后,查询方式性能得到了很好的提升,从原来的 O(n) 到了 O(log n)
10. HashMap在什么条件下扩容?为什么扩容是2的次幂?
10.1 HashMap的扩容条件以及扩容
扩容会发生在两种情况下(满足任意一种条件就可以发生扩容):
- 当前存入的数据个数大于扩容阈值(例如 16* 0.75)即发生扩容
- 存入数据到某一条链表上,此时长度大于 8,且数组长度小于64 即发生扩容
扩容:每次扩容的容量都是之前容量的2倍。 HashMap 的容量是有上限的,必须小于 1 << 30 即 2^30。如果容量超出了这个数,则不再增长,且阈值会被设置为 Integer.MAX_VALUE。 HashMap 1.8版本的扩容相对于1.7版本,性能方面做了优化(见 14 问),1.8版本的HashMap扩容后,节点在新数组的位置只有两种,原下标位置或者原下标+旧数组的长度
相关源码:put()->putVal() treeifyBin() 其中有对是否要resize()做判断
10.2 为什么扩容是2的次幂
HashMap 的初始容量是 2 的 n 次幂,扩容也是 2 倍的形式进行扩容,可以使得添加的元素均匀分布在 HashMap 中的数组上,减少 hash 碰撞,充分利用内存空间(所有下标都能用上)。
HashMap的长度为什么是 2 的幂次方具体数学原因:
看到寻址算法:https://deep-sea-tramp.blog.csdn.net/article/details/111243057
- 公式:( n - 1 ) & hash
- 当 n 为 2 的指数次幂时,减 1 后换算成 2 进制,则每一位都为 1 , 与 hash 进行与运算, 就可以得到 ( 0 ~ n-1 )范围内的每一个 index
- 当 n 不为 2 的指数次幂时,减 1 后换算成 2 进制,二进制数中会出现 0,与 hash 进行与运算,会导致 ( 0 ~ n-1 ) 范围内的某些 index 永远得不到
寻址算法 (n-1) & hash 当 n为 2 的次幂的时候,等同于 hash % n,其中n为数组长度,我们也知道在计算机中除法和取模运算是性能低下的,而位运算则能提高运算效率。此外,如果 n 是奇数,如 15 即1111 减 1 之后再和 hashcode进行与运算,最后一位就是 0 ,有一些位置就不能存放元素,极大浪费空间。
11. HashMap的get过程?
- 通过 hash 值获取 key 映射到的桶
- 根据该桶的存储结构决定是遍历红黑树还是遍历链表
- table[i] 的首个元素是否和 key 一样,如果相同则返回该value
- 如果不同,先判断首元素是否是红黑树节点,如果是,则去红黑树中遍历查找,反之去链表中遍历查找。
相关源码:
public V get(Object key)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key)
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果当前table没有数据的话返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//根据当前传入的hash值以及参数key获取一个节点即为first,如果匹配的话返回对应的value值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {//如果参数与first的值不匹配的话
//判断是否是红黑树,如果是红黑树的话先判断first是否还有父节点,然后从根节点循环查询是否有对应的值
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//如果是链表的话循环拿出数据
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
get过程图:
12. HashMap的put过程?
- 对key的hashCode()做hash运算,计算index
- 查看 table[index] 是否存在数据,没有,则构造一个Node节点存放在其中;
- 存在数据,说明发生了 hash 冲突,继续判断 key 是否相等,相等,用新的 value 替换原数据;
- 若不相等,判断当前节点类型是不是树形节点,如果是树形节点,创造树形节点插入红黑树中;(如果当前节点是树形节点证明当前已经是红黑树了)
- 若不为树形节点,创建普通 Node 加入链表中;判断链表长度是否大于 8 并且数组长度大于 64,则链表转换为红黑树;
- 插入后,判断当前节点数是否大于阈值,如果大于,则扩容为原数组的两倍
相关源码: put()->putVal() treeifyBin()
put流程图:
13. HashMap1.8版本用的是头插法还是尾插法?
头插法与尾插法:
HashMap1.7版本用的是头插法,多线程场景下,1.7版本的头插法存在死循环的风险,1.8版本用的是尾插法,改进了存在死循环的缺陷。HashMap1.7在并发情况下,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%占用问题,所以一定要避免在并发环境下使用HashMap。
HashMap1.8版本在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的应用关系。并发环境下,优先考虑使用ConcurrentHashMap
1.7版本为什么会出现死循环?头插法导致死循环示例:
以下死循环情况部分参考博客:https://blog.csdn.net/thqtzq/article/details/90485663
jdk1.7中,扩容的核心源码如下:
void resize(int newCapacity) {//传入新的容量
Entry[] oldTable = table;//引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE;//修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity];//初始化一个新的Entry数组
//将数据转移到新的Entry数组里
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//HashMap的table属性引用新的Entry数组
table = newTable;
//修改阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
//1, 获取旧表的下一个元素
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
//!!重新计算每个元素在数组中的位置
int i = indexFor(e.hash, newCapacity);
//此处体现了头插法,当前元素的下一个是新数组的头元素
e.next = newTable[i];
//将原数组元素加入新数组
newTable[i] = e;
e = next;
}
}
}
-
假设旧表的初始长度为 2,此时已经在下标为 1 的位置存放了两个元素,再 put 第三个元素的时候需要考虑扩容
-
此时两个线程AB都进行 put 操作,线程 A 先扩容,代码执行到Entry<K,V> next = e.next,线程A挂起;
然后线程B开始执行transfer函数中的while循环,会把原来的table变成一个table(线程B自己的栈中),再写入到内存中。
注意,因为线程A的e指向了 key(3),next指向了key(7),其在线程B rehash后,指向了线程B重组后的链表。我们可以看到链表的顺序被翻转了。
-
线程A被唤醒,继续执行:
-
先执行了 newTable[i] = e;
-
然后是 e = next,导致了 e 指向了 key(7);
-
而下一次循环的 next=e.next 导致 next指向了 key(3);
如下图:
-
-
当前循环:
e.next = newTable[i];
newTable[i] = e ;
e = next;
将key(7)摘下来采用头插法,放到newTable[i]的第一个元素中,下一个结点指向key(3)
下一次循环:
next = e.next; 此时e为key(3)
此时next = null; 不会在往下循环了。 -
此时key(3)采用头插法又放到newTable[i]的位置,导致key(3)指向key(7),注意此时key(7).next已经指向了key(3),所以环形链表就出现了。如下图:
于是当我们的线程A调用get()方法时,如果下标映射到3处,则会出现死循环。
总结:
线程A先执行,执行完Entry<K,V> next = e.next;这行代码后挂起,然后线程B完整的执行完整个扩容流程,接着线程A唤醒,继续之前的往下执行,当while循环执行3次后会形成环形链表。
1.8版本尾插法源码分析:(在putVal()方法中)
...
//如果第i个桶是一个链表,则遍历整个链表
for (int binCount = 0; ; ++binCount) { //利用binCount来计数链表的节点数
if ((e = p.next) == null) {
//已经遍历到链表最后,则在尾部添加一个节点
p.next = newNode(hash, key, value, null);
//假如此时链表有8个结点,遍历到第8个结点的时候(此时binCount为7,binCount初始值为0),
//条件成立,则链表转变为红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
...
14. HashMap1.8版本相对于1.7版本主要做了哪些改进?
- 数据结构的差异 1.7 数组+链表 ;1.8 数组+链表或红黑树
- 链表的插入方式优化 1.7头插法; 1.8尾插法
- 扩容的改进 1.7全部rehash ;1.8简单判断(判断新增位是0还是1),要么原位置,要么【原位置+旧容量】的位置
- 插入数据的差别 1.7先判断是否要扩容再插入; 1.8先插入,插入完成再判断是否需要扩容
1.7版本中的插入:(1.8为put和putVal方法)
void addEntry(int hash, K key, V value , int bucketIndex){
if((size >= threshold) && (null != table[bucketIndex])){
resize(2*table.length);
hash = (null != key)?hash(key):0;
bucketIndex = indexFor(hash,table.length);
}
createEntry(hash,key,value,bucketIndex);
}
为什么1.8不选择也是先扩容再加?(复制知乎上别人的回答)
jdk7先扩容,然后使用头插法,直接把要插入的Entry插入到扩容后数组中,头插法不需要遍历扩容后的数组或者链表。而jdk8如果要先扩容,由于是尾插法,扩容之后还要再遍历一遍,找到尾部的位置,然后插入到尾部。(也没怎么节约性能)
感觉jdk8可能浪费性能的地方,在Node插入之后,如果当前数组位置上节点数量达到了8,先树化,然后再计算需不需要扩容,前面的树化可能被浪费了。
15. HashMap和HashTable有什么区别?
- HashMap是线程不安全的,HashTable是线程安全的;
- 由于线程安全,所以HashTable的效率比不上HashMap;
- HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而HashTable则不允许;
- HashMap 默认初始化数组的大小为 16,HashTable 为 11,前者扩容时,扩大两倍(1.7与1.8又不一样),后者扩大两倍+1
- 添加键值对时的 hash 值算法不同:HashMap 的 hash 算法是扰动函数,而 HashTable直接使用对象的 hashCode ;
HashTable初始容量为11
public Hashtable(){
this(11,0.75f);
}
HashTable扩容为2倍+1
protected void rehash() {
int oldCapacity = table.length;
Entry<?,?>[] oldMap = table;
// 扩容为2倍+1
int newCapacity = (oldCapacity << 1) + 1;
...
}
HashTable直接使用对象的hashCode作为hash
public synchronized V put(K key, V value) {
...
Entry<?,?> tab[] = table;
//直接使用对象的hashCode作为hash
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
...
addEntry(hash, key, value, index);
return null;
}
HashMap 的扩容:要么在【原位置】要么在【旧位置+旧容量】,通过判断新增位是 0 还是 1,如果是 0 就在【原位置】,如果是 1 就在【旧位置+旧容量】。
//resize()方法中对链表的扩容判断处理
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//oldCap 1 0000
//e.hash 1 0110 -> 【旧位置+旧容量】
//e.hash 0 0110 -> 【原位置】
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;
}
16. HashMap、LinkedHashMap、TreeMap有什么区别?
共同点: 线程不安全
不同点:数据无序、数据有序、数据有序还可以对数据进行排序
- HashMap 中 key 的值没有顺序,相对于 LinkedHashMap 和 TreeMap,使用更广泛。
- LinkedHashMap 内部有一个双向链表(head、tail、双向),保持Key插入的顺序。访问顺序和插入顺序是一致的。
- TreeMap 的顺序是 key 的自然顺序(如整数从小到大),也可以指定比较函数。访问顺序和插入顺序不一定是一致的。
三者的使用方法:
public class MapsTest {
public static void main(String[] args) {
testHashMap();
System.out.println("===========");
testLinkedHashMap();
System.out.println("===========");
testTreeMap();
}
private static void testHashMap() {
Map<String, String> hashMap = new HashMap<String, String>();
hashMap.put("name1", "图图11");
hashMap.put("name2", "图图12");
hashMap.put("name3", "图图13");
Set<Map.Entry<String, String>> set = hashMap.entrySet();
Iterator<Map.Entry<String, String>> iterator = set.iterator();
while(iterator.hasNext()) {
Map.Entry entry = iterator.next();
String key = (String) entry.getKey();
String value = (String) entry.getValue();
System.out.println("HashMap , key:" + key + ",value:" + value);
}
}
private static void testLinkedHashMap() {
Map<String, String> linkedHashMap = new LinkedHashMap<>(16, 0.75f, true);
linkedHashMap.put("name1", "图图21");
linkedHashMap.put("name2", "图图22");
linkedHashMap.put("name3", "图图23");
System.out.println("开始时顺序:");
Set<Map.Entry<String, String>> set = linkedHashMap.entrySet();
Iterator<Map.Entry<String, String>> iterator = set.iterator();
while(iterator.hasNext()) {
Map.Entry entry = iterator.next();
String key = (String) entry.getKey();
String value = (String) entry.getValue();
System.out.println("LinkedHashMap, key:" + key + ",value:" + value);
}
System.out.println("通过get方法,导致key为name1对应的Entry到表尾");
linkedHashMap.get("name1");
Set<Map.Entry<String, String>> set2 = linkedHashMap.entrySet();
Iterator<Map.Entry<String, String>> iterator2 = set2.iterator();
while(iterator2.hasNext()) {
Map.Entry entry = iterator2.next();
String key = (String) entry.getKey();
String value = (String) entry.getValue();
System.out.println("LinkedHashMap, key:" + key + ",value:" + value);
}
}
static void testTreeMap() {
TreeMap<String,String> map = new TreeMap<String,String>(new xbComparator());
map.put("name1", "图图31");
map.put("name2", "图图32");
map.put("name3", "图图33");
Set<String> keys = map.keySet();
Iterator<String> iter = keys.iterator();
while(iter.hasNext())
{
String key = iter.next();
System.out.println("TreeMap , key is: "+key+" ,value: "+map.get(key));
}
}
static class xbComparator implements Comparator
{
public int compare(Object o1,Object o2)
{
String i1=(String)o1;
String i2=(String)o2;
return i1.compareTo(i2);
}
}
}
运行结果:
HashMap , key:name3,value:图图13
HashMap , key:name2,value:图图12
HashMap , key:name1,value:图图11
===========
开始时顺序:
LinkedHashMap, key:name1,value:图图21
LinkedHashMap, key:name2,value:图图22
LinkedHashMap, key:name3,value:图图23
通过get方法,导致key为name1对应的Entry到表尾
LinkedHashMap, key:name2,value:图图22
LinkedHashMap, key:name3,value:图图23
LinkedHashMap, key:name1,value:图图21
===========
TreeMap , key is: name1 ,value: 图图31
TreeMap , key is: name2 ,value: 图图32
TreeMap , key is: name3 ,value: 图图33
规律:
- HashMap无序
- LinkedHashMap 插入和get都会往队尾放入(最新的都在队尾)
- TreeMap 有序 由小到大 compareto()返回-1为小
17. HashMap、TreeMap、LinkedHashMap各自的使用场景?
见16的顺序特性
18. 为什么HashMap是不安全的?如何规避HashMap的线程不安全?
- HashMap 不安全的原因:内部没有锁的机制,多个线程某个时刻同时操作HashMap并执行put操作,且hash值相同,这个时候就需要解决冲突。很多方法如put()、addEntry()、resize()等都是不同步的。
- 并发场景下,优先考虑使用ConcurrentHashMap替换HashMap,不建议使用HashTable替换HashMap,因为ConcurrentHashMap是HashTable的优化。