1. 并发编程的核心从锁的底层看你到底在竞争什么很多人学Java并发上来就背synchronized和lock的区别问我哪个性能更好。这个问题本身就是伪命题——性能取决于使用场景但真正拉开差距的是你懂不懂这两个东西在底层到底做了什么。并发编程的本质其实是协调多个线程对共享资源的访问。这里的“协调”不是靠自觉而是靠内存屏障、锁和原子操作这些硬约束。Java并发工具包java.util.concurrent简称JUC里绝大部分组件都是建在同一块地基上的volatile、CAS、AQS以及JVM内部对synchronized的不断优化。第二篇我们不再讲Thread和Runnable怎么用而是把这些地基掀开看。1.1 synchronized的锁升级过程不是一上来就是重量级synchronized在JDK 1.6之后经历了大改引入了“偏向锁→轻量级锁→重量级锁”的升级路径。很多人把这条路径背下来就完了但面试官真正想听的是你知道为什么要这么设计。一开始HotSpot团队发现大多数锁不仅不竞争而且往往只被同一个线程反复获取。于是搞出了偏向锁锁对象头里的Mark Word会记录持有它的线程ID之后该线程再来无需任何CAS操作直接进入临界区。这相当于给对象贴了个标签“这是我常用座位别人别坐”。一旦出现第二个线程竞争偏向锁就撤销升级为轻量级锁。轻量级锁的逻辑是线程在栈帧中创建一个Lock Record用CAS试着把对象头里的Mark Word替换成指向这个Lock Record的指针。如果成功锁就归你了失败说明有人抢线程就自旋等待转几圈再试避免立刻挂起线程——线程挂起和恢复是要内核态参与的成本极高。但如果自旋也搞不定比如锁持有时间太长、等待线程太多JVM会尝试自适应自旋实在不行就把锁膨胀为重量级锁由操作系统互斥量monitor管理拿不到锁的线程会进入阻塞队列。注意偏向锁在JDK 15之后被废弃在JDK 17中默认不再使用。但这不代表你没用了——理解这段演进才能明白为什么synchronized在很多场景下并不比ReentrantLock差。看下面的简单示例我们用-XX:PrintBiasedLocking或者jol工具都能观察到不同阶段对象头的变化但概念上记住竞争越激烈锁越重性能越差。所以写程序时临界区要尽量短不要在持锁期间去调外部接口、睡大觉、跑大循环不然锁升级到重量级性能直接跳水。public class LockUpgradeDemo { private static final Object LOCK new Object(); public static void main(String[] args) { long start System.currentTimeMillis(); for (int i 0; i 100_0000; i) { synchronized (LOCK) { // 临界区只做一件事 int x i * 2; } } System.out.println(耗时(ms): (System.currentTimeMillis() - start)); } }这段代码在真实环境里的耗时会被锁升级路径极大地影响。如果你在临界区里写了Thread.sleep(1)就能直观感受到重量级锁带来的性能恶化。这不是让你背结论而是建议你亲自跑一遍看看不同竞争强度下的耗时曲线。1.2 AQS是JUC的“心脏”ReentrantLock只是它的门面ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock名字各不相同但内部几乎都是同一个框架在工作——AbstractQueuedSynchronizer也就是AQS。AQS的核心是一个volatile int state和一个CLH变体双向等待队列。state代表共享资源的占用状态对ReentrantLock来说state表示“锁被重入了几次”对Semaphore来说state表示“剩余许可数”。线程抢不到锁就包装成节点放进队列尾部然后安全地阻塞等待前驱节点唤醒它。import java.util.concurrent.locks.ReentrantLock; public class AqsDemo { private static final ReentrantLock LOCK new ReentrantLock(); private static int count 0; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[10]; for (int i 0; i 10; i) { threads[i] new Thread(() - { for (int j 0; j 10000; j) { LOCK.lock(); try { count; } finally { LOCK.unlock(); } } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); } }这里有个必须记住了的规矩lock()之后一定要在finally里unlock()。忘了释放锁轻则线程阻塞重则死锁。你可能会说synchronized就不用管释放编译器自动搞定。对这正是synchronized的优点但也是它不够灵活的地方——ReentrantLock支持公平锁、超时获取锁、可中断获取锁、多条件变量这些都是synchronized没有的。AQS里还有个大坑自定义同步器时tryAcquire和tryRelease必须保证state修改的原子性。我见过有同事用自定义Semaphore时在tryAcquire里直接state--结果并发一上来状态全乱了。正确做法是compareAndSetState或者直接继承Semaphore别手搓。2. 线程池的正确打开方式参数不是背出来的是算出来的说到并发编程绕不开线程池。但绝大多数人只是知道Executors.newFixedThreadPool(10)这么用直到线上出问题才回头补课——ExecutorService用起来很顺手坑起来也很顺手。2.1 核心线程数、最大线程数、队列容量到底怎么设我在面试里经常问一个场景一个任务平均耗时100ms机器的核数是8QPS大概是500线程池核心线程数应该设多少很多人张口就答“CPU核数1”这是不对的。那是计算密集型任务的拍脑袋经验值。IO密集型任务的线程数经验公式是线程数 CPU核心数 * (1 平均等待时间 / 平均计算时间)对应上面的场景等待时间占大头假设计算时间10ms等待时间90ms那么8核下的理论线程数就是8 * (1 90 / 10) 80。这还只是理论值实际要让线程池的核心线程数略高于80因为会有生产消费的不平衡和GC停顿。更严谨的做法是用经典的Little定律QPS 线程数 / 平均响应时间。如果目标QPS是500平均响应时间100ms线程数就是500 * 0.1 50。这两个公式算出的结果不一样很正常因为一个是资源利用维度一个是容量维度。我们要以容量为准再叠加一定的缓冲比例。ThreadPoolExecutor构造函数的每个参数都有讲究corePoolSize常驻线程数即使空闲也不回收除非allowCoreThreadTimeOut。maximumPoolSize线程池允许的最大线程数它不是随便设的受机器资源、任务类型影响。workQueue等待队列。LinkedBlockingQueue默认无界很容易导致任务无限堆积、内存溢出SynchronousQueue不存任务直接转交线程常用于需要立即执行的场景ArrayBlockingQueue有界配合拒绝策略使用最稳。handler拒绝策略。AbortPolicy默认直接抛异常CallerRunsPolicy把任务退回调用线程执行适合不想丢任务的场景DiscardOldestPolicy丢弃最老的任务。没有绝对的好坏按业务容忍度选。import java.util.concurrent.*; public class ThreadPoolDemo { private static final int CORE_POOL_SIZE 50; private static final int MAX_POOL_SIZE 80; private static final int QUEUE_CAPACITY 1000; private static final int KEEP_ALIVE_TIME 60; public static void main(String[] args) { ThreadPoolExecutor executor new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue(QUEUE_CAPACITY), new ThreadPoolExecutor.CallerRunsPolicy()); for (int i 0; i 200; i) { int taskId i; executor.execute(() - { System.out.println(Thread.currentThread().getName() 执行任务 taskId); }); } executor.shutdown(); } }这里特别想提醒一点Executors工厂方法看着方便但在生产环境慎用。newFixedThreadPool和newSingleThreadExecutor用的是无界队列任务堆积时OOM风险极高newCachedThreadPool最大线程数是Integer.MAX_VALUE高并发下能创建出天文数字的线程直接把机器拖垮。我的习惯是业务代码里一律显式new ThreadPoolExecutorCPU密集和IO密集分别配置。2.2 任务提交后内部到底发生了什么execute()和submit()有什么区别很多初级教程只讲了“submit有返回值execute没有”但线程池内部的流转过程更值得琢磨。当你调用execute(Runnable task)时ThreadPoolExecutor内部是一套“三级漏斗”如果当前工作线程数 corePoolSize直接新建线程执行任务哪怕已经有空闲线程。这听起来反直觉但它是为了响应突然的网络请求。如果线程数 corePoolSize任务先丢进等待队列让已有线程慢慢消化。如果队列满了且线程数 maximumPoolSize才创建新线程来应急执行任务。如果线程数已经达到maximumPoolSize队列也满只能走拒绝策略。这个顺序意味着核心线程平时可能忙不过来新任务不会立刻开新线程而是排队。如果你把corePoolSize设到很大又用了无界队列那maximumPoolSize基本没意义因为队列永远满不了。submit()会返回Future内部会把任务包装成FutureTask再执行。这里有个我踩过多次的坑调用future.get()时如果任务抛出异常ExecutionException会包裹住原始异常。直接e.printStackTrace()看不到真正的错误位置必须解包try { FutureInteger future executor.submit(this::doWork); System.out.println(future.get()); } catch (ExecutionException e) { Throwable cause e.getCause(); // 真正的异常在这 cause.printStackTrace(); }另外线程池里的线程一定要用有意义的命名。默认线程名是pool-1-thread-1这种线上排查问题时线程dump出来根本不知道哪个是老板的业务线程。用ThreadFactory给线程取个和业务相关的名字比如order-service-thread-1定位问题能省一半时间。ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger m new AtomicInteger(0); Override public Thread newThread(Runnable r) { return new Thread(r, order-service-thread- m.getAndIncrement()); } };3. 并发容器这么选才不会给自己挖坑我说的是ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue这些不是Vector和Hashtable。后者虽然线程安全但相当于把所有方法都加上synchronized读读之间也互相阻塞并发性能极差。3.1 ConcurrentHashMap的演进从分段锁到CASJDK 7的ConcurrentHashMap用分段锁Segment数组本质是缩小锁粒度——每个段是一把独立的锁不同段的读写互不干扰默认16段并发度就是16。JDK 8之后抛弃了分段锁改用volatileCASsynchronized。为什么反而放弃更“高级”的分段锁因为分段粒度还是不够细。JDK 8把锁粒度直接落到了每个哈希桶Node数组的每个元素上插入时如果桶为空用CAS直接放入桶不为空才锁住这个桶的头节点进行插入或链表转换。这样并发度从固定16提升到整个数组长度理论上能支撑更高的并发读写。put方法的流程可以简化为检查Node数组是否已初始化没有就先初始化用的是懒初始化。根据hash定位到桶位置。桶为空CAS放进去成功就结束。桶非空判断头节点是ForwardingNode扩容中就去帮忙扩容。否则synchronized锁住头节点插入链表或红黑树。读操作大多不需要加锁因为Node的val和next都用volatile修饰读线程能拿到最新值。这里有个面试高频点为什么读不用加锁也不会读到半初始化状态因为volatile可以保证可见性和一定的有序性同时tabAt和casTabAt用Unsafe做内存屏障避免了指令重排序。我们实际使用时要特别注意ConcurrentHashMap的size()和isEmpty()并不是实时的精确值它内部用sumCount和CounterCell去估算所以size()可能滞后一点。如果业务里需要精确统计元素个数还是得自己加锁或用别的方案。另外ConcurrentHashMap不允许null键和null值。很多人不理解——HashMap允许null为什么它不允许Doug Lea的解释是调用get(key)返回null时无法区分是“没有这个键”还是“键对应的值是null”在并发场景下会产生二义性。记住这个设计初衷面试时答上“避免并发下的歧义”比背结论强多了。import java.util.concurrent.ConcurrentHashMap; public class ConcurrentMapDemo { public static void main(String[] args) { ConcurrentHashMapString, Integer map new ConcurrentHashMap(); map.put(java, 1); map.put(并发, 2); // 错误示范下面这行会抛 NullPointerException // map.put(nullKey, null); System.out.println(map.getOrDefault(python, 0)); } }3.2 CopyOnWriteArrayList读多写少时的“银弹”CopyOnWriteArrayList的原理是“写时复制”每次修改add、set、remove都会创建一个新的底层数组修改后替换原数组引用然后让写操作基于新数组继续。读操作始终在旧数组上进行因为数组引用是volatile的读线程能感知到最新替换。它非常适合读多写少的场景比如缓存的Key列表、白名单、配置项等。但如果你把它用错地方比如频繁写入的大列表那性能会惨不忍睹——每写一次都要复制整个数组空间和时间都是O(n)频繁写会带来大量Young GC和Card Table扫描压力。还有一个隐藏坑CopyOnWriteArrayList的迭代器是“弱一致性”的迭代过程中其他线程修改了列表迭代器不会抛ConcurrentModificationException但也看不到新数据。如果你误以为迭代器是强一致快照可能会在业务里做出错误判断。它保证的是“不崩溃”不是“实时结果”。import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; public class CowListDemo { public static void main(String[] args) throws InterruptedException { ListString list new CopyOnWriteArrayList(); list.add(article); Thread writer new Thread(() - { for (int i 0; i 1000; i) { list.add(item- i); } }); writer.start(); // 这里读到的 size 可能不是最终值但不会抛异常 for (String s : list) { System.out.println(s); } writer.join(); System.out.println(最终大小: list.size()); } }如果写操作占比超过20%或者列表本身很大那CopyOnWriteArrayList就不合适了。换个思路用ConcurrentLinkedQueue做队列或者直接用不可变集合加版本号管理效果可能更好。4. 异步编程的现代姿势CompletableFuture把回调地狱变成流水线Java 8引入了CompletableFuture就是为了解决Future不能编排、不能回调的痛点。Future.get()会同步阻塞接口响应时间被最慢的子任务拖住这在微服务场景下尤其致命。4.1 用thenApply把异步任务串成流水线CompletableFuture提供了一大批以then开头的编排方法可以理解为把异步任务的依赖关系声明出来事件驱动地执行。比如先查用户信息再查订单列表最后计算总金额。如果两次查询之间没有依赖用thenCombine并行执行如果有依赖用thenCompose串行。import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class CompletableFutureDemo { public static void main(String[] args) { ExecutorService pool Executors.newFixedThreadPool(4); CompletableFuture.supplyAsync(() - 用户id1001, pool) .thenApplyAsync(userId - userId 的订单, pool) .thenApplyAsync(order - order 总金额999, pool) .whenComplete((result, err) - { if (err null) { System.out.println(result); } else { err.printStackTrace(); } }) .join(); pool.shutdown(); } }这里有个容易踩的坑thenApply和thenApplyAsync虽然只差一个Async后缀但执行线程完全不同。thenApply会在上一个任务完成的线程里继续执行如果上一个任务用的是线程池线程那后续任务也在这个线程里跑不会重新排队thenApplyAsync则会重新提交到指定线程池或默认ForkJoinPool.commonPool。默认的commonPool线程数 CPU核数 - 1如果业务里有阻塞操作比如查数据库那commonPool很容易被占满导致所有异步任务互相排队。我的经验是所有异步编排方法尽量显式传线程池不要用默认池。否则线程池里的任务跑到一半卡在IO上整个JVM的异步任务都会跟着遭殃。4.2 异常处理exceptionally和handle别搞混CompletableFuture链式调用中一旦某个环节抛了异常后续的thenApply都不会执行异常会顺着链条传递到终点。如果想在任意环节兜底可以用exceptionally它接收上一个阶段的异常返回一个默认值handle无论有没有异常都会执行第一个参数是结果第二个参数是异常。CompletableFutureInteger future CompletableFuture.supplyAsync(() - 1 / 0) .exceptionally(ex - { System.out.println(捕获到异常: ex.getMessage()); return -1; }); System.out.println(future.join()); // 输出 -1还有个更隐蔽的坑CompletableFuture里的异常如果没人调用get()或join()异常会被吞掉日志里看不到任何报错。所以在写链式调用时建议在最后加一个whenComplete记录错误日志宁可多打一条日志也不要让异常悄无声息地消失。5. 并发问题排查实录没有玄学只有你没想到的角度并发问题之所以难是因为它不是必然复现的而是概率性出现。这里分享几个我踩过坑、也帮人排查过的问题给你当参考。5.1 死锁、活锁、饥饿三个容易被混淆的并发故障先区分三个概念死锁两个线程各自持有一把锁同时等对方释放另一把锁永远互相等待。活锁线程不是在等待而是反复重试同一个失败操作状态一直在变但任务没进展。饥饿线程永远得不到执行机会比如优先级设置不合理或者非公平锁导致某些线程老抢不到锁。死锁的经典示例就是“哲学家就餐问题”。过度同步的锁嵌套最容易引发死锁而且一旦发生大概率是线上事故。定位死锁最直接的工具是jstack它会输出线程的栈信息如果发现两个线程各自持有锁并互相等待就能看到类似Thread-1 - waiting to lock 0x...和Thread-0 - waiting to lock 0x...的记录并且JVM在结尾会有Found one Java-level deadlock的明确提示。import java.util.concurrent.locks.ReentrantLock; public class DeadLockDemo { private static final ReentrantLock LOCK_A new ReentrantLock(); private static final ReentrantLock LOCK_B new ReentrantLock(); public static void main(String[] args) { new Thread(() - { LOCK_A.lock(); try { Thread.sleep(100); LOCK_B.lock(); try { System.out.println(Thread-1 拿到了两把锁); } finally { LOCK_B.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } finally { LOCK_A.unlock(); } }).start(); new Thread(() - { LOCK_B.lock(); try { Thread.sleep(100); LOCK_A.lock(); try { System.out.println(Thread-2 拿到了两把锁); } finally { LOCK_A.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } finally { LOCK_B.unlock(); } }).start(); } }死锁的预防手段有不少最实用的是“锁排序法”让所有线程都按同一个全局顺序获取锁。比如都先锁A再锁B就不会出现互相等待。另一个是ReentrantLock的tryLock(long timeout, TimeUnit unit)获取锁带超时拿到就执行拿不到就放弃重试也能有效打破死锁的等待条件。5.2 线程池拒绝策略与队列堆积怎么用jstack和监控发现线程池的问题不像死锁那么显眼它往往是慢慢恶化到系统不可用的。典型的现象是CPU居高不下线程数猛增。接口响应时间越来越长超时率上升。内存中对象堆积GC频繁且耗时。排查的第一步先看线程池的关键指标活跃线程数、队列积压量、被拒绝任务数。这些指标在ThreadPoolExecutor内部都有但没有现成的API暴露需要自己用AOP或者包装线程池记录。我建议在初始化线程池时用ThreadPoolExecutor的子类重写afterExecute和beforeExecute方法把每次任务执行时间、异常情况打点数据送到监控平台。第二步如果怀疑某个业务线程池队列被堆满用jstack导出线程dump搜索线程池线程的名称比如order-service-thread-看看它们在哪个方法上阻塞。如果大量线程卡在数据库查询、外部HTTP调用上说明不是线程不够而是下游太慢线程池被IO拖住了。这时候加线程数只是治标真正要做的是给下游调用加超时、加熔断。第三步检查拒绝策略。使用CallerRunsPolicy时任务会被丢回调用线程执行如果调用线程是Web应用的处理线程大量积压任务会让这些线程全部卡在任务执行上导致Tomcat或Jetty线程池耗尽整个Tomcat端口无响应表现为“整个服务像死了”。这种情况下jstack里能看到大量http-nio-exec-*线程卡在你的业务Runnable里而不是等在accept调用上。import java.util.concurrent.*; public class MonitorPool extends ThreadPoolExecutor { public MonitorPool(int core, int max, int queueSize) { super(core, max, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(queueSize)); } Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); // 这里埋点上报任务耗时和异常 System.out.println(任务执行完毕队列大小 getQueue().size() , 活跃线程数 getActiveCount()); } }5.3 ThreadLocal的内存泄漏不是危言耸听ThreadLocal是并发编程里的“单线程变量”但它有个著名的坑如果线程池里的线程存活时间很长而ThreadLocal没有及时清理ThreadLocal里的对象会一直堆在线程的ThreadLocalMap里无法被垃圾回收。原理是ThreadLocalMap的key是ThreadLocal本身的弱引用value是强引用。ThreadLocal对象被引用的外部引用消失后key会被回收变成null但value还挂在Map里。线程池的线程长期复用这个“脏value”就会一直占据内存造成泄漏。解决方式很简单用完ThreadLocal后一定要调用remove()而不是等GC。尤其在线程池里跑业务每个任务开始时要思考“这个ThreadLocal会不会被下一个任务读到”如果会必须清理。import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadLocalLeakDemo { private static final ThreadLocalString USER_CONTEXT new ThreadLocal(); public static void main(String[] args) { ExecutorService pool Executors.newFixedThreadPool(2); pool.execute(() - { USER_CONTEXT.set(用户A); System.out.println(任务1: USER_CONTEXT.get()); // 如果不 remove用户A 会被任务2读到 USER_CONTEXT.remove(); }); pool.execute(() - System.out.println(任务2: USER_CONTEXT.get())); // 输出 null pool.shutdown(); } }我自己在实际项目中干脆用try-finally包裹ThreadLocal的使用在finally里强制remove。虽然代码看起来啰嗦一点但线上内存泄漏的概率会大大降低。还有个和它相关的场景MDC日志链路追踪。你在logback里用MDC.put(traceId, ...)存请求ID如果请求结束时不MDC.remove()下一个请求复用同一个线程池线程时就会打出上一个请求的traceId排查问题时会把你带进沟里。6. 一些并发编程的实操习惯帮你少交学费想把自己从“Java新手”变成“并发能手”光看这些工具类是不够的更重要的是培养几个习惯。第一所有共享的可变状态都要想清楚“谁在写、谁在读、写入频率”。如果你的共享数据是HashMap在多线程下只用ConcurrentHashMap如果你的集合读多写少且写操作不频繁CopyOnWriteArrayList如果你只是要一个线程安全的计数器直接用AtomicLong或者LongAdder别自己加synchronized去锁基础类型。第二能用volatile解决的问题就不要上锁。volatile只能保证可见性和有序性不能保证复合操作的原子性。一个经典的用法是“状态标志位”多个线程只需要读到一个布尔变量的结果主线程在某个时刻把它置为true其他线程看到后立即停止。这种场景用volatile boolean就很合适成本比锁低得多。第三不要在循环里重复创建线程池或锁对象。我见过有人把ExecutorService放在for循环里每个任务都new一个线程池跑完就shutdown()。线程池的创建本身有成本频繁创建销毁性能比单线程串行执行还差。线程池的创建我建议放到静态初始化或用Spring的Bean管理全局只用一个。第四发现问题时先拿证据再改代码。并发问题的复现往往是概率性的你改了代码之后发现“好了”可能只是运气。正确的姿势是先留现场抓线程dumpjstack -l pid、抓堆dumpjmap、记录GC日志-Xlog:gc。等数据齐了再动手分析才不会被假象迷惑。第五不要迷信“加锁就能解决一切”。并发问题里有相当一部分是逻辑设计问题不是同步问题。比如两个线程本来应该各做各的事结果你非让他们竞争一个全局变量那是设计得有问题。能用无锁数据结构解决的优先无锁能用局部变量解决的别共享能用函数参数传的别用全局状态。我也遇到过很多同学学并发上来就深挖Disruptor、akka这类重型框架结果基础工具都没玩熟。我个人的建议是先把ThreadPoolExecutor、CompletableFuture、ConcurrentHashMap、ReentrantLock这几个吃透能解决90%的业务并发场景。剩下的工具都是在这几个核心之上做功能扩展。Java并发编程的乐趣在于它不仅仅是一门语言特性更是一套关于“资源有限、竞争存在、协作必要”的思维模型。你理解得越深入写代码的时候越从容。这篇是“并发编程篇二”里面提到的线程池、AQS、并发容器、异步编排都是日常开发最常用的武器。下一篇如果时间允许我们可以专门聊聊LongAdder的伪共享问题、ForkJoinPool的工作窃取机制以及如何用JMM的happens-before规则去推理复杂的并发可见性问题。敬请期待。