资讯详情

资讯详情

Java线程八股详解:从原理、取舍到面试实战

先说个前提交代一下写作背景组里今年招人我把候选人的简历翻了一遍十个人里有八个都在“熟练掌握Java多线程”那一栏打了勾。可真到了现场聊到wait()和sleep()的区别、聊到ThreadPoolExecutor的参数怎么设不少人就开始含糊其辞。八股背得滚瓜烂熟一问到“为什么”就露馅。所以这篇东西我是这样定位的把所有常见的Java线程八股点摊开每条都说到“能背出来”背后的“原理和取舍”既有面试层面怎么答也有实际写代码时怎么用。不追求冷门只覆盖真正高频、真正干活会碰到的内容。如果你正在准备Java面试或者写并发代码时脑子里总有几处模糊这篇应该能帮上忙。1. 线程的创建方式四种写法背后的设计取舍1.1 继承Thread类为什么不是首选教科书上永远把四种创建方式排在前面继承Thread、实现Runnable、实现Callable配合FutureTask、使用线程池。这个题谁都会背但真正值得问的是“为什么推荐接口方式而不是继承Thread”。核心原因只有一个Java是单继承。你继承了Thread这个类就不能再继承别的业务基类。更关键的是继承Thread会把“线程”和“任务”混在一起。线程是执行载体任务是业务逻辑载体两者本来就该分开。如果你把业务逻辑直接写在Thread子类里以后想把这个逻辑交给线程池执行才发现根本没法复用。// 不推荐的写法线程和任务耦合在一起 public class UploadTask extends Thread { Override public void run() { // 上传逻辑 } } // 推荐的写法任务和线程解耦 public class UploadTask implements Runnable { Override public void run() { // 上传逻辑 } }1.2 Runnable与Callable的分工差异Runnable的run()方法没有返回值、不能抛受检异常Callable的call()方法有返回值、可以抛异常。这也是线程创建问题里必考的延伸点。但它们本质上是同一个东西的两种形态Runnable适合“只做事情不需要回执”的任务Callable适合“需要拿到结果”的任务而FutureTask这个类特别有意思它同时实现了Runnable和Future接口。也就是说它既能丢给线程执行又能在后面通过get()取回结果。我第一次看这个类的时候觉得设计得很别扭后来才知道这是典型的外观模式把执行和获取结果两件事封装在一起。1.3 面试必问的隐藏考点直接调run()会怎样这块还有个高频考点基本每次面试都会被问到“我调用了thread.run()会启动新线程吗”答案是不会。start()方法会让JVM创建新的执行栈并调用run()直接调用run()只是一次普通的方法调用在调用方线程里同步执行。这个问题十个人里能答对五六个但能把“为什么start()能创建线程而run()不能”讲明白的人就很少了。start()会触发JVM底层的新线程创建机制而run()就是Thread类里一个普通的实例方法谁调用就在谁那儿跑。还有一个细节需要记住一个线程对象不能调用两次start()第二次会抛IllegalStateException。但run()你爱调几次调几次它就是个普通方法。2. 线程生命周期六种状态与面试最爱挖的状态转换坑2.1 Java定义了哪六种状态线程状态是线程八股里的送分题也是送命题。Thread.State枚举里定义了六个状态NEW新建、RUNNABLE就绪/运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING超时等待、TERMINATED终止。这里最大的误解在于RUNNABLE。很多刚学的人以为RUNNABLE只是“就绪”线程真正跑起来应该叫“运行中”。但JVM层面并没有区分。只要线程没有等待锁、没有主动等待、没有被挂起它都算RUNNABLE。至于它此刻是拿着CPU在执行指令还是在排队等CPU时间片JVM不管也不关心。所以面试时如果有人问“线程运行中是什么状态”答案不是“Running”而是RUNNABLE。2.2 BLOCKED与WAITING的核心区别六个状态里BLOCKED和WAITING最容易被混淆也最值得说清楚。简单粗暴的理解是BLOCKED线程想进入synchronized代码块/方法但锁被别人持有进不去。它被动地等着锁释放。WAITING线程在主动等一个信号。比如调用了wait()、join()、LockSupport.park()。它需要被别人的notify()/unlock唤醒才能继续。区别在于是“被动等锁”还是“主动等信号”。BLOCKED是别人占着资源你不爽但只能忍着WAITING是你自己选择停下来等消息。要能体现出这个主动性差异才说明你真的理解状态机。TIMED_WAITING和WAITING的区别更简单——带不带超时时间。sleep(1000)、wait(1000)、join(1000)、LockSupport.parkNanos()都在状态里带上“时间到了自动恢复”的退路所以叫TIMED_WAITING。2.3 一次状态变化的完整走读我建议你把下面这段代码当作状态切换的最小复现来跑一遍比单纯背状态图清晰得多public class StateDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { synchronized (StateDemo.class) { try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); System.out.println(t.getState()); // NEW t.start(); System.out.println(t.getState()); // RUNNABLE Thread.sleep(100); Thread blocker new Thread(() - { synchronized (StateDemo.class) { // 占用锁什么都不干 } }); blocker.start(); Thread.sleep(100); System.out.println(blocker.getState()); // BLOCKED因为锁被 t 持有 Thread waiter new Thread(() - { synchronized (StateDemo.class) { try { StateDemo.class.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); waiter.start(); Thread.sleep(100); System.out.println(waiter.getState()); // WAITING } }t在sleep期间是TIMED_WAITINGblocker因为抢不到锁是BLOCKEDwaiter调用wait()之后是WAITING。把这三个状态放到同一个程序里对比印象会非常深。3. synchronized与volatile锁到底是什么可见性又是怎么回事3.1 锁的三种用法与锁定的对象synchronized有三种写法修饰实例方法、修饰静态方法、修饰代码块。它们锁的东西不一样这个考点几乎必考写法锁的对象修饰实例方法this即当前对象修饰静态方法当前类的Class对象同步代码块括号里指定的任意对象很多人背得住这张表但不理解“锁对象”意味着什么。锁对象的意义在于唯一的身份标识。说白了两个线程能不能互斥取决于它们锁的是不是同一个对象。你要是两个线程各自用new Object()当锁那谁也管不着谁锁了个寂寞。synchronized还有一个重要特性是可重入同一个线程已经持有锁再进入同一个锁保护的代码块不会死锁。底层原因是每个锁对象都关联一个持有者和计数器同一条线程重入时计数器加一不需要重新申请。3.2 锁升级从偏向锁到重量级锁的演进逻辑synchronized在JDK 1.6之后引入了锁升级方向是无锁 - 偏向锁 - 轻量级锁 - 重量级锁。面试官问锁升级本质上是想看你有没有理解“为什么需要这几种状态”。答案是性能的权衡大多数情况一个锁其实只被一个线程反复访问。此时直接上互斥锁纯属浪费于是有了偏向锁相当于“这个锁默认归我我再来不用抢”。后来渐渐有少量线程竞争但竞争不激烈这时候用CAS自旋尝试获取锁就有了轻量级锁避免立刻进入内核态。如果竞争激烈到CAS都不断失败再自旋就是浪费CPU于是膨胀为重量级锁真正挂起线程、依赖操作系统的线程调度。锁只能升级、不能降级。所以写代码时不要觉得synchronized很重在现代JDK下无竞争时它的开销非常小。3.3 volatile可见性与有序性但不解决原子性说完synchronizedvolatile是紧接着的高频题。volatile做的事情只有两件保证变量在多线程间的可见性禁止指令重排序它不做的是原子性。volatile int count再多人抢着count结果依然可能是错的因为count是“读-改-写”三步volatile管不了中间被打断的问题。这个考点最经典的落地场景是双重检查锁单例。为什么单例字段要加volatile因为instance new Singleton()底层有三步分配内存、初始化对象、把引用指向内存。如果不加volatile编译器/CPU可能把后两步重排另一个线程看到引用不为空就使用了未初始化完成的对象。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; } }这段代码背下来只是入门能讲清楚“为什么加volatile”才是面试官想听的。4. 线程通信与协作wait/notify/sleep/join的边界问题4.1 wait()和notify()为什么要配合synchronized使用先说结论wait()和notify()必须在持有对应对象锁的环境里调用否则抛IllegalMonitorStateException。很多人不理解这层限制的来由。wait()的语义是“当前线程条件不满足我先把锁让出来等条件满足再拿回来”。那问题来了你连锁都没有怎么“让”另外wait()判断条件往往和共享资源有关如果不在锁保护内判断条件和修改条件之间就可能被插队。再往深一层说wait()释放的是当前对象的锁不是所有锁。如果一个线程持有两把锁在A锁上wait()它只释放A锁B锁还在它手里。这种细节写并发代码时很容易踩坑。4.2 sleep()和wait()的区别这是线程八股里最经典的一题也是面试官最爱连环追问的一题。核心差异可以列成一张表维度sleep()wait()是否释放锁不释放释放属于谁Thread类静态方法Object类实例方法是否需要锁不需要必须在synchronized中唤醒方式时间到自动醒notify()/notifyAll()/带超时版本自动醒这题的深层逻辑是sleep是“我自己困了歇一会儿接着干不想把资源让给别人”wait是“我等某个条件先把锁放出去让别人有机会改变条件”。另外要记得wait()被唤醒后不会立刻执行。它要从等待队列里加入到锁的竞争队列抢到锁之后才能从wait()调用处返回。所以wait()之后的代码不能假设条件一定满足了必须再用循环重新检查条件。这就是所谓的while(!condition) wait()模式而不是if(!condition) wait()。4.3 join与yield的现实意义join()底层其实是用wait()实现的等目标线程终止后当前线程被唤醒。它解决的问题很明确主线程想等子线程执行完再继续。比如做单元测试时等异步任务结束或者批处理时需要等几个线程都到位。yield()则是个很微妙的家伙。它的作用是提示线程调度器“我可以让出CPU了”但调度器听不听是另一回事。就算让出了CPU线程也还是RUNNABLE状态随时可能被再次调度。在我的实践经验里几乎没有任何业务代码需要主动调yield()它更像是为了某些轮询场景留的后门。面试时能答出“yield只是暗示不保证效果”就已经过关了。5. 线程池参数与执行流程背完七参数之后你还得会答这几问5.1 ThreadPoolExecutor的七大参数线程池是Java线程八股的大头几乎场场必考。ThreadPoolExecutor最完整的构造方法有七个参数参数作用corePoolSize核心线程数常驻线程maximumPoolSize最大线程数keepAliveTime非核心线程空闲存活时间unit存活时间单位workQueue任务队列threadFactory线程工厂定义线程名/是否守护线程handler拒绝策略一个常见的误解是“线程池创建时就会立刻开满核心线程”。默认情况下它不是要等任务提交后才慢慢创建到corePoolSize当然也可以用prestartAllCoreThreads()强行预热。5.2 任务的完整流转路径什么先做、什么后做线程池的执行流程是面试高频题必须能顺畅复述提交任务先判断当前线程数是否小于corePoolSize小于就创建核心线程执行。线程数已经达到corePoolSize把任务丢进队列等待。队列满了再判断是否小于maximumPoolSize小于则创建非核心线程执行。线程数已经到maximumPoolSize且队列也满了触发拒绝策略。这里的关键认知是核心线程满了之后新任务先进队列而不是立刻扩线程。这是线程池防止资源被无限制占用的核心手段。很多人面到这道题都能把顺序背出来但换个角度问“为什么核心线程满了不直接扩容而是要先排队”就答不上来。答案在线程池的设计哲学任务是存在波动的。把任务放进队列是让池子用最小资源去消化压力如果一有压力就扩线程高峰过去后这些线程就闲置浪费了。排队相当于给了系统一个缓冲带。5.3 拒绝策略与工程实践四种拒绝策略也要分清AbortPolicy直接抛异常。默认策略最安全但也最粗暴。CallerRunsPolicy谁提交的任务谁自己跑。这是我最推荐的策略能降速削峰而且不会丢任务。DiscardPolicy静默丢弃最新任务。DiscardOldestPolicy丢弃队列中最旧的任务再提交新任务。我见过不少项目直接默认AbortPolicy一旦流量溢出就是一堆异常日志。与其抛异常给用户看不如用CallerRunsPolicy让提交线程自己吃掉压力整个系统反而更平滑。5.4 为什么不建议用Executors的快捷工厂方法有些面试题会问Executors.newFixedThreadPool()和newSingleThreadExecutor()有什么问题它们的底层都用了无界的LinkedBlockingQueue队列可以无限堆积请求极端情况内存被占满。newCachedThreadPool()则相反最大线程数是Integer.MAX_VALUE来了任务就开新线程线程数无上限也能拖垮系统。所以我一直建议自己构造ThreadPoolExecutor把核心数、队列长度、拒绝策略都显式定下来。只有参数是自己手写的心里才清楚系统承受不住时会以什么方式失败。6. ThreadLocal与内存泄漏一个用不好就能坑全组人的机制6.1 ThreadLocal的核心结构每个线程一张MapThreadLocal也是高频考点。它表面是一个“全局变量”实际原理是每个Thread对象内部都维护了一个ThreadLocalMap。当你调用set(value)时实际是以当前ThreadLocal对象为key往当前线程自己的Map里存值get()则是用当前ThreadLocal对象去当前线程的Map里取值。所以ThreadLocal从来不是“给所有线程共享变量”而是“给每个线程发一份独立副本”。各线程之间互不干扰省掉了对共享变量加锁的成本。这也是为什么ThreadLocal大量用于存储上下文信息比如请求ID、登录用户、数据库连接。一次请求从头到尾在同一个线程里流转放进去就能到处取多方便。6.2 键弱引用、值强引用内存泄漏的根源ThreadLocal的内存泄漏问题是面试中必延展的话题也是实际生产环境经常出现的故障。ThreadLocalMap的Entry继承了WeakReferencekey是ThreadLocal的弱引用value是强引用。问题就在这如果ThreadLocal对象在外面没有强引用了GC时key会被回收变成null但value还挂在那个Entry里。只要线程活着这个Entry就永远无法被访问、也永远无法被回收。如果这个线程是普通业务线程线程结束整个Map也销毁问题不大。但如果用的是线程池核心线程是复用的长期存活那这些value就会一条条积累下来最终就是内存泄漏。解决办法就一句话用完务必调用remove()。很多框架里能看到try { ... } finally { threadLocal.remove(); }这种写法这就是为了规避线程池场景下的泄漏。用InheritableThreadLocal还能让子线程继承父线程的副本但这个类本身也有同样的泄漏风险用之前要想清楚。7. 死锁的必要条件与jstack排查八股与实际排查的衔接点7.1 四个必要条件死锁的四个必要条件也是死记硬背的高频题互斥、持有并等待、不可剥夺、循环等待。面试官要是再问“怎么避免死锁”思路就往这四条上靠破坏互斥很难加锁本来就是要互斥破坏持有并等待一次申请所有资源破坏不可剥夺用tryLock()加超时拿不到就释放已有锁破坏循环等待给锁编号所有线程按固定顺序加锁实际代码里我见得最多的是最后一条。多个事务服务需要同时更新多张表如果每个服务都以自己的固定顺序去加锁就不会出现你等我、我等你的环路。7.2 用jstack定位死锁的完整过程面试不只会问理论还会问排查工具。最简单的实战操作是遇到疑似死锁先jps找到Java进程ID再执行jstack拿到线程快照最后在dump文件里搜索“Found one Java-level deadlock”。jps jstack pidjstack输出的死锁检测段落会把每个线程持有什么锁、正在等什么锁、涉及哪几行代码全部列出来。出现“Waiting for”“Locked ownable synchronizers”的时候基本就能锁定现场了。我觉得这里要先理解一个原则不要靠猜先拿证据。看到几条线程互相等待还不能急着说是死锁要把线程栈里的锁ID对应上证明它们是形成了环路才叫真正的死锁。7.3 写并发代码时避免死锁的几条实操纪律能用一个锁解决问题的不要用两把锁。多把锁时加锁顺序全局保持一致。锁的持有时间越短越好不要在锁里做耗时操作。优先使用ConcurrentHashMap、阻塞队列等并发容器从源头上不自己加锁。用ReentrantLock.tryLock做兜底拿不到锁就走降级逻辑。我自己的感受是死锁这类问题平时很难测出来一旦上线就被流量放大。与其将来半夜起来看堆栈不如写代码时就把加锁顺序定成一条铁律。最后的实践心得八股之所以是八股是因为它们是无数前人踩坑后提炼出来的精炼结论。我的建议是别把它们当作死记硬背的负担而是每个知识点都回到“它到底解决了什么问题”去思考一遍。比如synchronized锁升级是为了省性能、ThreadLocal用弱引用是为了回收key、线程池排队的缓冲设计是为了抗波动每一条背后都是真实工程的权衡。如果你正在准备面试可以对着每个标题自己先讲一遍卡壳的地方回头再看正文如果你已经在写并发代码建议把这篇文章当作一个checklist去Review一遍自己的代码里有没有sleep和wait不分、有没有ThreadLocal没remove、有没有线程池用Executors一把梭。线程这块就是这样背得熟不算会能讲清楚为什么才算真正过关。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →