Java并发编程知识点
一、并发容器和框架:解决手动同步的痛点
1.1 核心定义
在Java多线程编程中,并发容器 是java.util.concurrent包下提供的线程安全数据结构,内部已实现完善的同步机制或非阻塞算法,开发者无需手动加锁即可在多线程环境下安全操作数据。并发框架则是用于简化并发任务执行的组件集合,包括线程池、ForkJoin框架、同步工具类等。
1.2 三大核心优势
- 降低开发难度:避免手写同步代码带来的死锁、数据竞争等bug
- 性能更优 :经过JDK团队高度优化,采用分段锁、CAS等技术,远优于粗暴的
synchronized全表加锁 - 功能丰富:覆盖了从数据存储到任务执行的全场景并发需求
1.3 整体架构概览
java.util.concurrent
├── 并发容器
│ ├── 集合类:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap
│ └── 队列类:BlockingQueue、ConcurrentLinkedQueue
├── 并发框架
│ ├── 线程池:ThreadPoolExecutor、ScheduledThreadPoolExecutor
│ └── ForkJoin框架:ForkJoinPool、RecursiveTask、RecursiveAction
└── 工具类
├── 原子类:AtomicInteger、AtomicReference
└── 同步工具:CountDownLatch、CyclicBarrier、Semaphore
二、哈希表三巨头:HashMap、Hashtable与ConcurrentHashMap
这是Java面试100%必考点,必须彻底掌握三者的区别和底层实现。
2.1 核心对比表
| 特性 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | ❌ 非线程安全 | ✅ 线程安全(全表锁) | ✅ 线程安全(精细化锁) |
| 性能 | 极高 | 极差(单线程访问) | 高(并发度高) |
| 空键/空值 | 允许一个null键,多个null值 | 不允许任何null键或null值 | 不允许任何null键或null值 |
| 底层结构 | JDK1.7:数组+链表 JDK1.8:数组+链表+红黑树 | 数组+链表 | JDK1.7:分段锁(Segment数组) JDK1.8:数组+链表+红黑树+CAS+synchronized |
| 推荐场景 | 单线程环境 | 已废弃,不推荐使用 | 多线程并发环境 |
2.2 HashMap的进化与线程安全问题
- JDK 1.7 :采用头插法 插入元素,多线程扩容时会导致链表形成环形结构 ,后续
get()操作会陷入死循环,CPU使用率飙升至100% - JDK 1.8 :改为尾插法 解决了环形链表问题,但仍存在数据覆盖 等线程安全问题。例如两个线程同时执行
put()操作,可能导致其中一个线程的数据被覆盖
2.3 ConcurrentHashMap的实现原理(面试重中之重)
JDK 1.7:分段锁机制
- 内部维护一个
Segment数组,每个Segment继承自ReentrantLock,相当于一个独立的小哈希表 - 加锁粒度是Segment级别,不同Segment的操作可以完全并发执行
- 缺点:最多支持16个线程同时写(默认Segment数量为16),并发度有限
JDK 1.8:CAS+节点级锁
- 彻底取消了Segment,改为对每个数组节点(桶)进行加锁
- 当桶为空时,使用CAS操作插入元素,无需加锁
- 当桶不为空时,使用
synchronized锁定桶的头节点 - 引入红黑树优化长链表查询,与HashMap保持一致
- 优势:并发度大幅提升,理论上支持数组长度级别的并发写
2.4 面试高频追问
- 为什么ConcurrentHashMap不允许null键和null值?
为了避免歧义。如果get(key)返回null,无法判断是key不存在还是value本身就是null,在并发环境下这个问题会被放大。 - ConcurrentHashMap的size()方法如何实现?
JDK1.8采用先尝试无锁统计,失败后再加锁统计的方式,避免了1.7版本中加锁统计的性能问题。
三、哈希表底层原理与散列思想
3.1 哈希表的核心逻辑
哈希表之所以能实现O(1)时间复杂度的增删改查,核心在于散列技术:
- 通过哈希函数将任意长度的键(key)转换为固定长度的哈希值
- 将哈希值对数组长度取模,得到元素应该存放的**桶(bucket)**位置
- 如果多个元素映射到同一个桶,使用链地址法解决冲突(链表或红黑树)
3.2 HashMap的哈希函数设计(JDK 1.8)
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 将key的hashCode高16位与低16位进行异或运算
- 目的:让高位也参与到桶位置的计算中,减少哈希冲突,使元素分布更均匀
3.3 散列思想的工程应用
散列不仅用于哈希表,更是分布式系统的核心思想:
- 数据库分库分表:对主键哈希后取模,将数据分散到不同库表
- 负载均衡:对客户端IP哈希后分配到不同服务器
- 一致性哈希:解决分布式缓存的节点动态变化问题
四、HashMap多线程死循环问题深度解析
4.1 问题根源:JDK 1.7的头插法扩容
当HashMap元素数量超过阈值(容量×负载因子,默认0.75)时,会触发扩容:
- 创建一个容量为原来2倍的新数组
- 遍历旧数组,将每个元素重新哈希到新数组
- JDK 1.7采用头插法,将旧链表的节点依次插入新链表的头部
4.2 环形链表形成过程
假设两个线程同时对同一个HashMap进行扩容:
- 线程A执行到扩容代码的中间位置,被挂起
- 线程B完成扩容,将旧链表的节点重新排列
- 线程A恢复执行,继续按照原来的指针遍历
- 由于头插法的特性,两个节点会互相指向对方,形成环形链表
4.3 后果与解决方案
- 后果 :后续调用
get()方法查询该桶中的元素时,会陷入无限循环,CPU使用率飙升至100% - 解决方案 :
- 多线程环境下绝对不要使用HashMap
- 使用ConcurrentHashMap替代
- 如果必须使用HashMap,可以用
Collections.synchronizedMap()包装(性能较差)
五、阻塞队列:生产者消费者模式的最佳实践
5.1 核心特性
阻塞队列(BlockingQueue)是一种支持两个阻塞操作的队列:
- 当队列满时,入队操作会阻塞,直到队列有空闲空间
- 当队列空时,出队操作会阻塞,直到队列有元素
5.2 常见实现类对比
| 实现类 | 数据结构 | 有界性 | 特点 | 适用场景 |
|---|---|---|---|---|
ArrayBlockingQueue |
数组 | 有界 | 必须指定容量,公平/非公平锁可选 | 大多数生产环境 |
LinkedBlockingQueue |
链表 | 可选有界 | 默认无界,吞吐量高于ArrayBlockingQueue | 需注意内存溢出风险 |
SynchronousQueue |
无存储 | 特殊 | 不存储元素,每个入队必须等待一个出队 | 线程池newCachedThreadPool |
PriorityBlockingQueue |
堆 | 无界 | 支持优先级排序 | 需要按优先级处理任务的场景 |
5.3 有界队列与无界队列的权衡
无界队列的风险:
- 生产者速度远快于消费者时,任务会无限堆积,最终导致
OutOfMemoryError - 在线程池中使用无界队列时,线程池永远不会创建超过核心线程数的线程,任务响应时间会越来越长
有界队列的优势:
- 可以控制系统的资源使用,避免内存溢出
- 当队列满时,可以触发线程池扩容或执行拒绝策略,保证系统的可控性
最佳实践 :生产环境中必须使用有界队列,并根据业务场景合理设置队列容量。
5.4 代码示例:阻塞队列实现生产者消费者
java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class BlockingQueueDemo {
private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);
public static void main(String[] args) {
// 启动2个生产者线程
for (int i = 0; i < 2; i++) {
new Thread(new Producer(), "Producer-" + i).start();
}
// 启动3个消费者线程
for (int i = 0; i < 3; i++) {
new Thread(new Consumer(), "Consumer-" + i).start();
}
}
static class Producer implements Runnable {
@Override
public void run() {
int num = 0;
while (true) {
try {
queue.put(num); // 队列满时阻塞
System.out.println(Thread.currentThread().getName() + "生产:" + num);
num++;
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
static class Consumer implements Runnable {
@Override
public void run() {
while (true) {
try {
int num = queue.take(); // 队列空时阻塞
System.out.println(Thread.currentThread().getName() + "消费:" + num);
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
}
}
六、ForkJoin框架:分而治之的并行计算利器
6.1 核心思想
ForkJoin框架是Java 7引入的并行计算框架,基于分而治之的思想:
- Fork:将一个大任务递归拆分成多个足够小的子任务
- Join:并行执行所有子任务,然后合并子任务的结果得到最终结果
6.2 工作窃取算法(Work-Stealing)
ForkJoin框架的高效性得益于工作窃取算法:
- 每个工作线程都有自己的双端队列,用于存储分配给自己的任务
- 当一个线程完成自己队列中的所有任务后,会从其他线程队列的尾部窃取任务执行
- 这种方式可以有效减少线程竞争,提高CPU利用率
6.3 代码示例:计算1到n的和
java
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class ForkJoinDemo extends RecursiveTask<Long> {
private static final long THRESHOLD = 1000; // 任务拆分阈值
private final long start;
private final long end;
public ForkJoinDemo(long start, long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
// 如果任务足够小,直接计算
if (end - start <= THRESHOLD) {
long sum = 0;
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
}
// 否则拆分成两个子任务
long mid = (start + end) / 2;
ForkJoinDemo leftTask = new ForkJoinDemo(start, mid);
ForkJoinDemo rightTask = new ForkJoinDemo(mid + 1, end);
// 执行子任务
leftTask.fork();
rightTask.fork();
// 合并子任务结果
return leftTask.join() + rightTask.join();
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
long result = pool.invoke(new ForkJoinDemo(1, 1000000));
System.out.println("计算结果:" + result);
}
}
6.4 适用场景与限制
- 适用场景:CPU密集型任务,尤其是递归分治类任务(如排序、矩阵运算、大数据处理)
- 不适用场景:IO密集型任务(线程会阻塞,无法充分利用CPU)
- 注意事项:子任务中不能执行阻塞操作,否则会导致工作线程无法执行其他任务
七、原子类与CAS机制:无锁线程安全的实现
7.1 原子类分类
java.util.concurrent.atomic包提供了多种原子类,用于实现无锁的线程安全操作:
- 基本类型:AtomicInteger、AtomicLong、AtomicBoolean
- 引用类型:AtomicReference、AtomicStampedReference、AtomicMarkableReference
- 数组类型:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
- 字段更新器:AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
7.2 CAS机制详解
CAS(Compare-And-Swap,比较并交换)是一种CPU原语,是实现原子类的基础:
- 它包含三个操作数:内存地址V、旧的预期值A、新值B
- 当且仅当内存地址V中的值等于预期值A时,将V的值更新为B
- 整个操作是原子的,不会被其他线程中断
AtomicInteger的incrementAndGet()方法内部就是一个典型的CAS自旋:
java
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe类中的getAndAddInt方法
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 读取当前值
} while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS尝试更新
return v;
}
7.3 CAS的三大问题
- ABA问题 :一个值从A变为B又变回A,CAS会认为没有变化,但实际上已经发生了变化
- 解决方案 :使用
AtomicStampedReference(带版本号)或AtomicMarkableReference(带标记位)
- 解决方案 :使用
- 自旋开销大 :高并发下CAS会频繁失败,导致大量自旋重试,浪费CPU资源
- 解决方案:高竞争场景下使用锁替代
- 只能保证单个变量的原子性 :无法同时保证多个变量的原子操作
- 解决方案:使用锁或将多个变量封装成一个对象,使用AtomicReference
7.4 ABA问题代码示例
java
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABADemo {
private static final AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
public static void main(String[] args) throws InterruptedException {
// 线程1:执行ABA操作
Thread t1 = new Thread(() -> {
int stamp = ref.getStamp();
System.out.println("线程1初始版本号:" + stamp);
ref.compareAndSet(100, 101, stamp, stamp + 1);
ref.compareAndSet(101, 100, ref.getStamp(), ref.getStamp() + 1);
});
// 线程2:尝试更新
Thread t2 = new Thread(() -> {
int stamp = ref.getStamp();
System.out.println("线程2初始版本号:" + stamp);
try {
Thread.sleep(1000); // 等待线程1完成ABA操作
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean success = ref.compareAndSet(100, 200, stamp, stamp + 1);
System.out.println("线程2更新是否成功:" + success);
System.out.println("当前版本号:" + ref.getStamp());
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
执行结果:
线程1初始版本号:0
线程2初始版本号:0
线程2更新是否成功:false
当前版本号:2
八、同步工具类:CountDownLatch与CyclicBarrier
8.1 CountDownLatch:一次性计数器
- 作用:允许一个或多个线程等待其他线程完成一组操作
- 原理:通过一个计数器实现,初始值为需要等待的线程数
- 核心方法 :
countDown():计数器减1await():等待计数器变为0
- 特点:一次性使用,计数器变为0后无法重置
8.2 CyclicBarrier:循环屏障
- 作用:允许一组线程互相等待,直到所有线程都到达屏障点
- 原理:通过一个计数器实现,初始值为参与等待的线程数
- 核心方法 :
await():线程到达屏障点,等待其他线程reset():重置屏障,可以重复使用
- 特点:可循环使用,支持在所有线程到达时执行一个额外的Runnable任务
8.3 核心区别对比表
| 特性 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 等待关系 | 主线程等待子线程 | 子线程互相等待 |
| 计数器 | 只能减少,一次性使用 | 可以重置,循环使用 |
| 额外任务 | 不支持 | 支持在所有线程到达时执行 |
| 适用场景 | 等待多个任务完成后汇总结果 | 并行迭代计算,多线程同步执行 |
8.4 CyclicBarrier代码示例:模拟运动员赛跑
java
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierDemo {
public static void main(String[] args) {
int runnerCount = 5;
// 创建CyclicBarrier,当所有运动员到达后执行发令枪任务
CyclicBarrier barrier = new CyclicBarrier(runnerCount, () -> {
System.out.println("所有运动员准备就绪,发令枪响!");
});
for (int i = 0; i < runnerCount; i++) {
new Thread(new Runner(barrier), "运动员" + (i + 1)).start();
}
}
static class Runner implements Runnable {
private final CyclicBarrier barrier;
public Runner(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "正在准备...");
Thread.sleep((long) (Math.random() * 1000)); // 模拟准备时间
System.out.println(Thread.currentThread().getName() + "准备完毕");
barrier.await(); // 等待其他运动员
System.out.println(Thread.currentThread().getName() + "开始跑步!");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
总结
本文总结了Java并发容器和框架中最核心的面试知识点,涵盖了并发容器、阻塞队列、ForkJoin框架、原子类与CAS、同步工具类等多个方面。在面试中,不仅要记住这些概念,更要理解其底层原理和适用场景,并能结合代码示例进行说明。