Java语法专题3: HashMap

谈谈 HashMap 的特性

  1. 存储KV键值对, 实现快速存取, key和value都允许为null. key值唯一, 重复则覆盖.
  2. key为null时, 内部使用的是0这个值
  3. 底层数据结构是数组
  4. 非线程安全
  5. 无顺序

谈谈 HashMap 的工作机制

谈谈 HashMap 的底层原理

  1. 从 JDK8 开始采用数组 + 链表 + 红黑树的数据结构, 一直到JDK11基本没变
  2. 存储对象时, 先对键做 hash 计算(基于hashCode), 得到它在bucket数组中的位置, 存储Entry对象
  3. 获取对象时, 同样地先定位到bucket的位置, 再通过键对象的equals()方法找到正确的键值对, 返回值对象

HashMap 中 get 是如何实现的

先对键做 hash 计算(基于hashCode), 得到它在bucket数组中的位置, 然后在这个bucket的树或者链表中遍历查找, 直到找到满足 equals 的节点.

HashMap 中 put 是如何实现的

  1. 计算关于key的hashcode(与Key.hashCode的高16位做异或运算)
  2. 散列表为空时调用resize()初始化散列表
  3. 如果没有发生碰撞,直接添加元素到散列表
  4. 如果发生了碰撞(hashCode值相同), 进行三种判断
    • 若key地址相同或者equals后内容相同,则替换旧值
    • 如果是红黑树结构,就调用树的插入方法
    • 如果是链表结构, 循环遍历: 若遍历到有节点与插入元素的哈希值和内容相同则进行覆盖; 若无相同则尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阈值8.
  5. 如果桶容量超过阀值,则resize进行扩容

HashMap 的大小超过了负载因子定义的容量会怎样处理?

HashMap 的扩容是如何工作的

扩容的时机

HashMap 在初始化数组Table和当数组Table容量达到阈值时, 在putVal函数中触发扩容

阈值的计算

size > load factor * capacity

扩容的过程

扩容需要重新分配一个新数组, 新数组长度是旧数组的2倍, 遍历旧数组,将元素重新hash分配到新结构中去.

HashMap 中 hash 函数是怎么实现的?

hash的计算方法是: 对key对象计算hashCode, 再将 hashCode 与 hashCode 的高16bit做XOR. 如果key是null, 则直接返回0.

以下详细说明

hash 函数的代码

hash计算是 HashMap 实现机制中很重要的一环, 对任何对象(包括null), 返回一个int类型的hash值. 其实现的代码为

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Object 的 hashCode()

底层的实现, 实际上是 Object 的 hashCode() 函数, 这个函数是native的, 由 C/C++ 提供

public native int hashCode();

hashCode()这个函数有三个特性:

  1. 对同一个对象多次调用, 返回的结果一定相同
  2. 对满足equals(Object)的两个对象分别调用, 返回的结果一定相同
  3. 对于不满足equals(Object)的两个对象分别调用, 返回的结果不一定不同

为什么要对 hashCode() 的结果做高16位的异或运算

  1. h >>> 16是无符号位移运算, Java的int是4个字节16bit, 这个运算将高16bit移动到低16bit
  2. HashMap 默认初始化容量为16, 每次自动扩展或者是手动初始化容量时, 必须是2的N次幂
  3. 将结果与自身的高16bit进行XOR运算, 因为HashMap的增长使用容量是2的N次幂, 将这个作为掩码, 如果hash值只在比这个掩码更高位的bit上有变化, 那么产生的结果总是碰撞的. 这时候需要将高位的变化体现到低位上降低碰撞的概率
  4. 位移和XOR是代价最低的运算. XOR some shifted bits in the cheapest possible way to reduce systematic lossage.

在低位产生大量碰撞的例子

如果不做高位异或会造成碰撞的例子是均匀增长的浮点数, 参考以下代码

public static void main(String[] args) {
    float i;
    for (i = 0.1f; i < 1000; i++) {
        System.out.println(Float.valueOf(i).hashCode());
    }
}

i的初始值可以修改为0, 0.1f以及其他值, 观察输出, 可以看到在某些小数部分的情况下(例如0和0.1f), 最低位的结果是固定的0, 2, 4, 6, 8. 如果将上面的循环步长调整为5,

public static void main(String[] args) {
    float i;
    for (i = 0.1f; i < 1000; i = i + 5) {
        System.out.println(Float.valueOf(i).hashCode());
    }
}

可以看到在输出的后半部分, 结果的个位全部为8

...
1146963558
1147045478
1147127398
1147209318
1147291238
1147373158
1147455078

如果在实际应用中正好碰到了这种情况, HashMap 的效率将变得非常低. 如果是 JDK7 没有红黑树结构, 时间复杂度就是O(n), JDK8红黑树结构下对应的时间复杂度也是O(logn).

默认容量为什么是2的N次幂?

为了数据的均匀分布,减少哈希碰撞

确定数组位置是用的位运算, 若数据不是2的次幂, 则会增加哈希碰撞的次数和浪费数组空间

HashMap和HashTable的区别?

相同点

都是存储key-value键值对

不同点

  • HashMap 允许 Key-value为null, HashTable不允许
  • HashMap 线程不安全, HashTable 线程安全的
  • HashMap 继承于AbstractMap, HashTable继承于Dictionary
  • HashMap 的迭代器(Iterator)是fail-fast迭代器, 而 Hashtable 的enumerator迭代器不是fail-fast的. 所以当有其它线程改变了 HashMap 的结构(增加或者移除元素), 将会抛出 ConcurrentModificationException.
  • 容量的初始值和增加方式不一样: HashMap 默认的容量是16, 扩容时每次翻倍. Hashtable 默认容量是11, 扩容时每次将容量变为(原容量 x 2 + 1)
  • Hash值算法不同: HashMap使用自定义的hash方法, Hashtable 直接采用的key的hashCode()

谈谈 HashMap 的 loadFactor 参数的作用

loadFactor 是 HashMap 的负载因子, 表示数组的使用率上限. 默认为0.75, 当容纳的元素已经达到数组长度的75%时, 就会进行扩容

HashMap的缺点, JDK8 为什么引入红黑树?

JDK7的 HashMap 实现是数组+链表, 因为哈希计算存在碰撞的可能性, 当大量的元素都存放到同一个桶中时, 就会形成一条很长的链表, 这时 HashMap 读取时就需要遍历这个链表, 时间复杂度就是 O(n). JDK8 引入红黑树是为了解决这个问题, 将查找时间复杂度为优化到 O(logn).

posted on 2018-04-30 08:34  Milton  阅读(76)  评论(0编辑  收藏  举报

导航