1. 多线程到底解决了什么问题
先说个最直观的场景:你写了一段程序,要处理一万条数据,每条数据都要请求一次第三方接口,单条请求耗时200毫秒。按单线程跑,一万乘以0.2秒,整整2000秒,也就是33分钟。用户等得起吗?肯定等不起。但如果你开20个线程同时跑,理想情况下能把时间缩短到100秒左右,这就是多线程的价值——它不是让你单次操作变快,而是让"等"的时间被重叠利用起来。
我最早接触多线程是在做一个批量数据同步的工具,那时候对并发的理解就是"开几个线程丢进去跑",结果线上出了一堆诡异问题:数据重复、死锁、内存暴涨。后来踩了足够多的坑才明白,多线程真正的难点不在于"怎么开线程",而在于"怎么安全地协作、怎么控制资源、怎么优雅地收尾"。
这篇内容我打算围绕实际开发中最常见的几个场景展开,包括Java、Python、C++以及Linux环境下的多线程实践,涵盖线程创建方式、任务拆分策略、线程池参数调优、等待任务结果、多线程执行SQL的顺序控制、常见面试题解析等。不管你是刚接触多线程的新手,还是已经写过一阵子并发代码但总觉得心里没底的开发者,这篇文章都值得花十分钟看完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程创建方式与核心原理
2.1 线程的本质与操作系统调度
很多初学者把"线程"理解成一个"方法"或者"函数",这其实是不准确的。线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中实际运作的单位。每个线程都有自己的栈空间、程序计数器、寄存器状态,但多个线程共享进程的堆内存和全局变量。
打个比方:进程就像一家餐厅,线程就是餐厅里的服务员。餐厅本身(进程)有固定的场地和资源(内存),而服务员们(线程)各自负责不同的餐桌(任务),但他们共用同一个厨房(共享内存)和同一本菜单(代码段)。服务员多了,就存在抢厨房资源的问题,这就是锁要解决的。
在Linux下,线程本质上是通过clone系统调用实现的,底层是轻量级进程(LWP)。Java的线程在Linux上会一对一映射到原生线程,所以Java的Thread本质上是操作系统的线程。而Python由于GIL(全局解释器锁)的存在,同一时刻只有一个线程能执行Python字节码,所以Python多线程在CPU密集场景下反而可能更慢,这一点我在后面的Python章节会详细展开。
2.2 Java中创建线程的四种姿势
Java里创建线程的方式,很多面试题会问,但答案往往停留在"继承Thread、实现Runnable"这种层面。实际开发中,我更推荐用线程池和FutureTask,但基础方式必须理解,因为它们是理解线程生命周期的根基。
- 继承Thread类,重写run方法
- 实现Runnable接口,传入Thread构造器
- 实现Callable接口,配合FutureTask,可以有返回值
- 通过线程池Executors提交任务
第一种方式的缺点是Java是单继承的,继承了Thread就不能继承其他类了。第二种比第一种好,但run方法没有返回值,也没法抛出受检异常。第三种引入了Callable接口,call方法可以返回结果、可以抛异常,配合FutureTask可以拿到异步执行的结果。
java复制// 方式一:继承Thread
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程运行中: " + Thread.currentThread().getName());
}
}
// 启动
new MyThread().start();
// 方式三:Callable + FutureTask
Callable<Integer> callable = () -> {
Thread.sleep(1000);
return 42;
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
Integer result = futureTask.get(); // 阻塞等待结果
System.out.println("结果: " + result);
这里有个很容易踩的坑:futureTask.get()是阻塞方法,如果任务一直不结束,这里就会一直等下去。生产环境一定要用带超时的get(long timeout, TimeUnit unit),否则一旦任务挂起,整个调用线程就跟着卡死了。
2.3 C++多线程的创建与资源管理
C++11开始,标准库提供了std::thread,这是跨平台的线程创建方式。相比Java,C++的线程管理更加原始,需要手动管理生命周期,稍不注意就出现野指针或者资源泄漏。
cpp复制#include <thread>
#include <iostream>
#include <vector>
void worker(int id) {
std::cout << "线程 " << id << " 开始工作" << std::endl;
// 模拟耗时操作
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back(worker, i);
}
for (auto& t : threads) {
if (t.joinable()) {
t.join(); // 等待线程结束
}
}
return 0;
}
C++里有个非常关键的细节:std::thread对象在析构时,如果线程还在运行(没有join或detach),程序会直接std::terminate崩溃。所以要么在所有路径上都确保join(),要么明确detach()让线程在后台运行。实际开发中我建议尽量用RAII思想封装线程,或者在主线程退出前统一join。
3. 线程池及其参数调优
3.1 为什么不能用裸线程
很多刚接触多线程的开发者,第一反应就是"需要并发就new一个Thread"。在小规模场景下这没问题,但一旦任务量大了,频繁创建和销毁线程的开销会变得非常明显。创建一个线程需要分配栈空间(默认1MB左右)、建立线程控制块、完成内核态到用户态的切换,这些开销加起来比执行一个简单任务还要大。
更重要的是,无限制地创建线程会导致系统资源耗尽。我以前见过一个事故:一个服务在流量高峰时每来一个请求就new一个线程处理,结果线程数飙升到几千个,CPU上下文切换开销暴增,最终整个服务假死。线程池的价值就在于:复用线程、控制并发数、管理生命周期、提供任务队列缓冲。
3.2 Java线程池的七大参数
Java的ThreadPoolExecutor是线程池的核心实现,它有七个参数,每个参数都有明确的含义和调优逻辑。我在这里直接展开说明:
| 参数 | 含义 | 调优建议 |
|---|---|---|
| corePoolSize | 核心线程数 | CPU密集:CPU核数+1;IO密集:CPU核数 * 2(或更高) |
| maximumPoolSize | 最大线程数 | 通常为核心线程数的2~4倍 |
| keepAliveTime | 非核心线程空闲存活时间 | 一般设为30~60秒 |
| unit | 时间单位 | 配合keepAliveTime使用 |
| workQueue | 任务队列 | 有界队列优先,避免内存溢出 |
| threadFactory | 线程工厂 | 自定义命名,方便排查问题 |
| handler | 拒绝策略 | 生产环境建议CallerRunsPolicy或自定义 |
一个非常常见的面试题是:"线程池的核心线程数怎么确定?"这里我给一个经验公式:
- CPU密集型任务:核心线程数 = CPU核数 + 1,因为CPU密集任务很少阻塞,再多线程反而增加切换开销。
- IO密集型任务:核心线程数 = CPU核数 * 2,因为IO等待期间CPU是空闲的,可以用额外线程来利用这段空闲。
- 混合型任务:如果IO等待比例很高,比如等待时间占总时间的90%,那么线程数可以设为 CPU核数 / (1 - 阻塞系数) ≈ CPU核数 * 10。
实际调优不能光靠公式,最好配合压测。我曾经调过一个推送服务,按公式算出32个线程,压测后发现64个线程吞吐量反而更高,因为每次推送都有大量网络等待。所以公式只是起点,压测才是终点。
3.3 线程池的拒绝策略怎么选
ThreadPoolExecutor提供了四种拒绝策略:
- AbortPolicy:直接抛RejectedExecutionException,默认策略
- CallerRunsPolicy:由调用者所在线程执行任务
- DiscardPolicy:直接丢弃任务
- DiscardOldestPolicy:丢弃队列中最旧的任务
生产环境我强烈不建议用DiscardPolicy和Discard
