探索JDK源码-每行代码堪称教科书级别(2)HashMap.java(上)

本文详细解析了HashMap的工作原理,包括其内部结构、扩容机制、哈希碰撞处理及解决策略。介绍了HashMap如何通过链表和红黑树解决哈希冲突,以及不同构造方法的使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

HashMap对Java开发来说肯定是不陌生的了,而且面试基本逃不开‘谈谈你对HashMap或ConcurrentHashMaprentHashMap的理解’。因为HashMap的应用场景实在太多了,在存储数据方面我们可以把它当作一个缓存,即使我们存储的数据量十分之大,HashMap的扩容机制也能很好的帮我们解决按需扩容问题,其次不考虑哈希碰撞的情况下,它的存、取、删操作的时间复杂度均是O(1),这都是因为哈希表带来的高效。

基础知识

在开始之前需要对源码中的一些基本知识有所了解:

  1. 重要的几个数值

载入因子,决定了每一次扩容时的阈值。为0时导致无法扩容,无穷大又没意义,所以这两种情况都是不允许的。虽然这里定义了final类型却没有初始化,其实是没问题,因为只要在所有构造函数中对它赋值就不会报错了。

/**
 * The load factor for the hash table.
 */
final float loadFactor;

相对当前容量{@link HashMap#capacity()}的阈值,超过该阈值就需要扩容,但是这个阈值不会超过允许的int的最大值2147483647

/**
 * The next size value at which to resize (capacity * load factor).
 */
int threshold;
  1. HashMap的扩容机制

每当Hash表的大小超过了当前的阈值(threshold),会以2次幂的倍数扩大Hash表的容量(左移一位),并更新阈值为当前最大容量的loadFactor倍。所以Hash表的最大容量扩大规律为0,1,2,4,8,16…如此下去的。但并不是无尽头的扩大,对它的限制是:

 // 二次幂扩容,所以这里只左移30位=1073741824
 static final int MAXIMUM_CAPACITY = 1 << 30;
/**
 * Returns a power of two size for the given target capacity.
 * 这个算法是为了给Hash表确定一个2的次幂大小的容量
 */
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只能存储10个亿的数据吗?答案并非如此,因为这里面还涉及到哈希碰撞的情况。

哈希碰撞

  1. 先看HashMap是如何生成哈希值的
static final int hash(Object key) {
    int h;
    // h >>> 16 = h的高16位
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

其实就是通过对key取hashCode之后,用这个hashCode的高16位和低16异或(都为1时得0,其它情况都得1),这样就使得对一个key的哈希值的影响只取决于16位,所以哈希得分布会变得密集,也就是所谓得冲突。但这在速度和质量上是比较权衡的,合并了高16位后这16位将不会对索引产生影响。

  1. 接着看HashMap是如何确定从哈希值到哈希表的索引的
/**
 * 我们模拟一次索引计算
 * table是一个表,你可以暂时把它当作一个哈希表(其实是一个节点表,之后再讲)
 * 有一个key
 */
int size = table.size();		// 哈希表的大小,其实和目前HashMap的最大容量是一样的(先简化)
								// 因为涉及到put方法时对容量的一些列处理
int hash = hash(key);			// key的哈希值
int index = (size - 1) & hash;	// key在哈希表中的索引

对二进制计算熟悉的应该看完这个模拟就明白是怎么一回事了,不懂得继续看:

首先可以确定的是这个hash必须是在已知大小为size的表中,那么它的索引就必须最大为(size-1);其次是与运算,比如我们用具体值来计算一遍:

size = 8;
// key1
hash1 = 128; // 二进制表示 [1000 0000]
index1 = (8 - 1) & 128 = 0111 & 1000 0000 = 0;
// key2
hash2 = 8;	// 二进制表示 [1000]
index2 = (8 - 1) & 6 = 0111 & 1000 = 0;

这样看来key1和key2的就发生哈希碰撞了,因为出现同一个索引。

  1. HashMap如何解决哈希冲突

学过数据结构应该对链表不陌生吧,可以把一个数据看作一个节点Node:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;	// 节点的哈希值
    final K key;	// 节点的key
    V value;		// 节点的值
    Node<K,V> next;	// 链表的下一个节点
    ......
}

对于同一个索引值的所有节点,会被放到一个链表中。这样我们的哈希表存储的其实就是一个个链表的其实节点而已,通过确定一个索引值可以找到其所在链表的其实节点,然后遍历该链表就可以找到想要的节点了。

这里有一个问题,如果冲突非常密集,导致同一个哈希值的链表长度很长,那查找、插入和删除节点就会比较耗时,针对这个问题,还有另外一种数据结构:树(二叉树/红黑树)

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;	// 父节点
    TreeNode<K,V> left;		// 左叶子节点
    TreeNode<K,V> right;	// 右叶子节点
    TreeNode<K,V> prev;		// 前一个节点,删除当前节点时需要取消与prev的链接
    boolean red;			// 是否是红色节点
    ......
}

红黑树在查找、插入、删除节点的时间复杂度是比较平衡的,源码中会根据哈希冲突的情况选择普通的链表结构或者树结构,而且随着数据的插入、删除,这样的结构是动态变化的,比如这段源码:

/**
 * If the current tree appears to have too few nodes,
 * the bin is converted back to a plain bin. (The test triggers
 * somewhere between 2 and 6 nodes, depending on tree structure).
 * 如果节点数量很少(测试是2到6个,取决于树的结构),将变为普通结构。
 */
if (root == null || root.right == null ||
    (rl = root.left) == null || rl.left == null) {
    // 将树结构非树化,变为链表
    tab[index] = first.untreeify(map);  // too small
    return;
}

无根节点、根节点无右子节点、根节点无左子节点、根节点的左子节点没有左子节点。这些情况都会被认为节点数量过少,没必要继续使用树结构。

构造HashMap

讲了这么多的细节,当然最后要看一下一个HashMap到底可以怎样被构造了:

// 1. 指定一个初始容量和载入因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}
// 2. 指定一个初始容量,载入因子使用默认的0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 3. 不指定数值,所有参数都使用默认的值
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}
// 4. 通过已有的map初始化一个包含这个map的HashMap,但是它的键和值的类型要合法
public HashMap(Map<? extends K, ? extends V> map) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(map, false);
}

小结

这只是初步探索了一下HashMap的部分源码,要知道HashMap有2400多行代码,想要一下子就探索完是很难的。所以这里先做一个基础知识又是最重要的知识的了解,也是为后面进一步探索HashMap的put、get、replace、move等方法的最佳实践打个基础吧。

声明

目前除了需要继续这个JDK源码专题的探索,因为自己还是一个安卓开发者,接下来可能还会做一个安卓方面的专题(包括Kotlin、Flutter等)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值