Java创建线程三种方式详解:Thread、Runnable、Callable与FutureTask
网上关于“创建线程有几种方式”这个问题翻来覆去就是标准答案三选一继承Thread类、实现Runnable接口、实现Callable接口。我刚学Java那会儿也背得很熟可真到项目里去写并发代码才发现背答案和能上手是两码事。最开始我只会在代码里new Thread(...).start()等被问到“Runnable和Callable到底什么区别”时整个人是懵的。后来把JUCjava.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 CallableString { 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 FutureTaskString 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自己就是ThreadRunnableDemo是被一个普通的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 CallableV { V call() throws Exception; }call()定义了泛型返回值V而且允许抛异常。但注意Thread类的构造方法只接受Runnable它不认识Callable直接new Thread(callable)是编译不过的。为了桥接两者JDK提供了FutureTask。它的关键设计是FutureTask本身实现了RunnableFuture而RunnableFuture又继承了Runnable和Future两个接口。所以FutureTask既能塞进Thread构造方法又能通过Future接口获取最终结果。执行流程就成了三层结构CallableString callable () - 任务结果; FutureTaskString 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); FutureInteger 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能拿到返回值 FutureString resultFuture executor.submit(() - callable result);这里面有两个细节值得品一下第一execute()对应的是Executor接口它只收Runnablesubmit()是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系列第一篇文章最值得先想明白的一件事。