资讯动态

Java并发编程从基础到实战:掌握线程安全、锁机制与线程池调优

发布时间:2026/10/14 12:06:15 来源:尧图企业网站定制
1. 并发编程到底在解决什么问题先抛个问题一个单线程的程序跑得再快也就是用到了一个CPU核心。哪怕你的机器有32个核大部分时间都在闲着。并发编程要解决的核心问题就是怎么把多核CPU的计算能力真正用起来同时保证多个线程在共享数据时不出错。这个话题聊起来容易做起来难。我刚接触并发的时候觉得不就是开几个线程嘛new Thread().start()谁不会。但真正写业务代码时才发现线程安全、可见性、原子性、死锁、性能损耗每一个坑都能让你调一整天。这篇东西我想从基础概念讲到实战工具把我这些年踩过的坑和验证过的方法都整理出来适合刚入门的同学建立整体认知也适合写过一段时间但没系统梳理过的朋友查漏补缺。并发编程的本质其实就三件事互斥同一时刻只有一个线程操作共享资源、协作多个线程配合完成任务、通信线程之间传递状态。Java提供的所有并发工具synchronized、Lock、volatile、线程池、并发容器本质上都是在帮你解决这三件事。理解了这一点你再看各种API就不会觉得是零零散散的知识点而是一条完整的逻辑线。2. 线程的核心机制与生命周期2.1 创建线程的几种方式Java里创建线程大多数人第一反应是继承Thread类或者实现Runnable接口。这两种方式在很多教程里都是并列讲的但实际工作中我的建议是能不用Thread继承就不用。Java是单继承你继承了Thread就没法继承别的类了而且把任务逻辑和线程控制逻辑混在一起代码很难维护。更推荐的是用Callable配合FutureTask或者直接交给线程池。Callable和Runnable的区别在于Callable能返回结果、能抛异常这在需要拿到线程执行结果的场景下特别好用。比如你要并发查询多个数据源最后汇总结果用Callable就比Runnable优雅得多。// 推荐方式Callable FutureTask CallableString task () - { // 模拟一段耗时操作 Thread.sleep(1000); return 查询结果; }; FutureTaskString futureTask new FutureTask(task); new Thread(futureTask).start(); // 主线程可以继续做其他事需要结果时再获取 String result futureTask.get();2.2 线程状态机别被状态名骗了线程的六种状态——NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED看起来简单但很多人在排查问题时会搞混。这里最容易被误解的是RUNNABLE状态。RUNNABLE其实是两个层面的合成JVM层面它包含了“正在运行”和“随时可以运行但还没分到CPU时间片”两种情形。很多新手用jstack看线程dump发现一条线程长时间处于RUNNABLE就以为是死循环或者卡住了。实际上它可能只是CPU时间片没轮到自己。但如果一条线程一直RUNNABLE且CPU占用极高那大概率是真的在空转或死循环这是排查问题的关键判断依据。BLOCKED和WAITING的区别也很容易混淆。BLOCKED是线程想进入synchronized代码块/方法但锁被别的线程持有所以阻塞在外面。WAITING是线程已经进入了同步区域然后主动调用了wait()、join()这类方法把自己挂起来等别人唤醒。一个是被动进不去一个是主动让出语义完全不同。2.3 线程的中断机制很多人处理线程停止时习惯用stop()方法。这个方法是废弃的因为它会直接强制终止线程导致资源不释放、数据不一致。正确的方式是使用中断机制——调用interrupt()方法设置中断标志然后由线程自己在合适的时机响应。// 正确响应中断的方式 while (!Thread.currentThread().isInterrupted()) { try { // 执行任务 Thread.sleep(100); // 在阻塞方法中会抛出InterruptedException并清除中断标志 } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 // 做必要的清理工作 break; } }这里有个很多人不知道的细节当线程在sleep()、wait()、join()等阻塞方法中被打断时JVM会抛出InterruptedException然后清除中断标志。这意味着如果你在catch块里不做处理直接吞掉异常外层代码用isInterrupted()判断时会发现中断标志已经没了线程可能继续运行。正确做法是在catch里重新调用interrupt()把标志设回来或者直接向上抛。这个坑我见过好几次排查半天找不到线程为什么没停下来最后发现就是异常被吞了。3. synchronized与锁的底层逻辑3.1 synchronized到底锁住了什么我一直强调一个概念synchronized不是锁住代码而是锁住对象。每个Java对象都有一个监视器锁monitor locksynchronized的本质是竞争某个对象的monitor所有权。写synchronized修饰实例方法时锁的是当前实例this修饰静态方法时锁的是Class对象修饰代码块时锁的是括号里指定的对象。这里有个经典的坑两个线程分别new了两个对象各自调用synchronized修饰的实例方法它们互相不阻塞因为锁的是各自的对象不是同一个。public class Counter { private int count 0; // 锁的是this同一个Counter实例才互斥 public synchronized void increment() { count; } }3.2 锁升级过程JDK 1.6之后synchronized做了大量优化引入了偏向锁、轻量级锁、重量级锁的升级路径。很多人听到“synchronized性能差”这种老观点其实是拿JDK 1.5以前的眼光看问题。现在的synchronized在大多数场景下性能已经不输给ReentrantLock了。简单说一下升级过程刚开始一个对象没人竞争JVM会记录第一个获取它的线程ID这叫偏向锁——意思是“偏向”于第一个线程后续这个线程再来不需要做任何同步操作。一旦有第二个线程来竞争偏向锁撤销升级为轻量级锁通过CAS自旋来获取锁。如果自旋次数过多或者线程数很多说明竞争激烈就升级为重量级锁也就是传统的monitor实现未获取到锁的线程进入阻塞状态。理解这个机制的意义在于调优时你能判断你的锁竞争到底处于哪个层级。如果发现大量线程在BLOCKED状态说明锁已经升级到重量级阻塞唤醒的开销很大这时候你该考虑是不是锁粒度太大或者能不能用读写锁、无锁方案来优化。3.3 wait和notify的正确姿势synchronized配合wait()和notify()是最经典的线程协作模型——生产者消费者模式。但这个模型有几个非常容易踩的坑。wait()方法必须在synchronized代码块内调用而且它会释放当前持有的锁让其他线程可以进入同步区域。notify()只会唤醒一个正在等待的线程而且被唤醒的线程不会立即执行要等当前线程退出同步块、真正释放锁之后才能和其他线程一起重新竞争锁。真正容易出问题的点是条件判断用if而不用while。建议永远用while循环来包裹wait()的判断条件这个模式叫“循环等待”。原因是线程被唤醒后不会自动重新检查条件是否成立。假设队列只有1个容量两个生产者都在wait等待消费者取走元素后notifyAll唤醒了两个生产者它们都醒来后依次往下走如果用的是if判断第二个生产者不会重新检查队列是否已满就会直接往满的队列里塞数据造成数据覆盖。synchronized (queue) { while (queue.size() capacity) { // 必须用while queue.wait(); } queue.add(data); queue.notifyAll(); }4. volatile、可见性与有序性4.1 从一个多线程bug说起我遇到过这样一个问题两个线程共享一个boolean标志位线程A修改标志位线程B循环检测这个标志位来决定是否退出。理论上标志位变了B就应该停下来但程序跑起来B就是感觉不到变化。这就是典型的可见性问题。根源在于Java内存模型。每个线程在CPU上执行时会把主内存中的数据拷贝到自己的高速缓存或者CPU寄存器中。线程A修改了变量修改的是它自己缓存里的那份还没来得及写回主内存。线程B读取时读的是自己缓存里的旧值。两边各看各的就没有可见性了。volatile关键字的作用就是解决这个问题被volatile修饰的变量每次写操作都会强制刷新到主内存每次读操作都会从主内存重新加载。这样就能保证一个线程的修改对另一个线程立即可见。4.2 volatile不是万能的但是必须说清楚volatile只保证可见性和有序性不保证原子性。这是面试必考也是实际开发中最容易用错的地方。比如说一个计数器count哪怕count被volatile修饰了这个操作依然是线程不安全的。因为count在字节码层面分三步读取当前值、计算加1、写回新值。线程A读了值是10线程B也读了值10A算完写回11B算完也写回11最后结果应该是12却变成了11。volatile保证的是B在写回时能看到A的最新值但无法阻止B基于旧值做计算。这个场景要用AtomicInteger或者synchronized而不是volatile。4.3 双端锁定的DCL问题单例模式的双重检查锁定Double-Checked Locking是讨论volatile最经典的例子。public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题点 } } } return instance; } }问题在new Singleton()这一步。这条语句在CPU层面不是原子的大致分三步分配内存空间、调用构造方法完成初始化、把引用指向这块内存。由于指令重排序的存在第三步可能先于第二步执行。这意味着线程A正在执行new操作时另一个线程B通过了第一次判空检查看到引用已经非空直接return返回了一个尚未完成构造的对象。加上volatile就能禁止指令重排序保证对象一定是先完成初始化再暴露引用。顺带说一个细节现在很多人用枚举或者静态内部类来实现单例确实更安全但DCL配合volatile依然是理解内存模型的好教材。如果你维护的旧代码里有DCL单例一定要检查volatile有没有加我见过不止一次这个坑。5. JUC锁工具与原子类5.1 ReentrantLock和synchronized怎么选JUC包里的ReentrantLock是synchronized之外最常用的显式锁。两者都是可重入的——同一个线程可以多次获取同一把锁而不会死锁。但ReentrantLock提供了几个synchronized没有的能力可中断获取锁、可超时获取锁、公平锁机制、多个Condition条件队列。这里我给出一个实用建议如果只是简单的互斥用synchronized就够了它写起来简单、不容易出错、性能也不差。如果需要实现“尝试获取锁获取不到就放弃”或者“最多等3秒”这样的复杂控制再上ReentrantLock。// 带超时的锁获取 ReentrantLock lock new ReentrantLock(); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); // 一定要在finally中释放 } } else { // 获取锁超时做降级处理 }5.2 读写锁的适用场景如果读操作远多于写操作直接用synchronized或ReentrantLock是浪费的。ReentrantReadWriteLock维护了一对锁读锁可以多个线程同时持有写锁只能一个线程持有。多个线程同时读没问题读写互斥、写写互斥。使用读写锁时有一个隐藏问题锁降级。当一个线程持有写锁时它可以再获取读锁然后释放写锁让锁级别从写降到读这叫锁降级。反过来从读锁升级到写锁是不允许的如果强行在持有读锁时获取写锁会直接死锁。因为读锁是共享的多个线程都持有读锁时任何线程都没法获取排他的写锁。这个我亲测过一旦踩进去线程就全部卡死dump下来全是WAITING。5.3 Atomic家族和CASAtomicInteger等原子类是无锁编程的基础底层依赖CASCompare And Swap指令。CAS操作包含三个参数内存位置V、预期旧值A、新值B。只有当V的值等于A时才把V更新为B否则什么都不做。这一系列操作是CPU指令级的原子操作。我用生活化的比喻解释一下想象你要修改一个值先看一眼它当前是多少比如10然后跟CPU说“如果这个值还是10就把它改成11”。如果在你说完这句话到指令执行的间隙别人把值改成了10以外的数CPU就不会执行修改你也意识到了冲突需要重新读取再试一次。ABA问题就是CAS的一个理论缺陷线程A读到值是10线程B把10改成11又改回10线程A再执行CAS时发现还是10CAS成功。但对A来说这个10已经不是最初的10了。实际业务中大部分场景不关心这个过程但如果你的逻辑依赖“值没有被修改过”这样的语义就需要用AtomicStampedReference加版本号来规避。5.4 并发容器选型建议集合类的线程安全是很多人的知识盲区。Vector和Hashtable是线程安全的但它们的方法级同步在竞争激烈时性能很差。Collections.synchronizedList包装出来的集合性能也一般。实际开发我推荐优先用ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue这些JUC包下的容器。ConcurrentHashMap采用了分段锁/或CAS加锁的机制读写并发度远高于Hashtable。CopyOnWriteArrayList适合读多写极少的场景每次写入都会复制整个底层数组写入成本高但读取完全无锁。选型的时候一定要结合场景读多写少用CopyOnWriteArrayList写多读少用ConcurrentHashMap做数据聚合需要队列的生产者消费者模型用ConcurrentLinkedQueue或BlockingQueue。6. 线程池并发任务的管理中枢6.1 为什么不能new Thread每次new Thread的坏处是很多新人不理解的。每创建一个线程JVM就要为它分配一块比较大的栈内存默认可能1M左右创建和销毁都有系统开销。如果你的任务很多而且都是短任务频繁创建销毁线程的成本占了很大头。线程池的核心思想是复用一组线程来处理多个任务避免反复创建销毁的代价。更重要的是线程池能控制并发度防止资源被无限线程耗尽。6.2 ThreadPoolExecutor参数全解读ThreadPoolExecutor的七个参数是重点中的重点网上资料很多但很少有人把每个参数在实际运行中的行为讲透。我的理解是这样的corePoolSize是常驻核心线程数线程池启动后即使没有任务核心线程也会存活除非设置了allowCoreThreadTimeOut。maximumPoolSize是线程池允许的最大线程数。当提交的任务超过了核心线程队列容量能承载的范围线程池才会创建额外的非核心线程直到达到maximumPoolSize。workQueue是任务队列核心线程全忙时新任务进队列等待。keepAliveTime是非核心线程的空闲存活时间超过这个时间非核心线程会被回收。这里有个我观察到的误解很多人以为“队列满了之后才创建非核心线程”这个理解不完整。准确地说当新任务提交时如果核心线程都在忙碌任务会优先放入队列。队列满了才会尝试创建新线程。所以整个决策过程是核心线程优先 → 队列填充 → 非核心线程兜底 → 饱和策略兜底。饱和策略有四种AbortPolicy默认抛RejectedExecutionException、CallerRunsPolicy提交任务的线程自己执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃最老的任务再尝试。6.3 实际项目中的配置经验配置线程池参数没有统一答案要结合任务类型区分。CPU密集型任务线程数建议设为CPU核心数1。IO密集型任务比如大量网络请求、文件读写线程主要时间在等待IO可以让更多线程同时工作建议设为CPU核心数的两倍以上。我遇到过一个反例一个系统外部接口响应特别慢平均几百毫秒但开发把所有接口调用放在一个核心线程数2、最大线程数4的线程池里。高峰期大量请求把队列塞满任务积压越来越严重。这种场景应该把线程数提到CPU核心数的数倍甚至更高让更多请求并行等待IO总吞吐量才能上去。还有一点很重要不要用Executors自带的便捷方法创建线程池。Executors.newFixedThreadPool内部用的是无界队列LinkedBlockingQueue当任务速度持续大于处理能力时队列可以无限积压最终可能导致内存溢出。newCachedThreadPool用的是SynchronousQueue来一个任务就尝试创建一个线程短时间内大量请求会创建海量线程把系统资源打爆。规范的做法是用new ThreadPoolExecutor手动指定参数同时用有界队列。ThreadPoolExecutor executor new ThreadPoolExecutor( 4, // 核心线程数 12, // 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲回收时间 new ArrayBlockingQueue(500), // 有界队列 new ThreadFactoryBuilder().setNameFormat(biz-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 不能让任务静默丢失 );6.4 线程池关闭的正确方式线程池关闭也有学问。shutdown()和shutdownNow()语义不同shutdown是优雅关闭不再接受新任务但会把已提交到队列里的任务执行完。shutdownNow是立即关闭会尝试中断正在执行的任务并且返回队列中还没执行的任务列表。我一般建议在应用关闭时先调shutdown然后awaitTermination等待一定时间如果超时还没关闭再强制shutdownNow并且要把没执行完的任务记录下来做补偿。只管杀不管埋的关闭方式可能导致正在写的请求卡在一半数据出问题。7. CompletableFuture让异步编程不再痛苦7.1 从Future到CompletableFutureJava早期支持异步的方式是Future但Future有几个缺陷获取结果时用get()会阻塞当前线程这等于把异步又变回了同步。轮询isDone()判断结果会导致CPU浪费而且多个Future之间的组合编排极难写。CompletableFuture解决了这些问题。它本质上是“异步任务的结果容器 回调机制”你可以给它挂回调函数任务完成时自动执行不用阻塞等待。它真正的杀手锏是提供了类似Stream的那些链式操作把多个异步任务拼起来、合并、分支、排除异常。7.2 三个高频场景模板场景一两个异步任务并行执行都完成后再汇总。好比你同时查库存和查价格两个都拿到后一起展示。CompletableFutureInteger stockFuture CompletableFuture.supplyAsync(() - queryStock()); CompletableFutureDouble priceFuture CompletableFuture.supplyAsync(() - queryPrice()); CompletableFutureVoid combined stockFuture.thenCombine(priceFuture, (stock, price) - { return new ProductInfo(stock, price); }).thenAccept(System.out::println);场景二多个任务中的任意一个完成即可。比如同时查多个数据源谁先返回用谁的。CompletableFutureString f1 CompletableFuture.supplyAsync(() - querySource(A)); CompletableFutureString f2 CompletableFuture.supplyAsync(() - querySource(B)); f1.applyToEither(f2, result - 优先结果: result);场景三异常处理。CompletableFuture的异常可以通过exceptionally或handle来捕获exceptionally接收异常返回一个兜底值handle接收结果和异常不管成功失败都能处理。CompletableFuture.supplyAsync(() - { if (Math.random() 0.5) throw new RuntimeException(模拟异常); return 成功; }).exceptionally(ex - { System.out.println(捕获异常: ex.getMessage()); return 降级结果; });7.3 使用CompletableFuture的注意事项这里有一个很重要的坑默认的异步执行使用ForkJoinPool.commonPool。这个线程池是全局共享的被整个JVM里所有CompletableFuture默认使用。如果你的任务里有阻塞操作比如同步调用外部接口会占着公共池的线程导致其他任务排队。解决办法是使用自定义线程池CompletableFuture.supplyAsync(() - doSomething(), 自定义Executor)。这个习惯一定要养成。还有get()和join()的区别在异常处理上get()会抛出受检异常需要显式捕获join()只抛出CompletionException非受检异常不用强制捕获。在业务代码里我更常用join()但如果要处理业务异常get()加catch会更直接。8. 并发问题排查与实战调优8.1 死锁的识别与恢复死锁是并发中最经典的问题四个必要条件——互斥、占有且等待、不可剥夺、循环等待——任何一个被打破死锁就不会发生。实际代码里最常见的是锁的获取顺序不一致。比如线程A持有锁1想获取锁2线程B持有锁2想获取锁1两边都在等对方释放谁也不会释放就死锁了。排查手段是拿到线上线程dumpjstack命令导出的日志里会有明确的“Found one Java-level deadlock”字样并且会列出死锁线程的调用栈指出哪两个线程在互相等待哪把锁。在代码层面预防死锁的方法保证所有线程以相同的顺序获取多个锁。比如都先获取锁1再获取锁2就不存在循环等待了。或者使用tryLock加超时机制获取锁失败时释放已经持有的锁退避重试也能打破死锁。8.2 线程安全的判定方法判断一段代码是否线程安全最核心的问题是它是否访问了可被多个线程同时修改的共享状态。局部变量存在于每个线程自己的栈空间天然安全。成员变量和静态变量是共享的需要重点检查。有一种不容易发觉的问题叫“非原子性检查后执行”。典型的if (map.containsKey(key)) { map.put(key, value); }这段代码在单线程下没问题但两个线程可能同时判断都发现key不存在然后同时put其中一个结果就被覆盖了。解决方式是使用ConcurrentHashMap的putIfAbsent或者给整段代码加锁。8.3 性能调优经验遇到并发性能瓶颈我的排查顺序是先看是不是锁竞争太激烈再看是不是线程数配置不合理最后看是不是缓存一致性问题导致的性能下降。锁竞争激烈的标志是线程大量处于BLOCKED状态CPU整体利用率不高但上下文切换频繁。优化的方向有四个缩小锁的粒度比如把整个方法加锁改成只锁临界代码块、读多写少时用读写锁代替互斥锁、能用无锁数据结构就用无锁的、热点数据考虑用ThreadLocal做线程隔离。这里明确一点ThreadLocal不是用来解决共享变量问题的它是让每个线程拥有自己的独立副本压根不存在共享所以也就没有线程安全问题。但ThreadLocal有个必须注意的坑使用完一定要调用remove()清理因为如果线程池中的线程存活很久ThreadLocal里的对象会一直驻留在线程的map里导致内存泄漏。这个问题在生产环境非常典型我刚遇到时查了很久最后看堆dump发现大量本应回收的对象被线程对象引用链持有。8.4 线上问题排查工具速查工具/命令用途关键用法jstack导出线程dump查死锁、阻塞jstack 进程号 thread_dump.txtjstat查看GC、类加载、编译统计jstat -gcutil 进程号 1000jmap导出堆dump分析内存泄漏jmap -dump:formatb,fileheap.hprof 进程号jconsole图形化监控线程状态、内存可视化操作适合初步观察Arthas在线诊断方法调用追踪、反编译没有现成dump时热执行命令查线程状态9. 并发编程的工程实践守则写到这里我想把零散的经验收敛成几条实际开发中一直坚持的守则。第一条能不用并发就不用并发。很多场景其实根本不需要多线程引入并发意味着引入不确定性bug更难复现、更难排查。只有当任务确实耗时、确实能并行、确实有收益时才值得上并发。这个“收益评估”很重要有些东西用并发写起来代码复杂度翻倍收益却不明显。第二条锁的粒度能小则小。不要图省事在方法签名上直接加synchronized。先想清楚真正需要保护的共享数据是哪几行代码只给那几行加锁。锁的范围越大阻塞的线程越多系统的并发能力越低。第三条避免共享可变状态。编程模型上优先考虑不可变对象能用final就用final能让对象不可变就让对象不可变。不可变对象天然线程安全不需要任何同步。这是我做代码审查时最常提的一条建议——很多加锁代码完全可以用不可变设计替代掉。第四条并发代码必须有针对性测试。并发问题具有偶然性可能跑100次才出现一次常规单测很难暴露。建议用专门的并发压力测试工具显式创建多组线程、反复执行同一操作把可能的竞争窗口尽量放大。也可以用“慢速运行”的手段比如在临界区代码前后加小睡人为放大时间窗口提升出问题的概率便于复现和定位。10. 写在最后的一点体会做并发编程这几年我最深的感受是真正难的不是API怎么调而是思路的转变——从“一段代码顺序执行”的单线程心智模型切换到“多线程并行交错、共享状态随时可能被修改”的并发心智模型。有人问过我学习并发有没有什么捷径。我认为没有但有一条相对高效的路径先彻底理解线程生命周期和所有状态再深入理解synchronized和volatile的底层语义然后把JUC包的几大件——ReentrantLock、线程池、并发容器、原子类、CompletableFuture——一个个亲手写demo跑通最后去真实的并发压力场景中排查问题。走完这条路你看到线上线程dump时就不会慌因为你知道每条线程在等什么、为什么等、怎么让它继续走。如果你正在学习阶段卡在某一个概念上我的建议是不要死抠文档直接写一个多线程demo把问题“逼”出来。比如不理解可见性就写一个标志位死循环的程序跑一下不理解死锁就故意写一个交叉加锁的demo。亲手制造出问题再亲手解决它这个印象远比看十遍书深刻得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑