数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。
数组
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
链表
链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。
哈希表
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。
以下主要对JDK1.7和1.8进行介绍对比
JDK1.7:数组+链表
原理: JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低
JDK1.8:数组+链表+红黑树
原理: JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。
差异:
1. 首先头插法改成尾插法,在之前作者认为先插入的有更大几率会被get,但是这样在多线程并情况会导致死锁,于是改成了尾插发避免该情况(后面详谈)
2. 在1.8添加了红黑树,因为1.7单链长度越长,查询效率越慢,而引入红黑树后,将单链变成树,查询效率是根据树的深浅,链表的长度而定,所以增加效率。
HashMap的实现步骤:
接下来主要对HashMap的重要属性名词解释:
public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16---- 默认初始化大小 16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比------负载因子0.75
//当add一个元素到某个位桶,其链表长度达到8时将链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<k,v>[] table;//存储元素的数组
transient Set<map.entry<k,v>> entrySet;
transient int size;//存放元素的个数
transient int modCount;//被修改的次数fast-fail机制
int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容
final float loadFactor;//填充比
1.为什么需要使用加载因子,为什么需要扩容呢?
2.负载因子值的大小,对HashMap有什么影响
每个元素都是链表的数组(Entry数组)
transient Node<k,v>[] table;//存储(位桶)的数组
HashMap的主干是一个Entry数组,里面存放Entry对象,每一个Entry对象包含(key,value)键值対和next指针以及对应的hash值。[jdk1.8之前用的是Entry,jdk1.8之后用的是Node,两者基本等价]
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;-------------每一个Key都会计算出一个hash值
final K key;----------------put中的key值
V value;
Node<K,V> next;-------------下一个结点的指针,就是下一个结点的对象
.........
}
解释:首先获取Key的hash值,查找到指定的数组坐标后,若是该坐标已经存在数据,则形成链表,采用尾插法,插入到上一个对象下面,而上一个对象中的next中存着当前插入的key,也就是指针,而若是同一个坐标中的链表长度超过8,jdk1.8会将此链表转化为红黑树。
以上大概对hashMap的一些重要的属性进行解释 ,接下来对hashmap的一些基本方法原理进行解释
首先对key使用hashcode算出哈希值,通过哈希值来确定具体的数组下标:
例如: hashMap.put(“Java”,0)
此时要插入一个Key值为“Java”的元素,这时首先需要一个Hash函数来确定这个Entry的插入位置,设为index
即 index = hash(“Java”),假设求出的index值为2,那么这个Entry就会插入到数组索引为2的位置
如果索引处的Entry为null的话,则直接在此处插入元素,
如果索引出的Entry不为null的话,通过循环不断遍历链表查找是否有相同哈希值的key,
如果有,再比较两个key的是否相同,当哈希值与key都相同时,则认为是同一个Entry对象并覆盖原对象的value值。
但是HaspMap的长度肯定是有限的,当插入的Entry越来越多时,不同的Key值通过哈希函数算出来的index值肯定会有冲突,此时就可以利用链表来解决。(hash冲突)
HaspMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点,每一个Entry对象通过Next指针指向下一个Entry对象,这样,当新的Entry的hash值与之前的存在冲突时,只需要插入到对应点链表即可。
注意点:
添加到方法的具体操作,在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到Entry[]数组中,先进性扩容操作,空充的容量为table长度的2倍。重新计算hash值,和数组存储的位置
[jdk1.8之前插入链表头部,jdk1.8之后插入链表尾部]
get()方法用来根据Key值来查找对应点Value
当调用get()方法时
例如:
hashMap.get(“apple”),这时同样要对Key值做一次Hash映射,算出其对应的index值,
即index = hash(“apple”)。
获取到‘apple’的哈希值,例如上图apple的哈希为2,于是寻找到数组2的坐标下
然后判断key值是否相等,不相等就往下一个结点做判断,直到key=apple为止。
1. 构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16)
2. 如果Node[]数组中的元素达到(填充比*Node.length)重新调整HashMap大小 变为原来2倍大小,扩容很耗时
3. 新建的hashmap是一种懒加载的机制,当第一次put的时候才进行扩容
4. 散列rehash过程
当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。
在java jdk8中对HashMap的源码进行了优化,在jdk7中,HashMap处理“碰撞”的时候,都是采用链表来存储
当碰撞的结点很多时,查询时间是O(n)。
在jdk8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点较少时,采用链表存储,当较大时(>8个),采用红黑树(特点是查询时间是O(logn))存储(有一个阀值控制,大于阀值(8个),将链表存储转换成红黑树存储)
解释: 当存储的链表中长度大于8会自动转化为红黑树,在1.7是单链表,在查询是根据链表的长度来决定效率,而红黑树通过左大右小的方式形成一棵树,树的长度深度下降 ,效率自然就高了许多。
以下摘取了一些大佬对hashmap的原理解析
问题分析:
你可能还知道哈希碰撞会对hashMap的性能带来灾难性的影响。如果多个hashCode()的值落到同一个桶内的时候,这些值是存储到一个链表中的。最坏的情况下,所有的key都映射到同一个桶中,这样hashmap就退化成了一个链表——查找时间从O(1)到O(n)。
随着HashMap的大小的增长,get()方法的开销也越来越大。由于所有的记录都在同一个桶里的超长链表内,平均查询一条记录就需要遍历一半的列表。
JDK1.8HashMap的红黑树是这样解决的:
HashMap的死锁
HashMap的长度
HaspMap的默认初始长度是16,并且每次扩展长度或者手动初始化时,长度必须是2的次幂。之所以是16,是为了服务于从Key值映射到index的hash算法。
前面说到了,从Key值映射到数组中所对应的位置需要用到一个hash函数:index = hash(“Java”);
那么为了实现一个尽量分布均匀的hash函数,利用的是Key值的HashCode来做某种运算。
因此问题来了,如何进行计算,才能让这个hash函数尽量分布均匀呢?
一种简单的方法是将Key值的HashCode值与HashMap的长度进行取模运算,即 index = HashCode(Key) % hashMap.length,但是,但是!这种取模方式运算固然简单,然而它的效率是很低的, 而且,如果使用了取模%, 那么HashMap在容量变为2倍时, 需要再次rehash确定每个链表元素的位置,浪费了性能。
因此为了实现高效的hash函数算法,HashMap的发明者采用了位运算的方式。那么如何进行位运算呢?可以按照下面的公式:
index = HashCode(Key) & (hashMap.length - 1);
接下来我们以Key值为“apple”的例子来演示这个过程:
计算“apple”的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
HashMap默认初始长度是16,计算hashMap.Length-1的结果为十进制的15,二进制的1111。
把以上两个结果做 与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以看出来,hash算法得到的index值完全取决与Key的HashCode的最后几位。这样做不但效果上等同于取模运算,而且大大提高了效率。
参考优秀文章
文章1
文章2
文章3