您的位置:首页 > 职场人生

HashMap 1.7和1.8的区别 --答到面试官怀疑人生

2020-09-04 12:56 9983 查看

HashMap 1.7和1.8的区别

从今天起,争取一周之内整理完HashMap有关的各种问题。网上有很多HashMap的源码分析的帖子,我看了一些。当然也有写的非常好的,不过大多的源码分析帖企图一篇文章就把问题讲完,有时候看起来就很混乱。而且贴了较多的源码。这里我准备分为6个部分,分别上传五篇篇博文,希望用更清晰的方式,来总结HashMap的相关问题。

六个部分

文章目录

  • 2.节点区别
  • 3.Hash算法区别
  • 4对Null的处理
  • 5初始化的区别
  • 6扩容的区别
  • 扩容的实现细节
  • 7节点插入的区别
  • 究极总结
  • 1结构区别

    Jdk1.8

    HashMap1.8的底层数据结构是数组+链表+红黑树。

    Jdk1.7

    HashMap 1.7的底层数据结构是数组加链表

    区别:

    • 一般情况下,以默认容量16为例,阈值等于12就扩容,单条链表能达到长度为8的概率是相当低的,除非Hash攻击或者HashMap容量过大出现某些链表过长导致性能急剧下降的问题,红黑树主要是为了结果这种问题。
    • 在正常情况下,效率相差并不大。

    2.节点区别

    HashMap 1.7

    static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    }

    HashMap1.8

    static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    }
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    }

    区别:

    Jdk1.8

    • hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。
    • 新增了一个TreeNode节点,为了转换为红黑树。

    Jdk1.7

    • hash是可变的,因为有rehash的操作。

    3.Hash算法区别

    Jdk1.7

    final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
    return sun.misc.Hashing.stringHash32((String) k);
    }
    
    h ^= k.hashCode();
    
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
    }

    Jdk1.8

    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    区别

    • 1.8计算出来的结果只可能是一个,所以hash值设置为final修饰。
    • 1.7会先判断这Object是否是String,如果是,则不采用String复写的hashcode方法,处于一个Hash碰撞安全问题的考虑

    4对Null的处理

    Jdk1.7

    Jdk1.7中,对null值做了单独的处理

    public V put(K key, V value) {
    //判断是否是空值
    if (key == null)
    return putForNullKey(value);
    ...
    }

    简单的说,HashMap会遍历数组的下标为0的链表,循环找key=null的键,如果找到则替换。

    如果当前数组下标为0的位置为空,即e==null,那么直接执行添加操作,key=null,插入位置为0。

    private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
    if (e.key == null) {
    V oldValue = e.value;
    e.value = value;
    e.recordAccess(this);
    return oldValue;
    }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
    }

    Jdk1.8

    而1.8中,由于Hash算法中会将null的hash值计算为0,插入时0&任何数都是0,插入位置为数组的下标为0的位置,所以我们可以认为,1.8中null为键和其他非null是一样的,也有hash值,也能别替换。只是计算结果为0而已。

    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    区别

    • Jdk1.7中,null是一个特殊的值,单独处理
    • Jdk1.8中,null的hash值计算结果为0,其他地方和普通的key没区别。

    5初始化的区别

    我们常说Jdk1.8是懒加载,真的是这样吗?

    Jdk1.8

    transient Node<K,V>[] table;

    构造方法

    public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
    }
    
    public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    我们简答看一下tableSizeFor()方法,其实这个算法和Integer的highestOneBit()方法一样。

    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;
    }

    官方解释:Returns a power of two size for the given target capacity. (返回给定目标容量的二次幂。)

    也就是获取比传入参数大的最小的2的N次幂。
    比如:传入8,就返回8,传入9,就返回16.

    Jdk1.7

    Jdk1.7中,table在声明时就初始化为空表。

    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

    构造方法和Jdk1.8一致,但是没有立刻根据给定的初始容量去计算那个2的次幂。

    public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    }
    
    public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    我们可以看一下HashMap1.7的计算容量的方法

    首先是put方法时,发现是空表,初始化。传入threshold,也就是我们之前传入的initCapactity自定义初始容量

    public V put(K key, V value) {
    //判断是否是空表
    if (table == EMPTY_TABLE) {
    //初始化
    inflateTable(threshold);
    }
    ...
    }

    这个方法也有官方的注释,意思就是找到大于等给定toSize的最小2的次幂

    private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);
    ...
    }

    我们发现,这个方法没有什么操作难度,是个人都可以写的出来

    private static int roundUpToPowerOf2(int number) {
    // assert number >= 0 : "number must be non-negative";
    return number >= MAXIMUM_CAPACITY
    ? MAXIMUM_CAPACITY
    : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }

    最终调用了Integer的计算2次幂的方法。

    public static int highestOneBit(int i) {
    // HD, Figure 3-1
    i |= (i >>  1);
    i |= (i >>  2);
    i |= (i >>  4);
    i |= (i >>  8);
    i |= (i >> 16);
    return i - (i >>> 1);
    }

    和1.8的是一致的,但是我们阅读源码发现1.8更趋向于一个方法完成一个大的功能,比如putVal,resize,代码阅读性比较差,而1.7趋向于尽可能的方法拆分,提升阅读性,但是也增加了嵌套关系,结构复杂。

    区别

    Jdk1.7:

    • table是直接赋值给了一个空数组,在第一次put元素时初始化和计算容量。

    • table是单独定义的inflateTable()初始化方法创建的。

    Jdk1.8

    • 的table没有赋值,属于懒加载,构造方式时已经计算好了新的容量位置(大于等于给定容量的最小2的次幂)。
    • table是resize()方法创建的。

    6扩容的区别

    无论是哪个版本,扩容都是在新增数据时添加,我们看一下具体区别吧。

    扩容的时机

    Jdk1.7

    public V put(K key, V value) {
    //各种条件判断,key是否存在,是否为空...
    if () {
    ...
    ...
    //封装所需参数,准备添加
    addEntry(hash, key, value, i);
    return null;
    }

    我们看到,我们在准备添加数据的时候,我们先判断是否扩容,如果扩容成功了,我们要重新计算一下要插入的元素的hash值。

    还有扩容并不是大于阈值就扩容的,如果我们即将插入的桶是空的,我们不会走进这个if语句块,也就是直接指向createEntry方法。

    void addEntry(int hash, K key, V value, int bucketIndex) {
    //判断是否需要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) {
    //扩容
    resize(2 * table.length);
    //重新计算hash值
    hash = (null != key) ? hash(key) : 0;
    //计算所要插入的桶的索引值
    bucketIndex = indexFor(hash, table.length);
    }
    //执行新增Entry方法
    createEntry(hash, key, value, bucketIndex);
    }

    Jdk1.8

    虽然很想删减源码,但是也删不了几行,我以图示的方式来展现


    实际上在判断是否树化的时候,也会判断扩容。如图,我们知道树化的两个条件,单条桶长度大于等于8,桶总数大于等于64才发生。但是我们可能不知道这里不满足条件还会扩容(其实我写这这篇的时候也不知道,但是准备写红黑树转换过程的时候才看到的)。那么为什么有扩容这个考虑?

    我们认为:桶长度小于64。由于我们的扩容都是翻倍操作,所以我们此时的元素总数小于等于32。假设此时我们的数组容量为32,单个桶长度大于8的概率是微乎其微的,因为阈值是24,平均下来一个桶还不到一个Node节点,并且我在之前的HashMap的一些特定算法,常量的分析中,也说明了为什么选择8作为树化的阈值。
    但是此时已经有一条链表长度为8了,也就是说阈值24,已经有1/3的节点在单条链表了,我们认为这个哈希表太过于集中了,所以我们进行扩容来增加哈希表内元素的散列程度。

    扩容的实现细节

    Jdk1.7

    这是Jdk1.7的扩容,最重要的方法是transfer,转移的意思,也就是说,将旧数组的元素转移到新的数组。

    void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //达到最大值,无法扩容
    if (oldCapacity == MAXIMUM_CAPACITY) {
    threshold = Integer.MAX_VALUE;
    return;
    }
    Entry[] newTable = new Entry[newCapacity];
    //将数据转移到新的Entry[]数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//初始化散列种子
    //覆盖原数组
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    头插法

    除此之外,我们不扩容的情况加,正常插入元素(createEntry方法),也是头插法。

    Jdk1.8

    过程稍显复杂,我们截取部分程序进行讲解

    简单流程:

    • j表示当前正在操作的旧数组对应的桶的下标,每次操作一个桶,直至遍历到链表尾部
    • 如果正在操作的桶是空的直接下一次循环,否则进行一系列操作
    • 判断是否只有一个数据,如果是的,我们直接插入到新数组
    • 判断是否是数节点,如果是的,调用树的操作方法,如果不是,走do-while循环
    • 循环前标记2个头节点,两个尾节点,表示插入到新位置但不改变下标和插入到新位置改变下标。
    • 根据e.hash& oldCap==0来区分节点插入的位置
    • 最后do-while结束,将不为空的hoHead和hiHead插入到新数组。然后重复上述操作。

    有人说这个if(e.hash & oldCap==0)是这个resize算法最厉害的一行了,我觉得确实有道理。这里篇幅问题,引入别人对于这个判断的详解链接

    扩容的区别总结

    Jdk1.7:

    • 头插法,添加前先判断扩容,当前准备插入的位置不为空并且容量大于等于阈值才进行扩容,是两个条件
    • 扩容后可能会重新计算hash值。

    Jdk1.8:

    • 尾插法,初始化时,添加节点结束之后和判断树化的时候都会去判断扩容。我们添加节点结束之后只要size大于阈值,就一定会扩容,是一个条件
    • 由于hash是final修饰,通过e.hash & oldCap==0来判断新插入的位置是否为原位置。

    7节点插入的区别

    Jdk1.7

    扩容

    头插法,一个一个的添加进新数组。

    新增节点

    标记要插入的位置已有的元素,新插入的元素覆盖已有的元素成为新的链表的头,之前标记的已有的元素作为新插入元素的next属性传入构造器,也就是说原来的已有的链表插入到新的链表头的尾部。

    Jdk1.8

    扩容

    1.8中是先得到要插入的链表,再一口气插入到新的数组,为维护两个链表时,是尾插法。

    新增节点

    从橙色框的部分可以看出,是尾插法。

    区别

    • jdk1.7无论是resize的转移和新增节点createEntry,都是头插法
    • jdk1.8则都是尾插法,为什么这么做呢为了解决多线程的链表死循环问题。

    究极总结

    比较 HashMap1.7 HashMap1.8
    数据结构 数组+链表 数组+链表+红黑树
    节点 Entry Node TreeNode
    Hash算法 较为复杂 异或hash右移16位
    对Null的处理 单独写一个putForNull()方法处理 作为以一个Hash值为0的普通节点处理
    初始化 赋值给一个空数组,put时初始化 没有赋值,懒加载,put时初始化
    扩容 插入前扩容 插入后,初始化,树化时扩容
    节点插入 头插法 尾插法
    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: