HashMap在JDK1.8的重构

2018-07-06  本文已影响0人  taojian

参考(感谢以下大佬):
http://blog.csdn.net/qq_27093465/article/details/52207135
http://blog.csdn.net/u011392897/article/details/57105709
http://www.cnblogs.com/ygj0930/p/6543350.html
http://blog.csdn.net/u011392897/article/details/57115818
http://blog.csdn.net/u011392897/article/details/60141790
http://blog.csdn.net/u011392897/article/details/60149314
https://coolshell.cn/articles/9606.html
https://www.cnblogs.com/woshimrf/p/hashmap.html
https://blog.csdn.net/qq_27093465/article/details/52207135
https://blog.csdn.net/hzw05103020/article/details/47207787
https://github.com/crossoverJie/Java-Interview/blob/master/MD/HashMap.md

JDK1.7的HashMap

以下基于 JDK1.7 分析。

初始化
image

如图所示,HashMap 底层是基于数组和链表实现的。其中有两个重要的参数:

容量的默认大小是 16,负载因子是 0.75,当 HashMapsize > 16*0.75 时就会发生扩容resize(容量和负载因子都可以自由调整)。

//扩容判断值
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//0.75*16
threshold = newThr;

//容量大于阀值,触发resize
if (++size > threshold)
            resize();

//新建数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];

//后续是利用头插法,将旧值赋值到新的数组去
put 方法

(由于在计算中位运算比取模运算效率高的多,所以 HashMap 规定数组的长度为 2^n 。这样用 2^n - 1 做位运算与取模效果一致,并且效率还要高出许多。参考:https://blog.csdn.net/Feifan_Feimeng/article/details/71006015)

//源码
if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
//先获取长度:(tab = resize()).length
//再算出index:tab[i = (n - 1) & hash]

(这种情况可以利用链表来解决,HashMap 会在 table[index]处形成链表,采用头插法将数据插入到链表中。如:head --> 3 --> 7 --> 5)

get 方法

get 和 put 类似,也是将传入的 Key 计算出 index ,如果该位置上是一个链表就需要遍历整个链表,通过 key.equals(k) 来找到对应的元素。(注意:这里使得HashMap的性能下降,从O(1)变成O(n))

遍历 方法
 Iterator<Map.Entry<String, Integer>> entryIterator = map.entrySet().iterator();
        while (entryIterator.hasNext()) {
            Map.Entry<String, Integer> next = entryIterator.next();
            System.out.println("key=" + next.getKey() + " value=" + next.getValue());
        }
Iterator<String> iterator = map.keySet().iterator();
        while (iterator.hasNext()){
            String key = iterator.next();
            System.out.println("key=" + key + " value=" + map.get(key));

        }
map.forEach((key,value)->{
    System.out.println("key=" + key + " value=" + value);
});

强烈建议使用第一种 EntrySet 进行遍历。

缺点

JDK1.8中HashMap的改进

针对上述的HashMap的三个缺点,JDK1.8该如何解决?

1.扩容效率低怎么办?

没解决!

新版的依然是:容量的默认大小是 16,负载因子是 0.75,达到阀值依然执行resize()。
依然是新增一个数组,损耗依然存在!

2.hash取模冲突导致链表过长,O(1)变成O(n)怎么办?
//Node链表的容量大于8的时候,触发使用红黑树存储
static final int TREEIFY_THRESHOLD = 8;
if (binCount >= TREEIFY_THRESHOLD - 1)
                treeifyBin(tab, hash);

//不用链表存储了,用红黑树存储
     /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

Node中链表长度大于8,就转成红黑树存储了,当然旧数据也会放到红黑树里面。
同时,最差速度到达了O(log(n)),快了!

3.线程不安全,会出现循环链表导致线程死循环怎么办?

官方的回复是:线程安全请使用ConcurrentHashMap,HashMap本身就是线程不安全的。

但是虽然HashMap依然是线程不安全,但是1.8将不会出现死循环的情况了。原因如下:

4.解决了1.7中Hash攻击导致的CPU100%

参考:
https://www.cnblogs.com/stevenczp/p/7028071.html
https://blog.csdn.net/zly9923218/article/details/51656920
https://coolshell.cn/articles/6424.html

首先我们需要知道的是:

Java的hash算法是“非随机的”,是固定的,而且是弱化的,容易相同!

比如:

System.out.println("AaAaAaAa".hashCode());
System.out.println("AaAaBBBB".hashCode());

-540425984
-540425984

相同的hash值意味着相同的index,那么在hashMap中就是会建立N长的链表
因此:JDK1.7中会有Hash Collision DoS的攻击,链表无限长,循环进行equal的时候损耗极大,以至于CPU100%

JDK1.8中针对Hash攻击做了处理!

public class Person implements Comparable<Person>{
    private String firstName;
    private String lastName;
    private UUID id;
    public Person(String firstName, String lastName, UUID id) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.id = id;
    }
    @Override
    public int hashCode() {
        return 5;
    }

    @Override
    public int compareTo(Person o) {
        return this.id.compareTo(o.id);
    }
    // ...
}

@Test
    public void hashMap1_8() throws IllegalAccessException, NoSuchMethodException, InvocationTargetException {

        int LIMIT = 500_000;
            Person person = null;
            Map<Person, String> map = new HashMap<>();

            for (int i=0;i<LIMIT;i++) {
                UUID randomUUID = UUID.randomUUID();
                person = new Person("fn", "ln", randomUUID);
                map.put(person, "comment" + i);
            }
            long start = System.currentTimeMillis();
            map.get(person);
            long stop = System.currentTimeMillis();
            System.out.println(stop-start+" millis");
    }

上述代码在1.8的测试下,加了Comparable接口的情况下,get/set的速度得到了极大的提升!

但是如果不加Comparable接口,1.8下将会触发自己的对比判断,而这个判断是比较对象类型和hashcode,所以会降低速度,但是不会像JDK1.7那样导致链表过长而引起的CPU100%。

参考:https://blog.csdn.net/weixin_42340670/article/details/80673127

if ((kc == null &&
  (kc = comparableClassFor(k)) == null) ||
   (dir = compareComparables(kc, k, pk)) == 0)
  dir = tieBreakOrder(k, pk);

总结如下:
JDK1.7 + 大量hash冲突的情况下:导致CPU100%
JDK1.8 + 大量hash冲突的情况下 + 实现Comparable接口:get/set速度极快,500万下冲突数据都能在毫秒级别响应
JDK1.8 + 大量hash冲突的情况下+ 不实现Comparable接口:get/set速度很慢,因为冲突多会执行内部实现的对比代码,导致get/set速度降低,但是不会像JDK1.7那样导致链表过长而引起CPU100%。

上一篇 下一篇

猜你喜欢

热点阅读