1. 线程基础先搞清楚线程到底是什么线程这块内容可以说是Java面试里出场率最高的知识点没有之一。不管是校招还是社招只要是后端方向几乎必问线程相关的概念。我这些年面试别人也被别人面试发现线程八股问来问去核心就那么几个方向线程的创建方式、生命周期、状态流转、线程池、锁机制、并发工具类。把这些吃透了基本就能应对绝大部分面试场景。先聊线程的本质。线程是操作系统能够进行运算调度的最小单位它被包含在进程之中是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流一个进程中可以并发多个线程每条线程并行执行不同的任务。打个比方进程就像一家餐厅线程就像餐厅里的服务员一个餐厅可以有多个服务员同时服务不同的顾客。Java天生支持多线程编程这也是Java能成为企业级开发主流语言的重要原因之一。很多初学者对线程和进程的关系搞不清楚这里我多说一句。进程是资源分配的基本单位线程是CPU调度的基本单位。进程之间相互独立各有独立的地址空间同一个进程内的线程共享进程的地址空间和资源。所以多线程编程最大的优势就是可以共享数据、节省资源但同时也带来了并发安全问题这也是后面所有锁机制存在的根本原因。从面试角度来说线程基础这块经常被问到的一个问题是Java里创建线程有哪几种方式这个问题看起来简单但其实有很多细节可以深挖。我遇到过很多候选人能说出继承Thread类和实现Runnable接口但对Callable和FutureTask、以及线程池创建这几种方式理解不够深入。下面具体展开说。1.1 线程创建方式的演进与对比Java创建线程的方式从最初的继承Thread类到实现Runnable接口再到JDK 5引入的Callable和FutureTask以及最终的线程池经历了一个逐步演进的过程。这个演进过程本质上是在解决如何更好地管理和复用线程这个问题。继承Thread类是最传统的方式直接new一个Thread的子类重写run方法。这种方式代码简单但缺点很明显Java是单继承的一旦继承了Thread类就不能再继承其他类了扩展性差。而且这种方式把任务和线程耦合在一起不符合任务与执行分离的设计思想。实现Runnable接口是更推荐的方式它把任务逻辑和线程本身解耦了。Runnable接口只有一个run方法里面写的是任务要执行的内容。使用时还是需要创建Thread对象把Runnable实现类传进去调用start方法。这种方式的好处是任务类还可以继承其他类也更符合面向接口编程的思想。到了JDK 5引入了Callable接口和Runnable相比最大的区别是Callable的call方法有返回值而且可以抛出异常。但Callable不能直接传给Thread使用需要配合FutureTask来包装一下。FutureTask实现了Runnable接口所以可以传给Thread同时又实现了Future接口可以获取异步计算结果。线程池是JDK 5同时推出的也是目前生产环境用的最多的方式。核心思想是复用线程避免频繁创建和销毁线程带来的性能开销。阿里开发手册明确禁止使用Executors工具类创建线程池而是要求通过ThreadPoolExecutor的方式手动创建这样可以更明确地控制线程池的运行规则避免资源耗尽的风险。我个人的经验是面试中回答创建方式时不要只是干巴巴地罗列几种方式可以主动说一下各自的适用场景和优缺点对比这会让面试官觉得你是有真实项目经验的而不是背八股。1.2 线程生命周期与状态流转线程的生命周期是所有线程知识的基石理解不透彻后面学什么都不顺畅。Java线程在运行过程中有六种状态定义在Thread.State这个枚举类里。NEW状态是线程被创建但还没调用start方法时的状态。RUNNABLE是调用了start方法之后的状态这里要注意Java的RUNNABLE状态其实包含了操作系统层面的运行中和就绪两种状态也就是线程可能正在CPU上执行也可能在等待CPU时间片。BLOCKED是线程在等待获取监视器锁也就是synchronized的锁时的状态比如一个线程进入了synchronized代码块另一个线程也想进入就会进入BLOCKED状态。WAITING是线程正在等待另一个线程执行特定动作时的状态比如调用了Object.wait()、Thread.join()、LockSupport.park()都会进入这个状态。TIMED_WAITING是带超时时间的等待状态比如Thread.sleep、Object.wait(long timeout)、Thread.join(long millis)等。TERMINATED是线程执行完run方法后的最终状态。状态流转是面试的重点尤其容易被问到sleep和wait有什么区别这里一定要说清楚。sleep是Thread类的静态方法调用后线程进入TIMED_WAITING状态但不会释放任何锁资源wait是Object类的方法调用后线程进入WAITING状态并且会释放持有的锁。这也是为什么wait方法必须在synchronized代码块中调用因为它需要先持有锁然后释放锁让其他线程有机会获取锁。sleep则没有这个限制任何地方都能调用。还有一个高频考点是线程的start方法和run方法有什么区别很多新手会犯直接调用run方法的错误。start方法会启动一个新线程由新线程去执行run方法里的逻辑直接调用run方法就只是在当前线程执行一个普通的方法调用根本没有创建新线程。判断一个线程是否启动过可以看thread.getState()是否不等于NEW。关于状态流转我建议画一张状态转换图帮助记忆。我自己当年学的时候把六种状态的触发条件和转换关系整理成了一张表格贴在电脑旁边天天看很快就记住了。后面排查线上问题的时候用jstack工具查看线程栈信息看到线程停在什么状态、等在哪把锁上这都是一眼就能看出来的基本功。2. 线程安全的核心机制synchronized与volatile线程安全问题本质上是因为多线程同时访问共享数据时发生了竞争。Java解决线程安全的手段很多但synchronized和volatile是最基础、最核心的两个关键词。面试中几乎必问这两个关键字的原理和区别所以这块一定要深入理解。先理解什么是临界区和竞态条件。临界区是访问共享资源的代码段同一时刻只允许一个线程进入。竞态条件是指多个线程同时读写共享数据时最终结果取决于线程执行的相对时序可能导致数据不一致。解决竞态条件的思路就是让临界区的代码原子执行即不可分割要么全部执行完要么不执行。synchronized关键字就是Java提供的解决并发安全的经典机制。它有三种用法修饰实例方法、修饰静态方法、修饰代码块。修饰实例方法时锁的是当前实例对象修饰静态方法时锁的是类的Class对象修饰代码块时锁的是括号里指定的对象。这里有一个细节很多人会搞混synchronized修饰静态方法和修饰实例方法它们锁的对象是不同的所以两者之间并不会互斥。2.1 synchronized的底层实现与锁升级synchronized在JDK 6之前是一把重量级锁性能表现不佳因为它的底层依赖于操作系统的互斥量mutex线程阻塞和唤醒都需要从用户态切换到内核态这个切换代价很大。所以在早期很多高并发场景下会刻意避免使用synchronized转而使用JDK 5推出的Lock接口。但JDK 6引入了锁升级机制之后synchronized的性能得到了大幅提升很多场景下性能已经和ReentrantLock相差无几。锁升级的方向是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。这个升级方向是单向的不会降级。偏向锁是锁升级的第一级。它的设计思想是大多数情况下锁不仅不存在多线程竞争而且总是由同一个线程多次获得。所以当一个线程第一次访问同步代码块时会在对象头中记录持有锁的线程ID后续这个线程再次进入时就不需要再做任何同步操作了。如果有其他线程来竞争这把锁偏向锁就会撤销升级为轻量级锁。轻量级锁的获取方式是CAS自旋。多个线程在无竞争的情况下交替执行同步代码块时通过CAS操作尝试把对象头中的锁标记替换为自己的线程记录。如果替换成功就获得了锁如果失败说明有竞争就会自旋等待。自旋是占用CPU的所以如果竞争激烈、自旋次数过多就会升级为重量级锁。JDK 6之后自旋是自适应的JVM会根据上次自旋的结果动态调整自旋次数。重量级锁就是传统的互斥锁依赖操作系统的mutex实现。当竞争非常激烈时没有获取到锁的线程会进入阻塞状态由用户态切换到内核态这个切换涉及上下文切换开销很大。理解了锁升级机制之后再回头分析synchronized的适用场景就清楚了锁竞争不激烈的情况下用synchronized完全没问题但是一旦高并发下竞争激烈synchronized会膨胀为重量级锁性能急剧下降这时候就要考虑ReentrantLock或者读写锁了。2.2 volatile的可见性与有序性volatile是Java提供的最轻量级的同步机制它有两个核心作用保证可见性和有序性但不能保证原子性。可见性是指当一个线程修改了volatile变量的值该值会立即被刷新到主内存中其他线程读取该变量时会从主内存中重新读取最新的值而不是使用自己工作内存中的副本。这解决的是多线程之间的数据透明问题。有序性是指volatile会禁止指令重排序。编译器和CPU为了优化执行效率可能会对代码指令进行重排这在单线程环境下没有问题但在多线程环境下可能导致意想不到的结果。volatile通过在读写操作前后插入内存屏障阻止指令重排到屏障的另一侧从而保证了程序执行的顺序符合编写时的预期。但是volatile不保证原子性这也是面试中最容易考到的一个点。经典的场景是i操作虽然i被声明为volatile但i实际上是读-改-写三步操作不是一个原子操作。多个线程同时执行i时还是可能出现数据覆盖的问题。所以volatile适合修饰那些多个线程都会读取、但只有少数线程会写入、且写入不依赖当前值的场景典型的就是作为状态标志位。在并发编程中还有一个著名的happens-before原则需要了解。这个原则定义了多线程操作之间的内存可见性规则是JMMJava内存模型的核心内容。happens-before规则包括程序顺序规则、锁规则、volatile变量规则、传递性规则等。其中volatile变量规则说的是对一个volatile变量的写操作happens-before于后续对该变量的读操作。理解这些规则能帮你判断到底能不能用volatile解决当前的问题。我面试时喜欢问一个问题synchronized和volatile有什么区别这个问题考察的是对并发基础的理解深度。标准的回答应该围绕三方面一是volatile本质是告诉JVM这个变量是共享且不稳定的每次都要从主内存中读取synchronized则是让多个线程互斥访问临界区。二是volatile只能修饰变量不能修饰方法synchronized可以修饰方法和代码块。三是volatile不保证原子性synchronized保证原子性volatile不会阻塞线程synchronized会让未获取锁的线程阻塞。3. 线程通信wait/notify与LockSupport多线程之间除了互斥访问共享资源还经常需要互相协作配合。比如生产者-消费者模式中消费者要等生产者生产出数据之后才能消费这时候就需要线程通信机制。Java中线程通信最经典的方式是wait/notify机制使用Object类的wait、notify、notifyAll方法。这三个方法都必须在synchronized代码块或者synchronized方法中调用因为它们操作的对象的监视器锁。调用wait方法后当前线程会释放持有的锁并进入WAITING状态直到其他线程调用同一个对象上的notify或notifyAll方法将它唤醒。这里有个非常重要的细节notify方法是随机唤醒一个正在等待该对象锁的线程notifyAll是唤醒所有等待的线程。如果需要精确唤醒某个特定的线程那就要用后面引入的Lock和Condition机制了。JDK 5之后引入了Lock接口和Condition接口Condition提供了await、signal、signalAll方法功能类似wait、notify、notifyAll但更加灵活。一个Lock可以创建多个Condition每个Condition维护一个独立的等待队列这样就能实现精确唤醒某个或某类线程而不是像synchronized那样只能使用一个隐式条件队列。举个例子在实现一个有界阻塞队列时如果用synchronized实现只有一个等待队列生产者生产满时等待消费者消费空时等待notify时无法区分唤醒的是生产者还是消费者。而有了Condition之后可以创建两个ConditionnotEmpty和notFull消费者等待notEmpty条件生产者等待notFull条件这样就能精准唤醒对应类型的线程效率更高且逻辑更清晰。LockSupport是更底层的线程阻塞工具类基于Unsafe类的park和unpark方法实现。它的核心优势是不需要持有锁就能使用且unpark可以先于park调用也就是许可机制使得线程可以先被叫醒再等待。AQS框架中大量使用了LockSupport来管理线程的阻塞与唤醒。我在实际项目中用Condition的场景还是蛮多的比如实现限流器、实现任务队列的消费者调度。这套机制比单纯用synchronized加wait/notify要优雅得多代码可读性也更好。线程通信的常见面试题还包括wait方法为什么要放在while循环中答案是为了防止虚假唤醒。有些情况下线程被唤醒时等待的条件并不一定已经满足可能是被其他原因唤醒的所以被唤醒后要再次检查条件是否满足不满足就继续等待。这也是《Effective Java》里面明确的建议总是使用while循环来包围wait方法调用。3.1 生产者消费者模型的两种实现生产者-消费者模型是线程通信最经典的应用场景面试中经常要求手写实现。我用synchronized和Lock两种方式各自实现了一遍这里分享下核心代码和思路差异。用synchronized实现的核心逻辑是定义一个缓冲区列表和一个最大容量生产者和消费者都在synchronized代码块内操作。缓冲区满了就调用wait等待缓冲区空了也调用wait等待。每次生产或消费完一个数据后都调用notifyAll唤醒其他线程。class Buffer { private final ListInteger list new ArrayList(); private final int capacity 10; public synchronized void put(int value) throws InterruptedException { while (list.size() capacity) { wait(); } list.add(value); notifyAll(); } public synchronized int take() throws InterruptedException { while (list.isEmpty()) { wait(); } int value list.remove(0); notifyAll(); return value; } }用Lock和Condition实现的方式类似但可以区分生产者和消费者的唤醒。代码结构上synchronized是隐式获取和释放锁Lock则要求手动加锁和释放锁必须在finally中释放锁避免死锁。class Buffer { private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); private final ListInteger list new ArrayList(); private final int capacity 10; public void put(int value) throws InterruptedException { lock.lock(); try { while (list.size() capacity) { notFull.await(); } list.add(value); notEmpty.signal(); } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (list.isEmpty()) { notEmpty.await(); } int value list.remove(0); notFull.signal(); return value; } finally { lock.unlock(); } } }这两种实现的性能差异在并发量不大的时候几乎感觉不到但Lock版本的可扩展性明显更好。面试时如果能同时写出两种实现并且分析出各自的特点这是一个加分项。4. 锁的进阶ReentrantLock、读写锁与AQS前面反复提到Lock接口和AQS这一节重点展开讲。JDK 5引入的java.util.concurrent并发包是Java并发编程的里程碑而AQSAbstractQueuedSynchronizer是这个并发包的灵魂。ReentrantLock是最常用的Lock实现类它和synchronized相比有以下几个核心优势。一是支持公平锁和非公平锁两种模式synchronized只能是非公平的。公平锁按照线程请求锁的顺序来获取锁保证先来先得但吞吐量会低一些非公平锁允许线程插队吞吐量高但可能出现线程饥饿。二是支持可中断获取锁调用lockInterruptibly方法后等待锁的过程中可以被其他线程中断而synchronized等待锁时是不能响应中断的。三是支持超时获取锁调用tryLock(long timeout, TimeUnit unit)方法在指定时间内获取不到锁就会返回false可以避免无限期等待。四是支持多个条件变量通过newCondition可以创建多个Condition实例。这里说一个面试高频题ReentrantLock和synchronized的区别是什么除了上面说的四点还要补充底层的区别synchronized是JVM层面实现的基于监视器锁ReentrantLock是JDK层面实现的基于AQSsynchronized在JDK 6之后有锁升级机制ReentrantLock从诞生起就使用CAS加自旋的方式实现。4.1 AQS的实现原理AQS是Java并发包的核心抽象它维护了一个volatile int类型的state变量和一个FIFO的等待队列。state的含义由具体的子类去定义比如在ReentrantLock中state表示获取锁的次数在Semaphore中state表示剩余许可证的数量在CountDownLatch中state表示还需要等待的计数个数。AQS的基本思想是当线程请求资源时通过CAS尝试将state从期望值更新为新值如果成功就表示获取资源成功如果失败则将该线程封装成Node节点插入到等待队列的尾部然后通过LockSupport.park阻塞自己。当前驱节点的线程释放资源时会唤醒队列中下一个节点对应的线程。这里有几个细节值得注意等待队列中的Node节点每个节点都保存着线程引用和等待状态。AQS在释放资源时会从队列头部开始找第一个状态有效的节点来唤醒。这个唤醒过程也是需要循环判断的因为节点的等待状态可能会有变化。ReentrantLock的加锁过程本质上就是通过CAS把state从0改成1。如果当前线程已经持有锁了再次获取锁时state会继续累加这就是可重入的含义。释放锁时state递减直到state变为0锁才真正被释放。公平锁和非公平锁的区别在于非公平锁在加锁时会先尝试一次CAS抢锁抢不到再进入等待队列公平锁则直接进入等待队列排队。4.2 读写锁与StampedLockReadWriteLock读写锁是一种特殊类型的锁它维护了一对锁一个读锁和一个写锁。读锁之间可以共享多个线程可以同时持有读锁写锁是排他的一个线程持有写锁时其他线程既不能读也不能写。这种设计非常适用于读多写少的场景比如缓存系统、配置加载等。ReentrantReadWriteLock是ReadWriteLock的实现类但也存在一些问题。比如读锁和写锁之间会互相阻塞也就是所谓的写饥饿问题如果有大量线程在持续读写线程可能会长时间得不到执行。它也不是完全公平的在非公平模式下写线程插队的机会是有限的。JDK 8引入了StampedLock它提供了一种更先进的锁模型。StampedLock有三种模式写锁、读锁、乐观读。乐观读是一种完全不加锁的读操作它先获取一个stamp标记然后读数据读完后再次检查stamp是否发生了变化。如果没变说明读的过程中没有写操作这次读是可靠的如果变了则升级为普通的读锁重读。乐观读非常适合读多写少的场景能够大幅提升吞吐量。需要注意StampedLock不是可重入的而且不支持条件变量。使用乐观读时必须小心验证stamp的有效性这个验证过程要放在读数据之后。5. 线程池参数配置与运行机制线程池是Java并发编程中应用最广的组件也是面试中的重头戏。几乎所有的Java后端服务都离不开线程池从Tomcat处理请求到RPC框架的异步调用都在使用线程池。理解线程池的运作机制不只是应付面试更是日常开发的基本功。使用线程池的好处不需要多讲降低资源消耗、提高响应速度、提高线程的可管理性。JDK的线程池实现核心是ThreadPoolExecutor类它提供了非常丰富的构造参数和灵活的扩展点。面试中考察线程池时几乎必问的问题是ThreadPoolExecutor的核心参数有哪些分别代表什么含义核心参数包括corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。corePoolSize是核心线程数核心线程即使空闲也不会被销毁maximumPoolSize是线程池允许的最大线程数keepAliveTime是非核心线程空闲存活时间超过这个时间就会被回收workQueue是任务队列存储等待执行的任务threadFactory是线程工厂可以自定义线程命名等属性handler是拒绝策略当线程池和队列都满时如何处理新提交的任务。5.1 任务提交与执行流程线程池执行任务的流程是面试中必须掌握的。当调用execute提交一个任务时ThreadPoolExecutor的执行策略如下如果当前线程数小于核心线程数corePoolSize则创建新线程来执行任务如果当前线程数大于等于核心线程数且队列未满则把任务放入队列中等待执行如果队列已满且当前线程数小于最大线程数maximumPoolSize则创建非核心线程来执行任务如果队列已满且线程数已达到最大线程数则根据拒绝策略执行拒绝处理。这个流程可以用一句话总结先看核心线程是否够用再看队列是否能容纳最后看最大线程数是否到顶。有一点要注意很多框架实际使用的线程池配置比如Dubbo这类RPC框架为了降低任务处理延迟会设置足够大的核心线程数让线程池不会因为队列堆积而产生大量等待。这是典型的空间换时间的取舍。拒绝策略有四种实现分别是AbortPolicy抛出RejectedExecutionException异常阻止任务提交、CallerRunsPolicy调用提交任务的线程直接执行被拒绝的任务、DiscardPolicy默默丢弃任务、DiscardOldestPolicy丢弃队列中最早的任务然后尝试重新提交。生产环境最常用的还是AbortPolicy和CallerRunsPolicy前者快速失败后者能将压力回传给提交方起到背压效果。如果一个系统对任务丢失不能接受那就要自定义拒绝策略比如把任务持久化到数据库或消息队列里。5.2 线程池参数配置的经验公式线程池参数并没有一个万能的配置公式具体要结合业务场景来定。但从经验来看可以根据任务类型是CPU密集型还是IO密集型来做初步估算。CPU密集型任务指的是计算量大、很少有IO等待的任务比如大量数学运算、图像渲染。这类任务的瓶颈在CPU线程数和CPU核心数相当即可。一般配置为CPU核心数1公式是Ncpu1。加1是为了避免CPU空转当某个线程因为缺页或其他原因被暂停时多出的线程可以顶上。IO密集型任务指的是大量时间在等待IO操作的任务比如网络请求、数据库读写、文件操作。这类任务等待时间长CPU占用率低所以可以配置更多的线程。一般配置为CPU核心数的两倍公式是2*Ncpu。更精细的估算方法是线程数 CPU核心数 * (1 平均等待时间 / 平均计算时间)。但这个公式对时间比的估计很难精确实践中通常直接用2倍核数起步再通过压测调整。拒绝策略的选择和队列类型也要配套考虑。比如使用有界队列时队列容量可以这样估算期望的排队等待时间乘以请求速率。如果平均请求速率为1000个每秒期望最大排队时间为2秒那么队列容量就可以设置为2000。当然这只是理论参考实际还是以压测为准。我自己习惯的配置方法是先把核心线程数设置为系统的常驻请求并发数最大线程数设置为峰值并发数队列容量设置为峰值和均值之间的缓冲量然后通过压测逐步微调。线程池参数永远不是一次配置就完成的而是在运行过程中持续监控和调整的。5.3 手动创建线程池的正确姿势前文提到阿里开发手册禁止使用Executors创建线程池原因是Executors提供的几个工厂方法都有隐患。newFixedThreadPool和newSingleThreadExecutor使用了无界队列LinkedBlockingQueue当任务提交速度超过处理速度时队列会无限增长最终可能导致内存溢出。newCachedThreadPool的最大线程数是Integer.MAX_VALUE如果任务提交过快而处理速度跟不上会创建大量线程耗尽系统资源。newScheduledThreadPool同样存在队列无界的问题。正确的方式是使用ThreadPoolExecutor的构造方法手动创建。下面给一个参考配置ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 非核心线程空闲存活时间 TimeUnit.SECONDS, // 时间单位 new ArrayBlockingQueue(queueCapacity), // 有界任务队列 new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread thread new Thread(r, business-thread- counter.incrementAndGet()); thread.setDaemon(false); return thread; } }, new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );给线程命名是一个容易被忽视但很重要的细节。线上排查问题时如果线程名都是有业务含义的比如order-service-thread-1、mq-consumer-thread-2jstack输出一眼就能看出是哪个业务模块的线程出了问题。如果所有线程都叫pool-1-thread-1排查效率会低很多。线程池还需要关注动态调整。ThreadPoolExecutor提供了setCorePoolSize和setMaximumPoolSize方法可以在运行期调整参数。比较常见的做法是通过监控系统度量指标根据当前的队列长度和活跃线程数动态调整线程池参数来应对流量波动。这在秒杀、大促这类场景特别有用。最后必须强调线程池在Spring容器中要作为单例Bean管理不要在每个业务方法里new一个线程池。我见过有同事在循环内创建线程池直接把内存打爆了。线程池是重资源必须复用量级实例。6. ThreadLocal与内存泄漏陷阱ThreadLocal是一个很有意思的类它提供线程局部变量每个线程都有自己独立的变量副本互不干扰。ThreadLocal的典型应用场景包括存储用户会话信息、数据库连接管理、SimpleDateFormat日期格式化工具的线程安全替换方案。ThreadLocal的原理说复杂也复杂说简单也简单。每个Thread对象内部都有一个ThreadLocalMap这个Map的key是ThreadLocal对象本身的弱引用value是线程持有的变量值。当线程访问ThreadLocal时ThreadLocal内部类会先从当前线程中获取ThreadLocalMap然后以自身为key去查找对应的value。这里就引出了ThreadLocal最著名的内存泄漏问题。ThreadLocalMap的key是弱引用WeakReferencevalue是强引用。当外部没有对ThreadLocal的强引用时GC回收时就会把key清掉但value仍然是强引用链Thread - ThreadLocalMap - Entry - value。如果线程生命周期很长而value一直不被清除就会造成内存泄漏。规避这个问题的方法已经成为一个共识使用完ThreadLocal后必须调用remove方法清除。尤其是在线程池场景下线程复用次数很多如果不显式remove同一个线程处理不同请求时可能会读取到上一个请求残留的ThreadLocal值造成数据混乱。这个坑非常隐蔽线上偶发出现用户串号这类问题很多就是ThreadLocal没清理导致的。我习惯在finally块里调用remove保证异常情况下也能清除。示例代码如下try { threadLocal.set(userInfo); // 业务逻辑 } finally { threadLocal.remove(); }另外要提醒的是不要在静态方法中跨线程使用ThreadLocal也不要把ThreadLocal的value设计成可变的共享对象。ThreadLocal解决的是线程间的变量隔离问题如果value本身是被多个线程共享的引用类型那线程隔离的效果就大打折扣了。6.1 子线程如何继承父线程的ThreadLocal如果我们希望主线程设置的ThreadLocal值能被子线程读取到直接使用ThreadLocal是做不到的因为子线程是独立的新线程有自己的ThreadLocalMap。这时候可以使用InheritableThreadLocal。InheritableThreadLocal继承了ThreadLocal它在线程创建时会把父线程的ThreadLocalMap中的值复制一份到子线程中。这样子在子线程中就能读取到父线程设置的值了。但是这里也有坑如果主线程在创建子线程之后修改了ThreadLocal的值子线程中的副本并不会自动更新。它只是创建时的一份快照之后两者互不影响。如果需要实现父子线程间的全量传递比如链路追踪需要把traceId传递到异步线程池中那就需要更强大的方案了。目前业内常用的做法是使用TransmittableThreadLocalTTL这是阿里开源的一个库能在线程池场景下实现上下文的无损传递。TTL会在任务提交时捕获当前线程的ThreadLocal值并在任务执行时重新设置到执行线程中执行完再恢复原值。这个工具在分布式链路追踪和异步并发场景中很有用如果简历上写到了相关项目面试时提到TTL是一个不错的加分项。7. 并发工具类CountDownLatch、CyclicBarrier、SemaphoreJDK 5在并发包中提供了几个非常实用的同步工具类CountDownLatch、CyclicBarrier、Semaphore、Exchanger。它们基于AQS实现面试中考察概率很高尤其喜欢考CountDownLatch和CyclicBarrier的区别。CountDownLatch是一个倒计时器它允许一个或多个线程等待其他线程完成一组操作后再继续执行。典型场景是主线程启动多个子线程并行执行任务等所有子任务都完成后主线程再汇总结果。使用方法是先new CountDownLatch(n)指定计数数量每个子线程执行完任务后调用countDown方法将计数减一主线程调用await方法阻塞等待直到计数变为0。CountDownLatch的计数是单向的减到0之后就无法重置了所以它只能使用一次。如果需要重复使用的场景应该用CyclicBarrier。CyclicBarrier是循环屏障它允许多个线程在某个屏障点互相等待直到所有线程都到达该屏障点才继续执行。和CountDownLatch相比CyclicBarrier是所有线程互相等待而不是一个线程等待其他线程。它的计数可以重置所以可以循环使用。CyclicBarrier还支持一个可选的Runnable参数当所有线程到达屏障点时会先执行这个Runnable中的代码。Semaphore是信号量它维护一组许可证线程在执行前必须先acquire获取许可证执行完release归还许可证。Semaphore常用于控制同时访问特定资源的线程数比如连接池的连接数限制、限流控制。公平模式的Semaphore能保证先获取许可证的线程先执行避免饥饿问题。这三种工具类的适用场景很容易混淆我整理一个对比帮助记忆工具类核心用途使用次数等待关系CountDownLatch一个线程等待多个线程完成只能使用一次单向等待CyclicBarrier多个线程互相等待到达屏障可循环使用互相等待Semaphore控制同时执行任务的最大线程数可重复使用获取/释放许可证它们三个在AQS中的实现方式也不同。CountDownLatch和Semaphore都是通过state字段来记录计数或许可证数量获取资源就是CAS递减state释放资源就是CAS递增state。CyclicBarrier的实现更复杂一些它内部使用了一个ReentrantLock和Condition来协调所有线程的等待和唤醒。7.1 实际项目中如何选用并发工具我做一个具体案例来演示这三个工具的实际选型思路。假设有一个数据统计任务要把10份数据文件分别统计再合并汇总出总结果。这个问题用CountDownLatch就很合适主线程开着10个子线程分别统计每个统计完countDown一次主线程await后做合并汇总。用CyclicBarrier也可以做但语义上不如CountDownLatch直接。再假设有这样一个场景一个小组里的5个成员需要同时开始进行一个事务流程的各阶段处理每个成员处理完自己的本阶段工作后需要等所有成员都完成当前阶段才能进入下一阶段。这个场景就是CyclicBarrier的经典用例每轮阶段结束就自动进入下一轮循环使用。而控制某个服务的最大并发调用数比如限定对第三方接口的调用并发不超过20则会用Semaphore。每次调用前acquire调用完release超出的请求就会阻塞等待。在真实的高并发项目中这三个工具经常组合使用。比如用Semaphore限流用CountDownLatch做任务编排用CyclicBarrier做分阶段并行计算。理解了每个工具的设计目标和适用场景写起来就自然顺畅。8. 多线程问题排查与实战技巧理论讲了这么多最终还是要落到实战。多线程开发中代码层面的问题往往是隐蔽且难以复现的死锁、线程泄漏、CPU飙高、内存溢出每一类都让人头疼。这节分享一下我在实际项目中排查多线程问题的思路和方法。死锁是最经典的多线程问题。产生死锁的四个条件是互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。排查死锁时最快的方式是使用jstack命令获取当前Java进程的线程快照它会自动检测死锁并打印出死锁的线程和锁的持有情况。先通过jps找到Java进程的PID然后执行jstack PID输出信息中如果包含Found one Java-level deadlock这样的字样就说明死锁了。随后信息中会详细展示两个线程分别持有什么锁、在等待什么锁。根据这些信息就能定位到是哪段代码导致的死锁。线上遇到CPU飙高的问题排查思路是先通过top -H -p PID找到CPU占用率最高的线程ID把它转成十六进制然后jstack PID | grep -A 20 nid0x十六进制值就能定位到具体线程的堆栈。如果堆栈停留在线程池的任务执行方法上那大概率是某个业务方法出现了死循环或者大量的无效计算。对于线程池线程数量异常增长的问题多半是任务队列容量设置不合理或者拒绝策略配置不当。优先检查ThreadPoolExecutor的四个关键指标当前活跃线程数、核心线程数、最大线程数、队列剩余容量。可以通过暴露metrics接口来定期采集这些状态配合监控告警在问题扩大前及时介入。我曾经排查过一个典型的线程泄漏问题线上服务运行几天后线程数线性增长最终内存溢出。jstack后发现大量线程阻塞在数据库连接获取处。究其原因是某个线程池中的任务里调用了数据库操作但因为事务配置错误导致连接没有释放后续任务全部积压在等待连接的队列中线程池不断创建新的线程来承接新任务。最终定位到的是连接泄漏问题而不是线程池配置问题。所以排查线程问题时要跳出线程本身去看它依赖的资源和下游系统。多线程的调试也很特殊断点调试多线程时往往会改变线程的执行时序原来能复现的并发Bug可能一下子就不出现了。我建议多用日志来辅助排查在关键节点打印线程名、时间戳、关键数据状态。给线程起好名字在这里就显得特别重要了打印出来的日志里如果能直接看到是哪个业务的线程在活动排查效率能提升一大截。还有一个小技巧在开发阶段可以通过设置JVM参数-XX:PrintConcurrentLocks来打印并发锁的信息结合jstack使用能更清楚地看到锁的竞争情况。另外如果项目用了Caffeine或者Redisson这类第三方库它们在日志里也会有关键线索比如阻塞队列深度、锁等待时间等。9. 面试答题技巧与高频八股速查最后这部分我结合自己面试和被面的经验整理一份高频Java线程八股的快问快答顺便聊一下怎么答才能从海量候选人中脱颖而出。先说答题思路。八股文的答案大多有个标准范式但面试官真正想看的是你有没有自己的理解。所以回答任何概念题我建议按照是什么-为什么-怎么用-有什么坑的四段式结构来展开。比如被问到synchronized锁升级不要只背偏向锁、轻量级锁、重量级锁三个名词而要说清楚为什么需要锁升级、JDK 6之前性能差的原因是什么、升级条件是什么、什么场景下会出现什么问题。这样的回答会让面试官觉得你是真的用过在真实项目中踩过坑有思考深度。常见问题速查如下线程创建方式有几种四种继承Thread、实现Runnable、实现Callable配合FutureTask、线程池。线程有哪些状态六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。注意RUNNABLE是Java层概念实际可能处于运行中或者等待CPU调度。sleep和wait区别sleep不释放锁wait释放锁sleep是Thread方法wait是Object方法sleep需要指定时间wait可以不指定也可以指定时间sleep用于暂停执行wait用于线程间通信。synchronized和ReentrantLock区别是否自动释放锁、是否可重入都可重入、是否支持公平锁、是否可中断、是否支持多个条件变量、底层实现JVM层面 vs JDK层面。volatile和synchronized的区别volatile不保证原子性、只能修饰变量、不会阻塞线程synchronized保证原子性、可修饰方法和代码块、未获取锁的线程会阻塞。ThreadLocal内存泄漏的解决方案使用完调用remove或者在ThreadLocal的Entry中通过弱引用来辅助回收。线程池的拒绝策略有哪些AbortPolicy抛异常、CallerRunsPolicy调用者执行、DiscardPolicy丢弃、DiscardOldestPolicy丢弃最老的。生产环境最常用AbortPolicy和CallerRunsPolicy。CountDownLatch和CyclicBarrier区别方向不同CountDownLatch是一个/多个线程等待其他线程CyclicBarrier是多个线程互相等待CountDownLatch只能使用一次CyclicBarrier可以循环使用CountDownLatch基于AQSCyclicBarrier基于ReentrantLock和Condition。CAS的优缺点优点是非阻塞、无需锁和上下文切换缺点是只能保证一个变量的原子操作、循环时间长开销大、存在ABA问题。ABA问题可以通过AtomicStampedReference加版本号解决。什么是线程安全多个线程访问共享数据时无论线程如何交替执行最终结果都符合预期且不产生数据破坏。有一个问题值得特别说一下为什么阿里规范强调不用Executors创建线程池除了前面说的无界队列和最大线程数问题还有一个隐性问题Executors里创建的线程工厂基本都是默认的线程名毫无辨识度出问题排查起来很麻烦。使用自定义ThreadPoolExecutor加自定义ThreadFactory是最佳的实践路径。最后的最后想跟准备面试的朋友说一句线程八股的知识点再多归根结底是帮助你建立对并发编程的完整认知框架。不要只是死记硬背建议把文中的代码都动手跑一遍改一改参数观察现象遇到问题再回头看理论印象会深刻得多。并发编程是一门实践性很强的学问理论只是地图真正踏过一遍坑才能成为自己手头的能力。