距离接触HashMap有一段时间,我对Java集合类有了更加深入的理解。通过jdk1.8和1.7的对比发现HashMap的优化进步。最大的特点就是HashMap1.7版本最大的问题就是有很低的概率会发生在散列表中一个链表的长度很长,使得其查询退化为O(n)复杂度。在1.8中,当链表长度大于8时,转化为红黑树。众所周知,红黑树是一种平衡二叉树,是不会存在这种问题的。
同时,我们也是知道HashMap是一个线程不安全的集合类,但其性能在单线程环境下一直较佳。如果在并发环境下,推荐使用ConcurrentHashMap,它实现了更好的线程安全及并发性。HashTable也是与它们类似,但是其单线程环境下性能不如HashMap,多线程环境下由于HashTable使用了synchronized关键字,同一时间只允许一个线程访问,所以并发性不如ConcurrentHashMap,并不推荐HashTable。
简单介绍了一下HashMap,在接下来我选择HashMap的重点四项
- Hash算法获取桶
- Put方法
- 扩容机制
- 线程安全性,形成环形链表
1.Hash算法
我们希望HashMap的元素位置分布尽量均匀。这样我们不同遍历链表就可以拿到元素,大大优化了查询效率。
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
Hash算法分为三步:
- 取Key的HashCode
- 高位运算
- 取模运算(方法二为jdk1.7中的函数,jdk1.8虽然没有这个函数,但是细心的人就会发现在put方法是会带有取模运算的)
对于hash算法首先我们可能会想到对hashcode进行一次取模运算,如:key%length。但是我们知道这种方式在效率上是低于位运算的,对于length我们始终取2的N次方,h&(length-1)的效果和%是一样的,所以选择这个。
jdk1.8优化了高位运算,从速度,效率上都得到了提高key.hashCode()) ^ (h >>> 16)。这个运算和取模的过程如下
如图所示,先移16位,再与原hashcode异或,再与(length-1)进行&操作。
2. Put方法
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
代码如下
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//①若tab为空或长度为空,则扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//② 计算索引,并对key=null的情况进行处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//③key存在,则覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//④判断若为红黑树,则插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)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) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
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;
}
图解如下:
3.扩容机制
由于jdk1.8较为复杂,选择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数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
从transfer方法我们可以知道从老数组的的链表插入到新数组的方法为头插法,希望大家可以理解transfer这个方法,这个函数也是发生链表回环的重要方法。
4.线程安全问题
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。如下:
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
通过这个代码我们可以知道,我们需要先插入(5,“C”)。然后Thread1和Thread2同时插入(7,“B”)和(3,“A”),下面我将举例一种特殊的执行顺序,再现一下线程不安全。
Thread1和Thread2同时运行到transfer(老数组复制到新数组),注意添加数据都已经结束,Thread1运行到“Entry next = e.next;”停止,Thread2进行resize。如图
注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
Thread1被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
总结:简单的来说,就是在多线程条件下,可能一次扩容操作被做了两次,这样由于第一次的指针没有处理,所以产生环,导致下一个key相同的数据无法插入。
这就是我的理解,大家加油!!!