
Java面试里“面向对象”和“多线程”两个板块基本是绕不开的。我把这两块放在一起写是因为它们远不是独立的知识点——多线程编程里几乎处处都是面向对象设计的影子线程是对象任务是对象锁是对象连线程池本身也是对象。理解了这层关系再看多线程的各种创建方式、同步机制、并发工具很多东西就顺了。这篇文章我会从面向对象视角去拆解Java多线程的核心内容包括线程创建、生命周期、线程安全、锁机制、线程间协作、线程池以及面试里最常遇到的排查问题和实战经验。适合正在学习Java基础的同学、准备实习和校招面试的毕业生以及刚入行写业务代码、想系统性补并发短板的开发。看完不需要你背八股文而是能真正理解“为什么这么设计”顺便把面试题里那些高频考点一起消化掉。1. 多线程与面向对象先想明白这层关系1.1 为什么说多线程本身就是面向对象的先说一个常见误区很多人学多线程上来就背“继承Thread类、实现Runnable接口”然后一头扎进synchronized和锁里却从来没想过这些概念背后其实是一套典型的面向对象设计。Thread是对象Runnable是接口行为抽象Lock是对象线程池本身就是对象。也就是说Java把所有和线程、并发相关的底层能力全部封装成了“类”和“接口”。你拿到的永远是一组具有明确职责的对象而不是赤裸裸的系统调用。就像你在餐厅点菜你面对的是菜单对象接口而不是后厨的锅碗瓢盆操作系统线程原语。这其中最核心的设计思路是“职责分离”。Thread只负责“怎么执行”——管理线程生命周期、调度、启动而Runnable/Callable只负责“执行什么”——也就是具体的业务任务。如果把这两件事混在一个类里业务逻辑和线程控制逻辑耦合在一起后续想复用、想替换执行策略都很难受。这正是面向对象里“单一职责”和“组合优于继承”思想的落地。1.2 并发编程里的面向对象原则面向对象三大特征——封装、继承、多态在并发编程中都有直接体现封装共享数据的访问权限被收敛到对象的同步方法里。比如一个计数器类内部int count被private修饰外部必须通过synchronized方法或原子类来修改。封装做得好并发安全边界就清晰。接口抽象Runnable、Callable、ExecutorService、BlockingQueue这些接口把“任务的提交”“任务的执行”“队列的存取”解耦上层调用方只要依赖抽象不需要关心底层实现。模板方法AQSAbstractQueuedSynchronizer就是典型的模板方法模式框架定义好了同步状态的获取与释放流程把tryAcquire、tryRelease留给子类实现。所以学多线程如果只盯着语法会发现知识点很碎一会儿volatile一会儿CAS像在拼图。但如果你带着面向对象设计思路去看所有东西都有一条主线哪个对象负责什么、对象之间怎么协作、数据怎么安全地共享。建议你学任何一个并发类先问三个问题它封装了什么状态它提供了什么行为接口它依赖哪些其他对象想通了这三个问题等于拿到了一把通用钥匙。2. 线程创建与生命周期面试必考的第一关2.1 四种创建方式你会怎么选创建线程的常见方式有四种继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask、通过ExecutorService线程池提交。我直接给代码然后说清楚各自的适用场景。继承Thread类public class MyThread extends Thread { Override public void run() { System.out.println(线程执行中: Thread.currentThread().getName()); } } new MyThread().start();实现Runnable接口public class Task implements Runnable { Override public void run() { System.out.println(任务执行中: Thread.currentThread().getName()); } } new Thread(new Task()).start();Callable接口有返回值、能抛异常public class CallableTask implements CallableString { Override public String call() throws Exception { Thread.sleep(1000); return 任务结果; } } FutureTaskString futureTask new FutureTask(new CallableTask()); new Thread(futureTask).start(); String result futureTask.get(); // 阻塞等待结果线程池方式稍后单独讲。这里核心的问题是怎么选我的建议很明确能不用继承Thread就不用。原因很简单Java是单继承你继承了Thread这个类就不能再继承别的类了。而且继承Thread意味着你的业务类和线程控制代码强绑定任务逻辑没法复用。实现Runnable或Callable本质上把“任务对象”和“线程对象”分离了任务可以被线程池复用它也可以被多个线程执行灵活性不是一个量级。Callable比Runnable多一个返回值能力适合需要拿执行结果的场景比如并发调用外部接口收集结果。2.2 线程生命周期与状态转换线程的六个状态是面试高频考点NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人背了状态名但问到“阻塞和等待什么区别”就卡住了。NEWnew出来还没调用start()。RUNNABLE调用start()之后的状态包含了“就绪”和“运行中”两种子状态。Java把能否被CPU调度合在一起所以你看不到纯粹的“Running”状态。BLOCKED等着进入同步块或同步方法也就是等锁。只有synchronized会触发这种状态。WAITING进入无时间限制的等待通过Object.wait()、Thread.join()、LockSupport.park()触发。特征是没有超时时间必须等其他线程唤醒。TIMED_WAITING带超时的等待比如Thread.sleep(1000)、wait(1000)、join(1000)。TERMINATEDrun()执行完毕或异常终止。重点区分BLOCKED和WAITINGBLOCKED是在等一把别人持有的锁主动权在别人手里你只能干等着WAITING是你主动进入等待状态等的是“另一个线程的通知/条件”。后者更像社会协作前者更像抢一个被占用的资源。线程状态的切换我最容易踩坑的点是sleep和yield。很多新手以为sleep(0)和yield等价其实yield是提示调度器让出CPU线程还是RUNNABLE状态随时可能被再次调度sleep会进入TIMED_WAITING明确让出CPU一段时间。在写自旋之类的逻辑时sleep(0)配合yield的调试技巧很好用但生产代码里要格外小心别依赖这种调度行为。2.3 创建方式的面向对象复盘回到面向对象视角重新审视四种创建方式会发现一个清晰的设计脉络Thread类本身是“执行器”Runnable/Callable是“任务协议”FutureTask是“结果容器”ExecutorService是“任务调度器”。每一个角色都只有单一职责。这就引出一个很实际的建议写多线程代码前先画一下对象职责。哪个对象是任务任务的数据放在哪里结果由谁取异常由谁处理如果这些角色混在一个类里多半是设计出了问题。比如说某些同学喜欢在Service里new Thread并start把业务逻辑、线程创建、结果处理全堆在一起后续想换线程池、想加监控、想控制并发度都会非常痛苦。3. 线程安全问题并发编程的核心战场3.1 synchronized的三种用法与锁对象线程安全的核心问题是多个线程同时访问共享可变数据导致的竞态条件。Java最基础的解决方案就是synchronized。它有三种用法// 同步实例方法锁的是this public synchronized void increment() { count; } // 同步静态方法锁的是Class对象 public static synchronized void staticIncrement() { staticCount; } // 同步代码块锁指定对象 public void increment() { synchronized (this) { count; } }三个用法的锁对象分别是this、当前类的Class对象、你指定的任意对象。这个“锁对象”概念特别重要synchronized锁的是对象头里的Monitor不是锁代码块本身。不同线程想要互斥必须竞争同一把锁也就是同一个对象监视器。这也是为什么很多人写锁锁不住——两个线程用的是不同的锁对象互相之间根本没有互斥关系。Monitor的机制其实就是一个“门卫”模型入口处有计数器线程进入时count1并拿锁退出时count-1释放锁如果已经持有锁的线程再次进入同步块count继续累加这就是可重入性。synchronized的可重入性保证了一个线程可以多次进入同一个锁保护的区域不会自己锁死自己。3.2 锁升级过程从偏向锁到重量级锁HotSpot对synchronized做了大量优化锁状态依次是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。这个升级过程只用过一次现场调试理解它对于调优帮助很大。偏向锁的思路是在无竞争场景下锁倾向于同一个线程重复获取于是把线程ID记录在对象头里只要没有其他线程竞争就不做真正的加锁操作。一旦出现竞争会升级成轻量级锁自旋锁线程通过自旋CAS尝试获取锁用CPU时间换取线程阻塞切换的开销。如果自旋超过一定次数或者竞争线程太多就升级为重量级锁进入操作系统内核的互斥机制未抢到锁的线程被挂起阻塞。JDK 15之后偏向锁被默认禁用维护成本太高很多场景收益没那么明显。所以现在说锁升级更多是理解轻量级锁和重量级锁的权衡自旋适合锁临界区很短的场景阻塞适合锁持有时间较长的场景。写代码时能传递的实用经验是尽量缩小同步块范围不要在锁里面做IO、远程调用、长时间计算否则自旋锁会浪费大量CPU。3.3 volatile与可见性volatile是比synchronized更轻量的同步手段它解决两个问题可见性和有序性禁止指令重排序。Java内存模型JMM规定线程操作共享变量时数据先读到工作内存CPU缓存再写回主内存。一个线程改了变量另一个线程可能还在用自己缓存里的旧值这就是可见性问题。volatile关键字的底层是让写操作强制刷新到主内存并让其他线程的缓存行失效从而保证一个线程的修改能被其他线程及时看到。但volatile不保证原子性。典型反例是count这看起来是一条语句实际上是读取-修改-写入三步操作volatile只能保证每一步的可见性不能保证三步的连续执行。所以多线程累加计数器volatile是无论如何都不够用的必须用AtomicInteger或者synchronized。给一个volatile的经典正确用法状态标志位。public class ShutdownHook { private volatile boolean isRunning true; public void shutdown() { isRunning false; } public void work() { while (isRunning) { // 执行任务 } } }这个场景里标志位的读写本身是原子的也不需要复合操作volatile提供可见性就足够了。如果误用volatile去保护count这种复合操作线上会出现数据丢失而且难以复现。3.4 原子类与CAS原理AtomicInteger、AtomicLong、AtomicReference这些原子类底层依赖CASCompare And Swap。CAS有三个操作数内存位置V、预期旧值A、要写入的新值B。只有当V中的值和A相等时才把V更新为B否则说明有别的线程先改了操作失败并重试。CAS的优点是轻量、无需阻塞性能优于悲观锁缺点也明显——ABA问题。就是变量从A变成B又变回A但其他线程的CAS比较发现“还是A”误以为没有被修改过。解决方式是加版本号或时间戳AtomicStampedReference就是干这个的。AtomicInteger count new AtomicInteger(0); count.incrementAndGet(); // 内部就是CAS自旋真实使用中原子类适合计数器、资源序号生成、并发安全的布尔状态等细粒度场景。它的局限性是无法保护多个变量之间的复合一致性比如“先判断余额再扣款”这种需要多步骤一致的逻辑CAS就无能为力了得用锁或其他手段。3.5 Lock体系与AQSLock接口及其实现类ReentrantLock是synchronized之外的另一种锁方案。ReentrantLock提供synchronized不具备的能力可中断地获取锁lockInterruptibly、带超时的尝试加锁tryLock、公平/非公平策略、多个Condition条件等待队列。底层核心是AQSAbstractQueuedSynchronizer。AQS内部维护了一个volatile int state以及一个双向链表结构的等待队列CLH队列。获取锁的逻辑就是CAS修改state修改成功则持有锁失败则把线程包装成节点挂到队列尾部阻塞。ReentrantLock公平与否的区别在于非公平锁在发现锁被释放时当前线程会直接尝试CAS抢锁不排队公平锁严格按FIFO顺序从队列头部唤醒线程。性能上非公平锁通常更好因为减少了一次上下文切换但存在线程饥饿的可能。实际使用中我建议默认用非公平锁除非业务上有严格公平性要求。AQS本身是抽象类它内部用的是模板方法模式acquire流程搭好了骨架把tryAcquire、tryRelease留给子类实现。ReentrantLock实现tryAcquire来操作stateSemaphore实现tryAcquireShared来操作许可数CountDownLatch实现tryAcquireShared来计数归零判断。理解了AQS等于理解了半个java.util.concurrent包。4. 线程间协作通信与并发工具4.1 wait/notify的正确姿势线程协作的场景生产者消费者是经典模型。Java提供Object.wait()和Object.notify()实现等待通知机制但使用限制很多必须在同步块内调用而且必须用同一把锁。经典模板长这样synchronized (lock) { while (条件不满足) { lock.wait(); } // 条件满足了执行后续操作 lock.notifyAll(); }注意两件事第一判断条件必须用while而不是if。原因是存在“虚假唤醒”即使没有线程调用notifywait也可能被唤醒。用if的话唤醒后直接往下执行可能条件还是不满足用while能强制重新检查条件这是多线程教科书反复强调的细节也是很多并发Bug的根源。第二唤醒用notifyAll而不是notify。notify只唤醒一个线程如果唤醒的时机不对可能造成死锁。比如两个线程在等不同条件你没有条件队列的概念只唤醒了一个错误的对象那等预期条件的线程永远醒不了。notifyAll虽然可能造成“惊群”浪费一点性能但正确性优先。4.2 Lock的Condition精确通知synchronized的wait/notify只有一个隐式条件队列做不到精确唤醒。ReentrantLock的Condition可以创建多个条件队列实现更细粒度的控制。典型例子是有界缓冲区当缓冲为空时消费者等待“非空”条件当缓冲满时生产者等待“非满”条件。Lock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); public void put(E e) throws InterruptedException { lock.lock(); try { while (buffer.size() capacity) { notFull.await(); } buffer.add(e); notEmpty.signal(); } finally { lock.unlock(); } } public E take() throws InterruptedException { lock.lock(); try { while (buffer.isEmpty()) { notEmpty.await(); } E e buffer.remove(); notFull.signal(); return e; } finally { lock.unlock(); } }这段代码的精髓在于生产者和消费者等待的是不同的Condition唤醒时也精确唤醒对方不会误伤同类。Condition支持多个队列等于把协作维度扩宽了代码可读性和可维护性比wait/notify强很多。4.3 并发工具类CountDownLatch、CyclicBarrier、Semaphore这三个工具类面试出现频率极高关键是分清各自的使用场景。CountDownLatch用于“一个或多个线程等待其他N个线程完成”。典型场景是主线程等待多个子任务都执行完毕再汇总。它的计数只能减不能加用完之后不能复用。CountDownLatch latch new CountDownLatch(3); // 每个子线程完成后调用 latch.countDown(); // 主线程阻塞等待 latch.await();CyclicBarrier用于“N个线程互相等待全部到达屏障点后一起继续”。人话解释就是“集齐N个人再出发”。它可循环使用跑步比赛、分批并发处理任务都是这类场景。CountDownLatch是“等别人走完”CyclicBarrier是“互相等待后一起走”一个是终点线一个是起跑线。CyclicBarrier barrier new CyclicBarrier(3, () - { System.out.println(三个人都到了开跑); }); // 每个线程执行到barrier.await()时等待Semaphore信号量用来控制同时访问某个共享资源的线程数相当于“限流闸门”。数据库连接池、文件句柄管理、限流器里经常用到。acquire()拿许可release()释放许可内部也基于AQS。这三个工具类在生产中都能替代很多手写wait/notify的复杂逻辑而且语义一目了然强烈建议优先使用现成的并发工具不要自己发明一套协作机制。4.4 阻塞队列生产消费者的终极武器有了BlockingQueue手写生产者消费者基本可以退休了。ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue都是线程安全的队列put()在队列满时自动阻塞take()在队列空时自动阻塞。BlockingQueueTask queue new LinkedBlockingQueue(10); // 生产者 new Thread(() - { for (int i 0; i 100; i) { queue.put(new Task(i)); } }).start(); // 消费者 new Thread(() - { while (true) { Task task queue.take(); // 执行任务 } }).start();它之所以能阻塞内部依然是锁和Condition结合实现的。所以前面讲的Lock和Condition在队列源码里都有具体印证。搞懂了阻塞队列的源码对AQS、锁、条件队列的理解会通透一个档次。面试官问你“阻塞队列有没有界”也能立刻反应过来ArrayBlockingQueue是有界的LinkedBlockingQueue默认无界时容易内存暴涨SynchronousQueue不存储元素、直接交接。5. 线程池生产环境的最佳实践5.1 为什么建议手动配置线程池Executors工具类提供了几个快捷方法newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool面试书喜欢拿它们举例。但阿里巴巴开发规范明令不建议Executors创建线程池我也在实际项目中踩过坑。newFixedThreadPool用的是无界LinkedBlockingQueue任务无限堆积峰值期可能直接导致OOMnewCachedThreadPool允许创建Integer.MAX_VALUE个线程疯狂提交任务时会创建大量线程内存和CPU都会被拖垮。生产环境必须自己new ThreadPoolExecutor完全控制每个参数。5.2 线程池七大参数与执行流程线程池的核心参数是corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。corePoolSize常驻线程数即使空闲也不会回收除非allowCoreThreadTimeOut。maximumPoolSize线程数上限超过这个数新任务只能走拒绝策略。keepAliveTime非核心线程空闲存活时间。workQueue任务队列缓冲来不及处理的任务。threadFactory线程工厂建议设置线程名称为业务前缀方便日志排查。handler队列也满了之后的拒绝策略。提交任务的执行流程要烂熟于心当前线程数小于corePoolSize创建核心线程执行任务。线程数已达corePoolSize任务进入workQueue排队。队列已满且当前线程数小于maximumPoolSize创建非核心线程执行任务。队列满且线程数已达maximumPoolSize执行拒绝策略。这个流程的每一步都有讲究。比如混合场景中希望“少创建线程、多排队消化”就把corePoolSize设置大一些希望“宁可多加线程也别排队”就把队列设短一点。四种拒绝策略AbortPolicy默认策略直接抛RejectedExecutionException。CallerRunsPolicy任务回退给提交任务的线程执行。这是我最推荐的一种既能不让任务丢失又天然实现了“提交慢下来”的背压效果。DiscardPolicy悄悄丢弃任务绝不推荐任务消失无感知。DiscardOldestPolicy丢弃队列头部最老的任务再尝试提交适合允许丢旧任务的场景比如实时性要求高的消息推送。自定义线程工厂的代码很简单但价值很大ThreadPoolExecutor executor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - { Thread t new Thread(r); t.setName(order-center-pool- t.getId()); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );日志或堆栈里出现order-center-pool这个前缀你能瞬间定位是哪个业务线程出的问题。不设置的话线程名全是“pool-1-thread-1”定位问题全靠猜。5.3 线程池使用中的隐藏坑submit和execute的异常处理差异是我带过的人里最容易犯的错。execute直接提交任务run方法里如果抛异常线程会被终止异常打印到控制台线程池会创建新线程继续工作。submit提交的Future异常会被捕获并封装在Future里如果你不调future.get()异常会被静默吞掉整个线程池死气沉沉地继续跑但业务却悄悄失败了。所以用submit之后要么在业务代码里try-catch要么必须有对应的future.get()操作否则异常石沉大海。我见过一个定时任务用submit提交结果连续一周数据没刷新全是因为异常被吞掉没人发现。还有一个坑是线程池线程存活时间与核心线程的关系。默认核心线程不会超时回收如果你希望低峰期释放核心线程得设置allowCoreThreadTimeOut(true)并配合keepAliveTime否则你的corePoolSize设置多大常年就占用多少个线程。线程池的关闭也要规范。shutdown()是平缓停止不再接收新任务但会等已提交任务完成shutdownNow()是暴力停止尝试中断所有正在执行的任务并返回未执行的任务列表。生产环境要根据业务容忍度选一般用shutdown()配合awaitTermination等待一定时间避免直接kill。6. 常见问题与排查技巧实录6.1 死锁的产生与排查死锁的经典场景线程A持有锁1等待锁2线程B持有锁2等待锁1两个线程互相等谁都不放手。四个必要条件——互斥、持有并等待、不可剥夺、循环等待缺一不可。排查工具是JDK自带的三板斧jps # 找到目标Java进程的PID jstack PIDjstack输出中能看到明确的死锁提示“Found one Java-level deadlock”并列出循环等待的线程和锁ID。生产环境如果遇到服务卡死不要急着重启先jstack拍一下现场信息特别宝贵。预防死锁的常见办法多个锁加锁顺序一致约定死锁“先A后B”的次序。使用tryLock加超时拿不到锁就放弃避免无限等待。尽量缩小锁粒度一个同步方法里别同时锁多个对象。6.2 并发Bug的定位思路并发Bug往往偶发、难复现。我的一般排查思路分四步第一复现。如果复现不了先检查是否有共享可变状态。把代码里所有static、实例字段、map、list先列出来看哪些会被多线程同时读写。第二加日志。在读写共享变量的地方打上线程名和值结合时间戳判断顺序。第三抓现场。用jstack看所有线程状态如果大量线程BLOCKED说明有锁竞争激烈如果WAITING且长期不唤醒大概率是notify丢了或者条件判断出错。第四代码审查。重点看wait/notify是否成对出现、锁对象是否一致、复合操作是否被原子保护。实际遇到最多的问题不是高深的理论而是把非线程安全的集合傻乎乎地暴露给多线程。比如HashMap直接作为缓存被多个线程put轻则丢数据重则CPU飚到100%甚至死循环JDK8以前扩容时并发put会导致环形链表。现在谁敢在并发场景用HashMap我都会建议换成ConcurrentHashMap。6.3 面试高频题梳理我把问得最多的几个对比题型整理成表方便你快速过一遍对比项sleepwait来源Thread静态方法Object实例方法锁不释放锁释放锁使用范围任意位置必须在同步块内状态TIMED_WAITINGWAITING/TIMED_WAITING对比项synchronizedReentrantLock锁释放自动释放手动lock/unlockfinally里release锁公平非公平可公平可非公平中断不可中断lockInterruptibly可中断条件队列单一隐式队列多个Condition精确控制底层MonitorAQSCAS对比项volatilesynchronized可见性保证保证原子性不保证保证阻塞不阻塞可能阻塞适用范围状态标志、单写入复合操作、临界区保护还有一个必问的“线程池最多能同时执行多少个任务”答案是核心线程队列容量最大线程数和最大线程数之间的关系要结合流程说这也是考察对执行流程理解是否清晰的关键。6.4 ThreadLocal的内存泄漏陷阱ThreadLocal是线程隔离的利器但也容易踩内存泄漏的坑。原理是每个Thread内部有一个ThreadLocalMapkey是ThreadLocal实例的弱引用value是强引用。如果ThreadLocal使用完毕但线程还活着尤其是线程池场景key会被GC回收成nullvalue却永远无法被访问造成内存泄漏。解法非常简单粗暴每次用完之后调用remove()。ThreadLocalObject holder new ThreadLocal(); try { holder.set(obj); // 业务逻辑 } finally { holder.remove(); }线程池复用线程ThreadLocal的数据还会残留到下一次任务执行这比内存泄漏更隐蔽——看到上一笔业务的数据串到了下一笔排查起来非常痛苦。所以我建议ThreadLocal只用来传递上下文用完必须清干净别把它当成万年全局变量。我个人在实际项目中还有一个习惯所有涉及线程池提交的任务内部都会包一层try-catch捕获异常打日志。这不是为了吞异常而是确保异常有迹可循配合日志采集系统能快速定位。多线程的坑往往不是理论搞不懂而是异常被吞、状态看不到、日志对不上导致你在一片黑暗里调Bug。把可见性做好把工具用对比记住多少条面试题重要得多。最后分享一个小技巧调试多线程问题时给不同线程设置有意义的名字比如parse-thread-1、flush-thread-1日志里只要打线程名一条日志就能定位到是哪个任务链出了问题。看似微不足道但线上排查时这一个细节能帮你省下大半天时间。