关于HashMap1.7和1.8的关键知识点

你知道的越多,你不知道的越多

首先我们来说说HashMap的特点:

  1. HashMap存取是无序的
  2. 键和值可以为null,但是键
  3. 键的位置是唯一的
  4. JDK1.7HashMap采用的是数据结构是:数组+链表
    JDK1.8则采用的是:数组+链表+红黑树

说到这里,我们就来看一下HashMap1.7的数组和链表是什么玩意。
在这里插入图片描述
他底层使用类似这样子的数组(这个数组put的时候才会创建出来,默认长度16),数组的每个节点是一个Entry(在jdk1.8名称变成了Node),Entry代码如下

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
}
//键、值、指向下一个node的指针、hash值。

因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
比如我现在put(”rose“,123)
put方法首先会根据key值得出一个hash值,然后在根据hash&length-1得出数组下标,假如我们的得出的index是2,如下图
在这里插入图片描述
这时候我们就成功把元素插入进来,那如果我们再put(”harden“,234),哈希本身就存在概率性,就是”rose“和”harden“我们都去hash有一定的概率会一样,这个时候就产生了所谓的哈希碰撞,hashMap处理哈希碰撞他采用了是拉链法,也就是在该位置形成一个链表并且使用了头插法。
在这里插入图片描述
我都知道1.7使用的是头插法(同一位置上新元素总会被放在链表的头部位置),那么1.8使用的是尾插法,那个使用头插法会出现什么样的问题呢?
因为1.7在resize中transfer的时候转移到新数组的链表的顺序和原数组的顺序是相反的,如果在多线程的情况下,可能会造成循环链表,那个我们如果这时候去get的话,那就悲剧了。

这里我们提到了扩容那我们就来谈一下扩容的过程吧。
有两个因素:

  • Capacity:HashMap当前长度(默认的长度是16)。
  • LoadFactor:负载因子,默认值0.75f。
    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

根据以上代码,我们可以看出当size > = threshold(阈值)且null != table[bucketIndex](当前插入的位置不为空,这个条件就只有1.7JDK有)时就会进行扩容。
size表示的是整个hashMap的元素的个数,相当于(k-v键值对)的实时数量,而不是数组长度。
threshold = Capacity * LoadFactor

扩容?它是怎么扩容的呢?

扩容:创建一个新的Entry空数组,长度是原数组的2倍。
转移数组:遍历原Entry数组,看过源码的都知道在transfer里面有个rehash的逻辑,但是这个逻辑在一个情况下才会去走,不走这个逻辑情况下,重新算出的index只有两个位置,一种是原来的index,一种是原来的index+旧数组的容量。

        transfer(newTable, initHashSeedAsNeeded(newCapacity));
/**
     * Initialize the hashing mask value. We defer initialization until we
     * really need it.
     */
     
    final boolean initHashSeedAsNeeded(int capacity) {

        // hashSeed降低hash碰撞的hash种子,初始值为0
        boolean currentAltHashing = hashSeed != 0;
        //ALTERNATIVE_HASHING_THRESHOLD: 当map的capacity容量大于这个值的时候并满足其他条件时候进行重新hash
        boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        //TODO 异或操作,二者满足一个条件即可rehash
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            // 更新hashseed的值
            hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0;
        }
        return switching;
    }
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

为什么要重新计算位置呢,直接复制过去不香么?
我们重新计算位置不就可以是原来数组的链表长度短了吗,查询的效率不就更高了吗,这样不更香吗。

说到这里,有的小伙伴不仅有疑问了,为什么初始化数组的默认大小为啥是16呢,负载因子为啥是0.75呢?
在这里插入图片描述
那我们就一个一个来分析一下
我们来解决第一个问题,首先我们知道16是2的4次幂,读过源码的小伙伴都知道hashMap要求容量必须为 2 次幂,其实这个设计是为了方便去取数组的下标,我们都知道源码中是采用int i = indexFor(hash, table.length);来取数组下标的,这个算法其实和取余hash%length是一样的意思,可以让数组的位置更加均匀得分配(我们都知道,二进制中2次方数只占一个bit,而我们把length-1后和hash进行与操作得出的就是hash值本身,这样的设计不是很巧妙吗)

在这里插入图片描述
如果数组长度不是2的n次幂,计算出的索引特别容易相同,也就是容易发生hash碰撞,导致数组空间分配不均匀,链表或红黑树(1.8才有)过长,这样就很影响效率了。

最后取16作为初始化容量,也是考虑了性能的问题,如果取太小了数组频繁扩容,扩容是非常耗性能的,取太大的话有浪费内存。

我们上面提到了hash&length-1和hash%length是一样的,然而hashMap用hash&length-1是一个位运算,我们知道计算机进行位运算比对十进制数计算的效率更高,所以此处也是考虑了性能问题

接下来我们来解决第二个问题
首先我们要了解LoadFactor负载因子其实是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,LoadFactor太大导致查找元素的效率低,太小导致数组利用率低,存放的数据会很分散,所以既兼顾数组利用率,又考虑链表不要太多,就选择了0.75。

JDK1.7采用的是数组+链表,即使hash函数取得再好,也很难达到元素百分百均匀分布,加入链表有n个元素,则时间复杂度为O(n),则完全是去了优势,针对这种情况,JDK1.8中引入的红黑树(查询的时间复杂度为O(log n))来优化查询的问题,链表不断变长,肯定会对查询性能有一定的影响,这时候才需要转化成树。

看过源码都知道,链表长度超过8并且数数组的长度大于等于64才会把链表转化成红黑树,那么为什么是8呢?

因为在随机哈希码下,链表节点的频率服从泊松分布,链表长度达到8的概率已经很小了,而且红黑树节点(TresNode)占用空间是普通Node的两倍,所以选择8不是随便决定的,而是根据概率统计得来的,考虑了时间和空间的权衡。
扩容的原因是:如果数组的长度还比较小,就先利用扩容来减小链表的长度,这样也可以提高效率、

那么为什么是当红黑树的长度小于6就转成链表了呢?

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3;链表的平均查找长度是n/2,当长度为8是,平均查找长度为8/2=4,可见红黑树的查找效率高,这样才有转成红黑树的必要。如果链表的长度是6,6/2=3,而log(6)=2.6,虽然红黑树的效率还是很高,但是如果我们删除借点并且此时红黑树的长度小于6,他还要为了保持平衡进行(变色,左旋,右旋)一系列操作,那么这时候效率反而更低,这也是基于时间和空间的权衡。

细心的小伙伴可能会发现在put和remove方法中有个++modCount;的鬼东西,
modCount翻译过来就是修改次数,当我们使用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。

            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();

每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。这个过程其实就是快速失败机制(fail—fast)。

如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。
因此,不能依赖于这个异常是否抛出而进行并发操作的编程。

说到这里我们基本上把HashMap的大部分的关键知识点聊完了,我们都知道HashMap是线程不安全的,那我们可以使用Collection.synchonizedMap(), HashTable, ConcurrentHashMap,来保证线程安全,而HashTable只是简单粗暴地在每个操作上加锁,效率比较低,ConcurrentHashMap并发性能最高,我们下一篇文章再来聊ConcurrentHashMap。

总结

HashMap1.7和1.8的不同点:
1.JDK1.7采用的是数据结构是:数组+链表;JDK1.8则采用的是:数组+链表+红黑树
2.JDK1.7采用了头插法(头插法在多线程的情况下可能出现循环链表的情况),JDK1.8使用了尾插法(反正都要去遍历链表统计结点个数,不如直接使用尾插法,顺便解决了循环链表的问题)
3.JDK1.7的hash算法比1.8的要复杂,Hash算法更复杂,算出来的索引就散列,数组的查询效率更高,但是1.8引入了红黑树,查询性能得到保障(时间复杂度O(log n)),毕竟hash算法越复杂越消耗内存
4.在扩容转移元素的时候JDK1.7的一个rehash的过程,JDK1.8没有这个过程
5.JDK1.7扩容的条件有size>阈值且当前要put的位置不为空,JDK1.8只有size>阈值这个条件
6.JDK1.8新增api,putIfAbsent(key,value),在源代码中if(!onlyIfAbsent)体现。
7.扩容时转移元素的方式不一样,JDK1.7是一个一个转移,JDK1.8则是先计算哪些元素的下标是原数组的下标,哪些是原数组下标+旧数组容量,然后分别形成两条链表,在一次性转移。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值