为什么使用头插法?
因为如果使用尾插法的话,插入元素时需要遍历链表找到最后一个节点进行插入,效率没有头插法高。为什么数组容量要为2的幂次方数?
因为算下标时是用 hashcode & 2的幂次方数-1,得到下标数的,如果不是2的幂次方数,-1的位上不是1,还有一个原因是hashCode%length = hashCode&length - 1
HashMap基本概念
HashMap采用数组+链表的形式存储键值对,这里不多做解释。我们先来理一下HashMap中遇到的一些名词。
capacity:容量
这个容量指的不是Entry的数量,而是数组的长度:
size:大小
这个大小很好理解,表示的就是Map中真实存放的Entry的数量。
loadFactor:加载因子
他描述的是HashMap满的程度。接近0表示很空,1表示填满了。
threshold:阈值
当HashMap中的键值对超过了该值,HashMap就会进行扩容,这个值的大小和上面的capacity以及loadFactor息息相关,为容量*加载因子,即 threshold=capacity * loadFactor。比如:threshold = 16 * 0.75 = 12。那么当KV数量超过12时,HashMap就会进行扩容。
modCount
modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
jdk8为什么使用红黑树
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。本文不再对红黑树展开讨论,想了解更多红黑树数据结构的工作原理可以参考http://blog.csdn.net/v_july_v/article/details/6105630。
处理逻辑
不指定容量时,会使用默认容量16。但如果我们指定,容量将会被设为第一个不小于设置值的2的幂次方数。比如我们指定3,那么容量就是4;指定16,容量就是16,指定17,容量就是32。这个过程,是在上图代码中的最后一行的:tableSiezFor()函数里完成的(不是一次完成,通过一系列的位运算达到效果)。下面的代码可以试一下传入不同的值时,capacity的数值会被设置成多少:
public static void main(String[]args) throws Exception {
HashMap<String,String> m = new HashMap<>(16);
Class c1 = m.getClass();
Method m1 = c1.getDeclaredMethod("capacity");
m1.setAccessible(true);
System.out.println("capacity的数值是:" + m1.invoke(m));
}
现在我们就在看一下最后这个tableSiezFor(int cap)函数到底干了什么。源码如图:
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;
}
形参cap就是容量的缩写。最前面也提到了,我们这里说的容量就是指数组的长度,而这个数组就是table,所以也可以成为table的size。代码只有两行,总得来说作用就是通过一系列的位运算,找到那个合适的2的n次幂,并把他赋值给阈值threshold(后面会说为什么直接赋给阈值而不是乘以加载因子再赋值)。
threshold阈值。当集合中的键值对数量超过了该值,集合就会扩容。当然,阈值也会相应的重新计算。比如有一个集合,它的容量是16,阈值自然就是12,此时集合里面有11个键值对对象,当再增加一个的时候,将会达到阈值,但并没有超过,所以不会扩容,而如果再加一个的话,集合的容量就将被扩大为32,阈值也会被修改为32*0.75=24。(jdk1.7的时候还会做一个判断:如果插入元素刚好放在一个空的数组位,那么将不会扩容(resize()),但1.8之后不做这个判断了)
1.8之后,hashMap的初始化函数仅仅是对根据参数初始化了内部几个重要的参数,并没有构造内部数组,数组的生成被延迟到put函数中,在第一次put时,调用resize()函数进行初始化,阈值也是在此时才被乘以加载因子的,所以如果集合是空的,此时的阈值就是等于容量的。
HashMap在JDK1.8之后的改动
使用红黑树进行优化
原来的HashMap采用的是数组加链表的形式,而jdk1.8之后,改用了数组+链表+红黑树的方式,当我们某个数组元素的链表过长时,就会把链表转化为红色数进行存储,这里的阈值是8:
哈希值的计算化简(重点)
可能是因为红黑树能够很好得提高效率,所以新的HashMap允许散列值的计算稍微简化一点,虽然散列性变差了一点,但是红黑树完全可以弥补这个不足。我们这里就只具体看一下1.8中的哈希算法是如何计算的。
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);
}
这里的代码很简单,就是让key对象的哈希值自身和右移16位后的自己进行异或运算。我们这里来探究一下为什么要这样做。
计算key的hash值是int类型值,是32位,而我们的容量很明显不会有这么大,所以需要把原始的哈希值进行运算,使得其在某个固定的区间。比如我们容量只有16,那么下标就是[0 , 15],这样的话,我就必须保证哈希值在此区间。首先最直白的方式肯定是取余,比如对16取余,就可得到0-15这16种结果,刚好可以放入数组中。
但是,取余操作的效率很低,而JDK8采用了 hash & (table.length -1) 形式的位运算,其本质上就是一种效率更高的取余。当进行put操作的时候,底层通过hash & (table.length -1),巧妙得对原始哈希值进行了取余操作。n为数组的长度,(n - 1)减一就是会得到一个低位全为1的二进制数,如15(0000 1111),31(0001 1111)等,某个数对这种数进行与操作,很明显高位全为0,低位全保留了原样,从而把范围限定在了0到n-1之间。(按位运算方法取余只对2n-1才适用,所以我们容量n为2的整次幂,也是十分关键的)。
这个位运算是这样算的,举个实例:
链表插入方式的不同
HashMap的put方法中,在1.7之前,链表元素的插入采用的是头插法,也就是说,当有新结点进来时,会在插入在链表的头部。很明显,由于不用遍历链表,这种插入方式的效率是更高的。但是1.8之后,因为当结点插入的时候,本身就要为了判断元素的个数而遍历链表(看看是否达到了树化的阈值),所以就可以搭一个顺风车,在遍历完之后,把结点插入到链表尾部,即采用的尾插法。
这种方式也解决了多线程下可能引发的死锁问题。因为头插法的链表在扩容移动时,会被逆序,即后插入的先被处理,如果这个时候有另一线程进行get操作,就有可能引发死锁,具体的过程这里就不做解释了。
resize()的改动
- rehash的方式不同
- 新数据的插入时机的不同
1.8之后,初始化和扩容合二为一,都放在了resize()函数中。
rehash的方式不同
因为数组扩容了,key的下标需要重新通过哈希计算。在1.7之前,采用的是按照之前的方式全部重新计算一遍,这样很明显会比较耗费时间。而1.8之后,采用了非常巧妙的一种方式。我们发现,扩容过后,进行与运算的(n - 1),本质上就是把最低的0位变为1而已,比如原来是(0011 1111)就会变为(0111 1111)。那么同一份hash值,经过这2个不同的(n - 1)的与运算结果,是有一些关联的。由于我们的(n-1)就是前面多了一个1,所以原来的hash值经过该运算,和原结果相比,只会有2种结果,要么原hash值对应的那个位置是上原本就算0,那么与运算的结果不变,要么原来是1,现在就发生了变化。举个例子:
插入时机不同
1.7之前是扩容后再插入新的数据,并且不会先计算插入值的哈希值,最后单独算。
1.8之后是先插入再扩容,插入的值和大家一起计算新的哈希值。
MIN_TREEIFY_CAPACITY属性
因为如果桶的数量过少,又发生了严重的hash碰撞,那么根本问题其实是桶的数量太少了,所以此时树化的意义就不大,就会先优先扩容。
为什么capacity要是2的n次幂
为了避免哈希冲突且把值控制在一定范围内,我们会采用取余的方式。而想要高效取余,就得使用位运算,而想要使用位运算,只有当容量是2的整次幂时,才符合要求。同时这种处理方法,可以让rehash的过程,也变得简单很多。
总而言之:为了提高性能,高效得减少哈希冲突。
为什么loadFactor要是0.75f
我们先来看看极端的情况。
当过小时,数组里仅仅有很少的数据,数组就要进行扩容,虽然可以有效减少哈希碰撞,但是消耗的内存及时间过大,得不偿失,过于奢华。
当过大时,数组的内存虽然得到了很好的利用,但是哈希碰撞频繁,从而导致查找,修改效率降低,链表树化明显加重。
所以,需要找到一个平衡点,这个和统计学有关,日后再更新,但肯定是符合科学规律的,所以通常就使用默认值。
分析HashMap的put方法
HashMap的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,如果超过,进行扩容。
JDK1.8HashMap的put方法源码如下:
1 public V put(K key, V value) {
2 // 对key的hashCode()做hash
3 return putVal(hash(key), key, value, false, true);
4 }
5
6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
7 boolean evict) {
8 Node<K,V>[] tab; Node<K,V> p; int n, i;
9 // 步骤①:tab为空则创建
10 if ((tab = table) == null || (n = tab.length) == 0)
11 n = (tab = resize()).length;
12 // 步骤②:计算index,并对null做处理
13 if ((p = tab[i = (n - 1) & hash]) == null)
14 tab[i] = newNode(hash, key, value, null);
15 else {
16 Node<K,V> e; K k;
17 // 步骤③:节点key存在,直接覆盖value
18 if (p.hash == hash &&
19 ((k = p.key) == key || (key != null && key.equals(k))))
20 e = p;
21 // 步骤④:判断该链为红黑树
22 else if (p instanceof TreeNode)
23 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24 // 步骤⑤:该链为链表
25 else {
26 for (int binCount = 0; ; ++binCount) {
27 if ((e = p.next) == null) {
28 p.next = newNode(hash, key,value,null);
//链表长度大于8转换为红黑树进行处理
29 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
30 treeifyBin(tab, hash);
31 break;
32 }
// key已经存在直接覆盖value
33 if (e.hash == hash &&
34 ((k = e.key) == key || (key != null && key.equals(k)))) break;
36 p = e;
37 }
38 }
39
40 if (e != null) { // existing mapping for key
41 V oldValue = e.value;
42 if (!onlyIfAbsent || oldValue == null)
43 e.value = value;
44 afterNodeAccess(e);
45 return oldValue;
46 }
47 }
48 ++modCount;
49 // 步骤⑥:超过最大容量 就扩容
50 if (++size > threshold)
51 resize();
52 afterNodeInsertion(evict);
53 return null;
54 }
扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。
1 void resize(int newCapacity) { //传入新的容量
2 Entry[] oldTable = table; //引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; //HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
分析下:先把旧的Entry数组赋值给src,然后定义下新的Entry数组长度;for循环遍历旧数组,拿到旧数组下标对应的Entry重新赋值给临时Entry e,判断e不为空的话,就把该旧数组下标对应的Entry对象置为空,也就是就数组这个位置变为了null。接着要做的就是把这个链表重新放到扩容后的Map中,此时的e是旧链表的头节点,先把头节点的next节点赋值给临时节点(目的是为了后面的循环判断),然后就调用indexFor方法计算出应该放在新数组的下标数,newTable[i]即是新数组链表第一个节点,e.next = newTable[i]表明了采用头插法,将e的next指针指向新数组该下标的地址,然后再调用newTable[i] = e将该节点赋值给新数组的头节点,再把next赋值给e进行循环插入。
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,说明put顺序依次为 5、7、3(头插法)。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {
7 // 超过最大值就不再扩充了,就只好随你碰撞去吧
8 if (oldCap >= MAXIMUM_CAPACITY) {
9 threshold = Integer.MAX_VALUE;
10 return oldTab;
11 }
12 // 没超过最大值,就扩充为原来的2倍
13 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14 oldCap >= DEFAULT_INITIAL_CAPACITY)
15 newThr = oldThr << 1; // double threshold
16 }
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 else { // zero initial threshold signifies using defaults
20 newCap = DEFAULT_INITIAL_CAPACITY;
21 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22 }
23 // 计算新的resize上限
24 if (newThr == 0) {
25
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 @SuppressWarnings({"rawtypes","unchecked"})
32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33 table = newTab;
34 if (oldTab != null) {
35 // 把每个bucket都移动到新的buckets中
36 for (int j = 0; j < oldCap; ++j) {
37 Node<K,V> e;
38 if ((e = oldTab[j]) != null) {
39 oldTab[j] = null;
40 if (e.next == null)
41 newTab[e.hash & (newCap - 1)] = e;
42 else if (e instanceof TreeNode)
43 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44 else { // 链表优化重hash的代码块
45 Node<K,V> loHead = null, loTail = null;
46 Node<K,V> hiHead = null, hiTail = null;
47 Node<K,V> next;
48 do {
49 next = e.next;
50 // 原索引
51 if ((e.hash & oldCap) == 0) {
52 if (loTail == null)
53 loHead = e;
54 else
55 loTail.next = e;
56 loTail = e;
57 }
58 // 原索引+oldCap
59 else {
60 if (hiTail == null)
61 hiHead = e;
62 else
63 hiTail.next = e;
64 hiTail = e;
65 }
66 } while ((e = next) != null);
67 // 原索引放到bucket里
68 if (loTail != null) {
69 loTail.next = null;
70 newTab[j] = loHead;
71 }
72 // 原索引+oldCap放到bucket里
73 if (hiTail != null) {
74 hiTail.next = null;
75 newTab[j + oldCap] = hiHead;
76 }
77 }
78 }
79 }
80 }
81 return newTab;
82 }
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。代码例子如下(便于理解,仍然使用JDK1.7的环境):
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();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。
Hash较均匀的情况
为了便于测试,我们先写一个类Key,如下:
class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return value;
}
}
这个类复写了equals方法,并且提供了相当好的hashCode函数,任何一个值的hashCode都不会相同,因为直接使用value当做hashcode。为了避免频繁的GC,我将不变的Key实例缓存了起来,而不是一遍一遍的创建它们。代码如下:
public class Keys {
public static final int MAX_KEY = 10_000_000;
private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
static {
for (int i = 0; i < MAX_KEY; ++i) {
KEYS_CACHE[i] = new Key(i);
}
}
public static Key of(int value) {
return KEYS_CACHE[value];
}
}
现在开始我们的试验,测试需要做的仅仅是,创建不同size的HashMap(1、10、100、......10000000),屏蔽了扩容的情况,代码如下:
static void test(int mapSize) {
HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
for (int i = 0; i < mapSize; ++i) {
map.put(Keys.of(i), i);
}
long beginTime = System.nanoTime(); //获取纳秒
for (int i = 0; i < mapSize; i++) {
map.get(Keys.of(i));
}
long endTime = System.nanoTime();
System.out.println(endTime - beginTime);
}
public static void main(String[] args) {
for(int i=10;i<= 1000 0000;i*= 10){
test(i);
}
}
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:通过观测测试结果可知,JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。
Hash极不均匀的情况
假设我们又一个非常差的Key,它们所有的实例都返回相同的hashCode值。这是使用HashMap最坏的情况。代码修改如下:
class Key implements Comparable<Key> {
//...
@Override
public int hashCode() {
return 1;
}
}
仍然执行main方法,得出的结果如下表所示:从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。
测试环境:处理器为2.2 GHz Intel Core i7,内存为16 GB 1600 MHz DDR3,SSD硬盘,使用默认的JVM参数,运行在64位的OS X 10.10.1上。
小结
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。
(5) 还没升级JDK1.8的,现在开始升级吧。HashMap的性能提升仅仅是JDK1.8的冰山一角。