集合-Map-Hashtable ( 历史遗产)

2 阅读30分钟

概述

作为 JDK 1.0 时代的元老,Hashtable 是 Java 集合框架中最早提供的线程安全映射,它以全方法 synchronized 修饰的简单粗暴方式实现了并发安全,却也因锁粒度过粗、不允许 null 键值、单链表无树化等设计限制,在 JDK 1.2 集合框架革命及 JUC 并发包诞生后逐步被 HashMapConcurrentHashMap 取代。本文将以此“活化石”为镜,从历史演进、定义特性到底层源码,再到与现代容器的全方位对比,系统化剖析其设计精髓与历史局限,助你透彻理解为何它沦为遗留代码中的技术债务。

核心知识点速览

  • JDK 1.0 的线程安全遗产Hashtable 是 Java 最早期的键值对容器,与 Vector 同属第一代集合类,所有公开方法均用 synchronized 修饰,保证方法级互斥。
  • 数组+链表无红黑树:底层结构为 Entry<?,?>[] table 数组加单向链表,冲突通过头插法链接(与 JDK 7 HashMap 一致),JDK 8 中并未引入红黑树优化,查询最坏复杂度仍为 O(n)。
  • 独特的扩容算法:扩容时新容量为 oldCapacity * 2 + 1(保持奇数,偏向素数趋势),默认初始容量 11,负载因子 0.75,索引计算依赖取模而非位与。
  • 双遍历体系:同时支持遗留的 Enumerationkeys() / elements() 方法)和现代的 IteratorkeySet() / entrySet() 方法),后者支持 fail-fast 机制。
  • 严格不允许 null:任何 KeyValue 均不可为 null,直接继承自 Dictionary 的设计约束,与 HashMap 的宽松策略形成鲜明反差。
  • 已被现代容器取代:新代码中任何并发需求都应使用 ConcurrentHashMap,单线程环境用 HashMapHashtable 仅保留于极端遗留系统兼容。

全文组织架构

flowchart TB
    subgraph A["Part 1: 基础认知篇"]
        A1["定义与特性"]
        A2["适用场景"]
        A3["继承体系"]
    end
    subgraph B["Part 2: 存储与构造篇"]
        B1["存储结构字段"]
        B2["构造方法"]
    end
    subgraph C["Part 3: 核心原理篇"]
        C1["哈希计算定位"]
        C2["put操作"]
        C3["rehash扩容"]
        C4["get/remove"]
    end
    subgraph D["Part 4: 遍历与序列化篇"]
        D1["Enumeration vs Iterator"]
        D2["序列化克隆"]
    end
    subgraph E["Part 5: 对比与陷阱篇"]
        E1["vs HashMap"]
        E2["vs CHM vs SyncMap"]
        E3["常见陷阱"]
    end
    subgraph F["Part 6: 总结与面试篇"]
        F1["性能总结"]
        F2["面试高频专题"]
    end
    A --> B --> C --> D --> E --> F

架构图说明

  • 第一层(基础认知篇):从 Hashtable 的定义、核心特性以及历史继承链条出发,建立对它的宏观认知,明确它的源头——Dictionary 抽象类与 Map 接口的关系。
  • 第二层(存储与构造篇):深入到数据结构内部,拆解 Entry 节点、table 数组、thresholdloadFactor 等核心字段,以及四个构造器如何初始化这些参数,并理解初始容量为何选择 11 且不必为 2 的幂。
  • 第三层(核心原理篇):这是全文灵魂,详细剖析 putgetremoverehash 的源码实现,对比 HashMap 的差异,揭示其哈希计算为何没有扰动函数、扩容为何是 2n+1 以及无红黑树优化的设计逻辑。
  • 第四层(遍历与序列化篇):展示 Hashtable 特有的双遍历体系——历史遗留的 Enumeration 与现代 Iterator 如何共存,以及 writeObject/readObject 自定义序列化和浅克隆的机制。
  • 第五层(对比与陷阱篇):将 Hashtable 与其替代者 HashMapConcurrentHashMapCollections.synchronizedMap 进行全方位对比,总结并发演进路径,并指出实际项目中的常见误区和迁移最佳实践。
  • 第六层(总结与面试篇):给出时间/空间复杂度和现代定位总结,并将所有高频面试题独立归纳,每道题均包含标准回答、追问模拟和加分回答,方便读者系统性备战。

Part 1:基础认知篇

模块 1:定义、核心特性与适用场景

Hashtable 的定义极为明确:JDK 1.0 引入的、基于哈希表实现的、全方法 synchronized 线程安全的、不允许 null 键值的遗留映射类。它实现 Map 接口,但继承自过时的 Dictionary 抽象类,与 HashMapAbstractMap 继承线完全独立,是早期 Java 对“线程安全即全局锁”思想的最直接体现。

核心特性列表

  • 全方法同步:所有 public 方法均加 synchronized,锁对象为 this
  • 基于数组+链表:底层为 Entry<?,?>[] table,未使用红黑树,链表过长时查询退化为 O(n)。
  • 默认容量 11:容量无需为 2 的幂,因为索引使用取模运算。
  • 扩容 2n+1:新容量 = 旧容量 × 2 + 1,保持奇数以减少哈希碰撞。
  • 禁止 null 键/值key.hashCode() 和显式 value == null 检查均会抛出 NullPointerException
  • 支持 Enumeration:提供 keys()elements() 返回旧式枚举器。
  • 遗留类不推荐新代码使用:官方文档明确推荐使用 ConcurrentHashMap

适用场景
仅限维护遗留系统。对于 JDK 1.1 之前甚至更早的代码库,如果已经围绕 Hashtable 构建了复杂的同步逻辑,且重构成本极高,可以继续维持,但应设定计划向 ConcurrentHashMap 迁移。任何新项目、新模块一律禁止使用 Hashtable

反例场景

  • 需要线程安全 → 必须用 ConcurrentHashMap,其分段锁(JDK7)或 CAS+synchronized 桶级锁(JDK8)有着数量级领先的并发性能。
  • 单线程环境 → 用 HashMap
  • 需要 null 键或值 → 用 HashMapHashtable 无法胜任。
  • 需要排序或 LRU → 用 LinkedHashMapTreeMap

Hashtable 的历史定位与退出路线图

flowchart LR
    A["JDK 1.0: Hashtable 诞生\n全方法 synchronized"] 
    B["JDK 1.2: 集合框架诞生\nHashMap 出现"]
    C["JDK 5: JUC 并发包\nConcurrentHashMap 提供高并发"]
    D["JDK 8: HashMap 引入红黑树\nConcurrentHashMap 全面优化"]
    E["未来: 遗留维护\n全面淘汰"]
    A --> B --> C --> D --> E

图表说明

  • 第一层(JDK 1.0):Java 第一个版本仅提供 Hashtable 作为哈希表结构的键值对容器,内置方法级同步机制,是当时“线程安全”的全部含义。
  • 第二层(JDK 1.2):集合框架革命性引入 HashMap,提供非同步的高性能替代品,Hashtable 实现 Map 接口以融入新体系,但其 Dictionary 根已经过时。
  • 第三层(JDK 5)java.util.concurrent 包的诞生带来 ConcurrentHashMap,采用分段锁等高并发技术,Hashtable 的全表锁劣势被彻底暴露。
  • 第四层(JDK 8)HashMap 引入红黑树优化,ConcurrentHashMap 抛弃分段锁改用CAS + synchronized 锁桶,性能碾压 Hashtable,后者仅保留类定义以兼容旧代码。
  • 第五层(未来):官方已不推荐使用,Hashtable 最终将仅存在于古董代码与面试题中。

模块 2:接口与继承体系

Hashtable 的继承体系堪称 Java 早期设计混乱的一个缩影:

java.lang.Object
    java.util.Dictionary<K,V>          (抽象类,过时)
         java.util.Hashtable<K,V>      (实现类)
           implements Map<K,V>, Cloneable, Serializable

它直接继承 Dictionary——一个在 JDK 1.0 与 Hashtable 同时引入的抽象类,定义了 size()isEmpty()keys()elements()get(Object)put(K,V)remove(Object) 等基础方法。即使用现代 Java 看,Dictionary 的职责应完全由 Map 接口覆盖,但它却以抽象类形式遗留至今。更混乱的是,Hashtable 在 JDK 1.2 中又额外实现了 Map 接口,形成“一个类从两个方向继承映射规范”的奇特局面:静态类型上来自 Dictionary,行为约束上又遵循 Map。好在 Hashtable 已用 @Deprecated 的方式配合 Dictionary 不再推荐,遗留代码即便存在也基本是通过 Map<Object,Object> 引用操作。

类图:Dictionary → Hashtable 的继承链

classDiagram
    class Dictionary {
        <<abstract>>
        +size() int
        +isEmpty() boolean
        +keys() Enumeration
        +elements() Enumeration
        +get(Object key) V
        +put(K key V value) V
        +remove(Object key) V
    }
    class Map {
        <<interface>>
        +size() int
        +isEmpty() boolean
        +containsKey(Object key) boolean
        +containsValue(Object value) boolean
        +get(Object key) V
        +put(K key V value) V
        +remove(Object key) V
        +putAll(Map m)
        +clear()
        +keySet() Set
        +values() Collection
        +entrySet() Set
    }
    class Hashtable {
        -Entry[] table
        -int count
        -int threshold
        -float loadFactor
        -int modCount
        +Hashtable()
        +synchronized put(K V) V
        +synchronized get(Object) V
        +synchronized remove(Object) V
        +keys() Enumeration
        +elements() Enumeration
        +keySet() Set
        +entrySet() Set
        +clone() Object
        -writeObject(ObjectOutputStream)
        -readObject(ObjectInputStream)
    }
    Dictionary <|-- Hashtable
    Map <|.. Hashtable

图表说明

  • 第一层(Dictionary 抽象类):JDK 1.0 的遗留产物,定义了键值映射的基本操作,其中 keys()elements() 返回 Enumeration 类型,与现代集合框架的 Iterator 风格迥异。它没有 keySet()entrySet() 等集合视图,也不支持 containsValue(仅在 Hashtable 中直接实现)。
  • 第二层(Map 接口):JDK 1.2 引入的现代映射标准,提供了 SetCollection 视图以及 containsKeycontainsValue 等更丰富的方法。Hashtable 实现 Map 接口使其可以融入新的集合框架。
  • 第三层(Hashtable 实现):内部维护 Entry[] table,关键方法均标记 synchronized,并提供双遍历体系:keys()/elements() 返回 EnumerationkeySet()/entrySet() 返回支持 Iterator 的视图。自定义 writeObject/readObject 以处理序列化。

核心矛盾Dictionary 抽象类与现代 Map 接口完全平行,但 Hashtable 却“脚踩两条船”,导致它既必须实现 Dictionary 中无泛型擦除后丧失类型安全的老方法,又要满足 Map 的新契约。这就是为什么我们有时会在 Hashtable 的源码中看到一些重复的包装代码。


Part 2:存储与构造篇

模块 3:存储结构与核心字段(源码剖析)

Hashtable 底层数据结构与 JDK 7 的 HashMap 极其类似:一个数组 + 单向链表,但没有任何红黑树转换逻辑。核心字段定义如下(JDK 8 源码提取):

private transient Entry<?,?>[] table;   // 哈希桶数组
private transient int count;            // 实际键值对数量,对应 HashMap 的 size
private int threshold;                  // 扩容阈值 = (int)(capacity * loadFactor)
private float loadFactor;               // 负载因子,默认 0.75
private transient int modCount = 0;     // 结构修改计数器,用于 fail-fast

Entry<K,V> 内部类(简化):

private static class Entry<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Entry<K,V> next;  // 单向链表指针
    // 构造函数,getKey, getValue, setValue, equals, hashCode 等
}

HashMap.Node 的区别是:HashMap 中还有 TreeNode 子类继承 Node 形成红黑树结构,而 HashtableEntry终结类,没有进一步派生子类,不具备树化能力。

Entry 节点结构及 table 数组类图

classDiagram
    class Entry {
        +int hash
        +K key
        +V value
        +Entry next
        +getKey() K
        +getValue() V
        +setValue(V) V
    }
    class Hashtable {
        -Entry[] table
        -int count
        -int threshold
        -float loadFactor
    }
    Hashtable *-- Entry

图表说明

  • 第一层(Entry 节点):与 HashMap.Node 同构,包含 hashkeyvaluenext 指针,构成单向链表hash 字段缓存 keyhashCode() 值以避免重复计算。
  • 第二层(Hashtable 内部结构)table 数组的每个槽位存储链表头节点。当发生哈希冲突时,新节点直接插入链表头部(头插法)。
  • 第三层(与 HashMap 差异)Hashtable 没有 TreeNode 子类,故从 JDK 7 到 JDK 8 没有任何数据结构层面的进化,链表长度膨胀时查询性能急剧下降。

模块 4:构造方法(源码剖析)

Hashtable 提供 4 个构造器,参数含义与 HashMap 类似,但默认值不同:

// 1. 无参构造:容量 11,负载因子 0.75
public Hashtable() {
    this(11, 0.75f);
}

// 2. 指定初始容量,负载因子默认 0.75
public Hashtable(int initialCapacity) {
    this(initialCapacity, 0.75f);
}

// 3. 完全手动指定
public Hashtable(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0) throw new IllegalArgumentException(...);
    if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException(...);
    if (initialCapacity == 0) initialCapacity = 1;
    this.loadFactor = loadFactor;
    table = new Entry<?,?>[initialCapacity];
    threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
}

// 4. 从已有 Map 构造
public Hashtable(Map<? extends K, ? extends V> t) {
    this(Math.max(2*t.size(), 11), 0.75f);
    putAll(t);
}

关键设计

  • 初始容量不必为 2 的幂:因为定位索引使用 (hash & 0x7FFFFFFF) % table.length,任何正整数均可。选择 11 这个质数有助于在有限桶数下让哈希分布更均匀。
  • 阈值计算threshold = (int)(capacity * loadFactor),最大为 MAX_ARRAY_SIZE+1(实际为 Integer.MAX_VALUE - 8 + 1),防止溢出。
  • 从 Map 构造:首先分配 max(2*t.size(), 11) 的容量,然后调用 putAll,而 putAll 内部用 synchronized 同步,后续逐个 put

Demo:构造对比

// 可运行演示
Hashtable<String, Integer> ht1 = new Hashtable<>();               // 11桶
Hashtable<String, Integer> ht2 = new Hashtable<>(20);            // 20桶
Hashtable<String, Integer> ht3 = new Hashtable<>(20, 0.5f);     // 20桶,阈值10
Map<String, Integer> source = new HashMap<>();
source.put("a", 1); source.put("b", 2);
Hashtable<String, Integer> ht4 = new Hashtable<>(source);       // 容量max(2*2,11)=11

Part 3:核心原理篇

模块 5:哈希计算与索引定位(源码剖析)

Hashtable 的哈希计算非常直接,没有类似 HashMap 的扰动函数:

int hash = key.hashCode();  // 直接使用对象原始 hashCode
// 索引定位
int index = (hash & 0x7FFFFFFF) % tab.length;
  • hash & 0x7FFFFFFF:目的是消除符号位,保证结果为非负整数,因为负数取模在 Java 中结果仍为负(或 -0),无法作为数组索引。
  • % tab.length:使用取模运算(%),而非位运算 (n-1) & hash。这导致即使在 JDK 8 中,Hashtable 的桶容量也不必是 2 的幂,可以是任意正整数。

与 HashMap 的差异HashMap (JDK 8) 做了两步优化: (h = key.hashCode()) ^ (h >>> 16),将高 16 位与低 16 位异或,以高位参与低位运算,减少低位相同导致的碰撞,尤其适用于容量较小(低位掩码不足)时的分布优化。然后通过 (n - 1) & hash 高效取模(要求 n 为 2 的幂)。Hashtable 缺少扰动且使用 %,大数量下性能损耗显著,尤其在早期 Java 版本中。

哈希计算与定位对比流程图

flowchart TD
    subgraph HashTable
        A1["key.hashCode()"] --> A2["hash = hashCode"]
        A2 --> A3["hash & 0x7FFFFFFF"]
        A3 --> A4["index = positiveHash % table.length"]
    end
    subgraph HashMap_JDK8
        B1["key.hashCode()"] --> B2["h = hashCode"]
        B2 --> B3["h ^ (h >>> 16)"]
        B3 --> B4["index = (n - 1) & hash"]
    end

图表说明

  • 第一层(Hashtable 路径):获取原始 hashCode去除符号位 → 对数组长度取模。优点是不挑容量,缺点是无扰动位运算且 % 比位运算慢。
  • 第二层(HashMap 路径):获取原始 hashCode扰动函数混合高低位 → 利用 (n-1) & hash 快速索引。HashMap 的容量必须为 2 的幂,否则位与无法等价于取模。
  • 第三层(结论)Hashtable 的设计更古老且保守,只保证非负和基本分布;HashMap 用更高效的位运算替代了取模,以微小扰动实现更好的散列。这也是为何 Hashtable 不要求容量为 2 的幂。

模块 6:put 操作——同步插入(源码剖析)

put 方法是 Hashtable 并发安全的集中体现,源码精简如下:

public synchronized V put(K key, V value) {
    // 1. 检查 value 非 null
    if (value == null) {
        throw new NullPointerException();
    }
    // 2. 快速查找桶
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();  // 此处若 key 为 null 则 NPE
    int index = (hash & 0x7FFFFFFF) % tab.length;
    // 3. 遍历链表检查是否已存在
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;   // 已有键,覆盖值
            return old;
        }
    }
    // 4. 不存在,添加新节点(头插法)
    addEntry(hash, key, value, index);
    return null;
}

private void addEntry(int hash, K key, V value, int index) {
    modCount++;
    Entry<?,?> tab[] = table;
    if (count >= threshold) {
        // 扩容
        rehash();
        tab = table;
        hash = key.hashCode();
        index = (hash & 0x7FFFFFFF) % tab.length;
    }
    // 头插:新节点.next = 原头节点
    @SuppressWarnings("unchecked")
    Entry<K,V> e = (Entry<K,V>) tab[index];
    tab[index] = new Entry<>(hash, key, value, e);
    count++;
}

流程中几个关键点:

  • 方法级 synchronized:锁是 this,所有线程竞争同一把锁,并发度极低。
  • null 检查value 显式判空,key 因为调用 hashCode() 隐式判空。
  • 头插法:新节点插入链表头部(new Entry<>(hash,key,value, e)),这在 JDK 7 的 HashMap 中也如此,但 JDK 8 的 HashMap 已改为尾插法以解决死链问题,而 Hashtable 因同步块不会有并发死链问题,但仍保留头插。
  • 扩容在插入前检查:若 count >= threshold,先扩容再重新计算索引,然后真正插入。

put 操作流程图(无树化)

flowchart TD
    A["synchronized void put(key,value)"] --> B["value==null ?"]
    B -- yes --> C["抛出 NullPointerException"]
    B -- no --> D["int hash = key.hashCode()"]
    D --> E["index = (hash & 0x7FFFFFFF) % tablen"]
    E --> F["遍历链表"]
    F -- "找到相同key" --> G["覆盖 value,返回旧值"]
    F -- "未找到" --> H["modCount++"]
    H --> I{"count >= threshold ?"}
    I -- "yes 先扩容" --> J["rehash()"]
    J --> K["重新计算 index"]
    K --> L["头插法新增 Entry"]
    I -- "no" --> L
    L --> M["count++,返回 null"]

图表说明

  • 第一层(入口锁):整个方法用 synchronized 修饰,确保同一时刻只有一个线程可执行,锁对象为 this
  • 第二层(null 值检查):首先 value 非空校验,若为 null 直接 NPEkey.hashCode() 同样保证 keynull
  • 第三层(哈希定位与遍历):计算索引后遍历链表,比较 hashequals。找到则直接更新值并返回旧值;未找到则进入新增流程。
  • 第四层(扩容与头插):检查是否达到阈值,若需要则调用 rehash() 扩容,重新索引,最后用头插法构建新节点。头插法在并发下虽无死链(因为已同步),但破坏插入顺序。

模块 7:rehash 扩容机制(源码剖析)

扩容是 Hashtable 独特算法的重要体现:

protected void rehash() {
    int oldCapacity = table.length;
    Entry<?,?>[] oldMap = table;
    // 新容量 = 旧容量 * 2 + 1
    int newCapacity = (oldCapacity << 1) + 1;
    if (newCapacity - MAX_ARRAY_SIZE > 0) {
        if (oldCapacity == MAX_ARRAY_SIZE)
            return; // 已达上限,无法再扩容
        newCapacity = MAX_ARRAY_SIZE;
    }
    Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
    modCount++;
    threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
    table = newMap;
    // 重新散列所有节点到新表
    for (int i = oldCapacity ; i-- > 0 ;) {
        for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
            Entry<K,V> e = old;
            old = old.next;
            int index = (e.hash & 0x7FFFFFFF) % newCapacity;
            // 头插法迁移
            e.next = (Entry<K,V>)newMap[index];
            newMap[index] = e;
        }
    }
}

核心细节

  • 新容量公式newCapacity = (oldCapacity << 1) + 1,即 2n+1。为什么?保持奇数,且尽量接近素数。因为奇数在取模 hash % odd 时能利用哈希值的奇偶性,可能比偶数更均匀。历史意图是模拟素数哈希表,但现代观点认为 2 的幂 + 扰动函数效果更佳。
  • 迁移算法:两层循环,外层从旧数组末尾向前遍历桶,内层遍历链表并头插法插入新表。头插法使同一链表的节点在新链表中顺序反转,这是历史头插法的典型副作用,但同步锁屏蔽了并发问题。
  • 阈值更新threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1),有限制最大值。
  • 与 HashMap JDK 8 扩容对比HashMap 充分利用 2 的幂容量特性,将原链表拆分为高位链和低位链(hash & oldCap 是否为 0),避免了重新计算哈希,效率极高。Hashtable 则是对每个元素重新 % newCapacity,开销大很多。

rehash 扩容流程图

flowchart TD
    A["触发放容: count >= threshold"] --> B["计算新容量\nnewCap = oldCap*2 + 1"]
    B --> C{"newCap > MAX_ARRAY_SIZE?"}
    C -- yes --> D["若 oldCap 已达 MAX, 返回"]
    C -- no --> E["创建 newMap[newCap]"]
    E --> F["modCount++"]
    F --> G["更新 threshold, table = newMap"]
    G --> H["遍历旧数组每个桶"]
    H --> I["遍历桶内链表节点 e"]
    I --> J["计算新索引: \nindex = (e.hash & 0x7FFFFFFF) % newCap"]
    J --> K["头插: e.next = newMap[index]\nnewMap[index] = e"]
    K --> L{"还有节点?"}
    L -- yes --> I
    L -- no --> M{"还有桶?"}
    M -- yes --> H
    M -- no --> N["完成"]

图表说明

  • 第一层(触发条件)put 方法在插入前检测 count >= threshold,若成立则调用 rehash()
  • 第二层(新容量计算)新容量严格按 2n+1 递增,从 11 → 23 → 47 … 这在保持奇数方面具有连续性,但不保证为素数。
  • 第三层(数组迁移):旧表每个桶的链表元素被逐一遍历,重新计算索引(因长度变化)并采用头插法链接到新桶中,导致链表反转。没有 HashMap 的高低位移位优化。
  • 第四层(复杂度评估):扩容时间复杂度 O(N),且由于额外取模计算,比 HashMap 扩容更重。这也是高负载下 Hashtable 性能低下的结构性原因之一。

模块 8:get 与 remove 操作(源码剖析)

getremove 同样由方法级 synchronized 保护:

public synchronized V get(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return (V)e.value;
        }
    }
    return null;
}

public synchronized V remove(Object key) {
    Entry<?,?> tab[] = table;
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> e = (Entry<K,V>)tab[index];
    for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            modCount++;
            if (prev != null) {
                prev.next = e.next;   // 从链表摘除
            } else {
                tab[index] = e.next; // 删除的是头节点
            }
            count--;
            V oldValue = e.value;
            e.value = null;   // 助 GC
            return oldValue;
        }
    }
    return null;
}

流程共性

  • 均通过 hashequals 定位节点,无额外优化。
  • remove 的链表操作是经典的单链表删除,记录 prev 前驱。
  • 线程安全由 synchronized 保证,但细看之下 get 本身不修改结构,其实读也不允许并发,因为拿锁是独占。

get 和 remove 流程图

flowchart TD
    subgraph get
        G1["synchronized get(key)"] --> G2["计算 hash, index"]
        G2 --> G3["遍历链表"]
        G3 -- "找到" --> G4["返回 value"]
        G3 -- "未找到" --> G5["返回 null"]
    end
    subgraph remove
        R1["synchronized remove(key)"] --> R2["计算 hash, index"]
        R2 --> R3["遍历链表,维护 prev"]
        R3 -- "找到" --> R4["modCount++, 摘除节点"]
        R4 --> R5["count--, 返回旧值"]
        R3 -- "未找到" --> R6["返回 null"]
    end

图表说明

  • 第一层(get 流程):读操作也持有互斥锁,因此任何需要读的场景都会阻塞其他读写。即便只是简单读取,也要等待写操作释放锁,这在高并发读多写少的场景下是巨大的性能杀手。
  • 第二层(remove 流程):删除时需找到节点的前驱 prev,进行链表指针修改。modCount 递增用于 ConcurrentModificationException 检测。
  • 第三层(锁影响):两个操作均锁 this,如果读操作频繁会极度降低系统吞吐量,这也是 ConcurrentHashMap 采用 volatile 读 + CAS 写的原因。

Part 4:遍历与序列化篇

模块 9:双遍历体系——Enumeration vs Iterator

Hashtable 同时支持两种遍历方式,体现了新老接口的过渡:

  • 遗留 Enumeration

    • public synchronized Enumeration<K> keys()
    • public synchronized Enumeration<V> elements()
      返回的 Enumeration 对象(内部类 Enumerator)基于 modCount 的快照检查实现 fail-fast
  • 现代 Iterator
    通过 keySet()values()entrySet() 获得集合视图,再调用这些集合的 iterator() 方法获得。内部类 Enumerator 同时实现了 EnumerationIterator 接口,所以一套代码就兼容了两种调用。源码中有一个巧妙的类 Enumerator<T> 实现了 Enumeration<T>Iterator<T>,因此暴露给用户的 keys() 返回的就是这个对象,但声明为 Enumeration;而 keySet().iterator() 也是同一个类实例。

接口与实现关系

private class Enumerator<T> implements Enumeration<T>, Iterator<T> {
    private final Entry<?,?>[] table;
    private int index = table.length;
    private Entry<?,?> entry;
    private Entry<?,?> lastReturned;
    private int expectedModCount = modCount;
    ...
}

fail-fast 机制:若遍历过程中检测到 modCount != expectedModCount,抛出 ConcurrentModificationException。但是,因为 Hashtable 的方法均为 synchronized,在单线程中直接调用 put/remove 等方法会修改 modCount,立即触发 CME;多线程时由于锁互斥,一个线程在迭代时另一个线程阻塞,本身不会发生并发修改,但若一个线程同步块外迭代并同时自己修改(普通代码路径),仍可能 CME。

Enumeration 与 Iterator 调用关系

flowchart TB
    A["Hashtable 实例"]
    B["keys() : Enumeration<K>"]
    C["elements() : Enumeration<V>"]
    D["keySet() : Set<K>"]
    E["entrySet() : Set<Map.Entry>" ]
    F["Enumerator<T> 内部类\n实现了 Enumeration + Iterator"]
    B --> F
    C --> F
    D -- "iterator()" --> F
    E -- "iterator()" --> F

图表说明

  • 第一层(Enumeration 分支):直接调用 keys()elements() 获得 Enumeration 对象,只能顺序向前读取,没有 remove 迭代器删除方法。
  • 第二层(Iterator 分支):通过 keySet()entrySet() 获得 Set 视图,然后在视图上调用 iterator() 获取 Iterator 对象。该对象支持 hasNext()next()remove()Enumeratorremove 调用 Hashtable.this.remove,且可以正常删除)。
  • 第三层(实现统一):两种遍历方式底层都是 Enumerator 实例,Enumerator 实现了两个接口,因此在遍历行为和 fail-fast 上完全一致。
  • 第四层(设计意义)Hashtable 是 Java 集合过渡期的典型,它用一套实现满足新旧两种规范,避免了重复代码。

模块 10:序列化与克隆

序列化机制
Hashtable 实现 Serializable,并自定义了 writeObjectreadObject 方法。因为 table 字段是 transient 的(避免数组被默认序列化可能带来的版本兼容问题),所以需要手动按桶遍历输出所有键值对:

private void writeObject(java.io.ObjectOutputStream s) throws IOException {
    synchronized(this) {  // 加锁保证序列化时数据一致性
        s.defaultWriteObject();
        s.writeInt(table.length);
        s.writeInt(count);
        for (int i = 0 ; i < table.length ; i++) {
            Entry<K,V> entry = (Entry<K,V>)table[i];
            while (entry != null) {
                s.writeObject(entry.key);
                s.writeObject(entry.value);
                entry = entry.next;
            }
        }
    }
}

反序列化 readObject 类似,从流中读取容量、元素个数、负载因子等,重建 Entry[] table 并重新构建所有链表。

克隆
clone() 方法进行浅拷贝:创建新的 Hashtable,但 Entry[] table 会被复制一份新数组,但每个槽位的 Entry 节点仍是原对象的引用,即表结构深,节点浅。修改副本的值会通过 Entry.setValue 影响原对象,因为 Entry 是共享的,但副本的增删不会影响原表结构(因为数组桶是新建的)。

public synchronized Object clone() {
    Hashtable<?,?> t = (Hashtable<?,?>)super.clone();
    t.table = new Entry<?,?>[table.length];
    // ... 复制头节点引用,形成新的链表结构
    t.modCount = 0;
    return t;
}

Part 5:对比与陷阱篇

模块 11:Hashtable vs HashMap——线程安全与设计差异

这两者的对比几乎是 Java 集合面试的常驻题目,我们以表格和流程图综合呈现。

核心差异维度

维度HashtableHashMap (JDK 8)
线程安全全方法 synchronized,锁 this非线程安全
null 处理Key/Value 均不可 null允许一个 null Key,多个 null Value
继承体系继承 Dictionary,实现 Map继承 AbstractMap,实现 Map
默认容量11(无需 2 的幂)16(必须为 2 的幂)
扩容算法2n + 12n(拆分高低链)
哈希计算直接 hashCode,无扰动h ^ (h >>> 16) 扰动函数
索引定位(hash & 0x7FFFFFFF) % length(n - 1) & hash
数据结构数组 + 链表(纯)数组 + 链表/红黑树
性能低(锁竞争 + 取模)高(无锁 + 位运算 + 树化)
遍历方式Enumeration + IteratorIterator
推荐度仅遗留系统使用主流单线程映射

对比鸿沟总结流程图

flowchart LR
    A["Hashtable"] 
    B["HashMap"]
    A --- C["线程安全: 全表锁 synchronized"]
    B --- D["线程不安全"]
    A --- E["null 不支持"]
    B --- F["null 键值支持"]
    A --- G["容量: 11 -> 23 -> 47 ..."]
    B --- H["容量: 16 -> 32 -> 64 ... 2的幂"]
    A --- I["取模定位, 无扰动"]
    B --- J["位与定位, 扰动函数"]
    A --- K["纯链表, 无树化"]
    B --- L["链表+红黑树 O(log n)"]

图表说明

  • 第一层(安全策略)Hashtable方法级互斥实现安全,但代价是所有操作串行化;HashMap 完全放弃线程安全换得极致单线程性能。
  • 第二层(null 态度)Hashtableget 返回 null 代表键不存在,若值允许 null 则会造成歧义,所以禁止 null 值;HashMap 通过 containsKey 分辨允许 null 值。Hashtable 继承自 Dictionary,其 get 方法没有像 HashMap 那样提供专门的补救机制。
  • 第三层(容量与散列)Hashtable112n+1 是古老质数优化思想的遗迹,而现代 HashMap 已证明 2 的幂 + 扰动函数 的综合效果更好。
  • 第四层(数据结构进化)Hashtable 停留在纯链表时代,一旦哈希冲突严重,效率暴跌;HashMap 在链表长度 ≥8 时转为红黑树,查询复杂度从 O(n) 降为 O(log n)。
  • 第五层(结论)Hashtable 在架构每一维度都落后于 HashMap,非并发场景下没有生存空间。

模块 12:Hashtable vs ConcurrentHashMap vs Collections.synchronizedMap——并发方案的演进

Java 并发容器的迭代史,就是 锁粒度不断细化的过程

  1. Hashtable —— 全局锁,synchronized 修饰方法,同一时间只允许一个线程访问整个表,读读互斥,吞吐量极低。
  2. Collections.synchronizedMap(Map) —— 装饰器模式,内部类 SynchronizedMap 包装任意 Map,通过 synchronized (mutex) 块提供同步,可以指定锁对象,但本质仍是全表锁,仅稍灵活。
  3. ConcurrentHashMap (JDK 8) —— 放弃分段锁,改用 CAS + synchronized 锁定单个桶头节点 的细粒度锁方案,读操作大多无锁(通过 volatile 读),写操作仅锁对应桶,并发度大幅提升。

三种方案在并发 put 下的锁行为对比(时序图)

sequenceDiagram
    participant T1 as 线程1
    participant T2 as 线程2
    participant HT as Hashtable
    participant SM as SyncMap
    participant CHM as ConcurrentHashMap
    
    Note over T1,HT: Hashtable 全方法锁
    T1->>HT: put("a",1) 获取 this 锁
    T2->>HT: put("b",2) 阻塞等待
    HT-->>T1: 返回
    HT-->>T2: 获取锁,执行 put
    
    Note over T1,SM: synchronizedMap 装饰
    T1->>SM: put("a",1) 获取 mutex 锁
    T2->>SM: put("c",3) 阻塞
    SM-->>T1: 返回
    SM-->>T2: 执行 put
    
    Note over T1,CHM: ConcurrentHashMap 桶级锁
    par 并发执行
        T1->>CHM: put("a",1) CAS尝试或锁桶[x]
        T2->>CHM: put("b",2) CAS尝试或锁桶[y]
    end
    CHM-->>T1: 返回
    CHM-->>T2: 返回

图表说明

  • 第一层(Hashtable 串行):线程1 持对象锁执行 put 期间,线程2 完全阻塞,即使它们要插入的键落在不同的桶,也无法并行。吞吐量受限于单一线程。
  • 第二层(SynchronizedMap 类似):只会锁住装饰器内部的 mutex 对象(默认即为包装的 Map 本身),同样全表串行,是换汤不换药。
  • 第三层(ConcurrentHashMap 并发):线程1 和线程2 操作不同的桶时,可以完全并行。JDK 8 内部利用 CAS 尝试无锁插入桶头,若冲突则用 synchronized 仅锁该桶的头节点,粒度极小。这使得并发写入的吞吐量是前者的数十倍。
  • 核心结论:从 HashtableConcurrentHashMap锁粒度的演进是 Java 并发容器性能革命的根本

模块 13:常见陷阱与最佳实践

陷阱 1:遗留系统性能瓶颈

  • 错误代码:遗留应用中线程池访问 Hashtable,所有读写争抢同一锁,造成 CPU 上下文切换风暴。
  • 正确做法:迁移到 ConcurrentHashMap,如果逻辑依赖于整个表的原子性(如 putIfAbsentcomputeIfAbsent), ConcurrentHashMap 早已提供原子操作,不必手动同步。平滑迁移可先保留 Hashtable 引用为 Map 接口,逐步替换实现。

陷阱 2:null 值引发 NPE

  • 错误代码
    Hashtable<String, Integer> ht = new Hashtable<>();
    ht.put("key", null);   // NPE
    ht.put(null, 1);       // NPE
    
  • 正确做法:如果需要 null,必须使用 HashMap,或使用特殊占位对象表示“空”,并在取值时解包装。

陷阱 3:遍历时修改导致 CME

  • 错误代码
    for (Map.Entry<String, Integer> e : ht.entrySet()) {
        if (e.getValue() == 0) ht.remove(e.getKey()); // CME
    }
    
  • 正确做法:使用 entrySet().removeIf() 或显式使用 Iteratorremove() 方法。

陷阱 4:Properties 继承 Hashtable 的类型混乱
java.util.Properties 继承 Hashtable<Object,Object>,本意是存储字符串键值对,但由于继承暴露了 put(Object,Object) 等方法,允许插入非字符串对象,将来 getProperty 强制转 String 时会 ClassCastException

  • 正确做法:使用 setProperty(String,String)getProperty(String),但禁止直接使用 put 方法。

Part 6:总结与面试篇

模块 14:性能总结与现代定位

  • 时间复杂度:在均匀哈希假设下,get/put/remove 均为 O(1),但常数因 synchronized 和取模运算而远大于 HashMap。链表超长时退化为 O(n)。
  • 空间代价:与 HashMap 相近,但因为没有红黑树节点额外空间(TreeNode 的父、左、右、颜色等引用),在链表短小时反而略省内存,但这是用性能换来的。
  • 现代定位:JDK 官方已在多处注明“如果不需要线程安全,请使用 HashMap;如果需要线程安全,请使用 ConcurrentHashMap”。Hashtable 仅保留为了向后兼容,不会添加新功能,不会被修复性能缺陷。结论就是,所有新代码严禁使用 Hashtable,遗留系统应制定计划逐步剔除。

模块 15:面试高频专题(独立详细)

以下每道高频题均包含标准回答、追问模拟和加分回答,供读者自我测验。

1. Hashtable 和 HashMap 的区别?为什么 Hashtable 被淘汰?

标准回答
Hashtable 是线程安全的,用 synchronized 修饰所有方法,性能低;HashMap 非线程安全,性能高。Hashtable 不允许 null 键值,HashMap 允许。继承体系上 Hashtable 继承 DictionaryHashMap 继承 AbstractMap。默认容量 11 vs 16,扩容 2n+1 vs 2n。哈希算法上 Hashtable 直接使用 hashCode 取模,HashMap 有扰动函数且用位与。Hashtable 没有红黑树,查询退化为 O(n)。新版本中 ConcurrentHashMap 全面替代它实现线程安全映射,所以 Hashtable 成为历史遗留。

追问:为什么 Hashtable 的线程安全反而成了缺点?
回答:因为它的锁粒度太粗——锁定整个 this,极度限制并发性能,即使读操作也互斥。而 ConcurrentHashMap 用分段锁和桶级锁实现了读读并发、写写部分并发,吞吐量是几十倍。

加分回答:从设计哲学看,Hashtable 是 JDK 1.0 “以同步保安全”的原始思路,但 Java 并发实践逐渐转向非阻塞算法和细粒度锁,它的淘汰是时代必然。

2. Hashtable 的线程安全是如何实现的?有什么问题?

标准回答:所有公开方法用 synchronized 修饰,锁对象为 this,保证同一时刻只有一个线程执行任意方法。问题在于锁粒度太粗,任何操作都独占整个哈希表,导致剧烈锁竞争,即使是高并发的读也串行化。

追问:方法级同步有没有可能造成死锁?
回答:单一锁一般不会直接死锁,除非外部代码以不一致的顺序锁定多个 Hashtable。但更常见的是活锁性能瓶颈

加分回答:内部 rehash 期间仍然保持锁,迁移所有元素耗时较长,此时其他所有线程完全阻塞,这在面向吞吐的系统中是灾难性的。

3. Hashtable 为什么不允许 null 键和值?HashMap 为什么允许?

标准回答Hashtable 不允许 null 是因为它继承 Dictionaryget 返回 null 表示键不存在,如果值允许 null 则造成二义性。而 HashMap 通过 containsKey 方法可以区分,因此允许一个 null 键和多个 null 值。Hashtable 也可以在内部用特殊判断支持,但历史设计未做。

追问:从源码角度来看具体的 Null 检测机制?
回答put 方法第一行 if (value == null) throw new NullPointerException();;键的检查是调用 key.hashCode() 时隐含的。

加分回答ConcurrentHashMap 同样不允许 null 键值,因为并发环境下 null 的语义更加模糊,ConcurrentHashMapget 可能由于并发删除返回 null,若允许值 null 则会混淆“无键”与“值为 null”。

4. Hashtable 的初始容量为什么是 11?扩容为什么是 2n+1 而非 2 倍?

标准回答:11 是个质数,早期认为质数模数可以使哈希值分布更均匀。扩容 2n+1 也是为了保持奇数,且数字序列为 11,23,47... 偏向素数,以延续质数分布优势。而 HashMap 选择 2 的幂是为用高效位运算替代取模。

追问:那么奇数真的能有效减少碰撞吗?
回答:当一个数是偶数时,hash % even 结果的奇偶性和 hash 相关,可能导致某些模式聚集;奇数取模能混合奇偶位,对简单 hashCode 更友好。但现代扰动函数已经让 2 的幂表表现更优。

加分回答Hashtable 选择奇数并非必须是素数,实际扩容时 23、47 是质数,但后续 95、191 中 95 不是质数,已脱离严格素数路线。

5. Hashtable 的哈希如何计算?与 HashMap 的扰动函数有何不同?

标准回答Hashtable 直接 int hash = key.hashCode(),然后 (hash & 0x7FFFFFFF) % length。没有额外扰动。HashMap(key.hashCode()) ^ (hash >>> 16),把高位信息混入低位,再通过 (n-1) & hash 取模。扰动函数减少了低位相同导致的碰撞,尤其在小容量表时效果显著。

追问:这种差异在性能上造成多大影响?
回答:在没有树化时,碰撞增多会使链表长度增加,查询接近 O(n);HashMap 扰动+树化组合使其在重碰撞下仍保持 O(log n)。

加分回答:可以手写 demo 展现两个容器在相同低质量 hashCode (如 return 1 或固定值) 下的链表长度差异,Hashtable 所有元素在一个桶内,性能急剧恶化。

6. Hashtable 的 Enumerator 和 Iterator 有什么区别?

标准回答Enumerator 是 JDK 1.0 遗留遍历接口,方法 hasMoreElements()/nextElement(),不支持遍历中删除。Iterator 是集合框架的标准,支持 hasNext()/next()/remove()Hashtable 的内核类 Enumerator 同时实现了这两个接口,所以一套遍历逻辑,两种外部体会。

追问:遍历时修改会怎么样?
回答:两者都会检测 modCount,若在迭代器外修改了映射,抛出 ConcurrentModificationException。但是,因为 Hashtable 方法同步,在多线程下迭代期间其他线程试图修改会被阻塞,但仍可能发生单线程自己修改导致的 CME。

加分回答Enumerator 不支持 remove 操作,所以迭代删除只能用 Iteratorremove(),或使用 entrySet().removeIf 等。

7. Hashtable 和 ConcurrentHashMap 的区别?如何从 Hashtable 平滑迁移?

标准回答:并发上,Hashtable 是表级锁,ConcurrentHashMap 是桶级锁 + CAS,并发性能碾轧。数据结构上,Hashtable 纯链表,ConcurrentHashMap 链表+红黑树。迁移步骤:将 Hashtable 引用改为 ConcurrentHashMap,如果有复合操作(如 if(!map.containsKey(k)) map.put(k,v))则替换为 putIfAbsent;注意 null 限制与 Hashtable 一致。清理所有直接使用 synchronized 在 map 上的外部同步代码。

追问:如果代码中有 Enumeration 遍历怎么办?
回答ConcurrentHashMap 没有 Enumeration,必须改为 keys()IteratorkeySet() 增强 for 循环。

加分回答:迁移可以渐进式进行,将 Map 接口引用切割,底层替换,逐步消除对 Hashtable 类型的依赖。

8. Hashtable 和 Collections.synchronizedMap 的区别?为什么后者也不能完全替代 ConcurrentHashMap?

标准回答Collections.synchronizedMap 是对 Map 的包装,内部用同步块围住方法调用,也是全表锁。区别是它可以指定锁对象,且可以包装任意 Map(如 HashMapLinkedHashMap)。但本质仍是全局锁,迭代同样需要外部同步。因此也无法匹敌 ConcurrentHashMap 的并发度。

追问:syncMap 有什么典型的错误用法?
回答:迭代时没加 synchronized 块导致 CME,或以为线程安全就多线程并发迭代。

加分回答ConcurrentHashMap 设计目标就是高并发、弱一致性,迭代器不抛出 ConcurrentModificationException,而是在安全前提下尽可能反映当时映射状态,这种设计极其不同。

9. 在高并发下 Hashtable 性能瓶颈的根本原因是什么?

标准回答:根本原因是单一锁竞争线程上下文切换。所有操作争抢 this 锁,锁成为临界资源,CPU 大量时间花在锁的挂起和唤醒上,而非业务逻辑。另外,扩容期间持锁迁移所有元素,阻塞时间极长,瞬时流量高峰可能系统雪崩。

追问:可以用减少锁粒度的方法改造 Hashtable 吗?
回答:理论上可以,但不如直接使用 ConcurrentHashMap,后者已经做到了工业级极致优化。

加分回答:从硬件角度,频繁的锁竞争导致缓存一致性协议流量剧增(MESI 协议),“伪共享”和锁升级开销使吞吐量接近单核水平。

10. Properties 类继承自 Hashtable,有什么设计问题?

标准回答Properties 用来存放配置,初衷是 String 键值对,但由于继承 Hashtable<Object,Object>,用户可以直接调用 put(Object,Object),放入非字符串对象,造成类型污染,后续 getProperty 发生 ClassCastException。历史原因没有设计独立的接口和类型安全的实现。推荐使用 load/store 方法加载,使用 setProperty(String,String) 赋值,严禁直接 put

追问:如何避免该问题?
回答:代码中应限制 Properties 引用范围,严格 string 类型操作,或使用替代的配置管理库。

加分回答:Java 9 以后推荐使用 Map.of 等不可变语法,配置类可以用类型安全的 java.util.prefs.Preferences 或专门的配置库。