Java多线程三种实现方式与Thread核心方法全解析

发布时间:2026/10/1 19:13:20
Java多线程三种实现方式与Thread核心方法全解析 有个很有意思的现象Java 初学者学到多线程这一章最先卡住的往往不是“为什么要用线程”而是“线程到底怎么写”。网上教程一会儿说继承 Thread一会儿说要实现 Runnable后来又冒出个 Callable三种实现方式看着差不多用起来又总觉得哪里没搞透Thread 类的成员方法更多start、run、sleep、join、yield、interrupt……每个方法都混了个眼熟真到代码里又拿不准该用哪个。这篇文章我就把这两块一次性讲清楚三种实现方式各自的原理、适用场景、选型思路再加上 Thread 常用成员方法的作用、行为和坑点最后用一个完整的小案例把知识点串起来。如果你刚学完 Java 基础、准备面试或者工作中要写并发代码但心里没底这篇应该能帮你把地基打结实。1. 先理清楚多线程到底在解决什么问题1.1 进程和线程的关系很多教程上来就让你写代码但我觉得还是得先花两分钟想清楚“线程是什么”。进程是操作系统分配资源的基本单位线程是进程内部的一条执行路径多个线程共享同一个进程的内存空间。用生活里的比喻进程是一条生产线线程是这条线上的工人。生产线有自己的厂房和设备独立内存空间工人之间共用厂房里的工具共享内存工人可以随时上手干活、也可以换人。之所以要引入多线程核心诉求一般就两个一是提高 CPU 利用率让多核 CPU 真正并行处理任务二是提高响应速度比如下载文件的时候界面不卡、用户点按钮不至于转圈半分钟。但多线程并不是“无脑更快”的银弹。创建线程有开销线程切换有开销更麻烦的是多个线程访问共享数据时会引入并发问题。所以学多线程本质上是在学“如何在充分利用硬件性能的同时避免并发带来的混乱”。1.2 线程生命周期与状态变化Java 里线程的状态由 Thread.State 枚举定义一共六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。状态变化可以简化成下面这条链NEW - RUNNABLE - BLOCKED / WAITING / TIMED_WAITING - TERMINATED这里有个很容易被忽略的细节RUNNABLE 这个状态把“就绪”和“运行”合并了。也就是说线程只要能被调度执行不管此刻是不是真的在 CPU 上跑都叫 RUNNABLEJava 层面不细分“准备好了还没轮到”和“正在执行”。面试里问“线程有哪些状态”你要是只答出“就绪、运行、阻塞、结束”这种操作系统概念就会露馅因为 Java 的枚举里根本没有单独的 Running只有 RUNNABLE。状态是怎么切换的new Thread() 创建对象但没调用 start()是 NEWstart() 之后进入 RUNNABLE线程执行 synchronized 代码块时没抢到锁进入 BLOCKED调用 Object.wait() 或者 Thread.join() 这类等待操作进入 WAITING调用 Thread.sleep(1000) 或带超时的 wait/join进入 TIMED_WAITINGrun() 方法执行完或者抛出未捕获异常进入 TERMINATED。后面讲成员方法的时候这些状态会反复出现先有个印象。2. 三种实现方式写法、原理与选型2.1 继承 Thread最简单但最不推荐先看最传统的写法public class MyThread extends Thread { Override public void run() { System.out.println(线程执行了 Thread.currentThread().getName()); } public static void main(String[] args) { MyThread t new MyThread(); t.start(); } }继承 Thread 的写法足够简单但它的缺点也很明显。Java 是单继承你继承了 Thread 就不能再继承其他业务类任务代码直接被写死在 Thread 子类里完全耦合在一起。更实际的问题是这种写法本质上是把“任务”和“执行任务的线程”绑成一个对象线程跑完就销毁了想复用线程、想控制并发数量、想接收任务结果全都没法优雅实现。那什么时候用这种写法我个人的看法是只有极简的独立任务、教学示例、或者考试题目里才适用。真实项目中我几乎没见过靠继承 Thread 支撑业务线的。不是它不能跑是它扩展性太差后面接需求会非常痛苦。2.2 实现 Runnable解耦任务与线程Runnable 接口只有一个 run() 方法语义很明确描述“要做什么”。至于“谁来执行”交给 Thread 或者线程池去管。这才是面向对象设计里“职责分离”的体现。public class CountTask implements Runnable { Override public void run() { int sum 0; for (int i 1; i 100; i) { sum i; } System.out.println(求和结果 sum); } public static void main(String[] args) { CountTask task new CountTask(); Thread t new Thread(task, count-thread); t.start(); } }Runnable 是函数式接口所以也能用 Lambda 写得非常干净Thread t new Thread(() - System.out.println(hello thread), lambda-thread); t.start();实现 Runnable 的好处有三点第一不受单继承限制类还能继承其他基类第二同一个任务对象可以启动多个线程也可以在构造新线程时复用第三任务和线程分离后可以很方便地配合线程池使用把任务丢给线程池调度。Runnable 的局限也很明确run() 方法不能返回值也不能抛出受检异常。这就轮到 Callable 出场了。2.3 实现 Callable要返回值就得用它Callable 接口和 Runnable 很像区别在于它的 call() 方法有返回值并且可以抛异常。看代码public class SumCallable implements CallableInteger { Override public Integer call() throws Exception { int sum 0; for (int i 1; i 100; i) { sum i; } return sum; } public static void main(String[] args) throws Exception { FutureTaskInteger futureTask new FutureTask(new SumCallable()); new Thread(futureTask, callable-thread).start(); Integer result futureTask.get(); System.out.println(求和结果 result); } }这里必须引入 FutureTask因为 Thread 构造器的参数是 Runnable而 FutureTask 恰好实现了 RunnableFuture 接口这个接口同时继承了 Runnable 和 Future。所以 FutureTask 能被丢进 Thread 构造器启动又能在后续通过 get() 拿到返回结果。get() 方法会阻塞当前线程直到任务执行完也可以传入超时参数比如 futureTask.get(3, TimeUnit.SECONDS)避免永久等下去。在实际项目里Callable 更常见的用法是配合线程池提交后面会详细演示。你只需要记住需要返回值、需要捕获异步任务中的异常就选 Callable。它的 call() 能直接抛出异常不用像 Runnable 那样手动包裹。2.4 三种方式对比与选型建议对比维度继承 Thread实现 Runnable实现 Callable返回值无无有通过 Future.get 获取异常处理只能在 run 内部处理只能在 run 内部处理call 可以抛受检异常与线程解耦耦合任务写死在子类解耦任务与 Thread 分离解耦可提交给线程池配合线程池不方便方便方便典型使用场景极简临时任务常规无返回值任务需要结果或异常传输的任务我看很多人纠结“三种方式哪个更好”其实问题的关键不是选哪个而是按任务特性去选。无返回值任务用 Runnable有返回值任务用 Callable极少数玩具场景用 Thread 也不犯法。还有一个认知上的“祛魅”Java 里真正启动一个线程的方式只有一个就是 new Thread().start()。Thread、Runnable、Callable 本质上是“任务怎么写”的差异不是线程创建方式的差异。想明白这一点面试题“三种实现方式的区别”就已经通了。3. Thread 成员方法逐个拆解3.1 start 和 run一个启动线程一个只是调用方法这俩是最容易闹乌龙的一对。直接看代码Thread t new Thread(() - System.out.println(当前线程 Thread.currentThread().getName())); // 错误示范直接调 run t.run(); // 正确示范调 start t.start();如果调用 t.run()输出的线程名是 main因为它就是在当前线程里执行了一个普通方法根本没有创建新线程。调用 t.start() 的时候底层会通过 native 方法创建一个新的操作系统线程新线程再去执行 run() 里的逻辑此时输出的线程名才是子线程的名字。面试里经常这么问“为什么必须调 start不调 run”标准答案就是run 只是一个普通方法调用start 才会真正启动线程并让新线程执行 run。还有个细节同一个线程对象只能调一次 start()重复调用会抛 IllegalThreadStateException。我见过有人写循环里重复 start 同一个对象直接崩溃这个坑要避开。3.2 sleep 和 yield让出 CPU 的两种姿势Thread.sleep() 是静态方法含义是“当前线程主动休眠指定毫秒数”进入 TIMED_WAITING 状态。注意几个特点它会让出 CPU但不会释放锁它可能被 interrupt 打断打断后会抛 InterruptedExceptionsleep(0) 不是睡 0 毫秒它的作用是触发一次 CPU 重新分配某些循环里会用这个技巧让其他线程有机会跑。yield() 同样是静态方法语义是“当前线程愿意让出 CPU”从运行中回到就绪态让调度器重新挑选线程。但这个“让出”只是给调度器一个建议如果就绪队列里还是这个线程优先它可能立刻又接着跑。yield 通常被认为是不太可控的操作业务代码里很少依赖它更多是学习用。sleep 和 wait 的区别是另一个高频考点sleep 是 Thread 的静态方法wait 是 Object 的方法sleep 不释放锁wait 会释放锁sleep 指定时间后自动醒来wait 要么用带超时的方法要么靠 notify/notifyAll 唤醒。后面写多线程的时候这两个方法千万别混。3.3 join等待子线程结束join 的作用很朴素当前线程调用某个子线程的 join()就会一直等到子线程执行完才继续往下走。看代码Thread t new Thread(() - { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(子线程执行结束); }); t.start(); System.out.println(主线程等待子线程...); t.join(); System.out.println(主线程继续执行);执行顺序上“主线程等待子线程”一定先打印然后等两秒打印“子线程执行结束”最后打印“主线程继续”。join 的本质是让当前线程在子线程对象上进入 wait 状态子线程结束后由 JVM 唤醒。既然它基于 wait 机制所以 join 期间当前线程会释放锁。带参数的 join(1000) 表示最多等 1 秒超时后主线程不再等直接继续。实际开发中join 最常见的场景是“主线程需要聚合多个子线程的结果先等它们都干完再统一处理”。不过一旦任务多了更规范的做法是用线程池配合 CountDownLatch 或 Future.getjoin 更适合小规模、代码直观的场景。3.4 setDaemon 与 setPriority设置线程属性setDaemon(true) 把线程设置为守护线程。守护线程的特点是当进程中只剩守护线程时JVM 会自动退出不会等守护线程执行完。GC 线程就是最典型的守护线程。所以像日志清理、监控上报这类“主业务挂了它也跟着挂”的任务可以设成守护线程但核心计算任务千万别设成守护线程否则主线程一结束还没跑完的子线程可能直接被带走。调用 setDaemon 必须在线程 start() 之前一旦线程启动再设置会抛 IllegalThreadStateException。setPriority 用来设置优先级范围是 1 到 10默认是 5。看到这里很多人以为优先级高的线程一定会先跑实际上它只是给 JVM 调度器的“建议”不同操作系统对优先级的实现策略完全不同有的甚至直接忽略。所以千万不要用优先级来保证某个任务的执行顺序那样写出来的代码基本等于靠运气。优先级的正确用法是作为一种弱提示比如紧急任务稍微调高一点但不是强约束。3.5 interrupt协作式中断的正确理解interrupt 是最容易被误解的方法。它不是在“停止线程”而是给目标线程打上一个“中断标记”。线程是否响应中断完全取决于线程自己怎么处理。看代码Thread t new Thread(() - { while (!Thread.currentThread().isInterrupted()) { System.out.println(正在干活...); } System.out.println(检测到中断停止工作); }); t.start(); Thread.sleep(10); t.interrupt();这个例子里子线程在一个循环里反复检查自己的中断标记一旦标记变为 true就退出循环。这是正确的协作式中断模型外部只发出中断信号线程内部决定何时停止。但如果线程正卡在 sleep()、wait()、join() 这类可中断方法里外部调用 interrupt() 会触发这些方法立即抛出 InterruptedException并且会把中断标记清除。这时候不能只 catch 一下就默默吞掉规范做法是捕获异常后在 catch 块里重新设置中断标记或者根据业务直接退出任务。比如try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }这里重新设置中断标记是为了让上层代码也能感知到线程被中断过。这是很多新手乃至工作一两年的开发都会犯的错catch 到 InterruptedException 后直接不去管线程虽然被中断了但上层继续跑任务结果可能就是错的。至于 Thread.stop() 这种老古董因为会强制释放锁、留下不一致的半成品数据早就被标记为不推荐使用了别碰。4. 一套代码把三种方式和成员方法串起来4.1 实战统计三个线程的任务耗时与结果只有零散的知识点不够我写一个综合小案例。三个子线程分别用三种方式完成累加任务主线程负责等待并收集结果import java.util.concurrent.Callable; import java.util.concurrent.FutureTask; public class ThreadDemo { static class CountThread extends Thread { private final int target; public CountThread(int target) { this.target target; } Override public void run() { int sum 0; for (int i 1; i target; i) { sum i; } System.out.println(Thread方式线程名 getName() 结果 sum); } } static class CountTask implements Runnable { private final int target; public CountTask(int target) { this.target target; } Override public void run() { int sum 0; for (int i 1; i target; i) { sum i; } System.out.println(Runnable方式线程名 Thread.currentThread().getName() 结果 sum); } } static class SumCallable implements CallableInteger { private final int target; public SumCallable(int target) { this.target target; } Override public Integer call() throws Exception { int sum 0; for (int i 1; i target; i) { sum i; if (i % 100 0) { Thread.sleep(1); } } return sum; } } public static void main(String[] args) throws Exception { CountThread t1 new CountThread(3000); t1.start(); Thread t2 new Thread(new CountTask(3000), count-thread); t2.start(); FutureTaskInteger futureTask new FutureTask(new SumCallable(3000)); Thread t3 new Thread(futureTask, callable-thread); t3.start(); // 等待前两个线程执行完毕 t1.join(); t2.join(); // 前两个线程没有返回值Callable 的结果通过 FutureTask.get() 获取 // 如果 t3 还没执行完这里会阻塞等待 t3.join(); Integer sum futureTask.get(); System.out.println(Callable方式线程名 t3.getName() 结果 sum); int threadResult 3000 * 3001 / 2; System.out.println(三个线程总和 (threadResult * 3)); } }这段代码里几乎把前面的方法全用上了CountThread 里 getName() 拿到线程名t2 通过构造器第二个参数指定线程名CountTask 里用 Thread.currentThread().getName() 获取线程名t1.join() 和 t3.join() 等待子线程结束futureTask.get() 获取异步结果SumCallable 里用 Thread.sleep(1) 模拟耗时操作同时也展示了 Callable 的 call() 可以声明抛出异常。实际运行时会发现t1 和 t2 很快就执行完了t3 因为每次累加到 100 的倍数都要睡 1 毫秒整体会慢一些。你在 main 方法里加一行 System.out.println(t3.getState())在 futureTask.get() 之前打印有很大概率看到 TIMED_WAITING正好对应 sleep 时的状态。这就是理论和实践对上号的感觉。4.2 真实项目中的用法线程池组合 Callable真实开发里我几乎不会手动 new Thread 去跑业务任务而是直接把 Runnable 或 Callable 丢给线程池。原因很现实手动创建线程的销毁开销太大线程数不可控服务一高并发就容易被线程数拖垮而线程池可以复用线程、控制并发度、提供统一管理和拒绝策略。看一段典型写法ExecutorService pool Executors.newFixedThreadPool(3); FutureInteger future pool.submit(new SumCallable(3000)); System.out.println(线程池结果 future.get(3, TimeUnit.SECONDS)); pool.shutdown();这里用 Executors.newFixedThreadPool(3) 创建了一个固定 3 个线程的线程池submit 方法接受 Callable 并返回 Futureget 带了超时时间防止任务卡死导致调用方无限等待。最后一定要 shutdown()否则线程池的非守护线程会阻止 JVM 退出。不过 Executors 快捷方法在生产环境里也要小心newFixedThreadPool 用的是无界队列任务堆积多了可能导致内存暴涨newCachedThreadPool 的线程数几乎没有上限极端情况下会创建大量线程。所以更稳妥的做法是直接 new ThreadPoolExecutor手动指定核心线程数、最大线程数、队列容量和拒绝策略。这个属于高并发选型的进阶内容懂了这个门道后面学线程池会顺手很多。5. 常见问题与排坑速查5.1 高频报错与误区错误或误区原因正确做法Thread already started 异常同一个线程对象调了多次 start()一个线程对象只能 start 一次需要重新创建或使用线程池调用 run() 发现没并发执行把 run() 当普通方法调用必须走 start()由 JVM 创建新线程执行 run捕获 InterruptedException 后吞掉不清楚中断语义捕获后恢复中断标记或直接退出任务把线程优先级当强约束误以为优先级决定执行顺序优先级只是建议业务逻辑不能依赖优先级守护线程没执行完程序就退了进程内只剩守护线程时 JVM 直接退出核心任务用非守护线程或用 join 等待我在工作里踩过最狠的一个坑是把 Runnable 任务提交给线程池后忘记在请求结束时关闭线程池结果线上日志里线程越堆越多某个接口的响应时间直接飙到几十秒。排查小半天才发现是线程池没做优雅关闭。记住线程池的生命周期要跟应用或业务模块绑定不能每次请求都建一个也不能建了就不管。5.2 线程安全的几个基本认知多线程写共享数据最经典的问题就是 count。count 看起来是一行代码实际上拆成 CPU 指令是“读取-加一-写回”三步。两个线程同时读到了同一个值各自加一写回最后结果只增加了一次数据就丢了。这就是非原子操作的竞态问题。解决办法有两个方向一是给共享操作加锁比如 synchronized 或 ReentrantLock二是使用原子类比如 AtomicInteger它的 incrementAndGet() 在底层是 CAS 原子操作。这里要顺便说清楚 volatile它只能保证可见性也就是一个线程改完值其他线程立刻能看到但不能保证“读取-加一-写回”这个复合操作是原子的。所以 volatile 适合“单个变量被多线程读写”的场景不适合做计数器累加。多线程并发访问共享变量时先问自己三个问题这个变量会不会被多个线程同时写要不要保证每一步操作的原子性要不要限制访问的互斥性把这三个问题想清楚线程安全就成功了一半。5.3 多线程排查思路线上并发问题排查我一般按这个顺序来第一步看日志里有没有异常堆栈把异常堆栈里的线程名记下来第二步用 jstack 命令导出线程快照抓线程状态第三步重点看有没有大量线程卡在 BLOCKED 或 WAITING如果有大概率是锁竞争或死锁第四步检查共享数据的访问路径看是不是有多个线程同时改同一个集合或同一个变量。有个小经验值得分享给线程起名字是最便宜、回报最高的排查手段。new Thread(task) 这种匿名线程出了问题日志里只有一堆 thread-1、thread-2根本对不上业务起名之后比如 new Thread(task, order-query-thread)只要日志里带上线程名问题定位就是一瞬间的事。我在日志输出里习惯把线程名也打出来比如 log.info(thread{} process result{}, Thread.currentThread().getName(), result)就是这个原因。6. 面试考点和学习路径建议6.1 面试官最爱追问的几个点多线程是 Java 面试里的核心常驻题目围绕今天的主题高频问题基本就这几个问题一实现多线程有哪几种方式回答时先给三种方式再补一句“本质上是任务定义方式的差异启动线程最终都是 start()”比单纯背三种方式更有深度。问题二start() 和 run() 的区别这是送分题但也最容易翻车。核心结论是 start 创建新线程并执行 runrun 只是普通方法调用。问题三Runnable 和 Callable 的区别一个是 run() 没有返回值一个是 call() 有返回值且可以抛异常。再补充一句 Callable 通常配合 FutureTask 或线程池使用就完整了。问题四sleep() 和 wait() 有什么区别来源不同Thread 静态方法 vs Object 实例方法锁行为不同不释放锁 vs 释放锁唤醒机制不同自动唤醒 vs notify/notifyAll。问题五如何安全地停止一个线程用协作式中断。通过 interrupt() 设置中断标记线程内部检查 isInterrupted() 或捕获 InterruptedException 后自行退出。不要用 stop()。问题六多线程一定比单线程快吗不一定。线程创建销毁有开销上下文切换有开销还可能有锁竞争。只有任务本身适合并行、或者存在大量 IO 等待时多线程的收益才明显。纯 CPU 密集计算场景下线程数超过 CPU 核数后反而可能变慢。这几道题能答得出原理、答得出“为什么”面试基本就能过。如果只是背结论面试官追问一句就露馅。6.2 后续学习建议多线程这块不建议一上来就啃源码。按顺序来先把线程基础搞扎实比如今天讲的三种方式和成员方法然后学线程池理解 ThreadPoolExecutor 的线程数、队列、拒绝策略接着学锁synchronized 和 ReentrantLock 各自的使用场景再往后是并发工具类CountDownLatch、Semaphore、CyclicBarrier 做线程协作最后是并发容器比如 ConcurrentHashMap 和 CopyOnWriteArrayList。这套路线走完再去翻 JUC 的源码心里就有底了。学习的时候一定要自己敲代码实验。线程状态转换、sleep 不释放锁、join 的等待效果这些光看文档是记不住的把代码改一改、加几个日志观察线程名的变化和状态输出比背十遍都管用。最后说点个人体会。我平时写业务代码几乎都是 Runnable 或 Callable 配合线程池很少手动 new Thread。面试问三种实现方式表面是考语法实际是看你能不能根据任务场景做选型任务要不要返回值、要不要复用线程、要不要超时控制、要不要和线程池搭配使用。能想清楚这几点才算是把多线程学到手了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询