资讯动态

Java多线程入门:Thread与Runnable的区别、原理与工程实践

发布时间:2026/10/13 12:01:45 来源:尧图企业网站定制
Java多线程这事入门的第一步几乎都会撞上同一个问题同样能开启一条新线程Thread和Runnable两种方式到底用哪个教科书上写得很简单——一种是继承Thread类一种是实现Runnable接口可一旦动手写业务代码很多人就犯迷糊了为什么同样的效果要给出两种写法面试被问到“区别”时除了“Java是单继承”还能说什么今天这篇不整虚的就从最朴素的场景出发把两种实现方式、背后的设计差异、线程生命周期以及实际项目里怎么选型一次性掰开揉碎讲清楚顺带把跑通代码之后最容易踩的几个坑也一并说掉。无论你是刚学到线程的新手还是写过几年Java但对并发细节拿不准的人这篇都适合按顺序读完或者直接跳到你最关心的部分。1. 单线程撑不住的场景多线程到底在解决什么问题1.1 用户等不了的操作耗时IO与CPU计算先说个最常见的场景。假设你在做一个报表导出功能用户点一下“导出”后端要跑一个十几秒的查询再把数据写进Excel。如果整个请求处理链路都在一个线程里串行执行结果就是这十几秒里这个HTTP连接一直挂着用户浏览器转圈严重一点的还会因为响应超时直接报错。更麻烦的是如果这个服务是单线程模型其他用户发来的请求也会排队等在这一个线程后面整个系统的吞吐量瞬间被拉低。这里的关键点在于很多耗时操作并不是CPU在高速计算而是在等待IO。数据库查询、远程接口调用、文件读写、发送网络请求这些操作本质上都在“等待外部返回数据”。等待期间CPU其实闲着但线程被阻塞了。多线程的核心价值之一就是让一个线程阻塞等待的时候另一个线程还能继续干活把CPU和IO资源错峰利用起来。可能你会觉得现在大多数框架和中间件内部早就用了线程池普通业务程序员不需要手写Thread。这话对但完全不懂底层实现遇到线程池参数调优、排查死锁、分析线上CPU飙高的场景会非常被动。我见过不少工作两三年的同学线程池用得很溜可让他解释一下池子里一个任务从提交到执行的完整链路他却说不上来就是因为基础里没有把Thread和Runnable的关系摸透。1.2 从“顺序执行”到“并发执行”理解多线程之前先建立“顺序”和“并发”的差异。常规代码是一行一行往下跑的做完a再做b做完b再做c。多线程做的事情是把a、b、c拆成独立任务分别交给不同线程同时推进。这个“同时”在单核CPU上是宏观层面的错觉靠时间片轮转快速切换实现在多核CPU上则是真正并行。这个差异对实际业务影响巨大。比如一个接口要调用三个下游服务A耗时300msB耗时200msC耗时400ms。顺序调用总耗时900ms如果三个调用能并行发起瓶颈直接变成最慢那个400ms。体验完全是两个级别。这也是为什么很多性能优化方案会把“串行改并行”放在第一优先级。但这里要泼一盆冷水并行不是免费的。线程的创建、销毁、上下文切换都有成本多个线程同时访问同一份数据还会带来竞争和一致性问题。所以学习Thread和Runnable不只是学两个API更是学“什么时候该开线程、开了之后怎么控制、怎么保证数据安全”这一整套判断能力。1.3 用多线程要付出的代价第一是上下文切换开销。CPU在多个线程之间切换时要保存当前线程的寄存器、程序计数器等状态再加载下一个线程的状态。线程开得越多切换越频繁系统有时候反而更慢。第二是共享数据的竞争。多个线程同时读写同一个变量可能读到中间状态也可能出现覆盖。这属于并发安全范畴后面第六章具体讲。第三是调试复杂度。并发Bug经常随机出现本地复现不了加日志又可能改变时序。这种“灵异问题”是并发程序劝退很多人的主要原因。所以结论很清晰如果任务是IO密集或者有多个互相独立的事要一起做多线程是合理选择如果只是为了炫技引入多线程只会徒增复杂度。带着这个判断我们再看Thread和Runnable的具体用法。2. 两种写法的完整拆解Thread直写法与Runnable委托式2.1 继承Thread最直接的写法写一个类继承Thread重写run方法然后调start()这是最原始的写法任何一个Java入门教程都会有这一段public class MyThread extends Thread { Override public void run() { System.out.println(当前线程 Thread.currentThread().getName()); } }调用端这样使用MyThread thread new MyThread(); thread.start();很多初学者会误以为start()会调用run()逻辑上没错但两者有本质区别。start()会让JVM为这个线程分配执行所需资源并让run()在一个新线程里执行直接调用run()则只是普通方法调用不会产生新线程。第2.4节会专门演示这个坑。这种写法的直观感很强我自己就是一个线程我要执行什么逻辑直接写在run()里。小demo、局部小工具里这么写没什么问题。但把它放进大型项目里很快就会感受到约束。2.2 实现Runnable把任务拆出来Runnable的写法是把“要执行的任务”单独抽取成一个类或一个Lambda表达式再交给Thread去跑public class MyTask implements Runnable { Override public void run() { System.out.println(任务在线程中执行 Thread.currentThread().getName()); } }使用方式Thread thread new Thread(new MyTask()); thread.start();对比两种写法继承Thread是“线程任务”实现Runnable是“线程负责运行任务独立成体”。这个概念差异后面会展开但先记住一点Runnable更符合Java“面向接口”的编程习惯。任务不依赖具体的Thread实例它可以被丢给Thread也可以被丢给线程池甚至可以被用作定时任务的内容。2.3 简化写法匿名内部类与LambdaRunnable是函数式接口Java 8之后直接这样写Thread thread new Thread(() - { System.out.println(Lambda写法线程 Thread.currentThread().getName()); }); thread.start();这个写法在工程里非常常见因为它够短。但你得注意Lambda里面如果有局部变量要捕获这个变量必须是final或者effectively final。比如int taskId 10; Thread thread new Thread(() - System.out.println(taskId taskId));如果你在创建Lambda之后又去修改taskId编译直接报错。这个限制背后是变量值复制到另一个线程时的一致性保证不深究也能理解但写代码时容易踩到。2.4 你确定线程跑起来了吗start()和run()的区别新手最典型的错误就是把start()和run()搞混。看这段代码Thread thread new MyThread(); thread.run(); // 这不会开启新线程执行run()的那行代码整个逻辑都在当前线程里同步执行没有创建新线程。只有start()才会触发JVM创建新线程。判断标准很简单打印线程名字看看。public class MyThread extends Thread { Override public void run() { System.out.println(Thread.currentThread().getName()); } }如果调用start()输出的是系统分配的线程名比如Thread-0如果调用run()输出的是当前主线程名比如main。还有一个容易忽略的细节start()只能调用一次第二次会抛IllegalThreadStateException。但run()可以反复调用因为它就是一个普通方法。理解这个区别后面看线程生命周期时会轻松很多。3. 选型前一定要搞清的底层关系Thread和Runnable到底差在哪3.1 一个关于“线程”和“任务”的比喻把多线程比作一个工厂车间线程是工人任务是工单。继承Thread的写法相当于这个工人生下来就注定一辈子只做某一种工单换不了工种也没法再干别的事。实现Runnable的写法则是把工单从工人身上剥离开同一个工单可以分给不同工人做同一个工人也可以接不同工单。这个比喻能直接解答一个面试必问题Java是单继承的如果一个类继承了Thread就不能再继承其他类。但这只是“不能继承Thread”的表层限制真正的问题在于继承Thread让“线程”和“任务”这两件事强行绑在了一起违背了单一职责的思路。3.2 更重要的是解耦任务与执行器分离Runnable写法有个隐蔽的优势任务对象可以被回收可以传到任何执行器里去运行。比如同一个Runnable实例今天用Thread跑明天改成线程池跑代码完全不用动只要把对象传给ExecutorService即可。继承Thread则没这么灵活。Thread本身就是执行器直接复用Thread实例再start()一次是违法的所以一条线程跑完且终止后它的“唯一任务”也终结了。如果你想在同一条“逻辑任务”上跑多次你得重新创建Thread对象这显然不划算。从设计角度看Runnable代表的是“做什么”Thread代表的是“由谁来执行以及怎么调度”。把两者拆开后续想引入线程池、异步编排、任务队列都顺理成章。3.3 同一个Runnable交给多个线程执行会发生什么这部分就是并发编程的入口。Runnable实例可以被多个Thread共享MyTask task new MyTask(); Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start();如果MyTask里没有任何共享可变状态那这就是安全的并行。一旦MyTask里有个成员变量count两个线程同时修改count就会出现竞态条件。继承Thread的时候每个实例有自己独立的状态反而不会共享但工程需求里很多时候我们恰恰需要多个线程协作处理同一份数据。这时候Runnable共享实例的特性就是一把双刃剑——用得好可以做数据归集用不好就是数据错乱。3.4 从JDK源码看Thread与Runnable的关联很多人不知道Thread类本身就实现了Runnable接口。源码里有一行public class Thread implements Runnable { private Runnable target; // ... }Thread的run()默认实现长这样如果构造时传入了Runnable target就去调用target.run()否则什么都不做留给子类重写。这意味着我们在“继承Thread并重写run()”时其实是覆盖了Thread类从Runnable接口继承来的run()实现而“实现Runnable再传给Thread”则是让Thread把run()的执行转发给传入的target对象。这两种路径殊途同归但背后的建模方式截然不同。理解这层关系很多奇怪现象就解释得通了比如为什么Thread子类里既能重写run()又能通过构造器传Runnable为什么同时重写run()和传Runnable时只会执行重写的run()版本——因为子类重写的run()覆盖了默认转发逻辑。4. 线程的生命周期和控制细节启动、状态、暂停与结束4.1 六种线程状态与可见的切换场景线程从创建到结束一共六种状态用Thread.getState()可以随时查看状态含义触发场景NEW刚创建未启动new Thread()之后start()之前RUNNABLE可运行/运行中start()之后获得CPU时间片BLOCKED阻塞等待monitor锁试图进入synchronized块但锁被其他线程持有WAITING无限等待wait()、join()等TIMED_WAITING有限时间等待sleep(ms)、wait(timeout)、join(timeout)TERMINATED已结束run()正常返回或抛出未捕获异常有一个常见误区需要纠正RUNNABLE并不代表线程正在CPU上执行它包含了操作系统调度中的“就绪”和“运行中”两种子状态。你在Java代码里看到线程状态是RUNNABLE只说明它没有阻塞在Java层面。4.2 重复start()为什么会抛IllegalThreadStateException线程状态机是单向的一旦进入TERMINATED就不能再回头。start()内部会检查线程状态如果线程已经启动过直接抛异常。这个检查逻辑写在Thread类的start()方法里防止开发者在同一条线程上反复start导致不可控行为。所以如果你想执行第二次任务不要尝试重新start()原线程而是新建一个Thread实例。这也是为什么工程里更倾向于线程池——线程池里的线程可以被复用执行多个任务但它复用的是“线程”本身不是“线程的start过程”。本质上线程池里的工作线程都是已经start过的存活线程它们通过从任务队列里不断取新Runnable来接受新工作。4.3 sleep、join与interrupt控制线程的常用手段Thread.sleep(ms)让当前线程暂停指定毫秒进入TIMED_WAITING。sleep不会释放锁这是它和wait的一个关键差异但wait属于并发控制这里先不展开。join()表示“我等这个线程执行完再继续”。比如主线程启动了子线程然后想等子线程结果计算出来后再往下走就可以调t.join()。join(timeout)可以指定等待上限。interrupt()是协作式中断请求。调用线程的interrupt()方法不是强制杀死线程而是给目标线程打一个中断标记。目标线程如果正在执行普通代码不会自动停下来它需要自己检查中断状态并处理如果目标线程正阻塞在sleep、wait、join上会立即抛出InterruptedException并清除中断标记。这里给个实际场景。假设有这样一个后台轮询线程public class PollingTask implements Runnable { Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { System.out.println(轮询中...); Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } System.out.println(轮询线程已退出); } }注意catch到InterruptedException后重新调用interrupt()设置中断标记这个动作。因为异常抛出后中断标记会被清除如果不重新设置外层while循环可能无法感知中断导致退出逻辑失效。这是很多人在写优雅停机时踩过的坑。4.4 run()里能抛出异常吗Thread.run()方法声明里没有throws任何受检异常。这意味着你在run()里调用一个声明throws IOException或者SQLException的方法时不能直接把异常往外抛必须自己try-catch处理。第一反应可能是“这个设计太不友好了”但仔细想就知道原因一个线程被创建到执行调用栈和主线程完全隔离异常就算抛出去也没有谁来接收处理。如果你希望能捕获到线程里的异常有几个处理思路用UncaughtExceptionHandler或者把任务提交给线程池后用Future.get()拿到底层的ExecutionException。后面第五章节有代码示例。实际工程里run()中必须做的是兜底捕获所有异常至少记录足够日志。否则一个线程因为被一个运行时异常打断而默默退出整个系统的状态会变得非常难查。5. 从实现到工程Runnable与线程池的组合实践5.1 实际项目里为什么很少直接“继承Thread”翻一下你公司的代码库直接extends Thread的代码大概率几乎找不到。原因很现实继承Thread把任务和线程绑死每次执行任务都要new一个Thread对象线程用完即销毁频繁创建销毁的代价很高。可能你觉得创建一个线程也就几毫秒的事但在高并发、每秒成千上万请求的场景下这个成本会被放大。再加上线程的创建涉及操作系统级资源分配数量不受限制的话系统很快会被玩垮。工程上的标准化答案是用线程池。线程池里的核心线程可以复用任务只负责提交Runnable线程池本身处理调度、排队、拒绝策略。这样写出来的代码既避免了线程反复创建的开销也把“线程数量上限”和“任务执行策略”这类问题交给线程池统一管理。5.2 哪些场景确实必须继承Thread严格来说没有什么是必须继承Thread才能做的但有一些场景继承Thread更自然。比如你想给线程增加自定义行为重写run()以外的钩子方法或者你操作的是一个压根不依赖任务的Thread子类重写run()只是顺带。这种需求极少数。还有一个认知需要纠正网上有些文章说“Runnable比Thread好所以永远不要继承Thread”。这句话太绝对了。如果你要做的就是一个一次性任务而且不想引入额外类匿名内部类或Lambda已经足够了如果你真的需要自定义线程行为继承Thread不是罪过。技术选型讲究“合适”不讲究“绝对不用”。5.3 有返回值的任务Runnable和Callable的关系Runnable的run()方法没有返回值也不能抛出受检异常。但业务场景中我们经常需要拿到任务执行后的结果。JDK提供了Callable来解决这个问题public interface CallableV { V call() throws Exception; }Callable配套Future使用可以把任务提交到线程池后通过Future.get()获取结果ExecutorService pool Executors.newFixedThreadPool(4); FutureInteger future pool.submit(() - { Thread.sleep(2000); return 42; }); System.out.println(任务已提交可以干别的事); Integer result future.get(); System.out.println(结果 result);future.get()会阻塞等待任务执行完成。如果你不想无限等可以使用带超时的get(timeout, unit)。这个组合是Runnable思路的延续任务和执行分离并且你还能拿到任务的结果。5.4 线程池的简单使用与任务提交来一段最常用的线程池代码ExecutorService pool Executors.newFixedThreadPool(4); for (int i 0; i 20; i) { int taskId i; pool.execute(() - { System.out.println(任务 taskId 由线程 Thread.currentThread().getName() 执行); }); } pool.shutdown();execute(Runnable)没有返回值submit(Runnable)可以返回Future。如果只想发起任务、不需要结果execute就够了。需要区分的是调用shutdown()之后线程池会等已提交任务执行完才停止但拒绝接收新任务。用完记得关否则线程池里的线程会一直存活拖住JVM进程不退出。这里必须提一个生产经验Executors.newFixedThreadPool虽然用起来方便但它的默认队列是无界LinkedBlockingQueue在任务爆发时会积压海量任务内存压力巨大。规范一点的团队会用ThreadPoolExecutor手动创建显式指定核心线程数、最大线程数、阻塞队列容量和拒绝策略。篇幅有限不展开但你至少要知道“Executors工具类不是生产环境银弹”这回事。5.5 线程命名与守护线程给线程起个有业务含义的名字排查问题时会省很多力气。直接new Thread的时候可以指定名字Thread thread new Thread(task, report-export-thread);线程池场景下通常自定义ThreadFactory把线程名统一加上前缀比如”order-pool-%d”。这么做看起来只是小细节但线上日志一多你就能感受到按线程名grep日志有多痛快。守护线程是另一类容易迷惑的概念。setDaemon(true)必须在start()之前调用。守护线程的特点是如果JVM里只剩守护线程JVM会直接退出不会等待守护线程执行完。所以像后台监控、心跳上报这种非核心任务可以设成守护线程但如果你有线程在做写库、发消息这类的关键操作千万别设成守护线程不然JVM一退数据就丢了。6. 大多数人跑通代码之后踩的坑并发下的可见性、竞态与死锁6.1 标记位停不下来可见性问题很多人在实现“用开关控制线程停止”时会写这样的代码public class StopTask implements Runnable { private boolean running true; Override public void run() { while (running) { // 处理任务 } } public void stop() { this.running false; } }启动它再在主线程里调用stop()理论上循环应该退出。但实测中你会发现某些情况下子线程根本停不下来。原因是running变量没有可见性保证子线程可能一直在自己工作内存里读取旧值主线程的修改没有被它感知到。给running加上volatile关键字private volatile boolean running true;就能解决问题。volatile的核心语义是保证变量在多线程之间的可见性和一定的有序性但它不解决原子性问题。这也是为什么volatile不能替代synchronized的原因。6.2 数字累加对不上竞态条件与synchronized如果多个线程同时执行count最终结果往往比预期小。count根本不是原子操作它包含“读取count、加1、写回count”三步。两个线程同时读到的旧值一样写回时后写的覆盖先写的加法就丢了。用synchronized给方法或代码块加锁让每次累加操作变成原子的public synchronized void increment() { count; }或者使用AtomicIntegerprivate AtomicInteger count new AtomicInteger(); count.incrementAndGet();这个坑是所有并发初学者的必修课。写Thread和Runnable的demo很容易但demo一旦涉及共享变量就必须马上意识到“线程安全”这四个字的分量。6.3 不要用stop()用interrupt协作取消JDK早期确实提供Thread.stop()但它被标记为废弃因为它会在任意位置强制终止线程可能让对象处于不一致状态。比如线程正在写一个文件刚写了一半突然被停止文件的结尾就不完整如果这个线程持有锁锁还可能不释放。正确做法是使用interrupt()协作式中断或者用volatile标记位。关键要点是让线程自己决定在哪个安全点退出而不是外部强行掐断。6.4 死锁的一种典型形态与规避思路死锁的经典版本是线程A持有锁1去申请锁2线程B持有锁2去申请锁1。两个线程互相等对方释放锁永远等下去。代码上长得像这样Object lockA new Object(); Object lockB new Object(); // 线程1 synchronized (lockA) { synchronized (lockB) { // 业务 } } // 线程2 synchronized (lockB) { synchronized (lockA) { // 业务 } }排查死锁的常用手段是jstack命令它能打印线程栈出现“Found one Java-level deadlock”字样时基本就实锤了。规避思路几个方向减少锁的嵌套层级、所有线程按同一顺序获取锁、使用带超时的tryLock。入门阶段你首先得知道死锁长什么样再进一步理解“锁的顺序一致性”为什么重要。说一个我自己的习惯每次写完多线程代码都会先问自己一句“线程要访问哪些共享资源访问顺序多个线程之间是否一致谁负责通知停止”这三个问题想清楚了至少能避开一半以上的并发基础坑。多线程这个主题入门不难真正拉开差距的是把基础写法背后的状态管理和资源竞争理解到位。上面这些内容基本覆盖了从Thread、Runnable到线程池的一条完整学习路径实际操作时再遇到问题回来看一眼相关章节应该能少走不少弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑