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原则需要了解。这个原则定义了多线程操作之间的内存可见性规则,是JMM(Java内存模型)的核心内容。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之后,可以创建两个Condition:notEmpty和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 List<Integer> 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 List<Integer> 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并发编程的里程碑,而AQS(AbstractQueuedSynchronizer)是这个并发包的灵魂。
ReentrantLock是最常用的Lock实现类,它和synchronized相比有以下几个核心优势。一是支持公平锁和非公平锁两种模式,synchronized只能是非公平的。公平锁按照线程请求锁的顺序来获取锁,保证先来先得,但吞吐量会低一些;非公平锁允许线程插队,吞吐量高但可能出现线程饥饿。二是支持可中断获取锁,调用lockInterruptibly方法后,等待锁的过程中可以被其他线程中断,而synchronized等待锁时是不能响应中断的。三是支持超时获取锁,调用tryLock(long timeout, TimeUnit unit)方法,在指定时间内获取不到锁就会返回false,可以避免无限期等待。四是支持多个条件变量,通过newCondition可以创建多个Condition实例。
这里说一个面试高频题:ReentrantLock和synchronized的区别是什么?除了上面说的四点,还要补充底层的区别:synchronized是JVM层面实现的,基于监视器锁;ReentrantLock是JDK层面实现的,基于AQS;synchronized在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 读写锁与StampedLock
ReadWriteLock读写锁是一种特殊类型的锁,它维护了一对锁:一个读锁和一个写锁。读锁之间可以共享,多个线程可以同时持有读锁;写锁是排他的,一个线程持有写锁时其他线程既不能读也不能写。这种设计非常适用于读多写少的场景,比如缓存系统、配置加载等。
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,公式是Ncpu+1。加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-2",jstack输出一眼就能看出是哪个业务模块的线程出了问题。如果所有线程都叫"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是弱引用(WeakReference),value是强引用。当外部没有对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传递到异步线程池中,那就需要更强大的方案了。目前业内常用的做法是使用TransmittableThreadLocal(TTL),这是阿里开源的一个库,能在线程池场景下实现上下文的无损传递。TTL会在任务提交时捕获当前线程的ThreadLocal值,并在任务执行时重新设置到执行线程中,执行完再恢复原值。这个工具在分布式链路追踪和异步并发场景中很有用,如果简历上写到了相关项目,面试时提到TTL是一个不错的加分项。
7. 并发工具类:CountDownLatch、CyclicBarrier、Semaphore
JDK 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 "nid=0x十六进制值",就能定位到具体线程的堆栈。如果堆栈停留在线程池的任务执行方法上,那大概率是某个业务方法出现了死循环或者大量的无效计算。
对于线程池线程数量异常增长的问题,多半是任务队列容量设置不合理或者拒绝策略配置不当。优先检查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基于AQS,CyclicBarrier基于ReentrantLock和Condition。
CAS的优缺点:优点是非阻塞、无需锁和上下文切换;缺点是只能保证一个变量的原子操作、循环时间长开销大、存在ABA问题。ABA问题可以通过AtomicStampedReference加版本号解决。
什么是线程安全:多个线程访问共享数据时,无论线程如何交替执行,最终结果都符合预期且不产生数据破坏。
有一个问题值得特别说一下:为什么阿里规范强调不用Executors创建线程池?除了前面说的无界队列和最大线程数问题,还有一个隐性问题:Executors里创建的线程工厂基本都是默认的,线程名毫无辨识度,出问题排查起来很麻烦。使用自定义ThreadPoolExecutor加自定义ThreadFactory是最佳的实践路径。
最后的最后,想跟准备面试的朋友说一句:线程八股的知识点再多,归根结底是帮助你建立对并发编程的完整认知框架。不要只是死记硬背,建议把文中的代码都动手跑一遍,改一改参数观察现象,遇到问题再回头看理论,印象会深刻得多。并发编程是一门实践性很强的学问,理论只是地图,真正踏过一遍坑,才能成为自己手头的能力。