HashMap面试,看这篇就够了

1、HashMap的底层结构

废话不多说,我们直接上图,先看下HashMap的底层结构到底长什么样

图片

从上面这张图中可以看到HashMap的结构特点,最初创建出HashMap对象的时候,table数组中是没有元素的,然后添加元素的时候会根据key来计算出hash值,拿着这个hash值和当前的table数组长度做取模操作,得到当前元素要插入的槽点位置。之后再插入的时候,如果遇到hash冲突的情况,会采用拉链法来解决,就是以链表的形式存储。

当链表的长度大于或等于阈值的时候(默认8),并且当前的table数组长度大于或等于64的时候(默认为64),就会把链表转换成红黑树结构,如果后续由于删除或者其他原因调整了大小,当红黑树的结点小于或等于6个以后,又会重新转换成链表结构。

我们知道,之所以转换成红黑树,是为了在数据量变多的时候提升查找的效率,既然红黑树结构可以提升查找的效率,那么为什么不直接用红黑树呢?对于这一个问题,官方给出了相关的解释:

Because TreeNodes are about twice the size of regular nodes,  we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they  are converted back to plain bins.

翻译一下:

因为TreeNodes的大小大约是普通Node节点的两倍, 所以只有在节点足够多的情况下才会把Nodes节点转换成TreeNodes节点, 是否足够多又由TREEIFY_THRESHOLD决定,而当桶中的节点的数量由于移除或者调整大小变少后, 它们又会被转换回普通的链表结构以节省空间。

当链表长度达到8就转成红黑树结构,当树节点小于等于6时就转换回去,此处体现了时间和空间的平衡思想。

2、HashMap初始化

HashMap的默认初始化大小是16,加载因子是0.75,扩容的阙值就是12(16*0.75),如果进行HashMap初始化的时候传入了自定义容量大小参数size,那么初始化的大小就是正好大于size的2的整数次方,比如传入10,大小就是16,传入30大小就是32,源码如下:

static final int tableSizeFor(int cap) {

int n = cap - 1;

n |= n >>> 1;//n>>>1表示n的二进制向右移动1位,以下同理,然后跟移动前的n进行异或操作

n |= n >>> 2;

n |= n >>> 4;

n |= n >>> 8;

n |= n >>> 16;

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

}

上述源码中,通过将n的高位第一个1不断的右移,然后把高位1后面的全变为1,在最后return的时候返回n+1,就符合HashMap容量都是2的整数次幂了。例如下图:

图片

3、为什么Map中的节点超过8个时才转换成红黑树

这个问题官方给的解释是:​​​​​​​

In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally,

under random hashCodes, the frequency of nodes in bins follows a Poisson distribution

(http://en.wikipedia.org/wiki/Poisson_distribution) with a parameter of about 0.5

on average for the default resizing threshold of 0.75, although with a large variance

because of resizing granularity. Ignoring variance, the expected occurrences of list

size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are:

0: 0.60653066

1: 0.30326533

2: 0.07581633

3: 0.01263606

4: 0.00157952

5: 0.00015795

6: 0.00001316

7: 0.00000094

8: 0.00000006

more: less than 1 in ten millio

这段解释的意思是:在使用分布良好的hashCode时,很少使用红黑树结构,因为在理想情况下,链表的节点频率遵循泊松分布(意思就是链表各个长度的命中率依次递减),当命中第8次的时候, 链表的长度是8,此时的概率仅为0.00000006,小于千万分之一。

但是,HashMap中决定某一个元素落到哪一个桶中,是和某个对象的hashCode有关的,如果我们自己定义一个极其不均匀的hashCode,例如:

@Override

public int hashCode(){

return 1;

}

由于上述的hashCode方法返回的hash值全部都是1,那么就会导致HashMap中的链表比那的很长,如果此时我们向HashMap中放很多数据节点的话,HashMap就会转换成红黑树结构,所以链表长度超过8就转换成红黑树的设计更多的是为了防止用户自己实现了不好的哈希算法 而导致链表过长,影响查询效率,而此时通过转换红黑树结构用来保证极端情况下的查询效率。

4、为什么HashMap初始化的容量一定是2的整数次幂

不管我们传入的参数是怎么样的数值,HashMap内部都会通过tableSizeFor方法计算出一个正好大于我们传入的参数的2的整数次幂的数值,那么为什么一定要是2的整数次幂呢?我们先来看看计算key的hash方法如下:

//计算key的hash值,hash值是32位int值,通过高16位和低16进行&操作计算。

static final int hash(Object key) {

int h;

return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

得到了key的hash值后,在计算key在table中的位置索引的时候,代码如下:

if ((p = tab[i = (n - 1) & hash]) == null)

正是因为n是2的整数次幂,比如当n是2时,它的二进制是10,4时是100,8时是1000,16时是10000….,那么(n-1)的二进制与之对应就是(2-1)是1,(4-1)是11,(8-1)是111,(16-1)是1111,为什么要用(n - 1) & hash 来计算数组的位置索引呢,正是因为(n - 1) & hash的索引一定是落在0~(n-1)之间的位置,而不用管hash值是多少,因为“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例, 16-1=15,2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

5、为什么HashMap不是线程安全的

我们先来看HashMap中的put方法的源码,如下:

public V put(K key, V value) {

//调用putVal() 方法

return putVal(hash(key), key, value, false, true);

}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

boolean evict) {

Node<K,V>[] tab; Node<K,V> p; int n, i;

//如果table容量为空,则进行初始化

if ((tab = table) == null || (n = tab.length) == 0)

n = (tab = resize()).length;

//计算hash值在表table中的位置,这里采用&操作是为了更快的计算出位置索引,

//而不是取模运算,如果该位置为空,则直接将元素插入到这个位置

//此处也会发生多线程put,值覆盖问题。

if ((p = tab[i = (n - 1) & hash]) == null)

tab[i] = newNode(hash, key, value, null);

else {

Node<K,V> e; K k;

//判断tab表中存在相同的key。

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 {

//遍历链表寻找链表尾部插入新值,如果发现存在相同的key,则停止遍历此时e指向重复的key

for (int binCount = 0; ; ++binCount) {

//jdk1.7采用的是头差法,jdk1.8采用的是尾差法

if ((e = p.next) == null) {

p.next = newNode(hash, key, value, null);

//判断链表的长度是否大于TREEIFY_THRESHOLD,如果大于则转换红黑树结构

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st

treeifyBin(tab, hash);

break;

}

if (e.hash == hash &&

((k = e.key) == key || (key != null && key.equals(k))))

break;

p = e;

}

}

//发现了重复的key,判断是否覆盖,如果覆盖返回旧值,

if (e != null) { // existing mapping for key

V oldValue = e.value;

if (!onlyIfAbsent || oldValue == null)

e.value = value;

afterNodeAccess(e);

return oldValue;

}

}

//重点看这里 ====================================

// 继续更新次数,不是原子操作,多线程put存在并发安全问题

++modCount;

//如果大于阙值(这个阙值和上面那个不一样,这个等于当前容量*加载因子,默认是16*0.75 = 12),进行扩容。

if (++size > threshold)

resize();

afterNodeInsertion(evict);

return null;

}

上图部分源码中可以看出HashMap的put方法中有一行代码是++modCount, 我们都知道这段代码并不是一个原子操作,它实际上是三个操作, 执行步骤分为三步:读取、增加、保存,而且每步操作之间可以穿插其它线程的执行, 所以导致线程不安全。

6、HashMap在扩容期间的取值问题

先给出结论,在HashMap扩容期间通过get()方法取值可能会取出空值或者不准确的值。

先来看下HashMap中get()方法的源码:

public V get(Object key) {

Node<K,V> e;

return (e = getNode(hash(key), key)) == null ? null : e.value;

}

final Node<K,V> getNode(int hash, Object key) {

Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

//如果数组时空的,或者当前槽点是空的,说明key所对应的value不存在,直接返回null

if ((tab = table) != null && (n = tab.length) > 0 &&

(first = tab[(n - 1) & hash]) != null) {

//判断第一个节点是否是我们需要的节点,如果是则直接返回

if (first.hash == hash && // always check first node

((k = first.key) == key || (key != null && key.equals(k))))

return first;

if ((e = first.next) != null) {

//判断是否是红黑树节点,如果是的话,就从红黑树中查找

if (first instanceof TreeNode)

return ((TreeNode<K,V>)first).getTreeNode(hash, key);

//遍历链表,查找key

do {

if (e.hash == hash &&

((k = e.key) == key || (key != null && key.equals(k))))

return e;

} while ((e = e.next) != null);

}

}

return null;

}

get方法主要是以下步骤:

  • 计算Hash值,并由此值找到对应的槽点。
  • 如果数组是空的或者该位置为null,那么直接返回null就可以了。
  • 如果该位置处的节点刚好就是我们需要的,直接返回该节点的值。
  • 如果该位置节点是红黑树或者正在扩容,就用find方法继续查找。
  • 否则那就是链表,就进行遍历链表查找。

HashMap的get方法是从table数组中查询我们要查找的key是否存在,如果存在则返回,不存在则直接返回null, 那么如果是在扩容期间,为什么获取的结果不准确呢?我们再来看看HashMap的扩容方法resize(),部分源码如下:

if (oldTab != null) {

for (int j = 0; j < oldCap; ++j) {

Node<K,V> e;

if ((e = oldTab[j]) != null) {

oldTab[j] = null;//特别注意这一行代码

if (e.next == null)

newTab[e.hash & (newCap - 1)] = e;

//以下部分省略

}

}

}

上面的源码是HashMap的resize方法的一小部分,首先我们知道HashMap的扩容会把旧数组的数据迁移到新数组中(怎么迁移的我们后面再说),在搬迁的过程中会把旧数组正在迁移的桶置为空比如,正如 上面代码oldTab[j] = null这一行代码,正是把索引j的桶(或者槽点)置为空了,但是如果此时还没有完成所有的数据的迁移,那么HashMap中仍然是使用的旧数组, 此时我们通过get方法查询的key的所以正好在这个旧数组中索引位置是oldTab[j]的位置,因为这个位置已经置空了,所以就会返回null,所以发生了扩容期间读取数据不准确。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值