面试高频之HashMap

98 阅读5分钟

引言:

在面试中,问到Java 基础,无疑逃不开的是HashMap,ConcurrentHashMap,作为如此高频的,今天就来讲讲HashMap。

主要内容:

  1. HashMap数据结构
  2. 扩容机制
  3. 是怎么实现hash分布均匀的

面试高频之HashMap

HashMap数据结构是啥样的?

HashMap是由数组+链表组合构成的数据结构。 大概如下,数组里面都存了key-value这样的实例,在Java7中叫Entry,Java8中叫Node file 本身所有的位置都为null,在put的时候会根据key的hashCode计算index值,比如put("冰溪","520"),会计算key的hash值,如下:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

可以看到key为null,返回的hash值为0,也就是HashMap可以key为null。

为什么是要右移16位?

其实是为了减少碰撞,进一步降低hash冲突的几率。int是四个字节,右移十六位进行异或运算,能同时保留搞十六位和低十六位的特性

为什么要进行异或运算?

首先将高16位无符号右移,与低16位进行异或运算,如果直接进行&运算,那么高16位代表的特征就可能丢失,右移16位进行异或运算,那么高16位和低16位的特性就混合到了一起,就被保留在新的数值中。

通过(n - 1) & hash 计算key在数组中的index

前面提到数据结构还有链表,为啥需要链表,链表又是啥样的?

数组的长度是有限的,在有限的长度里使用hash,hash本身就存在概率性,可能“冰溪”和“溪冰”的hash是一样的,通过计算在数组中的index就是一样的了,在同一个位置两个不同的key,肯定不会覆盖原有的key,HashMap中采用的是拉链方式,采用链表方式解决Hash冲突。链表如下图形式: file 每个节点都会保存hash,key,value,以及下一个节点

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

那么新的节点在插入链表的时候,是怎么插入的?

java8之前采用的是头插法,也就是说新来的节点会取代原有的节点,原有的节点往后顺移,可能作者认为新来的值被查找的可能性更大一点。

但是java8后,采用的是尾插法了。

为什么java8后采用尾插法?这就涉及到HashMap的扩容机制了

数组容量是有限的,达到一定容量后就会进行扩容resize。

什么时候进行resize?

有两个因素:

  1. Capacity:HashMap的当前长度
  2. LOADFACTOR:负载因子,默认0.75ffile 怎么理解这个负载因子呢,例如,当前容量大小是100,那么当put到第76个的时候,判断发现需要进行resize了,就会进行扩容。

是怎么进行扩容的?

扩容分为两步:

  1. 扩容:创建一个新的Node数组,长度是原来的两倍
  2. ReHash:遍历Node数组,把所有的Node重新Hash到新数组中。

为什么需要重新Hash?

因为计算在table中index,是通过hash和数组长度计算得到,扩容后,数组长度发生变化,计算index规则也随之改变。index=(n - 1) & hash,n为数组长度。

回归到之前说的,为啥java8后采用尾插法?

比如现在有一个容量为2的HashMap,负载因子为0.75,2*0.75=1.5,在put第二个的时候就会进行resize操作。

假设现在采用三个不同的线程往这个容量为2的HashMap中put操作,在还没进行扩容resize之前可能是这样的: file采用头插法,同一个位置上,新元素总会被放在头部位置,在java8之前的Entry数组中,通过计算索引位置,同一链表上的元素可能被放到其他位置上,可能扩容后如下:file B此时指向了A ,一旦几个线程扩容完成后,就可能出现环形链表file 这个时候去取值就悲剧了,陷入死循环了。

java8之后采用尾插法,java8中右红黑树,将O(n)复杂度降低到了O(logn)

使用头插法会改变链表的顺序,但是使用尾插法就不会改变顺序A->B,采用尾插法,链表顺序依旧是A->B.file避免了环形链表的出现

java8中不会出现死循环,但他依旧是线程不安全的

多线程情况下,可能会出现覆盖的情况

if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

在一个线程if判断通过,进入到下一步操作,此时刚好另外一个线程进来,计算到跟上一个线程index一样,并且if判断也通过,此时就会进入到if里面进行下一步,此时就会把上一个线程的值给覆盖掉,因此是线程不安全的。

是怎么实现hash分布均匀的

DEFAULT_INITIAL_CAPACITY= 1 << 4默认容量为16,使用位运算,为了方便位运算,位运算比算数计算效率高,选择16为了将key映射到index的算法,怎么得到一个均匀分布的hash呢?

比如“冰溪”为key,hashCode十进制为676602,二进制位10100101001011111010,进行高低16位异或运算后十进制为676592,对应的二进制为10100101001011110000,通过HashMap中的计算公式(n-1)&hash,计算得到,也就是(16-1)& 676592=0 ,15的二进制为1111

10100101001011110000  &
                                    1111 =
00000000000000000000																		

之所以用位与运算,是因为位与运算比取模运算效率高,为什么是16呢,因为16-1=15,二进制位1111,位与运算,结果就是hashCode的后几位的值,只要输入的HashCode是均匀的,计算出来的结果也是均匀的,这就是为了实现分布均匀。

重写equals方法为什么要重写hashCode方法?

这两个方法都是Object中的,在为重写equals方法时,是继承了Object中的,比较两个对象的内存地址。

HashMap中get的时候,根据key的hash计算出index的值,然后根据equals比较两个key是否相等,如果对equals方法进行了重写,一定要对hashCode进行重写,以保证两个相同的对象返回的hash是一样的,不同的对象返回不同的hash。

本文由博客群发一文多发等运营工具平台 OpenWrite 发布