在 Java 中,最常用的数据类型是 8 中基本类型以及他们的包装类型以及字符串类型,其次应该就是 ArrayList和HashMap了吧。HashMap存的是键值对类型的数据,其存储和获取的速度快、性能高,是非常好用的一个数据结构。
结构
先来看一下整个 Map家族的集成关系图,一看东西还不少,但其他的可能都没怎么用过,只有 HashMap最熟悉。
以下描述可能不够专业,只为简单的描述 HashMap的结构,请结合下图进行理解。
HashMap主体上就是一个数组结构,每一个索引位置英文叫做一个 bin,我们这里先管它叫做桶,比如你定义一个长度为 8 的 HashMap,那就可以说这是一个由 8 个桶组成的数组。当我们像数组中插入数据的时候,大多数时候存的都是一个一个 Node 类型的元素,Node 是 HashMap中定义的静态内部类。
当插入数据(也就是调用 put 方法)的时候,并不是按顺序一个一个向后存储的,HashMap中定义了一套专门的索引选择算法,叫做散列计算,但散列计算存在一种情况,叫哈希碰撞,也就是两个不一样的 key 散列计算出来的 hash 值是一致的,这种情况怎么办呢,采用拉链法进行扩展,比如图中蓝色的链表部分,这样一来,具有相同 hash 值的不同 key 即可以落到相同的桶中,又保证不会覆盖之前的内容。
但随着插入的元素越来越多,发生碰撞的概率就越大,某个桶中的链表就会越来越长,直到达到一个阈值,HashMap就受不了了,为了提升性能,会将超过阈值的链表转换形态,转换成红黑树的结构,这个阈值是 8 。也就是单个桶内的链表节点数大于 8 ,就会将链表变身为红黑树。
以上概括性的描述就是 HashMap的整体结构,也是我们进一步研究细节的蓝图。我们将从中抽取出几个关键点一一解释,从整体到细节,降维打击 HashMap。
接下来就是说明为什么会设计成这样的结构以及从单纯数组到桶内链表产生,接着把链表转换成红黑树的详细过程。
几个关键概念
存储容器
因为HashMap内部是用一个数组来保存内容的,数组定义如下:
transient Node<K,V>[] table;
Node 类型
table 是一个 Node类型的数组,Node是其中定义的静态内部类,主要包括 hash、key、value 和 next 的属性。比如之后我们使用 put 方法像其中加键值对的时候,就会转换成 Node 类型。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
TreeNode
前面说了,当桶内链表到达 8 的时候,会将链表转换成红黑树,就是 TreeNode类型,它也是 HashMap中定义的静态内部类。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
容量和默认容量
容量就是 table 数组的长度,也就是我们所说的桶的个数。其定义如下:
int threshold;
默认是 16,如果我们在初始化的时候没有指定大小,那就是 16。当然我们也可以自己指定初始大小,而 HashMap 要求初始大小必须是 2 的 幂次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
元素个数
容量是指定了桶的个数,而 size 是说 HashMap中实际存了多少个键值对。
transient int size;
最大容量
table 的长度也是有限制的,不能无限大,HashMap规定最大长度为 2 的30次方。
static final int MAXIMUM_CAPACITY = 1 << 30;
负载因子
这是一个系数,它和 threshold 结合起作用,默认是 0.75。一般情况下不要改。
final float loadFactor;
扩容阈值
阈值 = 容量 x 负载因子,假设当前 HashMap的容量是 16,负载因子是默认值 0.75,那么当 size 到达 16 x 0.75= 12 的时候,就会触发扩容。
初始化 HashMap
使用 HashMap肯定要初始化吧,很多情况下都是用无参构造方法创建。
Map<String,String> map = new HashMap<>();
这种情况下所有属性都是默认值,比如容量是 16,负载因子是 0.75。
另外推荐的一种初始化方式,就是给定一个默认容量,比如指定默认容量是 32。
Map<String,String> map = new HashMap<>(32);
但是 HashMap 要求初始大小必须是 2 的 n 次方,但是又不能要求每个开发人员指定初始容量的时候都按要求来,比如我们指定初始大小为为 7、18 这种会怎么样呢?
没关系,HashMap中有个方法专门负责将传过来的参数值转换为最接近、且大于等于指定参数的 2 的 n 次方的值,比如指定大小为 7 的话,最后实际的容量就是 8 ,如果指定大小为 18的话,那最后实际的容量就是 32 。
public 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);
}
执行这个转换动作的就是 tableSizeFor方法,经过转换后,将最终的结果赋值给 threshold变量,也就是初始容量,也就是本篇中所说的桶个数。
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;