数据结构与算法(java)— HashMap和HashSet底层原理及实现
1. HashMap内部原理及实现
1. 哈希表
哈希表(hash table)也叫散列表,是一种非常重要的数据结构,我们先来看一下其他数据结构的特点。
(1)数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n) 。
(2)链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)。
我们可以发现,数组和链表几乎是两个极端,一个查找效率高,一个插入删除效率高,那么有没有一种数据结构融合两者的优点呢?没错,就是哈希表。
在哈希表中进行添加,删除,查找等操作,性能都非常高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),那么是如何做到的呢?首先,哈希表的主干为数组,例如我们要增加或查找某个元素,我们可以将当前元素通过某个函数映射到数组中的某个位置,通过数组下标直接定位即可。这个函数被称为哈希函数。
哈希函数的设计至关重要,好的哈希函数会尽可能地保证散列的地址分布均匀,但是再好的哈希函数也会出现冲突的情况,比如我们的两个元素通过哈希函数得到同一个存储地址,那么该如何解决呢?哈希冲突的解决方案有很多种,而HashMap采用了链地址法,就是数组+链表的方式,所有通过哈希函数得到同一地址的元素通过链表加在后面即可。
1.2 HashMap实现原理
HashMap是一个存储键值对的集合,允许存储null键和null值,线程不安全。HashTable不允许存储null值,线程安全,效率很差。
HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对。>
//HashMap的主干数组,可以看到就是一个Node数组,初始值为空数组,主干数组的长度一定是2的次幂
transient Node<K,V>[] table;
1.2.1 Node的内部结构
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
final K key;
V value;
Node<K,V> next;//存储指向下一个Entry的引用,单链表结构
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
1.2.2 HashMap中一些重要的字段
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
int modCount;
int size;
Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改。
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量,而modCount字段主要用来记录HashMap内部结构发生变化的次数,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法.
1.2.3 HashMap的hash方法
static final int hash(Object key) {
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//jdk1.7的源码,jdk1.8没有这个方法,jdk1.8直接使用这个算式而没有封装这个方法,但原理都一样
static int indexFor(int h, int length) {
return h & (length-1); //第三步 取模运算
}
我们怎么把生成的hashcode变成数组索引均匀的分布在数组中呢?我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
但是,模运算的消耗还是比较大的,在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。我们看一下原理,n为table的长度
1.2.4 HashMap的put方法实现
思路:
1.对key的hashCode()做hash,然后再计算index;
2.如果没冲突直接放到数组里;
3.如果冲突了,以链表的形式存在当前存在元素后;
4.如果冲突导致链表过长(大于等于TREEIFY_THRESHOLD,默认为8),就把链表转换成红黑树;
5.如果节点已经存在就替换old value(保证key的唯一性)
6.如果数组满了(超过load factor*current capacity),就要resize扩容。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
*生成hash的方法
*/
static