Java线程基础:创建、启动、终止与sleep深入解析 看到标题里有线程的创建、启动、终止及sleep我就知道这又是一篇给刚接触并发编程的Java新人准备的入门内容。说实话线程这块知识点我当年学的时候也绕了不少弯路——概念都懂一写代码就各种匪夷所思的问题要么线程没起来要么程序退不出去要么sleep时间不准要么只知道用stop()强行杀掉线程然后留下一堆隐患。这篇就把这些基础操作一次讲透穿插一些Java虚拟机层面的原理和工程上的取舍给准备面试的、刚工作的、甚至写了好几年业务代码但没认真抠过线程细节的朋友做个参考。1. 为什么要单独把线程基础拎出来讲很多人觉得线程基础没啥好学的面试也就是问个概念写业务代码时直接上线程池就行。这个想法我刚开始写Java时也有直到某次生产环境出了一次事故——一个定时任务用new Thread()裸奔跑到一半抛了异常线程死了定时任务再也没有下文。那次排查花了整整一个下午最后发现是创建线程的方式和异常处理机制出了问题。从那以后我才意识到线程的基础操作不只是面试敲门砖它决定了你对并发代码的控制力。线程的创建、启动、终止、休眠这四件事是并发编程的骨架。骨架没搭好后面你学线程池、锁、并发容器、原子类都会觉得悬浮在空中。比如你如果不理解start()和run()的区别就很难理解为什么线程池提交任务时用的是execute而不是直接调用run你如果不理解interrupt的中断机制就理解不了FutureTask取消任务时那套中断—响应—清理的协作流程。还有一点很重要线程这个概念本身就很容易被误解。很多人从操作系统课上听到线程是轻量级进程就真以为线程只是进程的缩小版。实际上在Java里一个线程背后关联着一整套执行状态程序计数器、虚拟机栈、本地方法栈、以及可选的ThreadLocal变量。线程创建、销毁的成本并不低哪些操作是线程安全的、哪些不是全部建立在你对线程生命周期的基本认知之上。所以这篇宁可讲得细一点也不直接丢一堆示例代码让你背。2. 线程创建的四条路径各自的适用场景要拎清线程创建在Java里看似有好几种写法本质其实只有两类一类是直接或间接创建Thread子类另一类是提供任务Runnable或Callable并交给某个线程载体去运行。四种常见方式我列个表先说结论再展开。创建方式核心思路返回结果适用场景继承Thread子类重写run()无直接返回简单的、一次性的、不需要复用的任务实现Runnable任务与线程分离无直接返回同一任务多线程执行推荐大量使用Callable FutureTask任务可返回结果、可抛异常有通过Future获取需要计算结果的异步任务线程池提交复用线程管理生命周期视提交方式而定生产环境绝大多数场景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(); } }问题在哪Java是单继承你extends Thread之后这个类没法再继承别的业务基类。而且把任务代码直接塞进Thread子类里任务和线程耦合在一起想换一种执行方式比如丢线程池就得重写。工程上我很少见到合理的使用场景唯一的例外是当你需要重写Thread的其他方法比如interrupt、join之类的钩子或者你想维护一个线程子类统一管理某些元数据时才值得这么干。2.2 实现Runnable任务与线程解耦的正确姿势Runnable接口只有一个run()方法没有返回值也不能抛受检异常。它把要执行的逻辑和由哪个线程执行分开了Runnable task () - System.out.println(通过Runnable执行 Thread.currentThread().getName()); Thread t1 new Thread(task, worker-1); t1.start();这里有个经常被忽略的点创建Thread对象时不传线程名系统会自动生成Thread-0这种名字。在日志里排查问题的时候这种名字几乎没有信息量。我习惯在new Thread()的第二个参数里把线程名带上哪怕只是task-executor-01出问题时看日志也能快速定位到具体线程。2.3 Callable FutureTask当任务需要返回结果Runnable的局限在于run()没有返回值也没法抛受检异常。于是JDK提供了CallableFutureTaskInteger futureTask new FutureTask(() - { Thread.sleep(2000); return 42; }); Thread t new Thread(futureTask, compute-thread); t.start(); Integer result futureTask.get(); // 阻塞直到任务完成 System.out.println(计算结果 result);Callable和Runnable的最大区别在于call()方法可以返回结果、可以抛异常。FutureTask同时实现了Runnable和Future所以既能交给Thread或线程池执行又能从外部获取执行结果和状态。这里的get()是阻塞的等任务做完才能拿到值如果想要超时没结果就不等了可以用get(3, TimeUnit.SECONDS)。2.4 线程池生产环境多数时候的直接选择虽然标题是线程基础但我必须提前把线程池拎出来说一句——初学者容易把创建线程理解成每次要干活就new一个Thread这是非常典型的误区。Java的线程生命周期开销很大创建线程需要分配虚拟机栈、进行系统调用销毁线程也要系统调用。如果任务本身只执行几十毫秒频繁创建销毁线程的开销可能比任务本身还高。所以生产环境里我更倾向于用Executors或ThreadPoolExecutor预先准备好一批线程让它们循环地取任务、执行、再取任务。线程池不是本篇主角但你在选择用什么方式创建线程时思维上应该从new一个线程切换到向线程池提交一个任务。对这四种方式的取舍我的建议很直接写demo和教学可以用继承Thread实际项目中写任务一律Runnable或Callable生命周期由线程池管理不要自己new Thread散养。3. start()和run()之间隔着整个线程调度的底层机制这是Java并发面试里最经典的一道题为什么启动线程必须调用start()直接调用run()不行吗直接调用run()当然能执行但它是同步的代码跑完才返回。线程并没有启动没有创建新的执行栈没有进入就绪状态等待操作系统调度。run()在调用它的线程里跑从头到尾都是单线程。start()才是真正去请求Java虚拟机创建一个新的线程新线程进入就绪队列等待操作系统分配CPU时间片一旦被调度执行它的入口就是这个线程的run()方法。来梳理一下start()之后的时间线当前线程调用t.start()Java虚拟机内部为新线程分配必要的栈空间线程状态从NEW变成RUNNABLE。新线程进入就绪队列等待操作系统调度。当操作系统选中这个线程分配CPU时新线程开始执行run()方法。run()执行完毕或抛出未捕获异常线程终止状态进入TERMINATED。另一个新手经常踩的坑是同一个线程对象不能重复start()第二次调用会抛出IllegalThreadStateException。这背后的原因和线程只能有确定的生命周期有关——一个已经运行完的线程它内部的固有属性比如线程状态标识已经定下来了Java虚拟机不会允许一条线程对象从头再执行一遍。直接调run()还有一个隐患藏在Java的内存模型里。由于没有真正创建新线程你的多线程程序其实是单线程在跑如果run()里有耗时操作界面会卡死、定时任务会积压而且因为没走线程调度线程优先级、中断状态这些机制也都不会生效。我自己的习惯是写完线程代码之后先想想这个线程到底是被start()启动的还是被run()同步调用了这种低级bug调试的时候特别费眼神因为代码看着好像没问题程序也在跑就是行为和你预期完全不一样。4. sleep的细节里藏着不少容易翻车的点sleep恐怕是Java线程操作里最简单也最容易被轻视的方法。它的基本语义是让当前正在执行的线程暂停执行一段时间期间不释放已经持有的锁时间到了自动回到就绪状态继续被调度。就这么点事实际用起来细节很多。4.1 参数、单位与TimeUnit最原始的写法是Thread.sleep(1000)单位是毫秒。JDK 5之后有了TimeUnit枚举我强烈建议不要再用裸的毫秒数字而是写TimeUnit.SECONDS.sleep(1)或者TimeUnit.MINUTES.sleep(2)。好处有两个代码可读性明显提升没人需要去数那一串0到底是多少毫秒底层会帮你做单位换算不会出现上一秒和下一秒之间单位搞混的错误。还有个细节是sleep传入的时间是至少的保证不是精确到毫秒的保证。线程睡眠到期后并不会立刻执行它只是进入就绪状态具体什么时候拿到CPU时间片还得看操作系统的调度策略。如果你写了sleep(100)它实际睡了110毫秒甚至更多都是正常的别拿这个去质疑JVM。4.2 sleep(0)和负数参数分别代表了什么sleep(0)在某些文章里被描述成让出CPU这个说法不准确。Thread.sleep(0)的本质是不做任何暂停但它会触发操作系统重新进行一次线程调度让同优先级的其他线程有机会获得CPU时间片。严格说的话它更像是主动要求重新调度而不是让出。参数传负数呢Thread.sleep(-1)会直接抛出IllegalArgumentException。很多人以为Thread.sleep的参数类型是long传负数应该没问题实际上Java虚拟机在native层会校验这个值负数一律非法。从规范上讲sleep的时间只能是非负的没得商量。4.3 sleep与interrupt的交互InterruptedException是响应而非崩溃sleep最大的坑在这里当一个线程正在sleep时如果另一个线程调用了它的interrupt()sleep会立刻醒来抛出一个InterruptedException同时清除该线程的中断标记。很多新手第一次遇到InterruptedException时很慌觉得程序报错了。实际上这是JVM在告诉你这个线程被人请求中断了你自己决定怎么办。常见的处理方式有三种在catch里恢复中断标记catch (InterruptedException e) { Thread.currentThread().interrupt(); }——适合当前线程是交给别人管理的那种情况比如线程池里的工作线程。把异常继续向上抛如果方法允许直接在方法签名上声明throws InterruptedException让调用方去处理。把异常吞掉直接返回除非你非常确定业务上无所谓否则我坚决不推荐因为中断信息一旦被吞掉上层的任务取消机制就失效了。其实把InterruptedException翻译成中断异常很误导人。它不是一个程序错误而是一个协作信号。就像你在排队打饭旁边有人拍你肩膀说别排了走吧你做什么反应取决于你自己——继续排、走人、还是先看看情况再说。4.4 sleep和wait的本质区别这两个方法太容易被混淆了我在代码评审里见到过不少把wait写成sleep的case或者反过来。说一个最关键的差异sleep是Thread的静态方法谁调用就睡谁Sleep期间不释放任何锁wait是Object的实例方法必须先持有该对象的监视器锁才能调用而且wait期间会释放锁让其他线程有机会进入同步块。有这么个经典例子如果三个线程共用一把锁一个线程在临界区里调sleep(5000)其他线程在外头干等5秒同样场景调wait()当前线程释放锁其他线程可以进来。具体用哪个取决于你到底是想让线程暂停一下继续干活还是等某个条件满足再干活。我自己的经验是凡是和条件判断相关的等待优先考虑wait/notify或者更高的并发工具比如CountDownLatch、Condition单纯就是想让线程节奏别太快、模拟耗时、或者做定时节奏控制用sleep就够了。5. 线程终止stop()早就该退出历史舞台真正靠的是协作聊到线程终止很多老程序员第一反应是Thread.stop()。这个方法从JDK 1.2开始就被标记为Deprecated但时不时还能在一些遗留代码里见到。必须明确一点stop()不是不能使线程停止而是它以极其暴力的方式做到这一点带来的后果往往比不停止还严重。5.1 为什么stop()是安全禁用的stop()的核心问题是它不给你任何清理的机会。它会让线程抛出一个ThreadDeath异常一个特殊的Error这个异常可以穿过几乎所有的catch块ThreadDeath在Java虚拟机早期设计里刻意不要求被捕获线程当前持有的所有锁都会立即释放已经写入一半的数据结构不会回滚文件的缓冲区不会flush数据库连接不会归还。举个例子一个线程往一个共享的ArrayList里写数据写到一半被stop()杀了另一个线程恰好在同一时刻读这个ArrayList就会看到半个对象。这种数据损坏可能不会立刻报错等一两周后在某个诡异的线上问题里爆发出来根本无从查起。所以不要用stop()这已经是业界的共识。如果你在公司的代码里看到有人还在用建议尽早提一个清理它的技术债。5.2 标志位法最朴素的协作式终止既然不允许暴力终止那就只能让线程自己决定什么时候退出。最直观的做法是定义一个volatile布尔标志线程每执行一段时间就检查一下这个标志发现外部要求终止就主动退出循环、清理资源、结束run()public class Worker implements Runnable { private volatile boolean running true; public void shutdown() { running false; } Override public void run() { while (running) { // 执行一轮任务逻辑 try { // 模拟耗时操作 Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; // 也可以通过标志位配合退出 } } // 清理资源 System.out.println(工作线程已安全退出); } }这里用volatile修饰running非常关键。如果不加volatile其他线程修改running的值在当前工作线程的视角里可能长时间不可见。关于volatile的原理展开说又是一大篇这里只需要记住一个通俗的解释volatile保证了多线程之间对这个变量的修改立刻对其他线程可见并且禁止编译器和CPU对它做过度重排。5.3 interrupt()Java提供的标准协作中断机制标志位法的局限也很明显如果线程正阻塞在sleep、wait、join这类操作上它根本没法去检查标志位。这时候就需要interrupt机制。interrupt()做的事情是若线程处于可中断的阻塞状态它会打断这个块抛出InterruptedException若线程在正常运行它会设置线程的中断标记线程可以自己通过isInterrupted()去检测这个标记并做出响应也可以完全不响应——中断只是一个信号不是强制命令。interrupt机制和Runnable标志位相比好处在于它和JVM内建的阻塞操作天然整合。你不太可能让所有阻塞方法都支持你的自定义标志位但JVM已经让sleep、wait、join、Condition.await、SocketChannel等一大批阻塞操作都支持interrupt。我自己在工程里处理线程终止一般是两套结合使用如果线程主要在执行CPU密集型计算逻辑很少处于阻塞状态就选择volatile标志位来终止简单直观如果线程会频繁陷入阻塞操作等待IO、等待条件、sleep模拟耗时就用interrupt机制来打断它并且配合catch InterruptedException来做资源清理。5.4 终止之后还有哪些尾巴要收拾线程终止不等于所有事情都完了。还有三件事值得注意。第一线程终止后线程对象仍然可以被访问它的isAlive()会返回falsegetName()还能拿到原本的名字。如果你持有的是线程池任务结束后线程本身并没有销毁而是回到线程池继续等待下一个任务这个生命周期概念要分清。第二正确识别线程到达终点和线程被要求终止。正常情况下run()执行完线程自然死亡被中断时如果run()内部没有对InterruptedException做出正确的响应线程可能不会因中断而终止。所以中断只是信号线程什么时候停取决于你代码里写的协作逻辑。第三异常处理。线程内部抛出未捕获异常时线程会终止但异常不会传播到创建它的线程里。想要监听线程的异常可以给Thread设置setUncaughtExceptionHandler或者在FutureTask的场景下通过future.get()捕获ExecutionException。很多线上问题里线程悄悄死了根因就是忘了设置异常处理器。6. 实操中常见的小问题逐个说透基础操作讲完了我把这几年在实际代码和面试题里反复出现的小问题集中梳理一下有些是踩过的坑有些是容易被忽略的边界情况。6.1 线程名与日志排查new Thread(runnable)默认生成的线程名是Thread-0、Thread-1这种一旦你的应用里同时跑了十几个线程日志一出来根本没法看。我给自己定了个规矩凡是创建线程必须显式指定线程名最好带上业务语义比如order-query-worker。Java 19之后Thread增加了一个以虚线程为主的新构造API但线程命名的习惯依然适用。使用线程池时同理ThreadFactory里的setName务必配置好。6.2 用lambda写Runnable时的注意事项Java 8之后Runnable最常见的写法是lambda表达式。有一个很经典的坑lambda里引用外部局部变量时这个变量必须是final或effectively final。如果你在lambda里试图修改循环变量编译直接报错。// 这种写法编译不过 for (int i 0; i 10; i) { new Thread(() - System.out.println(i)).start(); } // 正确写法把i拷贝到一个final变量里 for (int i 0; i 10; i) { final int taskId i; new Thread(() - System.out.println(taskId)).start(); }原因其实很简单lambda或者说匿名内部类访问外部局部变量时本质上捕获的是变量的值副本而不是变量本身。Java虚拟机规范里这种做法要求变量不可变否则无法保证多线程环境下的一致性。6.3 线程优先级这个东西别抱太大期望Thread类有MIN_PRIORITY(1)、NORM_PRIORITY(5)、MAX_PRIORITY(10)三档很多新手以为设置越高线程执行越快能靠它做调度。实际效果因操作系统而异对于现在的绝大多数主流JVM实现尤其是HotSpot在Linux上的实现线程优先级映射到操作系统原生优先级但操作系统并不保证严格按照这个顺序调度。频繁使用优先级非但不靠谱还可能带来性能回退。我基本不在业务代码里调优先级宁可通过调整任务拆分粒度、线程数量来控制行为。6.4 join的使用等别人的线程跑完join()是让当前线程等待被join的线程终止之后再继续执行。这个方法写得简单但在实际项目里很有用。比如你在主线程里派一个子线程去预加载配置主线程想等它加载完再继续初始化就可以让主线程调子线程的join()。注意join()也有一个限时版本join(long millis)。如果子线程迟迟不结束你不想无限等下去就给它一个上限超时后主线程继续走。这个方法在面试里常被问到和sleep、wait的区别三者放在一起回答时思路是sleep不涉及锁、wait涉及释放锁、join涉及等待目标线程结束。6.5 守护线程的两个实际意义setDaemon(true)把线程设为守护线程。守护线程有一个很实际的应用场景作为后盾做一些辅助性的清理工作不需要等到它结束才让进程退出。比如一些监控用的后台打印、缓存清理线程。但你必须清楚如果JVM中只剩下守护线程JVM会直接退出不会等待守护线程执行完。所以千万别把重要的任务逻辑放在守护线程里——进程要退出时守护线程可能随时被终止连清理的机会都没有。线程池里的工作线程默认是非守护的这在某种意义上正是为了避免JVM过早退出。7. 一块容易混乱的操作集合interrupt、isInterrupted与interrupted这部分我单独拎一节是因为三者的区别让很多人面试时栽过跟头。interrupt()是实例方法请求中断一个线程设置中断标记。isInterrupted()是实例方法查询目标线程是否已被中断不改变中断标记。interrupted()是静态方法查询当前线程是否已被中断但会清除中断标记。关键就是那个静态方法interrupted()会消费掉中断标记类似读取之后再重置。如果你先调Thread.interrupted()返回了true紧接着再调一次大概率返回false。还有一个容易忽略的地方InterruptedException被抛出时JVM会清除中断标记。这就是为什么很多文章建议在catch块里调用Thread.currentThread().interrupt()把标记恢复否则上层代码再去检查isInterrupted()时会得到一个中断已经消失的假象。我自己在协作式取消的场景里几乎总是在catch InterruptedException后立即恢复中断标记这是让中断信号可以一级一级向上传递的关键。给一个完整的协作中断示例把前面几个点串起来public class InterruptDemo { public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { System.out.println(执行任务中...); try { Thread.sleep(500); } catch (InterruptedException e) { System.out.println(线程在sleep中被中断响应并退出); Thread.currentThread().interrupt(); // 恢复中断标记 } } System.out.println(任务线程退出); }, interrupt-demo-worker); worker.start(); Thread.sleep(2000); worker.interrupt(); // 主线程请求中断 } }运行这段代码能清楚地看到interrupt之前线程每500毫秒跑一轮interrupt之后如果线程正卡在sleep里它会立刻抛出InterruptedException进入catch块恢复中断标记循环条件isInterrupted()为true循环退出。这个模式几乎是所有协作式取消的基础。8. 关于线程基操最后想提醒的一点点经验这可能是整篇文章里最重要的一段经验Java线程操作不是单靠API调用就能做对的你得先建立一套关于协作的心智模型。线程不是被指挥的木偶你不能命令它随时停止、随时开始、随时执行到某一行——你只能发出信号最终的执行权始终在线程自己手里。所以在写线程代码之前先问自己三个问题这个线程要不要长期活着它活着的时候可能阻塞在什么地方外部想停止它时它有没有足够的时间去清理现场这三个问题想清楚绝大多数线程相关的bug都能在设计阶段被掐死。面试里问到线程基础时别把概念背一遍就完事最好配合自己的实践谈。比如你能说出我在哪个项目里用过interrupt机制解决任务取消能让面试官明显感受到你是真的写过并发代码。后面如果时间和篇幅允许我会接着把线程的同步互斥、synchronized与Lock的选择、volatile底层原理、线程池核心参数逐一拆开写。线程这玩意儿越往底层挖越有意思也越能理解它为什么这么设计。希望这篇对你能有一点实际的帮助。