美文网首页
HashMap在JDK1.8的重构

HashMap在JDK1.8的重构

作者: taojian | 来源:发表于2018-07-06 15:55 被阅读0次

参考(感谢以下大佬):
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 方法
  • 首先会将传入的 Key 做 hash 运算计算出 hashcode,然后根据数组长度取模计算出在数组中的 index 下标。

(由于在计算中位运算比取模运算效率高的多,所以 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]
  • 由于数组的长度有限,所以难免会出现不同的 Key 通过运算得到的 index 相同,这个就是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 进行遍历。

  • 第一种可以把 key value 同时取出;
  • 第二种还得需要通过 key 取一次 value,效率较低,;
  • 第三种需要 JDK1.8 以上,通过外层遍历 table,内层遍历链表或红黑树。
缺点
  • 缺点1:数据量大的时候导致频繁扩容,效率降低
  • 缺点2:数据量大的时候,hash取模冲突导致链表过长,O(1)变成O(n)
  • 缺点3:线程不安全,会出现循环链表导致线程死循环

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将不会出现死循环的情况了。原因如下:

  • 1.7中扩容,复制数组使用的是头插法,原本1-2-3的链表会变成3-2-1,所以多线程下容易出现2-3和3-2的情况导致死循环,在查询一个不存在的key时,死循环没办法跳出导致CPU100%!!!
  • 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算法是“非随机的”,是固定的,而且是弱化的,容易相同!

比如:

  • Aa和BB这两个字符串的hash code(或hash key) 是一样的!
  • 同理,我们就可以通过这两个种子生成更多的拥有同一个hash key的字符串:”AaAa”, “AaBB”, “BBAa”, “BBBB”,“AaAaBBBB”...
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%。

相关文章

  • HashMap在JDK1.8的重构

    参考(感谢以下大佬):http://blog.csdn.net/qq_27093465/article/detai...

  • 数据结构解析-HashMap

    概要 HashMap在JDK1.8之前的实现方式 数组+链表,但是在JDK1.8后对HashMap进行了底层优化,...

  • 数据结构解析-HashMap

    概要 HashMap在JDK1.8之前的实现方式 数组+链表,但是在JDK1.8后对HashMap进行了底层优化,...

  • JDK1.8的HashMap源码分析

    JDK1.8之前的HashMap 在JDK1.8之前,HashMap通过散列表(哈希表)实现,并且散列表冲突解决方...

  • 深入浅出学Java-HashMap

    一、概要 HashMap在JDK1.8之前的实现方式 数组+链表,但是在JDK1.8后对HashMap进行了底层优...

  • HashMap

    1.概要 HashMap在JDK1.8之前的实现方式数组+链表,但是在JDK1.8后对HashMap进行了底层优化...

  • HashMap源码解析

    HashMap在JDK1.8之前底层的实现方式是数组+链表,从JDK1.8开始对HashMap底层进行了优化,改为...

  • HashMap 底层是怎么样的

    JDK1.8 之前 JDK1.8 前,HashMap 底层是 数组+链表,也就是 链表散列。 HashMap 通过...

  • 手写简单HashMap

    今日学习:1、了解jdk1.8版的HashMap原理2、手写jdk1.8版之前的HashMap 前言     好几...

  • HashMap及其并发的一些理解

    HashMap及其并发的一些理解 HashMap 在jdk1.8之前,HashMap通过数组加链表的方式实现。在1...

网友评论

      本文标题:HashMap在JDK1.8的重构

      本文链接:https://www.haomeiwen.com/subject/ynsvuftx.html