基于哈希表的 Map 接口的实现。此实现提供全部可选的映射操做,并容许使用 null 值和 null 键。(除了非同步和容许使用 null 以外,HashMap 类与 Hashtable 大体相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。 此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操做(get 和 put)提供稳定的性能。迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例。因此,若是迭代性能很重要,则不要将初始容量设置得过高(或将加载因子设置得过低)。前端
先复习一下数据结构java
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);经过给定值进行查找,须要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),固然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提升为O(logn);对于通常的插入删除操做,涉及到数组元素的移动,其平均复杂度也为O(n)node
线性链表:对于链表的新增,删除等操做(在找到指定操做位置后),仅需处理结点间的引用便可,时间复杂度为O(1),而查找操做须要遍历链表逐一进行比对,复杂度为O(n)算法
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操做,平均复杂度均为O(logn)。后端
哈希表:(数组 + 链表)相比上述几种数据结构,在哈希表中进行添加,删除,查找等操做,性能十分之高,不考虑哈希冲突的状况下(后面会探讨下哈希冲突的状况),仅需一次定位便可完成,时间复杂度为O(1),接下来咱们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。数组
它经过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数叫作散列函数,存放记录的数组叫作散列表。白话一点的说就是经过把Key经过一个固定的算法函数(hash函数)转换成一个整型数字,而后就对该数字对数组的长度进行取余,取余结果就看成数组的下标,将value存储在以该数字为下标的数组空间里。安全
当使用hash表查询时,就是使用hash函数将key转换成对应的数组下标,并定位到该下标的数组空间里获取value,这样就充分利用到数组的定位性能进行数据定位。性能优化
当咱们对某个元素进行哈希运算,获得一个存储地址,而后要进行插入的时候,发现已经被其余元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。经过构造性能良好的hash函数,能够减小冲突,但通常不可能彻底避免冲突,所以解决冲突是hash表的另外一个关键问题。 建立和查找hash表都会遇到冲突,两种状况下解决冲突的方法应该一致。bash
哈希冲突的解决方案:数据结构
开放定址法: 这种方法也称再散列法,基本思想是:当关键字key的hash地址p=F(key)出现冲突时,以p为基础,产生另外一个hash地址p1,若是p1仍然冲突,再以p为基础,再产生另外一个hash地址p2,。。。知道找出一个不冲突的hash地址pi,而后将元素存入其中。
线性探测法
链地址法(拉链法,位桶法):将产生冲突的关键字的数据存储在冲突hash地址的一个线性链表中。实现时,一种策略是散列表同一位置的全部冲突结果都是用栈存放的,新元素被插入到表的前端仍是后端彻底取决于怎样方便。
JDK1.7用的是头插法,而JDK1.8及以后使用的都是尾插法,那么他们为何要这样作呢?由于JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。可是在JDK1.8以后是由于加入了红黑树使用尾插法,可以避免出现逆序且链表死循环的问题。
扩容后数据存储位置的计算方式也不同:1. 在JDK1.7的时候是直接用hash值和须要扩容的二进制数进行&(这里就是为何扩容的时候为啥必定必须是2的多少次幂的缘由所在,由于若是只有2的n次幂的状况时最后一位二进制数才必定是1,这样能最大程度减小hash碰撞)(hash值 & length-1)。而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而再也不是JDK1.7的那种异或的方法。可是这种方式就至关于只须要判断Hash值的新增参与运算的位是0仍是1就直接迅速计算出了扩容后的储存方式。
JDK1.7的时候使用的是数组+ 单链表的数据结构。可是在JDK1.8及以后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提升了效率)
为何当桶中键值对数量大于8才转换成红黑树,数量小于6才转换成链表?
由于红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,若是继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度若是是小于等于6,6/2=3,虽然速度也很快的,可是转化为树结构和生成树的时间并不会过短。还有选择6和8,中间有个差值7能够有效防止链表和树频繁转换。
在头结点(为了操做方便,在单链表的第一个结点以前附加一个结点,称为头结点。头结点的数据域能够存储数据标题、表长等信息,也能够不存储任何信息,其指针域存储第一个结点的首地址)H以后插入数据,其特色是读入的数据顺序与线性表的逻辑顺序正好相反
将每次插入的新结点放在链表的尾部,尾插法就是要使后面插入的结点在前一个插入结点和NULL值之间。
主要是为了安全,防止多线程下造成环化 由于resize的赋值方式,也就是使用了单链表的头插入方式(1.8以前),同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,经过从新计算索引位置后,有可能被放到了新数组的不一样位置上。
B的下一个指针指向了A
使用头插会改变链表的上的顺序,可是若是使用尾插,在扩容时会保持链表元素本来的顺序,就不会出现链表成环的问题了
何时进行扩容?也就是resize
有几个因素:
DEFAULT_INITIAL_CAPACITY
位移运算符 效率高
为啥选择16呢?
若是两个元素不相同,可是hash函数的值相同,这两个元素就是一个碰撞
由于把任意长度的字符串变成固定长度的字符串,因此存在一个hash对应多个字符串的状况,因此碰撞必然存在
为了减小hash值的碰撞,须要实现一个尽可能均匀分布的hash函数,在HashMap中经过利用key的hashcode值,来进行位运算 公式:index = e.hash & (newCap - 1)
举个例子: 1.计算"book"的hashcode 十进制 : 3029737 二进制 : 101110001110101110 1001
2.HashMap长度是默认的16,length - 1的结果 十进制 : 15 二进制 : 1111
3.把以上两个结果作与运算 101110001110101110 1001 & 1111 = 1001 1001的十进制 : 9,因此 index=9
hash算法最终获得的index结果,取决于hashcode值的最后几位
为了推断HashMap的默认长度为何是16 如今,咱们假设HashMap的长度是10,重复刚才的运算步骤: hashcode : 101110001110101110 1001 length - 1 : 1001 index : 1001
再换一个hashcode 101110001110101110 1111 试试: hashcode : 101110001110101110 1111 length - 1 : 1001 index : 1001
从结果能够看出,虽然hashcode变化了,可是运算的结果都是1001,也就是说,当HashMap长度为10的时候,有些index结果的出现概率 会更大而有些index结果永远不会出现(好比0111),这样就不符合hash均匀分布的原则.
在使用是2的幂的数字的时候,Length-1的值是全部二进制位全为1,这种状况下,index的结果等同于HashCode后几位的值。只要输入的HashCode自己分布均匀,Hash算法的结果就是均匀的。能够下降hash碰撞的概率。
言归正传,到底何时进行扩容呢,假定默认长度就是16,负载因子0.75f,16 * 0.75 = 12
, 那么在put第13个的时候就会进行resize。
扩容:建立一个新的Entry空数组,长度是原数组的2倍。
ReHash:遍历原Entry数组,把全部的Entry从新Hash到新数组。
为何要从新Hash呢,不直接复制过去?
由于长度扩大之后,Hash的规则也随之改变。
Hash的公式---> index = HashCode(Key) & (Length - 1)
由于在java中,全部的对象都是继承于Object类。Ojbect类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。在未重写equals方法咱们是继承了object的equals方法,那里的 equals是比较两个对象的内存地址。
== 比较的是两个对象的地址
复制代码
在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证经过equals判断相等的两个对象,调用hashCode方法要返回一样的整数值。而若是equals判断不相等的两个对象,其hashCode能够相同(只不过会发生哈希冲突,应尽可能避免)。
在结合HashMap说一下,HashMap是经过key的hashCode去寻找index的,那index同样就造成链表了,也就是说”张三“和”李四“的index多是同样的,在一个链表上的。咱们去get的时候,他就是根据key去hash而后计算出index,找到了index,那我怎么找到具体的”张三“和”李四“呢?就是用到了equals方法!虽然它们的hashCode同样,可是他们并不相等。
红黑树也叫自平衡二叉查找树或者平衡二叉B树。二叉树不用多说,二叉查找树(Binary Search Tree),(又:二叉搜索树,二叉排序树)它或者是一棵空树,或者是具备下列性质的二叉树: 若它的左子树不空,则左子树上全部结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上全部结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。
时间复杂度为O(log n)
高度h <= log2(n+1)
HashMap中的代码
TreeNode继承自LinkedHashMap中的内部类——LinkedHashMap.Entry,而这个内部类又继承自Node,因此算是Node的子类。parent用来指向它的父节点,left指向左孩子,right指向右孩子,prev则指向前一个节点(原链表中的前一个节点),注意,这些字段跟Entry,Node中的字段同样,是使用默认访问权限的,因此子类能够直接使用父类的属性。
将节点以某个节点为中心向左或者向右进行旋转操做以保持二叉树的平衡
左旋:
咱们要对节点A执行左旋的操做,那咱们就须要执行接下来的几个操做:
①将A的右子树设置为D;
②若是D不为空,则将D的父节点设置为A;
③将C的父节点设置为A的父节点;
④若是A的父节点为空,则将C设置为root节点,若是A为父节点的左子树,则将C设置为A父节点的左子树,若是A为父节点的右子树,则将C设置为A父节点的右子树;
⑤将C的左子树设置为A;
⑥将A的父节点设置为C。
执行完成后的树形结构以下图:
动图展现:
右旋:
①将A的左子树设置为E;
②若是E不为空,则将E的父节点设置为A;
③将B的父节点设置为A的父节点,若是A的父节点为空,则将B设置为root节点,若是A为父节点的左子树,则将B设置为A父节点的左子树,若是A为父节点的右子树,则将B设置为A父节点的右子树;
④将B的右子树设置为A;
⑤将A的父节点设置为B。
执行完成后的树形结构以下图:
动图展现:
插入的时候作了哪些动做?
找了一个牛X的流程图
① 判断键值对数组table[i]是否为空或为null,不然执行resize()进行扩容;
② 根据键值key计算hash值获得插入的数组索引i,若是table[i]==null,直接新建节点添加,转向⑥,若是table[i]不为空,转向③;
③ 判断table[i]的首个元素是否和key同样,若是相同直接覆盖value,不然转向④,这里的相同指的是hashCode以及equals;
④ 判断table[i] 是否为treeNode,即table[i] 是不是红黑树,若是是红黑树,则直接在树中插入键值对,不然转向⑤;
⑤ 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操做,不然进行链表的插入操做;遍历过程当中若发现key已经存在直接覆盖value便可;
⑥ 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,若是超过,进行扩容。
源码:
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("a",1);
public V put(K key, V value) {
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;
// 步骤①:tab为空则建立
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null作处理(n 为数组长度)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K, V> e;
K k;
// 步骤③:节点key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 步骤④:判断该链为红黑树
else if (p instanceof TreeNode)
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);
//链表长度大于8转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key已经存在直接覆盖value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 步骤⑥:超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
index计算方式:
index = hashCode(key) & (当前table长度length - 1)
复制代码
扩容:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 若是老的容量大于0
if (oldCap > 0) {
// 若是容量大于容器最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 阀值设为int最大值
threshold = Integer.MAX_VALUE;
// 返回老的数组,再也不扩充
return oldTab;
}// 若是老的容量*2 小于最大容量而且老的容量大于等于默认容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新的阀值也再老的阀值基础上*2
newThr = oldThr << 1; // double threshold
}// 若是老的阀值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
// 新容量等于老阀值
newCap = oldThr;
else { // 若是容量是0,阀值也是0,认为这是一个新的数组,使用默认的容量16和默认的阀值12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 若是新的阀值是0,从新计算阀值
if (newThr == 0) {
// 使用新的容量 * 负载因子(0.75)
float ft = (float)newCap * loadFactor;
// 若是新的容量小于最大容量 且 阀值小于最大 则新阀值等于刚刚计算的阀值,不然新阀值为 int 最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将新阀值赋值给当前对象的阀值。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 建立一个Node 数组,容量是新数组的容量(新容量要么是老的容量,要么是老容量*2,要么是16)
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将新数组赋值给当前对象的数组属性
table = newTab;
// 若是老的数组不是null
if (oldTab != null) {
// 循环老数组
for (int j = 0; j < oldCap; ++j) {
// 定义一个节点
Node<K,V> e;
// 若是老数组对应下标的值不为空
if ((e = oldTab[j]) != null) {
// 设置为空
oldTab[j] = null;
// 若是老数组没有链表
if (e.next == null)
// 将该值散列到新数组中
newTab[e.hash & (newCap - 1)] = e;
// 若是该节点是树
else if (e instanceof TreeNode)
// 调用红黑树 的split 方法,传入当前对象,新数组,当前下标,老数组的容量,目的是将树的数据从新散列到数组中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 若是既不是树,next 节点也不为空,则是链表,注意,这里将优化链表从新散列(java 8 的改进)
// Java8 以前,这里曾是并发操做会出现环状链表的状况,可是Java8 优化了算法。此bug再也不出现,但并发时仍然不建议HashMap
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 这里的判断须要引出一些东西:oldCap 假如是16,那么二进制为 10000,扩容变成 100000,也就是32.
// 当旧的hash值 与运算 10000,结果是0的话,那么hash值的右起第五位确定也是0,那么该于元素的下标位置也就不变。
if ((e.hash & oldCap) == 0) {
// 第一次进来时给链头赋值
if (loTail == null)
loHead = e;
else
// 给链尾赋值
loTail.next = e;
// 重置该变量
loTail = e;
}
// 若是不是0,那么就是1,也就是说,若是原始容量是16,那么该元素新的下标就是:原下标 + 16(10000b)
else {
// 同上
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 理想状况下,可将原有的链表拆成2组,提升查询性能。
if (loTail != null) {
// 销毁实例,等待GC回收
loTail.next = null;
// 置入bucket中
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
复制代码
HashMap 的查找操做比较简单,
先定位键值对所在的哈希桶数组的位置
table[index]的首个元素是否和key同样(hash、equals都相等),若是相同则返回该value
若是不相同判断首个元素的类型,而后再对链表或红黑树进行查找。
源码:
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put("a",1);
hashMap.get("a");
public V get(Object key) {
Node<K,V> e;
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;
// 1. 定位键值对所在桶的位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//2.哈希桶的首个元素是否和key同样(hash、equals都相等),若是相同则返回该value
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 3. 若是 first 是 TreeNode 类型,则调用黑红树查找方法
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 4. 对链表进行查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
复制代码
有时间后续在更详细的分析源码部分。