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

HashMap源码分析

笔记首页

序号内容链接地址
1HashMap的继承体系,HashMap的内部类,成员变量https://blog.csdn.net/weixin_44141495/article/details/108327490
2HashMap的常见方法的实现流程https://blog.csdn.net/weixin_44141495/article/details/108329558
3HashMap的一些特定算法,常量的分析https://blog.csdn.net/weixin_44141495/article/details/108305494
4HashMap的线程安全问题(1.7和1.8)https://blog.csdn.net/weixin_44141495/article/details/108250160
5HashMap的线程安全问题解决方案https://blog.csdn.net/weixin_44141495/article/details/108420327
6Map的四种遍历方式,以及删除操作https://blog.csdn.net/weixin_44141495/article/details/108329525
7HashMap1.7和1.8的区别https://blog.csdn.net/weixin_44141495/article/details/108402128

HashMap 1.7和1.8的区别

1结构区别

Jdk1.8

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

image-20200905223727298

Jdk1.7

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

image-20200905223709296

区别:

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

2.节点区别

jdk 1.7

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

jdk 1.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

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

image-20200905224026634
实际上在判断是否树化的时候,也会判断扩容。如图,我们知道树化的两个条件,单条桶长度大于等于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);
    }

头插法

image-20200905224553286

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

image-20200905224758013

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

简单流程:

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

image-20200905225107679

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

扩容的区别总结

Jdk1.7:

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

Jdk1.8:

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

7节点插入的区别

Jdk1.7

扩容

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i6Nwt2nM-1599318595313)(C:\Users\Faker\Desktop\123.assets\image-20200905230341679.png)]

新增节点

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

image-20200905224758013

Jdk1.8

扩容

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

image-20200905230146731

新增节点

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

image-20200905225641879

区别

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

总结

比较HashMap1.7HashMap1.8
数据结构数组+链表数组+链表+红黑树
节点EntryNode TreeNode
Hash算法较为复杂异或hash右移16位
对Null的处理单独写一个putForNull()方法处理作为以一个Hash值为0的普通节点处理
初始化赋值给一个空数组,put时初始化没有赋值,懒加载,put时初始化
扩容插入前扩容插入后,初始化,树化时扩容
节点插入头插法尾插法
  • 28
    点赞
  • 111
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值