深入了解HashMap(1)

来源:互联网 发布:志鸿优化答案查询 编辑:程序博客网 时间:2024/06/06 06:06

    • hashCode 的生成
      • hashCode 最佳实现方式
      • String中的Hashcode方法
      • HashMap
      • 构造函数
      • put方法解析
        • hash 值的确定
        • HashMap中 table 长度
        • index 转换
      • get 方法解析
      • HashMap 与 HashTable 的区别

 
     小序: 这个是深入理解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方法

     Stringhashcode 的算法就充分利用了字符串内部字符数组的所有字符。生成 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,即 31i==(i<<5)i。现在的虚拟机都报我们搞定了这种优化。
 

HashMap

     对于HashMap大家一定都不陌生,不管是高级的, 还是初级的程序员基本上使用过。很多公司面试的时候都会聊起,既然HashMap这么重要,今天我们就一起谈谈这个牛逼的数据结构.

     先来看Java 语言中 HashMap 的数据结构, 有图有真相. (图片来自网络, 不知道原创是谁, 但是还是说一下, 对读者的尊重.)

hashmap

     可以看出 HashMap 就是一个数组加一组链表, 互相取长补短, 提高效率(如果不知道数组与链表的优缺点, 直接去面壁就好).

     再来一张更加直观的图, 体现了 Entry 的存在, 一个 Entry 封装了 hash, key, value, next. 不过在 jdk1.8 中, 命名为 Node, 观其大略, 不必纠结于细枝末节.

hashmap_entery

     接下来说一点专(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 1101length = 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)
  • 还有其他的一些细节问题, 版本不同会有差异, 不在深究(吾生也有涯, 而知也无涯, 以有涯追无涯, 殆矣!)
2 0
原创粉丝点击