JAVA 容器集合面试题
常见的集合有哪些?
java 集合框架主要集中在 java.util包中,可以分为以下几大类:
Collection 接口体系(单个元素集合)
- List(有插入顺序,可以重复)
- ArrayList:基于动态数组实现,查询快,增删慢(特别是中间插入/删除)
- LinkedList:基于双向链表,适合频繁插入/删除的场景
- Vector:线程安全的(已过时),功能与 ArrayList 类似
- Set(无序,不重复)
- HashSet:基于 HashMap 实现,元素无序,允许为 Null。
- LinkedHashSet:有插入顺序,基于 HashSet+链表实现。
- TreeSet:有序(按照元素自然排序或比较器),基于红黑树
- Queue(队列/双端队列)
- PriorityQueue:优先队列,元素按照优先级排列
- ArrayDeque:双端队列,比 Stack 更推荐
- LinkedList:也实现了 Queue 接口
Map 接口体系(键值对集合)
- HashMap(最常用)
- 无序,允许一个 Null 或多个 null 值(Key 只能为一个,Value 可以有多个null),线程不安全
- LinkedHashMap
- 有插入顺序,常用于需要顺序便利 Map
- TreeMap
- 按键排序(自然排序或自定义比较器排序),基于红黑树
- HashTable
- 线程安全的,老旧,不建议使用
- ConcurrentHashMap
- 支持高并发,线程安全的 HashMap 替代方法。
常见的并发集合有哪些
并发 Map (键值对结构)
ConcurrentHashMap
ConcurrentHashMap 是 HashMap 的线程安全版本。
- 替代 HashTable,线程安全
- JDK8 以前使用 Segment 分段锁
- JDK8 后使用 CAS+synchronized+Node 数组+红黑树;
- 适合高并发读写
ConcurrentSkipListMap
- 基于跳表(SkipList)实现的有序 Map,支持按顺序遍历。
- 是线程安全的版本的 TreeMap。
并发 List(列表结构)
- CopyOnWriteArrayList
- 写时复制。它的实现原理就是在添加元素的时候对方法加锁,先把 List 列表复制一份,再添加新的元素。这样就实现了线程安全性。读多写少场景下非常适用;
- 每次修改都会复制整个数组,开销大,但读操作无锁超快。
并发 Set(集合结构)
- CopyOnWriteArraySet
- 基于 CopyOnWriteArrayList 实现
- 支持线程安全,适合读多写少
- 自动去重
- ConcurrentSkipListSet
- 基于跳表,线程安全,元素有序;
- 可视为 ConcurrentSkipListMap 的 key 集合版本
并发 Queue(队列)
- ConcurrentLinkedQueue
- 非阻塞队列,是一种基于单向链表结构的无界并发队列,基于 CAS+链表;
- 适合高并发场景中的生产者消费者模型
- LinkedBlockingQueue
- 阻塞队列(支持容量限制)
- 基于链表,读写分别加锁,性能优
- ArrayBlockingQueue
- 有界阻塞队列,基于数组实现
- 每次读写都加锁,适合固定长度的任务队列。
- PriorityBlokingQueue
- 带优先级的阻塞队列
- 元素会根据优先级排序,不保证 FIFO。
- DelayQueue
- 支持延迟获取元素的阻塞队列;
- 元素必须实现 Delayed 接口。
总结表格
| 类型 | 并发集合类 | 特点 |
|---|---|---|
| Map | ConcurrentHashMap | 无锁/部分锁,最常用 |
| Map | CouncurrentSkipListMap | 有序 Map,跳表实现 |
| List | CopyOnWriteArrayList | 写时复制,适合读多写少 |
| Set | CopyOnWriteArraySet | Set 版本写时复制 |
| Set | ConcurrentSkipListSet | 有序,跳表实现 |
| Queue | ConcurrentLinkedQueue | 非阻塞,无界 |
| Queue | LinkedBlockingQueue | 阻塞,有界 |
| Queue | ArrayBlockingQueue | 阻塞,定长数组 |
| Queue | DelayQueue | 支持延时任务 |
| Queue | PriorityBlockingQueue | 元素有优先级 |
Comparable 和 Comparator 接口的区别
- Comparable 是排序接口,若类实现了 Comparable 接口,并实现起 compareTo 排序方法,就表示该类支持排序,相当一个内部排序器。需要对类本身进行修改
- Comparator 是比较器接口,可以新建多个 Comparator 接口的实现来实现自定义排序,相当于一个外部排序器,不需要对类做修改
示例代码对比
Comparable(类内部定义排序逻辑)
javapublic class Student implements Comparable<Student> { private String name; private int age; // 构造函数、getter、setter等略 @Override public int compareTo(Student other) { return this.age - other.age; // 按年龄升序排序 } }使用:
javaList<Student> list = new ArrayList<>(); Collections.sort(list); // 使用 compareTo 排序Comparator(外部定义排序逻辑)
javapublic class Student { private String name; private int age; // 构造函数、getter、setter等略 }定义比较器:
javapublic class AgeComparator implements Comparator<Student> { @Override public int compare(Student s1, Student s2) { return s1.getAge() - s2.getAge(); } }使用:
javaList<Student> list = new ArrayList<>(); Collections.sort(list, new AgeComparator()); // 使用自定义比较器排序
总结对比
| 特性 | Comparable | Comparator |
|---|---|---|
| 包 | java.lang | java.util |
| 方法名 | compareTo(T o) | compare(T o1, T o2) |
| 是否改变类 | 是 | 否 |
| 是否支持多种排序 | 否 (只能一个 ComparaTo) | 是(多个 Comparator 实现) |
| 使用场景 | 默认排序 | 多样化排序,自定义灵活 |
集合使用泛型的优点
- 类型安全,使用泛型之后,集合中只能存放特定类型的对象,避免了类型转换错误。
- 避免强制类型转换,泛型让你在取值的时候不需要手动强制转换,代码更加整洁。提高代码可读性和可维护性
- 优化 JVM 运行时环境,因为不会有类型检查的字节码指令
总结一句话,泛型=编译时类型检查+更少的强制转换+更清晰的代码结构
为什么 Map接口不继承 Collection 接口?
简单说:它们的设计目的不同,结构不相同,如果硬继承反而显得不合理。
- Collection 关注的是元素的集合,例如:一个
List<String>就是一堆字符串 - Map 是键值对集合,每一项都是
Key->Value,和Collection不是用一回事 - 两者方法的语义也不相同,Collection 的很多方法对 Map 来说都不适用。
常见的线程安全的 Map 有哪些?
ConcurrentHashMap
Java 8 起采用 CAS+synchronized 替代分段锁(java7)
支持高并发读写,性能优秀
不允许 Null 键或 Null 值。
- java
Map<String, String> map = new ConcurrentHashMap<>();
Map<String, String> map = new ConcurrentHashMap<>();CocurrentSkipListMap
支持按 Key 排序,类似 TreeMap,但是线程安全
内部接口是跳表(Skip List)适合范围查询、排序等
- java
Map<Integer, String> map = new ConcurrentSkipListMap<>();
Collections.synchronizedMap(Map<K,V> map)
给普通 Map 如 HashMap 加一层同步锁
线程安全但是性能较差,并发量高时会成为瓶颈
- java
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
HashTable
- 所有方法都使用的 Synchronized,性能较差。
- 属于早期 java 设计,不建议新项目使用。
总结
| 名称 | 所在包 | 特点 | 是否推荐 |
|---|---|---|---|
Hashtable | java.util | 古老的同步 Map,所有方法都加了 synchronized | ❌ 不推荐,效率低 |
Collections.synchronizedMap() | java.util.Collections | 对普通 Map 加了一层同步包装 | ⚠️ 可用,但不如 ConcurrentMap |
ConcurrentHashMap | java.util.concurrent | 高效并发 Map,分段锁或 CAS 实现 | ✅ 推荐使用 |
ConcurrentSkipListMap | java.util.concurrent | 支持并发的有序 Map(类似 TreeMap) | ✅ 用于需要排序的并发场景 |
ImmutableMap(Guava) | com.google.common.collect | 不可变线程安全 Map,构造后只读 | ✅ 用于读多写少场景 |
适用建议
| 场景 | 推荐 |
|---|---|
| 高并发读写 | ConcurrentHashMap |
| 有序并发 Map | ConcurrentSkipListMap |
| 读多写少且初始化后不变 | ImmutableMap(Guava) |
| 简单同步封装 | Collections.synchronizedMap()(低并发或临时使用) |
| 老代码兼容 | Hashtable(尽量替换) |
HashMap 在 JDK8 中有哪些改变?
引入红黑树优化链表结构,这是最核心的变化
- 原来 JDK7中,hash 冲突的 Key 都放在桶的链表中(拉链法)
- 冲突多时,链表长度较长,查询效率从 O(1),退化为 O(N)
- JDK8中,如果桶中的链表长度超过阈值(默认是 8),就将链表转换成红黑树
- 红黑树的查询性能是 O(log n),大大提高了在 hash 冲突下的性能
延迟树化机制,转换成红黑树不是立即执行
- 只有桶中的元素数量≥ 8 且整个
HashMap的容量 ≥ 64 时,才会进行树化 - 防止在小容量情况下频繁树化带来性能问题
- 只有桶中的元素数量≥ 8 且整个
链表结构变为“尾插法”而不是“头插法”
- JDK7 中使用的是头插法,新元素放到链表头,导致顺序和插入顺序相反,在并发或 rehash 时容易死循环(比如多线程扩容下)
- JDK8 中改成了尾插法,读取顺序和插入顺序一致,也避免的扩容死链问题。
重新设计了 Hash 函数(扰动函数)
在 JDK8 中对 hash 值做了扰动处理,(右移16 位异或原始 hash)
javastatic final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这样做是为了让 hash 分布更加均匀,减少 hash 冲突
JDK8 HashMap为什么引入红黑树,而不是 AVL 树
在JDK8中,HashMap在解决哈希冲突时,当链表长度超过阈值(默认为8)时会将其转换为红黑树,而不是AVL树。这种设计选择主要基于以下几个原因:
性能权衡
红黑树和AVL树都是自平衡二叉搜索树,但它们的平衡策略不同:
- AVL树:严格平衡,任何节点的左右子树高度差不超过1
- 红黑树:近似平衡,确保没有一条路径会比其他路径长出两倍
这种差异导致了:
- 插入/删除操作:红黑树通常更快,因为它的旋转操作更少
- 查找操作:AVL树略快,因为它的平衡更严格
在 HashMap 的场景中,插入和删除的操作比查找更频繁,因此红黑树更合适。
实现复杂度
红黑树的实现相对 AVL 树更加简单,代码维护成本更低,HashMap 作为基础数据结构,实现复杂度是一个重要的考量因素
实际应用场景
在 HashMap中:
- 树结构的转换是相对罕见的(哈希函数良好时)
- 即使转换为树,节点数量通常不会太大(超过阈值时会扩容)
- 红黑树的近似平衡已经足够提供 O log(n)的性能
空间开销
- 红黑树通常比 AVL 树需要更少的额外空间来维护平衡信息(红黑树只需要 1 位存储颜色信息,而 AVL 树需要存储平衡因子)。
统计性能
在实际应用汇总,红黑树的统计性能更好,因为:
- 对于随机插入的数据,红黑树的旋转操作更少
- 对于有序数据,两者性能相当
JDK8 HashMap 为啥不一开始就直接使用红黑树

因为树节点所占用的空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点,也就是说,最开始使用链表的时候,链表是比较短的,空间占用也是比较少的,查询性能都差不多,但是当链表越来越长,链表查询越来越慢,为了保证查询效率,才是设计链表而使用红黑树
HashMap 的 put 方法逻辑
先上一段源码
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with {@code key}, or
* {@code null} if there was no mapping for {@code key}.
* (A {@code null} return can also indicate that the map
* previously associated {@code null} with {@code key}.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果表为空或长度为 0,则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//计算桶索引 (n-1)& hash
if ((p = tab[i = (n - 1) & hash]) == null)
//如果桶为空,直接创建新节点
tab[i] = newNode(hash, key, value, null);
else {
//桶不为空
Node<K,V> e; K k;
//检查第一个节点是否就是要找的键
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
//节点是红黑树,则调用红黑树的插入方法
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//节点是链表,遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//到达链表尾部,添加新节点
p.next = newNode(hash, key, value, null);
//如果链表长度达到树化阈值(8),转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//检查是否找到相同键
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果已经找到键
if (e != null) { // existing mapping for key
V oldValue = e.value;
//根据 onlyIfAbsent 决定是否替换旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果 size 超过阈值,进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}大概源码流程如下:
- 调用 putVal 方法将键值对插入到 HashMap中。
- 计算键的哈希值,如果键位 null,则哈希值为 0
- 根据哈希值找到键值对应的位置
- 如果 HashMap 的 table 数组位 null 或长度为 0,则需要进行初始化;
- 计算键值对在 table 数组中的位置,如果该位置上没有其他键值对,则直接插入键值对;
- 如果该位置已经存在其他键值对,则需要进行链表或红黑树的插入操作:
- 如果该位置上已经存在相同的键,则直接用新的值替换旧的值;
- 如果该位置上存在 TreeNode 节点,则将键值对插入到红黑树中;
- 如果该位置上存在链表,则遍历链表找到合适的位置插入键值对;
- 如果链表长度超过了阈值,则需要将链表转换为红黑树;
- 如果键已经存在,则用新的值替换旧的值。
- 插入成功,增减 modCount 和 size,如果 size 超过了阈值,则需要进行扩容。
HashMap 的 get 方法逻辑
先上源码
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code (key==null ? k==null :
* key.equals(k))}, then this method returns {@code v}; otherwise
* it returns {@code null}. (There can be at most one such mapping.)
*
* <p>A return value of {@code null} does not <i>necessarily</i>
* indicate that the map contains no mapping for the key; it's also
* possible that the map explicitly maps the key to {@code null}.
* The {@link #containsKey containsKey} operation may be used to
* distinguish these two cases.
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods.
*
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
//检查第一个节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//如果是红黑树
if (first instanceof TreeNode)
//遍历红黑树
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}基本流程如下
- 计算哈希值:首先对输入的 key 调用 hashCode()方法计算哈希值
- 确定桶的位置:通过哈希值与数组长度计算得到数组下标(桶的位置)
- 通常使用 (n-1) & hash 来计算(n)是数组长度
- 查找节点:
- 如果该桶位置只有一个节点,直接检查该节点的 key 是否匹配
- 如果是链表,则遍历链表查找匹配的 key
- 如果是红黑树,则调用红黑树的查找方法
- 返回结果:找到匹配的节点则返回其 value,否则返回 null
关键点
- 哈希冲突处理:使用链表或红黑树解决哈希冲突
- 性能考虑:理想情况下时间复杂度为O(1),最坏情况下为 O(Log n)(当转化为红黑树时)
- null 键处理,HashMap 允许一个 Null 键,会放在数组的第一个位置
- equals 方法:确定 key 是否相等时,先比较 hash 值,再使用 equals方法
HashMap 允许一个键为 null,null 键会放在数组的第一个位置,以下三种情况,HashMap会返回 null:
- table数组为 null 或者长度为 0;
- table 数组中不存在要查找的键;
- 键存在,但对应的值为 null;
HashMap 是先插入元素还是先扩容,为什么这么设计?是怎么扩容的?有没有容量限制
插入与扩容顺序
HashMap 是先插入元素再检查是否需要扩容。这种设计有以下原因:
- 效率考虑:先插入可以避免不必要的扩容检查。如果先检查扩容,可能在很多情况下即使不触发扩容也要进行检查。
- 准确性:只要在实际插入新元素(而非替换旧值)时才可能触发扩容,先插入可以准确判断是否真的增加了元素数量。
- 一致性:确保扩容时新元素已经存在于表中,保持数据一致性。
扩容机制
HashMap 扩容的主要过程:
- 触发条件:当元素数量超过阈值(容量 x 负载因子(默认0.75))时触发扩容。
- 扩容大小:通常扩容为原容量的 2 倍(保持为 2 的幂次方)
- 数据迁移:
- 创建新数组(原大小的两倍)
- 重新计算每个元素的位置(使用新的数组长度)
- 处理哈希冲突(Java8 之后使用红黑树)
- java8优化:
- 元素在新数组中的位置要么是原位置,要么是原位置+原容量
- 减少了重新计算哈希值的开销
容量限制
- 最大容量:HashMap 的最大容量是 2^30(1073471824)
- 定义在代码中
static final int MAXIMUM_CAPACITY = 1 <<30 - 这是有数组索引使用 int 类型决定的
- 定义在代码中
- 默认值:
- 初始容量:16
- 负载因子:0.75
- 自动调整:当构造 HashMap 时指定了初始容量,实际容量会别调整为不小于该值的下一个 2 的幂次方数
HashMap的插入与扩容代码解析
以 java8 为例
插入元素的核心流程
插入元素的入口是
put()方法:javapublic V put(K key, V value) { return putVal(hash(key), key, value, false, true); }实际工作在 putVal() 方法中完成:
javafinal V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 步骤1:如果表为空或长度为0,先进行扩容(初始化) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步骤2:计算索引位置,如果该位置为空,直接插入新节点 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 处理哈希冲突的情况... } // 步骤3:修改计数器并检查是否需要扩容 ++modCount; if (++size > threshold) resize(); // 先插入元素,再检查扩容 afterNodeInsertion(evict); return null; }扩容机制代码解析
扩容的核心方法是
resize():javafinal Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // 步骤1:计算新容量和新阈值 if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // 双倍扩容 } else if (oldThr > 0) // 初始容量设为阈值 newCap = oldThr; else { // 使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; // 步骤2:创建新数组 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 步骤3:数据迁移(重新哈希) if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) // 单个节点 newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) // 树节点 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 链表节点 // 保持原有顺序 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 判断节点在新表中的位置 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 将链表放入新表 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }关键设计点解析
为什么先插入再扩容?
从 putVal() 方法可以看到:
java// 先插入元素 tab[i] = newNode(hash, key, value, null); // 然后检查是否需要扩容 if (++size > threshold) resize();这样设计的原因是:
- 减少不必要的扩容检查:只有当实际添加了新元素(size 增加)时才需要检查扩容
- 保持一致性:确保扩容时新元素已经在表中
- 性能优化:避免在替换现有键值时进行扩容检查
扩容优化技巧
java8 的优化体现在数据迁移时:
javaif ((e.hash & oldCap) == 0) { // 保持原索引位置 newTab[j] = loHead; } else { // 新位置 = 原索引 + oldCap newTab[j + oldCap] = hiHead; }这种优化:
- 避免了重新计算每个元素的哈希值
- 利用哈希值位特性快速确定新位置
- 保持里链表的原始顺序(避免发生并发问题)
容量限制实现
在扩容时检查:
javastatic final int MAXIMUM_CAPACITY = 1 << 30;//最大容量定义 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; // 不再扩容 }完整流程实例
假设默认初始容量为 16,负载因子为 0.75:
- 第一次插入:table 为 null,触发 resize() 初始化 table 为 16
- 插入第 13 个元素时(16 x 0.75=12):
- 先插入第 13 个元素
- 然后发现 size=13 > threshold=12 ->触发扩容
- 容量从 16 扩容到 32,阈值变为 24
- 后续插入都遵循相同逻辑
TreeMap的数据结构是什么?
TreeMap是 java 集合框架中基于红黑树(Red-Black tree) 实现的 NavigableMap,他保证了元素按照键的自然顺序或者构造时提供的 Comparator 进行排序
TreeMap 的核心结构
javapublic class TreeMap<K,V> extends AbstractMap<K,V> implements NavigableMap<K,V>, Cloneable, java.io.Serializable { // 比较器,用于确定键的顺序 private final Comparator<? super K> comparator; // 红黑树的根节点 private transient Entry<K,V> root; // 树中元素的数量 private transient int size = 0; // 修改计数器(用于快速失败机制) private transient int modCount = 0; // 静态内部类表示树的节点 static final class Entry<K,V> implements Map.Entry<K,V> { K key; V value; Entry<K,V> left; Entry<K,V> right; Entry<K,V> parent; boolean color = BLACK; // 红黑树节点颜色 Entry(K key, V value, Entry<K,V> parent) { this.key = key; this.value = value; this.parent = parent; } public K getKey() { return key; } public V getValue() { return value; } public V setValue(V value) { V oldValue = this.value; this.value = value; return oldValue; } } }红黑树的五个性质
- 每个节点是红色或者黑色
- 根节点是黑丝
- 所有叶子节点(NIL)是黑色
- 红色节点的子节点必须是黑色(不呢有连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
核心操作代码解析
在TreeMap 中,插入元素时,首先会根据键的大小确定元素插入的位置,然后执行插入操作。插入节点后,可能会破坏红黑树的平衡性,因此需要通过旋转和颜色调整来恢复平衡性
插入操作
javaprivate V put(K key, V value, boolean replaceOld) { //获取根据节点 Entry<K,V> t = root; //空值处理 if (t == null) { addEntryToEmptyMap(key, value); return null; } int cmp; Entry<K,V> parent; // 判断是否使用自定义比较器 Comparator<? super K> cpr = comparator; //使用比较器排序 if (cpr != null) { //使用自定义比较器比较键值,并找到插入位置 do { parent = t; cmp = cpr.compare(key, t.key); if (cmp < 0) //如果新键小于当前节点键,移动到左子数 t = t.left; else if (cmp > 0) //否则,移动到右子树 t = t.right; else { //如果键已经存在,决定是否替换旧值 V oldValue = t.value; if (replaceOld || oldValue == null) { t.value = value;//替换旧值 } return oldValue;//返回旧值 } } while (t != null); } else { //如果没有自定义比较器,使用键的自然排序 Objects.requireNonNull(key);//确保键不为 null @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) key; do { parent = t; cmp = k.compareTo(t.key); if (cmp < 0) t = t.left;//如果新建小于当前节点键,移动到左子树 else if (cmp > 0) t = t.right;//否则移动到右子树 else { //如果键已经存在,决定是否替换旧值 V oldValue = t.value; if (replaceOld || oldValue == null) { t.value = value;//替换旧值 } return oldValue;//返回旧值 } } while (t != null); } //没有找到相同的键,插入新节点 addEntry(key, value, parent, cmp < 0); return null; }红黑树平衡调整(fixAfterInsertion)
javaprivate void fixAfterInsertion(Entry<K,V> x) { x.color = RED;//新插入的节点总是红色 //只有当父节点也是红色时才需要调整 while (x != null && x != root && x.parent.color == RED) { if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { Entry<K,V> y = rightOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { //情况 1 :叔叔节点是红色 setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == rightOf(parentOf(x))) { //情况 2:叔叔节点是黑色且当前节点是右孩子,需要左旋 x = parentOf(x); rotateLeft(x); } //情况 3:叔叔节点是黑色且当前节点是左孩子,需要右旋 setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateRight(parentOf(parentOf(x))); } } else { //对称的情况 Entry<K,V> y = leftOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == leftOf(parentOf(x))) { x = parentOf(x); rotateRight(x); } setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateLeft(parentOf(parentOf(x))); } } } root.color = BLACK;//确保根几点永远是黑色 }查找操作
javapublic V get(Object key) { Entry<K,V> p = getEntry(key); return (p==null ? null : p.value); } final Entry<K,V> getEntry(Object key) { // Offload comparator-based version for sake of performance //如果有比较器,使用比较器查找 if (comparator != null) return getEntryUsingComparator(key); Objects.requireNonNull(key); @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) key; Entry<K,V> p = root; while (p != null) { int cmp = k.compareTo(p.key); if (cmp < 0) p = p.left; else if (cmp > 0) p = p.right; else return p; } return null; }
什么是 ConcurrentHashMap?ConcurrentHashMap 的数据结构?
什么是ConcurrentHashMap?
ConcurrentHashMap 是 Java 并发包(
java.util.concurrent)中的一个线程安全的哈希表实现,用于在多线程环境下高效地存储和访问键值对。相比于Hashtable或Collections.synchronizedMap,它提供了更高的并发性能,采用分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8) 来减少锁竞争。主要特点
- **线程安全:**支持高并发读写,不会抛出
ConcurrentModificationException。 - **高性能:**比
Hashtable和synchronizedMap更高的吞吐量。 - 弱一致性迭代器(Weakly Consistent Iterators):遍历时可能反映部分并发修改,但不会失败。
- 不允许
null键或值(与HashMap不同)。
- **线程安全:**支持高并发读写,不会抛出
ConcurrentHashMap 的数据结构
ConcurrentHashMap 的数据结构在 JDK 1.7 和 JDK 1.8 中有较大变化:
JDK 1.7:分段锁(Segment + HashEntry)
采用 分段锁(Segment Locking) 机制,将整个哈希表分成多个 Segment(段),每个 Segment 是一个独立的
ReentrantLock,不同 Segment 可以并发操作,提高并发度。java// JDK 1.7 结构 ConcurrentHashMap ├── Segment[] (默认 16 个 Segment) │ └── HashEntry[] (每个 Segment 维护一个哈希表) │ └── HashEntry<K,V> (链表节点)特点:
- 锁粒度:锁的是 Segment,而不是整个表,减少锁竞争。
- 并发度:默认 16 个 Segment,最多支持 16 个线程并发写。
- 查询不加锁:读操作无需锁,仅写操作加锁。
JDK 1.8:CAS + synchronized(Node + 红黑树)
JDK 1.8 抛弃了分段锁,改为 CAS(Compare-And-Swap) +
synchronized优化锁粒度,并引入 红黑树 优化长链表查询性能。java// JDK 1.8 结构 ConcurrentHashMap ├── Node<K,V>[] (哈希表数组) │ ├── Node<K,V> (链表节点) │ └── TreeBin<K,V> (红黑树节点, 当链表长度 ≥ 8 时转换)特点:
- 锁粒度更细:仅锁单个桶(
synchronized锁住链表头或树根),并发度更高。 - CAS 无锁化:
put操作先尝试 CAS 插入,失败才加锁。 - 红黑树优化:链表长度 ≥ 8 且桶数量 ≥ 64 时,链表转红黑树(提高查询效率)。
- 扩容优化:支持多线程协助扩容(
transfer方法)。
- 锁粒度更细:仅锁单个桶(
ConcurrentHashMap 核心方法实现
- JDK 1.7 的
put流程- 计算 key 的 hash,确定所属 Segment。
- 尝试获取该 Segment 的锁。
- 在 Segment 内部的 HashEntry 数组中找到对应桶,遍历链表:
- 如果 key 已存在,更新 value。
- 否则,插入新节点(头插法)。
- 释放锁。
- JDK 1.8 的
put流程- 计算 key 的 hash,定位到桶。
- 如果桶为空,CAS 插入新节点。
- 如果桶不为空:
synchronized锁住链表头或树根。- 遍历链表/树:
- 若 key 存在,更新 value。
- 否则,插入新节点(尾插法)。
- 如果链表长度 ≥ 8,尝试转红黑树。
- 检查是否需要扩容(
sizeCtl控制)。
- JDK 1.7 的
ConcurrentHashMap vs. HashMap vs. Hashtable
特性 ConcurrentHashMap HashMap Hashtable 线程安全 ✅(分段锁/CAS + synchronized) ❌ ✅(全表锁 synchronized) 并发性能 ⭐⭐⭐⭐(高) ❌(非线程安全) ⭐(低,全局锁) 允许 null 键/值 ❌ ✅ ❌ 迭代器弱一致性 ✅ ❌(可能抛出 ConcurrentModificationException)❌(强一致性) 总结
- JDK 1.7:分段锁(
Segment + HashEntry),锁粒度较粗,适合中等并发。 - JDK 1.8:
CAS + synchronized+ 红黑树,锁粒度更细,高并发性能更好。 - 适用场景:高并发读写、缓存、共享数据存储(如
Spring Bean 容器)。
- JDK 1.7:分段锁(
ConcurrentHashMap 是如何获取集合的准确大小的?
JAVA7 的实现方式
在 Java 7 中,ConcurrentHashMap 采用了一种特殊的方法来获取大小:
- 分段统计:首先不加锁尝试遍历所有段(Segment),累加各段的计数
- 比较前后结果:如果两次统计结果相同,则认为获取到了准确大小
- 加锁统计:如果前后结果不一致,则会对所有段加锁后重新统计
这种方法在大多数情况下能避免全局加锁,但在高并发环境下可能导致多次重试。
Java 8 及以后的实现
Java 8 对 ConcurrentHashMap 进行了重大重构,移除了分段锁设计,采用了更高效的大小统计方式:
- 基础计数器:使用
LongAdder或类似的机制来维护基础计数器(baseCount) - 计数器数组:当并发更新时,使用一个计数器数组(CounterCell[])来分散竞争
- 总和计算:size() 方法返回的是
baseCount与所有CounterCell中值的总和
关键源码实现:
public int size() {
long n = sumCount();//java 17 中直接调用的 mapcount
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}特点与注意事项
- 弱一致性:size() 返回的值是近似值,返回对象为一个 int 类型,因为在统计过程中可能有并发修改
- 高效性:相比 Java 7 减少了锁竞争,性能更高
- mappingCount():Java 8 新增了返回 long 类型的 mappingCount() 方法,推荐使用以避免整数溢出
- size()和 mappingCount()都是 弱一致性的,无法在多线程频繁插入/删除的情况下保持绝对准确的元素数量。
使用建议
如果需要精确的实时大小,可以考虑:
在应用层维护独立计数器
在需要精确统计时暂停所有写操作
使用 mappingCount() 而非 size() 以避免整数溢出问题
强一致性代码参考
javaint accurateSize; synchronized (map) { accurateSize = 0; for (Map.Entry<K, V> entry : map.entrySet()) { accurateSize++; } }
常见的线程安全的 List 集合有:
同步包装类线程安全的 List(Synchronized Wrappers)
这些是通过 Collections.synchronizedXXX() 方法将普通集合包装成线程安全的:
Collections.synchronizedList(List<T>)Collections.synchronizedMap(Map<K,V>)Collections.synchronizedSet(Set<T>)
特点:
- 使用内部同步(
synchronized关键字)实现线程安全。 - 每个方法调用都会加锁,性能较低。
- 适合低并发环境。
并发集合类(java.util.concurrent包)
这些都是为高并发设计的线程安全的常见的集合类型
| 集合类型 | 实现类 | 特点/用途 |
|---|---|---|
| List | CopyOnWriteArrayList | 写时复制,适合读多写少场景 |
| Set | CopyOnWriteArraySet | 基于 CopyOnWriteArrayList 实现 |
| Map | ConcurrentHashMap | 分段锁/无锁设计,高并发性能优秀 |
| Queue | ConcurrentLinkedQueue | 基于链表的无界队列,非阻塞 |
| Deque | ConcurrentLinkedDeque | 双端无界队列,非阻塞 |
| BlockingQueue | ArrayBlockingQueue | 有界阻塞队列,基于数组 |
| BlockingQueue | LinkedBlockingQueue | 有界或无界阻塞队列,基于链表 |
| BlockingQueue | PriorityBlockingQueue | 优先级阻塞队列 |
| BlockingQueue | DelayQueue | 延迟元素队列,用于定时任务调度等 |
| BlockingQueue | SynchronousQueue | 直接交付,不存储元素,用于线程之间传递 |
| BlockingDeque | LinkedBlockingDeque | 双端阻塞队列 |
| Skip List | ConcurrentSkipListMap / ConcurrentSkipListSet | 有序结构,适合范围查询,高并发有序操作 |
原子变量集合(Atomic 系列)
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
这些不是传统意义的集合,但提供对数组的原始访问能力。
使用建议
| 需求类型 | 推荐集合 |
|---|---|
| 高并发读写 Map | ConcurrentHashMap |
| 读多写少的 List | CopyOnWriteArrayList |
| 阻塞式生产者消费者 | LinkedBlockingQueue 或 ArrayBlockingQueue |
| 有序高并发 Map | ConcurrentSkipListMap |
ArrayList 的默认大小是多少,如何进行扩容的?
默认构造函数
/**
* Shared empty array instance used for default sized empty instances. We
* distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
* first element is added.
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}此时默认容量为:0(不是 10),但它会在第一次添加元素时变为 10。
/**
* Default initial capacity.
*/
private static final int DEFAULT_CAPACITY = 10;扩容大小为多少
java21 源码
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
* @throws OutOfMemoryError if minCapacity is less than zero
*/
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//扩容在原始基础上增加 0.5 倍,即扩容为原始大小的 1.5 倍
//如果扩容为原值的 1.5 倍后超过了容量最大值,每次扩容的值为 minCapacity - oldCapacity 的差值(java 21中应该是 1)
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity >> 1 /* preferred growth */);
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
//初始扩容,容量由 0 扩容为 10
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
// preconditions not checked because of inlining
// assert oldLength >= 0
// assert minGrowth > 0
int prefLength = oldLength + Math.max(minGrowth, prefGrowth); // might overflow
if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {
return prefLength;
} else {
// put code cold in a separate method
return hugeLength(oldLength, minGrowth);
}
}如何避免频繁扩容?
由于每次扩容时,都是调用一次Arrays.copyOf() 进行复制,比较耗费资源,初始化数组时,如果你知道大概的数据量范围,提前设置容量可以显著减少扩容次数,提高性能
private List<String> list = new ArrayList<>(1000);//初始化时提供初始值ArrayList 为什么不是线程安全的?
ArrayList 不是线程安全的,这是因为它没有对关键的共享操作做同步处理。在多线程环境中,如果多个线程同时对同一个 ArrayList 实例进行读写操作,可能会导致数据错乱、异常甚至崩溃。
结构性修改不同步
ArrayList 的核心成员是一个普通的数组:
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
transient Object[] elementData; // non-private to simplify nested class access常用的操作如 add(), remove(), get() 等,在源码中都**没有使用任何同步(如 synchronized 或 Lock)**来保护这些数组操作。
// 非线程安全的 add 方法简化逻辑
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 步骤1:检查容量
elementData[size++] = e; // 步骤2:赋值并增加size
return true;
}这两个步骤不是原子操作,多线程环境下可能导致:
- 数据覆盖:两个线程同时执行步骤2时可能覆盖彼此的值
- size不一致:size++不是原子操作(实际是读-改-写三步操作)
扩容机制不安全
扩容步骤 Grow() 包含多个非原子操作
// 非线程安全的扩容简化过程
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 计算新容量
elementData = Arrays.copyOf(elementData, newCapacity); // 创建新数组并复制数据
}多线程环境下可能导致:
- 数据丢失:一个线程正在复制数据时,另一个线程修改了原数组
- 数组越界:扩容过程中其他线程访问旧数组
迭代器快速失败(Fail-Fast)
ArrayList 的迭代器会在检测到并发修改时抛出 ConcurrentModificationException:
final void checkForCommodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}modCount(修改计数器)没有同步保护,多线程操作时会导致意外抛出异常
什么是 fail-fast?什么是 fail-safe?
在编程和系统设计中,fail-fast(快速失败) 和 fail-safe(安全失败) 是两种处理错误或异常的机制,目标不同,行为也相反。
Fail-Fast(快速失败)
定义:在程序运行中,一旦检测到错误或异常,立即抛出异常并停止当前操作。
目的:尽早发现问题,避免错误传播,便于调试。
特点:
快速响应异常。
提高程序的健壮性和调试效率。
通常会抛出异常(如 Java 中的
ConcurrentModificationException)。
常见场景:
Java 中的集合类(如
ArrayList、HashMap)的 迭代器 Iterator 是 fail-fast 的:javaList<String> list = new ArrayList<>(); list.add("A"); list.add("B"); for (String s : list) { list.remove(s); // 抛出 ConcurrentModificationException }
Fail-Safe(安全失败)
Fail-Safe 是一种避免失败而不是立即报告失败的策略。
特点:
在遇到问题时不会抛出异常,而是尝试继续操作
Java 并发集合(如 CopyOnWriteArrayList, ConcurrentHashMap)采用此策略
通常通过数据副本或特殊并发控制实现
设计目的是保证系统在错误发生时仍能继续运行
示例:
javaList<String> list = new CopyOnWriteArrayList<>();//CopyOnWriteArrayList 是 fail-safe 的,迭代器基于快照,修改不会影响正在进行的迭代,也不会抛异常。所以CopyOnWriteArrayList 是线程安全的 list.add("A"); list.add("B"); for (String s : list) { list.remove(s); // 不会抛出异常 }
关键区别
| 特性 | Fail-Fast | Fail-Safe |
|---|---|---|
| 错误处理 | 立即抛出异常 | 尝试继续操作 |
| 性能影响 | 无额外开销 | 可能有复制数据的开销 |
| 使用场景 | 单线程环境 | 多线程环境 |
| 集合示例 | ArrayList, HashMap | CopyOnWriteArrayList, ConcurrentHashMap |
| 数据一致性 | 可能不一致(被中断) | 保持一致性 |
TreeSet 的数据结构是什么?
TreeSet 是 Java 中基于 红黑树(Red-Black Tree) 实现的集合类。
核心数据结构:红黑树(Red-Black Tree)
TreeSet底层使用的是TreeMap,而TreeMap是一个基于红黑树的有序映射结构。- 因此,
TreeSet实际上是用TreeMap来存储元素,只不过键是元素本身,值是一个常量(如PRESENT占位符)。
红黑树的特点
红黑树是一种 自平衡的二叉查找树(BST),它在插入和删除元素后,仍能保持树的平衡,从而保证 查找、插入、删除操作的时间复杂度都是 O(log n)。
红黑树具备以下几个性质:
- 每个节点要么是红色,要么是黑色。
- 根节点永远是黑色。
- 所有叶子节点(null)都是黑色。
- 如果一个节点是红色的,则它的子节点必须是黑色的(即不能有两个连续的红色节点)。
- 从任一节点到其所有后代叶子节点的路径上,黑色节点数量相同(黑高一致)。
这些规则确保了树不会退化成链表,从而维持操作效率。
TreeSet 的行为特性(来源于红黑树)
| 特性 | 表现 |
|---|---|
| 元素有序 | 根据自然顺序(Comparable)或自定义 Comparator 排序 |
| 不允许重复元素 | 判断依据是元素的比较结果为 0(不是 equals()) |
| 操作复杂度 | 插入、删除、查找都是 O(log n) |
| 支持范围操作 | 如 subSet(), headSet(), tailSet() 等 |
| 支持排序遍历 | 迭代出来的顺序是排序后的顺序 |
示例代码
import java.util.TreeSet;
public class TreeSetExample {
public static void main(String[] args) {
TreeSet<Integer> set = new TreeSet<>();
set.add(5);
set.add(2);
set.add(8);
set.add(1);
System.out.println(set); // 输出:[1, 2, 5, 8],有序
}
}怎么确保一个集合不能被修改?
使用 Collections.unmodifiableXXX()
Java 提供了标准包装方法使集合变为只读:
import java.util.*;
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
List<String> unmodifiableList = Collections.unmodifiableList(list);
unmodifiableList.add("c"); // 抛出 UnsupportedOperationException⚠️ 注意:原始集合如果被修改,包装集合也会反映变化。
使用 Java 9+ 的 List.of(), Set.of(), Map.of()
这些方法返回真正的不可变集合:
List<String> list = List.of("a", "b"); // 不可变
list.add("c"); // 抛出 UnsupportedOperationException- 优点:原始集合不能修改,也不能反射外部集合的变化。
- 缺点:只在 Java9 及以上版本支持
使用第三方库(如 Google Guava)
import com.google.common.collect.ImmutableList;
List<String> immutableList = ImmutableList.of("a", "b");
immutableList.add("c"); // 抛出异常Guava 的 ImmutableList 等类是真正不可变的、线程安全的。
常见场景及理由
| 场景 | 原因 |
|---|---|
| 防止方法外部修改集合 | 避免暴露内部状态,增强封装性 |
| 多线程场景下共享集合 | 不可变集合天然线程安全 |
| 作为常量集合 | 避免初始化后被意外改动 |
为什么不建议使用双括号初始化集合?
双括号初始化(Double Brace Initialization)是一种看起来简洁的集合初始化方式,但存在几个重要问题,因此不建议使用。
List<String> list = new ArrayList<String>() {{
add("A");
add("B");
add("C");
}};外层大括号创建匿名子类,内层大括号是实例初始化块(instance initializer block)
主要问题
性能问题:
- 每次使用都会创建一个新的匿名子类
- 增加类加载开销(类加载器需要处理额外的类)
- 生成的.class文件会增多
内存泄漏风险:
- 匿名内部类隐式持有外部类的引用
- 如果集合被长期持有,会导致外部类无法被GC回收
序列化问题:
- 匿名类的序列化行为可能与预期不同
- 反序列化时可能遇到问题
代码可读性:
- 看似简洁,但实际语法晦涩
- 许多开发者不熟悉这种写法
- IDE静态分析工具可能无法正确处理
equals()方法行为:
因为创建的是匿名子类,所以
getClass()检查会失败javanew ArrayList<String>() {{ add("A"); }}.equals(new ArrayList<String>() {{ add("A"); }}) // 返回false,因为它们是不同的类
推荐替代方案
Java 9+ 的工厂方法:
javaList<String> list = List.of("A", "B", "C"); Set<String> set = Set.of("A", "B", "C"); Map<String, Integer> map = Map.of("A", 1, "B", 2);Guava 库(Java 8及之前):
javaList<String> list = ImmutableList.of("A", "B", "C"); Set<String> set = ImmutableSet.of("A", "B", "C");传统方式:
javaList<String> list = new ArrayList<>(); list.add("A"); list.add("B"); list.add("C");使用工具方法:
javaList<String> list = Arrays.asList("A", "B", "C"); // 固定大小列表
什么是阻塞队列?
阻塞队列是一种特殊的队列,它在多线程编程中扮演着重要角色,提供了线程安全的入队和出队操作,并具有阻塞特性。
核心特性
- 线程安全:所有操作都是原子性的,多线程访问无需额外同步
- 阻塞操作:
- 当队列满时,插入操作会被阻塞,直到队列有空间
- 当队列空时,获取操作会被阻塞,直到队列有元素
- 容量限制:可以是有界队列(固定容量)或无界队列(理论上无限容量)
Java 中的主线实现类
Java在java.util.concurrent包中提供了多种阻塞队列实现:
- ArrayBlockingQueue:基于数组的有界阻塞队列(FIFO)
- LinkedBlockingQueue:基于链表的可选有界阻塞队列(FIFO)
- PriorityBlockingQueue:支持优先级排序的无界阻塞队列
- DelayQueue:元素只有到期才能取出的无界阻塞队列
- SynchronousQueue:不存储元素的阻塞队列(直接传递)
- LinkedTransferQueue:改进的传输队列(Java 7+)
核心方法
阻塞队列提供四种不同的操作方法:
| 操作类型 | 抛出异常 | 返回特殊值 | 阻塞 | 超时 |
|---|---|---|---|---|
| 插入 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
| 移除 | remove() | poll() | take() | poll(time, unit) |
| 检查 | element() | peek() | 不可用 | 不可用 |
典型应用场景
生产者消费者模式:
java// 生产者 public void produce() throws InterruptedException { while (true) { Object item = generateItem(); queue.put(item); // 队列满时阻塞 } } // 消费者 public void consume() throws InterruptedException { while (true) { Object item = queue.take(); // 队列空时阻塞 processItem(item); } }线程池任务队列(如
ThreadPoolExecutor使用阻塞队列)异步消息处理系统
流量控制和缓冲
工作原理示例(ArrayBlockingQueue)
内部结构:
- 使用数组存储元素
- 维护两个指针:putIndex(插入位置)和takeIndex(取出位置)
- 使用ReentrantLock保证线程安全
- 使用Condition实现阻塞通知机制
put()方法代码
java/** * Inserts the specified element at the tail of this queue, waiting * for space to become available if the queue is full. * * @throws InterruptedException {@inheritDoc} * @throws NullPointerException {@inheritDoc} */ public void put(E e) throws InterruptedException { Objects.requireNonNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) //队列满 notFull.await();// 等待"不满"信号 enqueue(e);//入队 } finally { lock.unlock(); } } /** * Inserts element at current put position, advances, and signals. * Call only when holding lock. */ private void enqueue(E e) { // assert lock.isHeldByCurrentThread(); // assert lock.getHoldCount() == 1; // assert items[putIndex] == null; final Object[] items = this.items; items[putIndex] = e; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); //发送队列不空信号 }
阻塞队列是线程安全的吗?
阻塞队列(Blocking Queue)是线程安全的,这是它的核心特性之一。
线程安全实现机制
阻塞队列通过以下几种方式保证线程安全:
内部锁机制:
使用
ReentrantLock或synchronized保证操作的原子性例如
ArrayBlockingQueue使用ReentrantLock:javafinal ReentrantLock lock = new ReentrantLock();
条件变量(Condition):
实现精确的线程等待/唤醒机制
通常包含两个Condition:
javaprivate final Condition notEmpty = lock.newCondition(); // 队列非空条件 private final Condition notFull = lock.newCondition(); // 队列非满条件
线程安全的操作设计:
所有 public 方法都内置同步控制
例如
put()和take()方法的典型实现:javapublic void put(E e) throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); } }
线程安全操作示例
| 方法类型 | 线程安全保证 | 典型方法 |
|---|---|---|
| 插入 | 队列满时阻塞等待 | put(e), offer(e, timeout, unit) |
| 移除 | 队列空时阻塞等待 | take(), poll(timeout, unit) |
| 检查 | 非阻塞安全访问 | peek(), size() |
为什么需要线程安全?
生产者-消费者模式:多线程并发操作队列时不需要额外同步
java// 生产者线程 new Thread(() -> { blockingQueue.put(data); // 无需额外同步 }).start(); // 消费者线程 new Thread(() -> { Data data = blockingQueue.take(); // 无需额外同步 }).start();避免竞态条件:防止多个线程同时修改导致数据不一致
内存可见性:通过volatile变量或锁保证修改对所有线程立即可见
注意事项
批量操作:如
drainTo()方法虽然是原子的,但操作期间其他线程的修改不可见迭代器:迭代器是弱一致性的(不保证反映所有最新修改)
特殊方法:
remove(Object)等非阻塞方法也是线程安全的但组合操作(如"检查再行动")仍需外部同步:
javaif (!queue.isEmpty()) { // 这两行不是原子操作 queue.poll(); // 可能需要外部同步 }
java中常见的阻塞队列有哪些?阻塞队列有哪些常用的应用场景?
java 中常见的阻塞队列
Java在java.util.concurrent包中提供了多种阻塞队列实现,以下是主要的几种:
- ArrayBlockingQueue
- 基于数组实现的 有界阻塞队列
- 按照 FIFO(先进先出)原则排序元素
- 必须指定容量,创建后不能调整大小
- 使用单个 ReentrantLock 控制 出入队操作
- LinkedBlockingQueue
- 基于链表实现的可选有界阻塞队列
- 默认容量为 Integer.MAX_VALUE(近似无界)
- 使用分离的读写锁(putLock和takeLock)提高吞吐量
- 同样遵循FIFO原则
- PriorityBlockingQueue
- 支持优先级排序的无界阻塞队列
- 基于二叉堆实现,默认自然排序或通过 comparator 比较器自定义排序
- 自动扩容(最大为Integer.MAX_VALUE)
- DelayQueue
- 基于PriorityQueue实现的无界延迟队列
- 元素必须实现Delayed接口
- 只有延迟期满的元素才能被取出
- SynchronousQueue
- 不存储元素的特殊阻塞队列
- 每个插入操作必须等待对应的移除操作
- 支持公平和非公平两种模式
- 吞吐量通常高于ArrayBlockingQueue和LinkedBlockingQueue
- LinkedTransferQueue
- 基于链表的无界TransferQueue
- 新增transfer()和tryTransfer()方法
- 可以直接将元素传递给等待的消费者
- LinkedBlockingDeque
- 双向阻塞队列,两端都可插入和移除
- 减少多线程竞争,适合"工作窃取"模式
- 可选有界(默认Integer.MAX_VALUE)
阻塞队列的常用应用场景
- 生产者-消费者模式
- 解耦生产者和消费者,平衡处理速度差异
- 生产者通过put()添加数据,消费者通过take()获取数据
- 队列满时阻塞生产者,队列空时阻塞消费者
- 线程池任务队列
- Java线程池(如ThreadPoolExecutor)使用阻塞队列作为工作队列
- 当核心线程忙时,新任务进入队列等待
- 队列满时根据策略处理(拒绝或创建新线程)
- 异步消息处理系统
- 不同线程间安全传递消息
- 发送方put消息,接收方take消息
- 特别适合事件驱动架构
- 定时任务调度
- DelayQueue可用于实现定时任务系统
- 任务按执行时间排序,到期自动触发
- 如TimerQueue和ScheduledThreadPoolExecutor内部实现
- 缓存系统设计
- 使用DelayQueue管理缓存过期
- 后台线程定期检查并移除过期缓存
- 元素过期时间作为延迟时间
- 流量控制和缓冲
- 控制突发流量,防止系统过载
- 作为缓冲区平衡生产消费速率差异
- 如文件监控系统中处理文件变化事件
- 高并发数据传输
- SynchronousQueue适合线程间直接传递数据
- 无中间存储,减少内存开销
- 如CachedThreadPool使用它处理新任务
阻塞队列通过内置的线程安全机制和阻塞/唤醒功能,极大简化了多线程编程的复杂度,是Java并发包中最重要的工具之一。选择哪种阻塞队列取决于具体需求,如是否需要边界、排序、延迟等特性
