Thread 与 Runnable 属于那种"人人以为简单、追问就露怯"的题。基础问答谁都答得上,可面试官顺着往下问两句——start 和 run 到底差在哪、Runnable 返回值去了哪里、线程状态怎么从 RUNNABLE 变成 BLOCKED——能接住的就不多了。这题的上限不在记忆,而在能不能把"任务"与"执行单元"这两个概念拆开。
先把结论放在前面:Runnable 只描述"要做什么",Thread 负责"由谁去做"。Runnable 是一个函数式接口,只有一个 run() 方法,没有返回值也不允许抛受检异常——所以任务无法把结果回传给调用者,这是它与 Callable 的分水岭。 Thread 则封装了线程状态、优先级、守护标记与线程组,并提供 start、join、interrupt 这一整套生命周期控制方法。理解了"任务与执行单元分离"这条主线,后面的追问都能顺着推出来。
机制拆解
讲清 start 与 run 的本质差异,这是最常被追问的一处。run 只是普通方法调用,执行在当前线程里,代码串行跑完,什么并发都没发生;start 才是向 JVM 申请新线程,由新线程的 run 执行入口去回调这个方法体。 可以用 Thread.currentThread().getName() 在 run 内部打印名字来验证:直接调 run 拿到的是 main,调 start 拿到的是另一个名字。同理,一个 Thread 对象只能 start 一次,第二次会抛 IllegalThreadStateException,因为线程只能被创建一次。
这些坑的正确绕法
最常见的坑是 new Thread 后直接 run,代码在当前线程串行执行,还以为是并发。 表现是"代码里的 Thread.sleep 明明写了,界面却一点没卡",或者"两个任务的结果总按调用顺序输出"。定位方法很简单:在 run 里打印线程名,与 main 对比即可确认。写异步代码时顺手检查这一点,能省掉不少"明明起了线程却不生效"的困惑。
其次是线程里未捕获异常静默终止,不设置 UncaughtExceptionHandler 就没有日志可查。 线程没有调用方的栈帧去接住异常,异常会直接交给线程的 UncaughtExceptionHandler;默认实现只是把栈打到标准错误流,而 Android 上这个流经常不落日志,于是线程"悄悄死了",任务再也没人推进。 对策是给线程池设置全局的 UncaughtExceptionHandler,或提交任务时用 submit 而非 execute——前者会把异常封进 Future,能被 get 时重新抛出。
还有一个更隐蔽的坑:子线程持有 Activity 引用,页面销毁后任务还在跑,内存泄漏与 UI 崩溃一起来。 后台任务里 Log.d("tag", userName) 这种写法间接引用了外层对象,Activity 迟迟无法回收;更糟的是任务回调里调 runOnUiThread 更新已经销毁的界面。修法是任务只持有纯数据与弱引用,界面更新前检查生命周期状态。
给 Thread 与 Runnable 建一份边界清单,注释里写清楚,单测覆盖边界与异常路径。 具体的约定可以是:耗时操作一律交给线程池而非手工 new Thread;任务只接收参数化数据、不持有界面对象;所有异步回调都要能容忍"回来时数据已过期"。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// Thread 与 Runnable:任务与执行单元分离
Thread t = new Thread(() -> { // Runnable 只描述任务
System.out.println(Thread.currentThread().getName());
}, "worker-1"); // 自定义线程名,排查时很有用
t.start(); // start 才创建新线程
t.join(1000); // 等最多 1 秒
// 生命周期:NEW → RUNNABLE → BLOCKED/WAITING → TERMINATED
// run() 是普通方法调用,直接调会在当前线程串行执行
这段代码值得盯三处:第一处,构造器的第二个参数是线程名,线上排查时靠它定位是谁的线程;第二处,start 与 run 的区别决定了并发是否真的发生;第三处,join 的语义是"等对方结束",带超时参数版本更适合兜底场景。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
"请简单介绍一下 Thread 与 Runnable,它在 Android 开发中起什么作用?"先各给一句定位:Runnable 是任务定义,Thread 是执行单元。再补一个真实场景——把一次网络请求拆成任务交给线程池,任务本身不关心由哪个线程执行。再落一个细节:需要返回结果就用 Callable 加 Future,需要异常回传就用 submit 而不是 execute。
"Thread 与 Runnable 的底层原理是什么?能不能详细说一下?"按动机、机制、代价三层答。动机是复用线程、控制并发度;机制是 start 最终走到 JVM 的 start0 原生方法,由操作系统创建线程后回调 run;代价是线程切换有开销、线程数过多会耗尽栈与内存。 再补状态机:BLOCKED 专指等 synchronized 监视器,而 wait、join、sleep 进入的是 WAITING/TIMED_WAITING,这个区分是高频追问点。
"在使用 Thread 与 Runnable 时遇到过什么问题?"拿真实案例。一个典型案例:埋点上报用了 new Thread(...).start(),某次上报里读取了页面上下文,页面销毁后回调里更新 UI 引发崩溃;定位到线程持有 Activity 引用且未做生命周期检查;修复为任务只携带埋点数据、上报完成后不触碰界面;验证是线上崩溃率归零。
"Thread 与 Runnable 和相关的替代方案相比,有什么优劣?"与 ExecutorService 比,手工 new Thread 没有复用、没有队列、没有拒绝策略,是应当被淘汰的写法;与 Handler 比,Handler 适合与消息队列绑定的场景(跨线程更新 UI),线程池适合纯后台任务。 选型结论是:新代码默认用线程池,Thread 只在需要精细控制线程属性时才手工创建。选型时把"异常静默终止、持有界面引用"这类代价摆到台面上,再决定是否引入。
再补一个工程上值得讲清的点:Callable 与 Future 补上了 Runnable 的两块缺口。Callable 允许返回值与抛受检异常,Future 则提供了 get(阻塞取结果)、isDone、cancel 这套控制接口。 但要注意三个实践细节:一是 get() 会阻塞,忘记设超时会造成线程卡死,稳妥做法是 get(timeout, unit);二是 cancel(true) 是"请求中断",任务是否真停下来取决于任务内部有没有响应中断,所以循环里要检查 Thread.currentThread().isInterrupted();三是任务抛出的异常会被封进 Future,只有调用 get 时才重新抛出,若从不 get,异常就一直沉默着——这一点与前面 UncaughtExceptionHandler 的坑本质相同。 能把这两者串起来讲,说明对"异步结果回收"这件事有完整认识。
给正在准备面试的你
把线程这条线画成一张图:横轴是状态(NEW / RUNNABLE / BLOCKED / WAITING / TERMINATED),纵轴标出触发转换的方法——start 进 RUNNABLE、抢不到锁进 BLOCKED、wait/join/sleep 进 WAITING、run 结束进 TERMINATED,再把 interrupt 能打断哪些状态标出来。 面试中关于 Thread 与 Runnable 的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是把项目里所有 new Thread().start() 收敛到一个受控线程池,并加一个埋点统计线程池的活跃数、队列长度与拒绝次数。
复习时别孤立刷题:synchronized 原理——锁与线程状态强绑定,BLOCKED 与 WAITING 的区别就出自这里。
划两句重点:Runnable 描述任务、Thread 承载执行,start 才开新线程;异步任务的异常会随 Future 被封存,取不出就一直沉默。
下一篇聊 synchronized 原理:对象头、锁升级与重量级锁——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:动态代理:AOP-与框架的基石
下一篇预告:synchronized-原理:对象头、锁升级与重量级锁
有任何问题欢迎在评论区留言交流。