寻址算法
当我们计算得到 Hash 值以后,我们还需要计算这个 Hash 值在数组中的位置。
简单的方式我们可以直接使用求模的方式定位,比如
hash=1000 table.size=16 n=hash%table.size=8
但是 HashMap 采用的是另外一种更为精妙的算法:
这种方式等同求模法,但是计算性能会更好,但是这里我们需要注意了,这种方式前提为 n 也就是数组的长度必须为 2 的幂次方。
这里阿粉就不具体计算了,感兴趣的同学可以网上查找一下推导过程
面试题
为什么 HashMap 扩容都是 2 的幂次方?
看完这个,大家这个面试题了吧。
扩容
当 HashMap 元素过多时,这时必须扩容,从而保证 HashMap 查找性能。
扩容过程,我们就需要涉及以下几个核心参数:
- 扩展因子:loadFactor
- 实际元素数量:size
- 数组长度:capacity
- 扩容的阈值大小:threshold=capacity*loadFactor
当 HashMap 中元素数量大于 threshold,HashMap 就会开始扩容。
JDK1.7 扩容的时候,HashMap 每个元素将会重新计算 Hash 值,然后使用寻址算法,查找新的位置。
在 JDk1.8 中,采用了一种更精妙的算法:
其使用 e.hash & oldCap == 0, 元素要么放在原位置,要么放在原位置+原数组长度。
这里解释起来比较复杂,这里阿粉就不再详细展开,感兴趣同学可以自行查找一下相关文章。
面试题
问:加载因子为什么 0.75,而不是其他值?
答:可以说是一个经过考量的经验值。加载因子涉及扩容,下次扩容的阈值=数组桶的大小*加载因子,如果加载因子太小,这就会导致阈值太小,这就会导致比较容易发生扩容。
如果加载因子太大,那就会导致阈值太大,可能冲突会很多,导致查找效率下降。
多线程并发
好了,终于到到了最后一个知识点,多线程并发。
HashMap 在多线程并发情况下会怎么样?
这里我们需要分 JDK1.7 与 JDK1.8 来讲。
在 JDK1.7 中,由于扩容迁移时采用了头插法,从而将会导致产生死链。
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; } } }
而一旦产生死链,极有可能导致程序陷入死循环,从而导致 CPU 使用率上升。
JDK1.8 中使用尾插法,从而解决这个问题,但是依然还会存在相关问题。
比如:
并发赋值时被覆盖
并发的情况下,一个线程的赋值可能被另一个线程覆盖,这就导致对象的丢失。
size 计算问题
img
每次元素增加完成之后,size
将会加 1。这里采用 ++i
方法,天然的并发不安全。
面试题
关于并发,这里可以提到很多面试题。
可以是线程相关的,也可以是并发编程相关。
不过如果面试官既然已经提到这里,我们可以试着将他引导他如何解决 HashMap 并发编程的问题,从而我们下面开始回答出 ConcurrentHashMap
。
最后
经过上面一顿分析,我们可以看到小小一个 HashMap 其实涉及到很多知识点,这些点拆开来讲就可以变成一道道面试题。
另外这些点在平常编程的过程中也要特别注意,一不小心我们就会踩一个大坑。
今天这篇文章主要提及一下,HashMap 涉及的知识点,所以阿粉没有过多深入的分析,这里感兴趣的同学可以在深入学习准备一下。