文章目录

  • 1. HashMap简介
  • 1.1 HashMap的底层数据结构
  • 1.2 为什么链表改为红黑树的阈值是 8?
  • 1.3 解决hash冲突的办法有哪些?HashMap用的哪种?
  • 1.4 为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
  • 1.5 HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?
  • 1.6 HashMap 中 key 的存储索引是怎么计算的?
  • 1.7 为什么 hash 值要与length-1相与?
  • 1.8 HashMap数组的长度为什么是 2 的幂次方?
  • 1.9 put()方法流程
  • 1.10 get()
  • 1.11 resize(扩容)
  • 1.12 一般用什么作为HashMap的key?
  • 1.13 HashMap为什么线程不安全?
  • 2. 其他问题
  • 2.1 遍历HashMap
  • 2.2 为什么equals()和hashCode()方法要同时重写?
  • 3. ConcurrentHashMap
  • 4. Hashtable
  • 5. TreeMap
  • 6. SynchronizedMap



13 道 Java HashMap 精选面试题

1. HashMap简介

1.1 HashMap的底层数据结构

HashMap的本质可以理解为 Entry[ ] 数组。HashMap初始容量大小默认是16,默认加载因子是0.75。

  • JDK1.7 采用数组+链表实现,
  • JDK1.8 采用数组+链表+红黑树实现的(当当链表超过 8 且数据总量超过 64 时会转红黑树)。
  • 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。

链表长度超过 8 体现在 putVal 方法中的这段代码:

//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);

table 长度为 64 体现在 treeifyBin 方法中的这段代码:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) 
        resize();
}

//MIN_TREEIFY_CAPACITY 的值正好为 64。
static final int MIN_TREEIFY_CAPACITY = 64;

1.2 为什么链表改为红黑树的阈值是 8?

  • 因为泊松分布,理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。

1.3 解决hash冲突的办法有哪些?HashMap用的哪种?

解决Hash冲突方法有:

  • 开放定址法:也称为再散列法,基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,p1=H§,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。
  • 再哈希法:双重散列,多重散列,提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。这样做虽然不易产生堆集,但增加了计算的时间。
  • 链地址法:拉链法,将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。HashMap中采用的是链地址法 。
  • 建立公共溢出区:将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。

1.4 为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?

  • 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。
  • 当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。

因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。

1.5 HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?

参考:为什么HashMap的加载因子一定是0.75?而不是0.8,0.6?

加载因子是用来表示 HashMap 中数据的填满程度:加载因子 = 填入哈希表中的数据个数 / 哈希表的长度,因此:

  • 加载因子越小,填满的数据就越少,哈希冲突的几率就减少了,但浪费了空间,而且还会提高扩容的触发几率;
  • 加载因子越大,填满的数据就越多,空间利用率就高,但哈希冲突的几率就变大了。

必须在“哈希冲突”与“空间利用率”两者之间有所取舍,尽量保持平衡,0.75 是个比较完美的选择。HashMap 是通过拉链法来解决哈希冲突的。为了减少哈希冲突发生的概率,当 HashMap 的数组长度达到一个临界值的时候,就会触发扩容(可以点击链接查看 HashMap 的扩容机制),扩容后会将之前小数组中的元素转移到大数组中,这是一个相当耗时的操作。

1.6 HashMap 中 key 的存储索引是怎么计算的?

首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。

1.7 为什么 hash 值要与length-1相与?

把 hash 值对数组长度取模运算,模运算的消耗很大,没有位运算快。
当 length 总是 2 的n次方时,key & (length-1) 位 运算等价于对length取模,也就是 key%length,但是 & 比 % 具有更高的效率。

1.8 HashMap数组的长度为什么是 2 的幂次方?

2 的 N 次幂有助于减少碰撞的几率。如果 length 为2的幂次方,则 length-1 转化为二进制必定是11111……的形式,在与h的二进制与操作效率会非常的快,而且空间不浪费。

HashMap存取数据时大量涉及到哈希计算,必须尽量减少碰撞,Hash函数的作用就是就要把数据尽量分布均匀,而Hash函数的最主要的是取模运算(取余),但是在计算机中,直接取模的运算速度不如位运算(&),当容量为2的n次方时,效率更高。hash & (capacity - 1) == hash % capacity(源码中有类似的代码),于是源码中都是用hash & (capacity - 1)代替了hash % capacity,而前提是容量必须是2的n次方。

1.9 put()方法流程

以JDK 8为例,简要流程如下:

  1. 首先根据 key 的值计算 hash 值,将键映射到HashMap的桶(bucket)上;
  2. 然后,put()方法会检查该桶是否为空,如果为空,表示该桶还没有任何元素,直接将键值对插入到该桶中,并返回null;
  3. 如果桶不为空,put()方法会遍历该桶上的链表或红黑树,根据键的hashCode和equals方法进行比较,找到对应的节点。
  4. 如果找到了相同的键,则将新的值替换旧的值,并返回旧的值
  5. 如果没有找到相同的键,表示该键在当前桶中不存在,需要将新的键值对插入到链表或红黑树的末尾。
  6. 在插入新的节点之前,put()方法会检查当前桶中节点的数量是否超过了阈值(通常为8)。如果超过了阈值,会将链表转换为红黑树,以提高查找效率。
  7. 最后,put()方法会更新HashMap的大小(size)并返回null。。

1.10 get()

1、判断数组是否为空,数组长度是否大于0,判断当前位置是否有元素。
2、判断当前位置存放的元素是否相同,如果相等则返回,如果不相等则遍历链表。
3、判断当前元素是否有下一个元素,如果有元素,判断是不是红黑树,如果是红黑树则进入红黑树进行判断。
4、如果不是红黑树,则遍历链表;能找到则返回节点Node,没有就返回false;

1.11 resize(扩容)

HashMap的容量 >= 指定容量 * loadfactor时,便会扩容,扩容就是创建一个新的Node数组即Node[]之后将原来的数据重新装填到其中的过程

1.12 一般用什么作为HashMap的key?

一般用Integer、String 这种不可变类当作 HashMap 的 key,String 最为常见。

  • 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。
  • 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的。Integer、String 这些类已经很规范的重写了 hashCode() 以及 equals() 方法

1.13 HashMap为什么线程不安全?

  • put()插入操作时:线程A和线程B同时进行put()操作时,如果A和B插入的Key-Value中key的hashcode是相同的,这说明该键值对将会插入到Table的同一个下标的,也就是会发生哈希碰撞,有可能会丢失修改。
  • resize()扩容时:单线程下的rehash是完全没有问题的,但是在多线程环境下,就有可能出现线程不安全问题(有可能出现循环链表)。

链表插入元素时,JDK1.7 使用头插法插入元素,在多线程的环境下有可能导致环形链表的出现,扩容的时候会导致死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了,但JDK1.8 的 HashMap 仍然是线程不安全的,具体原因会在另一篇文章分析。

2. 其他问题

2.1 遍历HashMap

HashMap的底层是一个Entry数组,HashMap的entrySet()keySet()方法返回一个Entry集合和一个Key集合,然后遍历Entry集合或者遍历Key集合就可实现遍历HashMap。

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;

public class foreachIteratorMap {

	public static void main(String[] args) {
		HashMap<Integer,Integer> map = new HashMap<>();
		map.put(1, 2);
		map.put(3, 4);
		map.put(5, 6);
		map.put(7, 8);
		
		//通过Map.entrySet使用iterator遍历key和value
		Iterator<?> iterator = map.entrySet().iterator();
		while(iterator.hasNext()) {
			Map.Entry entry = (Map.Entry) iterator.next();
			System.out.println(entry.getKey()+" "+entry.getValue());
		}
		
		//通过Map.keySet使用iterator遍历key和value
		Iterator<Integer> iter = map.keySet().iterator();
		while (iter.hasNext()) {
			Object key = iter.next();
			Object val = map.get(key);
			System.out.println(key+" "+val);
		}
		
		//通过keySet遍历HashMap
		for (Integer key : map.keySet()) {
			System.out.println(key+" "+map.get(key));
		}
		
		//通过entrySet遍历HashMap
		for (Entry<Integer, Integer> entry : map.entrySet()) {
			System.out.println(entry.getKey()+" "+entry.getValue());
		}
	}

}

2.2 为什么equals()和hashCode()方法要同时重写?

equals()判断相等的对象,hashCode一定相等,其他继承Object的类都遵守该约定。在HashMap中,如果只重写了equals()方法而不重写hashCode()方法,可能会导致HashMap中存在两个Key值相同的元素(put时hashCode不同会被认为是两个不同的key)。

3. ConcurrentHashMap

ConcurrentHashMap位于java.util.concurrent包下,ConcurrentHashMap是HashMap的线程安全版,与Hashtable相比,ConcurrentHashMap不仅保证了访问的线程安全性,而且在效率上比Hashtable要高。ConcurrentHashMap的数据结构与HashMap基本类似,区别在于:1、内部在数据写入时加了同步机制(分段锁+CAS,分段锁使用synchronized实现)保证线程安全,读操作是无锁操作;2、扩容时老数据的转移是并发执行的,这样扩容的效率更高。

ConcurrentHashMap和HashMap、Hashtable对比,ConcurrentHashMap的底层与HashMap是一样的:数组+链表+红黑树。不同的是以下几点:

  • HashMap是线程不安全的,所以在处理并发的时候会出现问题。
  • HashTable虽然是线程安全的,但是是通过synchronized来对整个哈希表加锁的方式,当一个线程在写操作的时候,另外的线程则不能进行读写。
  • ConcurrentHashMap则可以支持并发的读写。跟1.7版本相比,1.8版本又有了很大的变化,已经抛弃了Segment的概念,虽然源码里面还保留了,也只是为了兼容性的考虑。

ConcurrentHashMap原理:在ConcurrentHashMap中通过一个Node<K,V>[]数组来保存添加到map中的键值对,而在同一个数组位置是通过链表和红黑树的形式来保存的。具有以下特征:

  • JDK1.8底层是数组+链表+红黑树
  • ConCurrentHashMap支持高并发的访问和更新,它是线程安全的
  • 检索操作不用加锁,get方法是非阻塞的
  • key和value都不允许为null

ConcurrentHashMap的同步机制:在jdk1.7中ConcurrentHashMap使用锁分段技术提高并发访问效率。首先将数据分成一段一段地存储,然后给每一段数据配一个锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问。然而在jdk1.8中的实现已经抛弃了Segment分段锁机制,利用CAS + Synchronized来保证并发更新的安全,底层依然采用数组+链表+红黑树的存储结构。

4. Hashtable

Hashtable是从JDK1.0就出现的一个Map实现类,底层使用synchronized实现线程安全,性能比HashMap要低。

HashMap与Hashtable的区别

  • Hashtable是线程安全的,而HashMap是线程不安全的,所以HashMap比Hashtable的性能高一点;但是如果有多个线程访问同一个Map对象时,使用Hashtable会更好。
  • HashMap可以使用null作为key和value,但是只有一个key-value对的key为null,可以有无数多个key-value对的value为null。Hashtable不允许使用null做为key和value。

注:尽量少使用Hashtable实现类,即使需要创建线程安全的Map类,也无须使用Hashtable类,可以通过Collections工具类的synchronizedMap(new HashMap())方法把HashMap变成线程安全的,也可以使用ConcurrentHashMap

5. TreeMap

TreeMap底层是⼀一个Entry的红黑树。

6. SynchronizedMap

线程安全的,通过Collections工具类的方法产生。

Map<String,Integer> map = Collections.synchronizedMap(new HashMap<String,Integer>());