文章目录
一. 散列表概述
HashMap是Java语言中的一种十分常用的数据结构,它是一种基于散列表(哈希表)的键值对实现,它继承自AbstactMap并实现了Map接口;所以首先需要介绍一下散列表相关知识.
1.1 散列表是什么
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
若关键字为 k,则其值存放在 f(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系 f为散列函数,按这个思想建立的表为散列表。
1.2 散列表的实现形式
散列表有许多实现形式,其中最常用的实现形式有两种:一种是基于红黑树的实现(TreeMap),即每个红黑树节点存储着Key和Value,当使用Key查询红黑树节点时,可以做到最坏O(logn)的时间复杂度;
第二种实现是基于数组与链表的实现,即每个Key通过哈希函数处理之后变成一个数组的索引Index,通过Index找到数组中的链表节点,并且通过Key去对比链表节点的Key值,从而确定出该节点,得到其Value,这种做法被运用在了HashMap中,在最好情况下,查询一个Key的Value的时间复杂度为常数级O(1)(经过哈希求index后,没有产生哈希冲突),而最坏情况是O(k),即在求Index的时候,发现出现了哈希冲突,即链表中含有k个元素,此时需要遍历这k个元素去寻找到Key.
既然说到了散列函数和哈希冲突, 那么下文即将对这两个方面展开介绍.
二. 散列函数
散列函数f的作用是对key进行处理,使得f(key)=index,在下次需要找到key的value时,只需要再次调用f(key)即可直接计算出index,时间复杂度为O(1);散列函数的选取对于散列表性能来说至关重要,好的散列函数能够极大地减少哈希冲突的产生.
-
散列函数具备的特性:
1.简单快速:散列函数的计算需要简单和快速
2.经过散列函数计算出的值能均匀地映射到存储地址空间中
3.任何时间,同一key计算出来的散列值必须是相等的 -
散列函数的类型
1.直接寻址法
:即直接使用一个与key线性相关的函数作为散列函数,例如f = a * key + k
,其中a和k是常数.
2.数字分析法
:分析一组数据,找出数字的规律,尽可能利用这些数字构造冲突几率低的散列地址.
3.除余法
:这是求哈希值的常见方法,即f(key) = key % m
,其中m为存储空间长度,这与就可以使得key的哈希值一定落入[0, m - 1]这个存储空间中;需要注意的是m最好选择为素数,这样可以使得哈希值分布更加均匀(数学证明忽略).
4.随机数法
5.平方取中法
三. 哈希冲突与解决方案
选用哈希函数计算哈希值时,可能不同的 key 会得到相同的结果,这被称为哈希冲突;而如何在同一个内存地址中存储多个结果,这就是哈希冲突的解决方案.
常见解决方案:
3.1.拉链法
拉链法是一种解决哈希冲突的常用手段,它将所有关键字为同义词的结点链接在同一个单链表中:
若选定的散列表长度为 m,那么散列表定义为一个由 m 个头指针组成的指针数组 nodes[0,m-1] 。
凡是散列值为 i 的结点,均插入到以 nodes[i] 为头指针的单链表中。
nodes中各分量的初值均应为空指针。
这样就可以解决哈希冲突了.
3.2. 开放定址法
以下转载自https://blog.youkuaiyun.com/u011240877/article/details/52940469
用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。查找时探测到开放的地址则表明表中无待查的关键字,即查找失败。
简单的说:当冲突发生时,使用某种探查(亦称探测)技术在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。
按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。
a.线性探查法
hi=(h(key)+i) % m ,0 ≤ i ≤ m-1
基本思想是:
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到 有空余地址 或者到 T[d-1]为止。
b.二次探查法
hi=(h(key)+i^2) % m,0 ≤ i ≤ m-1
基本思想是:
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+12],T[d+22],T[d+3^2],…,等,直到探查到 有空余地址 或者到 T[d-1]为止。
缺点是无法探查到整个散列空间。
c.双重散列法
hi=(h(key)+i*h1(key)) % m,0 ≤ i ≤ m-1
基本思想是:
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+h1(d)], T[d + 2*h1(d)],…,等。
该方法使用了两个散列函数 h(key) 和 h1(key),故也称为双散列函数探查法。
定义 h1(key) 的方法较多,但无论采用什么方法定义,都必须使 h1(key) 的值和 m 互素,才能使发生冲突的同义词地址均匀地分布在整个表中,否则可能造成同义词地址的循环计算。
该方法是开放定址法中最好的方法之一。
四. 载荷因子
散列表的载荷因子定义为:a = 填入表中的元素个数 / 散列表的长度
a 是散列表装满程度的标志因子。由于表长是定值, a 与“填入表中的元素个数”成正比,所以, a 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,a 越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子a 的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8的范围。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
五. HashMap 概述
5.1.HashMap的简介
HashMap是基于哈希表的Map实现的的,一个Key对应一个Value,允许使用null键和null值,不保证映射的顺序,特别是它不保证该顺序恒久不变!也不是同步的。
怎么理解不保证恒久不变呢?
当哈希表中的条目数超出了加载因子与当前容量的乘积时的时候,哈希表进行rehash操作(即重建内部数据结构),此时映射顺序可能会被打乱!
HashMap存放元素是通过哈希算法将其中的元素散列的存放在各个“桶”之间。
5.2.HashMap的继承关系
类 HashMap<K,V>
java.lang.Object
继承者 java.util.AbstractMap<K,V>
继承者 java.util.HashMap<K,V>
类型参数:
K - 此映射所维护的键的类型
V - 所映射值的类型
所有已实现的接口:
Serializable, Cloneable, Map<K,V>
5.3 HashMap API
- 构造函数:
//构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap()
//构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity)
//构造一个带指定初始容量和加载因子的空 HashMap。
HashMap(int initialCapacity, float loadFactor)
//构造一个映射关系与指定 Map 相同的新 HashMap。
HashMap(Map<? extends K,? extends V> m)
- 方法
//从此映射中移除所有映射关系
void clear()
//返回此 HashMap 实例的浅表副本:并不复制键和值本身。
Object clone()
//如果此映射包含对于指定键的映射关系,则返回 true。
boolean containsKey(Object key)
//如果此映射将一个或多个键映射到指定值,则返回 true。
boolean containsValue(Object value)
//返回此映射所包含的映射关系的 Set 视图。
Set<Map.Entry<K,V>> entrySet()
//返回指定键所映射的值;如果对于该键来说,此映射不包含任何映射关系,则返回 null。
V get(Object key)
//如果此映射不包含键-值映射关系,则返回 true。
boolean isEmpty()
//返回此映射中所包含的键的 Set 视图。
Set<K> keySet()
//在此映射中关联指定值与指定键。
V put(K key, V value)
// 将指定映射的所有映射关系复制到此映射中,这些映射关系将替换此映射目前针对指定映射中所有键的所有映射关系
void putAll(Map<? extends K,? extends V> m)
//从此映射中移除指定键的映射关系(如果存在)。
V remove(Object key)
//返回此映射中的键-值映射关系数。
int size()
//返回此映射所包含的值的 Collection 视图。
Collection<V> values()
六. 底层基本实现
6.1 数组 + 链表
HashMap的底层实现是基于数组+链表的形式,数组用以存储链表,是典型的拉链法实现:
//链表节点
static class Node<K,V> implements Map.Entry<K,V> {
//当前节点的哈希值,注意这个值并不是数组下标
final int hash;
//当前节点的key值
final K key;
//当前节点的value值
V value;
//后继节点
Node<K,V> next;
//...
//判断节点之间是否相等
public final boolean equals(Object o) {
if (o == this)
//如果是当前节点,则返回true
return true;
//如果是Map.Entry类型,则当key和value都相等时返回true
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
//用以存储链表节点的数组
transient Node<K,V>[] table;
6.2 两个重要参数:初始容量和加载因子
HashMap 的初始容量和加载因子 由于 HashMap 扩容开销很大(需要创建新数组、重新哈希、分配等等),因此与扩容相关的两个因素:
初始容量(DEFAULT_INITIAL_CAPACITY)
:初始化容量数量.
加载因子(DEFAULT_LOAD_FACTOR)
:决定了 HashMap 中的元素占有多少比例时扩容 成为了 HashMap 最重要的部分之一,它们决定了 HashMap 什么时候扩容.
最大容量(MAXIMUM_CAPACITY)
:HashMap所能存储的最大数据量.
//默认为16,其值一定要为2的次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
6.3 hash(Object o)方法
这是JDK 1.8中HashMap用以求得key哈希值的方法,与JDK 1.7的有一些不同.右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,这将大大地减少哈希冲突,这段求哈希值的代码也可被称为"扰动函数".
详情请参见:https://www.zhihu.com/question/20733617
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
6.4 put(K key, V value)方法
public V put(K key, V value) {
//调用putVal方法
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;
if ((tab = table) == null || (n = tab.length) == 0)
//如果当前HashMap为空, 则调用resize()方法将其n置为初始容量
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//如果tab[i] == null,则直接在这个位置插入新的链表
tab[i] = newNode(hash, key, value, null);
else {
//如果tab[i] != ull, 说明发生了哈希冲突
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果当前需要插入的节点的哈希值和key值都等于tab[i]这个节点的值,说明两者可以直接替换
e = p;
else if (p instanceof TreeNode)
//jdk 1.8更新的红黑树结构
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//寻找链表末尾进行插入
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//已经到了转化为红黑树的阀值,将这条链表转化为红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 已经存在了该节点映射,直接替换该Key的Value即可
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//Fail-fast机制
++modCount;
if (++size > threshold)
//如果增加元素后大小超过了阀值(阀值=加载因子*初始化容量),那么进行两倍扩容
resize();
afterNodeInsertion(evict);
return null;
}
6.5 get(K key)方法
public V get(Object key) {
Node<K,V> e;
//先将key取hash值,调用getNode方法
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 &&
(first = tab[(n - 1) & hash]) != null) {
//(n - 1) & hash获取该链表所处的索引
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
//如果是链头,则直接返回
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
//如果是红黑树的根,则直接返回
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
//通过哈希值和key值不断地在链表中迭代寻找
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
6.6 resize()方法
resize()方法是HashMap的扩容方法, 在进行元素的添加时,如果当前元素的数量已经等于阀值( 加载因子 *初始化容量
) 时,将会对数组容量进行两倍扩容,以下是部分源码解析:
final Node<K,V>[] resize() {
// 获取当前的table数组
Node<K,V>[] oldTab = table;
// 获取当前table数组的链表个数
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 获取当前table数组的扩容阀值
int oldThr = threshold;
// 新数组容量和阀值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
// 当前数组链表个数已经多于HashMap所内容纳的最大数目,不能扩容,返回当前数组
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新阀值为之前的两倍
newThr = oldThr << 1;
}
else if (oldThr > 0)
// 未到达扩容阀值,不进行扩容
newCap = oldThr;
else {
//若当前容量为0,则进行容量的初始化和阀值的初始化
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的链表数组用以存储之前的旧数据,并且容量扩大
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//将老链表数组的值置为null,释放的空间
oldTab[j] = null;
if (e.next == null)
//旧节点插入新数组的位置可能发送改变:
//i = e.hash & (newCap - 1)
//newTab[i] = e
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
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;
}
七. JDK 1.8 新增红黑树结构
HashMap在jdk 1.8之前采用的是常规的 “数组 + 链表” 实现(基于避免哈希冲突的拉链法技术),在Jdk 1.8之后,
HashMap中的节点在特点情况下会变更为红黑树结构(当链表的元素大于8时,将会转变为红黑树存储这条链表的元素),这样
就能够避免在相同哈希值的情况下,即在数组的同一位置,进入过长的链表中进行搜索(在链表中搜索的时间复杂度为 O(n) ,
而在红黑树中搜索的时间能够维持在(logn) ).
7.1 HashMap关于红黑树的主要参数值
TREEIFY_THRESHOLD
:桶的树型化阀值,默认为8;当桶中的元素数量大于这个值时,桶中元素将变为使用红黑树存储.
2.UNTREEIFY_THRESHOLD
:桶在树型化之后,变为链表化的阀值,默认为6.MIN_TREEIFY_CAPACITY
: HashMap采用树型化存储的阀值,默认为64,当HashMap中的元素个数大于这个值时才会出现桶存储结构树型化.
7.2 桶的树形化 treeifyBin()
在Java 8 中,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就使用红黑树来替换链表,从而提高速度。
这个替换的方法叫 treeifyBin()
即树形化。
//将桶内所有的 链表节点 替换成 红黑树节点
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果当前哈希表为空,或者哈希表中元素的个数小于 进行树形化的阈值(默认为 64),就去新建/扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
//如果哈希表中的元素个数超过了 树形化阈值,进行树形化
// e 是哈希表中指定位置桶里的链表节点,从第一个开始
TreeNode<K,V> hd = null, tl = null; //红黑树的头、尾节点
do {
//新建一个树形节点,内容和当前链表节点 e 一致
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null) //确定树头节点
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//让桶的第一个元素指向新建的红黑树头结点,以后这个桶里的元素就是红黑树而不是链表了
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
Thanks:
- 维基百科
- 算法第四版
- https://blog.youkuaiyun.com/u011240877/article/details/52940469
- https://blog.youkuaiyun.com/u011240877/article/details/52929523
- https://blog.youkuaiyun.com/u013074465/article/details/45059639
- https://blog.youkuaiyun.com/u010648555/article/details/60324303
- https://www.zhihu.com/question/20733617