Java 并发与多线程
乐观锁和悲观锁
悲观锁
悲观锁的核心思想是:先假设冲突一定会发生,因此在访问共享资源前先把资源“锁住”。
它更适合写操作频繁、冲突概率高的场景,可以避免大量重试带来的性能损耗。
synchronized
synchronized 是 Java 内置的监视器锁,使用方式主要有三种。
- 修饰实例方法:锁的是当前对象实例,同一时刻同一个对象只能有一个线程执行该同步实例方法。
1 | public synchronized void decrement() {} |
- 修饰静态方法:锁的是当前类对应的
Class对象,同一时刻只能有一个线程执行该类的同步静态方法。
1 | public static synchronized void incrementStatic() {} |
- 修饰代码块:锁的粒度更细,可以显式指定锁对象,通常比直接锁整个方法更灵活。
1 | class BlockCounter { |
ReentrantLock
ReentrantLock 是显式锁,相比 synchronized 更灵活,常见特点如下:
- 支持公平锁和非公平锁,构造时传入
true表示公平锁。 - 支持
lock()、tryLock()、lockInterruptibly()等更丰富的加锁方式。 - 必须在
finally中手动释放锁,否则容易死锁。
1 | Lock lock = new ReentrantLock(); |
乐观锁
乐观锁的核心思想是:先假设冲突不大,更新时再检查数据有没有被别人改过。它更适合读多写少、冲突较低的场景。
CAS
CAS 即 Compare And Swap,通常会比较三个值:
V:内存中的当前值E:期望值X:要更新成的新值
只有当 V == E 时,才会把值更新为 X。这个比较并交换过程是原子的。
在 Java 中,CAS 可以理解为 JVM 基于底层硬件原子指令提供的一种能力。像 AtomicInteger、AtomicLong 这类原子类,底层就大量使用了 CAS。
1 | public final int getAndAddInt(Object o, long offset, int delta) { |
上面这段逻辑可以概括为:
- 先读取当前值
v - 尝试用 CAS 把它更新为
v + delta - 如果失败,说明别的线程已经改过了,继续重试
CAS 的 ABA 问题
CAS 只能判断“当前值是否等于期望值”,但不知道这个值在中间是否被改过又改回去了,这就是 ABA 问题。例如:
- 线程 A 读取到值为
A - 线程 B 把它改成
B - 线程 B 又把它改回
A - 线程 A 再做 CAS 时,仍然会认为“值没有变过”
常见解决方案是给值加上版本号或时间戳,例如 AtomicStampedReference。
1 | public boolean compareAndSet( |
AQS
AQS 全称 AbstractQueuedSynchronizer,是 JUC 中非常核心的一个同步器框架。它本身不是一个具体的锁,而是为“如何获取同步状态、如何失败排队、如何唤醒后继节点”提供了一套通用模板。
很多同步组件都建立在 AQS 之上,例如:
ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock
需要注意的是,CyclicBarrier 并不是直接基于 AQS 实现的,它更偏向于 ReentrantLock + Condition 的组合。
AQS 的核心组成
AQS 的底层可以抓住三件事:
state,一个volatile int变量,表示同步状态。不同组件对它的含义定义不同,比如:- 在
ReentrantLock中,常表示锁的占用状态和重入次数 - 在
Semaphore中,常表示剩余许可数 - 在
CountDownLatch中,常表示剩余计数
- 在
等待队列,一个 FIFO 的双向链表队列,用来保存获取同步状态失败的线程。
获取与释放模板。AQS 把“获取失败就入队、挂起、被唤醒后再竞争”这套流程抽象好了,子类只需要重写如
tryAcquire()、tryRelease()、tryAcquireShared()等方法。
ReentrantLock 的加锁流程
以 ReentrantLock 为例,可以把流程理解为:
先尝试直接获取锁:
- 如果
state == 0,说明当前没有线程持有锁,尝试 CAS 抢锁 - 如果成功,则把持有线程设为自己
- 如果
如果
state != 0:- 如果持有锁的线程就是自己,说明是可重入,直接把
state增加 - 如果不是自己,则获取失败,准备进入等待队列
- 如果持有锁的线程就是自己,说明是可重入,直接把
获取失败的线程会被包装成一个
Node节点,加入 AQS 的等待队列尾部入队后线程不会无限忙等,而是:
- 判断自己的前驱节点是否是头节点
- 如果前驱是头节点,就再尝试一次获取锁
- 如果还拿不到,就挂起自己,等待前驱节点释放锁后被唤醒
当前线程释放锁后,会唤醒队列中的后继节点继续竞争
公平锁和非公平锁
- 非公平锁:新线程到来时可以直接尝试抢锁,不一定老老实实排队,吞吐量通常更高
- 公平锁:更强调先来后到,通常会先检查等待队列里是否已有前驱节点
因此,非公平锁更常用;公平锁的好处是更“规矩”,代价是性能通常略低。
Condition
Condition 可以理解为 Lock 体系下的“等待 / 通知机制”,常与 ReentrantLock 搭配使用。如果说 Object 提供了 wait()、notify()、notifyAll(),那么 Condition 就提供了更灵活的:
await()signal()signalAll()
它最常见的价值有两个:
- 把“加锁”和“等待队列”从
synchronized体系里解耦出来,写法更灵活 - 一个
Lock可以创建多个Condition对象,从而维护多个等待队列,比wait/notify更容易精准唤醒
基本用法
1 | Lock lock = new ReentrantLock(); |
生产者线程在放入数据后,可以调用:
1 | lock.lock(); |
和 wait/notify 的区别
wait/notify必须配合synchronized使用,await/signal必须配合Lock使用wait()操作的是对象监视器,await()操作的是Condition等待队列- 一个对象监视器本质上只有一组等待队列;而一个
Lock可以创建多个Condition,适合区分“队列非空”“队列未满”等不同条件
因此,在像生产者消费者模型这类“存在多个等待条件”的场景里,Condition 通常比 wait/notify 更清晰。
底层理解
Condition 的底层实现和 AQS 关系很深。可以把它简单理解为有两套队列:
- AQS 同步队列:竞争锁失败的线程会进入这里
Condition等待队列:调用await()后暂时等待条件满足的线程会进入这里
一个线程调用 await() 时,大致会经历:
- 当前线程必须已经持有锁
- 调用
await()后,线程会先释放当前锁 - 线程进入
Condition的等待队列并挂起 - 其他线程调用
signal()后,不会让它立刻继续执行,而是先把它转移到 AQS 同步队列 - 该线程重新竞争到锁之后,才会从
await()返回
这也是为什么 await() 一定要放在 while 循环里判断条件,而不能只用 if:
- 线程被唤醒后不代表条件一定满足
- 它只是获得了“重新竞争锁并再次检查条件”的机会
Semaphore
Semaphore 表示信号量,本质上是对“可同时访问某资源的线程数量”做限制。
1 | final Semaphore semaphore = new Semaphore(5); |
可以把它理解成:state 初始值是许可总数,每获取一次许可就减 1,每释放一次就加 1。
CountDownLatch
CountDownLatch 是倒计时门闩,适合“等待一批任务全部执行完再继续”的场景。它是一次性的,计数值减到 0 之后不能重置。
1 | final CountDownLatch latch = new CountDownLatch(3); |
注意,它不是“允许 count 个线程阻塞”,而是:
- 一个或多个线程调用
await()进入等待 - 其他线程调用
countDown()递减计数 - 当计数减到 0 时,所有等待线程同时继续
CyclicBarrier
CyclicBarrier 是循环栅栏,适合“让一组线程互相等待,等大家都到齐后再一起继续”的场景。和 CountDownLatch 的区别在于:
CountDownLatch更像“主线程等子线程”CyclicBarrier更像“多个线程彼此等到齐”
而且 CyclicBarrier 可以重复使用,这也是它名字里 Cyclic 的来源。
JMM 内存模型
JMM 全称 Java Memory Model,即 Java 内存模型。
它不是对真实硬件内存结构的简单复刻,而是一套并发访问规则和可见性规范。
我们通常会用“主内存 / 工作内存”来帮助理解它。
主内存:所有线程共享的内存区域,共享变量最终都要以主内存中的值为准。
工作内存:每个线程都有自己的工作内存,可以理解为共享变量在当前线程中的本地副本。线程对共享变量的读取、赋值,通常都先发生在工作内存,再在合适的时候同步回主内存。

happens-before 规则
happens-before 可以理解为 Java 并发中的一组“可见性与有序性保证”。
如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见,并且 A 的执行顺序排在 B 之前。
常见规则有:
程序次序规则
同一个线程内,代码前面的操作happens-before后面的操作。监视器锁规则
对一个锁的解锁,happens-before于后续对同一个锁的加锁。volatile变量规则
对一个volatile变量的写,happens-before于后续对这个变量的读。线程启动规则
线程对象调用start()之前的操作,happens-before于该线程中的任意操作。线程终止规则
线程中的所有操作,happens-before于其他线程检测到该线程已经结束,例如Thread.join()返回。
并发编程三大特性
原子性
一个操作或一组操作要么全部执行成功,要么全部不执行,不会只执行一半。
常见实现手段:
synchronizedLock- 原子类,如
AtomicInteger
可见性
一个线程对共享变量的修改,其他线程能够及时看到。
常见实现手段:
volatilesynchronizedLock
有序性
程序执行时,编译器和处理器可能会对指令进行重排序。
单线程下这通常不会影响结果,但在多线程环境下可能带来问题。JMM 会通过 happens-before 规则、内存屏障等机制来约束这种重排序。
ThreadLocal
ThreadLocal 用来为每个线程单独保存一份变量副本,常用于保存用户上下文、事务上下文、请求 ID 等线程隔离数据。
它的关键点是:
数据不是存在线程共享的 ThreadLocal 对象里,而是存在线程自己的 ThreadLocalMap 里。
基本原理
每个 Thread 对象内部都有一个 ThreadLocal.ThreadLocalMap 类型的成员变量 threadLocals。
当线程第一次调用 ThreadLocal.set() 时,才会懒加载创建这个 ThreadLocalMap。
可以把 ThreadLocal 的存取过程理解为:
- 先拿到当前线程
Thread.currentThread() - 找到这个线程自己的
ThreadLocalMap - 以当前
ThreadLocal对象作为 key,以业务值作为 value 存进去
因此,不同线程虽然拿的是同一个 ThreadLocal 对象,但访问到的是各自线程私有的数据。

1 | public class ThreadLocalMultiThreadDemo { |
为什么 key 要用弱引用
ThreadLocalMap 中的每个条目本质上是一个 Entry,其中:
- key 是
ThreadLocal,并且是弱引用 - value 是业务对象,并且是强引用
这样设计的原因是:如果业务代码已经不再持有某个 ThreadLocal 的强引用,那么这个 ThreadLocal 对象应该可以被 GC 回收,而不是被线程长期“绑死”。
如果 key 不是弱引用,在使用线程池时会很危险:
- 线程对象通常长期存在
- 线程内部的
ThreadLocalMap也长期存在 ThreadLocal作为 key 也会一直被引用,导致对应 entry 无法回收
为什么仍然可能内存泄漏
key 是弱引用,只能解决“ThreadLocal 对象本身不再被引用”的问题,并不能自动解决 value 泄漏。
典型问题在于:
ThreadLocal被回收后,Entry中的 key 会变成null- 但是 value 仍然被
Thread -> ThreadLocalMap -> Entry -> value这条引用链强引用着 - 如果线程一直不销毁,这个 value 就可能长期滞留
因此,ThreadLocal 的正确使用姿势是:**用完后显式调用 remove()**。
1 | try { |
ThreadLocalMap
ThreadLocalMap 不是普通的 HashMap,它有几个特点:
- 只服务于当前线程,是线程私有结构
- 底层使用
Entry[]数组存储数据 - 发生哈希冲突时,使用的是线性探测法
- 在
get()、set()、remove()过程中,会顺带清理一部分 key 已失效的陈旧 entry
也正因为它不会像通用 Map 那样自动、及时、彻底地清理所有脏数据,所以在线程池场景中,remove() 非常重要。
ConcurrentHashMap
ConcurrentHashMap 是 Java 中最常用的并发容器之一,适合在多线程环境下高并发读写 Map 的场景中使用。
它的目标很明确:在保证线程安全的前提下,尽量提高并发性能。
它和 HashMap、Hashtable 的区别
HashMap线程不安全,多线程同时读写时可能出现数据覆盖、链表结构异常等问题Hashtable线程安全,但基本是用synchronized修饰整张表的方法,锁粒度较粗,并发性能一般ConcurrentHashMap也是线程安全的,但它不会简单地“整张表一把大锁”,而是尽量缩小锁粒度,提高吞吐量
因此,实际开发里如果需要线程安全的 Map,通常优先考虑 ConcurrentHashMap,而不是 Hashtable。
JDK 1.7 和 JDK 1.8 的实现差异
这是面试里非常经典的一个点。
JDK 1.7
JDK 1.7 的 ConcurrentHashMap 使用的是 Segment 分段锁 思想。
可以粗略理解为:
- 整个
ConcurrentHashMap被拆成若干个Segment - 每个
Segment本身类似一个小型HashMap - 不同线程访问不同
Segment时,可以并行执行
优点是思路直观;缺点是结构偏重,扩容和维护成本也更高。
JDK 1.8
JDK 1.8 取消了 Segment,整体结构更接近 HashMap,底层也是:
Node[]数组- 链表
- 红黑树
但它在并发控制上结合了:
CASvolatilesynchronized
可以简单理解为:
能用 CAS 的地方尽量用 CAS,只有在链表 / 红黑树节点更新时才在桶级别加锁。
这让它相比旧版拥有更灵活的并发能力。
为什么它是线程安全的
ConcurrentHashMap 的线程安全,不是靠单一手段实现的,而是多种机制配合:
- 数组、节点中的关键字段会通过
volatile保证可见性 - 插入空桶时,优先通过 CAS 完成,避免不必要的加锁
- 当某个桶中已经有元素时,会在桶头节点上加
synchronized锁,只锁当前桶,不锁整张表 - 扩容时允许多个线程协助迁移数据,而不是只让一个线程独自完成
所以它的核心思想可以概括成一句话:
锁只加在必要的位置,而且尽量缩小范围。
get() 和 put() 的大致流程
get()
get() 通常不需要加锁,流程大致如下:
- 根据 key 的 hash 定位到数组下标
- 读取该位置的桶头节点
- 如果桶头节点就匹配,直接返回
- 如果是链表或红黑树结构,就继续向后查找
这也是 ConcurrentHashMap 读性能通常比较好的原因之一。
put()
put() 的流程比 get() 更复杂一些:
- 先根据 hash 定位桶位置
- 如果桶为空,优先尝试 CAS 直接插入
- 如果桶不为空,则对该桶进行同步控制,再执行链表插入或红黑树插入
- 插入后如果链表长度超过阈值,可能会树化
- 最后还要检查是否需要扩容
也就是说,put() 并不是全程都加锁,而是先尝试无锁更新,失败后再进入更细粒度的同步逻辑。
为什么不允许 null key 和 null value
ConcurrentHashMap 不允许 null key,也不允许 null value。在并发场景下,如果 get(key) 返回 null,你很难区分:
- 这个 key 本来就不存在
- 还是这个 key 对应的 value 就是
null
在单线程 HashMap 中,这个歧义还能靠 containsKey() 再判断一次;但并发场景下,两次判断之间数据可能已经变化,因此这种设计会让语义变得不可靠。
使用时的一个常见误区
ConcurrentHashMap 能保证单次操作的线程安全,例如:
get()put()remove()putIfAbsent()
但它不能自动保证复合操作的原子性。例如下面这段逻辑就仍然可能有并发问题:
1 | if (!map.containsKey(key)) { |
因为“判断”和“写入”是两步,中间可能被别的线程插入。这种场景应该优先考虑:
putIfAbsent()computeIfAbsent()compute()merge()
Executor 相关类
Runnable 和 Callable
Runnable:只定义run(),没有返回值,也不能直接抛出受检异常Callable<V>:定义call(),有返回值,并且允许抛出异常
1 | Runnable runnable = () -> System.out.println("run"); |
Executor 和 ExecutorService
Executor是更顶层的执行器接口,只提供execute(Runnable)ExecutorService在此基础上扩展了线程池生命周期管理、提交任务、返回Future等能力
1 | executor.execute(runnable); |
它们的典型差异是:
execute()只接收Runnable,没有返回值submit()可以接收Runnable或Callable,并返回Future
Future 和 FutureTask
Future 用来表示异步任务的结果,可以:
- 判断任务是否完成
- 获取任务结果
- 取消任务
FutureTask<V> 则既实现了 Runnable,又实现了 Future<V>,相当于把“可执行任务”和“异步结果”合在了一起。
1 | Callable<Integer> callable = () -> 1 + 1; |

定时与周期任务
ScheduledExecutorService 用来执行延时任务和周期任务,常见实现类是 ScheduledThreadPoolExecutor。
1 | ScheduledExecutorService scheduledExecutor = |
常用方法:
schedule(Callable<V> callable, long delay, TimeUnit unit)
延迟一段时间后执行一次Callableschedule(Runnable command, long delay, TimeUnit unit)
延迟一段时间后执行一次RunnablescheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
按固定频率执行,更关注“开始时间间隔”scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
按固定延迟执行,更关注“上一次执行结束到下一次开始”的间隔
ForkJoinPool
ForkJoinPool 适合可以拆分成多个子任务、最后再汇总结果的场景,典型特点是工作窃取算法(work stealing)。
一句话理解:
- 大任务拆成小任务并行执行
- 空闲线程会去“偷”别的线程队列中的任务,提高 CPU 利用率
典型使用场景包括分治计算、并行递归任务等。
线程池参数
ThreadPoolExecutor 的 7 个核心参数
1 | public ThreadPoolExecutor( |
1. corePoolSize
核心线程数。即使线程空闲,默认也会保留这些线程,除非显式开启核心线程超时。
2. maximumPoolSize
线程池允许创建的最大线程数。
3. keepAliveTime
非核心线程的空闲存活时间。
当线程数大于 corePoolSize 时,多出来的空闲线程在空闲超过这个时间后会被回收。
4. unit
keepAliveTime 的时间单位。
5. workQueue
任务队列,用于保存等待执行的任务。常见选择:
ArrayBlockingQueue:有界队列LinkedBlockingQueue:链表阻塞队列,默认容量非常大SynchronousQueue:不存储元素,任务必须直接移交给工作线程
6. threadFactory
线程工厂,用于自定义线程创建逻辑,例如:
- 线程名称
- 是否守护线程
- 优先级
- 是否统一设置未捕获异常处理器
7. handler
拒绝策略。当任务队列满了、线程数也达到上限时,线程池如何处理新任务。
常见策略:
AbortPolicy:直接抛异常CallerRunsPolicy:由提交任务的线程自己执行DiscardOldestPolicy:丢弃队列中最旧的任务DiscardPolicy:直接丢弃当前任务
线程池的执行流程
提交一个任务时,大致遵循下面的顺序:
- 当前线程数 <
corePoolSize,直接创建核心线程执行任务 - 否则尝试把任务放入
workQueue - 如果队列也满了,并且当前线程数 <
maximumPoolSize,创建非核心线程执行 - 如果队列满了且线程数也达到上限,则触发拒绝策略
这个流程是理解线程池参数的关键。
Executors 提供的常见线程池
newFixedThreadPool
固定线程数,底层通常搭配 LinkedBlockingQueue。
优点是线程数稳定;缺点是任务队列可能堆积过多,请求量大时有 OOM 风险。
newSingleThreadExecutor
单线程执行器,保证任务串行执行。
适合按顺序执行任务的场景,比如串行消费、顺序写文件等。
newCachedThreadPool
线程数按需扩张,常与 SynchronousQueue 搭配使用。
优点是短平快任务很多时伸缩性较强;缺点是线程数上限很大,流量失控时可能创建过多线程。
newScheduledThreadPool
用于延迟和周期任务,本质上对应 ScheduledThreadPoolExecutor。
1 | ScheduledThreadPoolExecutor executor1 = new ScheduledThreadPoolExecutor(3); |
上面两种写法在运行时本质上指向同类能力,只是变量的静态类型不同。
生产环境中的建议
生产环境里,一般不建议直接用 Executors 提供的默认线程池,而是自己 new 一个 ThreadPoolExecutor,把核心线程数、最大线程数、队列容量、线程工厂和拒绝策略都写清楚。原因也不复杂:默认配置往往把风险藏起来了,比如队列太大导致任务一直堆,或者线程数放得太开,把机器打满了才发现;另外线程名、监控、拒绝策略也都不够好控制。
线程池参数没有固定答案,通常还是先看任务类型。CPU 密集型任务,比如计算、加密、压缩,线程数通常设得接近 CPU 核心数就够了,再往上加意义不大,因为线程一多,额外付出的主要就是上下文切换成本。IO 密集型任务不一样,像查库、调 RPC、读文件这类操作,线程会花很多时间在等结果,所以线程数通常会比 CPU 核心数大一些,常见做法是先按 CPU 核心数 * 2 或者更高一点去试,再结合压测慢慢调。也有人会用 CPU 核心数 * (1 + 等待时间 / 计算时间) 这个公式来估一个起点,但它只能拿来粗估,最后还是要看机器负载、接口时延和高峰流量。
真正落到配置时,通常要同时看下面几件事:
corePoolSize和maximumPoolSize先按任务类型来估,CPU 密集型保守一点,IO 密集型可以适当放大。workQueue最好用有界队列,不要图省事直接给一个很大的队列,否则问题不会立刻爆出来,只会慢慢变成请求堆积、响应变慢、内存上涨。handler要按业务能不能丢任务来选。如果任务不能丢,CallerRunsPolicy往往比直接丢弃更稳,因为它至少能把压力回推给调用方;如果就是要快速失败,那才考虑AbortPolicy。threadFactory最好自己写,至少把线程名带上业务前缀,不然线上看到一堆pool-1-thread-1,排查起来会很难受。
例如一个以数据库查询和 RPC 调用为主的业务线程池,可以先这样配:
1 | int cpuCores = Runtime.getRuntime().availableProcessors(); |
这类配置背后的思路很直接:任务偏 IO 密集,所以线程数可以比 CPU 核心数放大一些;队列用有界队列,避免请求无限堆积;线程名要清楚,方便排查;高峰期扛不住时,用 CallerRunsPolicy 做一层反压。面试里如果被问到“生产环境线程池参数怎么定”,按这个思路回答就够了:先判断任务类型,再估线程数范围,然后说明队列要有界、拒绝策略要结合业务容忍度,最后补一句要靠压测和监控持续调整,不是一次性写死。




