HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。
HashMap
可以存储 null 的 key 和 value
- null 作为键只能有一个
- null 作为值可以有多个
JDK1.8之前的HashMap
JDK1.8之前的HashMap的底层是数组+链表,数组是 HashMap 的主体,链表是用来解决冲突。(也就是所谓的“拉链法”)
- 【获取hash值】HashMap通过key的hashCode经过扰动函数处理后得到hash值
- 【找到存放位置】通过
(n-1)&hash
获取当前元素当前存放位置(这里的 n 指的是数组的长度)。 - 【判断冲突】如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同【其实就是比较hash值以及使用key的equals方法。】
- 如果相同的话,直接覆盖
- 不相同就通过拉链法解决冲突。
拉链法是一种解决冲突的方法,当发生冲突的时候,就创建一个节点,将该节点放在链表最后面

扰动函数hash
1 | static int hash(int h) { |
JDK1.8的HashMap
JDK1.8 以后在解决哈希冲突时有了较大的变化。
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。【Java7是 数组+链表】
为什么要添加红黑树结构?
Java7的不足:
当我们进行查找时,根据 hash 值我们能够快速定位到数组的具体下标。
但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的。
时间复杂度取决于链表的长度,为 **O(n)**。
Java8的改进:
在 Java8 中,为了降低这部分的开销,当链表中的元素达到了 8 个时,会将链表转换为红黑树。
在这些位置进行查找的时候可以降低时间复杂度为 **O(logN)**。
HashMap结构示意图【主要是描述结构,不会达到这个状态的,因为这么多数据的时候早就扩容了】

1 | public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable |
类属性
1 | static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认初始容量 16 |
装填因子LOAD_FACTOR
什么是装填因子?$ a = \frac{n}{m}$
n为哈希表的关键字个数,也就是哈希表中已经有多少个位置有元素了。
m表示哈希表的长度,也就是容量。
- loadFactor 越趋近于 1,那么 数组中存放的数据(entry)也就越多,也就越密
- loadFactor 太大导致查找元素效率低,而且很容易导致冲突
- loadFactor 越小,也就是趋近于 0,数组中存放的数据(entry)也就越少,也就越稀疏。
- 太小导致数组的利用率低,存放的数据会很分散
**loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值**。
默认情况下,默认容量为 16,负载因子为 0.75。
Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 个时。
就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
threshold
threshold = capacity * loadFactor,当 Size>=threshold的时候,那么就要考虑对数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准。
也就是这个size,你向map中添加元素的个数,并不是数组的长度【容量】。
底层数据结构
1 | //存储元素的数组,总是2的幂次倍 |
Node<K,V>
链表节点内部类,源码如下
1 | static class Node<K,V> implements Map.Entry<K,V> { |
抽象结构如下:
TreeNode<K,V>
红黑树节点内部类
1 | static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { |
构造方法
无参构造,装填因子为默认的装填因子【0.75】,所有属性均为默认
1
2
3public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段均为默认值
}包含另一个“Map”的构造函数
1
2
3
4public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;//默认装填因子 0.75
putMapEntries(m, false);
}内部调用了
putMapEntries
方法【这个方法在后面讲】指定“容量大小”的构造函数
1
2
3public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}指定“容量大小”和“加载因子”的构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14public HashMap(int initialCapacity, float loadFactor) {
//----------------判断参数是否合法-------------------------------
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//----------------判断参数是否合法-------------------------------
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);//返回给定目标容量的2次方大小。
}
putMapEntries
方法
从一个Map对象中批量添加的函数。
这个方法服务于 Map的构造方法HashMap(Map<? extends K, ? extends V> m)
和Map的putAll(Map<? extends K, ? extends V> m)
方法。
当构造方法调用这个方法,**evict为False**
当putAll调用这个方法,**evict为True**
1 | final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { |
tableSizeFor(t)
返回给定目标容量的2次方大小,比如 t = 10,就返回16;t=100,返回128。
流程总结:
首先判断自身的
Node<K,V>[] table
有没有初始化如果没有初始化
1.根据传入Map所含元素数量,装填因子,最大容量,来计算预计最终所需容量,t。
2.如果t>阈值,则阈值就是大于t的二次方大小。
如果初始化过,且 传入Map所含元素数量 s 大于 阈值,则对Map进行扩容。
最后再循环遍历传入的Map,调用
putVal()
方法添加。
注:
if (t > threshold)
这里的threshold成员实际存放的值是capacity的值。因为在table还没有初始化时(table还是null),用户给定的capacity会暂存到threshold成员上去(毕竟HashMap没有一个成员叫做capacity,capacity是作为table数组的大小而隐式存在的)else if (s > threshold)
说明传入map的size都已经大于当前map的threshold了,即当前map肯定是装不下两个map的并集的,所以这里必须要执行**resize()
[扩容]**操作putval
也是使用的默认修饰符,因此只能被本类或者该包下的类访问到,最后循环里的putVal
可能也会触发resize操作
put()
方法&putVal()
方法
HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。
put()
方法:
1 | public V put(K key, V value) { |
内部调用了putVal()
方法
参数:
- hash:key的hash值
- key: 键
- value:值
- onlyIfAbsent:如果为真,不更改现有值
- Evict :如果为false,表示表处于创建模式。
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { |
总结:
- 如果定位到的数组位置没有元素 就直接插入
- 如果定位到的数组位置有元素就和要插入的 key 比较【比较hash和调用
equals()
】- 如果 key 相同就直接覆盖
- 如果 key 不相同
- 如果是一个树节点就调用
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)
将元素添加进入 - 如果是链表节点就遍历链表插入(插入的是链表尾部)。
- 如果是一个树节点就调用
当链表长度大于阈值(默认为 8)并且 HashMap 数组长度超过 64 的时候才会执行链表转红黑树的操作,否则就只是对数组扩容。
参考 HashMap 的
treeifyBin()
方法
扩容方法
1 | final Node<K,V>[] resize() { |
总结一下是如何扩容的
当你进行初始化时:
如果你是无参构造一个
HashMap
初始容量就是16(默认容量),初始阈值就是12(默认容量*默认装填因子)。
如果你使用初始容量的那个构造函数 new 一个HashMap
初始阈值就是 你指定的初始容量的2次方大小,初始容量就是初始阈值。
例如,
new HashMap<>(24);
,则初始容量为36,初始阈值也是36。
当你添加的元素需要扩容时:
如果旧的容量小于最大容量【2^30】
新的容量 为 旧的容量两倍,新的阈值因情况而定
旧的容量 > 默认容量【16】,且新的容量<最大容量【2^30】:新的阈值 就是 旧的阈值两倍
旧的容量大于默认容量,且新的容量大于最大容量:
1
2float ft = (float)newCap * loadFactor;//装填因子*新的容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY?(int)ft:Integer.MAX_VALUE);
如果旧的容量大于最大容量【2^30】
那阈值就是int 最大值,然后就不会再去扩充。
我们会发现,hashmap的容量总是2的次方,比如8、16、32、64…..
还有就是,在扩容的过程中它还需要进行数据迁移,这是非常耗时的,所以我们应该尽量避免扩容!
HashSet
HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法,因此HashSet的实现非常简单,只有不到300行代码。这里不再赘述。
1 | public class HashSet<E> |
__END__