
1. 从“单打独斗”到“协同作战”为什么我们需要启动新线程如果你刚开始接触Java可能大部分代码都是按顺序执行的一行接一行就像一个人在厨房里做饭先洗菜、再切菜、最后下锅炒每一步都得等上一步做完。这在处理简单任务时没问题但一旦遇到耗时操作比如从网络下载一个大文件、读取一个巨大的数据库、或者进行复杂的图像处理整个程序就会“卡住”用户界面失去响应体验极差。这就是多线程要解决的问题。启动一个新线程相当于你请了一个“帮手”。主线程比如负责更新UI的线程可以继续流畅地响应用户点击而把下载文件这种耗时任务交给新创建的“后台线程”去处理。两者并行不悖程序的效率和响应速度就上来了。在服务器端多线程更是高并发处理的基石一个服务端程序同时处理成千上万个客户端请求靠的就是线程池里大量复用的线程。今天我们就来彻底搞懂在Java中启动一个新线程的三种经典方法。这不仅是面试高频题更是日常开发中必须掌握的基本功。我会结合代码、原理以及我踩过的坑让你不仅知道怎么写更明白为什么这么写以及在不同场景下该如何选择。2. 方法一继承Thread类——最直观的“子承父业”这是教科书上最常见也最容易被初学者理解的方式。Thread类本身代表了线程你通过继承它并重写run()方法就定义了这个线程要执行的任务。2.1 基础实现与代码示例让我们先看一个最简单的例子创建一个打印数字的线程。public class MyThread extends Thread { private String threadName; public MyThread(String name) { this.threadName name; } Override public void run() { // 这里定义了线程要执行的任务 for (int i 0; i 5; i) { System.out.println(threadName 运行: i); try { // 睡眠一段时间模拟耗时操作 Thread.sleep(500); } catch (InterruptedException e) { System.out.println(threadName 被中断); } } System.out.println(threadName 结束.); } public static void main(String[] args) { System.out.println(主线程开始...); // 创建线程对象 MyThread thread1 new MyThread(线程-A); MyThread thread2 new MyThread(线程-B); // 启动线程注意是调用 start()不是 run() thread1.start(); thread2.start(); // 主线程继续执行自己的任务 for (int i 0; i 3; i) { System.out.println(主线程运行: i); } System.out.println(主线程结束等待子线程...); } }运行这段代码你会看到类似下面的交错输出每次运行顺序可能不同主线程开始... 主线程运行: 0 线程-A 运行: 0 主线程运行: 1 线程-B 运行: 0 主线程运行: 2 主线程结束等待子线程... 线程-A 运行: 1 线程-B 运行: 1 ...2.2 核心原理start()与run()的天壤之别这是新手最容易踩的坑务必理解透彻。run()方法这只是一个普通的方法。当你直接调用thread1.run()时它会在当前线程通常是主线程中同步执行run()方法里的代码。这根本没有创建新线程只是普通的方法调用完全失去了多线程的意义。start()方法这是一个本地方法Native Method由JVM底层实现。它的作用是启动一个新的线程JVM会从操作系统申请新的线程资源。初始化线程栈、程序计数器等为新线程准备好运行环境。将线程状态置为“就绪”Runnable等待操作系统调度。一旦被OS调度执行新线程会自动调用其run()方法。所以启动线程的唯一正确方式是调用start()。直接调用run()是常见的逻辑错误。2.3 适用场景与局限性分析什么时候用继承Thread类简单演示或学习概念清晰易于理解。线程本身需要重写Thread的其他方法比如Thread类中还有一些关于线程优先级、线程组等方法如果你的线程行为需要定制这些继承可能更方便。它的局限性为什么不是首选单继承限制Java是单继承的。如果你的类已经继承了另一个类比如Applet,Frame就无法再继承Thread。这严重限制了代码的灵活性。任务与线程耦合过紧Thread类代表“线程”这个执行单元而run()方法里的代码代表“任务”。继承的方式将两者绑定在了一起。一个任务是独立的逻辑它不应该关心自己是被哪个线程执行的。更好的设计是“任务”与“线程”分离。不利于资源共享如果多个线程执行相同的任务继承方式需要创建多个不同的线程子类对象不如共享同一个任务对象来得自然和高效。实操心得在我早期的项目中曾因为图方便大量使用继承Thread。后来当需要重构让某些类从继承Thread改为实现Runnable时改动成本很大。所以除非有非常特殊的理由否则在正式项目中我更推荐使用方法二。3. 方法二实现Runnable接口——更灵活的“任务委托”这是目前最推荐、使用最广泛的方式。它完美解决了继承Thread的局限性。3.1 基础实现与代码示例Runnable是一个函数式接口只有一个抽象方法run()。我们创建一个类来实现它定义任务内容然后将这个任务对象Runnable实例交给一个Thread对象去执行。public class MyRunnable implements Runnable { private String taskName; public MyRunnable(String name) { this.taskName name; } Override public void run() { // 定义任务逻辑 for (int i 0; i 5; i) { System.out.println(taskName 执行: i 线程名: Thread.currentThread().getName()); try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { System.out.println(主线程: Thread.currentThread().getName()); // 创建任务对象Runnable实例 Runnable task1 new MyRunnable(任务-1); Runnable task2 new MyRunnable(任务-2); // 创建线程对象并将任务传递给它 Thread thread1 new Thread(task1, 自定义线程-1); // 第二个参数可以指定线程名 Thread thread2 new Thread(task2); // 启动线程 thread1.start(); thread2.start(); // thread2会使用默认线程名如“Thread-1” // 使用Lambda表达式Java 8更简洁地创建Runnable任务 Thread thread3 new Thread(() - { for (int i 0; i 3; i) { System.out.println(Lambda任务执行: i); } }, Lambda线程); thread3.start(); } }3.2 为什么说它更优秀——解耦与复用解耦任务与线程MyRunnable类只关心“任务是什么”run方法里的逻辑完全不关心“谁来执行”。执行任务可以交给Thread也可以交给线程池ExecutorService甚至可以在单线程中直接调用run()。这种设计符合“单一职责原则”。突破单继承限制你的类可以实现多个接口同时还能继承另一个类。这给了类设计极大的灵活性。便于资源共享多个线程可以共享同一个Runnable实例。这在某些场景下非常有用但也需要注意线程安全问题后续会提到。public class SharedResourceDemo { private static int counter 0; public static void main(String[] args) throws InterruptedException { // 多个线程共享同一个Runnable任务 Runnable sharedTask () - { for (int i 0; i 10000; i) { counter; // 这里存在线程安全问题 } }; Thread t1 new Thread(sharedTask); Thread t2 new Thread(sharedTask); t1.start(); t2.start(); t1.join(); t2.join(); // counter的结果很可能不是20000因为counter不是原子操作 System.out.println(Final counter: counter); } }与现代并发API无缝集成Java 5之后引入的java.util.concurrent包中的线程池ExecutorService、Future、Callable等高级工具其核心设计就是接受Runnable或Callable任务。使用Runnable让你的代码能平滑地迁移到这些更强大的并发工具上。3.3 Lambda表达式带来的简洁革命从Java 8开始由于Runnable是函数式接口我们可以用Lambda表达式极大地简化代码尤其是在需要创建一次性任务时// 传统匿名内部类 new Thread(new Runnable() { Override public void run() { System.out.println(Hello from anonymous class); } }).start(); // Lambda表达式等价于上面 new Thread(() - System.out.println(Hello from Lambda)).start(); // 多行逻辑 new Thread(() - { // 模拟一些工作 String result doSomeWork(); System.out.println(Result: result); }).start();这种写法让代码意图更清晰将关注点完全放在任务逻辑本身。4. 方法三实现Callable接口与Future——能“带回结果”的线程前两种方法Thread.run()和Runnable.run()都有一个共同的缺点它们的run()方法返回类型是void。如果线程执行的任务需要返回一个计算结果或者需要抛出受检异常给调用者处理它们就无能为力了。Callable接口就是为了解决这个问题而生的。它定义了一个call()方法该方法可以返回泛型结果V并且可以抛出异常。4.1Callable与Future的基本用法你不能直接用Thread类来执行Callable任务。需要借助ExecutorService执行器服务通常是一个线程池来提交任务并返回一个Future对象。Future就像一个“凭据”你可以用它来查询任务是否完成、取消任务以及获取任务执行的结果。import java.util.concurrent.*; public class CallableDemo { // 定义一个实现Callable的任务计算阶乘 static class FactorialTask implements CallableLong { private int number; public FactorialTask(int number) { this.number number; } Override public Long call() throws Exception { // 注意可以返回值和抛出异常 if (number 0) { throw new IllegalArgumentException(Number must be non-negative.); } long result 1; for (int i 2; i number; i) { result * i; Thread.sleep(10); // 模拟一点耗时 } return result; } } public static void main(String[] args) { // 1. 创建线程池这里用单线程池演示 ExecutorService executor Executors.newSingleThreadExecutor(); try { // 2. 创建Callable任务 CallableLong task new FactorialTask(10); // 3. 提交任务给执行器得到一个Future对象 FutureLong future executor.submit(task); System.out.println(任务已提交主线程可以继续做其他事...); // 主线程模拟做一些其他工作 Thread.sleep(200); // 4. 在需要结果的时候通过Future获取 if (future.isDone()) { // 检查是否完成 System.out.println(任务已完成。); } // get() 方法会阻塞直到任务完成并返回结果 Long result future.get(); System.out.println(10的阶乘结果是: result); // 再提交一个会抛出异常的任务 FutureLong badFuture executor.submit(new FactorialTask(-5)); try { Long badResult badFuture.get(); } catch (ExecutionException e) { // ExecutionException包装了任务执行时抛出的异常 System.out.println(任务执行出错: e.getCause().getMessage()); } } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } finally { // 5. 关闭线程池重要 executor.shutdown(); } } }4.2Future.get()的阻塞性与超时控制future.get()是一个阻塞方法。如果任务还没执行完调用get()的线程通常是主线程会一直等待这可能造成程序卡顿。为此Future提供了带超时参数的get方法。try { // 最多等待2秒如果2秒后任务仍未完成则抛出TimeoutException Long result future.get(2, TimeUnit.SECONDS); } catch (TimeoutException e) { System.out.println(任务计算超时可能太复杂了。); // 可以选择取消任务 future.cancel(true); // true表示尝试中断正在执行任务的线程 }4.3 适用场景分析何时使用CallableFuture需要获取线程执行结果这是最典型的场景如并行计算、批量处理数据后汇总。任务可能抛出受检异常且需要在主线程中处理Callable.call()声明了throws Exception。需要更精细地控制任务生命周期通过Future可以查询状态、取消任务。它和Runnable的关系Callable是Runnable的增强版。在底层线程池执行器会将Callable包装成一个RunnableFutureFutureTask是其实现类它既是一个Runnable可以交给线程执行又是一个Future可以获取结果。所以你可以把Callable看作是一个“有返回值的Runnable”。踩坑实录早期不懂Future.get()会阻塞在一个Web请求处理中提交了多个Callable任务然后顺序调用它们的get()方法本以为能并行结果变成了“串行等待”严重拖慢了接口响应时间。正确的做法是先提交所有任务收集所有Future对象到一个列表然后再遍历列表获取结果这样任务的执行才是真正并行的。5. 三种方法的深度对比与选型指南了解了三种方法后我们通过一个表格进行全方位对比这能帮助你在实际开发中做出正确选择。特性维度继承Thread类实现Runnable接口实现Callable接口 Future核心区别线程即任务二者耦合任务与线程分离任务实现Runnable任务与线程分离任务实现Callable可返回结果返回值无 (void run())无 (void run())有 (V call())异常处理run()不能抛出受检异常只能在内部处理同Threadcall()可以抛出受检异常灵活性差单继承限制高可继承其他类实现多接口高同Runnable资源共享较差需创建多个线程对象好多个线程可执行同一Runnable实例好但需注意结果对象的线程安全与现代并发库集成不友好友好可直接提交给ExecutorService原生友好专为ExecutorService设计代码简洁度一般高尤其配合Lambda高配合Lambda典型应用场景简单演示、需要定制Thread行为的特殊场景绝大多数场景尤其是无返回值的异步任务需要获取执行结果或处理任务异常的并行计算场景性能与资源每次新建Thread对象开销较大任务对象可复用线程可通过线程池管理资源利用更高效同Runnable且能避免主线程轮询等待结果选型决策流你的任务需要返回一个结果吗或者需要抛出受检异常给调用者是- 选择CallableFuture/ExecutorService。否- 进入第2步。你的类是否因为单继承限制而无法继承Thread或者你希望任务与线程解耦是或希望解耦- 选择实现Runnable接口。否且任务极其简单仅用于演示- 可以考虑继承Thread但生产环境不推荐。结论对于生产环境的Java并发编程实现Runnable接口是默认和首选方案。当需要任务返回值时则升级为使用Callable接口。继承Thread类的方式因其固有的局限性在现代Java开发中已逐渐被视为一种“过时”或“仅用于教学”的模式。6. 从“启动线程”到“管理线程”线程池的必要性直接使用new Thread().start()在简单场景下没问题但在严肃的应用程序中尤其是服务器端这是非常危险的。这就是为什么“线程池配置”会成为网络热词。6.1 为什么不能随意new Thread()资源消耗大创建和销毁一个线程本身需要时间并且线程本身会占用大量内存主要是线程栈。频繁创建销毁线程会消耗大量系统资源。稳定性风险如果不加控制地创建线程在高并发下可能导致线程数量爆炸耗尽系统内存或CPU调度资源最终导致OutOfMemoryError或程序完全卡死。缺乏管理你无法方便地控制系统的总并发数、线程的生命周期、任务的排队与拒绝策略。6.2 引入线程池ExecutorService线程池的核心思想是复用。预先创建好一定数量的线程放在“池”里当有任务到来时从池中分配一个空闲线程来执行。任务执行完毕后线程不销毁而是返回池中等待下一个任务。这避免了频繁创建销毁的开销。Java通过java.util.concurrent.ExecutorService接口及其实现类如ThreadPoolExecutor提供了强大的线程池支持。我们通常使用Executors工具类来创建常见的线程池。import java.util.concurrent.*; public class ThreadPoolDemo { public static void main(String[] args) { // 创建一个固定大小为4的线程池 ExecutorService fixedThreadPool Executors.newFixedThreadPool(4); // 提交10个Runnable任务 for (int i 0; i 10; i) { final int taskId i; fixedThreadPool.submit(() - { System.out.println(Thread.currentThread().getName() 正在执行任务 taskId); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 优雅关闭线程池不再接受新任务等待已提交任务完成 fixedThreadPool.shutdown(); try { // 等待所有任务完成最多等1小时 if (!fixedThreadPool.awaitTermination(1, TimeUnit.HOURS)) { // 如果超时强制关闭 fixedThreadPool.shutdownNow(); } } catch (InterruptedException e) { fixedThreadPool.shutdownNow(); } } }6.3 关键配置参数解析回应热词热词中提到了“线程池 queuecapacity 队列大小怎么设置 和并发量的关系”这指向了线程池最核心的构造参数。我们直接使用ThreadPoolExecutor构造函数来理解ThreadPoolExecutor executor new ThreadPoolExecutor( corePoolSize, // 核心线程数即使空闲也会保留的线程数量。 maximumPoolSize, // 最大线程数池中允许的最大线程数。 keepAliveTime, // 空闲线程存活时间超过核心线程数的空闲线程多久后被回收。 unit, // 存活时间单位 workQueue, // 工作队列用于存放等待执行的任务的阻塞队列。 threadFactory, // 线程工厂用于创建新线程。 handler // 拒绝策略当线程池和队列都满了如何处理新提交的任务。 );队列大小queueCapacity与并发量的关系这是一个权衡。假设每秒有N个任务到达。如果workQueue是无界队列如LinkedBlockingQueue那么任务会一直堆积在队列里可能耗尽内存。但线程数不会超过corePoolSize。如果workQueue是有界队列如ArrayBlockingQueue设其容量为Q。当任务数 corePoolSize直接创建线程执行。当任务数 corePoolSize新任务进入队列排队。当队列也满了且当前线程数 maximumPoolSize会创建新线程直到达到maximumPoolSize来执行。当线程数已达maximumPoolSize且队列也满了新任务将触发拒绝策略。如何设置这没有银弹需要根据业务特点压测。CPU密集型任务计算为主建议线程数接近CPU核数队列可设短一些以便快速响应。corePoolSize≈ CPU核数maximumPoolSize可以等于或略大于corePoolSize。I/O密集型任务网络、磁盘IO为主线程可以多一些因为线程大部分时间在等待。corePoolSize可以设大如CPU核数*2队列也可适当设大。但要注意系统资源上限。队列容量需要结合业务容忍的延迟和系统内存来定。队列太长意味着任务等待时间久内存占用高队列太短则容易触发拒绝策略。经验之谈在Web服务器中常见的错误是使用无界队列Executors.newFixedThreadPool默认使用LinkedBlockingQueue。在突发流量下任务会无限堆积最终导致内存溢出OutOfMemoryError。生产环境更推荐使用有界队列并设置合理的拒绝策略如记录日志、暂存到外部队列、或向用户返回友好错误。7. 进阶话题与避坑指南掌握了三种启动方法和线程池基础你已经超越了80%的初学者。但要写出健壮的多线程代码还需要注意以下陷阱。7.1 线程安全共享数据的“修罗场”当多个线程访问和修改同一个共享变量如前面的counter时就会发生竞态条件导致结果不可预测。解决方法是同步。1. 使用synchronized关键字public class SynchronizedCounter { private int count 0; // 同步方法锁是当前实例对象(this) public synchronized void increment() { count; } // 同步代码块可以指定锁对象更灵活 public void incrementWithBlock() { synchronized (this) { count; } } public synchronized int getCount() { return count; } }2. 使用java.util.concurrent.atomic包下的原子类对于简单的计数器使用AtomicInteger等原子类性能更高因为它们利用了CPU的CAS指令。import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 原子操作 } public int getCount() { return count.get(); } }7.2 线程间协作wait(),notify()与notifyAll()这是经典的线程通信机制但使用起来非常容易出错现在更推荐使用java.util.concurrent包下的高级工具如CountDownLatch,CyclicBarrier,BlockingQueue等。// 使用 CountDownLatch 等待多个线程完成 public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int workerCount 5; CountDownLatch latch new CountDownLatch(workerCount); ExecutorService executor Executors.newFixedThreadPool(workerCount); for (int i 0; i workerCount; i) { executor.submit(() - { try { // 模拟工作 Thread.sleep((long) (Math.random() * 1000)); System.out.println(Thread.currentThread().getName() 完成工作); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); // 计数器减1 } }); } System.out.println(主线程等待所有工人完工...); latch.await(); // 阻塞直到计数器归零 System.out.println(所有工人都完工了主线程继续。); executor.shutdown(); } }7.3 死锁永恒的等待死锁是指两个或更多线程互相持有对方所需的资源导致所有线程都无法继续执行。经典条件是互斥、持有并等待、不可剥夺、循环等待。// 一个简单的死锁例子 public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { new Thread(() - { synchronized (lockA) { System.out.println(线程1持有lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { // 尝试获取lockB System.out.println(线程1同时持有lockA和lockB); } } }).start(); new Thread(() - { synchronized (lockB) { System.out.println(线程2持有lockB); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { // 尝试获取lockA System.out.println(线程2同时持有lockA和lockB); } } }).start(); } }运行后两个线程都会卡住。避免死锁需要精心设计锁的获取顺序或者使用tryLock等带超时的锁机制。7.4 虚拟线程Java 19 预览Java 21 正式发布这是Java并发模型的一次重大革新。传统平台线程我们上面讨论的与OS线程是1:1映射创建成本高。虚拟线程Virtual Threads是JVM管理的轻量级线程与OS线程是M:N映射。你可以创建成千上万个虚拟线程来处理大量并发I/O操作而系统开销极小。// Java 21 使用虚拟线程 try (var executor Executors.newVirtualThreadPerTaskExecutor()) { for (int i 0; i 10_000; i) { executor.submit(() - { Thread.sleep(Duration.ofSeconds(1)); System.out.println(Thread.currentThread()); return i; }); } } // executor.close() 会等待所有任务完成虚拟线程非常适合处理高并发、I/O密集型的请求如Web服务器它能用极少的OS线程支撑海量的并发任务是未来解决“线程池配置”和“高并发”难题的一把利器。不过它并不改变Runnable和Callable的任务定义方式只是换了一种更高效的执行载体。