HashMap

为什么使用头插法?
因为如果使用尾插法的话,插入元素时需要遍历链表找到最后一个节点进行插入,效率没有头插法高。

为什么数组容量要为2的幂次方数?
因为算下标时是用 hashcode & 2的幂次方数-1,得到下标数的,如果不是2的幂次方数,-1的位上不是1,还有一个原因是hashCode%length = hashCode&length - 1

HashMap基本概念

HashMap采用数组+链表的形式存储键值对,这里不多做解释。我们先来理一下HashMap中遇到的一些名词。
capacity:容量
这个容量指的不是Entry的数量,而是数组的长度:

hashmap的简单结构
capacity表示的并不是Map所能存放的最大KV数,而是数组的长度,也就是桶的大小。

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:

触发红黑树阈值的条件
我们可以看出,虽然变成树化的阈值是8,但如果要从树经过删减变回链表,这个阈值就是6了。

哈希值的计算化简(重点)
可能是因为红黑树能够很好得提高效率,所以新的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的整次幂,也是十分关键的)
这个位运算是这样算的,举个实例:

put方法时计算数组下标的高效方法
但是这样有一个问题,就是hash的高位全部都没有参与运算,无论高位是什么,都不能影响到结果,从而加大了哈希碰撞的概率,所以我们看到的hash其实是已经经过一个函数进行扰乱的了,这个函数就是hash()。
完整计算下标的过程
可以看出,我们在把hash进行取余操作前,已经经过了一次函数的变化,这个操作的作用是,让自己和右移16位后的自己进行异或运算。由于hash值是32的,所以说白了,右移后的数值,高16位肯定是0,低16位就是原来的高16位。对0进行异或运算,是一个"幺元"运算(两边相同为0,不同为1),结果还是自身,所以新hash的高16位不受影响,而新hash的低16将会是原来hash低16和高16位相异或得到的结果,这样的话,高16位就在一定程度上影响了最终hash的值。实验表明,这样是可以降低哈希碰撞的概率的(不能降低也不会用了)。

链表插入方式的不同
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,现在就发生了变化。举个例子:

rehash的巧妙之处
也就算讲,重新计算后的元素,要么就在原来的位置,要么就会在一个新位置这两种选择。而这个新位置,从进制中也可以明显得看出,就是在前面多了一个1而已,也就是在原基础上加上了2n(这里的n都是指原来的n),这个2n刚好也是原来的容量,所以说,元素新的位置,要么不变,要么就在(原位置+原容量)这个索引处。而究竟是哪一个,完全就由hash中权值为2n的这个位决定,而这位的数字,在概率上来说,也是随机的,也就是大家都是50%的概率,这样也很好得减小了hash碰撞的概率。

插入时机不同
1.7之前是扩容后再插入新的数据,并且不会先计算插入值的哈希值,最后单独算。
1.8之后是先插入再扩容,插入的值和大家一起计算新的哈希值。

MIN_TREEIFY_CAPACITY属性

树化的另外一个条件

前面提到了1.8之后,当遇到阈值时,链表会发生树化。但是树化还有一个条件,就是此时的容量必须不小于64。
相关的代码

因为如果桶的数量过少,又发生了严重的hash碰撞,那么根本问题其实是桶的数量太少了,所以此时树化的意义就不大,就会先优先扩容。

为什么capacity要是2的n次幂

为了避免哈希冲突且把值控制在一定范围内,我们会采用取余的方式。而想要高效取余,就得使用位运算,而想要使用位运算,只有当容量是2的整次幂时,才符合要求。同时这种处理方法,可以让rehash的过程,也变得简单很多。
总而言之:为了提高性能,高效得减少哈希冲突。

为什么loadFactor要是0.75f

我们先来看看极端的情况。
当过小时,数组里仅仅有很少的数据,数组就要进行扩容,虽然可以有效减少哈希碰撞,但是消耗的内存及时间过大,得不偿失,过于奢华。
当过大时,数组的内存虽然得到了很好的利用,但是哈希碰撞频繁,从而导致查找,修改效率降低,链表树化明显加重。
所以,需要找到一个平衡点,这个和统计学有关,日后再更新,但肯定是符合科学规律的,所以通常就使用默认值。

分析HashMap的put方法

HashMap的put方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习。
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的过程。

jdk8版本的rehash图示

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
扩容后的变化
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
下标变化
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
resize示意图
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。有兴趣的同学可以研究下JDK1.8的resize源码,写的很赞,如下:

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。结果如下图。
线程2进行resize

注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。

线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
线程1来执行

e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
环形链表就这样出现
于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。

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方法,得出的结果如下表所示:
hash不均匀情况

从表中结果中可知,随着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的冰山一角。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容