从内存到磁盘:数据结构选型——ArrayList、HashMap

25 阅读25分钟

引言:数据结构没有好坏,只有场景。

世界上没有绝对的事情,所有事情,所有的东西都是相对而言。

就像是选择笔记本电脑一样,

轻薄本 不能兼顾太多的性能,但是轻薄本易于携带,更加轻便

游戏本/性能本 重量较大,不方便携带,但是提供了更强大的性能

不同场景中有不同的选择,没有绝对一说

CPU 缓存的“偏爱”

内存与缓存

相关文章 Hello 算法 - 内存与缓存*

我们知道,数据结构中的“数组”与“链表”由于它们采用两种相反的存储策略,因此各种性质和操作效率也呈现对立的特点。

在计算机物理层中,硬盘,内存,缓存 都能够存储数据,但是他们都有对应的特点

****硬盘内存缓存
用途长期存储数据,包括操作系统、程序、文件等临时存储当前运行的程序和正在处理的数据存储经常访问的数据和指令,减少 CPU 访问内存的次数
易失性断电后数据不会丢失断电后数据会丢失断电后数据会丢失
容量较大,TB 级别较小,GB 级别非常小,MB 级别
速度较慢,几百到几千 MB/s较快,几十 GB/s非常快,几十到几百 GB/s

多层级的设计并非偶然,而是计算机科学家和工程师们经过深思熟虑的结果。

  • 硬盘难以被内存取代。内存中的数据在断电后会丢失,它不适合长期存储数据。
  • 缓存的大容量和高速度难以兼得。随着缓存的容量逐步增大,其物理尺寸会变大,与 CPU 核心之间的物理距离会变远,从而导致数据传输时间增加,元素访问延迟变高。

而为了使得 CPU 的读写效率更高,CPU 会采取算法,将内存中的可能需要,或者频繁访问的数据加入缓存(内存同理)

如果 CPU 加入缓存的数据恰好是需要使用的数据则叫做 缓存命中

相反叫做 缓存未命中,若是缓存未命中就意味着 CPU 需要再耗费时间和性能从内存,甚至是硬盘中获取对应的数据,那样子太耗费时间和性能

这也引申出了 缓存命中率(即 CPU 从缓存中获取成功数据的比例)

缓存的数据结构

缓存的底层既可以使用数组,也可以使用链表,以下是在 Java 中使用数组和链表构建的缓存亲和度对比,实验记录了 CPU 使用不同底层数据结构(底层是数组或链表)进行相同活动(插入数据)所损耗的时间

代码:

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class PriorityDemo {
    public static void main(String[] args) {
        // ArrayList 的底层使用【数组】实现
        List<Integer> arrayList = new ArrayList<>();

        // 记录开始前时间
        Long time = new Date().getTime();

        // 往当前 arrayList 中插入巨量数据
        for (int i = 0; i < 1000000; i++) {
            arrayList.add(i);
        }

        // 记录结束时间
        Long nowTime = new Date().getTime();

        System.out.printf("当前使用的数据结构底层使用【数组】,使用了百万次插入方法消耗的时间为 %d ", nowTime - time);
    }
}

运行结果:

代码:

import java.util.Date;
import java.util.LinkedList;
import java.util.List;

public class PriorityDemo {
    public static void main(String[] args) {
        // linkedList 的底层使用【链表】实现
        List<Integer> linkedList = new LinkedList<>();

        // 记录开始前时间
        Long time = new Date().getTime();

        // 往当前 linkedList 中插入巨量数据
        for (int i = 0; i < 1000000; i++) {
            linkedList.add(i);
        }

        // 记录结束时间
        Long nowTime = new Date().getTime();

        System.out.printf("当前使用的数据结构底层使用【链表】,使用了百万次插入方法消耗的时间为 %d ", nowTime - time);
    }
}

运行结果:

对比两次实验我们不难发现,使用数组所损耗的时间远小于链表

为什么数组的缓存亲和度会比链表高?

无论是数据还是链表,当在创建之后,他都会进入内存,而 CPU 会根据缓存命中算法来提高效率

缓存中数组和链表的利用效率是不同的

  • 占用空间:链表元素比数组元素占用空间更多(链表除去本身存储的数据除外还需要存储下一个链表的地址值),导致缓存中容纳的有效数据量更少。
  • 缓存行:链表数据分散在内存各处,而缓存是“按行加载”的,因此加载到无效数据的比例更高。
  • 预取机制:数组比链表的数据访问模式更具“可预测性”(因为数组在内存中的地址是连续的),即系统更容易猜出即将被加载的数据。
  • 空间局部性:数组被存储在集中的内存空间中,因此被加载数据附近的数据更有可能即将被访问。

总体而言,数组具有更高的缓存命中率,因此它在操作效率上通常优于链表。这使得在解决算法问题时,基于数组实现的数据结构往往更受欢迎。

内存中 JDK 哈希奇闻:链表会进化成红黑树?

哈希表是什么?

哈希表,是一个键(key)对应一个值(value)的键值对散列表。字典/映射/map 通常使用哈希表来实现

“桶”是存储数据的基本单元。哈希函数将一个键转换成一个数组索引,这个索引对应的位置就是一个“桶”。

桶可以简单理解成哈希表中对应的值(value)

如何实现

从本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。

注意!这里的空间指的是地址值,举一个简单的例子:

我们使用数组来模拟哈希表,那么数组若是在内存中的地址为 0~255,则代表哈希表的 value 必须映射到对应的地址,而数组中的偏移量(下标)刚好与数组内的数据唯一对应,所以我们可以采取 - 想存入哈希表的元素地址,我们对 255 取余,余后就是需要被放入数组的地址值

哈希冲突!

由于输入的空间往往远大于输入空间,因此,理论上一定存在“多个输入对应相同输出”的情况

哈希冲突,由此产生

如何解决哈希冲突

最笨的方式:扩容

哈希表容量越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,我们可以通过扩容哈希表来减少哈希冲突

但是我们不难想到频繁扩容带来的后果:

  1. 哈希表的构建需要哈希值 / 哈希表中每个值(value)都需要 键(key)% 对应的哈希表长度(初始数组长度)
  2. 产生冲突之后进行扩容会改变哈希表长度(初始数组长度被改变)

这样子会引发一些问题:哈希表一旦扩容,内部的键值对需要重新洗牌存入,这样子做即耗时又损耗性能

JDK 底层是如何解决哈希冲突的

以下是 JDK 源码 HashMap 注释文档部分,我进行了机翻和转述,其中讲述了 JDK 底层对于 哈希表 的各种操作,提及了线程安全和负载因子

/**
 * Hash table based implementation of the {@code Map} interface.  This
 * implementation provides all of the optional map operations, and permits
 * {@code null} values and the {@code null} key.  (The {@code HashMap}
 * class is roughly equivalent to {@code Hashtable}, except that it is
 * unsynchronized and permits nulls.)  This class makes no guarantees as to
 * the order of the map; in particular, it does not guarantee that the order
 * will remain constant over time.
 * 【第一段讲解了相关与 HashMap 类实现的相关内容】
 * 基于哈希表的Map接口实现。
 * 此实现提供了所有可选的映射操作,并允许使用null值和null键。
 * (HashMap类大致等同于Hashtable,但它是非同步的,且允许使用null值。)
 * 此类不对映射的顺序做出任何保证;特别是,它不保证顺序会随时间保持不变。
 * 【即: HashMap 在 push 后不保证数据在物理地址以及逻辑地址上的先后性】
 *
 * <p>This implementation provides constant-time performance for the basic
 * operations ({@code get} and {@code put}), assuming the hash function
 * disperses the elements properly among the buckets.  Iteration over
 * collection views requires time proportional to the "capacity" of the
 * {@code HashMap} instance (the number of buckets) plus its size (the number
 * of key-value mappings).  Thus, it's very important not to set the initial
 * capacity too high (or the load factor too low) if iteration performance is
 * important.
 * 此实现为基本操作(get 和 put)提供了常数时间性能【即:时间复杂度为 O(1)】,
 * 前提是哈希函数【即:哈希算法】能在桶【②桶可以简单理解成哈希表中对应的值(value)】之间正确分散元素。
 * 【即:如果发生了错误导致哈希冲突,所有的数据都堆砌到同一个桶中,虽然 JDK 会将对应的链表转为红黑树,但是会使得查找和添加的时间复杂度上升】
 * 遍历集合视图所需的时间与 HashMap 实例的“容量”(桶的数量)加上其大小(键值映射的数量)成正比。
 * 【①遍历 HashMap 的时间 与 HashMap 底层哈希表(组成哈希表的数组)的大小和哈希表中存储的值的数量 成正比】
 * 因此,如果遍历性能很重要,则不要将初始容量【即哈希表初始大小】设置得太高(或将负载因子设置得太低),这一点非常重要。
 * 【负载因子是一个数值,为哈希表的元素数量除以桶数量】
 * 【这里提及了负载因子和初始容量,初始容量设置得过高会使得遍历性能下降,】
 * 【而负载因子设置的太低会导致 HashMap 底层的哈希表扩容的频率提高(引发初始容量升高)】
 *
 * <p>An instance of {@code HashMap} has two parameters that affect its
 * performance: <i>initial capacity</i> and <i>load factor</i>.  The
 * <i>capacity</i> is the number of buckets in the hash table, and the initial
 * capacity is simply the capacity at the time the hash table is created.  The
 * <i>load factor</i> is a measure of how full the hash table is allowed to
 * get before its capacity is automatically increased.  When the number of
 * entries in the hash table exceeds the product of the load factor and the
 * current capacity, the hash table is <i>rehashed</i> (that is, internal data
 * structures are rebuilt) so that the hash table has approximately twice the
 * number of buckets.
 * HashMap的一个实例有两个影响其性能的参数:初始容量【即哈希表初始大小】和负载因子(翻译可能会翻译成加载因子,需要注意)。
 * 【即:哈希表的底层有两个参数会影响其性能,可能是遍历(容量越大遍历时间越长,在上一段①处标明成正比关系)】
 * 【也可能是说当哈希表重新哈希化增加哈希表容量这一过程(在重新哈希化的过程中需要耗费额外性能进行操作)】
 * 容量是指哈希表中的桶数【哈希表中能容纳多少的桶】,而初始容量则是指哈希表创建时的容量。
 * 负载因子是衡量哈希表在自动增加容量之前允许达到的满载程度的一个指标。
 * 【JDK 底层会使用一个哈希表扩容算法,其中 负载因子 到达一定的数值时哈希表会扩容】
 * 当哈希表中的元素数超过负载因子与当前容量的乘积时,哈希表会被重新哈希化(即重建内部数据结构),以便哈希表中的桶数大约增加一倍。
 *
 * <p>As a general rule, the default load factor (.75) offers a good
 * tradeoff between time and space costs.  Higher values decrease the
 * space overhead but increase the lookup cost (reflected in most of
 * the operations of the {@code HashMap} class, including
 * {@code get} and {@code put}).  The expected number of entries in
 * the map and its load factor should be taken into account when
 * setting its initial capacity, so as to minimize the number of
 * rehash operations.  If the initial capacity is greater than the
 * maximum number of entries divided by the load factor, no rehash
 * operations will ever occur.
 * 通常,默认的负载因子(0.75)在时间和空间成本之间取得了良好的平衡。
 * 更高的值会减少空间开销,但会增加查找成本(体现在HashMap类的大多数操作中,包括get和put)。
 * 【当哈希表固定大小时只有固定的几个桶存储数据,在 JDK 中会采取链表 -> 红黑树 来进行桶扩容操作,本意是降低查找成本】
 * 【但当这个红黑树过大时,相对于直接扩容哈希表,查找成本较高】
 * 在设置其初始容量时,应考虑映射中预期的元素数及其负载因子,以尽量减少重新哈希操作的数量。
 * 【上文提及,频繁哈希化很影响性能】
 * 如果初始容量大于元素最大数量除以负载因子所得的值,则永远不会发生重新哈希操作。
 * 【哈希表的元素数量 / 桶数量 = 负载因子的值】
 * 【元素最大数量除以负载因子所得的值 是 负载因子的逆推公式,仅此而已】
 *
 * <p>If many mappings are to be stored in a {@code HashMap}
 * instance, creating it with a sufficiently large capacity will allow
 * the mappings to be stored more efficiently than letting it perform
 * automatic rehashing as needed to grow the table.  Note that using
 * many keys with the same {@code hashCode()} is a sure way to slow
 * down performance of any hash table. To ameliorate impact, when keys
 * are {@link Comparable}, this class may use comparison order among
 * keys to help break ties.
 * 如果要在HashMap实例中存储多个映射,那么使用足够大的容量来创建它,相比于让它根据需要自动重新散列以扩展表,将能更高效地存储这些映射。
 * 【这里 JDK 底层提及了:如果使用了足够大的容量去创建哈希表(引起哈希冲突的概率无限小),这会比让哈希表的桶生成链表更高效(单指时间复杂度)】
 * 请注意,使用多个具有相同hashCode()的键肯定会降低任何哈希表的性能。
 * 【即如果存在两个相同映射的键值对存入一个桶中,那么这个存在重复映射的哈希表 比 一个重复映射都没有的哈希表 性能低】
 * 为了减轻影响,当键是Comparable时,这个类可能会使用键之间的比较顺序来帮助打破平局
 * 【减轻重复键值对的影响】
 * 【这里 JDK 创建了一个新的类 - Comparable,这是一个接口,只有一个方法:compareTo 比较的意思】
 * 【打破平局的意思是: 单纯比较的意思】
 * 【注:目前还不知道为什么比较,以及比较什么东西,再往下看看】
 *
 * <p><strong>Note that this implementation is not synchronized.</strong>
 * If multiple threads access a hash map concurrently, and at least one of
 * the threads modifies the map structurally, it <i>must</i> be
 * synchronized externally.  (A structural modification is any operation
 * that adds or deletes one or more mappings; merely changing the value
 * associated with a key that an instance already contains is not a
 * structural modification.)  This is typically accomplished by
 * synchronizing on some object that naturally encapsulates the map.
 * If no such object exists, the map should be "wrapped" using the
 * {@link Collections#synchronizedMap Collections.synchronizedMap}
 * method.  This is best done at creation time, to prevent accidental
 * unsynchronized access to the map:<pre>
 *   Map m = Collections.synchronizedMap(new HashMap(...));</pre>
 * 【这段话表达了,为了保障 HashMap 的线程安全性,不允许多个线程同时访问,所以给他加了锁】
 * 请注意,此实现不是同步的。
 * 如果多个线程同时访问 HashMap,并且至少有一个线程在结构上修改了映射,
 * 则必须在外部进行同步。
 * 【这段话的意思是,如果上述情况可能会发生,那么我们必须要对其进行互斥操作,也就是加锁】
 * (结构修改是添加或删除一个或多个映射的任何操作;仅仅更改与实例已包含的键关联的值不是结构修改。)
 * 这通常是通过在自然封装映射的某个对象上同步来实现的。
 * 如果不存在这样的对象,则应使用Collections.synchronizedMap方法对映射进行“包装”。
 * 这最好在创建时完成,以防止意外不同步地访问 map
 *
 * <p>The iterators returned by all of this class's "collection view methods"
 * are <i>fail-fast</i>: if the map is structurally modified at any time after
 * the iterator is created, in any way except through the iterator's own
 * {@code remove} method, the iterator will throw a
 * {@link ConcurrentModificationException}.  Thus, in the face of concurrent
 * modification, the iterator fails quickly and cleanly, rather than risking
 * arbitrary, non-deterministic behavior at an undetermined time in the
 * future.
 * 【即:当 HashMap 被一个进程使用时,另一个进程想要访问,会使得前一个进程报错】
 * 此类所有“集合视图方法”返回的迭代器都是快速失败的:
 * 如果在迭代器创建后的任何时候,
 * 以除迭代器自身的remove方法外的任何方式对映射进行结构修改,
 * 迭代器将抛出ConcurrentModificationException。
 * 因此,面对并发修改,迭代器会快速且干净地失败,而不是冒着在未来不确定的时间产生任意、非确定性行为的风险。
 * 【这一段话的含义是: 倘若一个线程正在访问 HashMap 时,另一个线程也想访问,则会返回 ConcurrentModificationException 错误,并终止两个线程】
 * 【详情请见实验 makeConcurrentModificationException 见本文最下方附件】
 *
 * <p>Note that the fail-fast behavior of an iterator cannot be guaranteed
 * as it is, generally speaking, impossible to make any hard guarantees in the
 * presence of unsynchronized concurrent modification.  Fail-fast iterators
 * throw {@code ConcurrentModificationException} on a best-effort basis.
 * Therefore, it would be wrong to write a program that depended on this
 * exception for its correctness: <i>the fail-fast behavior of iterators
 * should be used only to detect bugs.</i>
 * 【还是那句话,为了保障 HashMap 的线程安全, JDK 做了什么】
 * 请注意,此实现不是同步的。
 * 如果多个线程同时访问哈希映射,
 * 并且至少有一个线程在结构上修改了映射,则必须在外部进行同步。
 * (结构修改是添加或删除一个或多个映射的任何操作;仅仅更改与实例已包含的键关联的值不是结构修改。)
 * 这通常是通过在自然封装映射的某个对象上同步来实现的。
 * 如果不存在这样的对象,则应使用Collections.synchronizedMap方法对映射进行“包装”。
 * 这最好在创建时完成,以防止意外不同步地访问地图:
 *
 * <p>This class is a member of the
 * <a href="{@docRoot}/java.base/java/util/package-summary.html#CollectionsFramework">
 * Java Collections Framework</a>.
 *
 * @param <K> the type of keys maintained by this map
 * @param <V> the type of mapped values
 * @author Doug Lea
 * @author Josh Bloch
 * @author Arthur van Hoff
 * @author Neal Gafter
 * @see Object#hashCode()
 * @see Collection
 * @see Map
 * @see TreeMap
 * @see Hashtable
 * @since 1.2
 */

以下仍是 JDK 源码,简述了 JDK 对于哈希表的策略

/*
 * Implementation notes.
 *
 * This map usually acts as a binned (bucketed) hash table, but
 * when bins get too large, they are transformed into bins of
 * TreeNodes, each structured similarly to those in
 * java.util.TreeMap. Most methods try to use normal bins, but
 * relay to TreeNode methods when applicable (simply by checking
 * instanceof a node).  Bins of TreeNodes may be traversed and
 * used like any others, but additionally support faster lookup
 * when overpopulated. However, since the vast majority of bins in
 * normal use are not overpopulated, checking for existence of
 * tree bins may be delayed in the course of table methods.
 * 此映射通常充当一个分箱(bucketed)哈希表,
 * 【分箱可以简单理解为 桶】
 * 但当分箱太大时,它们会被转换为 TreeNodes 的分箱,每个分箱的结构都类似于java.util.中的分箱、树状图。
 * 【当 哈希表 桶内链表太大了,就会被转成树】
 * 大多数方法都尝试使用普通的容器,但在适用的情况下会中继到TreeNode方法(只需检查节点的实例)。
 * 【大多数时候都会采取链表,或者其他的普通桶,但是在合适的时候会转变成树】
 * TreeNodes的Bins【桶】可以像其他任何Bins一样被遍历和使用,但在过度填充时还支持更快的查找。
 * 然而,由于正常使用中的绝大多数桶都没有“人满为患”,在表格方法的过程中可能会延迟检查树箱的存在。
 *
 * Tree bins (i.e., bins whose elements are all TreeNodes) are
 * ordered primarily by hashCode, but in the case of ties, if two
 * elements are of the same "class C implements Comparable<C>",
 * type then their compareTo method is used for ordering. (We
 * conservatively check generic types via reflection to validate
 * this -- see method comparableClassFor).  The added complexity
 * of tree bins is worthwhile in providing worst-case O(log n)
 * operations when keys either have distinct hashes or are
 * orderable, Thus, performance degrades gracefully under
 * accidental or malicious usages in which hashCode() methods
 * return values that are poorly distributed, as well as those in
 * which many keys share a hashCode, so long as they are also
 * Comparable. (If neither of these apply, we may waste about a
 * factor of two in time and space compared to taking no
 * precautions. But the only known cases stem from poor user
 * programming practices that are already so slow that this makes
 * little difference.)
 * 树容器(即其元素都是TreeNode的容器)主要按hashCode排序,
 * 但在关系的情况下,如果两个元素属于相同的“类C实现Comparable<C>”,则使用其compareTo方法进行排序。
 * 【这里能发现 JDK 转变的是红黑树 - 从根节点开始,大于当前节点的树向向右,小于当前节点的树向左】
 * (我们通过反射保守地检查泛型类型以验证这一点——请参阅方法compareClassFor)。
 * 当键具有不同的哈希值或可排序时,树箱的额外复杂性在提供最坏情况下的O(log n)操作方面是值得的。
 * 【当发生哈希冲突过多,导致链表过长时,转变为红黑树会降低时间复杂度】
 * 因此,在意外或恶意使用下,性能会优雅地下降,
 * 【这里说,如果有人想要恶意使用相同的键值对想要搞崩哈希表,转变为红黑树的桶能降低性能损耗】
 * 其中hashCode()方法返回分布不均的值,以及许多键共享hashCode的值,只要它们也是可比的。
 * 【这里说,只要 桶 内元素的哈希值是可比的,红黑树就塞得下】
 * (如果这两种情况都不适用,与不采取预防措施相比,我们可能会浪费大约两倍的时间和空间。
 * 【如果这两个策略都不去使用,那就会浪费大概两倍的空间和时间】
 * 但唯一已知的情况源于糟糕的用户编程实践,这些实践已经非常缓慢,几乎没有什么区别。)
 * 【这里 JDK 开发者在吐槽】
 *
 * Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)).
 * 因为TreeNodes的大小大约是常规节点的两倍,所以我们只在容器包含足够的节点时才使用它们(请参阅TREEIFY_THRESHOLD)。
 * 【这里说树的空间更大,只有空间足够时才会用】
 * 当它们变得太小(由于删除或调整大小)时,它们会被转换回普通垃圾箱。
 * 【这里说,如果树变小了,那可能会调整回链表】
 * 在使用分布良好的用户hashCode时,很少使用树箱。
 * 理想情况下,在随机hashCode下,bin中节点的频率遵循泊松分布默认的调整大小阈值0.75的参数平均约为0.5,尽管由于调整大小的粒度存在较大的差异。
 * 忽略方差,列表大小k的预期出现次数为(exp(-0.5)*pow(0.5,k)/阶乘(k))。
 * 【这里说产生树最大子节点的概率】
 *
 * The first values are:
 *
 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million
 *
 * The root of a tree bin is normally its first node.  However,
 * sometimes (currently only upon Iterator.remove), the root might
 * be elsewhere, but can be recovered following parent links
 * (method TreeNode.root()).
 * 树bin的根通常是它的第一个节点。
 * 然而,有时(目前仅在Iterator.remove时),根可能在其他地方,但可以在父链接后恢复(方法TreeNode.root())。
 *
 * All applicable internal methods accept a hash code as an
 * argument (as normally supplied from a public method), allowing
 * them to call each other without recomputing user hashCodes.
 * Most internal methods also accept a "tab" argument, that is
 * normally the current table, but may be a new or old one when
 * resizing or converting.
 * 所有适用的内部方法都接受哈希码作为参数(通常由公共方法提供),允许它们在不重新计算用户哈希码的情况下相互调用。
 * 大多数内部方法也接受“tab”参数,通常是当前表,但在调整大小或转换时可能是新的或旧的。
 *
 * When bin lists are treeified, split, or untreeified, we keep
 * them in the same relative access/traversal order (i.e., field
 * Node.next) to better preserve locality, and to slightly
 * simplify handling of splits and traversals that invoke
 * iterator.remove. When using comparators on insertion, to keep a
 * total ordering (or as close as is required here) across
 * rebalancings, we compare classes and identityHashCodes as
 * tie-breakers.
 * 当bin列表被树化、拆分或未经测试时,
 * 我们将它们保持在相同的相对访问/遍历顺序中(即字段Node.next),以更好地保持局部性,并稍微简化对调用itera.remove的拆分和遍历的处理。
 * 当在插入时使用比较器时,为了在重新平衡过程中保持总顺序(或尽可能接近此处要求的顺序),
 * 我们将类和identityHashCodes作为连接断路器进行比较。
 *
 * The use and transitions among plain vs tree modes is
 * complicated by the existence of subclass LinkedHashMap. See
 * below for hook methods defined to be invoked upon insertion,
 * removal and access that allow LinkedHashMap internals to
 * otherwise remain independent of these mechanics. (This also
 * requires that a map instance be passed to some utility methods
 * that may create new nodes.)
 * 子类LinkedHashMap的存在使纯模式与树模式之间的使用和转换变得复杂。
 * 请参阅下文,了解在插入、删除和访问时定义的钩子方法,这些方法允许LinkedHashMap内部保持独立于这些机制。
 * (这也要求将映射实例传递给一些可能创建新节点的实用方法。)
 *
 * The concurrent-programming-like SSA-based coding style helps
 * avoid aliasing errors amid all of the twisty pointer operations.
 */

以下是 JDK 怎么使用这些策略的

其中的重要分支(下图红色方框),则代表 判断链表长度是否满足创建树的条件,当条件满足时创建树,而这个创建树的条件是,链表长度大于 8

哈希算法

哈希算法在源码中及其精简

你敢相信,这一段就是哈希算法吗?

让我们分割一下每一段运算,我们首先得知道:哈希值二进制是 32 位

  • 三元运算符:先判断 key 是否为 null 如果是 返回 0,如果不是 返回后面这一大段看起来很复杂的运算式
  • 获取当前 key 的哈希值,并将它赋值给 h
  • 将 h (32 位) 右移 16 位
  • 按位异或 两个 32 位数

着重讲一下为什么右移 bit 位、按位异或是什么以及为什么要按位异或

  • 右移 16 位的原因:结合哈希表的计算公式(哈希值对桶数量(数组长度)capacity 取模,从而获取该 key 对应的桶(数组索引)index)和桶数量不会过大这两个我们能知道,哈希表计算时基本上只有低位在进行运算,右移 16 位是获取当前哈希值的最高位。
  • 什么是按位异或: 异或的规则非常简单:两个位相同则为 0,不同则为 1。
  • 为什么要按位异或:为了扰动函数 (Perturbation) ,能让哈希值的高位与低位都参与运算,让高位的信息也参与到下标计算中,把“冲突”的概率降到最低。
扩容机制

扩容机制在 JDK 1.7 之前和 JDK 1.8 之后有着天壤之别

  • JDK 1.7 之前: 扩容时需要重新计算每个元素的 Hash 值再塞到新表里,非常耗性能。
  • JDK 1.8 之后:👇

利用了“扩容是 2 倍”的特性,不需要重新计算 Hash。它只需要检查 Hash 值中新增的那一个二进制位。

如果是 0:下标不变。

如果是 1:下标变为 原下标 + 原容量。

这极其高效,因为这只是一个简单的位运算判断,避免了大量的取模运算。这就是计算机底层位运算的魅力!

总结:

内存里追求 CPU 缓存命中(用数组)。

复杂计算追求时间复杂度(用红黑树)。

磁盘存储追求减少 I/O(用 B+ 树)。

附件

makeConcurrentModificationException

代码:

import java.util.HashMap;
import java.util.Set;

/**
 * Writer: Serene Dream
 * Time: 2025/11/25 : 19:58
 * Explanation:
 */
public class makeConcurrentModificationException {
    public static void main(String[] args) {
        // 先在 map 中增加一些数据
        HashMap<Integer, Integer> map = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            map.put(i, i);
        }
        // 两个进程,一个进程在使用 HashMap 还未完成时,另一个进程插入会发生什么

        Thread t1 = new Thread(() -> {
            // 第一个进程对 map 内容进行访问,遍历
            // 我发现 map 中没有迭代器,只有对应的 Set 中才有,但是迭代器的作用就是循环,所以我认为迭代器的意思就是循环遍历
            // 文档中迭代器的意思可能代指正在遍历访问 HashMap 或者修改其中数据
            Set<Integer> set = map.keySet();
            for (Integer key : set) {
                Integer value = map.get(key);
                System.out.printf("key = %d, value = %d", key, value);

                // 让进程循环中开始睡眠,模拟还在占用 HashMap
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        Thread t2 = new Thread(() -> {
            // 让进程二睡眠一会,保证进程二在访问 HashMap 时 HashMap 必须被 t1 占用
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            // 第二个进程对 map 内容进行删除
            map.remove(1);
        });

        t1.start();
        t2.start();
    }
}

运行结果: