简介
Java 集合框架(Java Collections Framework, JCF)是 Java 平台的一部分,为数据的存储和操作提供了一整套强大的接口和实现。Java 集合框架不仅仅是一些数据结构的聚合,它通过一组精心设计的接口和类提供了数据操作的各种算法和机制。在这一框架中,集合被定义为存储对象的容器。这使得开发人员可以操作集合中的对象,而无需关心集合的具体实现细节。
Java 集合框架的核心接口
Java 集合框架主要包括以下几类接口和它们的实现:
- Collection****接口:这是最基本的集合接口,它有几个重要的子接口:
-
- List:一个有序的集合,可以包含重复的元素。实现类包括
ArrayList
,LinkedList
等。 - Set:一个不允许重复元素的集合。常见的实现有
HashSet
,LinkedHashSet
,TreeSet
等。 - Queue:一种用于在处理前保持元素的集合。典型的队列实现如
LinkedList
(也实现了List
)、PriorityQueue
等。 - Deque:双端队列,允许元素从两端被添加或移除,如
ArrayDeque
和LinkedList
。
- List:一个有序的集合,可以包含重复的元素。实现类包括
- Map****接口 :不是
Collection
的子接口,但也是集合框架的一部分。Map
存储键值对(key-value pairs)的集合,其中键是唯一的。常见实现包括:
-
- HashMap:基于哈希表的
Map
实现,存取时间为常数时间,即 O(1)。 - TreeMap:基于红黑树的
Map
实现,可以根据键的自然顺序或构造器中提供的Comparator
进行排序。 - LinkedHashMap:类似
HashMap
,但它维护了一个运行于所有条目的双向链表,从而保持插入顺序。
- HashMap:基于哈希表的
集合框架的主要类和特点
- ArrayList:基于动态数组实现,提供随机访问和快速迭代。
- LinkedList:基于双向链表实现,优化了元素的插入和删除操作。
- HashSet:基于哈希表实现,是最快的 Set 实现,不保证顺序。
- TreeSet:基于红黑树实现,元素按自然顺序或创建时提供的比较器排序。
- LinkedHashSet:结合了哈希表和链表实现,保持了插入顺序。
集合操作的算法
Java 集合框架提供了强大的工具类,如 Collections
和 Arrays
,这些类为集合和数组类提供了静态方法来执行常见的任务,比如排序、搜索和线程安全化包装等:
- 排序 :
Collections.sort()
和Arrays.sort()
方法可以对 List 或数组进行排序。 - 搜索 :
Collections.binarySearch()
和Arrays.binarySearch()
提供了二分搜索算法的实现。 - 线程安全化 :
Collections.synchronizedList()
和类似方法可以将非线程安全的集合转换为线程安全的集合。
Java 集合框架是 Java API 中不可或缺的一部分,为处理对象集合提供了一系列强大的工具。了解和掌握这个框
架是每个 Java 开发者必备的技能。通过这些集合类和接口,可以极大地提升数据操作的效率和质量。不同的集合类型有着各自的使用场景和优缺点,选择合适的集合类型对性能优化至关重要。此外,集合框架的扩展性和灵活性也使得 Java 在企业级开发中得到了广泛应用。
List
Java 中的 List
接口是一个非常核心的数据结构,它属于 Java 集合框架的一部分,位于 java.util
包下。List
接口为集合元素提供了一个有序的序列,并且允许包含重复的元素。这意味着每个元素都有其对应的索引,第一个元素的索引为 0,最后一个元素的索引为 list.size()-1
。List
是一个接口,它定义了操作列表(比如添加、删除、获取元素)的基本方法,但具体的实现则依赖于其实现类。
List
接口的主要方法
List
接口提供了一系列方法用以操作集合中的元素:
- 添加元素:
-
add(E e)
: 在列表的末尾添加指定的元素。add(int index, E element)
: 在列表的指定位置插入元素。
- 访问元素:
-
get(int index)
: 返回列表中指定位置的元素。iterator()
: 返回在列表中的元素上进行迭代的迭代器。listIterator()
: 返回列表中元素的列表迭代器。
- 修改元素:
-
set(int index, E element)
: 替换列表中指定位置的元素。
- 删除元素:
-
remove(Object o)
: 从列表中移除第一次出现的指定元素(如果存在)。remove(int index)
: 移除列表中指定位置的元素。
- 查询操作:
-
indexOf(Object o)
: 返回列表中指定元素首次出现的位置。lastIndexOf(Object o)
: 返回列表中指定元素最后出现的位置。size()
: 返回列表中的元素数。isEmpty()
: 判断列表是否为空。
- 范围操作:
-
subList(int fromIndex, int toIndex)
: 返回列表中指定的从起始索引到终止索引的子列表。
- 其他操作:
-
clear()
: 移除列表中的所有元素。contains(Object o)
: 判断列表是否包含指定的元素。
List
的主要实现类
为了深入理解 List
的不同实现,我们需要详细探讨其三个主要实现类:ArrayList
、LinkedList
和 Vector
,并扩展到如 CopyOnWriteArrayList
这样的并发集合实现。这些实现各有特点和适用场景,了解它们的内部机制有助于在实际开发中作出合适的选择。
1. ArrayList
ArrayList
是最常用的 List
实现之一,基于动态数组(Resizable Array)构建。以下是其特点和工作原理的详细解析:
- 内部结构 :
ArrayList
内部使用数组存储元素。初始时,数组大小通常是10,但这个值可以在构造函数中设置。当添加元素超出当前容量时,ArrayList
会自动扩容,通常是将数组大小增加到原来的1.5倍。 - 性能分析:
-
- 随机访问:获取指定索引的元素是 O(1),因为底层是数组。
- 添加元素 :添加元素到
ArrayList
的末尾是平摊 O(1)。但如果在列表中间插入或删除元素,需要移动后续所有元素,这种操作的时间复杂度为 O(n)。 - 内存空间 :
ArrayList
扩容机制可能会导致内存占用较高,因为在扩容过程中,旧数组需要被完整复制到新数组中。
- 应用场景 :
ArrayList
适合于频繁的查找操作和在列表末尾添加或删除元素的场景。
2. LinkedList
LinkedList
提供了一个双向链表的实现,支持对数据的高效插入和删除操作。
- 内部结构 :
每个元素(节点)包含三部分:数据、指向前一个节点的引用和指向后一个节点的引用。LinkedList
还维护着指向首尾节点的引用,以优化对头尾元素的操作。 - 性能分析:
-
- 随机访问:访问中间的元素需要从头节点或尾节点开始遍历,时间复杂度为 O(n)。
- 添加和删除 :向
LinkedList
的开头或结尾添加或删除元素只需要 O(1)。插入和删除中间元素的操作虽然也是 O(1),但找到插入位置需先遍历到该位置,总体为 O(n)。 - 内存消耗 :每个元素需要额外空间存储两个引用,相比
ArrayList
,这增加了内存的使用。
- 应用场景 :
LinkedList
适合于插入和删除操作频繁的场景,特别是在列表的头部或尾部。
3. Vector
Vector
是 ArrayList
的同步对应版本,提供了线程安全的动态数组实现。
- 内部结构 :
与ArrayList
类似,Vector
内部也是使用数组来存储元素。不同之处在于,Vector
的方法都是同步的,使用了synchronized
关键字。 - 性能分析:
-
- 随机访问 :与
ArrayList
相同,时间复杂度为 O(1)。 - 添加元素:由于方法是同步的,当多线程尝试修改 `Vector
- 随机访问 :与
` 时,可能会引起线程阻塞,这降低了性能。
- 扩容机制 :默认情况下,
Vector
扩容时增加一倍,而不是ArrayList
的1.5倍。 - 应用场景 :
在需要保证线程安全的场景中使用,但现代多线程程序中通常推荐使用java.util.concurrent
包的类,如CopyOnWriteArrayList
。
4. CopyOnWriteArrayList
作为并发环境下的 List
实现,CopyOnWriteArrayList
提供了一种读写分离的高效机制。
- 内部结构 :
类似于ArrayList
,但在修改操作(添加、设置、删除等)时,CopyOnWriteArrayList
会复制整个底层数组。读操作不需要加锁,因为它们作用于不变的数组快照。 - 性能分析:
-
- 读取操作:非常高效,因为不需要同步。
- 写入操作:较慢且消耗内存,因为每次修改都需要复制数组。
- 应用场景 :
适用于读多写少的并发应用场景。
以上对 List
的主要实现类的详细分析展示了不同类的内部结构、性能特征以及适用场景,这有助于开发者在面对具体需求时,做出合理的技术选择。
Set
Java 中的 Set
接口是集合框架的一个重要部分,它代表了一个不包含重复元素的集合。这一接口主要用于存储不允许重复的元素,强调的是元素的唯一性而不是顺序。在 Java 的 java.util
包中,Set
是所有集合类实现类的共同父接口之一。下面我们将详细探讨 Set
接口的核心特点及其各个实现类。
Set 接口的特点
- 不允许重复元素 :
Set
最主要的特征是它不允许集合中有重复的元素。 - 无序集合 :除了
LinkedHashSet
保持元素的插入顺序外,大多数Set
实现不保证元素的顺序。 - 基本操作:提供添加、删除、清空、包含等操作,不支持索引访问。
Set 的主要实现类
Java的Set
接口在Java集合框架中扮演着核心角色,提供了一个无序且不允许重复元素的集合模型。为了更深入地理解Set
接口的不同实现,我们将详细探讨每种实现的内部结构、性能特性以及最佳应用场景。重点分析的实现包括HashSet
、LinkedHashSet
、TreeSet
、EnumSet
和CopyOnWriteArraySet
。
1. HashSet
HashSet
是基于哈希表的Set
实现,它如何存储元素、处理冲突、以及其性能影响是理解它的关键。
- 内部结构 :
HashSet
内部实际上是通过一个HashMap
实现的。每个插入的元素都作为键存储在HashMap
中,所有的值都是一个固定的虚拟对象。这种方法利用了HashMap
强大的键控唯一性来确保没有重复元素。 - 性能特点:
-
- 时间复杂度:理想情况下,添加、删除、查找操作的时间复杂度为O(1)。然而,这依赖于哈希函数的质量和冲突解决机制。
- 扩容和冲突:当哈希表中的元素超过负载因子定义的容量(默认为0.75)时,哈希表将会扩容,即增加存储空间并重新散列所有元素。这个过程可能导致短暂的性能下降。
- 应用场景 :
HashSet
适用于不需要维护元素顺序的情况,特别是在查找操作频繁的场景中。
2. LinkedHashSet
LinkedHashSet
继承自HashSet
,并通过维护一个元素插入顺序的双向链表来扩展HashSet
。
- 内部结构 :
LinkedHashSet
基于LinkedHashMap
,这意味着每个元素的插入顺序被保留。元素同时存在于哈希表和链接列表中,后者保证了迭代的顺序性。 - 性能特点:
-
- 时间复杂度 :与
HashSet
相同,大部分操作都是O(1)。 - 顺序迭代 :与
HashSet
相比,迭代LinkedHashSet
更加高效,因为它通过链表顺序访问元素。
- 时间复杂度 :与
- 应用场景 :
当需要保持元素添加顺序时,LinkedHashSet
是更好的选择。
3. TreeSet
TreeSet
提供了一个基于红黑树的有序集合实现,支持全序的维护。
- 内部结构 :
TreeSet
是基于TreeMap
实现的,利用红黑树这种自平衡的二叉搜索树来存储元素。元素按自然顺序或构造时提供的比较器排序。 - 性能特点:
-
- 时间复杂度:添加、删除和查找操作的时间复杂度为O(log n),这比哈希表实现的O(1)慢,但提供了排序。
- 有序迭代:可以非常高效地按顺序迭代元素。
- 应用场景 :
TreeSet
非常适合需要有序访问元素的场景,如实现范围搜索和排序元素。
4. EnumSet
EnumSet
是 Java 集合框架中的一个非常特别的实现,专门为枚举类型(enum
)设计。这种 Set
实现是使用位向量(bit vectors)进行枚举元素的表示,提供了极高的效率和性能,尤其在添加、删除和查询操作上。下面将详细介绍 EnumSet
的原理、内部实现和性能特点。
EnumSet 的设计原理
EnumSet
的设计是基于两个主要目的:提供一个枚举类型的高效集合操作,以及保持与 Set
接口的兼容性。由于枚举类型的数据具有固定数量且序数(ordinal)固定,EnumSet
可以使用一个非常紧凑和高效的表示方法------位字段(bit fields)。
内部结构和实现
EnumSet
的内部实际上是通过私有的位向量来实现的。每个枚举实例对应位向量中的一个位位置,该位置标记该枚举常量是否存在于集合中。这种表示方法允许 EnumSet
高效地执行各种集合操作:
- 存储机制 :
EnumSet
使用一系列长整型(long
)数组,每个长整型可以表示 64 个枚举常量(因为long
类型有 64 位)。因此,一个枚举类型的所有常量可以通过一个或多个long
数组表示,具体取决于枚举中常量的数量。 - 操作细节:
-
- 添加元素:设置相应的位。例如,要添加的枚举常量的序数是 n,那么就将第 n 位设置为 1。
- 删除元素:清除相应的位。
- 检查元素是否存在:检查相应的位是否为 1。
- 迭代集合:通过检查每一位是否为 1 来顺序遍历枚举常量。
性能特点
由于 EnumSet
的底层实现是基于位操作的,它在性能上有着显著的优势:
- 空间效率 :使用位数组比使用常规的
HashSet
或TreeSet
需要更少的空间,因为它直接操作原始数据类型(long
),而不需要为每个枚举值维护一个独立的对象节点。 - 时间效率:添加、删除和查询操作的时间复杂度接近 O(1),因为这些操作只涉及简单的位操作,而位操作在现代计算机架构中是极其快速的。
- 迭代性能:虽然迭代性能不如基于哈希或树结构的集合,因为它需要遍历所有可能的枚举值来检查它们是否存在于集合中,但这在枚举常量数量有限的情况下通常不是问题。
使用场景
EnumSet
是处理枚举类型集合的最佳选择,尤其是在需要高性能集合操作的场景下。它非常适用于需要频繁访问和修改枚举集合的场景,例如状态标记、特性支持检查等。
总结来说,EnumSet
提供了一种高效且空间节省的方式来处理枚举类型的集合数据。它的设计充分利用了枚举类型固有的限制和特性(如有限的序数范围),使得在实际使用中,EnumSet
能够提供出色的性能和效率。在适用的场景下,EnumSet
是比 HashSet
或 TreeSet
更
优的选择。
5. CopyOnWriteArraySet
CopyOnWriteArraySet
是一个线程安全的Set
实现,基于CopyOnWriteArrayList
。
- 内部结构 :
该集合的所有可变操作(如添加、删除)都是通过制作底层数组的新副本来实现的。 - 性能特点:
-
- 写操作昂贵:每次修改都涉及复制整个数组,适合读多写少的场景。
- 迭代器安全 :迭代器基于数组的快照,不会抛出
ConcurrentModificationException
。
- 应用场景 :
适用于读操作远多于写操作,且需要线程安全的环境中。
每种Set
实现都有其特定的优势和限制。选择合适的实现取决于具体的应用需求,如元素的顺序、操作的性能要求、数据量大小以及线程安全性等。理解这些实现的内部机制不仅有助于正确选择使用场景,还能在必要时进行性能优化,确保Java应用的高效和健壯性。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
在Java中,HashSet
、LinkedHashSet
和TreeSet
都是实现了Set
接口的集合类,它们主要用于存储不重复的元素集。尽管它们的目的相同,但它们在内部数据管理、元素排序以及性能方面各有特点。
基本特性和内部实现
- HashSet
-
- 实现方式 :基于哈希表(实际上是一个
HashMap
实例)。 - 顺序:元素没有固定顺序,顺序有可能随着元素的插入和移除发生改变。
- 性能:提供快速的查询速度,增加和查找元素通常具有常数时间的复杂度,即O(1);性能高度依赖于哈希函数的效率。
- 用途:适用于需要快速查找的情况,不关心集合元素的顺序。
- 实现方式 :基于哈希表(实际上是一个
- LinkedHashSet
-
- 实现方式 :基于
LinkedHashMap
,维护了一个双向链表来记录插入顺序。 - 顺序:元素按照它们被添加到集合中的顺序排列。
- 性能 :略低于
HashSet
,因为维护元素顺序的开销,但增加和查询元素的时间复杂度依然是O(1)。 - 用途:适用于需要维护插入顺序的场景。
- 实现方式 :基于
- TreeSet
-
- 实现方式:基于红黑树(一种自平衡的二叉查找树)。
- 顺序 :元素按照自然排序顺序(如实现
Comparable
接口)或者根据构造时提供的Comparator
进行排序。 - 性能 :增加和查询元素的时间复杂度为O(log n),这比
HashSet
和LinkedHashSet
慢。 - 用途:适用于需要有序集合的场景,如范围搜索和排序遍历。
比较和对比
- 存储机制:
-
HashSet
和LinkedHashSet
内部实现基于哈希表,而TreeSet
是基于树结构(红黑树)。
- 元素顺序:
-
HashSet
不保证任何顺序;LinkedHashSet
保持元素的添加顺序;TreeSet
按照元素的排序顺序(自然排序或者比较器排序)维护元素。
- 性能差异:
-
HashSet
在性能上通常最优,特别是在查找和插入操作上;LinkedHashSet
性能略低,因为维护了元素的插入顺序;TreeSet
在添加和查询操作上需要更多时间,因为涉及到树的平衡和遍历。
- 功能特性:
-
TreeSet
提供了丰富的功能,如first()
,last()
,headSet()
,tailSet()
等,这些在HashSet
和LinkedHashSet
中不可用。LinkedHashSet
能维持元素的添加顺序,这在迭代访问时非常有用。
- 空间使用:
-
TreeSet
通常会使用比HashSet
和LinkedHashSet
更多的内存,因为红黑树结构需要更多的指针和颜色信息。
应用选择
选择哪种类型的集合主要取决于你的具体需求:
- 如果你需要快速访问且不关心元素顺序,那么
HashSet
可能是最好的选择。 - 如果你需要保持元素
插入顺序,LinkedHashSet
是更适合的选择。
- 如果你需要一个有序集合,可以进行快速范围搜索,
TreeSet
会是最佳选择。
在实际应用中,正确的选择可以提高程序的效率和性能。
Queue
Java 中的 Queue
接口是 Java 集合框架的一部分,代表了先进先出(FIFO)的数据结构,尽管实际上某些 Queue
实现可能不完全遵循 FIFO 的行为。Queue
提供了一种管理数据集合的方式,其中元素在被处理前保持排队。这个接口主要用于控制元素的插入和删除操作。
Queue 接口的基本操作
Queue
接口定义了几种方法,可以分为两组:一组在操作失败时抛出异常,另一组在操作失败时返回特殊值(如 null
或 false
)。
- 插入操作
-
add(E e)
: 添加一个元素至队列末尾。如果成功,返回true
;如果没有可用空间,抛出IllegalStateException
。offer(E e)
: 添加一个元素至队列末尾。如果成功,返回true
;如果没有可用空间,返回false
。
- 删除操作
-
remove()
: 移除并返回队列头部的元素。如果队列为空,抛出NoSuchElementException
。poll()
: 移除并返回队列头部的元素。如果队列为空,返回null
。
- 检查操作
-
element()
: 返回队列头部的元素但不移除它。如果队列为空,抛出NoSuchElementException
。peek()
: 返回队列头部的元素但不移除它。如果队列为空,返回null
。
Queue 与 Deque
Java中的Queue
和Deque
接口是Java集合框架的一部分,提供了在队列结构中管理元素的一系列功能。为了深入理解这两个接口的作用、实现及其使用场景,我们将从基本概念入手,逐步探讨它们的特性、实现类和典型应用。
1. 队列(Queue)基础
1.1 Queue
接口概述
在Java中,Queue
接口定义了队列的基本操作。队列是一种特殊的线性数据结构,遵循先进先出(FIFO)的原则。但是,队列的实现可能不仅仅是标准的FIFO,例如优先队列(PriorityQueue)按照元素的优先级来确定出队顺序。
Queue
接口提供的基本操作包括:
boolean add(E e)
:添加一个元素至队尾。如果队列已满,抛出一个IllegalStateException
。boolean offer(E e)
:添加一个元素至队尾。如果队列已满,返回false
。E remove()
:移除队首元素。如果队列为空,抛出一个NoSuchElementException
。E poll()
:移除并返回队首元素。如果队列为空,返回null
。E element()
:返回队首元素但不移除。如果队列为空,抛出一个NoSuchElementException
。E peek()
:返回队首元素但不移除。如果队列为空,返回null
。
1.2 Queue
的实现
Queue
接口有多种实现,每种实现有其特定的用途:
- LinkedList :实现了
Queue
接口的链表,提供标准的FIFO队列操作。 - PriorityQueue:根据元素的自然排序或比较器来确定元素的优先级,不保证同等优先级元素的顺序。
- ArrayDeque:基于数组的双端队列,可以高效地在两端添加或删除元素。
- LinkedBlockingQueue:一个基于已链接节点、范围任意的阻塞队列。
- ConcurrentLinkedQueue:一个基于链接节点、线程安全的无界非阻塞队列。
2. 双端队列(Deque)深入
2.1 Deque
接口概述
Deque
(双端队列)接口扩展了Queue
接口,提供从两端插入和移除元素的能力。这种灵活性使得双端队列可以用作普通队列和栈。Deque
支持的操作包括:
- 在两端添加元素的方法(如
addFirst
,addLast
,offerFirst
,offerLast
) - 从两端移除元素的方法(如
removeFirst
,removeLast
,pollFirst
,pollLast
) - 查看两端元素的方法(如
getFirst
,getLast
,peekFirst
,peekLast
) - 事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。
2.2 Deque
的实现
Deque
接口的主要实现类有:
- ArrayDeque:一个基于数组的高效双端队列实现。
- LinkedList :同时实现了
List
和Deque
接口的链表。
3. 结论与思考
在选择Queue
或Deque
的具体实现时,开发者应考虑其性能特性和场景需求。例如,在多线程环境下,选择线程安全的实现(如LinkedBlockingQueue
),而在需要高效地在两端操作数据时,选择ArrayDeque
可能更为适宜。理解这些数据结构的内部工作机制可以帮助开发者更好地利用Java集合框架提供的丰富功能,优化应用程序的数据处理和性能表现。
BlockingQueue
在Java并发编程中,BlockingQueue
(阻塞队列)是一个非常重要的接口,它扩展自Queue
接口。阻塞队列不仅支持常规的队列操作,还能在队列为空时阻止(或阻塞)数据的检索操作,在队列满时阻塞添加操作。这种特性使得阻塞队列成为生产者-消费者问题中的理想选择。
1. BlockingQueue
接口概述
1.1 基本功能
BlockingQueue
接口在java.util.concurrent
包中定义,提供了以下几种基本操作:
put(E e)
:将元素插入队列尾部,如果队列满,等待队列可用。take()
:移除并返回队列头部的元素,如果队列为空,等待直到有元素可用。offer(E e, long timeout, TimeUnit unit)
:尝试在指定时间内将元素插入队列,如果在指定时间内队列仍然满,则返回false
。poll(long timeout, TimeUnit unit)
:尝试在指定时间内从队列取出元素并返回,如果在指定时间内队列仍然空,则返回null
。
这些操作提供了处理并发任务时等待的能力,从而可以在多线程环境下有效地管理任务队列和工作负载。
1.2 特性
BlockingQueue
的主要特性包括:
- 线程安全:所有排队方法都可以安全地由多个线程同时调用。
- 内部锁机制:实现通常通过锁(例如重入锁)或其他形式的并发控制机制来保护队列的状态,确保操作的原子性。
- 条件阻塞:提供条件的阻塞和唤醒机制,以便在合适的时候恢复线程的执行。
2. BlockingQueue
的实现
在Java中,BlockingQueue
是一种特殊的队列,用于处理在多线程环境下生产者和消费者之间的数据交换。它不仅支持常规的队列操作,还支持阻塞的插入和移除方法,使得生产者在队列满时挂起,消费者在队列空时挂起。这种设计极大地方便了并发编程中对于资源共享的管理,避免了忙等导致的CPU资源浪费,并简化了线程间的协调。
为了深入理解BlockingQueue
,我们将从其核心机制、几种关键的实现,以及它们的内部工作原理出发,探讨它们在实际应用中的表现和优化方法。
核心机制
BlockingQueue
的核心机制依赖于Java的并发控制工具,主要是锁(Locks)和条件(Conditions)。这些工具帮助实现了两个主要功能:
- 阻塞控制:通过锁和条件变量控制线程在特定状态下挂起和唤醒。例如,当队列满时,生产者线程将在条件变量上等待,直到消费者从队列中移除一个元素为止。
- 线程安全的数据访问:保证在队列的状态修改过程中,同时只有一个线程对数据进行操作,防止数据竞争和数据不一致。
关键实现
2.1 ArrayBlockingQueue
ArrayBlockingQueue
是一个基于数组结构的有界阻塞队列,这种队列在创建时需要指定容量。
内部工作原理:
- 锁机制:使用一个单一的重入锁(ReentrantLock)来实现线程间的互斥访问。
- 条件变量:有两个条件变量,notEmpty和notFull,分别用于控制队列非空和非满的条件。
由于ArrayBlockingQueue
基于数组,它在随机访问元素时表现良好,但在元素插入和移除时可能涉及数组的复制和移动,特别是在队列满或空时。
2.2 LinkedBlockingQueue
LinkedBlockingQueue
是基于链表结构的可选有界阻塞队列。如果不指定容量,默认为Integer.MAX_VALUE
,表现为无界队列。
内部工作原理:
- 锁分离技术:使用两把锁,分别控制队列的头部和尾部,这样在多线程环境下,入队和出队可以并行进行,提高了队列的并发性能。
- 节点设计:每个元素都包装在一个节点对象(Node)中,每个节点包含一个元素和指向下一个节点的引用。
LinkedBlockingQueue
在并发环境下通常表现出比ArrayBlockingQueue
更好的吞吐量,因为头尾操作分离减少了锁竞争。
2.3 PriorityBlockingQueue
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列,内部通过一个优先级堆实现,可以根据元素的自然顺序或者构造时提供的Comparator
来决定元素的出队顺序。
内部工作原理:
- 全局锁:使用单一的重入锁来控制对堆的访问,这可能在高并发环境下成为性能瓶颈。
- 动态数组:所有元素存储在一个动态数组中,该数组按照堆的
性质进行排序。
由于需要全局锁来控制对内部堆的访问,PriorityBlockingQueue
的并发性能不如LinkedBlockingQueue
和ArrayBlockingQueue
。然而,它在需要处理具有优先级的任务时非常有用。
2.4. DelayQueue
DelayQueue
是一种无界的阻塞队列,用于放置实现了Delayed
接口的对象,其中每个元素只有在其指定的延迟时间过后才能从队列中取出。
关键特性
- 元素排序:队列内部使用优先级队列来管理元素,这个优先级队列确保延迟时间最长的元素总是在队列的头部。
- 动态延迟:每个元素的延迟时间不是固定的,而是动态计算的,这意味着元素的实际等待时间可以在运行时被调整。
内部工作原理
- 优先级堆 :
DelayQueue
使用一个优先级堆来存储队列元素,这个堆根据元素的延迟时间来排序元素。 - 锁机制:整个队列由一个单一的重入锁(ReentrantLock)保护,确保在多线程环境下元素的安全添加和移除。
- 条件变量:使用一个条件变量来使得等待队列中的线程在元素到期时被唤醒。
应用场景
DelayQueue
非常适合用于实现任务调度和定时任务。例如,它可以用于缓存系统中的过期元素清除,或者在指定时间后执行任务(如重试机制中的任务调度)。
2.5. SynchronousQueue
SynchronousQueue
是一个不存储元素的阻塞队列。每个插入操作必须等待另一个线程的移除操作,反之亦然,因此SynchronousQueue
实际上是一个没有任何内部容量的传递通道。
关键特性
- 直接交接 :
SynchronousQueue
不存储元素,生产者线程和消费者线程必须同时到达队列,元素才能从生产者传递到消费者。 - 公平性选择:可以选择公平模式或非公平模式。在公平模式下,等待时间最长的线程会优先匹配到对方线程,而在非公平模式下,匹配更快速但可能不公平。
内部工作原理
- 双栈和双队列算法 :
SynchronousQueue
可以选择基于LIFO(后进先出)的栈或者基于FIFO(先进先出)的队列实现。这两种结构都用于暂时保存由线程提供的数据直到另一线程来取走它。 - 锁和同步组件:使用复杂的锁机制和同步组件来确保线程在没有相对线程到达时挂起。
应用场景
SynchronousQueue
非常适合于任务的交接工作,例如,在资源池模式和线程池管理中经常使用,用于确保任务直接、立即地从生产者传递到消费者。
应用优化
使用BlockingQueue
时,选择正确的实现和配置对于性能至关重要:
- 容量选择:合理配置队列的容量可以避免资源浪费,同时避免频繁的队列满导致的阻塞。
- 锁策略 :根据具体的场景选择合适的锁策略,例如,在高并发的生产者-消费者场景中,
LinkedBlockingQueue
的锁分离策略可能更有效。 - 队列类型选择 :根据需求选择合适的队列类型,例如,处理具有明确优先级的任务时使用
PriorityBlockingQueue
。
BlockingQueue
在Java的并发编程中提供了一种强大的工具,能够帮助开发者有效地管理跨多线程的数据交换和处理。通过深入了解不同实现的内部机制和适用场景,可以更合理地在实际项目中运用这些队列,以实现高效、稳定的并发处理。
DelayQueue
和SynchronousQueue
都是为特定场景设计的高级并发编程工具。
DelayQueue
适用于需要根据时间进行任务调度的场景,- 而
SynchronousQueue
适合于需要手递手传递任务的场景。
理解这些队列的内部工作原理和适用场景可以帮助开发者更有效地在Java应用程序中实现复杂的并发任务管理和调度。
主要的 Queue 实现
为了提供一个更深入的视角,我们将详细探讨 Java 集合框架中几种主要的 Queue
实现,并分析它们的内部结构、性能特征及适用场景。这些实现包括 LinkedList
、PriorityQueue
、ArrayDeque
、LinkedBlockingQueue
、ConcurrentLinkedQueue
以及 PriorityBlockingQueue
。
1. LinkedList
LinkedList
作为 List
和 Deque
的实现,自然也实现了 Queue
接口。它是基于双向链表的数据结构。
- 内部结构:每个元素都作为一个节点存储,节点包含数据和指向前后节点的链接。
- 性能特点:
-
- 时间复杂度:添加和删除操作(在头尾)为 O(1)。查找或访问元素为 O(n)。
- 内存开销:每个节点对象除了存储数据外,还需要额外空间存储两个引用,导致相对较高的内存开销。
- 适用场景:适合于实现堆栈、队列或双端队列,特别是在频繁进行头尾操作的场景。
2. PriorityQueue
PriorityQueue
是一个基于优先级堆的队列,通常实现为二叉堆。
- 内部结构 :虽然抽象地表现为树,但实际存储在数组中。元素按照优先级顺序(自然排序或提供的
Comparator
)排列。 - 性能特点:
-
- 时间复杂度:添加元素为 O(log n),删除最小元素也是 O(log n)。查看但不移除头元素为 O(1)。
- 扩容机制:内部数组会根据需要自动扩容,但大批量操作可能导致多次扩容。
- 适用场景:适合需要按特定顺序处理元素的场景,例如数据流中的中值查找、任务调度等。
3. ArrayDeque
ArrayDeque
是一个基于数组的双端队列实现,提供高效的固定时间性能。
- 内部结构 :使用循环数组支持快速的头尾操作。不允许存储
null
值。 - 性能特点:
-
- 时间复杂度:在头部或尾部添加或删除元素均为 O(1)。
- 内存使用 :相较于
LinkedList
,内存使用更为高效因为没有节点间的额外引用。
- 适用场景:非常适合作为栈或队列使用,尤其是在需要大量头尾操作而非随机访问的场景。
4. LinkedBlockingQueue
LinkedBlockingQueue
是一个可选有界的阻塞队列,基于链表结构,主要用于生产者-消费者场景。
- 内部结构:基于链表,可以选择容量上限。
- 性能特点:
-
- 线程安全:使用单独的锁(put锁和take锁)来控制数据的并发访问,优化了多线程操作。
- 内存占用:每个节点对象需要额外空间来存储数据引用和节点链接。
- 适用场景:非常适用于多线程任务处理,如Web服务器的请求处理队列。
5. ConcurrentLinkedQueue
ConcurrentLinkedQueue
是一个基于链接节点的无界并发队列,使用非阻塞算法实现。
- 内部结构:使用非阻塞的链接节点,支持高并发访问。
- 性能特点:
-
- **
并发性能**:利用 CAS 操作(Compare-And-Swap),提高了在高并发情况下的效率。
- 内存一致性:保证了无锁的线程安全。
- 适用场景:适合高并发应用场景,尤其是计数、事件通知等轻量级同步任务。
6. PriorityBlockingQueue
PriorityBlockingQueue
是一个支持优先级排序的无界阻塞队列。
- 内部结构:内部使用数组存储元素,通过二叉堆维护优先级顺序。
- 性能特点:
-
- 线程安全:使用单一的重入锁来保护所有数据操作,确保线程安全。
- 动态扩容:自动扩容以适应添加的元素。
- 适用场景:适用于需要处理优先级任务的多线程环境,如操作系统的任务调度、实时消息处理系统。
上述详细的介绍展示了各种 Queue
实现的内部机制、性能特征和最佳使用场景。这有助于开发者在具体的应用需求和性能要求之间做出合适的选择,优化 Java 应用的响应性和效率。正确的队列选择可以显著提升应用性能,尤其是在数据处理和多线程环境中。
Map
在Java编程语言中,Map
是一个非常重要的接口,属于Java集合框架的一部分。Map
存储键值对的集合,并且可以快速检索所存储元素。键必须是唯一的,每个键最多只能映射到一个值。这个接口实现了键到值的映射,并且不支持重复的键;每个键最多只能映射到一个值。
1. Map
接口的核心方法
Map
接口定义了一些基础方法,用于操作包含在其中的键值对:
- put(K key, V value): 将指定的值与此映射中的指定键关联(可选操作)。
- get(Object key): 返回此映射中映射到指定键的值。
- remove(Object key): 如果存在一个键的映射关系,则将其从映射中移除(可选操作)。
- containsKey(Object key): 如果此映射包含指定键的映射关系,则返回 true。
- containsValue(Object value): 如果此映射将一个或多个键映射到指定值,则返回 true。
- keySet(): 返回此映射中包含的键的 Set 视图。
- values(): 返回此映射中包含的值的 Collection 视图。
- entrySet(): 返回此映射中包含的映射关系的 Set 视图。
2. Map
的主要实现
了解Java中Map
接口的各种实现是理解和优化Java应用程序的关键部分。Map
接口提供了一种映射关系,可以将唯一的键映射到特定的值上。Java为不同的使用场景提供了多种Map
实现,每种都有其特定的优点和设计用途。下面,我们将深入探讨HashMap
、TreeMap
、LinkedHashMap
、Hashtable
和ConcurrentHashMap
的内部实现细节和性能影响因素。
1. HashMap
内部结构
HashMap
是基于散列表实现的,它使用数组和链表(或红黑树)的结构来存储键值对。Java 8及以上版本中,当链表长度超过特定阈值(默认为8)时,链表将转换为红黑树,以减少搜索时间。
性能分析
- 时间复杂度 : 通常情况下,
HashMap
提供常数时间的性能,即O(1)的时间复杂度,对于get和put操作。在最坏的情况下,由于散列冲突,性能可能退化到O(n)。 - 负载因子和容量 :
HashMap
有一个称为负载因子的参数,它是一个衡量HashMap填满程度及其扩容时间的标准。默认的负载因子是0.75,这是时间和空间成本之间的一个平衡选择。 - 迭代性能 : 迭代
HashMap
的时间复杂度与其"容量 + 大小"成正比,即HashMap
的容量加上它包含的键值对数量。
适用场景
适合大多数需要快速访问的场景,不需要维护任何键值对顺序。
2. TreeMap
内部结构
TreeMap
在Java中是通过红黑树实现的,这是一种自平衡的排序二叉树。键必须实现Comparable
接口或者在构造时提供一个Comparator
。
性能分析
- 时间复杂度 :
TreeMap
提供保证的log(n)时间复杂度对于包括插入、删除和查找操作,因为每次操作都是基于二叉树的。 - 有序性: 自然保持键的顺序,无论是自然排序还是自定义排序。
适用场景
适合需要大量的范围查找操作或者需要按自然顺序访问键的应用程序。
3. LinkedHashMap
内部结构
LinkedHashMap
继承自HashMap
,但它使用一个双向链表来维护插入顺序或访问顺序。
性能分析
- 时间复杂度 :
LinkedHashMap
保留了HashMap
的性能特征,即大多数操作仍然是O(1)。但是,维护链表的开销使得它略微慢于HashMap
。 - 顺序性: 保持了元素的插入顺序,对于创建有序的缓存非常有用。
适用场景
当需要维护访问或插入顺序时使用,例如在构建基于LRU缓存时非常有用。
4. Hashtable
内部结构
Hashtable
是一个古老的实现,与HashMap
类似,但它是同步的。每个方法都是同步的,使用Synchronized
关键字。
性能分析
- 时间复杂度 : 基本操作为O(1),与
HashMap
相似。但由于同步,当多个
线程访问时,其性能会显著下降。
- 线程安全: 提供基本的线程安全,但在高并发环境下效率低下。
适用场景
不推荐在新的开发中使用;ConcurrentHashMap
是一个更好的选择,因为它提供了更高的并发性能。
5. ConcurrentHashMap
ConcurrentHashMap
是 Java 并发包 (java.util.concurrent
) 中的一颗明星,它提供了高效的并发访问和良好的扩展性。在 Java 8 以后,ConcurrentHashMap
的内部实现经历了重大的变化,让我们深入理解这些变化以及它如何在高并发环境下工作。
1. ConcurrentHashMap 的基础
ConcurrentHashMap
是一个线程安全的哈希表,它使用了若干种机制来提高并发性和性能:
- 分段锁(Segmentation) :在 Java 7 及之前的版本中,
ConcurrentHashMap
使用分段锁的概念来提高并发性。这意味着每一个段(segment)都是一个独立的哈希表,有自己的锁。不同线程可以同时写入不同的段而不会互相阻塞。 - 锁分离(Lock Stripping) :从 Java 8 开始,
ConcurrentHashMap
去掉了传统的分段锁,转而使用了一种更加精细的锁分离策略。这种方法使用了多种无锁和基于锁的技术来管理并发修改,包括使用了 CAS 操作(比较并交换),和 synchronized 关键字。
2. Java 8 之后的内部实现细节
Node 数组和链表
- 内部结构 :
ConcurrentHashMap
在 Java 8 中使用了一个 Node 数组(称为 table)来存储键值对。每个节点(Node)是一个包含键、值和哈希值的数据结构,并可能有一个指向下一个节点的引用,形成链表。 - 链表转红黑树:当链表的长度超过一定阈值(默认为 8)时,链表会被转换为红黑树,以减少搜索时间。
为了实现高性能并发访问,ConcurrentHashMap
在锁的优化策略上进行了大量的精细化工作。在 Java 8 之后,这些锁优化策略尤其突出,表现在几个关键的技术实现上。下面将详细探讨这些锁的优化策略,特别是如何通过减少锁争用来提高性能。
锁分离技术
在 Java 8 之前,ConcurrentHashMap
使用分段锁(Segmentation)的概念,将数据分为几个段,每个段独立锁定,从而减少了线程间的竞争。然而,这种方法仍然存在局限性,因为整个段内的所有操作都需要通过单一的锁来同步,这在高并发时可能成为瓶颈。
Java 8 改进了这一设计,摒弃了分段锁,转而使用更细粒度的锁分离技术。这种技术主要体现在以下几个方面:
a. 节点级的同步
ConcurrentHashMap
的每个桶中的链表或红黑树的节点都可以独立锁定。这意味着多个更新操作可以同时在表的不同部分进行,只要它们不涉及相同的桶。这种方式通过减少锁的范围,降低了锁争用的可能性。
b. Synchronized 关键字
尽管 synchronized
在早期 Java 版本中性能较差,但在 Java 8 以后,得益于 JVM 和编译器的优化,synchronized
的性能显著提高。ConcurrentHashMap
使用了细粒度的 synchronized
块来锁定单个节点,这比起早期的粗粒度锁控制(如整个段加锁)更为高效。
无锁操作和 CAS
除了使用锁分离技术外,ConcurrentHashMap
还大量使用无锁编程技术,如 CAS (Compare-And-Swap) 操作,以进一步提升性能。
a. CAS 操作
ConcurrentHashMap
在内部使用 CAS 操作来执行关键的更新操作,如节点的插入和删除。CAS 是一种原子操作,它检查位置上的值是否与预期的值相同,如果相同,就将该位置上的值替换为新值。这种方法避免了锁的使用,减少了线程阻塞,特别适合于竞争激烈的环境。
b. 优化的数据结构
Java 8 中的 ConcurrentHashMap
在内部结构上也做了优化,链表长度超过一定阈值时,会转换为红黑树。这样做不仅改进了搜索效率,还减少了需要使用 CAS 操作的节点数量,因为红黑树的操作往往局限于树的顶部。
扩容和数据迁移
ConcurrentHashMap
在 Java 中广泛应用于需要高并发处理的场景。其性能之一的关键体现在如何处理哈希表的扩容。在并发环境中,扩容是一个特别具有挑战性的问题,因为它需要在保持高性能的同时,确保数据的一致性和线程安全。下面将详细分析 Java 8 以后的 ConcurrentHashMap
的扩容机制及其数据迁移过程。
扩容的触发
在 ConcurrentHashMap
中,扩容是由多个因素触发的:
- 容量阈值:当实际元素数量超过了加载因子与当前容量的乘积时,触发扩容。
- 链表长度:如果桶中链表的长度超过 TREEIFY_THRESHOLD(默认值为 8),并且当前表的容量大于 MIN_TREEIFY_CAPACITY(默认为 64),则考虑将链表转化为红黑树,或是触发整个表的扩容。
ConcurrentHashMap
的扩容过程可以分为以下几个步骤:
a. 初始化扩容
一旦决定扩容,首先需要初始化一个新的 Node 数组,其容量是原数组的两倍。这个新数组会在扩容过程中逐步填充。
b. 数据迁移
数据迁移是扩容过程中最关键的部分。在 Java 8 中,ConcurrentHashMap
采用了一种渐进式的数据迁移策略,允许多个线程同时进行迁移,而不阻塞对哈希表的其他访问和更新操作。
- 多线程迁移:各个线程可以认领某些桶(通常是一段连续的桶区间),并负责这些桶中数据的迁移。每个线程处理迁移任务时,会先锁定这些桶,然后逐一复制每个节点到新表中。
- 节点拆分:由于新表的大小是原表的两倍,原表中每个位置的节点会被拆分成两个索引位置在新表的节点。每个节点的新位置可以通过其哈希值与新表长度的位运算得到。
c. 更新控制结构
扩容的另一个关键部分是如何管理多个线程之间的协调。ConcurrentHashMap
使用了一个特殊的转移节点(ForwardingNode),该节点包含对新表的引用。当其他线程访问还未迁移完的老表桶时,会通过这个转移节点被重定向到新表。
d. 扩容完成
一旦所有数据都迁移到新表中,老表的引用会被更新为新表,老表将被垃圾收集器回收。此时,所有的操作都会直接定向到新表。
扩容中的优化策略
ConcurrentHashMap
在扩容时的几个优化策略包括:
- 渐进式迁移:通过允许并发迁移,大大减少了系统的停顿时间,提高了系统的响应速度和吞吐量。
- 无锁读操作:即使在扩容期间,读操作通常是无锁的,因为读操作可以安全地访问老表,或通过转移节点访问新表。
- 最小化锁的范围:通过锁定单个桶或桶的小范围,减少了锁竞争,使得非迁移路径的操作几乎不受扩容影响。
ConcurrentHashMap
的扩容机制体现了其设计的高度智能化和对并发性能的深刻理解。通过细粒度的锁控制和高效的数据迁移策略,它能够在高并发环境中提供稳定且高效的性能。这种设计使 ConcurrentHashMap
成为处理并发数据结构的首选实现之一。
ConcurrentHashMap
的锁优化策略是其支持高并发的关键。通过锁分离技术、无锁的 CAS 操作和渐进式的数据迁移,它能够在多线程环境中提供高性能的数据访问,是构建高并发应用的理想选择。这些策略不仅提高了效率,也确保了操作的原子性和一致性,使得 ConcurrentHashMap
成为 Java 并发包中不可或缺的一部分。
3. 高级并发特性
在并发编程环境中,ConcurrentHashMap
是Java中最关键的数据结构之一,它不仅提供了线程安全的哈希映射管理,还通过一系列高级技术优化了计数统计和遍历操作。这些优化确保了即使在多线程高并发的情况下,ConcurrentHashMap
也能保持高性能和正确性。
计数统计
在Java 8及以上版本的ConcurrentHashMap
中,计数统计的处理采用了一种非常精巧的方法来确保在高并发环境中的高效率和准确性。
LongAdder和CounterCells
传统的单一原子变量(如AtomicLong
)在高并发情况下会成为热点,因为多个线程尝试更新同一个原子变量会导致激烈的缓存行争用。为了解决这个问题,ConcurrentHashMap
采用了LongAdder
或其内部实现类似的CounterCells
数组来分散计数值的存储。
- 工作原理 :
LongAdder
采用分段的思想,将热点数据分散在多个单元(Cell
)上,每个线程主要更新它自己的单元。这些单元的值最终会聚合起来形成总数。在ConcurrentHashMap
的实现中,CounterCells
作为独立的数组存在,每个桶或一组桶可以具有自己的计数器单元,从而减少了线程间的争用。 - 聚合操作: 当需要获取或更新映射的大小时,系统会遍历这些计数单元,合并数据以提供准确的大小或计数。这种方式虽然提高了更新操作的效率,但需要聚合所有单元来获取准确计数时可能会稍微慢一些。
遍历操作
ConcurrentHashMap
的遍历操作也是设计得非常巧妙,旨在提供一种称为"弱一致性"的遍历方式。
弱一致性迭代器
弱一致性迭代器的主要特点是它可以反映出构造迭代器时(或者在迭代期间的某个时刻)映射的状态,但不一定反映所有后续修改。
- 实现机制: 迭代器创建时,它会基于当前的映射状态创建一个数据的快照。这意味着迭代器遍历的是快照中的数据,而不是直接在映射上操作。因此,在迭代器生命周期内对映射的修改不会影响遍历操作,这样可以无锁地进行遍历。
- 遍历性能: 由于迭代器操作的是数据的快照,所以遍历性能不会因为映射的并发修改而受到显著影响。这使得遍历操作即使在高并发修改环境中也能保持较高的性能。
4. 性能考量
由于其高级的并发控制机制,ConcurrentHashMap
在多线程环境下提供了极高的读写性能,而且不会出现 Hashtable
那样的整体锁定。这使得它非常适合用作高并发环境中的缓存、统计数据集合或者作为存储大量数据的高效地图。
ConcurrentHashMap 能保证复合操作的原子性吗?
ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的,一定不要搞混了!
复合操作是指由多个基本操作(如put、get、remove、containsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}
如果线程 A 和 B 的执行顺序是这样:
- 线程 A 判断 map 中不存在 key
- 线程 B 判断 map 中不存在 key
- 线程 B 将 (key, anotherValue) 插入 map
- 线程 A 将 (key, value) 插入 map
那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。
那如何保证 ConcurrentHashMap 复合操作的原子性呢?
ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
上面的代码可以改写为:
// 线程 A
map.putIfAbsent(key, value);
// 线程 B
map.putIfAbsent(key, anotherValue);
或者:
// 线程 A
map.computeIfAbsent(key, k -> value);
// 线程 B
map.computeIfAbsent(key, k -> anotherValue);
很多同学可能会说了,这种情况也能加锁同步呀!确实可以,但不建议使用加锁的同步机制,违背了使用 ConcurrentHashMap 的初衷。在使用 ConcurrentHashMap 的时候,尽量使用这些原子性的复合操作方法来保证原子性。
ConcurrentHashMap
是 Java 并发包中的一个重要组成部分,通过复杂的内部机制和优化提供了高效的线程安全的哈希表实现。它的设计反映了 Java 平台对于并发编程支持的不断进步,使得开发者可以在保持代码简洁性的同时,获得高性能的数据访问能力。
null 键值支持
在 Java 中,HashMap
和 Hashtable
是两种常用的哈希表实现,它们在处理键(key)和值(value)时的行为有明显的不同,尤其是关于 null
值的处理。这些行为差异主要源于它们的设计理念和应用场景的不同。
HashMap 和 Null 值
允许 Null 键和 Null 值
HashMap
是 Java 集合框架的一部分,允许使用 null
作为键和值。这种设计使得 HashMap
能够灵活地应用于更广泛的场景,其中对于某些键可能并不总是有一个实际的值可用,或者表达的是某种特殊状态。
- Null 键 :
HashMap
允许有一个null
键。因为哈希映射本质上是通过键的哈希码来定位值存储的位置,而null
的哈希码被定义为 0。因此,在HashMap
的内部实现中,使用null
作为键时,会特殊处理,将其存储在哈希表的一个特定位置(通常是数组的第一个位置)。由于哈希表的键是唯一的,这也意味着null
键只能有一个。 - Null 值 :
HashMap
允许将多个键的值设置为null
。在哈希表中,每个键都映射到一个独立的值,键的唯一性并不要求值也是唯一的。因此,不同的键可以有相同的值,包括null
。
Hashtable 和 Null 值
不允许 Null 键和 Null 值
Hashtable
是更早在 Java 中实现的一种哈希表,它是同步的,设计之初是为了在多线程环境中使用。由于 Hashtable
的设计目标是在并发场景下使用,因此在其设计中不允许键或值为 null
。
- 线程安全和Null : 在多线程环境下,正确地识别和同步
null
值相对复杂,因为null
常常用来表示某种特殊状态。如果Hashtable
允许null
值,那么在检查、同步和识别键或值的状态时就需要引入额外的复杂性。 - 抛出 NullPointerException : 如果尝试使用
null
作为键或值,Hashtable
将抛出NullPointerException
。这是一种快速失败(fail-fast)的行为,目的是立即通知开发者非法的操作,防止在表中存储不一致或无效的数据。
设计理念的体现
这两种不同的处理方式反映了 Java 集合框架中不同类的设计哲学和预期用途:
- HashMap: 更加灵活,适用于单线程或者是管理外部同步的多线程应用程序中,提供了更多的功能性和灵活性。
- Hashtable: 为早期 Java 版本设计,强调在多线程环境下的安全和健壮性,牺牲了一些灵活性。
在实际应用中选择哪一种,取决于具体的应用场景需求以及对性能、灵活性、安全性的综合考量。
各种实现对 null 键值的支持
在 Java 中,不同的 Map
实现对 null
键和 null
值的支持各不相同。这些差异反映了每种 Map
的内部数据结构、设计理念和预期用途。下面是 Java 标准库中几种常见 Map
实现对 null
键和 null
值的支持情况:
1. HashMap
- 支持 null****键和 null****值 :
HashMap
允许一个null
键和多个null
值。这使得HashMap
在使用上非常灵活,可以适应各种需要处理空值的场景。
2. Hashtable
- 不支持 null****键和 null****值 :如前所述,
Hashtable
不允许null
键或null
值。尝试插入null
键或null
值会抛出NullPointerException
。这是因为Hashtable
是同步的,并设计为在多线程环境下使用,其中对空值的处理需要更严格的数据完整性。
3. LinkedHashMap
- 支持 null****键和 null****值 :
LinkedHashMap
继承自HashMap
,因此它继承了HashMap
的特性,包括支持一个null
键和多个null
值。LinkedHashMap
在保持插入顺序的同时,也支持对空值的灵活处理。
4. TreeMap
- 不支持 null****键 :
TreeMap
在其键上使用自然排序或一个Comparator
。如果使用自然排序,那么键必须实现Comparable
接口,并且不允许null
键,因为Comparable
的compareTo
方法不接受null
参数。如果使用Comparator
,则取决于Comparator
的实现是否接受null
。 - 支持 null****值 :
TreeMap
支持多个null
值,因为它对值没有排序或比较的需求。
5. ConcurrentHashMap
- 不支持 null****键和 null****值 :
ConcurrentHashMap
是为并发环境设计的,不允许使用null
作为键或值。这主要是因为null
可能引起歧义,例如,在get()
或put()
方法中返回null
可以表示键不存在或键映射到了null
值。在并发操作中,明确这种区别是很重要的,以确保数据的一致性和线程安全。
6. WeakHashMap
- 支持 null****键和 null****值 :
WeakHashMap
与HashMap
类似,允许null
键和null
值。然而,WeakHashMap
的键是弱键(weak keys),意味着如果键没有其他强引用,这些键可以被垃圾回收器回收。
7. IdentityHashMap
- 支持 null****键和 null****值 :
IdentityHashMap
使用==
而不是equals()
方法来比较键,因此它也支持一个null
键和多个null
值。由于IdentityHashMap
的设计用途通常与标准的键值对映射略有不同(例如用于身份键的映射),支持null
使得它在某些特殊应用中更加灵活。
8. EnumMap
- 不支持 null****键 :
EnumMap
是一个键类型为枚举类型的专用Map
。由于枚举类型的值是预先定义的,EnumMap
不允许使用null
作为键。 - 支持 null****值 :虽然键不能是
null
,EnumMap
仍然允许存储null
值。
分析和小结
各种 Map
实现对 null
键和值的支持主要取决于它们的数据结构特点、用途和设计理念。例如,需要排序的 Map
如 TreeMap
和为特定数据类型设计的 EnumMap
不支持 null
键。同步的 Map
实现,如 Hashtable
和 ConcurrentHashMap
,则出于线程安全和性能考虑,避免支持 null
。了解每种 Map
的这些特性对于选择正确的类型以满足特定的应用需求非常重要。
ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。
拿 get 方法取值来说,返回的结果为 null 存在两种情况:
- 值没有在集合中 ;
- 值本身就是 null。
这也就是二义性的由来。具体可以参考 ConcurrentHashMap 源码分析 。
多线程环境下,存在一个线程操作该 ConcurrentHashMap 时,其他的线程将该 ConcurrentHashMap 修改的情况,所以无法通过 containsKey(key) 来判断否存在这个键值对,也就没办法解决二义性问题了。
与此形成对比的是,HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,就会返回 hash 值为 0 的位置的值。单线程环境下,不存在一个线程操作该 HashMap 时,其他的线程将该 HashMap 修改的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。
也就是说,多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。
public static final Object NULL = new Object();
最后,再分享一下 ConcurrentHashMap 作者本人 (Doug Lea)对于这个问题的回答:
The main reason that nulls aren't allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can't be accommodated. The main one is that if map.get(key) returns null, you can't detect whether the key explicitly maps to null vs the key isn't mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.
翻译过来之后的,大致意思还是单线程下可以容忍歧义,而多线程下无法容忍。
Map总结
Map
接口及其各种实现在Java中提供了强大的数据结构支持,使得键值对的管理变得高效且易于操作。根据应用的具体需求选择适合的实现,可以优化应用程序的性能和响应能力。