
初学JavaEE多线程时我最深的感受是明明每个概念都看得懂但真正写起并发代码、排查线上问题的时候脑子还是一片空白。synchronized和Lock都会用线程池参数背得滚瓜烂熟但遇到一次真实的性能瓶颈或者看到一份陌生的线程Dump瞬间不知道该从哪里下手。这种“学过但不会用”的尴尬几乎每个Java开发者都经历过。这篇内容不是教科书式的概念罗列而是从一个JavaEE开发者的实际视角出发把多线程初阶阶段必须吃透的核心知识、容易踩的坑以及服务端并发编程的真实思考方式整理出来。适合正在学习Java并发、准备面试或者刚入职需要接手并发相关代码的同学。读完你会发现多线程不是一堆孤立的API和关键字而是一套关于“资源共享与效率”的思维模型。1. 先破除三个认知误区多线程初学阶段的隐形坑很多人学多线程的第一个动作就去翻Thread类和Runnable接口的文档然后照着示例写两个线程跑起来觉得自己会了。但我见过太多人在这个阶段走偏这里先聊三个最常见的误区。1.1 误区一把多线程等同于new Thread()刚学Java的时候写多线程最常见的就是这样new Thread(() - { // 业务逻辑 }).start();这段代码本身没问题但它掩盖了一个关键问题线程是操作系统级别的稀缺资源频繁创建和销毁线程的开销非常大。一个标准的Java服务端应用在高并发场景下如果每个请求都new一个Thread系统负载会迅速飙升甚至直接OOM。我在刚工作第一年就踩过这个坑。当时做一个报表导出功能数据量大、耗时长想着开个新线程跑任务就行。结果压测时发现线程数量暴涨内存占用率一路飙升最后服务直接挂掉。后来才意识到真正的多线程开发核心不在于“怎么创建线程”而在于“怎么管理线程”。这也是线程池、并发容器、锁机制这些工具存在的意义。初学阶段把new Thread()当作理解线程的入口没问题但思维一定要尽快升级到“线程管理”的层面。后面会详细讲线程池这是JavaEE环境下最常见的线程使用方式。1.2 误区二以为synchronized就是线程安全synchronized是初学阶段接触最多的关键字很多人觉得给方法或代码块加上synchronized并发问题就万事大吉了。但线程安全有三个维度——原子性、可见性、有序性synchronized能保证原子性和可见性但锁的使用范围和粒度的设计直接影响性能和正确性。举个典型的例子public class Counter { private int count 0; public synchronized void increment() { count; } }这段代码看起来是线程安全的但如果increment方法里还包含耗时的I/O操作、远程调用或者持有锁的时间过长那么这个synchronized就会成为性能瓶颈。更糟的是如果多个线程持有不同的锁去互相等待对方的锁会发生死锁程序直接卡死。所以synchronized不是万能的重要的是理解锁的对象是谁、锁的粒度多大、锁的持有时间多长。这些问题的答案往往决定了系统的正确性和并发能力。1.3 误区三本地跑不出并发问题就以为代码没问题这是我踩过最深的一个坑。开发阶段写了一个看似没有并发问题的模块本地测试怎么跑都正常因为本地环境并发量低、执行速度快问题很难暴露。但一旦部署到生产环境多个线程同时操作共享变量各种奇怪的问题就冒出来了——数据错乱、偶发性异常、系统响应变慢排查起来极其痛苦。并发问题的本质是“不确定性”。它依赖于线程调度、CPU缓存、指令重排等一系列底层行为。初学阶段要学会工具辅助验证——用压测工具模拟高并发场景用线程Dump分析线程状态用日志记录关键节点的执行顺序。不要相信“我测过没问题”这句话要相信工具和代码逻辑本身。2. 从Thread到Executor线程创建的演进逻辑搞清楚了思维层面的误区接下来看具体的编码实现。这里不是单纯罗列API而是分析它们背后的设计思路。2.1 三种创建方式的对比与选型Java创建线程有三种经典方式直接看对比表格创建方式优点缺点适用场景继承Thread类简单直接适合快速测试Java单继承扩展性差任务与线程耦合学习测试、简单一次性任务实现Runnable接口解耦任务与线程可配合线程池无返回值无法抛出受检异常无返回值的异步任务实现Callable接口有返回值可抛异常支持Future使用稍复杂需要配合FutureTask或线程池需要获取执行结果的异步任务三种方式中实际项目中最常用的是Runnable和Callable配合线程池使用而直接new Thread()的情况越来越少。原因很直接线程池能复用线程、控制并发数、管理生命周期性能和稳定性都比裸创建好太多。Callable和Runnable最大的区别在于返回值public class CallableDemo { public static void main(String[] args) throws Exception { ExecutorService executor Executors.newSingleThreadExecutor(); FutureInteger future executor.submit(() - { Thread.sleep(1000); return 42; }); // 阻塞等待结果 Integer result future.get(); System.out.println(结果: result); executor.shutdown(); } }这里future.get()是阻塞操作如果任务执行时间很长主线程会一直卡在这里。实际开发中需要注意这个特性避免因为等待任务结果而阻塞了主线程导致整体性能下降。2.2 线程状态六种状态与迁移时机理解线程状态是排查线程问题的基本功。Java中线程有六种状态状态含义进入方式NEW线程已创建未启动new Thread()之后start()之前RUNNABLE运行中或等待CPU调度调用了start()BLOCKED等待获取监视器锁未抢到synchronized锁WAITING无限期等待其他线程唤醒调用了wait()、join()、park()TIMED_WAITING有限期等待调用了sleep(time)、wait(time)、join(time)TERMINATED线程执行结束run()执行完或抛异常退出排查线上问题的时候这个状态列表是救命稻草。有一次服务响应变慢我通过jstack命令拿到线程Dump发现大量线程处于BLOCKED状态卡在同一个对象的synchronized代码块上。顺着调用栈定位发现是一个数据库连接池的临界区代码锁竞争激烈导致大量请求排队。如果不懂线程状态的迁移逻辑看到这种Dump根本无从下手。值得特别注意的是WAITING和BLOCKED的区别BLOCKED是等待锁是被动的WAITING是主动放弃CPU等待被唤醒比如调用wait()和join()之后的状态。很多初学阶段的人容易把这两个混淆但在分析线程Dump时这两个状态指向的问题完全不同。2.3 中断机制为什么不是强制停止初学多线程时我一度以为Thread.stop()可以终止线程后来才知道这是个废弃方法因为强行终止线程会导致资源无法释放、数据不一致等问题。Java设计的线程中断是协作式的——通过interrupt()方法设置中断标志线程自己决定何时响应中断。Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { try { // 执行任务 Thread.sleep(100); } catch (InterruptedException e) { // 重新设置中断标志或者处理中断逻辑 Thread.currentThread().interrupt(); break; } } }); worker.start(); // 其他线程发起中断 worker.interrupt();有一个细节值得注意当线程在sleep()、wait()、join()等阻塞状态下被interrupt()时会抛出InterruptedException同时清除中断标志位。所以在catch块里通常需要再次调用interrupt()来恢复中断状态否则上层代码无法感知中断发生。我在学法阶段就踩过这个坑catch了InterruptedException之后直接忽略导致线程无法优雅退出。初学阶段理解这个机制可能有些别扭但请务必记住Java的线程中断是“请求”不是“命令”。设计逻辑必须靠业务代码配合而不是强制终止。3. 原子性、可见性、有序性并发编程的三座大山这三个概念是Java并发编程的理论基石。很多面试题和线上问题扒开本质都是这三座大山在作祟。3.1 原子性从i说起先看一段最经典的代码public class AtomicDemo { private static int count 0; public static void main(String[] args) throws Exception { ExecutorService executor Executors.newFixedThreadPool(10); for (int i 0; i 1000; i) { executor.submit(() - count); } executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); System.out.println(count count); } }执行多次你会发现count结果不稳定有时候是998有时候是999甚至可能更少。count这行代码看起来是一步操作但在字节码层面其实包含三个动作读取count的值、加1、写回count。如果两个线程同时执行“读取”这一步读到的都是旧值然后分别加1写回结果就少加了一次。解决原子性问题最直接的方式是CASCompare And Swap机制。Java中对应的工具类是java.util.concurrent.atomic包比如AtomicIntegerAtomicInteger atomicCount new AtomicInteger(0); // 多线程环境下 atomicCount.incrementAndGet();AtomicInteger底层通过CAS volatile实现无锁的原子操作性能比synchronized要好。但CAS也有自己的坑ABA问题、自旋开销。初学阶段先掌握AtomicInteger的正确用法即可底层原理可以在后续深入学习。3.2 可见性Java内存模型下的变量读取另一个经常被忽略的问题是可见性。Java内存模型JMM规定每个线程都有自己的工作内存寄存器或CPU缓存线程操作共享变量时先拷贝到自己的工作内存操作完再写回主内存。这个过程不保证实时同步——线程A修改了变量线程B可能看不到。public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws Exception { new Thread(() - { while (!flag) { // 空转 } System.out.println(线程结束); }).start(); Thread.sleep(1000); flag true; // 主线程修改flag } }理论上主线程把flag设为true后子线程应该退出循环。但如果不加volatile这个程序很可能永远不退出因为子线程的循环读的是工作内存里的旧值看不到主线程的修改。volatile关键字就是为了解决可见性问题的。它强制线程每次读写都从主内存操作而不是使用工作内存的缓存。同时volatile还有防止指令重排序的作用这个后面再说。初学阶段需要注意的是volatile只能保证可见性和有序性不能保证原子性。所以volatile修饰的变量如果被多个线程做“读取-修改-写回”这种复合操作依然有并发问题。3.3 有序性指令重排与Happens-BeforeCPU和编译器为了提高执行效率会对代码指令进行重排。在多线程环境下重排可能导致程序逻辑错乱。最经典的例子是双重检查锁DCL单例模式public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }instance为什么要加volatile因为new Singleton()这一步在指令层面包含多个动作分配内存空间、初始化对象、将引用指向内存地址。如果这三步被重排成“分配内存、引用指向地址、初始化对象”那么线程A在执行到“引用指向地址”时线程B进入getInstance()发现instance不为null直接返回了一个尚未初始化完成的对象程序就出错了。volatile在这里有两个作用禁止指令重排保证可见性。初学阶段记住一个核心结论volatile修饰的变量的读写操作会建立一个Happens-Before关系前后的指令不会被随意重排序。Java并发包里的很多工具如ConcurrentHashMap、ReentrantLock底层都依赖这些内存语义。理解了这三座大山你才能明白并发框架的设计初衷。4. 锁机制的演进从synchronized到ReentrantLock锁是并发编程的“交通警察”。没有锁共享资源的并发访问一片混乱锁用不好系统直接堵死。所以初学阶段对锁的理解必须到位。4.1 synchronized的三种使用形态synchronized有三种用法锁的对象范围完全不同使用方式锁对象影响范围修饰实例方法当前实例对象this同一实例的方法间互斥修饰静态方法当前类的Class对象所有实例的该方法互斥修饰代码块手动指定的对象灵活控制锁范围关键区分在于实例锁与类锁。看个例子public class SyncDemo { // 实例锁 public synchronized void instanceMethod() { // ... } // 类锁 public static synchronized void staticMethod() { // ... } }实例方法锁的是this两个不同的实例调用同一个实例方法不会互斥静态方法锁的是Class对象所有实例之间都互斥。理解这个区别非常重要——很多线上问题都是因为没有区分清楚锁对象导致加锁形同虚设。JDK 1.6之后synchronized做了一系列锁优化包括偏向锁、轻量级锁、重量级锁的升级过程目的是让锁的代价从“高昂”逐渐降到“低成本”。初学阶段可以先掌握使用逻辑底层优化原理是进阶阶段的内容。4.2 ReentrantLock的进阶能力ReentrantLock是JDK提供的显式锁提供了比synchronized更灵活的功能可中断获取锁lockInterruptibly()可超时获取锁tryLock(timeout, unit)公平锁与非公平锁切换条件变量Condition支持多个等待队列用场景对比一下就能理解ReentrantLock的意义。假设一个线程获取锁后迟迟不释放其他线程调用lock()会一直阻塞。如果用tryLock()等待1秒后获取失败就返回线程可以去做别的逻辑而不是无限期卡死。Lock lock new ReentrantLock(); if (lock.tryLock(1, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 获取锁超时走降级逻辑 }这里有个使用细节lock()和unlock()之间的业务代码try-catch一定要完整unlock必须放在finally中执行。否则一旦业务逻辑抛异常锁永远不会释放线程全部卡死。这个坑我见过好几个同事踩过。选择synchronized还是ReentrantLock我的经验是默认先用synchronized它足够简单可靠能cover大多数场景。只有当需要超时获取锁、可中断获取锁、多条件队列等高级功能时才考虑ReentrantLock。4.3 死锁产生条件与应对策略死锁是并发编程最危险的问题一旦发生相关线程全部阻塞服务直接失去响应。死锁的产生需要同时满足四个条件互斥条件资源同时只能被一个线程占用占有且等待线程持有一个资源同时在等待另一个资源不可剥夺线程持有的资源不能被其他线程强行抢走循环等待多个线程形成环路互相等待对方持有的资源理解这四个条件后规避死锁的思路就很清晰了破坏任意一个条件即可。初学阶段最常见的死锁场景是“加锁顺序不一致”。比如A线程持有锁1等待锁2B线程持有锁2等待锁1两者互相等待直接死锁。实际的预防策略有两个比较实用保持全局固定的加锁顺序——所有线程都按相同的顺序获取多个锁就不会出现环路等待。使用tryLock获取锁——获取不到就主动放弃而不是无限期阻塞等待相当于打破了“不可剥夺”的条件。在排查死锁问题时jstack命令可以直接检测出死锁并列出造成死锁的两个线程和它们等待的锁。线上出现死锁时一次jstack基本就能定位问题但不建议等到线上出了大事才去学这个操作。5. 线程池实战JavaEE服务端并发性能的基石JavaEE应用几乎离不开线程池。无论是Tomcat处理HTTP请求还是Spring的Async异步任务、定时任务调度底层都是线程池在支撑。理解和用好线程池等同于拿到了服务端并发性能的钥匙。5.1 核心参数与执行流程先看ThreadPoolExecutor最核心的七个参数参数含义说明corePoolSize核心线程数即使空闲也保留的线程数maximumPoolSize最大线程数线程池允许的最大线程数keepAliveTime空闲存活时间非核心线程空闲多久后被回收unit时间单位配合keepAliveTime使用workQueue任务队列核心线程满后任务进队列等待threadFactory线程工厂创建线程的工厂可自定义线程名handler拒绝策略队列满后新任务的处置方式线程池的执行流程可以概括成四步核心线程执行任务 → 任务进入队列 → 创建非核心线程执行任务 → 触发拒绝策略。这个顺序非常重要我见过不少同学误以为只要任务来了就优先创建新线程实际上任务队列的缓冲作用不可忽视。拒绝策略有四种AbortPolicy直接抛异常默认策略CallerRunsPolicy提交任务的线程自己执行该任务DiscardPolicy静默丢弃DiscardOldestPolicy丢弃队列中最老的任务然后尝试提交新任务实际项目中最推荐的是使用自定义的拒绝策略比如记录日志或写入MQ做补偿。直接抛异常可能导致请求失败静默丢弃可能造成数据丢失两种策略都需要格外谨慎。一个规范的自定义线程池示例ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲存活时间 new ArrayBlockingQueue(500), // 有界队列 new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread thread new Thread(r); thread.setName(biz-pool- count.getAndIncrement()); thread.setDaemon(false); return thread; } }, new RejectedExecutionHandler() { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 记录日志、落库、后续补偿 } } );这里有几个容易被忽略的点线程工厂的name设置自定义线程名在排查问题时太重要了。如果你的线程叫“pool-1-thread-1”看Dump根本不知道是哪个业务模块的线程如果叫“order-async-thread-1”一眼就能定位。有界队列的重要性队列一定要用有界队列如ArrayBlockingQueue如果用了无界队列LinkedBlockingQueuemaximumPoolSize参数将失去意义——任务永远不会触发创建非核心线程的逻辑队列无限膨胀下来直接OOM。5.2 四种内置线程池的适用边界Executors工具类提供了几种现成的线程池初学阶段觉得非常方便但阿里巴巴Java开发手册中明确给出了禁用建议。各自的适用边界可以参考这个表格线程池队列类型风险适用情况newFixedThreadPool无界LinkedBlockingQueue任务堆积OOM小规模任务明确可控并发量newCachedThreadPoolSynchronousQueue线程数量不受控短时高频异步任务但风险大newSingleThreadExecutor无界LinkedBlockingQueue任务堆积OOM需要保证任务顺序执行newScheduledThreadPool延迟队列需注意异常处理周期任务、延时任务最典型的坑是newFixedThreadPool和newSingleThreadExecutor使用无界队列。如果提交的任务处理很慢队列积压越来越多内存迟早被打满。建议在项目里直接禁用Executors创建线程池强制走自定义ThreadPoolExecutor把队列长度交给开发者决策。定时任务这一块也需要了解ScheduledThreadPoolExecutor虽然它看起来像定时器但它的本质是线程池只是增加了延迟调度能力。跟Timer相比它的优势在于并发执行多个定时任务、支持异常处理、线程池复用实际项目中基本不用原生Timer。5.3 生产环境如何设置核心线程数核心线程数设置多少才算合理这是多线程面试的高频问题也是实战中最让人纠结的事。我的经验分两种场景CPU密集型任务线程数约等于CPU核心数 1。加1的原因是为了应对偶发的缺页中断或I/O等待让CPU尽可能不空闲。I/O密集型任务线程数可以设置为CPU核心数 * 2甚至更高。因为线程在等待I/O的时候不占用CPU可以调度其他线程执行。业界有一个比较通用的估算公式最佳线程数 CPU核心数 * (1 任务等待时间 / 任务计算时间)比如一个任务CPU计算耗时20ms等待I/O耗时80ms那么单个核心的并发线程数是1 80/20 5。四核机器就是20个线程。这个公式的价值在于让人有了一个思考模型而不是拍脑袋定参数。不过必须说一句公式只是起点线上最可靠的参数来源是压测。设置一个初始值再通过JMeter等工具逐步加压观察CPU使用率、响应时间、线程池活跃数最后找到最优值。这个调优过程才是线程池参数设计的完整闭环。5.4 线程池的优雅关闭线程池关闭这件事很多初学阶段的人完全没概念。当应用停止时如果线程池里的任务还没处理完直接System.exit()数据就丢了。规范的做法是两步// 1. 停止接收新任务 executor.shutdown(); // 2. 等待已提交任务执行完超时则强制关闭 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); }shutdown()和shutdownNow()的区别在于前者等所有已提交任务执行完后者是立即中断所有正在执行的任务。实际项目中建议先shutdown再给一个合理的宽限期awaitTermination宽限期过了还没结束就shutdownNow兜底。6. JavaEE真实场景中的多线程从容器线程模型到异步化改造JavaEE开发者的多线程技能最终都要落到Web应用的实际场景里。这一部分聊几个最常见的实战切入点。6.1 Servlet容器的多线程本质很多初学JavaWeb的开发者没有意识到Servlet容器本身就是多线程的。Spring Boot默认内置TomcatTomcat处理HTTP请求时会从线程池中分配一个线程来处理请求。也就是说同一个Servlet单例会被多个线程同时调用。这意味着什么意味着你的Controller、Service、DAO层代码全部运行在多线程环境下。如果定义的成员变量是可变的多个请求之间可能相互干扰RestController public class UserController { // 危险多个线程共享这个变量并发访问会出问题 private SimpleDateFormat dateFormat new SimpleDateFormat(yyyy-MM-dd); GetMapping(/date) public String getDate() { return dateFormat.format(new Date()); } }SimpleDateFormat不是线程安全的多个线程共享这个实例就会报错或者得到错误的结果。解决方式是每次使用都new一个实例或者用ThreadLocal包装或者改用JDK 8的DateTimeFormatter线程安全。初学阶段必须建立一个意识在JavaEE中写代码默认所有共享的Bean都是多线程并发的场景。必须对实例变量的线程安全性保持高度警惕。6.2 ThreadLocal的正确使用与内存泄漏隐患ThreadLocal在多线程环境中提供了一个巧妙的设计每个线程都有自己独立的变量副本线程之间互不干扰。private static final ThreadLocalSimpleDateFormat DATE_FORMAT ThreadLocal.withInitial(() - new SimpleDateFormat(yyyy-MM-dd)); public String format(Date date) { return DATE_FORMAT.get().format(date); }这个逻辑看起来很美但藏着一个大坑如果使用线程池线程复用的场景下ThreadLocal变量不会被自动清除。线程执行完任务后回到线程池下次再被分配执行其他任务时ThreadLocal里还残留着上次的数据这就会导致数据串用。最典型的场景就是用户上下文信息。一个请求进来把用户ID存到了ThreadLocal里请求结束但没有调用remove()线程池复用这个线程处理下一个请求时新请求拿到的可能是上一个用户的ID。规范使用ThreadLocal的黄金法则是在finally块中显式调用remove()养成习惯。不要用ThreadLocal存储大对象它会被线程一直持有GC无法回收。使用try-with-resources或过滤器/AOP统一清理。这个坑在初学阶段几乎必踩重点是要理解“线程池复用”与“线程独享变量”之间的冲突。6.3 异步化改造从Future到AsyncJavaEE开发中经常会遇到耗时操作阻塞主线程的场景比如发送短信、邮件通知、数据导出、调用第三方接口。最初级的做法是直接同步调用请求响应时间被拖得很长。进阶一点的做法是引入异步化。同步执行public Order createOrder(OrderRequest request) { Order order orderService.create(request); // 同步调用耗时5秒 smsService.sendNotification(order.getPhone(), 下单成功); return order; }改造后Async public void sendSms(String phone, String content) { smsService.sendNotification(phone, content); }在Spring Boot中Async能派上用场的前提是启动类加上EnableAsync启用异步支持。Async方法不能与调用方在同一个类中——Spring AOP的代理机制决定了同类的自调用会绕过代理。必须走Spring管理的Bean不能直接new一个对象去调用。使用Async时要特别关注线程池配置。Spring默认的SimpleAsyncTaskExecutor有一个问题不复用线程每次执行都新建线程而且没有最大并发数限制。生产环境建议自定义一个线程池并把它配置成Async的默认执行器跟前面定义的线程池逻辑保持一致。对于有返回值的异步任务要用Future或CompletableFuture。CompletableFuture是Java 8提供的高级异步编程工具支持链式回调、组合任务、异常处理。相对Future那套“阻塞等待get()”的用法CompletableFuture让异步代码可读性大幅提升。CompletableFuture.supplyAsync(() - { // 查询用户信息 return userService.getUser(id); }).thenApply(user - { // 查询订单信息 return orderService.getOrdersByUser(user); }).thenAccept(orders - { // 处理结果 System.out.println(orders.size()); }).exceptionally(ex - { // 异常处理 log.error(异步任务执行失败, ex); return null; });初学阶段建议先把Async用熟练再去接触CompletableFuture。因为Async背后的线程池管理逻辑和Spring生命周期息息相关能帮助建立“异步任务不是没有代价的”这个认知。7. 并发问题排查与面试高频点从初阶到进阶的跳板多线程学到一定程度必然会面临两个现实考验线上出问题怎么排查面试官问起来能不能答清楚。7.1 线程转储与死锁检测线上排查并发问题最常用的工具是jstack。用法很简单jstack PID thread_dump.txt拿到线程Dump后重点关注三个信息线程状态大量BLOCKED说明锁竞争激烈大量WAITING说明有线程一直在等待。线程栈哪个方法执行时间长、哪些代码是关键路径一清二楚。死锁检测jstack会自动输出死锁信息并指出死锁涉及哪几个线程、持有哪把锁、等待哪把锁。有一次排查后台服务卡顿我抓了Dump一看几十个线程全部卡在同一个数据源连接池的获取方法上。再往下一看是数据库连接池被跑慢的查询占满了导致所有请求都在等连接。整个链路通过线程栈很快就理清楚了。除了jstack还有几个工具也比较实用Arthas阿里开源的Java诊断工具线上动态查看线程状态、方法调用栈不需要重启服务非常强大。VisualVM可视化的监控工具可以看线程状态、CPU占用、内存分布适合本地调试。Arthas的thread命令可以直接分析最耗时的线程。7.2 并发容器速查很多人学完线程和锁一遇到“多线程操作集合”的场景就开始自己加锁。其实JDK已经提供了一套非常完善的并发容器比自己加锁可靠得多。这里列一个速查表场景普通版本并发版本特点HashMapHashMapConcurrentHashMap分段锁/CAS锁高并发读快ArrayListArrayListCopyOnWriteArrayList写时复制读多写少场景SetHashSetConcurrentHashMap.newKeySet()基于ConcurrentHashMap实现Map有序TreeMapConcurrentSkipListMap跳表实现支持排序队列LinkedListBlockingQueue系列支持阻塞读写适合生产者消费者ConcurrentHashMap在Java 8之后改成了CAS synchronized锁桶的实现放弃了JDK 7的分段锁读性能接近无锁写操作只锁当前桶并发度大幅提升。如果面试官问ConcurrentHashMap的原理JDK 8的实现逻辑是必须答清楚的。CopyOnWriteArrayList是另一个很有意思的容器写的时候复制一份新数组在新数组上修改修改完将引用指向新数组。读操作不需要加锁因为读的是不可变的老数组。适用于读多写少、集合内元素变化少的场景比如监听器列表。7.3 初阶到进阶的学习路线建议走到这里你已经把JavaEE多线程初阶的核心内容过了一遍。按照我的经验接下来可以按这个顺序继续深入Java内存模型JMM的底层原理主内存、工作内存、内存屏障、happens-before规则细节。AQSAbstractQueuedSynchronizer框架理解ReentrantLock、Semaphore、CountDownLatch的共同底层。ConcurrentHashMap源码解析JDK 8的实现逻辑不求和内存、锁、CAS结合理解重点看resize和计数逻辑。线程池源码阅读ThreadPoolExecutor的execute()方法、Worker的内部逻辑、任务的排队与拒绝。高性能框架的并发实践Netty的Reactor线程模型、Disruptor的无锁队列设计属于进阶阶段的高价值参考。我个人在实际学习中还有一个额外建议不要把时间全花在刷面试题上多亲手写一些多线程的小Demo再配合压测模拟高并发观察结果是否符合预期。遇到不符合预期的时候记下场景、查资料、看源码经过三五次从“出问题”到“想明白”的过程你对多线程的理解会有一个质的飞跃。