HashMap

HashMap

HashMap是应用广泛的哈希表实现,通常HashMap进行put或者get操作,可以达到常数时间的性能,因此它是绝大部分利用key-value进行存取场景的首选,例如实现一个用户的ID和用户信息的运行时存储结构。下面是HashMap的一些特性:

①HashMap不是同步的

②支持null键和值

由于HashMap的key值不重复的特性,只能允许存在一个null键。若出现key值重复,则会将原来的value覆盖,具体实现可以参考HashMap的putVal方法。

③底层数据结构

HashMap的内部可以看作是数组和链表结合组成的复合结构,数组被分为一个个桶,通过hashCode决定键值对在这个数组中的寻址;哈希值相同的键值对则以链表形式存储,这里需要注意的是如果链表长度超过8,就会将其转换为红黑树,如果链表长度从8衰减到6,则由红黑树转换为链表。

④效率高,通常情况下put或get的时间复杂度为O(1)

理想状态下,若不发生碰撞则HashMap是一个基于数组的数据结构,因此存取效率相当高,但是非常依赖散列的有效性,下文有详细的描述。

⑤初始容量大小以及扩容机制

从源码中可以看到,HashMap采用lazy-load机制,也就是我们通过构造函数初始化了一个HashMap后,仅仅是赋予了该容器负载因子,并没有创建容器。

也可以通过指定负载因子和初始容量进行创建,但是即便指定了长度,HashMap也会将其转换为相近的2的幂次。

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
        //若初始容量<0 则抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //若传入初始容量>最大容量,则将初始容量设置为最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //若传入的负载因子<=0或非数字,则抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
}

我们在调用了put()时,才会真正创建一个位桶数组容器。在这个方法中,resize()就显得尤为关键了,它负责容器的初始化,也负责插入节点后长度超出阈值的容器的扩容。

判断插入后是否超出阈值则依赖于负载因子,当 有效长度 = 原长 * 负载因子 时,HashMap就会进行扩容。

扩容会新创建一个长度为之前两倍的容器,然后将原来的数据放入新的容器中。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
        Node<K,V>[] tab; 
        Node<K,V> p;//存放待插入node 
        int n, i;
        //若桶数组为空的话,通过resize方法创建一个桶数组
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通过hash运算得到的位置没有元素,则在该位置插入node
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; 
            K k;
            //若出现hash碰撞,并且链表上第一个节点的key值与插入元素的key相同
            //会在方法结尾进行相同key的value替换
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //判断当前位置是否是树化的节点,若树化了则按照红黑树插入方式插入元素
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {//若不是树化节点
                //遍历链表节点
                for (int binCount = 0; ; ++binCount) {
                    //若next节点为空,则插入node
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //判断插入node后是否需要树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //若next节点的key和插入node.key相同,退出循环
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //替换value
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //若桶数组有效长度大于阈值(当前容量*负载因子0.75),则调用resize方法进行扩容
        //扩容到原来的两倍后重排
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

 

问题一,为什么HashMap要树化呢?

起初,我认为是链表过长会因为链表线性查询的特性导致HashMap存取性能受到影响,但是仔细一想,一般出现hashCode重复的情况是比较少的,不然也没必要去搞什么散列处理扰动函数了,这也就意味着链表的长度在正常情况下是非常短的,这么看来不是很有必要对HashMap进行树化。

后来在阅读到某篇专栏后,对于这个问题才茅塞顿开,其本质是一个安全问题

在现实环境下,构建冲突的数据并不是非常复杂的事,恶意代码就可以利用这些数据大量与服务端进行交互,导致服务端CPU大量被占用,这就构成了hash碰撞拒接服务攻击。树化可以一定程度上减少碰撞攻击带来的性能损失。

 

问题二,为什么HashMap的长度是2的幂次方

我们知道创建一个数组必须提前声明长度,那么HashMap底层采用数组+链表实现,也就意味着它也必须要先声明位桶数组的长度。那么如何让存入的元素更加散列地分布到位桶数组上,就是HashMap存取高效的关键。简单来说,只要把数据在HashMap的数组上分配均匀,就避免了位桶数组的资源浪费,同时也避免了链表过长导致的查询效率低下的问题。

Hash值的取值范围是-2147483648到2147483647,前后加起来大概40亿的映射空间,只要hash函数映射均匀松散,一般是很难出现hash碰撞的。但我们是不可能在内存中存放一个40亿长度的数组的,所以该Hash值并不能直接使用,还需要通过一个Hash运算作特殊处理。

此时回归问题,为什么HashMap长度都是2的幂次方,答案就要追溯到Hash运算的方式了,hash&(n-1)。

Hash函数会将插入元素的Hash值与数组的长度进行取模运算,得到的余数用于数组下标进行存放,例如一个位桶数组长度为16的HashMap:

    10100101 11000100 00100101
&   00000000 00000000 00001111
=   0101    //高位全部归零,只保留末四位

得出下标为5

我们可以发现,若n为2的幂次方的话,将n-1转换为二进制的最高位都为0,最低位全为1,这样我们就可以结合&运算的特点,充分利用元素的哈希值进行运算,从而使得计算出的下标尽量分散

那么既然是取模运算,为什么不采用hash%length,反而采用hash&(n-1)呢?这是因为相比起%,&的运算效率更高效

 

补充:扰动函数

即使hash值分布得再松散,经过处理之后也大概率会产生碰撞,这时就得靠HashMap的扰动函数了。

当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,而在使用了扰动函数之后碰撞只有92次,减少了近10%。

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值