面试——HashMap
1. JDK1.7版本:
1.1 数据结构:数组+链表
1.2 存放
- 根据hash(key)确定存储在数组上的位置后,以链表的形式在该位置处存数据。此时数组该位置的链表存了多个数据,因此也称为桶
- 存放的数据是用Entry描述
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
...
2. JDK1.8版本:
2.1 数据结构:数组+链表+红黑树
2.2 存放
- 根据**hash(key)**确定存储在数组上的位置后,以链表的形式在该位置处存数据
- 存放的数据是用Node描述
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
...
- 链表有可能过长,所以在满足以下条件时,链表会转换成红黑树:
- 链表长度>8 并且 数组大小>=64
- 1.8版本:当红黑树节点个数<6时转换为链表
3. 1.8相比1.7,做了哪些优化?
- 插入方式升级:
- 1.7的HashMap在添加数据的时候,采用头插法;扩容之后,获得新的index,头插法会导致链表反转,会存在遍历时死循环的情况
- 1.8插入数据采用的则是尾插法,在扩容时会保持链表元素原先的顺序,因此不会出现链表成环的死循环问题
- 扩容后数据存储位置的计算方式升级:
- 1.7:重新hash运算
- 1.8:扩容后的位置=原位置 or 原位置 + 旧容量
- 存储结构的升级:
- 1.8的HashMap加入了红黑树存储结构,查询方式性能得到了很好的提升,从原来的是O(n)到O(logn)
4. HashMap如何初始化大小的?
- 如果没有指定容量,则默认初始化容量为16,加载因子是0.75
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
- 若指定了初始化容量,若大于指定容量的,最近的2的整数次方的数。比如传入是10,则会初始化容量为16
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16; /**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
} /**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
在扩容时会保持链表元素原先的顺序,因此不会出现链表成环的死循环问题。
5. HashMap怎么实现扩容的
HashMap执行扩容关系到两个参数:
- Capacity:HashMap当前容量
- loadFactor:负载因子(默认是0.75)
- 1.7版本:先扩容,再插入数据。扩容时会创建一个为原数组的2倍大小的数组,然后将原数组的元素重新hash,存进新数组。
- 1.8版本:先插入数据,再执行扩容。扩容时会创建一个为原数组的2倍大小的数组,然后将原数组的元素存进新数组。不同的是1.8使用位移操作创建2倍大小的新数组
6. 提到hash函数,你知道HashMap的hash函数是如何设计的?
- hash运算的目的是用来定位该数据要存放在数组的哪个位置
- 1.7版本是这样的
//jdk1.7 相比jdk1.8, jdk1.7做了四次移位和四次异或运算,效率比1.8要低
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
- 1.8版本如下:
用key的hashCode()与其低16位做异或运算。这个扰动函数的设计有两个原因:
- 计算出来的hash值尽量分散,降级hash碰撞的概率
- 用位运算做算法,更加高效
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
7. 那么为什么不能直接用key的hashCode()作为hash值,而一定要^ (h >>> 16)?
- 因为如果直接用key的hashCode()作为hash值,很容易发生hash碰撞。
- 使用扰动函数^ (h >>> 16),就是为了混淆原始哈希码的高位和低位,以此来加大低位的随机性。且低位中参杂了高位的信息,这样高位的信息也作为扰动函数的关键信息。
8. 插入数据时扩容的重新hash是怎么做的?
- 1.7:需要再做一次hash
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
- 1.8 不需要做hash,通过原方式获取存储位置
现位置 = 原位置(或者 现位置=原位置加原长度)
9. 为什么重写equals方法后还要重写hashCode方法
https://blog.csdn.net/babycan5/article/details/88586821
- 重写equals和hashCode方法的目的就是根据对象的属性来进行判断对象是否相同
- 如果是需要比较内存地址“==”号就足够了,
10. 红黑树
- 红黑树是一个特殊的平衡二叉树。他在查询性能和自旋所带来的性能开销之间找到了一个平衡点。
- 可以牺牲平衡性,而不做多余(自旋和不自旋对查询的性能来说差距微小)的自旋带来的系统开销
- 特征
- 根节点是黑色, 叶节点是不存储数据的黑色空节点
- 任何相邻的两个节点不能同时为红色
- 任意节点到其可到达的叶节点间包含相同数量的黑色节点
- 是其查询的性能一定不会超过O(2(logN+1))
- 红黑树在插入(删除)数据时要遵循的原则
- 根节点一定是黑色节点
- 插入的节点一定是红色节点
- 父节点如果是红色,则把父节点变成黑色,同时判断父节点的兄弟节点是否是红色,如果是,则一并变成黑色
- 判断父节点的父节点是否是根节点,如果不是,则变为红色。重复上述过程,直到变成完整的红黑树。
11. HashMap在多线程使用场景下会存在线程安全问题,怎么处理?
使用Collections.synchronizedMap()创建线程安全的map集合
使用Hashtable
使用ConcurrentHashMap(鉴于效率考虑,推荐使用ConcurrentHashMap)