自动扩容和函数函数待补充
哈希表介绍
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
哈希表是一种数据结构,它可以提供快速的插入操作和查找操作。不论哈希表中有多少数据,插入和删除(有时包括删除)只需要接近常量的时间,即O(1)时间级。哈希表也有一些缺点:
1、它基于数组的,数组创建后难于扩展。某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清除表中将要存储多少数据(或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程)。
2、没有一种简便的方法可以已任何一种顺序(例如从小到大)遍历表中数据项。
冲突
对不同的关键字可能得到同一散列地址,这种现象称为冲突。而不发生冲突的可能性是非常之小的,所以通常对冲突进行处理。常用方法有以下几种:
开放地址法
在开放地址法中,若数据不能直接放在由哈希函数计算出来的小标所指的单元时,就要寻找数组的其他位置。下面介绍下开发地址法的三种方法:
-
线性探测
在线性探测中,线性地查找空白单元。如果5421是要插入数据的位置,它已经被占用了,那么就是5422,然后是5423,依次类推,数据下标一直递增,直到找到空位。
-
二次探测
在开放地址法的线性探测中会发生聚集。一旦聚集形成,它会变得越来越大。二次探测是防止聚集产生的一种尝试,思想是探测相隔较远的单元,而不是和原始位置相邻的单元。
在线性探测中,如果哈希函数计算的原始下标是x,线性探测就是x+1,x+2,x+3,依此类推。而在二次探测中,探测的过程是x+1,x+4,x+9,依此类推,到原始位置的距离是步数平方。
-
再哈希法
二次探测虽然解决原始聚集,但也带来二次聚集。比如将184,302,420和544依次插入到表中,它们都映射到7。那么302需要以一为步长的探测,420需要以四为步长的探测,544需要以九为步长的探测。只要有一项,其关键字映射到7,就需要更长步长的探测。这个现象叫做二次聚集。
再哈希法可以解决原始聚集和二次聚集,把关键字用不同的哈希函数再做一遍哈希,用这个结果作为步长。对指定的关键字,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。
链地址法
在哈希表每个单元中设置链表,某个数据项的关键字还是通常一样映射到哈希表的单元,而数据项本身到这个单元的链表中,其他同样的映射到这个位置的数据项只需要加到链表中。
JDK 1.8 HashMap源码实现
数据结构
JDk 1.8 HashMap是数组+链表+红黑树实现的,如下所以

(1)从源码可知HashMap中使用数组为Node<K,V>[] table,即哈希桶。Node源码如下:
static class Node<K,V> implements Map.Entry<K,V> {
// 用来定位数组索引位置
final int hash;
final K key;
V value;
// 链表的下一个node
Node<K,V> next;
}
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。
(2)Java中HashMap采用了链地址法解决哈希冲突。
初始化过程
HashMap提供了4种构造函数,其源码如下:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
// 返回最接近(大于或等于)指定cap大小的2次方,比如cap = 7,则返回8
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;
}
可以发现构造函数的作用仅是对loadFactor(负载因子)或threshold赋值,并没有初始化哈希桶。分析源码可知初始化是由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;
// oldCap > 0表示table已初始化了
if (oldCap > 0) {
}
// 设定了threshold的值
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 默认情况即没有设定threshold的值
else {
// DEFAULT_INITIAL_CAPACITY = 16,DEFAULT_LOAD_FACTOR = 0.75f
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;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
return newTab;
}
如果指定了threshold的值,table的length(长度)就是threshold;如果没有,则table的length默认为16。初始化过程就是创建table数组,并计算触发下一次扩容的阀值。
在HashMap中,哈希桶table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考blog.csdn.net/liuqiyao_01…,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
put方法
HashMap的put方法执行过程如下:
(1).判断键值对数组table[i]是否为空或为null,否则通过resize()进行初始化;
(2).根据键值key的hash值计算得到插入的数组索引i,如果table[i]==null,直接新建节点添加,如果table[i]不为空,转向(3);
(3).判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向(4)
(4).判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向(5);
(5).遍历table[i],如果遍历过程中若发现key已经存在直接覆盖value即可;如果没有,则在链表末尾插入键值对并判断链表长度是否大于8,大于8的话把链表转换为红黑树;
(6).插入成功后,判断实际存在的键值对数量size是否超多了threshold,如果超过,进行扩容。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table为null或空,则进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 对应哈希槽tab[i]为null,直接添加
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 1、tab[i]存储node节点的key与当前要存储的key是否相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 2、tab[i]存储node节点的类型是否为TreeNode类型
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3、如果不满足以上两种条件,则遍历node节点会存在两种情况。(1)遍历过程中发现存在与当前要存储key相同的节点;
// (2)不存在与当前要存储key相同节点,将当前节点存储链表的末尾
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果链表长度大于TREEIFY_THRESHOLD,则将其转换为红黑树结构。
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;
}
}
// 当e不为null时表示当前要存储key已存在,更新原来key的value值并直接返回之前的value值。
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// size表示map中存储键值对的个数,是否扩容
if (++size > threshold)
resize();
// 空实现
afterNodeInsertion(evict);
return null;
}
get方法
get()方法执行流程如下:
(1)、根据关键字key的hash值计算得到存储在哈希槽的节点first,如果first.key等于关键字key,则直接返回;如果不是,转向(2);
(2)、判断first节点类型是否TreeNode(前提是first存在next节点,不存在直接返回null),如果是,直接从红黑树中查找;如果不是,遍历查找;
(3)、如果以上情况没有找到对应的node,直接返回null。
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) {
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 {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
自动扩容
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。

经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的?
在并发下HashMap的自动扩展操作可能使Node链表形成环形数据结构,一旦形成环形数据结构,Node的next节点永远不为空,就会在获取Node时产生死循环。