哈希表(hashMap)又叫散列表
在讨论哈希表原理之前,先看一下常用数据结构在新增,查找等基础操作对比执行性能
上图从数据结构上分析时间复杂度,明显看出哈希表的时间复杂度是很低的(不考虑冲突的情况下)
什么是哈希表
散列表(也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。
哈希冲突
public static void main(String[] args) {
System.out.println("--------------hashmap--------------");
HashMap maps=new HashMap();
maps.put("1", "1");
maps.put("6", "6");
maps.put("3", "3");
maps.put("7", "7");
maps.put("2", "2");
maps.keySet().stream().forEach(m->{
System.out.println(maps.get(m));
});
}
--------------hashmap--------------
1
2
3
6
7
从上面对于这个例子来看
以jdk1.8为例
hashmap的主干是Node数组,node是每个 数组的基本单元,里面包括hash值, key value 并包含 下个node节点(所以说 hashmap是一个单链表数组)
/**
*表,在第一次使用时初始化,并将其大小调整为必要的。分配时,长度总是2的幂。
*(在某些操作中,我们也允许长度为零,以允许当前不需要的引导机制。)
*/
transient Node<K,V>[] table;
Node 节点单元
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;
}
/**
*基于哈希表的映射接口实现。这个实现提供了所有可选的map操作和许可证
*null值和null键(哈希图类大致相当于哈希表,只是
*这个类不保证地图的顺序;特别是,它不能保证将随时间保持不变。
*
*此实现为基本操作(get和put),假定哈希函数在桶中适当分散元素。迭代结束
*集合视图需要与视图的“容量”成比例的时间HashMap实例(bucket的数量)加上它的大小(bucket的数量键值映射)。因此,设置初始值是非常重要的
*如果迭代性能不好,容量太高(或负载系数太低)都会导致时间复杂度降低。
*
*
HashMap的实例有两个参数影响其性能:初始容量和负载系数。这个容量是哈希表中的存储桶数,以及
*容量只是创建哈希表时的容量。这个加载因子是允许哈希表的填充程度的度量
*在其容量自动增加之前获取。当哈希表中的条目超过了加载因子和当前容量时,哈希表被重新格式化(即内部数据)结构)以便哈希表具有大约两倍的
*铲斗数量。
*
*
一般来说,默认负载系数(.75)提供了良好的性能时间和空间成本之间的权衡。较高的值会降低
*空间开销,但会增加查找成本(反映在大多数类的操作,包括获取和放置)。中的预期条目数
*在安装时应考虑地图及其荷载系数设置其初始容量,以尽量减少再冲洗操作。如果初始容量大于最大条目数除以负荷系数,无需再灰分
*行动永远不会发生。
*
*
如果要在HashMap中存储许多映射例如,使用足够大的容量创建它将允许存储映射要比让它执行更有效
*根据需要自动重新灰化以增大桌子。注意,使用许多具有相同{@code hashCode()}的键肯定会减慢速度
*降低任何哈希表的性能。当钥匙如果{@link Comparable},这个类可以使用
*帮助打破关系的钥匙。
*
*
请注意,此实现不同步。
*如果多个线程同时访问哈希映射,则线程从结构上修改映射,它必须外部同步(结构修改是指任何操作
*添加或删除一个或多个映射的;只是改变了价值与实例已包含的键关联的不是
*结构修改)这通常由在自然封装地图的某个对象上进行同步。
*
*如果不存在这样的对象,则应该使用
*{@link Collections#synchronizedMap Collections.synchronizedMap}
*方法。这最好在创建时完成,以防止意外对地图的非同步访问:
*Map m=Collections.synchronizedMap(新HashMap(…))预处理>
*
*这个类的所有“集合视图方法”返回的迭代器是否快速故障:如果在故障发生后的任何时间对地图进行结构修改
*迭代器是以任何方式创建的,除了通过迭代器自己的
*remove方法,迭代器将抛出{@link ConcurrentModificationException}。因此,面对
*修改后,迭代器会快速而干净地失败,而不是冒着
*在一个不确定的时间里的任意的,不确定的行为
*未来。
*
请注意,不能保证迭代器的快速失败行为一般说来,不可能在未来作出任何硬性保证
*存在未同步的并发修改。失败快速迭代器尽最大努力抛出ConcurrentModificationException。
*因此,编写依赖于此的程序是错误的其正确性例外:迭代器的快速失败行为应仅用于检测错误。
*
This class is a member of the
*
* Java Collections Framework.
*
* @param the type of keys maintained by this map
* @param the type of mapped values
*
* @author Doug Lea
* @author Josh Bloch
* @author Arthur van Hoff
* @author Neal Gafter
* @see Object#hashCode()
* @see Collection
* @see Map
* @see TreeMap
* @see Hashtable
* @since 1.2
*/
注释分析
在分析该篇文章前我们看一下实现的注意事项
/*
*实现注意事项。
*
*这个映射通常充当一个装箱的哈希表,但是当箱子变得太大时,它们会被转化为垃圾箱
*树节点,每个树节点的结构与java.util.TreeMap。大多数方法尝试使用普通的垃圾箱,但是适用时,中继到树节点方法(仅通过检查
*节点的实例)。树状物箱可以穿过与其他方法一样使用,但还支持更快的查找当人口过剩时。然而,由于绝大多数垃圾箱
*正常使用不会过多,检查是否存在在表方法的过程中,树容器可能会被延迟。
*树仓(即其元素均为树节点的仓)是主要按hashCode排序,但在tie的情况下,如果是两个元素属于相同的“C类”,
*然后使用compareTo方法进行排序(我们通过反射保守地检查泛型类型以验证这个——请参见方法CompariableClassfor)。增加的复杂性
*在提供最坏情况O(logn)时,树仓的数量是值得的当键具有不同的哈希或
*因此,在这种情况下,性能会优雅地下降hashCode()方法的意外或恶意使用
*返回分布不均匀的值,以及哪些键共享一个哈希码,只要它们也是
*可比(如果这两个都不适用,我们可能会浪费一个月的时间在时间和空间上两个因素与不采取行动相比
*注意事项。但已知的唯一案例来自于糟糕的用户编程实践已经非常缓慢,这使得
*差别不大。)
*因为树节点的大小是常规节点的两倍,所以我们仅当容器包含足够的节点以保证使用时才使用它们
*(参见TREEIFYèu阈值)。当它们变得太小(由于移除或调整大小)它们被转换回普通垃圾箱。在
*使用分布良好的用户hashcode,树容器很少使用。理想情况下,在随机哈希码下
*箱中的节点遵循泊松分布
* (http://en.wikipedia.org/wiki/Poisson_distribution)带着
*默认调整大小的参数平均约为0.5 阈值为0.75,但由于调整粒度。忽略方差,预期列表大小k的出现次数为(exp(-0.5)*pow(0.5,k)/阶乘(k))。第一个值是:
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
*更多:不到千万分之一
*树bin的根通常是它的第一个节点。然而,有时(当前仅在Iterator.remove上),根可能位于其他位置,但可以通过父链接恢复(方法TreeNode.root())。
*所有适用的内部方法都接受哈希代码作为参数(通常由公共方法提供),允许
*它们可以在不重新计算用户哈希码的情况下互相调用。大多数内部方法也接受“tab”参数,即通常是当前表,但可能是新表或旧表
*调整大小或转换。当垃圾箱列表被树化、拆分或未经审核时,我们会保留它们具有相同的相对访问/遍历顺序(即字段
*Node.next)以更好地保留局部性,并简化处理调用
*迭代器.remove。在插入时使用比较器,以保持总订购量(或尽可能接近此处要求)再平衡,我们比较类和身份密码系紧断路器。
*简单模式和树模式之间的使用和转换是由于子类LinkedHashMap的存在而变得复杂。看到了吗对于定义为在插入时调用的钩子方法,
*允许LinkedHashMap内部否则的话,它们将与这些力学无关(这也是要求将映射实例传递给某些实用程序方法
*可能会创建新节点。)
*像基于SSA的编码风格这样的并发编程很有帮助避免在所有扭曲指针操作中出现别名错误。
*/
第一段说明为什么要使用红黑树
红黑树的插入、删除和遍历的最坏时间复杂度都是log(n),因此,在意外或者恶意使用导致hashCode()方法返回值的分布很糟糕,以及在那些许多key共享一个hashCode的情况下,只要Key具有可比性,性能的下降将会是"优雅"的。(如果这两种方法都不适用,与不采取预防措施相比,我们可能会浪费大约两倍的时间和空间。但目前所知的唯一案例来自于糟糕的用户编程实践,这些实践已经非常缓慢,以至于没有什么区别。)
第二段说明链表转化为红黑树的阈值设置为8
当hashCode离散性很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中,几乎不会有bin中链表长度会达到阈值。但是在随机hashCode下,离散性可能会变差,然而JDK又不能阻止用户实现这种不好的hash算法,因此就可能导致不均匀的数据分布。不过理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。所以选择8。
初始化容量(数组的容量大小)
/**
* 默认初始容量-必须是2的幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; aka 16
为什么默认初始容量-必须是2的幂,
初始化链表数组长度
else { // zero initial threshold signifies using defaults
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[] newTab = (Node[])new Node[newCap];
这里如果是默认值的话添加值,创建一个和容量大小的数组长度也就是16.也就是整个hashmap第一次扩容,在最理想的情况下 链表存11个数据,然后每个数组格存一个也就是15个 总的就是26个;因此扩容条件一是要到扩容的阈值,然后并且添加数据的数组格不为空
最大容量(数组的容量大小)
/**
*如果隐式指定了更高的值,则使用最大容量由带参数的构造函数之一。
*必须是2的幂<=1<<30。
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
为什么最大容量1 << 30,我想它有两个点值得思考
扩容加载因子
/**
* 构造函数中未指定时使用的负载因子。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子是表示Hsah表中元素的填满的程度
链表转换树的阈值
/**
* 这个值是为存储箱(链表)使用树不是列表的存储箱计数阈值,当将元素添加到至少有这么多节点的容器中时,容器将转换为树
* 并且值必须大于2,且至少应为8,以符合中的假设关于转换回普通链表的树木移除收缩。
*/
static final int TREEIFY_THRESHOLD = 8;
这个阈值至少大于8的原因
为什么不一开始就直接使用红黑树而不用阈值
要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。
树转换链表的阈值
/**
*存储过程中取消检测(拆分)存储箱的存储箱计数阈值
*调整大小操作。应小于阈值,并且多数6目筛下用收缩检测去除。
*/
static final int UNTREEIFY_THRESHOLD = 6;
为什么不直接用上面 TREEIFY_THRESHOLD 进行树的收缩
想的主要原因还是防止频繁的转换,从而导致速率降低
最小树容量
/**
*最小的树容量,箱子可以被树化。(否则,如果bin中的节点太多,则会调整表的大小。)
*应至少为4*TREEIFY_THRESHOLD阈值以避免冲突在调整大小和树化阈值之间。 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
*/
static final int MIN_TREEIFY_CAPACITY = 64;
桶中的Node被树化时最小的hash表容量。
下面属性就不怎么分析,在实现hashmap时,具体用这些属性进行存储记录,都是比较基础,需要在应用时才能看到使用地方
/**
* 以node节点的数组,用于扩容 数组等
*/
transient Node<K,V>[] table;
/**
* 保留缓存的 entrySet(). 请注意,使用了AbstractMap字段
* 的 keySet() and values() 方法
*/
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
/**
* 要调整大小的下一个大小值(容量*负载系数)。
*
* @serial
*/
// (序列化后javadoc描述为true。
// 此外,如果尚未分配表数组,则
// 字段保存初始数组容量,或零表示
// 默认容量(初始容量)
int threshold;
无参构造方法
/**
* 用默认的初始容量构造一个空的HashMap
* (16) 和默认加载因子为 (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段均为默认值
}
有参构造方法
/**
* 用指定的初始值构造一个空的HashMap
* 容量和默认负载系数(0.75)。
*
* @param initialCapacity the initial capacity.
* @throws IllegalArgumentException if the initial capacity is negative.
*/
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);
}
tableSizeFor方法
该方法用于进行找到大于等于initialCapacity的最小的2的幂,结合上面的容量解释就想得明白,提高查询效率 减少hash碰撞
/**
* 返回给定目标容量的二次幂。
*/
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;
}
需要理解上面的代码,我们先了解一下位运算(>>>)这个是什么
>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0
比如5的二进制是101,5>>>2表示右移2位,变成001,即为1
按二进制形式把所有的数字向右移动对应位数,低位移出(舍弃),高位的空位补零。
从另一个角度来分析,它向右移动2 位,其实就是除以2 的2 次方
然后在理解该代码的意思
综上可得,该算法让最高位的1后面的位全变为1。最后再让结果n+1,即得到了2的整数次幂的值了。由于int是32位,所以>>>16便能满足。
下面有两个需要注意的点
其次这种方法的效率非常高,也是得益于位运算操作
从put方法中可以看出 分为两个步骤 一是根据key计算出hash值 、二 增加value值
/**
* 将指定值与此映射中的指定键相关联。
* 如果映射以前包含键的映射,则旧的值被替换。
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with key, or
* null if there was no mapping for key.
* (A null return can also indicate that the map
* previously associated null with key.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
计算hashcode值
/**
* 计算key.hashCode()并将哈希的高位(XOR)展开降低。因为这个表使用了两个掩蔽的幂,所以
* 仅在当前掩码上方的位中变化的哈希将总是碰撞(在已知的例子中有一组浮点键把连续的整数放在小表格里。)所以我们
* 应用扩展高位影响的变换向下。在速度、效用和效率之间有一个折衷钻头扩展质量。因为许多常见的散列
* 已经合理分配(因此不要从中受益因为我们用树来处理大量的在箱子里发生碰撞时,我们只是对箱子里的一些移位位进行异或运算
* 减少系统性损失的最便宜的方法,以及合并最高位的影响,否则由于表边界的原因,不能在索引计算中使用
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扰动函数
只做一次16位右位移异或混合,而不是四次。具体的可以看看
JDK 源码中 HashMap 的 hash 方法原理是什么?
putVal方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//声明局部变量 tab(散列表), p 链表节点,n 记录散列表的长度 i 得到数组指针
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 将 成员属性中的散列表节点赋值给局部变量 并判断数组节点下是否为空,(也就是判断数组节点下是否是第一次增加数据)
if ((tab = table) == null || (n = tab.length) == 0)
//resize 方法创建一个二次幂整数的散列表 (默认为16)
n = (tab = resize()).length;
//判断当前指针下的table数组值是否为空并赋值给局部p链表,(n - 1) & hash等价于hash % n ,jdk1.7就是这么写的 取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)
if ((p = tab[i = (n - 1) & hash]) == null)
//创建一个新的节点(链表节点),赋值给当前数组下
tab[i] = newNode(hash, key, value, null);
else {//代表要插入的数据所在的位置是有内容的,则就要做判断处理
//声明了一个节点, 泛型
Node<K,V> e; K k;
if (p.hash == hash && //判断插入数据的 hash 和当前位置hash是否相等
//判断插入key值和当前节点的key值相等,那就替换值
((k = p.key) == key || (key != null && key.equals(k))))
//直接替换了节点
e = p;
else if (p instanceof TreeNode)//当前节点的 key 和要插入的 key 不一样,判断当前节点是红黑树
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) // 判断节点是否超过默认阈值
treeifyBin(tab, hash);//超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容而不转换树
break;
}
//如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,只做替换,并跳出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { //key已经存在
V oldValue = e.value;//将当前节点的值赋值给 oldvalue
if (!onlyIfAbsent || oldValue == null)
e.value = value; //将当前要插入的 value 替换当前的节点里面值
afterNodeAccess(e); //回调以允许LinkedHashMap发布操作 主要是通知 LinkedHashMap将节点移到最后一个
return oldValue;
}
}
++modCount;//操作数增加1
if (++size > threshold)
resize();//如果当前的 hash表的长度已经超过了当前 hash 需要扩容的长度,然后进行重新扩容(首次增加数据不会走这里,在2步骤已经扩容)
afterNodeInsertion(evict);//调用LinkedHashMap判断是否把可能把老大除掉
return null;
}
上面的注释做一个拆分
a.判断节点是否超过默认阈值,超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容而不转换树
b. 如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,只做替换,并跳出循环
这篇分析jdk1.8的hashmap的源码,比jdk1.7的源码看来痛苦多,我的直观感受在于,多了很多位运算,不能直接看懂;还有在 判断语句中赋值 例如 if ((tab = table) == null || (n = tab.length) == 0) ;但我觉得还是有很多值得我们深研的地方,包括hashmap虽然允许我们存储空元素 ,但还是别把数据设置为空好点,希望各位码农一起研究一起提高开发水平。
还有其余的 扩容,红黑树转换 get方法,我放到另外篇文章深究
Java 集合深入理解 (十二) :HashMap之扩容 功能