☰
Java并发编程基础全解析:从JMM到线程池的实战指南
2026/10/14 12:06:20 网站建设 项目流程

刚接触Java并发编程的人,十有八九都会有这样的感受:线程、锁、同步、volatile、线程池,每个词都认识,放到一起做题就懵,写出来的程序时而正常时而出错。我在带新人和做代码评审过程中,发现很多工作两三年的人,对并发的基础概念也停留在“会用synchronized、知道有几个线程池方法”这个层面。这篇文章想用一次系统梳理,把Java并发编程基础这条线完整串起来:从JMM三大特性到底层原理,从线程创建到锁机制,从线程池到JUC工具,最后聊几个我实际排查过的线上案例。内容偏基础但绝不水,适合还没系统学过并发、或者学完就忘的开发者,当作一面镜子,先照照自己哪块是薄弱点。

1. 并发编程到底在解决什么问题

1.1 并发与并行的区别,别再傻傻分不清

先说一个最容易混淆的概念。并发(Concurrency)和并行(Parallelism)经常被放到一起说,但含义完全不同。并发是指多个任务在同一个时间段内交替执行,看起来像同时发生;并行是指多个任务在同一时刻真正同时执行。打个比方:你一边烧水一边看手机,这是并发,因为你的注意力在两者之间切换;你左手拿锅右手拿铲同时开火,这是并行,因为在同一个时间点你干了两件事。计算机里,单核CPU通过时间片快速切换实现并发,多核CPU才能让多个线程真正并行。

Java并发编程的舞台就是围绕这件事展开的。进程是操作系统分配资源的基本单位,线程是CPU调度的基本单位。一个进程里可以有多个线程,线程之间共享进程的堆内存和方法区,但每个线程有自己的虚拟机栈和程序计数器。这种设计让线程切换比进程切换轻量得多,也正因为共享内存,线程安全问题才成了一个绕不开的话题。你在多线程环境下访问同一个变量,如果不做控制,轻则数据错乱,重则程序崩溃。

上下文切换是另一个被低估的开销。操作系统在切换线程时,需要保存当前线程的执行状态、程序计数器、寄存器内容,再加载下一个线程的状态。这些本钱不小,如果一个线程只执行很短的时间就被切走,那大部分CPU时间都可能耗在切换上。所以线程数量不是越多越好,开太多反而会导致吞吐量下降。这也是后面要讲线程池的核心原因之一。

1.2 JMM与线程安全的三大特性

Java内存模型(Java Memory Model,JMM)描述了多线程环境下变量访问的规则。它的核心目标是定义什么时候一个线程对变量的修改对另一个线程可见。JMM规定所有变量存在主内存,每个线程自己的工作内存中保存变量的副本,线程对变量的读写都在工作内存中进行,不能直接操作主内存。这个设计对应到现代CPU,就是多级缓存与内存之间的数据不一致问题。

由此引出了线程安全的三个基本特性:原子性、可见性、有序性。原子性指一个操作或者多个操作,要么全部执行成功,要么全部不执行,中间不能被线程调度打断;可见性指一个线程修改了共享变量后,其他线程能立即看到修改后的值;有序性指程序执行的顺序按照代码顺序来,但实际上编译器和CPU为了优化会做指令重排,一定场景下可能破坏线程安全。

我举一个最简单也最常见的例子。两个线程同时对int变量i执行i++,理论上i从0加到10000,两个线程各加5000次,结果应该是10000,实际却可能只是几千。原因有三层:i++不是原子操作,它包含了读取、计算、写回三步;线程之间的修改互不可见;指令重排可能让执行顺序和想象中不一样。要解决这个问题,要么用AtomicInteger保证原子性,要么用volatile加CAS配合,要么直接用锁把整个操作包起来。

1.3 一段代码看懂为什么需要同步

下面这个场景特别典型。模拟两个线程同时操作一个共享Map,一个线程做put,一个线程做遍历,正常运行一会儿后抛出ConcurrentModificationException。这不是玄学,而是遍历过程中集合被修改导致的快速失败机制。如果在多线程环境下直接用HashMap,更严重的是可能在扩容时形成环形链表,导致get()死循环。这个坑在JDK 7及以前是真的会出现,JDK 8虽然改进了扩容方式,但数据丢失问题依然存在。

所以你会看到Java并发编程的第一原则是:共享可变数据必须加锁,或者干脆用并发容器。什么叫共享可变数据?就是多个线程都能访问,并且内容会变。只要满足这两个条件,就属于危险地带。反过来,不可变对象天然线程安全,局部变量因为存在线程自己的栈中也是安全的。很多并发问题,本质上不是锁不够用,而是把不该暴露的东西随意暴露了出去。

2. 线程的创建与生命周期:最基础也最容易忽略的细节

2.1 三种创建方式,到底怎么选

Java里创建线程一共有三种常规方式。第一种是继承Thread类,重写run()方法;第二种是实现Runnable接口,把任务对象传给Thread;第三种是实现Callable接口,配合FutureTask获取返回值。很多新手问是不是第三种更好,其实没有绝对的对错,看场景。

继承Thread的写法通常出现在教学示例里,实际项目中我建议少用,因为Java是单继承,一旦继承Thread,这个类就没法再继承别的类。而且重写run()方式把任务和线程绑定死,不利于多线程执行同一份任务。实现Runnable接口是使用率最高的方式,因为任务和线程解耦,配合线程池使用非常自然。Callable和Runnable的区别在于它有返回值,还会抛出受检异常,适合需要拿到执行结果的场景。

public class ThreadDemo { public static void main(String[] args) throws Exception { // 方式一:继承Thread Thread t1 = new Thread() { @Override public void run() { System.out.println("thread created by extending Thread"); } }; t1.start(); // 方式二:实现Runnable Thread t2 = new Thread(() -> System.out.println("thread created by runnable")); t2.start(); // 方式三:实现Callable FutureTask<Integer> task = new FutureTask<>(() -> 42); Thread t3 = new Thread(task); t3.start(); Integer result = task.get(); System.out.println("callable result: " + result); } }

这三种方式创建出来的线程本身没有本质区别,区别在于任务的定义形式。实际开发中,大家几乎不会直接new Thread来跑任务,而是扔给线程池。线程池内部也是通过ThreadFactory创建线程,底层还是这些机制。

2.2 线程状态流转,jstack输出全靠它定位

线程状态是排查问题的一个重要入口。Java线程有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人疑惑RUNNABLE和BLOCKED的区别。RUNNABLE并不代表线程正在执行,它可能正在等待CPU时间片;BLOCKED是线程在等待进入synchronized同步块或方法时被阻塞。WAITING是线程执行了wait、join或LockSupport.park之后无限期等待,需要别人唤醒;TIMED_WAITING则是sleep、带超时的wait、join或park,时间到了自动唤醒。

线程刚new出来还没调用start()时是NEW;调用start()后进入RUNNABLE;run()方法执行完毕变成TERMINATED。每次用jstack命令看线程栈,这些状态会直接体现在输出里。如果大量线程停在BLOCKED,多半是锁竞争严重;大量WAITING但没想象中忙,很可能是任务等待条件不满足。这些状态的名字不是死概念,是线上排障时的第一手信息。

线程的常用方法也要搞清楚。start()和run()是新手最先踩的坑,start()是向操作系统申请创建线程,run()只是普通方法调用,在本线程里执行,不会起新线程。sleep()的作用是让线程进入TIMED_WAITING,但睡醒后不一定会马上恢复执行,得看CPU调度。yield()是礼貌地让出CPU,让其他同优先级线程先跑,但不保证一定让出去,用意是提升公平性。join()则是让当前线程等待被调用的线程结束。

2.3 wait和notify:最原始的线程间通信方式

synchronized与wait、notify是一对经典组合。wait()的作用是让获取了锁的线程释放锁并进入等待状态,notify()是唤醒一个正等待该锁的线程,notifyAll()唤醒所有等待线程。这里有个关键点:wait和notify必须放在synchronized代码块里,否则会抛出IllegalMonitorStateException,因为wait操作的前提就是持有对象的监视器锁。

public class WaitNotifyDemo { private final Object lock = new Object(); private boolean ready = false; public void produce() throws InterruptedException { synchronized (lock) { while (ready) { lock.wait(); } System.out.println("produce one item"); ready = true; lock.notifyAll(); } } public void consume() throws InterruptedException { synchronized (lock) { while (!ready) { lock.wait(); } System.out.println("consume one item"); ready = false; lock.notifyAll(); } } }

示范代码里用的while循环判断条件,而不是if,这是面试也爱问的细节。因为线程被唤醒后不一定马上能执行,它还需要重新竞争锁。在抢锁期间,可能其他线程已经修改了条件,如果醒过来直接用旧条件,就会出错。所以等待条件必须放在循环中,唤醒了检查,不满足继续等。这个套路俗称“等待-通知”标准范式。

还有一个困扰很多人的问题:sleep和wait有什么区别?两个方法都会让线程暂停,但sleep不释放锁,wait释放锁。如果线程持有某个对象的锁,调用sleep睡觉,别人想进同一同步块照样进不来;调用wait,别人就能抢到锁进去了。理解了这一点,很多死锁代码就看得懂了。

3. 锁与同步:synchronized和Lock怎么选

3.1 synchronized的三种用法与锁升级

synchronized有三种用法:修饰实例方法,锁的是当前实例对象this;修饰静态方法,锁的是Class对象;修饰代码块,锁的是括号里指定的对象。选择哪种取决于你要保护什么资源。保护的是某个实例的属性,就用实例方法锁;保护的是所有实例共享的静态变量,就得用静态方法或Class锁。锁错了对象,等于没锁。

从JDK 6开始,synchronized做的优化非常重要。无锁、偏向锁、轻量级锁、重量级锁,这条升级路线代表了JVM对锁的不断优化。偏向锁假设只有一个线程进来,只在对象头里做个标记;轻量级锁用CAS自旋,适合锁竞争不激烈的情况;自旋失败才升级为重量级锁,由操作系统管理线程阻塞唤醒。线上评估锁竞争,这个升级过程可以直接通过JFR或一些工具看到。需要注意的是,JDK 15中偏向锁已被默认禁用,后续版本也在逐步废弃,原因是现代应用大多数场景下锁竞争激烈,偏向锁的收益已经很小。

synchronized的优点在于简单可靠,自动释放锁,不需要手动解锁,就算代码抛异常,锁也会归还。缺点是功能上比较受限:不能中断正在等待锁的线程,不能设置超时时间,无法实现公平锁,也不支持多条件队列。如果需求只要求基本互斥,用它准没错。

3.2 Lock接口与ReentrantLock的进阶能力

Lock是JDK 5引入的显式锁接口,最常用的实现是ReentrantLock。和synchronized相比,它最突出的能力有几点:可以响应中断(lockInterruptibly),可以设置等待超时(tryLock带时间),可以指定公平策略,还可以配合多个Condition实现更细粒度的线程协调。这些能力在业务复杂时非常有用。

Lock lock = new ReentrantLock(); boolean acquired = false; try { acquired = lock.tryLock(3, TimeUnit.SECONDS); if (acquired) { // 这里操作共享资源 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { lock.unlock(); } }

不能只说优点。ReentrantLock比synchronized复杂,手动加锁解锁就意味着必须在finally里unlock,否则一旦方法中途抛异常,锁可能永久不释放。还有一个公平性问题,ReentrantLock默认的非公平锁允许线程插队,吞吐量更高,但是有可能造成后面等待线程饿死;公平锁按顺序排队,吞吐量低但更公平。实际项目中,我很少用公平锁,除非业务上对等待时间有强要求。

读写锁ReentrantReadWriteLock也是常见选择。它把锁拆成了读锁和写锁:读锁可以被多个线程同时持有,写锁独占。适合读多写少的场景,比如配置中心、缓存刷新。但要注意,读锁和写锁的相互排斥是严格的,写锁持有期间任何读都不能进来。还有一种改进版叫StampedLock,提供乐观读,JDK 8后可用,但使用姿势要慎重,读多写极少的场景可以研究。

3.3 死锁的产生与排查思路

说到锁就不得不提死锁。死锁的经典条件有四个:互斥、持有并等待、不可剥夺、循环等待。代码里最常见的死锁是交叉加锁,比如线程A持有锁1等待锁2,线程B持有锁2等待锁1。排查死锁,我推荐一套流程:先通过jstack打印线程栈,找到状态为BLOCKED的线程;然后看它们分别持有什么锁、等待什么锁;如果形成循环,基本可以锁定。

解决死锁的方向通常是破坏循环等待条件。最简单粗暴的方法是保证所有线程按同一个全局顺序加锁,比如两个资源都按ID大小排序后再锁。也可以用tryLock加超时,拿不到就释放已持有的锁再重试。一旦在项目里遇到多个锁嵌套,需要特别警惕。我在代码评审时看到嵌套synchronized的第一反应就是问:这个顺序是全局统一的吗?能合并成一个锁吗?

4. volatile与final:轻量级并发控制手段

4.1 volatile能保证什么,不能保证什么

volatile在Java中是一个轻量级的同步机制。它有两个作用:保证变量在多个线程间的可见性,防止代码里的指令重排序。但它不保证原子性,这个结论无数面试题都在反复考。为什么?因为volatile不会锁住变量,两个线程同时读取、修改、写回,该丢数据还是丢数据。它只保证一点:一旦某个线程把新值写回主内存,其他线程立刻能看到这个新值,不会读旧数据。

适用场景在哪里?状态标志就是一个典型。比如一个boolean标志控制线程是否继续跑的循环,用volatile修饰后,另一个线程把它改成false,循环线程很快就能发现并退出。这种场景没有复合操作,用volatile足够。但如果是个计数器i++,请老老实实用AtomicInteger或者synchronized。我经常看到一个误区:觉得volatile比synchronized轻量,就什么都用它,结果计数不准。记住:轻量的前提是使用场景匹配。

还有一个容易被忽略的点:volatile只能保证引用本身可见,不能保证引用指向的对象内部状态可见。也就是说volatile一个Map引用,Map内容变了其他线程未必能感知。如果共享的是复杂对象,要么用并发容器,要么用锁。

4.2 单例模式里为什么非要volatile

有名的双检锁(Double-Checked Locking)单例,就是一个最能解释volatile价值的例子。它的写法是:先判空,再同步块,再判空,最后new对象。很多人有疑问,已经有了两层判空和synchronized,为什么instance还要加volatile?

关键就在new Singleton()这行代码上。它看起来是一条语句,实际拆成三件事:分配内存空间、初始化对象、把引用指向这块内存。编译器和CPU可能把它重排序成“先指向内存,再初始化对象”,这在单线程下没有问题,但多线程场景下,线程A刚把引用指向半初始化的对象,线程B通过判空发现引用不为null,直接拿去用,就拿到一个还没构造好的对象。

volatile在这里的作用是禁止这个重排序,保证引用指向完整对象才能被其他线程看到。说到底,volatile是保证有序性的利器。这段代码在面试里出现频率极高,把它彻底想明白,你对JMM理解会上一个台阶。没有volatile的双检锁,是教科书级的隐患,很多老项目里躺着这种bug。

4.3 final的安全发布

final修饰的字段也很有讲究。不可变对象有一个好处:只要对象正确构造,并且引用被正确发布,那么其他线程就能看到完整的对象,不需要额外同步。final字段在构造完成后,JVM会保证其他线程看到的一定是被final修饰的最新值。这对不可变对象设计是一个有力支持。

把字段尽量设计成final,是我在代码评审时经常做的事。所谓安全发布,就是让初始化完整的对象对其他线程可见,而不是暴露一个“半成品”。通过volatile引用、通过静态初始化、通过线程池提交、通过原子类包装,都算安全发布。直接new一个对象并赋值给普通共享变量,严格来说未必安全。理解这个概念,不只是为了面试题,更是为了养成设计并发安全类的直觉。

5. 线程池:企业开发中的标配

5.1 ThreadPoolExecutor的核心参数与工作流程

线程池要回答一个核心问题:任务是直接执行、排队等待、还是直接拒绝?ThreadPoolExecutor用七个参数完整地定义了这套策略:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。

corePoolSize是核心线程数,默认情况下核心线程创建后即使空闲也不会销毁;maximumPoolSize是最大线程数,线程池最多能创建多少个线程;keepAliveTime是线程空闲存活时间,超出默认会被回收,如果设置了allowCoreThreadTimeOut为true,核心线程也可以回收。workQueue是任务队列,线程都忙时新任务先排队。threadFactory用于创建线程,可以自定义线程名,方便排查问题。handler是拒绝策略,线程池满且队列满时触发。

工作流程并不复杂。提交任务后,先判断当前线程数是否小于corePoolSize,小于则新建线程执行;如果已经达到corePoolSize,任务入队;如果队列也满了,再判断是否小于maximumPoolSize,小于则新建临时线程执行;如果临时线程数也满了,就执行拒绝策略。临时线程空闲时间超过keepAliveTime就会被回收。

ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("biz-worker-" + seq.getAndIncrement()); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );

这个例子里的配置比较常规:核心4,最大8,队列100,线程命名方便定位,拒绝策略用CallerRunsPolicy保证任务不丢。CallerRunsPolicy的含义是提交任务的线程自己去执行这个任务,牺牲调用方的性能来兜底,比简单抛异常友好一些。

5.2 为什么Executors的快捷方法不够用

Executors类提供了好几个快捷方法,比如newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor。方便是真方便,但很多公司规范里直接禁止在线上用它们,根本原因是它们的参数选择太激进。

newFixedThreadPool和newSingleThreadExecutor使用了无界的LinkedBlockingQueue,任务排到队列里不设上限。如果请求冲过来,队列会无限膨胀,最终内存溢出,OOM就是这么来的。newCachedThreadPool的最大线程数是Integer.MAX_VALUE,每个任务都会临时创建一个线程,短期内大量任务会导致线程数量爆炸,同样有OOM风险。

我推荐的做法是:根据业务场景手动配置ThreadPoolExecutor。CPU密集型任务,核心线程数可以设为CPU核数加一;IO密集型任务,线程数可以适当调大,比如CPU核数的两倍加一。这些公式不是金科玉律,只是一个起点,真正准确的结果要通过压测来验证。第一版配置宁可保守,也不能让队列无限大。

线程池里的线程名是个细节,但价值很高。默认线程名是pool-1-thread-1,一旦你把线程池日志打到线上,看到这个名字基本不知道是哪条链路发的。自定义ThreadFactory,给线程加上业务前缀,例如biz-order-worker,排查问题时,一眼就知道是哪块代码在作怪。

5.3 线程池调优与监控

线上线程池怎么调优,确实是个深度话题。最直接的手段是监控线程池的运行状态。ThreadPoolExecutor提供了一些可获取的指标:getPoolSize是当前线程数,getActiveCount是活跃线程数,getQueue size是队列积压数,getTaskCount是历史任务总数,getCompletedTaskCount是完成总数。把这些值定期采样,就可以画出线程池的繁忙度曲线。

如果发现核心线程长时间跑满,队列持续增长,说明corePoolSize配置小了;如果线程池长期空闲,队列基本为零,说明线程开多了,或者业务本身并发量就不高。这两种都是配置与实际负载不匹配。比较理想的状态是日常负载维持在60%到80%之间,留出应对瞬时高峰的空间。

线程池里的坑,还有一个容易被忽略:线程池提交任务时,Runnable的run方法里如果抛出非受检异常,线程池会默默吞掉,线程本身直接走完,日志里可能什么都没留下。解决办法有两个:一种是在run方法里用try-catch兜底,另一种是自己传入ThreadFactory,给线程设置一个UncaughtExceptionHandler,把异常记录到日志。这个细节很多资深开发也容易漏。

6. JUC并发工具与并发容器速览

6.1 CountDownLatch、CyclicBarrier与Semaphore

java.util.concurrent包下的工具类,是除了锁之外解决协作问题的一批利器。CountDownLatch是倒计时门闩,一个或多个线程等待多个事件完成。经典场景是主线等待多个子线程都处理完,再继续执行。CountDownLatch的计数器只能减不能增,也不能重置。

CountDownLatch latch = new CountDownLatch(3); for (int i = 0; i < 3; i++) { new Thread(() -> { try { // 模拟执行任务 Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println("all tasks completed");

CyclicBarrier的字面意思是循环栅栏,它是让一组线程互相等待,全部到达之后同时突破。和CountDownLatch的区别在于,CyclicBarrier可以循环使用,并且支持一个barrierAction,在所有线程到达后先执行这个动作,再继续后面的流程。模拟会议中所有人员到齐后开始开会,就很适合用它。

Semaphore是信号量,用来限制同时访问某类资源的线程数。比如一个接口限制最多10个线程同时查询数据库,Semaphore(10)就能做到。acquire和release要成对出现,最好放在try-finally里确保释放。这三个工具各有各的适用场景,不难,但选错也很常见。我见过有人拿CountDownLatch当Semaphore用,功能上勉强能实现,代码却别扭得多。

6.2 ConcurrentHashMap的演进与选型

并发容器里最高频的选手是ConcurrentHashMap。JDK 7时代它用分段锁,把数据分成一段一段的,每段配一把锁,并发度取决于段的数量;JDK 8直接用Node数组加CAS和synchronized,锁粒度更细,并发性能更强。日常使用时,你不要过度关心内部实现细节,但要知道它在多线程下安全,并且性能优于给整个HashMap加锁。

还有一个常被搞混的点:ConcurrentHashMap并不允许null键和null值。这是设计上回避二义性的做法。HashMap可以put null,是因为HashMap根本不知道null键存在与否;ConcurrentHashMap设计上为了避免并发判断时的二义性,直接不允许。这与今天要强调的线程安全模型息息相关。如果你的Map允许null值,只能谨慎地用Collections.synchronizedMap。

另外还要区分集合的线程安全方法。Collections.synchronizedMap是把所有方法加锁,简单粗暴但容易造成瓶颈;CopyOnWriteArrayList适合读多写极少的场景,每次写都复制底层数组;ConcurrentSkipListMap是跳表实现的有序Map,适合有序并发访问。选型逻辑很简单:读多写少,优先考虑CopyOnWrite;写多读也多,ConcurrentHashMap;需要排序,ConcurrentSkipListMap。

6.3 并发队列的选择思路

BlockingQueue接口下面有好几个实现,选哪个往往让新手头疼。LinkedBlockingQueue是基于链表的无界或有序队列,默认无界,如果没指定容量,任务可以一直堆积,有OOM风险。ArrayBlockingQueue是有界数组队列,容量固定,满了就阻塞或拒绝,线程池用它比较稳。SynchronousQueue不存储任何任务,每个put必须等待take,相当于线程直接交接。PriorityBlockingQueue支持按优先级出队,适合任务本身有先后权重的场景。

这些队列背后都实现了生产者消费者模式。以ArrayBlockingQueue举例,生产者put,消费者take,队列满时put阻塞,队列空时take阻塞,不需要你手动用wait和notify实现。使用的时候要关注它的容量该怎么设置。太大会导致堆积延迟增大,太小又会频繁拒绝任务,中间值要靠压测来量。有一个经验是队列大小至少能容纳峰值期间的积压量,再留一点缓冲。

7. 常见问题与排查技巧实录

7.1 线程安全问题从哪开始查

遇到并发bug,第一步不是猜,而是把问题场景固定下来。先看这段代码里,哪些变量是多个线程共享的,哪些是只读的,哪些是可变的。共享可变数据就是嫌疑最大的源头。接着看操作这些数据的代码路径,是不是存在“先读后写”的复合操作。最后再确认是否加了合适的锁或者用了并发容器。

一个比较典型的案例:业务系统里用static List缓存了一些配置,多个线程往里写。日志有时能看到部分配置变成半截数据,排查下来就是List本身不安全,又用了普通add方法。解决办法很直接,换成CopyOnWriteArrayList,或者干脆用不可变配置对象。很多线程安全问题这么一梳理,就发现不是锁不够,而是用了不合适的容器。

7.2 锁竞争导致的性能瓶颈怎么看

线上服务CPU居高不下,线程dump一打出来,发现大量线程都在同一个synchronized块的入口处阻塞,状态是BLOCKED。这通常意味着锁竞争已经非常激烈。锁竞争的本质是并发度超过了一把锁能承载的带宽。常见手段有几种:缩小锁代码块的范围;用读写锁把读操作并行化;改用CAS原子类;或者用分片思路,把一把大锁拆成多把小锁。

有一次排查一个库存扣减接口,所有请求串行在一个synchronized方法里,QPS永远上不去。后来分析业务发现,真正的临界区只是库存余量扣减那几行,就把它从方法锁改成代码块锁,然后把库存字段换成了AtomicInteger,核心操作用CAS重试。QPS翻了几倍,代码结构反而更简单了。这件事给我的感受是:锁的粒度一定要够小,保护的范围一定要明确。

排查工具方面,jstack是基本功。它能打印线程栈和锁信息。多打几次jstack,如果多次采样中同一个线程总在同一个锁点阻塞,说明锁竞争概率很高。再配合jstat看GC情况,排除Full GC导致停顿,基本能定位到瓶颈。更进一步的,可以使用JFR录制一段时间的飞行记录,去看锁延迟区间分布,分析哪个锁最频繁。

7.3 关于线程安全的一些实用检查清单

下面这几条,是我做代码评审时必看的点,列出来供大家自查:

  • 静态变量和单例对象的成员变量,是否被多个线程可变访问;如果是,是否做了同步或使用并发容器。
  • 非线程安全的集合(HashMap、ArrayList)是否被并发读写;如果是,立即替换。
  • synchronized锁的对象是否是同一个;如果锁的是不同的对象实例,互斥就无效。
  • 使用ReentrantLock时,unlock是否一定在finally里执行;没做到就是定时炸弹。
  • volatile修饰的变量是否存在i++之类的复合操作;有则改用Atomic类或锁。
  • 线程池的队列是否无界;无界队列在业务高峰是否有OOM风险。
  • 线程名称是否自定义;没有自定义,出了事故连哪个线程池都查不出来。

这条检查清单看起来简单,但能覆盖最常见的并发事故源。很多线上问题都是这些问题排列组合产生的。一次扎实的代码评审,比事后排查节约的时间多得多。

再讲一个细节:日期格式。很多人喜欢写SimpleDateFormat作为静态字段复用,因为它创建成本高。但SimpleDateFormat本身是不线程安全的,并发调用parse或format经常出现错乱甚至异常。解决方案包括改用ThreadLocal包装,或者直接用DateTimeFormatter(它是线程安全的)。这个问题太经典了,只要线上日志里出现NumberFormatException或者时间错乱,第一反应就该检查日期格式工具。

最后再分享一点个人体会

Java并发编程这块,理论门槛和实战门槛是两回事。看书做题觉得自己会了,真去调线上问题时才发现经验很重要。我强烈建议新手用几个小项目把基础打扎实:模拟多线程抢票验证原子性问题、用线程池批量处理大文件、用CountDownLatch协调多个异步任务,再做一个小型生产者消费者Demo。这三个项目做完,并发编程基础就算真正入门了。

从面试角度看,synchronized与Lock的区别、volatile语义、线程池参数、ConcurrentHashMap原理,都是高频考点。从实战角度看,排查线程问题和性能调优能力的价值更高。先掌握本文讲到的基础概念和排查思路,再去啃AQS源码、CompletableFuture、虚拟线程这些进阶内容,会比较顺。

最后分享一个我踩过多次坑后养成的习惯:写并发代码之前,先定义清楚哪些数据是共享的、哪些状态是可变的,然后决定加锁还是使用不可变设计。代码写完后,再用jstack连续采样几次看线程状态,而不是一测通过就以为万事大吉。这个习惯救了我的线上系统不止一次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询