Java 多线程编程:让程序同时干多件事
本文系统讲解 Java 多线程编程,覆盖进程与线程、Java 默认多线程、Thread、Runnable、Callable、Thread 常见方法、线程安全、同步代码块、同步方法、Lock 锁、ThreadLocal、JDK 线程池和自定义 ThreadPoolExecutor。文章从概念到代码逐步展开,帮助读者理解并发任务、共享数据保护和线程资源管理。
多线程的核心目标,是让一个程序在同一段时间里处理多件事情。比如后台系统一边接收请求,一边访问数据库,一边写日志;桌面程序一边下载文件,一边保持界面可操作。学习多线程时,不要一开始就陷入复杂名词,先抓住一条主线:线程负责执行任务,锁负责保护共享数据,线程池负责管理线程资源,ThreadLocal 负责线程数据隔离。
1. 进程和线程的相关概念
进程是正在运行的程序。比如打开一个浏览器、一个 IDEA、一个微信,它们在操作系统里都可以看成进程。进程有自己独立的内存空间,进程之间默认互不影响。
线程是进程内部的一条执行路径。一个进程至少有一个线程,Java 程序启动后最先运行的是 main 线程。一个进程中可以有多个线程,它们共享进程里的内存数据。
可以这样理解:
| 概念 | 类比 | 特点 |
|---|---|---|
| 进程 | 一个公司 | 有独立资源,进程之间隔离 |
| 线程 | 公司里的员工 | 共享公司资源,可以同时做不同任务 |
线程共享数据带来了效率,也带来了风险。多个线程同时修改同一个变量时,如果没有控制顺序,就可能出现数据错乱,这就是后面要讲的线程安全问题。
2. Java 程序默认是多线程的
很多同学以为只有自己创建了 Thread,程序才是多线程。其实不是。Java 程序启动后,JVM 本身就会启动多个线程,例如:
main线程:执行我们写的main方法。- GC 线程:负责垃圾回收。
- 编译优化相关线程:帮助 JVM 优化热点代码。
- 信号处理、监控等 JVM 内部线程。
可以用下面的代码观察当前 JVM 中的线程:
import java.util.Map;
public class ThreadListDemo {
public static void main(String[] args) {
// getAllStackTraces 可以拿到当前 JVM 中所有存活线程的信息
Map<Thread, StackTraceElement[]> threadMap = Thread.getAllStackTraces();
for (Thread thread : threadMap.keySet()) {
System.out.println(thread.getName());
}
}
}
输出里通常不只有 main,还会看到一些 JVM 内部线程。这说明 Java 程序天然运行在多线程环境里。
3. 创建线程方式一:继承 Thread 类
第一种方式是自定义类继承 Thread,然后重写 run() 方法。
public class MyThread extends Thread {
/**
* 定义线程要执行的任务。
*/
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(getName() + " 正在执行:" + i);
}
}
}
启动线程:
public class ThreadDemo {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.setName("线程A");
t2.setName("线程B");
// start 会开启新线程,并由 JVM 自动调用 run 方法
t1.start();
t2.start();
}
}
注意:启动线程必须调用 start(),不要直接调用 run()。
t1.start(); // 正确:开启一条新的执行路径
t1.run(); // 错误理解:这只是普通方法调用,不会创建新线程
继承 Thread 的优点是写法直观,缺点是 Java 只支持单继承。如果这个类已经继承了其他父类,就不能再继承 Thread。
4. 创建线程方式二:实现 Runnable 接口
第二种方式是实现 Runnable 接口,把“任务”和“线程对象”分开。
public class MyRunnable implements Runnable {
/**
* 定义线程任务,Runnable 本身不代表线程,只代表可执行任务。
*/
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " 正在执行:" + i);
}
}
}
启动线程:
public class RunnableDemo {
public static void main(String[] args) {
MyRunnable task = new MyRunnable();
Thread t1 = new Thread(task, "线程A");
Thread t2 = new Thread(task, "线程B");
t1.start();
t2.start();
}
}
这种方式更常用,因为它有几个优势:
- 避免 Java 单继承限制。
- 任务和线程分离,结构更清晰。
- 多个线程可以共享同一个任务对象,适合模拟共享数据场景。
也可以用 Lambda 简化:
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务");
}, "工作线程");
thread.start();
5. 创建线程方式三:实现 Callable 接口
Runnable 的 run() 方法没有返回值,也不能直接抛出受检异常。如果希望线程执行完后返回结果,可以使用 Callable。
Callable 通常要配合 FutureTask 使用:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class CallableDemo {
public static void main(String[] args) throws Exception {
Callable<Integer> task = new Callable<Integer>() {
/**
* 计算 1 到 100 的和,并把结果返回给调用方。
*/
@Override
public Integer call() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread thread = new Thread(futureTask, "计算线程");
thread.start();
// get 会阻塞等待,直到线程执行完成并返回结果
Integer result = futureTask.get();
System.out.println("计算结果:" + result);
}
}
三种创建方式对比:
| 方式 | 是否有返回值 | 是否适合共享任务 | 常见使用场景 |
|---|---|---|---|
继承 Thread | 否 | 一般 | 简单演示、入门理解 |
实现 Runnable | 否 | 适合 | 常规多线程任务 |
实现 Callable | 是 | 适合 | 需要拿到线程执行结果 |
6. Thread 类的常见方法一
Thread 类提供了很多方法,用来设置线程名称、获取当前线程、启动线程和控制线程执行。
| 方法 | 作用 |
|---|---|
start() | 启动线程,让 JVM 调用 run() |
run() | 线程任务内容,直接调用不会开启新线程 |
setName(String name) | 设置线程名称 |
getName() | 获取线程名称 |
currentThread() | 获取当前正在执行的线程对象 |
示例:
public class ThreadMethodDemo {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
Thread current = Thread.currentThread();
System.out.println("当前线程对象:" + current);
System.out.println("当前线程名称:" + current.getName());
});
thread.setName("下载线程");
thread.start();
System.out.println("main 方法由 " + Thread.currentThread().getName() + " 执行");
}
}
线程名称很重要。真实项目里如果日志中只有 Thread-1、Thread-2,排查问题会很痛苦。给线程起清晰名字,是一个好习惯。
7. Thread 类的常见方法二
下面这些方法会影响线程的执行节奏。
| 方法 | 作用 |
|---|---|
sleep(long millis) | 让当前线程休眠指定毫秒 |
join() | 等待另一个线程执行完 |
yield() | 提示当前线程愿意让出 CPU |
setDaemon(boolean on) | 设置为守护线程 |
setPriority(int priority) | 设置线程优先级 |
sleep 示例:
public class SleepDemo {
public static void main(String[] args) throws InterruptedException {
for (int i = 3; i >= 1; i--) {
System.out.println(i);
// 让当前 main 线程休眠 1 秒
Thread.sleep(1000);
}
System.out.println("开始执行");
}
}
join 示例:
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
System.out.println("子线程执行:" + i);
}
});
worker.start();
// main 线程会等待 worker 执行结束后再继续往下走
worker.join();
System.out.println("子线程结束后,main 线程继续执行");
}
}
守护线程示例:
public class DaemonDemo {
public static void main(String[] args) {
Thread daemon = new Thread(() -> {
while (true) {
System.out.println("后台守护线程运行中");
}
});
// 守护线程会随着所有非守护线程结束而结束
daemon.setDaemon(true);
daemon.start();
System.out.println("main 线程结束");
}
}
线程优先级只是给操作系统调度的建议,不代表优先级高的线程一定先执行。
8. 线程安全问题和同步代码块
线程安全问题通常发生在三个条件同时出现时:
- 有多个线程。
- 多个线程共享同一份数据。
- 多个线程同时修改这份数据。
经典例子是卖票:
public class TicketTask implements Runnable {
private int tickets = 100;
/**
* 多个线程共享同一个 TicketTask 对象,因此共享 tickets 变量。
*/
@Override
public void run() {
while (true) {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
tickets--;
} else {
break;
}
}
}
}
如果多个线程同时执行 if (tickets > 0) 和 tickets--,可能出现重复卖票、卖出 0 号票等问题。原因是判断和修改不是一个不可拆分的整体。
解决方式是给操作共享数据的代码加同步代码块:
public class SafeTicketTask implements Runnable {
private int tickets = 100;
private final Object lock = new Object();
/**
* 使用同步代码块保护共享变量 tickets。
*/
@Override
public void run() {
while (true) {
synchronized (lock) {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
tickets--;
} else {
break;
}
}
}
}
}
同步代码块的格式:
synchronized (锁对象) {
// 操作共享数据的代码
}
锁对象必须是多个线程共同使用的同一个对象。如果每个线程拿到的锁对象都不同,就锁不住共享数据。
9. 同步方法
如果一个方法内部整体都需要同步,可以把方法声明为 synchronized 方法。
public class SyncMethodTicketTask implements Runnable {
private int tickets = 100;
/**
* 线程任务:循环调用同步方法卖票。
*/
@Override
public void run() {
while (sellTicket()) {
// 循环卖票,直到 sellTicket 返回 false
}
}
/**
* 同步方法会自动加锁,普通成员方法默认锁对象是 this。
*
* @return 还有票并成功卖出时返回 true,没有票时返回 false。
*/
private synchronized boolean sellTicket() {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
tickets--;
return true;
}
return false;
}
}
同步方法的锁对象规则:
| 方法类型 | 默认锁对象 |
|---|---|
| 普通同步方法 | this |
| 静态同步方法 | 当前类的 Class 对象 |
同步代码块更灵活,可以只锁关键代码;同步方法更简洁,适合整个方法都属于临界区的场景。
10. Lock 锁
synchronized 是 Java 内置锁,使用简单,但控制能力有限。Lock 是 JDK 提供的显式锁,常用实现类是 ReentrantLock。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockTicketTask implements Runnable {
private int tickets = 100;
private final Lock lock = new ReentrantLock();
/**
* 使用 Lock 手动加锁和释放锁。
*/
@Override
public void run() {
while (true) {
lock.lock();
try {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
tickets--;
} else {
break;
}
} finally {
// 释放锁必须写在 finally 中,避免异常导致锁无法释放
lock.unlock();
}
}
}
}
使用 Lock 时要牢记:
lock.lock()和lock.unlock()必须成对出现。unlock()一定要放在finally中。Lock支持更灵活的加锁能力,比如可中断锁、公平锁、尝试加锁等。
初学阶段可以这样选择:
| 场景 | 推荐方式 |
|---|---|
| 简单同步 | synchronized |
| 整个方法都需要同步 | 同步方法 |
| 需要更灵活控制锁 | Lock |
11. 线程池介绍和 JDK 提供的线程池
如果每来一个任务就创建一个新线程,会有几个问题:
- 创建和销毁线程都有成本。
- 线程数量不受控制,可能把服务器资源耗尽。
- 缺少统一管理,不方便监控和关闭。
线程池就是用来管理线程的工具。它会提前准备一批线程,任务来了就交给空闲线程执行,执行完后线程不销毁,而是回到池里继续等待新任务。
JDK 提供了 Executors 工具类,可以快速创建线程池:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorsDemo {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
int taskId = i;
pool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
});
}
// shutdown 表示不再接收新任务,但会把已提交的任务执行完
pool.shutdown();
}
}
常见线程池:
| 方法 | 特点 |
|---|---|
newFixedThreadPool(n) | 固定线程数量 |
newSingleThreadExecutor() | 只有一个工作线程 |
newCachedThreadPool() | 线程数量可动态增长 |
newScheduledThreadPool(n) | 支持延迟和周期任务 |
不过在企业开发中,一般不推荐直接使用 Executors 创建线程池。原因是它的某些实现可能使用无界队列或允许创建过多线程,任务量过大时容易造成内存溢出或资源耗尽。
12. 自定义线程池
更推荐使用 ThreadPoolExecutor 自定义线程池参数。
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class CustomThreadPoolDemo {
public static void main(String[] args) {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // 核心线程数:长期保留的线程数量
4, // 最大线程数:任务高峰时最多能创建的线程数量
60, // 非核心线程空闲后的存活时间
TimeUnit.SECONDS, // 存活时间单位
new ArrayBlockingQueue<>(10), // 任务队列:等待执行的任务存放位置
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略:任务太多时直接抛异常
);
for (int i = 1; i <= 20; i++) {
int taskId = i;
pool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
});
}
pool.shutdown();
}
}
ThreadPoolExecutor 的核心参数:
| 参数 | 说明 |
|---|---|
corePoolSize | 核心线程数 |
maximumPoolSize | 最大线程数 |
keepAliveTime | 非核心线程空闲存活时间 |
unit | 时间单位 |
workQueue | 任务队列 |
threadFactory | 线程创建工厂 |
handler | 拒绝策略 |
线程池执行任务的大致流程:
- 当前工作线程数小于核心线程数:直接创建核心线程执行任务。
- 核心线程已满:任务进入队列等待。
- 队列也满了,且线程数小于最大线程数:创建非核心线程执行任务。
- 线程数达到最大值,队列也满:触发拒绝策略。
常见拒绝策略:
| 拒绝策略 | 行为 |
|---|---|
AbortPolicy | 直接抛异常,默认策略 |
CallerRunsPolicy | 让提交任务的线程自己执行 |
DiscardPolicy | 直接丢弃任务,不抛异常 |
DiscardOldestPolicy | 丢弃队列中最早的任务,再尝试提交新任务 |
13. ThreadLocal 线程本地变量
ThreadLocal 是 Java 提供的一种实现线程数据隔离的机制。它并不是一个 Thread,而是 Thread 的局部变量。
13.1 什么是 ThreadLocal
ThreadLocal 为每个线程提供一份单独的存储空间。当多个线程访问同一个 ThreadLocal 变量时,每个线程都会在自己的本地内存中保存一份该变量的副本,具有线程隔离的效果,不同的线程之间不会相互干扰。
13.2 ThreadLocal 常用方法
| 方法 | 作用 |
|---|---|
public void set(T value) | 设置当前线程的线程局部变量的值 |
public T get() | 返回当前线程所对应的线程局部变量的值 |
public void remove() | 移除当前线程的线程局部变量 |
13.3 应用场景与示例
应用场景:
- 解决线程安全问题:当某个变量不是线程安全的,但你又不希望使用锁(Lock/Synchronized)来同步时,可以给每个线程分配一个变量副本。
- 跨层传递数据:在复杂的业务链条中,避免在每个方法参数里传递同一个变量(如用户信息、事务 ID 等)。
代码示例:
public class ThreadLocalDemo {
// 定义一个 ThreadLocal 变量,用来存储字符串
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
threadLocal.set("线程A的数据");
System.out.println(Thread.currentThread().getName() + " 获取数据:" + threadLocal.get());
// 使用完记得移除,防止内存泄漏
threadLocal.remove();
}, "Thread-A").start();
new Thread(() -> {
threadLocal.set("线程B的数据");
System.out.println(Thread.currentThread().getName() + " 获取数据:" + threadLocal.get());
threadLocal.remove();
}, "Thread-B").start();
}
}
注意:在使用完 ThreadLocal 变量后,强烈建议调用 remove() 方法。因为在线程池环境下,线程是被复用的,如果不清理,可能会导致上一个任务的数据“污染”下一个任务,甚至引起内存泄漏。
多线程章节最后要记住四句话:
- 创建线程是为了并发执行任务。
- 共享数据必须考虑线程安全。
- ThreadLocal 用于实现线程间的数据隔离。
- 真实项目中更常用线程池,而不是频繁手动创建线程。