java ee--多线程(进阶)

Java多线程进阶:

引言

多线程编程是Java开发中绕不开的核心命题。从最初的Thread类起步,到理解synchronized背后的JVM锁升级机制,再到熟练运用JUC并发工具包中的各种利器------这条进阶之路充满了"看似简单实则精妙"的设计哲学。

本文基于系统性的课程学习,结合我个人对并发编程的理解与思考,从锁策略 这一底层概念出发,逐层递进地剖析CAS机制、synchronized原理、JUC工具类、线程安全集合以及死锁等核心话题。我的核心体会是:并发编程的本质,是在"正确性"与"性能"之间寻找最优平衡点。每一种锁策略、每一个并发工具的设计,都体现了这一权衡。


一、常见的锁策略

锁策略不仅仅是Java的概念,任何涉及"锁"的编程语言和系统都会涉及这些分类。理解这些策略,既能帮助我们在应用层正确选择锁,也能帮助我们理解底层框架的实现原理。

1.1 乐观锁 vs 悲观锁

这是两种根本性的设计哲学 差异,核心分歧在于:对并发冲突概率的预判不同。

悲观锁:总是假设最坏的情况,每次取数据时都认为别人会修改,所以每次都会先上锁。这样其他线程想拿这个数据就会阻塞,直到它拿到锁。

乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才正式检测是否产生了并发冲突。如果发现冲突,则返回错误信息,由调用方决定如何处理。

生活中的类比:同学A和同学B都想请教老师问题。

  • 同学A(悲观锁)认为"老师比较忙",所以先发微信确认:"老师您下午两点有空吗?"得到肯定答复后才去。这就是先加锁再访问资源。
  • 同学B(乐观锁)认为"老师比较闲",直接跑去办公室。如果老师确实在,问题就解决了;如果老师不在,下次再来。这就是不加锁直接访问,遇到冲突再处理。

我的深刻理解 :这两种策略没有绝对的优劣,关键在于场景适配 。如果冲突频繁(老师确实很忙),用乐观锁会导致"白跑很多趟"(CAS重试次数过多),反而浪费资源;如果冲突很少(老师确实很闲),用悲观锁会让本来不需要等待的线程白白阻塞,降低了并发度。这告诉我们:架构设计没有银弹,只有权衡。

实现方式:

  • 悲观锁:synchronized、ReentrantLock等,先真正加锁再操作数据。
  • 乐观锁:借助CAS机制或版本号机制,在提交时检测冲突。

1.2 重量级锁 vs 轻量级锁

要理解这个区别,需要先理解锁的底层实现链路:

复制代码
CPU 原子指令 → 操作系统 mutex → JVM synchronized/ReentrantLock

重量级锁:加锁机制重度依赖操作系统提供的mutex。

  • 大量的内核态与用户态切换
  • 很容易引发线程调度

这两个操作成本极高。一旦涉及用户态和内核态的切换,就意味着时间上"沧海桑田"。

轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态代码中完成。实在搞不定了,再降级使用mutex。

  • 少量的内核态用户态切换
  • 不太容易引发线程调度

用户态 vs 内核态的直观理解:想象去银行办业务。在窗口外自己准备材料,这是用户态,时间成本可控;在窗口内让工作人员操作,这是内核态,时间成本不太可控。如果反复沟通还需要重新排队,效率极低。

synchronized开始是一个轻量级锁,如果锁冲突比较严重,才升级为重量级锁。

我的深刻理解:这种"先尝试低成本方案,不行再升级"的渐进式设计,是性能优化的经典范式。它体现了"大部分情况下不需要最重手段"的乐观假设,与乐观锁的哲学一脉相承。

1.3 自旋锁(Spin Lock)

传统的挂起等待方式中,线程抢锁失败后进入阻塞状态、放弃CPU,需要过很久才能再次被调度。但实际上,大部分情况下锁不会持有太久------没必要就这样放弃CPU。

自旋锁的做法:获取锁失败后,立即再次尝试获取,无限循环,直到获取到锁为止。

java 复制代码
while (抢锁(lock) == 失败) { }

生动类比:去追求一个女神。

  • 挂起等待锁:表白被拒后陷入沉沦不能自拔,过了很久突然女神发来消息"咱俩要不试试?"(注意这个很长的时间间隔里,女神可能已经换了好几个对象了)。
  • 自旋锁:死皮赖脸坚韧不拔,每天持续说早安晚安。一旦女神恢复单身,立刻抓住机会上位。

自旋锁的本质:它是一种典型的轻量级锁实现方式。

优点:

  • 没有放弃CPU,不涉及线程阻塞和调度
  • 一旦锁被释放,就能第一时间获取到锁
  • 在锁持有时间较短的场景下非常高效

缺点:

  • 如果锁的持有时间较长,就会白白浪费CPU资源(空转)

我的深刻理解 :自旋锁的代价本质上是"用CPU时间换响应延迟"。这提醒我们,在锁持有时间不确定的场景中,纯自旋是危险的。因此实际工程中的自旋锁都有"自适应"机制(后面 synchronized部分会详述),即自旋到一定次数后放弃,转为阻塞等待。

1.4 公平锁 vs 非公平锁

假设有三个线程A、B、C依次尝试获取锁:A获取成功,B和C获取失败后等待。当A释放锁时:

  • 公平锁:遵守"先来后到",B比C先来,所以B先于C获取锁。
  • 非公平锁:不遵守先来后到,B和C都有可能获取到锁。

类比:一群男生追同一个女神。女神分手后,先来追的男生上位------这是公平锁;女神不按顺序挑一个自己看得顺眼的------这是非公平锁。

关键注意:

  • 操作系统内部的线程调度本身就是随机的。如果不做任何额外限制,锁就是非公平锁。要实现公平锁,需要依赖额外的数据结构(如队列)来记录线程的先后顺序。
  • 公平锁和非公平锁没有好坏之分,关键看适用场景。
  • synchronized是非公平锁。

我的深刻理解:非公平锁性能通常更好,因为公平锁需要维护等待队列,每次切换都要做"入队/出队"操作。非公平锁允许新来的线程"插队",减少了线程上下文切换,吞吐量更高。公平锁适合对响应时间有严格一致性要求的场景(如实时系统),非公平锁适合追求最大吞吐量的场景。

1.5 可重入锁 vs 不可重入锁

可重入锁字面意思是"可以重新进入的锁",即允许同一个线程多次获取同一把锁。

为什么需要可重入? 考虑递归函数中有加锁操作的场景------递归过程中这个锁会阻塞自己吗?如果不会,说明它是可重入锁(因此也叫递归锁)。

不可重入锁的致命问题------"把自己锁死":

java 复制代码
// 第一次加锁,加锁成功
lock();
// 第二次加锁,锁已经被占用(被自己占用),阻塞等待
lock();  // 永远等不到自己释放锁 → 死锁!

释放锁的操作本来应该由持有锁的线程完成,但这个线程已经阻塞了,啥都干不了------这就是死锁。

Java中的情况:

  • synchronized 是可重入锁
  • ReentrantLock 是可重入锁(类名中"Reentrant"就是"可重入"的意思)
  • JDK提供的所有现成Lock实现类都是可重入的
  • Linux系统的mutex是不可重入锁

可重入锁的实现原理:在锁内部记录当前持有锁的线程身份,以及一个计数器记录加锁次数。如果发现当前加锁的线程就是持有锁的线程,则计数器自增;解锁时计数器自减,减到0才真正释放。

1.6 读写锁

核心思想:多线程之间,数据的读取方之间不会产生线程安全问题,但写入方互相之间以及写入方与读取方之间都需要互斥。如果两种场景都用同一把锁,会产生极大的性能损耗。

读写锁(Readers-Writer Lock)的规则:

  • 读锁和读锁之间:不互斥(可以并发读)
  • 写锁和写锁之间:互斥
  • 读锁和写锁之间:互斥

适用场景:特别适合于"频繁读、不频繁写"的场景(这样的场景在实际中非常广泛)。

典型案例:教务系统------每节课老师都要点名(查看同学列表,读操作),一周可能执行好几次;而修改同学列表(写操作),可能一个月都不必一次。同学们查看作业(读操作)一天可能几十上百次,但老师布置作业(写操作)一节课只有一次。

Java实现 :ReentrantReadWriteLock类提供了ReadLock和WriteLock,各自有lock()和unlock()方法。

注意 :synchronized不是读写锁。

我的深刻理解 :读写锁的本质是将锁的粒度从"操作类型"维度进行了拆分。它背后的洞察是:大多数读操作之间不会互相冲突。这是降低锁冲突概率的经典手段------在能确定不会冲突的地方就不要加锁。


二、CAS机制------乐观锁的基石

2.1 什么是CAS

CAS全称Compare And Swap(比较并交换),是乐观锁思想的核心实现方式。

假设内存中的原数据为V,旧的预期值为A,需要修改的新值为B:

  1. 比较:判断A与V是否相等
  2. 交换:如果相等,将B写入V
  3. 返回:操作是否成功

CAS伪代码(注意:下面代码本身不是原子的,真实CAS由CPU硬件指令原子完成):

java 复制代码
boolean CAS(address, expectValue, swapValue) {
    if (&address == expectValue) {
        &address = swapValue;
        return true;
    }
    return false;
}

两种典型的非原子操作:

  1. check-then-set:if判断然后设定值(如上CAS伪代码形式)
  2. read-then-update:i++(读旧值、计算、写回)

当多个线程同时对某个资源进行CAS操作时,只有一个线程操作成功,但不会阻塞其他线程------其他线程只会收到操作失败的信号。

2.2 CAS是怎么实现的

CAS的底层实现遵循硬件→OS→JVM→Java的层次:

  • Java的CAS利用Unsafe类提供的CAS操作
  • Unsafe的CAS依赖JVM针对不同操作系统实现的Atomic::cmpxchg
  • Atomic::cmpxchg使用汇编的CAS指令,并通过CPU硬件提供的lock前缀指令保证其原子性

简而言之:因为硬件予以支持,软件层面才能做到原子性。 这是并发编程中"自底向上"的根基。

2.3 CAS的应用------实现原子类

标准库中java.util.concurrent.atomic包下的类都是基于CAS实现的。最典型的是AtomicInteger:

java 复制代码
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

伪代码实现:

java 复制代码
class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while (!CAS(value, oldValue, oldValue + 1)) {
            oldValue = value;  // CAS失败,重新读取最新值
        }
        return oldValue;
    }
}

两个线程并发执行的流程:

  1. 两个线程都读取value的值到各自的oldValue(局部变量在栈上,每个线程有自己的栈)
  2. 线程1先执行CAS:oldValue和value相同,赋值成功
  3. 线程2执行CAS:发现oldValue和value不相等(线程1已改),CAS失败,进入循环
  4. 线程2重新读取value赋值给oldValue,再次CAS成功
  5. 两个线程各返回自己的oldValue值

关键注意:CAS是直接读写主内存的(不是寄存器),且读内存→比较→写内存是一条硬件指令,是原子的。

2.4 CAS的应用------实现自旋锁

基于CAS可以实现更灵活、更轻量的锁:

java 复制代码
public class SpinLock {
    private Thread owner = null;
    
    public void lock() {
        // CAS尝试将owner从null设为当前线程
        // 成功则加锁成功;失败说明锁被别人持有,自旋等待
        while (!CAS(this.owner, null, Thread.currentThread())) { }
    }
    
    public void unlock() {
        this.owner = null;
    }
}

2.5 CAS的ABA问题

问题描述:假设变量num初始值为A。线程t1想CAS把num从A改成Z。但在t1执行两个步骤之间,t2线程把num从A改成了B,又从B改回了A。此时t1的CAS操作会"误判"数据未被修改------因为当前值仍然是A。

类比:买手机时,无法判定这个手机是刚出厂的新手机,还是别人用旧了又翻新过的。

ABA问题的实际危害------取款案例:

正常过程:滑稽有100存款,两个线程并发执行-50操作。线程1扣款成功(余额变50),线程2发现余额为50≠100,操作失败。正确!

异常过程:线程1扣款成功(余额变50)。此时朋友正好转了50(余额变回100)。线程2发现余额为100==100,再次扣款成功。扣了两次!

解决方案:引入版本号

CAS操作在比较数据的同时,也比较版本号:

  • 版本号一致:修改数据,版本号+1
  • 版本号不一致:认为数据已被修改过,操作失败

对应上面取款例子:线程1扣款成功(余额50,版本号变2)。朋友转账50(余额100,版本号变3)。线程2发现版本号为3≠1,判定操作失败。

Java解决方案 :AtomicStampedReference<E>------对引用类型包装,内部维护版本号管理。

我的深刻理解:ABA问题的本质是CAS只关心"值是否变化",不关心"值的变化轨迹"。引入版本号相当于把"值比较"升级为"状态比较",这是从一维到二维的升维思考。


三、synchronized原理------JVM锁升级全解

3.1 基本特点总结

综合前面的锁策略知识,synchronized在JDK 1.8中具有以下特性:

特性维度 表现
乐观/悲观 开始是乐观锁,锁冲突频繁时转为悲观锁
重量级/轻量级 开始是轻量级锁,持锁时间长时转为重量级锁
自旋 实现轻量级锁时大概率用到自旋策略
公平性 非公平锁
可重入性 可重入锁
读写分离 不是读写锁

3.2 锁升级机制

JVM将synchronized锁分为无锁→偏向锁→轻量级锁→重量级锁四种状态,会根据竞争程度依次升级。

3.2.1 偏向锁

核心思想:第一个尝试加锁的线程优先进入偏向锁状态。偏向锁不是真的"加锁",只是给对象头做一个"偏向锁标记",记录这个锁属于哪个线程。

  • 如果后续没有其他线程竞争:不用进行任何同步操作,避免了加锁解锁开销
  • 如果有其他线程来竞争:取消偏向锁,进入轻量级锁状态

偏向锁本质是"延迟加锁"------能不加就不加,但标记还是要打的,否则无法识别何时需要真正加锁。

类比:假设男主是一个锁,女主是一个线程。只有这一个线程使用时,即使不领证结婚(避免高成本操作),也能幸福生活。但女配出现了(第二个线程来竞争),此时不管领证成本多高,都必须完成这个动作让女配死心。

3.2.2 轻量级锁

随着其他线程进入竞争,偏向锁被消除,进入轻量级锁状态(自适应的自旋锁)。

  • 通过CAS尝试将锁对象头中的Mark Word改为指向当前线程栈帧中的Lock Record的指针
  • CAS成功:加锁成功
  • CAS失败:锁被别人占用,继续自旋等待(不放弃CPU)

"自适应"的含义:自旋操作一直让CPU空转比较浪费。因此自适应自旋不会一直进行,而是根据历史成功率动态调整自旋次数------成功率高就多转几圈,成功率低就少转或放弃。

3.2.3 重量级锁

如果竞争进一步激烈,自旋无法快速获取锁,就膨胀为重量级锁。

重量级锁直接使用操作系统提供的mutex:

  1. 执行加锁操作,进入内核态
  2. 在内核态判定锁是否已被占用
  3. 未占用:加锁成功,切换回用户态
  4. 已占用:线程进入等待队列挂起,等待操作系统唤醒
  5. 经过漫长等待,锁被释放,操作系统唤醒该线程尝试重新获取

我的深刻理解 :锁升级机制是JVM对"自适应优化"的最佳实践。大部分锁在大部分时间内只被一个线程使用(偏向锁就够了);少数情况下有少量竞争但不激烈(轻量级锁足够);极少数激烈竞争才需要重量级锁。这种"按需付费"的设计思想,让synchronized在低竞争场景下性能极高,而在高竞争场景下也能正确工作。

3.3 锁消除

编译器+JVM在运行时判断锁是否可以消除。如果可以,就直接消除。

典型案例 :StringBuffer在单线程中的使用------每个append都涉及加锁解锁,但变量只在方法内部使用,不可能被其他线程访问,所以JIT编译器会消除这些锁。

java 复制代码
StringBuffer sb = new StringBuffer();  // 局部变量
sb.append("a");
sb.append("b");
// 单线程场景下,这些锁都可以被消除

3.4 锁粗化

一段逻辑中如果频繁加锁解锁,编译器+JVM会自动合并为一次加锁。

类比:领导给下属安排任务:

  • 方式一(细粒度锁):打电话交代任务1挂电话 → 打电话交代任务2挂电话 → 打电话交代任务3挂电话
  • 方式二(粗化后):打电话一次交代完所有任务再挂电话

显然方式二更高效。频繁加锁解锁如果没有其他线程竞争,就是纯粹的浪费。

我的深刻理解 :synchronized背后有如此复杂的优化策略(偏向、自适应自旋、锁消除、锁粗化),体现了JVM团队的设计理念------让程序员哪怕对性能一无所知,也不至于写出特别慢的并发程序 。"傻瓜式"的安全保证是 synchronized存在的核心价值。


四、JUC工具包:并发编程的瑞士军刀

4.1 Callable接口与FutureTask

Callable和Runnable是相对的------都是描述一个"任务",但Callable描述的是带返回值 的任务,Runnable描述的是不带返回值的任务。

对比代码:计算1+2+3+...+1000

不用Callable(复杂、易出错):需要辅助类Result + wait/notify + 手动同步。

用Callable + FutureTask(简洁优雅):

java 复制代码
Callable<Integer> callable = () -> {
    int sum = 0;
    for (int i = 1; i <= 1000; i++) sum += i;
    return sum;
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
System.out.println(futureTask.get());  // 阻塞等待结果

FutureTask的理解:想象去吃麻辣烫。餐点好后后厨开始做,同时前台给你一张"小票"------这个小票就是FutureTask。你可以随时凭小票查看麻辣烫做好了没。

4.2 ReentrantLock

可重入互斥锁,和synchronized定位类似,都是实现互斥效果保证线程安全。

基本用法:

java 复制代码
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock();  // 必须在finally中释放!
}

ReentrantLock vs synchronized 全面对比:

对比维度 synchronized ReentrantLock
本质 JVM关键字(C++实现) 标准库类(Java实现)
释放锁 自动释放(离开同步块) 必须手动unlock()
获取锁失败 死等 支持tryLock超时放弃
公平性 非公平锁 默认非公平,可设公平
等待唤醒 wait/notify(随机唤醒) Condition(可精确唤醒指定线程)

如何选择:

  • 锁竞争不激烈:用synchronized,效率更高,自动释放更方便
  • 锁竞争激烈:用ReentrantLock,tryLock更灵活
  • 需要公平锁:用ReentrantLock
  • 需要精确唤醒特定线程:用ReentrantLock + Condition

4.3 原子类

原子类内部使用CAS实现,性能远高于加锁方案。常见类:

  • AtomicBoolean、AtomicInteger、AtomicIntegerArray
  • AtomicLong、AtomicReference、AtomicStampedReference

AtomicInteger常用方法:addAndGet(delta)、incrementAndGet()(++i)、getAndIncrement()(i++)、decrementAndGet()(--i)、getAndDecrement()(i--)。

4.4 线程池

虽然创建销毁线程比进程轻量,但频繁创建销毁仍然低效。线程池的核心思想:用空间换时间------线程用完不销毁,放到池子里复用。

Executors工具类提供的四种线程池:

方法 说明 适用场景
newFixedThreadPool(n) 固定线程数 负载稳定的服务
newCachedThreadPool 线程数动态增长 大量短期异步任务
newSingleThreadExecutor 单线程 需要串行执行
newScheduledThreadPool 支持延时/周期执行 定时任务

ThreadPoolExecutor构造参数------把线程池想象成开公司:

  • corePoolSize:正式员工数量(一旦录用永不辞退)
  • maximumPoolSize:正式员工+临时工的最大数目
  • keepAliveTime:临时工允许的空闲时间(超时辞退)
  • unit:时间单位
  • workQueue:任务队列(待处理任务暂存处)
  • threadFactory:线程工厂
  • RejectedExecutionHandler:拒绝策略(公司超负荷怎么办)

四种拒绝策略:

  • AbortPolicy:直接抛异常
  • CallerRunsPolicy:让调用者自己执行
  • DiscardOldestPolicy:丢弃队列中最老的任务
  • DiscardPolicy:丢弃新来的任务

线程池工作流程:提交任务 → 核心线程未满则创建核心线程 → 核心线程满了入队列 → 队列满了创建临时工 → 临时工也满了触发拒绝策略。

4.5 信号量 Semaphore

信号量用来表示"可用资源的个数",本质上是一个计数器。

类比:停车场展示牌------当前有100个车位。车开进去-1(P操作),车开出来+1(V操作)。计数为0时新的车必须等待。

java 复制代码
Semaphore semaphore = new Semaphore(4);  // 4个可用资源
semaphore.acquire();   // P操作:申请资源(可能阻塞)
// 使用资源...
semaphore.release();   // V操作:释放资源

Semaphore的PV操作中加减计数器都是原子操作,天然线程安全。

应用场景:

  • 限流:限制同时访问某资源的线程数
  • 共享锁:允许N个线程同时持有锁

4.6 CountDownLatch

一句话:同时等待N个任务执行结束。

类比:跑步比赛------10个选手依次就位,哨声响同时出发;所有选手都通过终点,才能公布成绩。

java 复制代码
CountDownLatch latch = new CountDownLatch(10);
// 每个任务完成时调用 latch.countDown()(计数器-1)
// 主线程 latch.await()(阻塞等待计数器归零)

五、线程安全的集合类

5.1 多线程环境使用List

原始集合类中只有Vector、Stack、Hashtable是线程安全的(但不建议使用)。在多线程环境下使用ArrayList有三种方案:

  1. 手动同步 :用synchronized或ReentrantLock包裹
  2. Collections.synchronizedList():标准库提供的基于synchronized的线程安全List包装
  3. CopyOnWriteArrayList:写时复制

CopyOnWriteArrayList的核心思想:写时复制。往容器添加元素时,不直接添加,而是先复制出新容器,在新容器中修改,完成后将引用指向新容器。

优点 :读操作无需加锁,读多写少场景下性能很高。

缺点:

  • 占用内存较多(复制了一份)
  • 新写入的数据不能被第一时间读取到(读写分离的代价)

本质:这也是读写分离思想的体现------读和写在不同的容器上进行。

5.2 多线程环境使用队列(阻塞队列)

实现类 底层结构 特点
ArrayBlockingQueue 数组 有界阻塞队列
LinkedBlockingQueue 链表 可选有界/无界
PriorityBlockingQueue 堆 带优先级
TransferQueue - 最多只包含一个元素

阻塞队列在队列为空时take()阻塞等待,队列为满时put()阻塞等待------天然的线程同步机制。

5.3 多线程环境使用哈希表

Hashtable的三大性能问题:

  1. 直接锁整个Hashtable对象,所有线程都竞争同一把锁
  2. size属性也是通过synchronized控制,效率低
  3. 扩容由触发线程独立完成,涉及大量元素拷贝

ConcurrentHashMap(JDK 1.8)的优化:

  • 读操作无锁 :使用volatile保证可见性,只对写操作加锁
  • 锁粒度细化:不是锁整个对象,而是"锁桶"------用每个链表头结点作为锁对象,大幅降低锁冲突概率
  • 充分利用CAS:size属性通过CAS更新,避免重量级锁
  • 优化扩容(化整为零):发现需要扩容的线程只需创建新数组并搬几个元素;后续每个来操作的线程都参与"搬家";搬完最后一个元素才删除老数组。期间插入只往新数组加,查找需要同时查新老数组

JDK 1.8的其他优化:

  • 取消了分段锁(Segment),直接给每个哈希桶分配锁
  • 数据结构从"数组+链表"升级为"数组+链表/红黑树"------链表长度≥8时转红黑树

Hashtable vs HashMap vs ConcurrentHashMap 对比:

特性 HashMap Hashtable ConcurrentHashMap
线程安全 否 是 是
key允许null 是 否 否
锁机制 无 synchronized锁整个对象 synchronized锁链表头+CAS
性能 单线程最快 多线程最差 多线程最优

六、死锁------并发编程的头号大敌

6.1 什么是死锁

死锁是多个线程同时被阻塞,它们中的一个或全部都在等待某个资源被释放。由于线程被无限期地阻塞,程序不可能正常终止。

生活类比:滑稽和女神去饺子馆。滑稽拿了酱油瓶,女神拿了醋瓶。滑稽说"你把醋给我我再把酱油给你",女神说"你把酱油给我我再把醋给你"。俩人互不相让------死锁!

哲学家就餐问题:五个哲学家围坐一桌,每人左右各一根筷子。所有人同时拿起左手筷子,再试图拿右手筷子------发现都被占用了,所有人都阻塞等待。

6.2 死锁产生的四个必要条件

  1. 互斥使用:资源被一个线程占有时,别的线程不能使用
  2. 不可抢占:资源请求者不能强制从占有者手中夺取资源
  3. 请求和保持:线程在请求新资源的同时保持对原有资源的占有
  4. 循环等待:存在等待环路------P1等P2的资源,P2等P3的资源,P3等P1的资源

四个条件同时满足才会产生死锁。打破其中任何一个,死锁就不会发生。

6.3 如何避免死锁

最常用、最实用的方法是破坏"循环等待"条件------锁排序:

假设有N个线程尝试获取M把锁,对M把锁进行编号(1, 2, 3...M),所有线程都按照编号从小到大的顺序获取锁,就不会产生环路等待。

可能产生死锁的代码:

java 复制代码
// 线程1: 先lock1再lock2
synchronized (lock1) {
    synchronized (lock2) { /* ... */ }
}

// 线程2: 先lock2再lock1 ← 顺序不一致,可能死锁
synchronized (lock2) {
    synchronized (lock1) { /* ... */ }
}

不会产生死锁的代码:

java 复制代码
// 所有线程统一顺序:先lock1再lock2
synchronized (lock1) {
    synchronized (lock2) { /* ... */ }
}

我的深刻理解 :死锁的预防策略本质上是在便利性 和安全性之间做取舍。锁排序是最简单的方案,但它要求开发者全局了解所有锁的依赖关系。实际工程中,还可以用超时获取锁(tryLock)来打破"不可抢占"条件,用一次性申请所有锁来打破"请求和保持"条件。选择哪种方案取决于系统的复杂度和对响应性的要求。


七、高频面试要点汇总

7.1 volatile关键字

volatile保证内存可见性:强制从主内存读取数据。如果其他线程修改了被volatile修饰的变量,当前线程可以第一时间读取到最新值。注意:volatile不保证原子性。

7.2 Java线程的六种状态

状态 说明
NEW 新创建,还没调用start
RUNNABLE 可运行(正在CPU上运行或准备运行)
BLOCKED synchronized获取锁失败,阻塞等待
WAITING 调用wait()后等待被唤醒
TIMED_WAITING 调用sleep(n)或wait(n),超时等待
TERMINATED run方法执行完毕,线程终止

7.3 进程和线程的区别

  • 进程包含线程,每个进程至少有一个主线程
  • 进程间不共享内存空间,同进程内的线程共享同一内存空间
  • 进程是资源分配的最小单位,线程是CPU调度的最小单位

7.4 多线程下对一个数进行叠加

  • 使用synchronized/ReentrantLock加锁
  • 使用AtomicInteger原子操作(推荐,CAS实现,性能更高)

7.5 Servlet是否线程安全

Servlet本身工作在多线程环境下。如果在Servlet中创建了成员变量,多个请求到达服务器时多线程操作,可能出现线程不安全的情况。最佳实践:Servlet不使用成员变量,或者将成员变量设为线程安全类型。


总结

回顾整个多线程进阶的知识体系,我想分享三个层面的理解:

第一,从"是什么"到"为什么" :不仅要记住synchronized是可重入的非公平锁,更要理解JVM为什么这样设计------因为"默认安全、默认高效"是对开发者最友好的选择。不仅要会用CAS,更要理解它为什么需要CPU硬件支持。

第二,没有银弹,只有权衡:乐观锁vs悲观锁、公平锁vs非公平锁、细粒度锁vs粗粒度锁------每一个选择背后都是"正确性"与"性能"、"公平性"与"吞吐量"、"便利性"与"灵活性"之间的权衡。优秀的并发设计不是选"最好的",而是选"最合适的"。

第三,分层抽象的力量:从CPU原子指令到OS mutex,到JVM synchronized,到JUC工具包,再到线程安全集合------每一层都在向上提供更高层次的抽象,同时向下隐藏实现的复杂性。理解这个分层结构,才能在出问题时精准定位是哪一层的坑,在优化时知道往哪一层发力。

并发编程的学习是一个从"知道怎么做"到"理解为什么这样做"再到"知道什么时候该换一种做法"的渐进过程。希望本文能帮助你构建起多线程进阶的完整知识地图。

相关推荐
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (二十三):ByteBuf 核心设计 —— 索引、类型与零拷贝
java·后端·架构
wang_shu_mo_ran3 小时前
MyBatis 从入门到进阶(一)
java·spring·mybatis
量化分析码农4 小时前
股票历史数据的复权参数对照:多级别返回的字段与数值差异
java·javascript·后端
猫猫不是喵喵.4 小时前
从0到1创建一个微服务项目
java·数据库·微服务·1024程序员节
瑞雪兆丰年兮4 小时前
JavaWeb-day13-SpringBootWeb事务管理&AOP
java·开发语言·springboot·javaweb·事务·web·aop
曲径海盗5 小时前
Try-Except怎么用,3个追问解答在自动化下的应用
java·jvm·oracle
Cosolar6 小时前
极简智库:快速搭建你的开源项目教程阅读平台,让 docs/ 目录被更多人看见
java·架构·github
nice_lcj5206 小时前
【从零理解 Spring AI · 16】Agent 与工作流:什么该写死,什么该让模型决定
java·人工智能·后端·spring ai·ai应用开发
北京云帆互联科技6 小时前
云帆培训考试系统更新说明:v9.3.0
java·学习