本文从 线程安全性性能特点历史版本继承关系扩容机制等方面的区别,详细对比 HashtableHashMap 两种 哈希表的实现类的区别

一、主要区别

HashtableHashMap都是用于存储键值对的哈希表实现,但它们在设计、功能和性能上存在显著差异

线程安全性

  • Hashtable:线程安全。 它的所有公共方法都使用synchronized关键字进行修饰,这意味着在任何给定时间,只有一个线程可以访问Hashtable的实例。这种粗粒度的锁机制在多线程环境下可以保证数据一致性,但会带来较大的性能开销
  • HashMap:非线程安全。 它的方法没有synchronized修饰。在单线程环境下性能很高,但在多线程环境下,如果不进行外部同步控制,可能会导致数据不一致、死循环等问题

性能

  • Hashtable:性能相对较低。 由于其所有操作都进行了同步,在多线程并发访问时,线程会频繁地竞争同一把锁,导致性能下降
  • HashMap:性能相对较高。 在单线程环境下,由于没有同步开销,其性能优于Hashtable。在并发场景下,通常会使用ConcurrentHashMap(而非HashMap加外部同步),它提供了更细粒度的锁机制,性能远优于Hashtable

允许null键和null

  • Hashtable键和值都不允许null。 如果尝试存入null键或null值,会抛出NullPointerException
  • HashMap键和值都允许nullHashMap允许一个null键(且只有一个),以及任意数量的null

历史版本与继承关系

  • Hashtable 是Java早期(JDK 1.0)提供的集合类,属于遗留类。它继承自Dictionary抽象类,并实现了MapCloneableSerializable接口
  • HashMap 是Java 2(JDK 1.2)引入的,是Hashtable的改进版本。它继承自AbstractMap抽象类,并实现了MapCloneableSerializable接口

迭代器(Iterator)

  • Hashtable 提供了Enumeration(旧版迭代器)和Iterator两种遍历方式。Enumeration不是fail-fast的,而Iterator是fail-fast的
  • HashMap 只提供Iterator遍历方式,并且是fail-fast的。这意味着在迭代过程中,如果集合的结构被修改(除了通过迭代器自身的remove()方法),会立即抛出ConcurrentModificationException

初始容量和扩容机制

  • Hashtable 默认初始容量为11,负载因子(Load Factor)为0.75。扩容时,容量变为2*oldCapacity + 1
  • HashMap 默认初始容量为16(必须是2的幂),负载因子为0.75。扩容时,容量变为2*oldCapacity

总结表格

Hashtable HashMap
线程安全 线程安全 (所有方法synchronized) 非线程安全
性能 较低 (锁粒度粗) 较高 (无同步开销)
Null键/值 不允许 null键和null 允许 null键 (一个) 和 null值 (多个)
历史版本 JDK 1.0 遗留类 JDK 1.2 引入,Hashtable的改进
继承体系 继承Dictionary,实现Map 继承AbstractMap,实现Map
迭代器 Enumeration (非fail-fast) 和 Iterator (fail-fast) Iterator (fail-fast)
默认容量 11 16 (2的幂)
扩容方式 2*oldCapacity + 1 2*oldCapacity
推荐使用 几乎不推荐,除非是遗留代码或特定要求 单线程环境下首选,多线程用ConcurrentHashMap

二、扩容机制

HashMap 的扩容机制

  • 默认初始容量: HashMap的默认初始容量是16。当你创建一个HashMap实例时,内部的数组table并不会立即创建,而是等到第一次调用put()方法时才会被初始化为16
  • 负载因子(Load Factor): 默认负载因子是0.75。它表示在哈希表自动扩容之前,可以达到多满
  • 扩容阈值(Threshold): 扩容阈值 = 容量 × 负载因子。例如,初始容量16 * 0.75 = 12。当HashMap中存储的元素数量(size)超过这个阈值时,就会触发扩容
  • 扩容触发条件:
    1. 当前存储的元素数量 size > 扩容阈值 threshold
    2. 插入元素时发生哈希冲突,并且链表长度达到TREEIFY_THRESHOLD(8),同时当前容量capacity小于MIN_TREEIFY_CAPACITY(64),这时会优先扩容而不是直接树化
  • 扩容过程:
    1. 创建新数组: 创建一个新的、更大的数组。新数组的容量通常是原容量的两倍oldCapacity << 1
    2. 元素迁移(Rehashing): 遍历旧数组中的所有桶(包括链表和红黑树),将每个元素重新计算哈希值(因为数组长度变了,index = hash & (newCapacity - 1)会得到不同的索引),然后将其放到新数组的正确位置
      • JDK 1.7 及之前: 链表中的元素在迁移时,可能会出现头插法导致的死循环问题(在多线程并发扩容时)
      • JDK 1.8 及之后: 链表中的元素在迁移时,采用尾插法,并且由于新容量是旧容量的两倍,元素的新的索引要么是原索引,要么是原索引 + oldCapacity。这大大简化了迁移过程,避免了死循环,并提升了效率。链表元素会根据其哈希值的某个位是0还是1,分别放入新数组的两个位置,形成两个新的链表(或树)
  • 延迟初始化: HashMap的底层数组table是在第一次put操作时才进行初始化的,而不是在构造函数中

关于更加详细的 HashMap 扩容及源码剖析佐证,可以阅读上一篇博客:【集合篇04】:HashMap扩容机制,resize()源码解读_hashmap resize-CSDN博客

Hashtable 的扩容机制

  • 默认初始容量: Hashtable的默认初始容量是11
  • 负载因子(Load Factor): 默认负载因子是0.75
  • 扩容阈值: 扩容阈值 = 容量 × 负载因子
  • 扩容过程:
    1. 创建新数组: 创建一个新的数组。新数组的容量是原容量的两倍加12 * oldCapacity + 1
    2. 元素迁移(Rehashing): 遍历旧数组,将所有元素重新计算哈希值并放到新数组的正确位置。这个过程同样会涉及到元素的重新散列

总结表格对比

HashMap Hashtable
默认初始容量 16 (2的幂) 11
负载因子 0.75 (默认) 0.75 (默认)
扩容阈值 容量 * 负载因子 容量 * 负载因子
新容量计算 原容量的两倍 (oldCapacity << 1) 原容量的两倍加1 (2 * oldCapacity + 1)
数组初始化 延迟初始化 (第一次put时) 立即初始化 (构造函数时)
元素迁移 JDK 1.8 优化,链表元素按位拆分,避免死循环 简单重新哈希
线程安全 非线程安全 (扩容过程无同步,多线程可能数据丢失) 线程安全 (扩容过程也同步,性能开销大)
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐