JDK1.8集合之HashMap

185 阅读4分钟

静态成员变量

// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子(以前的版本也有叫加载因子的)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 这是一个阈值,当桶(bucket)上的链表数大于这个值时会转成红黑树,put方法的代码里有用到
static final int TREEIFY_THRESHOLD = 8;
// 也是阈值同上一个相反,当桶(bucket)上的链表数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中bin的数量大于了TREEIFY_THRESHOLD且桶的数量大于MIN_TREEIFY_CAPACITY,才会链表转红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

内部Node节点类

// 链表节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V 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;
    }
}
// 红黑树节点,LinkedHashMap.Entry<K,V> 继承 Node<K,V>
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

成员变量

// 存储元素的数组,长度总是2的幂次方
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
// 实际存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器  (修改次数)
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;

数据结构

数组 + 链表的结构实现的。每个数组单元中存放着一个单链表的头结点,其中每个结点是一个Node对象,通过Node对象封装Key-Value键值对,还有一个Node<K,V> next指向链表中下一个结点。HashMap采用拉链法解决哈希冲突,jdk1.8开始当桶里链表的元素数据>=8且桶的数量大64(MIN_TREEIFY_CAPACITY)会将链表转成红黑树

寻址、碰撞检测、扩容机制

  • 寻址
  //先用hash()函数对key的hashcode进行高低半区异或运算,提高hashcode低半区的随机性
  //再与数组长度-1进行位与运算得到下标
  hash(key.hashCode()) & (table.length-1)

HashMap源码中模运算逻辑是把散列值和数组长度-1做一个位与操作

这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为数组长度-1相当于一个低位掩码右边的值全是1。位与操作的结果就是散列值的高位全部归零,只保留低位值.以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做位与操作如下,结果就是截取了最低的四位值。h & (length - 1) h % length,它俩是等价不等效的,明显位运算效率非常高。

  01111010 00111100 00100101
& 00000000 00000000 00001111
----------------------------------
  00000000 00000000 00000101  //高位全部归零,只保留末四位
  • 碰撞检测
    这得说说java对于对象的equals()与hashCode()的规定

    equals()相等的两个对象,hashcode()一定要确保相等
    hashcode()相等的两个对象,equals()不一定相等

HashMap是根据key的hashcode确定散列表的下标位置的,如果HashMap中有两个key的hashcode相等或计算出来的下标相等,但equals不相等说明发生了碰撞

  • 扩容机制
    存入元素大于 > table.length* loadFactor进入扩容,倍增扩容table.length << 1

hash算法

HashMap的hash函数作用就是提高hash值的随机性,从而降低碰撞几率,实现如下

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 右位移16位,正好是32bit的一半,高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来
 // (h = key.hashCode()) ^ (h >>> 16) 效果如下
   01111010 001111 00 00100101 11000101   // 原始哈希码 (key.hashCode()) 
 ^ 00000000 00000000 01111010 00111100   //原始哈希码的高半区(key.hashCode()>>>16) 
-------------------------------------------------------------------
 = 01111010 00111100 01011111 11111001   // 高半区信息完整保留,并加大低半区的随机性
  • 设计者考虑到现在的hashCode分布的已经很不错了,而且当发生较大碰撞时也用树形存储降低了冲突。仅仅异或一下,少了系统的开销,也不会造成因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞

为什么HashMap的初始容量是16?

HashMap的长度必需是2的n次方(2,4,8,16,32,64,....)或者1,这跟列表运算及扩容有关,默认16是基于性能跟空间的考量值

  • hashmap通过key的hashcode & (length-1) 得到散列表下标,因为2的n次方减1的二进制表现为右边的值全是1,相当取模的效果,且比模运算性能好
  • 扩容运算简单,只需要左移一位 16<<1 = 32

建议 初始容量(initialCapacity) = 需要存储的元素个数/负载因子(loadFactor) + 1, loadFactor默认0.75

实际上initialCapacity不管传什么值HashMap会采用第一个大于该数值的2的幂作为初始化容量,以下是HashMap的内部实现方法:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

图解HashMap实现原理

put操作

put完成后最终结果

get操作

Thanks