HashMap
本文源码除特殊说明,默认指JDK1.8
源码解析可以参考:HashMap 源码详细分析(JDK1.8)
1. 实现方式
JDK1.7 哈希表 + 链表
JDK1.8 哈希表 + 链表/红黑树
2. 基本参数
/**
* 默认的初始化长度 - 必须是2的n次方.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量 1<<30 2^30=1073741824
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 负载因子:默认值为`0.75`。 当元素的总个数>当前数组的长度 * 负载因子。
* 数组会进行扩容,扩容为原来的两倍
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表树化阙值: 默认值为 `8` 。表示在一个node(Table)节点下的值的个数大于8时候,
* 会将链表转换成为红黑树。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 默认值为 `6` 。 表示在进行扩容期间,单个Node节点下的红黑树节点的个数小于6时候,
* 会将红黑树转化成为链表。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小树化阈值,当Table所有元素超过改值,才会进行树化(为了防止前期阶段频繁扩容和树化过程冲突)
*/
static final int MIN_TREEIFY_CAPACITY = 64;
3. 基本方法-hash()
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在JDK 1.8中hash方法计算的是做一次16位右位移异或混合。网上也有说将hashcode高低十六位异或,那应该是JDK 1.7的实现实现形式。不同版本有不同的方式。
这部分可以参考一下:JDK 源码中 HashMap 的 hash 方法原理是什么?
3. 基本方法-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;
if ((tab = table) != null && (n = tab.length) > 0 &&
//1. 首先根据key的hash后的值获取在table中的位置,方式为hash的值
// 与当前table的长度进行 & 运算,高效
(first = tab[(n - 1) & hash]) != null) {
//2. 先检查第一个节点,检查方式为先对比hash后的值再比较key的内存地址或者equals比较
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 3. 判断第一个节点是不是TreeNode(红黑树),是的话就从红黑树中查找
// 红黑树的查找就不分析了,感兴趣的可以深入研究
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 4. 不是红黑树就是链表,循环查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
3.1 如何比较两个key?(重点)
先对比hash后的值再比较key的内存地址或者equals比较
4. 基本方法-put()
public V put(K key, V value) {
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;
// 1. 如果table为空或者table是一个空数组的话,会对table进行扩容操作,
// 扩容的讲解放在了 第 5 小节
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 根据key hash后的值与当前table的长度进行 & 运算后获取要存放的桶位置
// 如果当前位置没有数据则创建一个节点放到当前位置
// 否则进行下一步
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果key与当前节点比较相同,则用 e 指向当前节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 否则如果第一个节点是红黑树,则向红黑树中插入或者替换节点值。
// 如果是插入,会返回一个null,如果是替换,就会返回老的节点,
// 并由e指向它
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 否则就是链表结构
else {
for (int binCount = 0; ; ++binCount) {
// 6. 如果遍历到了末尾,则插入一个该K-V生成的节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7. 插入后,如果当前大小大于等于8个,就转化成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 8. 如果当前节点的key就是要插入的key值,找到了这个点并且由 e 指向了它
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//
p = e;
}
}
// 9. e此时指向的就是要操作的节点
// 不过有的时候是新增节点的场景,有的时候是要修改节点的场景
// 如果是新增节点的场景,e会是null
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 10. onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
// 默认为 false
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 这是一个空方法,可以重写
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// HashMap修改的次数
++modCount;
// 10. 如果走到这里,证明e是新增节点的场景,会将当前size + 1
// 如果插入后的size大于阈值,进行resize操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
// 11. 新增节点的场景返回null
return null;
}
5. 基本方法-resize()
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1. 判断table是否已经初始化过
if (oldCap > 0) {
// 2. 容量达到最大值,不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 3. 如果容量X2后小于最大容量并且老的容量大于默认容量,阈值X2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 4. 如果没有初始化,并且老的阈值大于0,直接用老的阈值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 5. 如果没有初始化,老的阈值不大于0,设置新的容量为默认值,
// 新的阈值为 默认大小 * 默认负载因子
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 6. 新的阈值为0的话
if (newThr == 0) {
// 7. 计算一个阈值 新的容量 * 当前设置的负载因子(没有设置的话是默认值)
float ft = (float)newCap * loadFactor;
// 8. 如果新的容量小于HashMap支持的最大值,
// 并且计算出来的阈值小于HashMap支持的最大值
// 新的阈值为计算出来的阈值,否则为Int的最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 7. 设置阈值为经过一系列计算出来的阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 8. 如果旧的table不为空,进行遍历
// 并且将旧的table没一位的数据置空
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 9. 如果没有链表和树的结构,重新计算hash值并且重新计算在桶的位置,
// 放在相应位置上
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 10. 如果是树的结构,对树进行拆分,在这个过程中,如果树小于6是会转成链表的
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 11. 链表结构,重新根据hash值和桶大小计算位置,
// 并将要移动位置的节点和不移动位置的节点分别放在两个链表中
// 最后将两个链表分别放在桶的相应位置上
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
关于resize这部分,可以深入看看,推荐:
深入理解HashMap(四): 关键源码逐行分析之resize扩容
6. 常见问题汇总
写到这里,最重要的三个方法就分析完了,剩下的几个方法可以参考推荐文章再细看吧。
6.1 如何让HasnMap有序?
使用 LinkedHashMap ,他的实现原理为节点同时保存了一个向前的节点的引用,节点既有前面节点的引用,又有后面节点的引用,是一个链表结构了,具体的可以参考:blog.csdn.net/justloveyou…
6.2 如何让HashMap线程安全?
- 使用HashTable,不过性能差,不推荐使用。
- 使用Collections.synchronizedMap(map)包装map,返回 SynchronizedMap 其实就是内部持有一个Map,然后对Map的各方法利用synchronized包装。性能也不是很好,不推荐使用。
- 使用ConcurrentHashMap。
6.2.1 ConcurrentHashMap
可以参考:HashMap? ConcurrentHashMap? 相信看完这篇没人能难住你!
JDK 1.7的实现
由Segment数组、HashEntry组成。
理解起来就是Segment就是一个改造版的HashMap。
put数据的时候先根据key->hash, 根据计算出来的下标找到在Segment中的位置,在往Segment中put数据。
往Segment中put数据跟往HashMap中put数据流程一样。这样相当于把桶上以前的链表放到了Segment中,Segment在put数据的时候会加锁。
这样把锁的力度控制在了以前table的每个节点的范围,缩小了范围,提高了效率。
get数据的时候不需要加锁。
JDK 1.8的实现
抛弃了Segment结构,它的结构跟HashMap的一样。
不过在put数据的时候,会先查找该key是否有对应节点。
如果没有,利用CAS的方式插入节点。
如果有,利用 synchronized 加锁,在插入数据。
6.3 HashMap允许空键空值么
HashMap最多只允许一个键为Null(多条会覆盖),但允许多个值为Null
6.4 HashMap的key一般怎么选?用可变类当HashMap的key有什么问题?
一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
- (1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- (2)因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。 用可变类当HashMap的key,该对象的hashcode可能发生改变,导致put进去的值,无法get出。
6.5 如果让你实现一个自定义的class作为HashMap的key该如何实现?
此题考察两个知识点
- 重写hashcode和equals方法注意什么?
- 如何设计一个不变类
针对问题一,记住下面四个原则即可
(1)两个对象相等,hashcode一定相等 (2)两个对象不等,hashcode不一定不等 (3)hashcode相等,两个对象不一定相等 (4)hashcode不等,两个对象一定不等
针对问题二,记住如何写一个不可变类
(1)类添加final修饰符,保证类不被继承。 如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。
(2)保证所有成员变量必须私有,并且加上final修饰 通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。
(3)不提供改变成员变量的方法,包括setter 避免通过其他接口改变成员变量的值,破坏不可变特性。
(4)通过构造器初始化所有成员,进行深拷贝(deep copy) 如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如:
public final class ImmutableDemo {
private final int[] myArray;
public ImmutableDemo(int[] array) {
this.myArray = array; // wrong
}
}
这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。 为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法:
public final class MyImmutableDemo {
private final int[] myArray;
public MyImmutableDemo(int[] array) {
this.myArray = array.clone();
}
}
(5)在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝 这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。
6.6 重写equals()方法,为什么也需要重写hashcode()方法?跟HashMap有关系吗?为什么?
www.huaweicloud.com/articles/1a…
Java集合类大量使用了hashcode和equals,如果不同时重写在使用集合类的时候会出现问题。
跟HashMap有关系,或者说因为HashMap中用到了对象的hashcode方法所以会有关系,因为我们如果在设计两个对象相等的逻辑时,如果只重写Equals方法,那么一个类有两个对象A1,A2,他们的A1.equals(A2)为true,A1.hashcode和A2.hashcode不一样,当将A1和A2都作为HashMap的key时,HashMap会认为它两不相等,因为 HashMap在判断key值相不相等时会判断key的hashcode是不是一样,hashcode一样才进行equals判断,所以会有问题。
6.7 JDK 1.8中做了哪些优化优化?
数组+链表改成了数组+链表或红黑树链表的插入方式从头插法改成了尾插法扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
6.8 为什么HashMap的底层数组长度为何总是2的n次方
- 第一:当length为2的N次方的时候,h & (length-1) = h % length 为什么&效率更高呢?因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高
- 第二:当length为2的N次方的时候,数据分布均匀,减少冲突
6.9 rehash的解释:
在创建hashMAP的时候可以设置来个参数,一般默认 初始化容量:创建hash表时桶的数量 负载因子:负载因子=map的size/初始化容量 当hash表中的负载因子达到负载极限的时候,hash表会自动成倍的增加容量(桶的数量),并将原有的对象重新的分配并加入新的桶内,这称为rehash。这个过程是十分好性能的,一般建议设置比较大的初始化容量,防止rehash,但是也不能设置过大,初始化容量过大浪费空间。