一、什么是哈希表
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
哈希表:其实它的存储结构就是数组,但是和普通数组的索引不同,不是简单的0-length-1,而是根据其hash值算出它的落点。相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
二、什么是哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀,但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。
三、Map及其子类继承关系
(1) HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
(2) Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
(3) LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
(4) TreeMap:TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。
四、HashMap基本字段
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 该节点的hash值
final K key; // 存储的Key
V value; // 存储的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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
}
// HashMap类成员变量
transient Node<K,V>[] table; // 数组,初始化默认长度是16
transient int size; // 当前key-value对数
final float loadFactor; // 负载因子,默认0.75f
int threshold; // 是否要扩容的临界值,表示最多容纳多少对key-value,其值=table.length * loadFactor
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。换句话说,数组的长度越长,产生hash冲突的可能性就越低,增删改查效率就会越高,但是对内存消耗就越大。所以,数组初始化长度需要取一个合理值,在时间空间和内存上起到一个平衡作用。
五、源码
// put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 计算hash值
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
举个例子:
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为空,则创建一个默认长度为16的数组,这个值可以通过initialCapacity修改
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 判断数组的某个位置内容是否为空
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 &&
((k = p.key) == key || (key != null && key.equals(k))))
// 节点key已经存在,直接覆盖
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) {
// 找到链表最后一个节点,添加一个新的Node进去(JDK1.8)
// JDK1.8前,添加的元素会放在链表的头部
p.next = newNode(hash, key, value, null);
// 链表长度大于等于8,转为红黑树
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;
}
}
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;
}
putVal对应的逻辑图如下:
六、扩容机制resize
// newCapacity-新容量
void resize(int newCapacity) {
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; // 初始化一个新的Entry数组
transfer(newTable); // 将数据转移到新的Entry数组里
table = newTable; // HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);// 修改阈值
}
图解,扩容后rehash的位置变化。n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n从16变成32,即扩容了2倍,所以元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。假设原元素处于index=5的位置,那么扩容后,就有两种可能性,第一种还是在5这个位置,第二种是在5+16这个位置。是属于那种可能性,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话还在原位置,是1的话索引变成“原位置+oldCap”
七、数组table长度为什么一定要2的n次冥?
1、减少哈希冲突
length 为偶数时,length-1 为奇数,奇数的二进制最后一位是 1,这样便保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(这取决于 h 的值),即 & 运算后的结果可能为偶数,也可能为奇数,从而使结果分布得更加均匀,减少哈希冲突。
2、提高扩容效率
比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)
八、为什么HashMap是线程不安全的?
1、put的时候,多线程导致数据不一致
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2、get操作因为多线程扩容而导致死循环
在扩容的时候,jdk1.8之前是采用头插法,当两个线程同时检测到hashmap需要扩容,在进行同时扩容的时候有可能会造成链表的循环,主要原因就是,采用头插法,新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
假设数组的长度为2,在index=1的位置,链的结构是(3,value)->(7,value),再添加一个key-value就要扩容了。
此时,线程t1和线程t2都在执行扩容操作。比如t1执行到上面的代码Entry<K,V> next = e.next;时,时间片用完了,此时e=(3, value),next=(7,value) 。线程t2被调度并顺利执行完扩容resize操作,那么此时数组的长度是4,并且index=3的位置的链的结构是(7,value)->(3,value)(JDK1.8前都是插入头部)。接着t1重新被调度运行,要往下执行代码,e.next = newTable[i]; ,结果是(3,value)->newTable[3](即(7,value)),newTable[3]=(3,value),所以结构变成了index=3的位置,(3,value)->(7,value)->(3,value),陷入了一个死循环的情况。