HashMap 如何组织
和查找数据
先理解“用键找到值”,再看它为什么快,以及什么时候不该直接使用它。
先建立一个整体印象
如果你想通过一个主题名找到对应的笔记标题,可以把主题名当作键,把标题当作值。Map 表达的就是这种映射关系:同一个键最多对应一个值。
HashMap 用哈希表实现这件事。可以先把它理解为一组存放条目的桶:查找时,利用键的哈希信息缩小范围,再判断是否找到了相同的键。
哈希帮助定位,相等性决定是不是同一个键。两个键的哈希值相同,并不意味着它们相等。
从一次查找开始
先不钻进源码,看看调用者实际做了什么:
Map<String, String> notes = new HashMap<>();
notes.put("collections", "理解集合如何组织数据");
notes.put("concurrency", "理解多个任务如何协作");
String title = notes.get("collections");
String missing = notes.get("jvm");
第一次 get 得到已经存入的标题;第二次查找的键不存在,因此返回 null。实际使用时,不需要自己管理桶的位置。
当哈希分布合理时,基本的 get 和 put 操作具有常数时间性能。这里的前提很重要:不要把“通常很快”理解成“任何数据、任何情况都一样快”。
扩容时,元素去哪里?
同一个旧桶里的节点,到了新数组不一定还在一起。把容量从 16 翻倍到 32,看看哪一位决定了它们的去向。
试一次扩容
数字是节点保存的 hash,不是 key。只展开 5 号旧桶,其余桶省略。
新数组多看一位,位置就有了两种可能。
先猜一下:hash 为 21 的节点,会留在 5 号桶,还是去 21 号桶?逐步检查四个节点,看看你的判断。
例子 1 · 5 / 21 / 37 / 53
为什么只看这一位?
容量 16 的掩码是 15(二进制 00001111);容量 32 的掩码是 31(00011111)。新增的有效位正是 16 对应的那一位。
链表桶逐节点检查 hash & 16:结果为 0,就进入新数组的原下标;结果为 16,就进入原下标 + 16。各组保留原链中的相对顺序,不保证正好各一半。
教学模型 · JDK 8 链表桶。假设已触发扩容,逐节点展示分组结果,不逐语句模拟源码;“原下标”仍属于新数组。复用已保存的 hash,不重新调用 key.hashCode()。本例不演示扩容触发、树桶或最大容量。
把几个边界分开理解
不存在,不一定等于没有映射
HashMap 允许值为 null。因此,get 返回 null,也可能是这个键已经存在,只是它的值就是 null。需要区分时,使用 containsKey。
能遍历,不代表有稳定顺序
不要依赖 HashMap 的遍历顺序来生成文章目录。如果需要按插入顺序展示,应选择明确提供这一语义的实现,例如默认配置的 LinkedHashMap。
单次查找方便,不代表并发访问安全
HashMap 本身不提供同步。多个线程共享并修改映射时,需要明确的同步方案,或使用符合场景的并发集合。并发集合也不自动保证多步业务操作整体原子。
根据需要选择集合
先问自己需要什么语义,再选具体的类。
| 需要什么 | 可以考虑 | 额外留意 |
|---|---|---|
| 按键查找,不依赖遍历顺序 | HashMap | 共享修改需要同步 |
| 保留插入顺序 | LinkedHashMap | 不要混淆访问顺序模式 |
| 多线程共享映射 | ConcurrentHashMap | 组合操作仍要检查语义 |
回过头来想一想
现在可以用三个问题检查自己是否理解了它,而不是只记住类名:
- 哈希值相同的两个键,为什么不一定是同一个键?
- 为什么不能只用
get的返回值判断键是否存在? - 如果目录必须保持顺序,选择集合时应该改变什么?
把数据关系、顺序要求和并发边界分开,就更容易知道什么时候适合用 HashMap。
参考资料
本文是为验证排版与交互编写的短示例,不替代知识库中的正式内容。