ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java多线程入门:Thread、Runnable、Callable三种创建方式详解与实战对比

Java多线程入门:Thread、Runnable、Callable三种创建方式详解与实战对比 1. 项目概述为什么线程是Java开发的必修课在Java开发的世界里无论你是刚入门的新手还是已经摸爬滚打多年的老手“多线程”这个词都像空气一样无处不在却又常常让人感到既熟悉又陌生。我见过太多项目初期跑得飞快一旦用户量上来或者需要处理批量任务时程序就变得像老牛拉车一样慢甚至直接卡死。追根溯源十有八九是线程没用好。今天我们不谈那些高深莫测的线程池源码也不扯复杂的并发框架就从一个最基础、最核心、面试必问的问题入手在Java中如何启动一个新的线程这个问题看似简单背后却串联起了Java并发编程的整个基石。从最原始的Thread类到更优雅的Runnable接口再到后来引入的Callable和Future每一种方法都代表了不同时期的设计思想和最佳实践。理解它们不仅仅是知道怎么写代码更是理解“为什么这么写”。比如为什么推荐实现Runnable而不是继承ThreadCallable比Runnable强在哪里这些选择直接关系到你代码的可维护性、可扩展性甚至是性能。接下来我会结合我踩过的坑和实战经验把这三种方法的来龙去脉、使用场景和隐藏细节给你掰开揉碎了讲清楚。2. 核心方法一继承Thread类这是教科书上最常见也是最“原始”的一种方式。它的逻辑非常直白你想创建一个能独立运行的线程那就直接创建一个Thread类的子类。2.1 基础实现与原理java.lang.Thread类是Java语言中线程概念的具象化。当你继承它并重写其run()方法时你实际上是在定义这个线程被启动后要执行的具体任务。public class MyThread extends Thread { Override public void run() { // 这里是新线程要执行的代码 for (int i 0; i 5; i) { System.out.println(Thread.currentThread().getName() 执行: i); try { Thread.sleep(500); // 模拟耗时操作 } catch (InterruptedException e) { e.printStackTrace(); } } } } // 使用方式 public class Main { public static void main(String[] args) { MyThread thread1 new MyThread(); thread1.start(); // 关键调用start()来启动新线程 // 主线程继续执行 System.out.println(主线程执行完毕。); } }这里有一个至关重要的细节启动线程必须调用start()方法而不是直接调用run()。这是新手最容易栽跟头的地方。如果你直接调用thread1.run()那么run()方法里的代码会在当前通常是main线程中同步执行这根本没有创建任何新线程完全失去了多线程的意义。start()方法是一个本地方法Native Method它的作用是通知Java虚拟机JVM去操作系统申请一个新的系统线程资源然后由这个新的系统线程去自动调用你重写的run()方法。这个过程涉及到用户态到内核态的切换是真正的“启动”。2.2 方法优劣分析与适用场景优点简单直观对于初学者来说继承的思维模式符合直觉易于理解。直接访问Thread方法在你的自定义线程类内部可以直接调用Thread类的方法如setName(),setPriority()等因为this就是线程对象本身。缺点与注意事项单继承限制这是最大的硬伤。Java是单继承的你的类一旦继承了Thread就无法再继承其他任何类。这严重限制了代码的灵活性。在实际项目中你的业务类很可能需要继承自某个框架基类比如HttpServlet这时再用继承Thread的方式就行不通了。任务与线程耦合过紧这种模式下“任务”即run()方法里的逻辑和“线程执行机制”被捆绑在了一起。从设计模式角度看这违反了“单一职责原则”。线程应该是一种执行任务的机制而任务本身应该是独立的。资源共享不便多个Thread子类的实例之间如果需要共享数据通常需要将数据设置为静态static变量或者通过构造函数传入同一个对象引用不如Runnable方式来得自然。适用场景快速原型或演示写个小Demo验证想法时图个方便。需要重度定制线程行为当你确实需要重写Thread类的其他方法虽然这种情况极少时。明确不需要再继承其他类的简单工具类。实操心得在我早期的项目中曾因为图省事大量使用继承Thread的方式。后来当业务需要引入一个公共基类时重构代码成了噩梦。我的建议是除非有非常特殊的理由否则在生产代码中尽量避免这种方式。把它当作理解线程概念的起点就好。3. 核心方法二实现Runnable接口这是目前被广泛认为是最佳实践的标准方式。它完美解决了Thread继承方式的核心痛点。3.1 标准实现与解耦思想java.lang.Runnable是一个函数式接口只有一个抽象方法run()。它的设计精髓在于将“任务”与“执行任务的线程”进行解耦。public class MyRunnable implements Runnable { Override public void run() { // 定义任务内容 for (int i 0; i 5; i) { System.out.println(Thread.currentThread().getName() 执行: i); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } } // 使用方式 public class Main { public static void main(String[] args) { // 1. 创建任务对象 Runnable task new MyRunnable(); // 2. 创建线程对象并将任务传递给它 Thread thread1 new Thread(task, “MyThread-1”); // 第二个参数可设置线程名 Thread thread2 new Thread(task); // 也可以不设置名字 // 3. 启动线程 thread1.start(); thread2.start(); System.out.println(主线程执行完毕。); } }这种方式的美妙之处在于MyRunnable类就是一个纯粹的任务描述类它不关心自己会被哪个线程、以何种方式执行。它既可以像上面这样被多个Thread包装执行也可以提交给ExecutorService线程池去执行复用性极高。3.2 资源共享的天然优势实现Runnable接口的方式为多线程共享资源提供了极其便利的条件。因为你可以将同一个Runnable实例传递给多个Thread构造函数。public class SharedResourceRunnable implements Runnable { private int ticketCount 10; // 共享资源例如剩余票数 Override public void run() { while (ticketCount 0) { synchronized (this) { // 需要同步控制防止超卖 if (ticketCount 0) { System.out.println(Thread.currentThread().getName() 卖出一张票剩余: (--ticketCount)); } } try { Thread.sleep(100); // 模拟出票时间 } catch (InterruptedException e) { e.printStackTrace(); } } } } public class Main { public static void main(String[] args) { SharedResourceRunnable task new SharedResourceRunnable(); // 一个任务实例 // 三个窗口线程同时卖这10张票 new Thread(task, “窗口1”).start(); new Thread(task, “窗口2”).start(); new Thread(task, “窗口3”).start(); } }在这个经典的“卖票”例子中三个线程操作的是同一个task对象内部的ticketCount变量实现了资源的共享。如果使用继承Thread的方式你需要费劲地将票数设为静态变量或者通过复杂的构造器传递远不如这种方式清晰直接。3.3 Lambda表达式简化Java 8从Java 8开始借助Lambda表达式创建Runnable任务变得异常简洁尤其适合一次性执行的简单任务。public class Main { public static void main(String[] args) { // 传统匿名内部类方式 Thread t1 new Thread(new Runnable() { Override public void run() { System.out.println(“匿名内部类方式”); } }); // Lambda表达式方式推荐 Thread t2 new Thread(() - { for (int i 0; i 3; i) { System.out.println(“Lambda线程: ” i); } }); // 甚至更简洁单行语句 Thread t3 new Thread(() - System.out.println(“超级简洁的线程任务”)); t1.start(); t2.start(); t3.start(); } }Lambda表达式让线程的创建几乎没有任何样板代码极大地提升了开发效率也使得代码更加聚焦于业务逻辑本身。注意事项虽然Lambda很香但也要注意。如果任务逻辑非常复杂超过10行或者需要复用我仍然建议将其抽取成一个独立的、有名字的Runnable实现类这样代码结构更清晰也更利于测试和维护。不要为了简洁而牺牲可读性。4. 核心方法三实现Callable接口与Future前两种方式Thread.run(),Runnable.run()都有一个共同的局限它们的run方法返回类型是void。这意味着你无法直接从线程执行的任务中获取一个计算结果。如果你需要异步计算一个值并在主线程中获取它Callable和Future就是为此而生的黄金搭档。这是Java 5在java.util.concurrent包中引入的强大工具。4.1 Callable与Runnable的核心区别CallableV也是一个函数式接口它定义了一个call()方法。与Runnable的run()相比关键区别有两点有返回值call()方法返回一个类型为V的值。可抛出受检异常call()方法声明了throws Exception允许你将计算过程中遇到的异常向上抛出而run()方法只能内部处理。import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; public class MyCallable implements CallableString { private final String taskName; public MyCallable(String taskName) { this.taskName taskName; } Override public String call() throws Exception { System.out.println(taskName “ 开始执行...”); // 模拟一个耗时的计算任务 TimeUnit.SECONDS.sleep(2); int sum 0; for (int i 1; i 100; i) { sum i; } System.out.println(taskName “ 执行完毕。”); // 返回计算结果 return taskName “ 的计算结果是: ” sum; } }4.2 FutureTask连接Callable与Thread的桥梁Thread类的构造函数只能接收Runnable。为了让Callable能在新线程中运行我们需要一个适配器。FutureTask就是这个适配器它同时实现了Runnable和Future接口。作为Runnable它可以被Thread执行。作为Future它提供了方法来检查计算是否完成、等待计算完成并获取结果。public class Main { public static void main(String[] args) { // 1. 创建Callable任务 CallableString callableTask new MyCallable(“计算任务1”); // 2. 用FutureTask包装Callable FutureTaskString futureTask new FutureTask(callableTask); // 3. 将FutureTask交给Thread执行 Thread thread new Thread(futureTask); thread.start(); // 主线程可以继续做其他事情... System.out.println(“主线程在异步任务执行时可以做别的工作。”); try { // 4. 在需要结果时通过FutureTask获取 // get()方法是阻塞的会一直等待直到任务完成并返回结果 String result futureTask.get(); System.out.println(“获取到异步结果: ” result); } catch (InterruptedException e) { // 线程在等待过程中被中断 e.printStackTrace(); } catch (ExecutionException e) { // Callable任务执行过程中抛出了异常会被包装在ExecutionException中 System.out.println(“任务执行出错: ” e.getCause()); } } }4.3 Future的核心API与高级用法Future接口提供了对异步任务生命周期的控制get(): 获取结果如果任务还没完成会一直阻塞等待。get(long timeout, TimeUnit unit): 带超时的获取。在指定时间内没得到结果会抛出TimeoutException。这是防止线程无限等待、导致程序假死的必备技巧。isDone(): 判断任务是否已完成包括正常完成、被取消、发生异常。cancel(boolean mayInterruptIfRunning): 尝试取消任务。如果任务还没开始它永远不会运行如果任务正在运行mayInterruptIfRunning参数决定是否向执行线程发送中断信号。isCancelled(): 判断任务是否在正常完成前被取消。FutureTaskString futureTask new FutureTask(callableTask); new Thread(futureTask).start(); // 主线程先做点别的 TimeUnit.SECONDS.sleep(1); if (!futureTask.isDone()) { System.out.println(“任务还在进行中...”); // 再等最多500毫秒 try { String result futureTask.get(500, TimeUnit.MILLISECONDS); System.out.println(“在超时前得到结果: ” result); } catch (TimeoutException e) { System.out.println(“等待超时决定取消任务。”); boolean cancelled futureTask.cancel(true); // true表示尝试中断线程 System.out.println(“取消任务结果: ” cancelled); } }实操心得future.get()是一个阻塞调用在图形界面GUI或高并发服务的主线程中直接使用它很容易导致界面卡顿或服务响应迟缓。务必使用带超时参数的get方法或者将get操作也放到一个后台线程中执行。更现代的做法是使用CompletableFutureJava 8它提供了非阻塞的回调机制但FutureTask是理解这一切的基础。5. 三种方法的对比与选型指南了解了三种方法后我们通过一个表格来直观对比帮你做出最佳选择。特性维度继承Thread类实现Runnable接口实现Callable接口 Future核心区别线程即任务任务与线程分离可返回结果、可抛异常的任务返回值无 (void run())无 (void run())有 (V call())异常处理只能在run()内try-catch只能在run()内try-catch可抛出Exception由调用方处理继承限制受Java单继承限制不灵活无限制可实现多个接口灵活性高无限制灵活性高资源共享较麻烦需static或传参非常方便共享同一实例方便共享同一Callable或FutureTask代码复杂度低但耦合高低推荐中需处理Future线程池兼容可直接提交但设计不佳天然适合提交给线程池天然适合提交给线程池适用场景简单Demo、特定定制绝大多数业务场景、异步任务需要获取结果的异步计算、并行计算选型决策流你的任务需要返回一个结果吗是- 毫不犹豫选择CallableFuture。否- 进入下一步。你的任务类还需要继承其他类吗是- 选择Runnable。否- 进入下一步。你只是写一个临时测试或者有特殊线程行为要重写是- 可以考虑Thread但请清楚其局限性。否-默认且推荐选择Runnable。简单来说Runnable是通用解Callable是需返回值时的特化解而继承Thread在大多数生产代码中应被视为遗留方式或特殊用途。6. 从手动创建线程到线程池的最佳实践演进虽然学会了三种启动线程的方法但在真实的、严肃的生产环境中直接new Thread()并start()被视为一种反模式。原因如下资源消耗大创建和销毁一个操作系统线程的成本非常高。频繁创建会消耗大量系统资源。管理困难无法有效控制系统中并发线程的数量太多线程会导致过度切换性能下降太少则无法充分利用CPU。功能缺失缺乏统一的管理如定时执行、定期执行、线程中断策略等。线程池ExecutorService是解决这些问题的标准答案。它预先创建好一批线程工作线程形成一个“池子”。当有任务Runnable或Callable提交时池子分配一个空闲线程来执行。任务执行完毕后线程不销毁而是回到池中等待下一个任务。这实现了线程的复用极大地降低了开销。import java.util.concurrent.*; public class ThreadPoolDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { // 1. 创建线程池 (核心线程数2最大线程数5空闲存活时间1秒任务队列容量10) ExecutorService executor new ThreadPoolExecutor( 2, 5, 1L, TimeUnit.SECONDS, new ArrayBlockingQueue(10), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() // 拒绝策略直接抛出异常 ); // 2. 提交Runnable任务无返回值 executor.execute(() - System.out.println(“执行Runnable任务: ” Thread.currentThread().getName())); // 3. 提交Callable任务有返回值 FutureString future executor.submit(() - { TimeUnit.SECONDS.sleep(1); return “Callable任务结果”; }); // 4. 获取结果 System.out.println(“Future result: ” future.get()); // 5. 优雅关闭线程池重要 executor.shutdown(); // 停止接收新任务等待已提交任务完成 // executor.shutdownNow(); // 尝试立即停止所有正在执行的任务 boolean isTerminated executor.awaitTermination(5, TimeUnit.SECONDS); // 等待关闭 if (!isTerminated) { System.out.println(“仍有任务未完成强制关闭...”); } } }线程池配置的核心参数关系对应网络热词中的疑问corePoolSize核心线程数池中常驻的线程数量即使空闲也不会被回收除非设置了allowCoreThreadTimeOut。maximumPoolSize最大线程数池中允许的最大线程数。workQueue任务队列用于存放等待执行的任务的阻塞队列。queueCapacity队列大小与并发量的关系当任务提交时如果运行线程数 corePoolSize则创建新线程执行。如果运行线程数 corePoolSize则将任务放入队列。如果队列已满且运行线程数 maximumPoolSize则创建新线程执行。如果队列已满且运行线程数 maximumPoolSize则触发拒绝策略。系统最大并发量估算这取决于你的系统资源CPU核心数、内存、I/O和任务类型CPU密集型、I/O密集型。一个粗略的起点是对于CPU密集型任务线程数可设为CPU核数 1对于I/O密集型任务可设为CPU核数 * (1 平均等待时间/平均计算时间)。队列容量需要根据任务的平均处理速度和突发流量来设定起到“削峰填谷”的作用防止瞬间流量击垮系统。避坑指南绝对不要使用Executors的快捷工厂方法如newFixedThreadPool,newCachedThreadPool而不了解其内部队列newFixedThreadPool使用无界队列LinkedBlockingQueue任务过多会导致内存耗尽newCachedThreadPool最大线程数是Integer.MAX_VALUE可能创建海量线程拖垮系统。生产环境务必使用ThreadPoolExecutor构造函数根据实际情况明确指定所有参数。7. 常见问题与实战排查技巧即使理解了原理在实际编码和运行时多线程程序依然像一座布满暗礁的海洋。下面是我总结的一些最常见的问题和排查思路。7.1 线程安全与数据竞争这是多线程编程的万恶之源。当多个线程未经同步控制访问和修改同一共享数据时就会发生数据竞争导致结果不可预测。问题现象程序多次运行结果不一致计数器的值最终小于预期如前面卖票例子可能卖出超过10张票。解决方案synchronized关键字最基础的互斥锁。可以修饰方法或代码块。// 同步代码块 synchronized(lockObject) { // 访问共享资源的代码 } // 同步实例方法锁是当前实例this public synchronized void increment() { ... } // 同步静态方法锁是当前类的Class对象 public static synchronized void staticIncrement() { ... }java.util.concurrent.locks.Lock接口更灵活、功能更强的锁如ReentrantLock。支持尝试非阻塞获取锁、可中断、公平锁等特性。private final Lock lock new ReentrantLock(); public void safeMethod() { lock.lock(); // 获取锁 try { // 访问共享资源 } finally { lock.unlock(); // 务必在finally中释放锁 } }使用线程安全的数据结构ConcurrentHashMap,CopyOnWriteArrayList,AtomicInteger等。它们在内部实现了高效的线程安全机制多数情况下应优先考虑使用它们而非自己加锁。7.2 死锁Deadlock两个或更多线程互相等待对方持有的锁导致所有线程都无法继续执行。问题现象程序“卡住”了日志停止输出CPU占用率可能很低但程序无响应。产生条件四个必要条件缺一不可互斥条件请求与保持条件不剥夺条件循环等待条件排查与解决使用JDK工具运行程序后用jstack -l pid命令或JVisualVM等图形工具查看线程转储Thread Dump。在输出中搜索“deadlock”关键词JDK通常会帮你分析并指出死锁的线程和锁信息。避免策略固定锁的获取顺序所有线程都按相同的全局顺序例如按对象的哈希值排序申请锁。使用带超时的锁如Lock.tryLock(long time, TimeUnit unit)获取不到锁一段时间后放弃并释放自己已持有的锁。降低锁的粒度尽量只锁住必要的共享数据而不是大段代码或整个对象。7.3 线程间协作wait(), notify(), notifyAll()当线程需要等待某个条件满足时如生产者-消费者模型需要使用Object类的这组方法。它们必须在synchronized同步块内调用。public class ProducerConsumer { private final QueueString queue new LinkedList(); private final int MAX_SIZE 5; private final Object lock new Object(); public void produce(String item) throws InterruptedException { synchronized (lock) { while (queue.size() MAX_SIZE) { // 必须用while不能用if防止虚假唤醒 lock.wait(); // 释放lock锁并等待 } queue.offer(item); System.out.println(“生产: ” item); lock.notifyAll(); // 通知所有等待的消费者线程 } } public String consume() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } String item queue.poll(); System.out.println(“消费: ” item); lock.notifyAll(); // 通知生产者线程 return item; } } }关键细节判断条件时必须使用while循环而不是if语句。这是因为存在“虚假唤醒”spurious wakeup——线程可能在没有被notify的情况下从wait()中返回。用while可以确保被唤醒后再次检查条件是否真正满足。7.4 内存可见性与volatile关键字由于Java内存模型JMM和CPU缓存的存在一个线程对共享变量的修改可能不会立即被其他线程看到。问题现象一个线程修改了boolean flag false为true但另一个线程却一直读不到变化导致循环无法退出。解决方案使用volatile关键字修饰变量volatile保证了变量的可见性一个线程的修改能立刻被其他线程看到和有序性防止指令重排序但不保证原子性复合操作仍需同步。private volatile boolean running true; public void stop() { running false; // 写操作对其他线程立即可见 } public void run() { while (running) { // 读操作总能读到最新值 // do work } }使用synchronized或Lock锁的释放和获取会隐式地触发内存屏障保证可见性。使用Atomic类如AtomicBoolean它内部使用了volatile和CAS操作既保证了可见性又保证了简单操作的原子性。7.5 资源管理与线程中断线程可能因为I/O阻塞、锁等待、sleep()等原因而处于非运行状态。正确的中断和关闭线程是良好编程习惯的一部分。正确的中断响应Thread.interrupt()方法只是给线程设置一个中断标志位。线程需要自己检查这个标志并做出响应。public class InterruptibleTask implements Runnable { Override public void run() { // 正确做法定期检查中断状态 while (!Thread.currentThread().isInterrupted()) { try { // 如果任务中有可中断的阻塞调用如Thread.sleep, Object.wait, BlockingQueue.take // 这些方法在收到中断信号时会抛出InterruptedException TimeUnit.SECONDS.sleep(1); // ... 执行工作 } catch (InterruptedException e) { // 捕获到InterruptedException后中断状态会被清除 System.out.println(“任务被中断准备退出。”); // 恢复中断状态这是一个重要技巧让调用者知道发生了中断 Thread.currentThread().interrupt(); break; // 退出循环 } } System.out.println(“线程安全结束。”); } }关闭线程池如前所述务必调用shutdown()或shutdownNow()来优雅关闭线程池否则JVM可能无法正常退出。多线程编程的复杂性要求我们不仅要知道如何启动线程更要深刻理解其背后的并发原理、潜在陷阱和最佳实践。从最基本的三种启动方式入手逐步深入到线程池、锁、原子变量等并发工具最终构建出健壮、高效的多线程应用这是一个Java开发者成长的必经之路。每一次踩坑和解决问题的经历都会让你对“并发”二字的理解更加深刻。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表