HashMap底层原理初探

143 阅读13分钟

阅读文章需要的知识点

  • 位运算:源码、反码、补码,与(&)、或(|)、非(!)、异或(^)的运算、左移(<<)、右移(>>)、无符号右移(>>>)

开始

从HashMap的基本使用来逐步分析:

HashMap map = new HashMap();
map.put("name", "chen");

默认初始化大小

首先是初始化,HashMap在初始化的时候如果不指定容量大小,那么默认就是16,从HashMap源码中:

// The default initial capacity - MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为什么要写成1 << 4的形式,而不是直接写成 = 16?

准备测试代码:

public static void main(String[] args) {
    int a = 16;
    int b = 1 << 4;
    System.out.println(a);
    System.out.println(b);
}

编译成class文件后再看,内容是:

public static void main(String[] var0) {
    byte var1 = 16;
    byte var2 = 16;
    System.out.println(var1);
    System.out.println(var2);
}

看class可以发现赋值在编译期就已经完成,所以1<<4和直接赋值16都是一样的

结合源码注释,写成1<<4就是为了提醒读者HashMap的容量是2的幂,扩容方式也是2的幂。


最大容量

static final int MAXIMUM_CAPACITY = 1 << 30;
为什么是1 << 30,而不是1 << 31或者1 << 32?

1 << 32,因为在Java中int是32位的,所以可以左移的范围是0-31,左移32相当于左移0,即没有移位,所以1 << 32 = 1 << 0 = 1。最大容量设为1很明显不合理,所以不行。

1 << 31,通过分析可以知道结果是-2147483648,所以也不行。

那就只有 1 << 30 = 1073741824了。

为什么非要用左移来获取最大容量呢

那就涉及到为什么HashMap的容量为什么一定要是2的幂了,后面会说到。


HashMap添加元素的底层实现详解

现在看第二行,将新的元素put进 HashMap 里面,先看源码:

/**
* 涉及到的全局变量
*/

// 存放元素的节点数组,在首次使用时初始化,在需要时调整大小,并且大小总是2的幂
// transient关键字:被修饰的变量,在序列化对象的时候,不会序列化该变量
transient Node<K,V>[] table;

// 键值对的数量,即元素数量
transient int size;

// 记录HashMap结构被修改的次数
// 涉及到Fail-Fast机制,即快速失败机制,是Java集合中的一种错误检查机制
transient int modCount;

// 当HashMap的size大于threshold时会执行resize即扩容
// threshold = capacity * loadFactor
int threshold;

// 扩容因子,默认等于DEFAULT_LOAD_FACTOR,即0.75
final float loadFactor;

// 当节点的链表长度超过多少时就转成红黑树
static final int TREEIFY_THRESHOLD = 8;

/**
* 函数说明
*/

// 添加元素进来
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // tab:当前桶
    // p:point,哈希计算后桶在该索引上的元素
    // n:桶的容量
    // i:哈希计算后的索引
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 当前桶是否为空,是就初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算出的索引位置上是否有值,没有直接写入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else { // 索引位置上已经有值了,即哈希冲突,这里做处理
        // e:声明一个元素节点node;k:接收p的key值
        Node<K,V> e; K k;
        // 哈希冲突,并且key值相等不为null,就赋值给e,后面根据这个e不为空做是否覆盖旧值的处理
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果该节点是红黑树,就用红黑树添加元素的方式
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 该节点是链表,用链表添加元素的方式
            // 遍历链表
            for (int binCount = 0; ; ++binCount) {
                // 找到这个节点,也就是链表的最后一个元素
                if ((e = p.next) == null) {
                    // 将元素写入到节点/链表的后面,形成/修改链表
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度大于TREEIFY_THRESHOLD,即8,就转换成红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 在遍历链表的时候找到相同的key,即已经有相同key的元素了,就直接break
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 改变p的指针,让循环可以继续,即遍历的条件
                p = e;
            }
        }
        
        // e != null,说明存在相同的key,要覆盖该元素的值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent:如果当前位置已存在一个值,是否替换,false替换,true不替换 
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 后面说(搜索关键字:afterNode)
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 记录集合的修改次数,涉及到Fail-Fast机制
    ++modCount;
    // 当HashMap的size满足扩容条件后扩容
    if (++size > threshold)
        resize();
    // evict:表是否正在初始化,false表示是在初始化
    // 后面说(搜索关键字:afterNode)
    afterNodeInsertion(evict);
    return null;
}
HashMap中的三个方法

在上面HashMap添加元素的源码中有这三个方法:

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

这三个方法是表示在访问/插入/删除某个节点之后会进行一些处理,在HashMap中是空方法没有处理,但是在LinkedHashMap中实现了该方法,LinkedHashMap正是通过重写这三个方法来保证了链表的插入/删除的有序性,请自行阅读源码。

hash方法中的(h = key.hashCode()) ^ (h >>> 16)是什么意思

HashMap在添加元素的时候,需要先计算出该元素的索引位置,那么 key.hashCode() 不够用吗,为什么还需要做一步这样的操作:

(h = key.hashCode()) ^ (h >>> 16)

首先在putVal方法中看获取索引的代码: if ((p = tab[i = (n - 1) & hash]) == null),注:n为HashMap的容量

为什么是 (n - 1) & hash 呢?假设HashMap容量是16,那么索引范围就是:0 - 15,要计算hash在的索引位置,就要用hash值对15做取余运算,因为这样计算出来的结果一定在0 - 15以内,不会有索引越界的情况,即:

hash % (n - 1)

但是在Java中可以用位运算(&)来代替取余运算(%),因为位运算直接对内存数据进行操作,不需要转成十进制,所以速度特别快:

a % b = a & (b - 1)

这就是 (n - 1) & hash 的目的。

然后回到hash方法中,key.hashCode()已经可以获取到哈希值了,为什么还要做异或无符号右移16位呢

首先是异或的原因,我们来看这样的例子

// 与运算,都为1,结果才为1,否则为0
0 & 1 = 0
1 & 0 = 0
0 & 0 = 0
1 & 1 = 1

// 或运算,有一个1就为1,两个0才是0
0 | 1 = 1
1 | 0 = 1
0 | 0 = 0
1 | 1 = 1

// 异或运算,相同为0,不同为1
0 ^ 1 = 1
1 ^ 0 = 1
0 ^ 0 = 0
1 ^ 1 = 0

我们发现,&运算的值会偏向0,|运算的值会偏向1,异或运算的值比较平均,在计算hash获取索引上,我们期望能够有更加散列的结果,尽可能的减少哈希冲突,所以对hashCode进行异或运算正是这个目的。

然后是无符号右移16位,我们从上面的位运算可以知道,HashMap是通过i = (n - 1) & hash获取索引的,即将 (n - 1)hash 做一次与运算,我们以示例来说明:

假设HashMap容量是16,元素的key是hello world

String str = "hello world";
System.out.println(Integer.toBinaryString(str.hashCode()));
元素key的hashCode是:
01101010 11101111 11100010 11000100

(n - 1) = 16 - 1 = 15,15的hashCode是:
00000000 00000000 00000000 00001111

直接拿来计算索引,即与运算的话:
01101010 11101111 11100010 11000100
00000000 00000000 00000000 00001111 &(与运算,都为1,结果才为1,否则为0)
-----------------------------------
先不论结果,可以发现在HashMap的容量较小时,hash的前16位甚至更多都是没有用到的,这样不充分的计算容易导致哈希冲突的产生,于性能不利。

我们对hashCode进行无符号右移16位,再和hashCode本身进行异或运算:
00000000 00000000 01101010 11101111 (hashCode >>> 16)
01101010 11101111 11100010 11000100 (hashCode)
----------------------------------- ^(异或运算,相同为0,不同为1)
01101010 11101111 10001000 00101011
得到的结果高16位是不变的,新值的低16位是把原来的高16位和低16位都利用了进来通过异或运算,相比仅通过hashCode & (n - 1),能得到更加散列的值,让索引更加均匀的分布,尽可能减少哈希冲突的发生

这样当HashMap的容量较小,处于低16位时,能够与hashCode的高低位都进行计算,
当HashMap的容量足够大,处于高16位时同理,
主要的目的就是尽量充分的和hashCode的每一个位都进行计算,这样得到的索引值才能尽可能的理想。

注:假如高16位都是0会怎么样?因为0异或任何数都等于任何数本身,所以不会影响。

知识点
  • 位运算

什么是Fail-Fast

集合Collection中的一种错误机制,集合类中有一个modCount属性,当集合进行add/remove等操作时会发生改变,集合通过迭代器遍历元素时会检查这个元素,当遍历时modCount发生了改变,就会抛出ConcurrentModificationException异常。

如何避免Fail-Fast
  1. 在迭代过程中使用迭代器的remove()方法,但是这种方式有局限性,只能删除当前遍历的元素

  2. 使用Java并发包(java.util.concurrent)中的类来代替ArrayList或者HashMap。比如使用CopyOnWriteArrayList代替ArrayList,它是写时复制的容器,在读写时是线程安全的,在进行add/remove等操作时,并不是在原数组上进行修改,而是将原数组拷贝一份,在新数组上修改,待完成后才将指向旧数组的引用指向新数组,所以对于CopyOnWriteArrayList在迭代过程并不会发生fail-fast。但CopyOnWriteArrayList容器只能保证数据的最终一致性,不能保证数据的实时一致性。

    ConcurrentHashMap代替HashMap,它采用了锁机制,是线程安全的。在迭代中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,而是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变,但同样不能保证数据的实施一致性。

知识点
  • CopyOnWriteArrayList
  • ConcurrentHashMap
  • 数据的最终一致性和实施一致性
Fail-Fast总结
  1. 在集合进行增删改查或其他修改集合元素的方法时,会记录修改次数即modCount的值
  2. 在调用迭代器时,会将这个modCount值赋予给迭代器中的expectedModCount
  3. 在调用迭代器的过程中,每次执行next()都会检测expectedModCount和modCount是否相等
  4. 在迭代过程中调用迭代器的remove()方法不会造成ConcurrentModificationException异常
  5. 在迭代过程中调用集合的remove()方法会造成ConcurrentModificationException异常

扩容因子为什么默认为0.75

HashMap在达到扩容因子条件时扩容,扩容因子默认为0.75,即当HashMap中元素达到容量的75%就进行扩容。由此可以分析:

  • 假设loadFactor为1,初始容量为16,那么元素数量为16的时候HashMap才会扩容。首先要知道HashMap在put元素的时候会对元素key进行hash取值,计算出存放的索引位置,而哈希冲突是无法避免的,所以当容量满了才扩容,很可能会出现集合内同一个索引位置上元素过多,形成了链表或者红黑树,导致查询效率降低。不过好处也有,就是空间利用率高了,基本不会有内存空间被浪费。
  • 假设loadFactor为0.5,初始容量为16,那么元素数量为8的时候HashMap就会扩容,此时相比loadFactor为1,哈希冲突会减少很多,因为有足够的位置可以存放元素,自然也不会形成太多的链表或者红黑树,查询效率高了,但是带来的问题也很明显,就是内存利用率不高,本来1M的数据需要2M的空间来存放。

由此可以知道为什么loadFactor默认为0.75,这正是在空间和时间上做的一个妥协。

知识点
  • 哈希冲突
  • HashMap计算哈希获取索引位置

为什么HashMap的容量是2的幂

我们知道索引的计算方法是(n - 1) & hash,上面已经说了这里就不罗嗦了,然后2的幂有2,4,8,16,32等,那么(n - 1)的二进制有:

00000000 00000000 00000000 00000001
00000000 00000000 00000000 00000011
00000000 00000000 00000000 00000111
00000000 00000000 00000000 00001111
00000000 00000000 00000000 00011111

与运算是都为1结果才是1,否则是0,那么上面的数与hash进行与运算的时候,当hash的位上为1时结果为1,为0时结果为0,是比较均匀的。

现在假设容量不是2的幂,而是2的幂 +1,就有3,5,9,17,33,他们的(n - 1)的二进制有:

00000000 00000000 00000000 00000010
00000000 00000000 00000000 00000100
00000000 00000000 00000000 00001000
00000000 00000000 00000000 00010000
00000000 00000000 00000000 00100000

这时候和hash进行与运算的时候,会出现大量的0,极为容易出现哈希冲突,所以要用2的幂。

为什么默认容量是16

既然是2的幂,为什么不是4,8,32,会是16呢,我认为这是根据经验推出来的,我做了一个测试:

long start = 0;
long count = 1000000;
{
    Map map = new HashMap();
    start = System.currentTimeMillis();
    for (int i = 0; i < count; i++) {
        map.put(String.valueOf(i), i);
    }
    System.out.println("不指定size的时间:" + (System.currentTimeMillis() - start));
}
{
    Map map = new HashMap(4);
    start = System.currentTimeMillis();
    for (int i = 0; i < count; i++) {
        map.put(String.valueOf(i), i);
    }
    System.out.println("指定size为4的时间:" + (System.currentTimeMillis() - start));
}
{
    Map map = new HashMap(8);
    start = System.currentTimeMillis();
    for (int i = 0; i < count; i++) {
        map.put(String.valueOf(i), i);
    }
    System.out.println("指定size为8的时间:" + (System.currentTimeMillis() - start));
}
{
    Map map = new HashMap(16);
    start = System.currentTimeMillis();
    for (int i = 0; i < count; i++) {
        map.put(String.valueOf(i), i);
    }
    System.out.println("指定size为16的时间:" + (System.currentTimeMillis() - start));
}
{
    Map map = new HashMap(32);
    start = System.currentTimeMillis();
    for (int i = 0; i < count; i++) {
        map.put(String.valueOf(i), i);
    }
    System.out.println("指定size为32的时间:" + (System.currentTimeMillis() - start));
}

结果为:

不指定size的时间:188
指定size为4的时间:148
指定size为8的时间:101
指定size为16的时间:129
指定size为32的时间:670

可以看到连续存放一百万条数据的时候,效率最高的是size为8的,其次是16

我想在实际使用中设置为8容易造成扩容,所以就以16这个不大不小的值作为默认值了。

链表和红黑树的数据结构

这部分内容比较多,后面再更。

参考资料

HashMap为啥初始化大小是16

Fail-Fast机制总结

java中的fail-fast(快速失败)机制

Java8 - LinkedHashMap源码

java 位与 取模_【Java】使用位运算(&)代替取模运算(%)

一文回答面试必问的 HashMap 原理和部分源码解析