HashMap是一个KV的容器对象,也是日常在开发当中非常常用的对象,比如我们可能经常做一些内存缓存的时候,很多时候就选用HashMap这种KV的数据结构。在实现原理上,是基于哈希表的Map接口实现,是常用的java集合之一,非线程安全的。
HashMap可以存储null的key和value,但是null作为键值只能有一个,做为值的话,可以是多个。这和Map的键要保持唯一性并不冲突。
一、HashMap的类图结构
二、概念、原理概述
Jdk1.8之前的HashMap是由数组+链表作为底层数据结构实现的,数组是hashMap的主体,链表则是为了解决哈希冲突而存在内部解决方案(拉链法)。
jdk1.8以后的HashMap在解决哈希冲突时有了较大的变化,引入了红黑树,以减少搜索时间。
先明确几个概念:
哈希表:指的就是hashMap;
哈希桶:HashMap的底层数据结构,即数组;
链表:Hash桶的下标装的是链表(或树型结构体);
节点:链表上的节点就是哈希表上的元素
哈希表元素容量:元素的总个数
哈希桶的容量:数组数组个数。
哈希桶的默认容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。
上图初步呈现了hashMap的实现结构,在进行增删改查操作的时候,首先要定位到元素所在桶的位置,桶的位置指的就是table数组元素的位置,之后再从桶元素所对应的链表中定位该元素。
比如我们要查找35的元素,先定位到数组中3元素的位置,然后通过链表定位到第三个元素,确定了35元素的位置。
HashMap的底层结构原理概述就如上所示,HashMap的基本操作就是对拉链式散列算法的一层包装,无论1.8版本后引入的红黑树,虽底层数据结构由【数组+链表】变成【数组+链表+红黑树】,核心的原理设计没变。
在jdk1.8中引入的红黑树,在链表的长度大于8并且哈希桶的长度大于等于64的时候【TODO】,会将链表进行树化。红黑树是一个自平衡的二叉查找树,查找效率会从链表O(N)降低为o(logn),大大提升查找效率。
详细分析,看接下来的源码分析
三、HashMap源码原理分析
3.1构造函数分析
/**
* 根据初始化容量、加载因子初始化一个空元素的Map
* @param initialCapacity 初始化容量
* @param loadFactor 负载因子
* @throws IllegalArgumentException 负数抛异常
*/
public HashMap(int initialCapacity, float loadFactor) {
...
//初始化容量超过了最大容量1 << 30(2的30次幂),则使用最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
...
//负载因子初始化:负载因子或者叫做扩容因子
this.loadFactor = loadFactor;
//HashMap进行扩容的阈值,实际上就是数组的长度
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 以16的数组容量和0.75的负载因子,进行默认初始化
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
上述4个构造函数,最常用的是第三个,就是以默认的方式进行初始化一个HashMap,最终会调用都构造函数1,构造函数构造的过程,就是对几个核心的成员变量做了初始化。
3.2 hashMap中桶的长度设计(数组的长度是如何计算的)?
this.threshold = tableSizeFor(initialCapacity);看一下这个方法:
/**
* 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;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
5步或等和移位运算,最终会找到大于或等于 cap 的最小2的正数幂的值,举例说明一下:
比如构造过程中传入的是15,最终tableSizeFor后的值是16,如果是28,最终tableSizeFor的值是32,如果是64,则是64.
tableSizeFor()这个函数就是计算hashMap的长度,那么从设计层面为什么HashMap的长度设计成2的整数幂次方呢?
1、为了加快哈希计算
查找一个KEY在哈希表的那个桶中,需要计算hash(key)%桶的长度,%属于算术运算,算术运算的效率要低于&位运算符的效率,恰好,当被取余的数是2的n次幂的时候,可以用位&替代取余来提升效率。
如下,当b为2的n次方时,有如下替换公式(公式可自行验证):
a % b = a & (b-1) (b=2^n)
即:a % 2^n = a & (2^n-1)
2、2次幂必然是偶数,这种偶数设计能使得散列结果均匀,从而减少Hash冲突的可能性。
假设数组的长度length是奇数,length-1为偶数,最后一位是0,通过hash函数hash&(length-1)的结果最后一位肯定是0,即只能为偶数,这样任何hash值经过hash函数计算后的结果都是偶数,元素就只能被散列在偶数的下标位置上,这样既浪费了空间,同时可能带来2倍的hash冲突的可能性。
关于tableSizeFor()方法运算中大量的使用了位运算和逻辑运算的详细说明可以参考https://segmentfault.com/a/1190000039392972。
3.2 HashMap源码中的关键常量变量的声明部分
/**
* 最大的容量值
* MUST be a power of two(必须2的幂) <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 树化的链表数量阈值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 树拆分的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 最小的树化桶容量
*/
static final int MIN_TREEIFY_CAPACITY = 64;
以上都是简单的描述,在后续的章节分析中会得到这些常量的使用和设计。
3.3 hashMap的桶的的数据结构和源码实现
/**
* table数组, 第一次使用的时候初始化,必要的时候会进行扩容,扩容一般都是原来的2倍
*/
transient Node<K,V>[] table;
/**
* 基本的hash存储节点, 用于存储大量的entries.
*/
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;
}
public final K getKey() {
return key; }
public final V getValue() {
return value; }
public final String toString() {
return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
...
}
public final boolean equals(Object o) {
...
}
}
Node是HashMap的的一个静态内部类,实现了Map.Entry接口,本质上就是一个映射。final K key;V value; 类的成员变量中定义了key和value,是泛型化的,但是注意key被关键字final修饰了,虽然无论创建出来什么样的节点的key数据,程序不会出现问题,但是如果Key对应的对象本身应该也是不可变对象。
3.4.1HashMap的元素插入逻辑和源码实现分析
元素的插入流程是首先要定位需要插入的键值元素属于哪个通,定位到桶过后,判断当前的桶中是否已经有元素,如果桶为空,则直接将键值对存入即可。如果不为空,则需要将键值对接在链表的最后一个位置,或者更新键值对。
以上就是元素插入的核心流程,但由于hashMap是一个变长的集合,实际在插入的时候还有扩容机制,在1.8版本jdk中,还有树化过程和树拆过程。看一下源码,源码分析后,会画一个流程帮助理解:
/**
* 将键值对元素插入的桶中。
* 如果同种存在了相同key的键值对,则替换
*
* @param key
* @param value
*/
public V put(