
1. 先搞清楚一个前提线程的“创建”到底在创建什么很多人在学多线程的时候第一反应就是背面试题继承Thread、实现Runnable、实现Callable。背得滚瓜烂熟但真到写代码的时候还是会出问题。最典型的就是把“创建线程”和“创建一个能跑的任务”混为一谈。我举个例子你new了一个Thread对象但你没有调用start()那它就是个普通对象JVM里根本不会多出一条执行流。真正让一个线程“活”起来的是底层操作系统创建了一条原生线程然后Java层面的Thread对象去包装它、管理它。换句话说你写的三种创建方式本质上是在做两件事一是定义“这个线程要干什么”二是把“这个要干什么”交给一个Thread对象去启动。搞明白这个前提后面很多问题就通了。比如为什么Runnable接口只是定义了一个run()方法因为它只负责描述任务内容至于这个任务跑在哪个线程上、怎么调度、什么时候结束那是Thread和线程调度器的事。你定义任务JVM负责执行。再说深一点JVM里真正干活的线程是操作系统线程。HotSpot虚拟机里Java线程和内核线程是一一对应的调用Thread.start()之后底层会通过start0()这个native方法去创建一条新的系统线程。所以线程的创建成本并不低涉及内核调用、栈空间分配、上下文切换等这也是为什么后面要强调线程池的重要性。理解了这一层你再看“三种创建方式”它们其实都在解决同一个问题给Thread对象提供一个执行入口。只不过这个执行入口的形态不一样有的没有返回值有的有返回值有的还能抛受检异常。下面一个一个说。2. 方式一继承Thread类重写run方法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(); } }注意这里调用的是start()不是run()。你如果直接调t.run()那只是在当前线程里调用了一个普通方法不会新建线程。这在初学者里是高发错误我后面专门讲。这种方式的本质是把线程对象本身和任务绑定在一起。你定义了一个子类子类既是线程又携带了要执行的任务。看起来非常直观特别适合讲课演示。2.2 为什么说它“简单但危险”继承Thread有三个明显的坑。第一Java是单继承你一旦继承了Thread就不能再继承其他业务类了。如果这个类本身还需要依赖某个父类的逻辑你就只能改设计。很多项目里业务类已经有自己的继承体系硬生生再拉一个Thread父类进来整个类结构就变得很拧巴。第二任务和线程绑定死了不利于复用。同一个MyThread实例你只能start()一次第二次start()会抛IllegalThreadStateException。如果你想把同一个任务丢给多个线程并发执行就得new多个MyThread对象每个对象里都复制一份任务副本。如果任务是带状态的你还要小心多个对象之间状态同步的问题。第三任务逻辑写在run()里这个run()是继承下来的没法换。如果哪天你想让这个任务用线程池来跑你没法直接把MyThread丢给线程池因为线程池接收的是Runnable或Callable。你只能重构成实现接口的方式或者做适配。2.3 真的完全不能用吗也不是。一些小工具类、一次性脚本、简单的后台任务用继承Thread确实省事。尤其是你不需要什么额外的方法就是想在后台跑一段逻辑继承Thread写起来最短。我自己写演示代码或者做小实验的时候偶尔会用但生产代码里我几乎不写继承Thread。原因不是怕单继承而是任务与线程解耦这件事在真实项目里太重要了。你迟早要把任务提交给线程池Runnable接口的写法才是通用的。如果你现在还看不懂这句话等你用到ExecutorService那天就明白了。提示继承Thread的时候如果你想让多个线程共享同一个计数器千万别把计数器定义成实例变量然后new多个Thread对象。每个Thread实例是独立的实例变量不共享。想共享只能用静态变量但静态变量又有线程安全问题扯出一堆麻烦。这其实说明了一个道理继承Thread把任务放在线程对象里天然不利于任务状态共享。3. 方式二实现Runnable接口把任务交给Thread3.1 标准写法public class Task implements Runnable { Override public void run() { System.out.println(任务执行中 Thread.currentThread().getName()); } public static void main(String[] args) { Task task new Task(); Thread t1 new Thread(task); Thread t2 new Thread(task); t1.start(); t2.start(); } }这里能看到一个非常关键的区别同一个Task实例可以扔给两个Thread。两个线程跑的是同一个任务对象。如果Task内部有成员变量那么这两个线程天然共享这份状态。这是Runnable方式最核心的优势任务和线程分离。你在Runnable里描述的只是“做什么”至于谁来执行、开几个线程去执行完全由外部决定。你甚至可以把这个Runnable丢给线程池ExecutorService pool Executors.newFixedThreadPool(4); pool.submit(task);不需要改动任务类的一行代码。这在实际项目中太常见了。3.2 为什么更推荐这种方式用一句话总结Runnable把“任务”和“机制”拆开继承Thread把两者焊死。拆开之后你的任务类就是一个普通的接口实现类不受继承约束可以再实现其他接口也可以继承别的类。单元测试也好写直接new一个任务对象调用run()就能验证逻辑不用真的去起线程。另外Runnable配合线程池是绝配。线程池里的Worker线程是复用的一批线程你提交的任务只是被它们轮询执行。任务内容无论怎么变worker线程本身不用重建。这意味着避免了频繁创建线程的巨大开销。之前说过线程的创建要经过内核调用成本不低。在高并发场景下频繁new Thread然后start性能会很难看。我当时在做一个模拟项目X的并发压测时写过一段代码每个请求都new一个Thread去处理。结果压到300并发机器CPU直接打满线程堆栈里全是创建线程的竞争。后来改成固定线程池加Runnable任务同样压测资源占用降了一个量级。所以不是说Runnable本身比Thread快而是Runnable让你可以低成本地把任务交给复用线程这才是它被推荐的根本原因。3.3 常见误区run()和start()分不清这个坑我必须单独拿出来说因为踩过的人太多了。run()方法只是一个普通方法调用它不会启动新线程只是当前线程在那个方法里同步执行一遍代码。start()才是真正触发线程创建的关键。start()内部调用了start0()这个native方法由JVM去创建一条新的系统线程然后新线程会去执行run()方法。有个小细节值得注意如果你在run()里调用了Thread.currentThread().getName()用start()启动时名字是Thread-0之类的如果直接调run()名字是main。这个现象是用来区分自己有没有误调用的最直观手段。还有一种情况你在run()里面又调用了start()这会导致什么在不同的线程上再次尝试启动同一个线程对象。Thread对象的start()只能调用一次第二次必然抛IllegalThreadStateException。这是线程对象状态机保证的。实操心得写线程代码之前先问自己一句“我现在写的是任务还是线程”如果你发现自己在一个Thread子类里写业务逻辑大概率应该改成Runnable。这不仅是代码风格问题更是后续维护成本的取舍。4. 方式三实现Callable接口配合FutureTask4.1 为什么要引入CallableRunnable的run()方法没有返回值也声明不了受检异常。如果任务执行了一多半发现结果计算不出来你想把这个异常抛给调用方run()做不到。你只能在run()里try-catch然后打日志或者把结果塞到一个共享变量里让外部轮询读取。这两种办法都很难受。共享变量要额外处理同步轮询又浪费CPU还拿不到及时的结果。于是Java在1.5版本引入了Callable接口public interface CallableV { V call() throws Exception; }对比一下call()有返回值还允许抛异常。这解决了两大痛点结果可以安全地返回异常可以传递到调用方。但这里有个坑Thread类的构造函数不接收Callable。你没法直接new Thread(callable)。怎么办Java提供了一个适配器FutureTask。它实现了Runnable接口内部包装了一个Callable同时实现了Future接口可以获取执行结果和取消任务。4.2 完整代码示例import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class CallableDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { CallableString task () - { System.out.println(Callable任务执行线程 Thread.currentThread().getName()); Thread.sleep(1000); return 任务结果; }; FutureTaskString futureTask new FutureTask(task); Thread thread new Thread(futureTask); thread.start(); // 主线程可以继续做别的事 System.out.println(主线程继续干活); // 需要结果时再阻塞等待 String result futureTask.get(); System.out.println(拿到结果 result); } }这里有几个关键点。第一futureTask.get()是阻塞的。调用get()时如果任务还没执行完当前线程会挂起等待直到有结果或抛异常。你不想无限等的话可以用get(long timeout, TimeUnit unit)设置超时超时会抛TimeoutException。第二FutureTask是个适配器。new Thread接收的是Runnable接口而FutureTask恰好实现了Runnable所以能塞进去。但它内部真正执行的是你传入的Callable执行完之后把结果存到内部的outcome字段里get()再从里面取。第三FutureTask内部做了状态管理。任务还没开始时是NEW状态执行中是COMPLETING执行完是NORMAL或EXCEPTIONAL等。如果任务被取消会有CANCELLED状态。这些状态保证了一个FutureTask不能被两个线程重复执行第二个线程在start()同一个Thread时会抛异常而如果你把同一个FutureTask提交给线程池两次第二次会直接抛弃或拒绝取决于具体实现。4.3 和Runnable的本质区别表面区别是返回值本质区别是任务执行结果的可追踪性。Runnable模式下你发起一个任务后就失去了对这个任务的跟踪。它没跑完你也不知道它报错你也不知道run()异常只能在线程内被捕获除非你设置UncaughtExceptionHandler否则异常静默丢失。它到底成功没有没有任何机制告诉你。CallableFutureTask模式你持有一个Future对象就是持有了这个异步操作的一张“存款凭证”。你可以查询它是否完成可以取消它可以阻塞等待结果还可以在任务出错时拿到具体异常。这种能力在真实项目中非常关键。我用过一个实际的例子批量导入用户数据。每条数据解析、校验、写入单个任务返回一个结果对象包含成功还是失败、失败原因、耗时等。用Callable提交给线程池收集所有Future然后统一调用get()汇总结果。如果用Runnable这些信息就得用额外的并发收集器比如ConcurrentLinkedQueue去手动传代码量和出错概率都会增加。注意FutureTask.get()等待结果时如果你在任务执行过程中调用了futureTask.cancel(true)并且任务正在运行且响应中断那么任务会收到中断信号最终get()会抛出CancellationException。这个细节在写优雅停机逻辑时很有用。5. 三种方式对比与选型建议5.1 一张表说清核心区别对比维度继承Thread实现Runnable实现CallableFutureTask任务入口重写run()实现run()实现call()是否有返回值无无有能否抛受检异常不能不能能任务与线程关系绑定一个线程一个任务实例解耦可复用解耦可复用能否获取执行结果不能不能能通过Future.get()能否取消任务不能只能强制中断线程不能可以是否受单继承限制是否否配合线程池不方便方便方便适用场景演示、简单一次性任务生产环境最常见需要结果或异常传播的任务5.2 现实中到底用哪种我给你一个判断顺序如果任务需要返回结果或抛出异常优先Callable。如果任务不需要返回结果只需要执行逻辑优先Runnable。除非你在写教学示例或者做极简工具否则不要用继承Thread。这基本是行业共识。我翻过几个规模不小的Java项目生产代码里你基本找不到继承Thread的业务类但Runnable和Callable到处都有。特别是用了Spring之后开发者连手动提交Runnable都很少了都是Async注解加线程池配置底层依然是ExecutorService把任务包装成Runnable去执行。还有一点别忽略Lambda表达式。Runnable和Callable都是函数式接口所以现在写起来非常简洁Thread t new Thread(() - System.out.println(lambda线程)); t.start(); // 线程池提交Callable FutureInteger future pool.submit(() - 42);写起来更简单了而且代码意图也更清晰。但Lambda本质还是匿名实现类所以前面说的原理依然适用。5.3 补充线程池算不算“第四种”严格说线程池不是第四种创建线程的方式它是线程的管理方式。但很多面试官会故意把线程池和三种创建方式放到一起问考察你到底有没有真正理解线程创建的本质。线程池内部抽象了一个worker线程的概念。在ThreadPoolExecutor里线程池会创建若干个Worker对象每个Worker内部持有自己的Thread实例。当你调用execute()或submit()提交任务时任务会被放到工作队列里空闲的Worker会从队列里取任务执行。所以无论你提交Runnable还是Callable最终线程池内部也是通过new Thread一个worker线程来承载执行的只不过这个线程会被复用不会为每个任务新建。换句话说线程池并没有绕开Thread类的创建它只是通过复用机制大幅减少了创建和销毁的次数。回到开头那个点线程创建成本高所以复用是实现高性能的支点。补充如果你想真正深入线程池研读ThreadPoolExecutor源码时可以重点看addWorker()方法它是创建worker线程的入口。看完你会更清楚Runnable和FutureTask在线程池内部是如何被包装成worker任务的。6. 实操中的坑与排查经验6.1 线程安全问题三种方式都绕不开不管你是哪一种创建方式只要多个线程同时访问同一个共享变量就要考虑线程安全。最常见的N1事故就是计数器class Counter implements Runnable { private int count 0; Override public void run() { for (int i 0; i 100000; i) { count; } } }new两个Thread共享同一个Counter最后count不是200000因为count不是原子操作读、加、写三步线程切换会导致丢失更新。解决办法是锁、原子类、ThreadLocal或者不可变设计看场景选。还有一个隐蔽的坑你可能会觉得既然每个线程有自己的栈空间局部变量是安全的所以只要不加成员变量就安全。这个结论适用于无状态任务但一旦你用了静态变量或者依赖某类单例对象风险立刻就回来。6.2 那个经典的坑start()两次会怎样前面提过同一个Thread对象start()两次会抛IllegalThreadStateException。这个异常是在第二次调用start()时抛出的不是第一次。原因是Thread初始化时把线程状态设置为新建状态NEWstart()之后变为RUNNABLE第二次start()发现状态不是NEW直接抛异常。这个坑最常见的出现场景是你写了一个循环在循环里new Thread(task).start()写对了没问题。但如果你误把new Thread(task)写在了循环外面然后循环里反复调用同一个线程对象的start()第三次迭代就炸了。我印象里真有开发者在生产环境因为这个导致了定时任务中断。解决办法很简单要么每次创建新线程要么用线程池保证线程复用但不要试图复用同一个Thread对象。6.3 排查线程问题的几个工具命令如果你遇到线程相关的问题比如死锁、CPU飙高、线程数暴增优先用JDK自带工具。jps找到进程ID然后jstack打印线程堆栈。这是最常规的排查手段。比如死锁jstack会直接打印出“Found one Java-level deadlock”并列出互相等待的锁。线程数量暴增可以用jstack统计出大量线程卡在同一个地方然后顺藤摸瓜。如果你用的是Linux服务器可以用top -H -p 进程ID看具体哪个线程CPU最高然后把线程ID转成十六进制再到jstack里搜索对应的线程栈。这个组合操作很经典但要注意线程ID到十六进制的转换过程常见失误是忘记加0x前缀导致搜不到。还有一个容易被忽略的点Thread的线程名默认是Thread-0、Thread-1这种排查问题时非常不方便。生产代码里创建线程时几乎都会显式指定名字比如new Thread(task, order-pool-01)或者线程池工厂里用自定义ThreadFactory指定命名前缀。这样jstack一看就明白哪个线程是干什么的。没有这个习惯排查线上问题你只能对着几十个Thread-5分析效率极其低。我在实际项目里就是这么干的线程池的ThreadFactory统一设置成“业务名-线程编号”比如“import-worker-01”。这个收益在平时看不出来等到线上出问题要看堆栈时会非常感谢当初的那几行配置。实操心得写多线程代码时给自己立三条规矩。第一永远不要用裸的new Thread去跑生产级任务除非你能明确说出为什么不用线程池。第二Runnable和Callable之间做选择时先看有没有返回值再看有没有异常需要传播。第三给你的每一个线程起名字把ThreadFactory用好将来排查问题会省下大量时间。最后再分享一个小技巧如果你不确定一个任务该用哪种方式试着写一个接口返回结果就用Callable不返回结果就用Runnable。从多种方案里做选择名字只是想让你把任务和线程分开思考真正落地的还是“任务是什么、结果怎么拿、异常怎么处理”。把这三个问题想清楚了三种方式对你来说就是同一个答案的不同写法。