小序: 这个是深入理解HashMap 的第一篇, 因为小生不擅长多线程方面的分析. 所以在这片博客中不涉及HashMap 多线程的讨论, 待小生深入理解了<< java并发>> 再来献丑也不迟.
小生是一名初学者, 如果文中有不准确的地方, 还望诸位前辈多多包涵, 批评指正.
hashCode 的生成
Hashcode 在基于 key-value的集合如:HashMap 相关类中扮演很重要的角色。此外在 HashSet 集合中也会运用到,使用合适的hashcode方法检索的时间复杂度, 在最好情况下是 O(1) .
一个差劲的 hashCode 算法不仅会降低基于哈希集合的性能,而且会导致异常结果。Java应用中有多种不同的方式来生成 hashCode。
hashCode 最佳实现方式
Josh Bloch 在他的《Effective Java》告诉我们重写hashcode方法的最佳实践方式。
一个好的 hashcode 方法通常最好是不相等的对象产生不相等的hash值,理想情况下,hashcode方法应该把集合中不相等的实例均匀分布到所有可能的hash值上面。下面是具体做法.
1.把某个非 0 的常数值,比如 17, 或者是 31 (推荐31, 具体原因后面会讲到 ),保存在一个名为result的int类型的变量中。
2.对于对象中的每个域 f
,做如下操作, 为该域计算 int 类型的哈希值 c::
- 如果该域是 boolean 类型,则计算(f ? 1 : 0)
- 如果该域是 byte、char、short 或者 int 类型,则计算(int)f
- 如果该域是long类型,则计算 (int)(f ^ (f>>>32))
- 如果该域是float类型,则计算Float.floatToIntBits(f)
- 如果该域是double类型,则计算Double.doubleToLongBits(f)
,然后重复第三个步骤。
- 如果该域是一个对象引用,并且该类的 equals
方法通过递归调用 equals
方法来比较这个域,同样为这个域递归的调用 hashCode
,如果这个域为null,则返回0。
- 如果该域是数组,则要把每一个元素当作单独的域来处理,递归的运用上述规则,如果数组域中的每个元素都很重要,那么可以使用 Arrays.hashCode
方法。
把上面每一次计算得到的hash值c合并到result中
// result 初始化为一个非零值.
result = 31 * result + c
String中的Hashcode方法
String
的 hashcode
的算法就充分利用了字符串内部字符数组的所有字符。生成 hashCode 的算法的在 string
类中看起来像如下所示:
public int hashCode() {
int h = hash;
if (h == 0 && count > 0) {
for (int i = 0; i < count; i++) {
h = 31 * h + charAt(i);
}
hash = h;
}
return h;
}
这里的 s
是指该字符串, n
是指字符串的长度.
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
至于为什么要用这种方式. 《Effective Java》中也做出了相应的解释.
The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.
大意是: 选 31 是因为它是一个奇素数, 使用它的时候优点还是不清楚,但是大家走这么干,31 的乘法可以用位运算和减法代替。例如31 * i相当于是 i 左移 5 位减去 i,即
HashMap
对于HashMap
大家一定都不陌生,不管是高级的, 还是初级的程序员基本上使用过。很多公司面试的时候都会聊起,既然HashMap
这么重要,今天我们就一起谈谈这个牛逼的数据结构.
先来看Java 语言中 HashMap 的数据结构, 有图有真相. (图片来自网络, 不知道原创是谁, 但是还是说一下, 对读者的尊重.)
可以看出 HashMap 就是一个数组加一组链表, 互相取长补短, 提高效率(如果不知道数组与链表的优缺点, 直接去面壁就好).
再来一张更加直观的图, 体现了 Entry 的存在, 一个 Entry 封装了 hash
, key
, value
, next
. 不过在 jdk1.8 中, 命名为 Node, 观其大略, 不必纠结于细枝末节.
接下来说一点专(zhuang)业(bi)的东西.
简单地说,HashMap 的 key 做 hash 算法,并将 hash 值映射到内存地址,直接取得/写入 key 对应的 value。 HashMap 大体上就做这样一件事.
HashMap的高性能需要保证以下几点(这些 Sun 公司的大神们都帮我们搞定了, 我们只需看看大神们是怎么做到的, 观其大略即可 = - ):
- key hash 的算法必须是高效.
- hash 值映射到内存地址(数组索引)的算法是快速的.
- 根据内存地址(数组索引)可以直接取得对应的值.
构造函数.
HashMap 提供了四个构造函数, 不过没有必要纠结于实现, 因为不同的 jar 版本具体细节实现相差较大, 但都是对 loadFactor
以及 table
这两个属性初始化. 这里以 Android API 25 中的 HashMap 为例. (晚生是做 Android 开发的, 还请大家谅解)
/**
* The load factor for the hash table.
*/
final float loadFactor;
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE;
// 就贴一个构造函数吧. 实在是没有什么必要, 如果需要自己去看源码吧, 不同的 jar, 不同的 api, 实现都不同.
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); // 分别为4, 0.75
}
1.loadFactor
加载因子.
loadFactor,即散列表的实际元素数目(n)/ 散列表的容量(m)。另外,laodFactor 越大,存储长度越小,查询时间越长。loadFactor 越小,存储长度越大,查询时间短。hashmap默认的是 0.75. 负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是
O(1+a)
。(a 就是装填因子.)
2.capacity
表的长度(也称桶容量).
注释中写的比较明白, 数组的长度必须是
2n
, 原因是因为让取余运算更加的高效, 具体怎么做稍后解释.
3.threshold
表容量上限, 当元素的个数大于该数值时, 扩充容量. 多数情况下等于 capacity * loadFactor. (观其大略即可.)
put()
方法解析
在说put(key, value)
方法之前, 我们先了解下与 key
对象相关的几个值的概念.
- hashCode. Key 对象的 hashCode.
- hash. hash 值, 通过 hashCode 映射得到.
- index. HashMap 中数组的下标, 通过 hash 值取余运算得到.
了解了这几个概念之后, 我们来看一下put() 方法
public V put(K key, V value) {
//确定表的容量, 首次添加元素时候会调用.
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
// 获取key 对象的 hash 值. hash 值不同的jdk, sdk 版本会有不同的实现, 通过一次哈希函数, 让散列值更加散列, 我们只需要了解这个就足够了.
int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
// 通过 hash值, 获取相应的索引.
int i = indexFor(hash, table.length);
// 是否存在相同元素, 如果相同替换.
// 如果hash值相同, 但是 key 不同, 则出现 hash 冲突. 也就是说, 不同的对象出现相同的 hash值.
for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 操作计数, 监测多线程并发调用.
modCount++;
// 添加新的元素, 解决 hash 冲突.
addEntry(hash, key, value, i);
return null;
}
hash 值的确定.
先来聚焦一下这个hash值获取的方法:
// 获取key 对象的 hash 值.
// 不过不可见.
int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);
用 key
的hashCode , 通过某一个 hash 函数做映射, 来确定 hash值. 不同的版本用的 hash 函数不同, 这里不做深究, 随便找两个栗子:
// 这个是 api 15 的 HashMap, api 25 的不可见= =
private static int secondaryHash(int h) {
// Doug Lea's supplemental hash function
// 通过多次异或运算来实现该版本的 hash 函数.
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 这个是选自 api 21 的 hash函数.
private static int secondaryHash(int h) {
// Spread bits to regularize both segment and index locations,
// using variant of single-word Wang/Jenkins hash.
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
来看一下官方的解释.
Applies a supplemental hash function to a given hashCode, which defends against poor quality hash functions. This is critical because HashMap uses power-of-two length hash tables, that otherwise encounter collisions for hashCodes that do not differ in lower or upper bits.
大意是: 为了进一步防止hash冲突. = =
总结一下put() 方法的整个过程: 先通过 key 的 hashCode 做一次 hash 函数, 求出 key 的 hash值, 然后用hash值 求出 hashIndex(这个是桶的index), 找到对应的位置, 遍历该位置对应的 Entry 链表, 如果找到 hash值 相同的 Entry, 那么替换 value; 如果找不到, 那么就添加一个新的 Entry.
NOTE: 这个put() 的过程不包括当size > threshold
时, 扩展桶容量的过程.
HashMap中 table 长度
接下来我们聚焦这一部分代码 (api 25) 的实现 :
//确定表的容量, 首次添加元素时候会调用.
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
深入下去, 看看具体是怎么实现的. inflateTable()
方法中重新确定了 capacity 的大小.
/**
* Inflates the table.
*/
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
// 通过 roundUpToPowerOf() 重新确定容量大小, 满足 capacity = 2^n;
int capacity = roundUpToPowerOf2(toSize);
...
threshold = (int) thresholdFloat;
table = new HashMapEntry[capacity];
}
roundUpToPowerOf2
中, 调用了 Integer 的一个有关位运算的方法. 保留 number 的最高位为1, 其他位全部清零, 然后右移一位(相当于乘 2 操作).
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
int rounded = number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (rounded = Integer.highestOneBit(number)) != 0
? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
: 1;
return rounded;
}
Integer 的功能方法中, 因为整型是 32 位的, 所以通过五次移位和位与运算, 以及一次减法运算. 可以返回最高位数字为1, 其余位数为 0 的位数.
public static int highestOneBit(int i) {
// HD, Figure 3-1
// 从最高为开始, 地位全部置1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
// 保留最高位为1, 其余的全部为0.
// roundUpToPowerOf2() 方法中会做右移一位的操作.
return i - (i >>> 1);
}
I am sorry, 贴出来的源码略多, 不过引用下 Linus Torvalds 的话, “reading the fucking source code!”.
总结下: 把 HashMap 的容量由initialCapacity, 转换为 2 的幂次方, 然后初始化内部的 tables
数组.
index 转换
我们再来看一下将hash 值, 转换为 index 值.
// 通过 hash值, 获取相应的索引.
int i = indexFor(hash, table.length);
这一部分代码貌似不同的版本解决方式都是相同的. 通过位运算取余数.
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
// 因为length 的数值为 2^n 所以, 可以通过这样的方式来取余数.
return h & (length-1);
}
length 为 HashMap 的长度, 满足 2n 所以可以通过:
h & (length -1 ) = h % length
小生不才, 常识来解释一下. 我们都知道计算机中都是用二进制来表示数值. 也就是说 h 和 length 在计算机中可以表示为:
// 假设这里用 16 位来表示一个 int 类型
h = 0b 1001 1110 0010 1101
length = 0b 0000 0000 0000 1000
我们看一看出, h % length 的余数为 0b 0101
也就是 h 的后三位. 因为前十二位是肯定可以被 length 整除的. 所以我们只要求出 h 的后三位即可.
// 这个其实就是取 h 的后三位.
h & (length - 1)
这样子就可以得到余数.
get() 方法解析
get()
方法也会调用 hash 函数计算 hash值, 然后计算数组的index. 先聚焦代码:
PS: 代码来自Android api-25
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
// 计算 hash值
int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
// 通过 index 所以找到链表.
for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
相比于 put()
, get()
方法就简单多了, 先通过 key 的 hashCode, 计算出 key 的 hashIndex, 找到了相应的元素返回, 找不到返回null.
HashMap 与 HashTable 的区别.
仿佛印象中区别就是一个线程安全一个线程不安全, 然后这个时候有人问, 还有呢, 直接懵逼了… 不过说这里 HashTable 已经不推荐使用了, 如果是考虑多线程的话, 官方推荐使用 java.util.concurrent 包下的相关类.
为了防止这种情况再次发生, 我们来了解下这两种不同的实现:
- HashMap 线程不安全, HashTable 线程安全. (这个最基本的).
- HashMap 允许 Key, Value 为
null
. 当Key 为 null 时, Value 存储在 HashMap 的table[0]
中(NOTE:table[0]
中也会存放 Key 不为null
的元素); 而对于HashTable, 如果 Key 或 Value 任意一个为空, 直接NoPointerException
- 在 JDK1.8 和 api-25 中, HashTable 不限制 table 的大小, 确定 index 直接通过
index = hash % table.length
. (其他版本还是忽略, 观其大略) - HashMap 扩展容量每次是 *2, HashTable 则不是(我就看了2个版本, api-21是*2, JDK.1.8 是 *2; 而 api-25 是* 1.5)
- 还有其他的一些细节问题, 版本不同会有差异, 不在深究(吾生也有涯, 而知也无涯, 以有涯追无涯, 殆矣!)