网上关于“创建线程有几种方式”这个问题,翻来覆去就是标准答案三选一:继承Thread类、实现Runnable接口、实现Callable接口。我刚学Java那会儿也背得很熟,可真到项目里去写并发代码,才发现背答案和能上手是两码事。最开始我只会在代码里new Thread(...).start(),等被问到“Runnable和Callable到底什么区别”时,整个人是懵的。后来把JUC(java.util.concurrent)整个体系一点点啃下来,才慢慢意识到,真正值得搞清楚的不是“三种方式”这几个字,而是Thread、Runnable、Callable背后分别代表的角色:线程、任务、返回结果。这篇就把三种实现方式从头到尾拆开讲,配合能直接跑的示例代码、运行结果和对应的坑,给刚入门的读者一条能顺着走的路径,也给写了一段时间但还是有点含糊的同学把概念理理顺。
1. 三种实现方式的本质差异:线程、任务与返回值分别是谁的职责
1.1 三份最短示例代码,把三种运行方式摆在一起看
先写三个最基础的任务类,分别对应三种方式。
/** 方式一:继承 Thread */ public class ThreadDemo extends Thread { @Override public void run() { System.out.println("继承Thread -> " + Thread.currentThread().getName()); } }/** 方式二:实现 Runnable */ public class RunnableDemo implements Runnable { @Override public void run() { System.out.println("实现Runnable -> " + Thread.currentThread().getName()); } }/** 方式三:实现 Callable */ public class CallableDemo implements Callable<String> { @Override public String call() throws Exception { return "实现Callable -> " + Thread.currentThread().getName(); } }再写一个主方法把三种方式串起来跑。
public class Main { public static void main(String[] args) throws Exception { // 方式一:Thread子类直接start new ThreadDemo().start(); // 方式二:把Runnable实例交给Thread构造方法 new Thread(new RunnableDemo()).start(); // 方式三:Callable需要包一层FutureTask,再交给Thread FutureTask<String> futureTask = new FutureTask<>(new CallableDemo()); new Thread(futureTask).start(); System.out.println(futureTask.get()); // 回到主线程看一眼 System.out.println("当前执行线程 -> " + Thread.currentThread().getName()); } }运行之后,输出顺序每次都可能不一样,但大致长这样:
继承Thread -> Thread-0 实现Runnable -> Thread-1 当前执行线程 -> main 实现Callable -> Thread-2这里有个特别值得注意的细节:三个任务类里的代码,最终都是在不同名字的线程里执行的。ThreadDemo自己就是Thread;RunnableDemo是被一个普通的Thread包住再执行;CallableDemo则先被FutureTask包了一层,然后FutureTask又被另一个Thread包住。所以说,三种方式看起来都在“开线程”,但真正干活的姿态差异很大。
1.2 只有Thread在“创建线程”,Runnable和Callable在“定义任务”
从上面的代码能看出来,真正负责“创建线程”的,自始至终只有Thread类以及它背后的start()方法。Runnable和Callable本身并不产生线程,它们只是定义了“线程启动以后要干的那段活”。
换句话说,把这三者放在同一个维度上去比较“创建方式”,其实是把两个层次的概念混在一起了:
Thread代表执行单元本身,它有状态、有生命周期,能start()也能interrupt()。Runnable和Callable代表任务内容,它们只是声明了一段可以被执行的代码逻辑。
我经常用一个类比来解释:Thread像一台干活的生产设备,Runnable和Callable像是给设备下发的工作单。工作单可以有很多份,可以被同一台设备依次执行,也可以分发给不同设备同时执行;而设备本身是稀缺资源,不能无限制造。很多人对多线程感到混乱,根源就是把“设备”和“工作单”混为一谈,于是后面对线程池、任务复用这些概念就全乱套了。
搞懂这点之后,再回头背“三种方式”,就不会觉得它们只是三句口语化的答案,而是能自然理解成:线程只有一种真正的创建入口,但任务可以按“有没有返回值、会不会抛异常”分成Runnable和Callable两类。这也是JUC整个并发体系非常经典的二分法。
2. 继承Thread:最容易写,也最容易写错的一种方式
2.1 start()和run()调错之后,线程瞬间变单线程
继承Thread的方式对新手最友好,因为代码看起来最直白:定义一个子类,重写run()方法,然后new出来调用start()。但这里第一道坎就是start()和run()的区别。
我见过不少初学同事,随手把代码写成这样:
ThreadDemo thread = new ThreadDemo(); thread.run();表面上看也能输出“继承Thread -> main”,但注意看线程名,它跑在main线程里,根本没有新线程。因为run()只是普通方法,谁调用就在谁当前线程里执行;只有调用start()时,JVM才会真正申请操作系统线程,让新线程去执行run()里的代码。
所以要记住一个简单的判断标准:想看一段逻辑有没有真正并发执行,看线程名即可。调用start()再在run()里打印Thread.currentThread().getName(),得到的是Thread-0之类的子线程名;直接调用run(),得到的永远是当前调用方所在线程的名字。
2.2 单继承限制与类职责膨胀:我在项目里弃用它真实原因
继承Thread之所以在真实项目里越来越少,第一个原因是Java单继承约束。假设业务上已经有一个父类,比如某个基础任务类,那么再想用继承Thread的方式就很别扭:你没法同时继承两个类。Java里一个类只能有一个直接父类,如果要复用现有父类逻辑,又要变成线程,只能绕路去组合,代码改起来很费劲。
第二个原因是职责膨胀。写业务的人通常希望把“业务逻辑”和“线程机制”分开:业务类只关心自己的数据怎么处理、接口怎么调用,至于这个任务要不要异步、放到哪个线程执行,是另一层的事。但继承Thread之后,子类既承担了业务代码,又承担了线程调度职责,两种职责揉在一起,测试、扩展、替换执行策略都变得困难。
当时我在某一个模拟项目X里就踩过这样的坑。为了快速实现一个批量通知功能,我写了一个NotifyThread extends Thread,在run()里去调短信接口。刚开始很爽,等需求变成“通知任务要复用线程池、要支持并行和串行切换”时,我才发现这个类已经和线程强耦合了,改起来非常痛苦。最后只能重构,把通知逻辑抽成Runnable,才把问题解开。
2.3 多个Thread子类实例无法天然共享资源
还有一个新手经常忽略的细节:继承Thread后,如果把共享变量直接放在子类字段里,每个实例会有自己独立的一份,彼此之间根本没法共享。
public class CounterThread extends Thread { private int count = 0; @Override public void run() { for (int i = 0; i < 1000; i++) { count++; } } }如果创建两个CounterThread实例分别start(),最后每个实例的count都等于1000,但两个实例之间没有公共计数,合计并非2000。想要共享,就得把count放到一个外部对象里,或者在子类里用static修饰。但static字段又会引入类级别的公共状态,改起来要小心翼翼,一不小心就出现线程安全问题。
说到这你应该能体会到,继承Thread这条路,不管是复用性、扩展性还是资源隔离,都算不上理想。它适合作为理解线程运行机制的入门示例,但真放在工程里,我更推荐往下看第二种方式。
3. 实现Runnable:把任务从线程身上拆下来的关键一步
3.1 Runnable只声明run(),配合Lambda之后写起来很轻
Runnable接口结构非常简洁,核心方法就一个run():
@FunctionalInterface public interface Runnable { void run(); }它是一个标准的函数式接口,所以从JDK 8开始,可以用Lambda表达式直接替代匿名内部类。前后的对比感受一下:
// 早期写法:匿名内部类 Runnable task1 = new Runnable() { @Override public void run() { System.out.println("匿名内部类执行"); } }; // 现代写法:Lambda Runnable task2 = () -> System.out.println("Lambda执行");写起来轻量很多,而且不需要new一个专门的类,随手一写就是一个任务。实现Runnable的方式之所以被广泛使用,核心还是“解耦”:任务只管“要做什么”,不再管“在哪个线程跑,以及怎么被创建”。
3.2 共享变量自增现场:i++丢更新是大概率事件
用Runnable比继承Thread灵活,但灵活性也意味着陷阱。最常见的就是多个线程共享一个对象,同时对一个计数变量做i++。
来看这段代码:
public class Counter { int count = 0; } public class Main { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Runnable task = () -> { for (int i = 0; i < 10000; i++) { counter.count++; } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("最终count = " + counter.count); } }理论上两个线程各加10000次,最终应该是20000。但实际运行结果往往是19432、18217这种不到20000的数字,而且每次跑还不一样。问题就出在count++不是原子操作。它先在内存里读取count值,然后+1,再写回去;两个线程可能在同一时刻读到同一个旧值,各自加1再写回,于是两次自增实际只生效了一次,数据就丢了。
这里的“共享对象”是同一个Counter实例,因为两个Thread拿的是同一个Runnable任务,任务里又持有同一个counter,所以问题才会暴露出来。这也是为什么我一直强调,Runnable把任务抽出来的好处,不只是代码好看,它还把“多个执行单元共享同一份任务状态”这条路径打通了。状态共享得越方便,线程安全就越需要你时刻盯紧。
3.3 可见性选volatile还是原子类:先搞清楚问题本质
一旦出现上面的计数问题,很多人第一反应是给变量加volatile。但volatile解决的是“可见性”,它保证一个线程修改了值之后,其他线程能立刻看到最新值。它并不能把“读取-修改-写入”这个复合操作变成原子操作。换句话说,volatile int count上的count++照样会丢更新。判断正确性的关键先问自己:方法里的操作是不是原子的?
- 如果只是多个线程读同一个标记位,一个线程去修改,另一个线程判断后决定是否退出,这种情况
volatile够用。 - 如果操作是
count++、count = count + 1这类复合操作,应该用AtomicInteger。
改上面例子的正确姿势很简单:
public class Counter { AtomicInteger count = new AtomicInteger(0); } // 任务里 counter.count.incrementAndGet();AtomicInteger内部使用CAS机制,保证每次增加操作本身是原子的,这样就不会出现丢计数的问题。分享一个我自己的习惯:先看“要不要依赖上一次读到的值来决定写什么”,如果需要,直接考虑原子类或锁,别让volatile背它不该背的锅。
4. 实现Callable:返回结果与抛出异常,都需要FutureTask做桥梁
4.1 为什么Thread构造方法收不了Callable,一定要包一层
Runnable的run()方法没有返回值,也不能抛受检异常。但真实业务里,我们经常需要知道“后台任务跑完了没有,结果是什么”。这个时候就要用Callable。
@FunctionalInterface public interface Callable<V> { V call() throws Exception; }call()定义了泛型返回值V,而且允许抛异常。但注意,Thread类的构造方法只接受Runnable,它不认识Callable,直接new Thread(callable)是编译不过的。为了桥接两者,JDK提供了FutureTask。它的关键设计是:FutureTask本身实现了RunnableFuture,而RunnableFuture又继承了Runnable和Future两个接口。所以FutureTask既能塞进Thread构造方法,又能通过Future接口获取最终结果。
执行流程就成了三层结构:
Callable<String> callable = () -> "任务结果"; FutureTask<String> futureTask = new FutureTask<>(callable); Thread thread = new Thread(futureTask); thread.start(); String result = futureTask.get();一个标准的任务提交链路:Callable定义任务,FutureTask包装任务并记录状态,Thread驱动任务执行。
4.2 get()阻塞语义与超时控制:看似简单,实际很坑
FutureTask.get()是很多初学者最喜欢的收尾方式,因为它能直接拿到返回值。但这里有个特别容易翻车的点:get()是阻塞方法。线程调用get()时会一直等待任务运行结束,如果任务迟迟不结束,当前线程也会一直卡住,表现上就跟“死锁”很像。
下面这段代码就能演示:
ExecutorService executor = Executors.newFixedThreadPool(2); Future<Integer> future = executor.submit(() -> { Thread.sleep(5000); return 42; }); System.out.println("准备取结果"); Integer result = future.get(); // 会阻塞最多5秒 System.out.println(result);如果改成带超时的版本,至少不会被无限卡住:
try { Integer result = future.get(2, TimeUnit.SECONDS); } catch (TimeoutException e) { System.out.println("2秒没等到结果,先处理超时"); // 这里可以决定要不要取消任务 }我建议在真实项目里,凡是可能耗时较长的任务,取结果都尽量带超时参数。尤其调用第三方接口或者执行外部命令的场景,无限制等下去会拖垮整个调用线程,最后连取消的机会都没有。
4.3 异常、取消与任务状态:真正上线时最容易忽略的事
Callable.call()允许抛出异常,但异常不是直接在调用get()的方法里立刻出现的。任务线程里抛出的异常会被FutureTask捕获并保存,等到调用get()时,才会被包装成ExecutionException重新抛出。所以实际处理检索时,必须分类型处理异常:
try { String result = futureTask.get(3, TimeUnit.SECONDS); } catch (CancellationException e) { // 任务被取消了 } catch (ExecutionException e) { // 任务内部抛出的原始异常,需要再取cause Throwable cause = e.getCause(); } catch (InterruptedException e) { // 当前线程在等待时被中断 Thread.currentThread().interrupt(); } catch (TimeoutException e) { // 超时 }还有一个比较隐蔽的细节:如果get()调用前任务还没开始,这时候调用cancel(true),任务很大概率根本不会启动;如果任务已经启动,cancel(true)会尝试通过中断让任务停掉,但任务里的代码要主动响应中断才会真正停下,否则cancel(true)也没办法强制终止一个卡死的线程。所以,任务内是否关心中断状态,决定了取消到底能不能生效。
5. 再往前一步:线程池才是三种方式的共同归宿
5.1 线程池没绕开Thread,它只是把“创建”收口到统一工厂
聊完三种方式,很多人会想:那我在项目里是不是用第三种就够了?其实不是。真实项目里,几乎很少直接new Thread(...).start(),因为每次new Thread都会带来线程的创建、销毁开销,线程一多,系统资源消耗非常难看。
线程池并不是“创建线程的第四种方式”,它是在回答另一个问题:如何统一管理线程的生命周期、限制并发数量、复用空闲线程。其底层依然通过Thread对象去执行任务,只是这些Thread对象由统一的ThreadFactory或者说线程池内部机制来创建,而不是散落在业务代码里随意new。
对于基础理解,可以用一句话概括线程池的价值:它让“线程创建”从业务代码里回收,让“任务提交”变成业务代码唯一要做的事。
5.2 ExecutorService提交Runnable与Callable的代码形态
理解前面三种方式之后,再来看线程池代码会豁然开朗。ExecutorService对三种任务都有接收能力:
ExecutorService executor = Executors.newFixedThreadPool(4); // 提交Runnable:没有返回值 executor.execute(() -> System.out.println("execute Runnable")); // 提交Runnable,但可以拿到一个“完成信号” Future<?> future = executor.submit(() -> System.out.println("submit Runnable")); // 提交Callable:能拿到返回值 Future<String> resultFuture = executor.submit(() -> "callable result");这里面有两个细节值得品一下:
第一,execute()对应的是Executor接口,它只收Runnable;submit()是ExecutorService扩展的方法,Runnable和Callable都能收。submit(Runnable)返回的Future在任务完成后get()拿到的是null,它更多是作为“任务是否跑完”的信号。
第二,提交Callable比手动创建FutureTask再交给Thread要清爽得多,因为FutureTask被线程池内部消化掉了。这也是JUC体系设计的一个延伸:你只需要关心任务本身和最终结果,过程中如何复用线程、如何并发调度,池子帮你扛了。
再配合一个自定义线程池的简单配置,更容易理解为什么线程数不能随便填:
ExecutorService pool = new ThreadPoolExecutor( 2, 4, 30, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10), Thread::new, new ThreadPoolExecutor.AbortPolicy() );核心线程数2、最大线程数4、队列深度10。当任务提交越来越快、核心线程都忙不过来时,新任务先进队列排队;队列满了再创建额外线程;连额外线程也满了,就走到拒绝策略。这一套流程如果直接手写线程管理,会非常痛苦,但线程池把这套机制封装成了可配置的组件。
5.3 我的个人选择:哪些场景用哪种方式
结合这几年实际写代码的经验,我给自己的选择标准大致如下:
- 一次性临时任务,代码量很小,不关心返回值:直接用
Runnable配合线程池,别自己new Thread。 - 任务跑完需要拿结果,或者需要超时处理、取消控制:用
Callable配合ExecutorService.submit(),返回Future再统一处理。 - 少数固定生命周期线程,比如一个后台守护线程,专门用来刷缓存:直接定义
Thread子类或Runnable都可以,但更推荐Runnable。 - 继承Thread的方式,我基本只用在教学示例和代码演示里,在正式项目里我已经很少见到了。
还有一个我踩过多次坑之后的体会:无论是三种方式里的哪一种,最终都要回到“任务定义与执行器分离”这条主线上。Runnable和Callable定义任务,Thread和线程池执行任务,Future/FutureTask接收结果。把这几个角色摆清楚,后面看CompletableFuture、看各种JUC工具类,思路都会顺很多。这大概也是JUC系列第一篇文章最值得先想明白的一件事。