Hashtable、 HashMap、 TreeMap都是最常见的一些Map实现,是以键值对的形式存储和操作数据的容器类型。
Hashtable是早期Java类库提供的一个哈希表实现,本身是同步的,不支持null键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
自我补充:遗留的类,不建议使用。
HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable一致,主要区别在于HashMap不是同步的,支持null键和值等。通常情况下, HashMap进行put或者get操
作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个用户ID和用户信息对应的运行时存储结构。
TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、 put、 remove之类操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断。
散列表用的是数组支持按照下标随机访问的数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。
由于数组下标随机访问时间复杂度是O(1)的,hash表的时间复杂度也是O(1)的。
我们知道数组创建是一段连续的内存空间,并且存储相同的数据类型。
如上图,我们存储整型数据数组{500,562,14}.如果知道第0个数组的内存地址,我们就知道1这个下标的内存地址。因为整型数组占用4个字节。
利用这个特性。我们只要知道数组下标,也就是数据在数组中的位置,就可以知道内存地址位置。从而快速访问,时间复杂度O(1)。
但是,如果不是数组下标,只是知道数据的值,那么必须整体遍历一遍复杂度O(n)。
举个例子,如果校园运动会89名选手参加,胸前贴号码牌。这个号码牌就是1-89.
如果将这89名选手放到数组里面,标号为1的选手,放到1的数组下标中,2就放2的下标这样数组下标和标号一一对应了,我们查找的时间复杂度就是O(1)。
当然实际情况hash不会这么简单。
就像现实情况下,参赛的编号可能不是1-89,而是3年二班第二位选手就是我们之前的2号,我们可以表示成030202 -->2 号选手–>数组下标2。我们只需要截取后两位,这样我们还是能找到数组下标2的位置。
我们抽象出来就是一个(key,value)形式。 (“1号选手”,“张三”)
1号选手叫:key 键
张三叫:value值
key的后两位的值叫做:散列值,hash值,hash code
选手编号转化为数组下标的映射方法hash(key),叫做散列函数(Hash函数)。
顺便提一下hash code
key的后两位的值叫做hash code。所以我们需要注意的是,散列值不是内存地址,就是代表对象中hash表的位置,是一个整数。
从整体来看key和table的关系:
java中String的 hash code如何实现的?:
《Effective Java》中提到Hashcode中的约定:
所以根据第三条,我们要是设计一个散列函数,散列函数生成的值要尽可能随机并且均匀分布。
并且给出了一些办法。详细请参考:
《Effective Java》
第二版第三章第9条:覆盖equals是总要覆盖hash code.
工业级别不能像我们描述的那么粗糙,需要满足三个条件:
1.散列非负整数
2.key1 = key2 ,hash(key1) == hash(key2),相同的key的到的散列值也是相同的。
3.key1 !=key2 ,hash(key1) != key2 散列冲突问题。
工业级别的hash算法有:MD5,SHA,CRC。但是工业级别也无法完美解决散列冲突。
3不好理解:如果存储键值对(x,“123”) 通过hash函数hash(x)得到"123"的存储位置。
如果再来一个(y,“456”),hash函数hash(y), 得到的hash值是一样的,这两个对象的存储地址就冲突了。我们叫做散列冲突问题。
解决散列冲突主要有几种:
开放地址法:如果出现散列冲突,我们就重新探测一个空闲的位置,将其插入。
我们可以使用线性探测来插入位置。
橘黄色表示有值,黄色表示没有值。
插入:散列表的大小为10,如果x hash之后,散列到下标7,但是7是橘黄色的有值。所以需要顺序往后找,如果没找到,再次从开始找。
最终找到2的位置。
查找:还是依次查找。
二次探测(Quadratic probing):和线性探测很像。 线性探测每次探测的步长是1,那它探测的下标序列就是hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key)+0,hash(key)+12,hash(key)+22.
双重散列(Double hashing):散列两次。如果散列之后被占用,再次散列。
综合考虑了所有因素,采用链地址法。即,数组(hash)+链表
在散列表中,每个buket或者slot对应一条链表,散列值相同的元素我们放到相同槽位对应的链表中。
顺便提一句,jdk1.8中还添加了红黑树,防止链表过长。
(1)Node[] table:
transient Node<K,V>[] table;
结构:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
(2)加载因子(loadFactor)和边界值(threshold)
int threshold; // 所能容纳的key-value对极限
final float loadFactor; // 负载因子
int modCount;
int size;
Node[] table的初始化长度length(默认值是16)
Load factor为负载因子(默认值是0.75),平衡的选择,内存空间多,对时间效率高,降低load factor.反之增加。
threshold:HashMap所能容纳的最大数据量的Node(键值对)个数,扩容后的HashMap容量是之前容量的两倍。
threshold = length * Load factor
也就是说数组定义好长度后,load facotor越大,容纳键值对越多。
当超过threshold后,就会重新resize(扩容)
size:HashMap中实际存在的键值对数量。
modCount:记录HashMap内部结构发生变化的次数,例如put新的键值对,但是覆盖key对应的value值不变化。
jdk1.8实现:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 通过 putVal 方法中的 (n - 1) & hash 决定该 Node 的存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
当调用put()方法的时候,
key-value对添加到HashMap中,
1.程序首先会根据key的HashCode返回值,
2.再通过hash()方法计算出hash值。
3.接着通过putVal中的(n-1)&hash决定该Node的存储位置。
假设要添加两个对象 a 和 b,如果数组长度是 16,这时对象 a 和 b 通过公式 (n - 1) & hash 运算,也就是 (16-1)&a.hashCode 和 (16-1)&b.hashCode,15 的二进制为 0000000000000000000000000001111,假设对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000,你会发现上述与运算结果都是 0。这样的哈希结果就太让人失望了,很明显不是一个好的哈希算法。
但如果我们将 hashCode 值右移 16 位(h >>> 16 代表无符号右移 16 位),也就是取 int 类型的一半,刚好可以将该二进制数对半切开,并且使用位异或运算(如果两个数对应的位置相反,则结果为 1,反之为 0),这样的话,就能避免上面的情况发生。这就是 hash() 方法的具体实现方式。简而言之,就是尽量打乱 hashCode 真正参与运算的低 16 位。
我再来解释下 (n - 1) & hash 是怎么设计的,这里的 n 代表哈希表的长度,哈希表习惯将长度设置为 2 的 n 次方,这样恰好可以保证 (n - 1) & hash 的计算得到的索引值总是位于 table 数组的索引之内。例如:hash=15,n=16 时,结果为 15;hash=17,n=16 时,结果为 1。
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); // 取模运算
}
jdk1.7取模运算:
&比%具有更高的效率,所以使用&来实现。
h & (table.length -1)来得到该对象的保存位。
HashMap底层数组的长度(length)总是2的n次方
h& (length-1)运算等价于对length取模h%length。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当 table 为 null 或者 tab 的长度为 0 时,即 table 尚未初始化,此时通过 resize() 方法得到初始化的 table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为 tab 的下标 i,并另 p 表示 tab[i],也就是该链表第一个节点的位置。并判断 p 是否为 null
tab[i] = newNode(hash, key, value, null);
//1.1.1、当 p 为 null 时,表明 tab[i] 上没有任何元素,那么接下来就 new 第一个 Node 节点,调用 newNode 方法返回新节点赋值给 tab[i]
else {
//2.1 下面进入 p 不为 null 的情况,有三种情况:p 为链表节点;p 为红黑树节点;p 是链表节点但长度为临界长度 TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap 中判断 key 相同的条件是 key 的 hash 相同,并且符合 equals 方法。这里判断了 p.key 是否和插入的 key 相等,如果相等,则将 p 的引用赋给 e
e = p;
else if (p instanceof TreeNode)
//2.1.2 现在开始了第一种情况,p 是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型 p 后调用 TreeNode.putTreeVal 方法,返回的引用赋给 e
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//2.1.3 接下里就是 p 为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表 / 插入后转红黑树。另外,上行转型代码也说明了 TreeNode 是 Node 的一个子类
for (int binCount = 0; ; ++binCount) {
// 我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount 就是这个计数器
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加 1,而 binCount 并不包含新节点,所以判断时要将临界阈值减 1
treeifyBin(tab, hash);
// 当新长度满足转换条件时,调用 treeifyBin 方法,将该链表转换为红黑树
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;
}
HashMap 也是数组类型的数据结构,所以一样存在扩容的情况。
在 JDK1.7 中,HashMap 整个扩容过程就是分别取出数组元素,一般该元素是最后一个放入链表中的元素,然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标,然后进行交换。这样的扩容方式会将原来哈希冲突的单向链表尾部变成扩容后单向链表的头部。
而在 JDK 1.8 中,HashMap 对扩容操作做了优化。由于扩容数组的长度是 2 倍关系,所以对于假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值和左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引不变,1 的话索引变成原索引加上扩容前数组。
之所以能通过这种“与运算“来重新分配索引,是因为 hash 值本来就是随机的,而 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机分布到不同的索引中去。
这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
参考:
极客时间:《Java核心技术面试精讲》
极客时间:《Java性能调优实战》
极客时间:《数据结构与算法之美》
美团相关博客
Java 8系列之重新认识HashMap
不可不说的Java“锁”事
本笔记根据专栏主题进行学习笔记,虽然参考了许多做了笔记,但是加上了自己的整理,跟原作者的行文可能有很大偏差。如果想查看原版请自行搜索。谢谢