1. 多线程基础回顾:进程与线程的区别、线程的生命周期、Java/Python中的线程创建方式
1.1 进程与线程:别再傻傻分不清
说实话,我面试过不少人,问起进程和线程的区别,很多人张口就来「进程是资源分配的最小单位,线程是CPU调度的最小单位」。背得挺熟,但一问「那你项目里怎么用的?」就卡壳了。
咱们用大白话捋一捋。进程就像一家独立的公司——有自己的办公场地(内存空间)、财务(文件句柄)、员工(资源)。线程呢,就是公司里的一个员工。公司之间互不干扰,但公司内部的员工可以共享会议室、打印机这些资源。
我在重构一个支付网关项目时遇到过这么个事:原本每个用户请求都开一个新进程,结果服务器内存直接爆了。后来改成线程池,同样的硬件配置,吞吐量翻了3倍。你想想看,进程切换的开销有多大?光是切换页表、刷新TLB就够CPU忙活一阵子了。
核心区别总结:
- 资源开销:进程拥有独立地址空间,线程共享进程地址空间。创建线程的开销比进程小得多(大约快10-100倍)。
- 通信方式:进程间通信(IPC)需要管道、消息队列、共享内存等机制;线程间通信直接读写共享变量即可。
- 健壮性:一个进程挂了不影响其他进程;一个线程挂了,整个进程都可能崩溃——嗯,这里要注意,这也是为什么我在关键服务里会做线程隔离。
1.2 线程的生命周期:从出生到消亡
线程的一生,说白了就是「新建→就绪→运行→阻塞→死亡」这五个状态来回切换。我习惯用一张图来理解它:
这张图我画了好多年了,每次培训新人都会拿出来讲。有几个关键点我得提醒你:
- 新建→就绪:调用
start()后线程进入就绪队列,等着CPU临幸。注意,不是调用run()!run()只是普通方法调用,不会启动新线程。我曾经见过一个同事直接调run()调试了半天——嗯,谁还没犯过这种错呢。 - 就绪→运行:由操作系统调度器决定。你没法控制具体哪个线程先跑,只能通过优先级给个「建议」。
- 运行→阻塞:调用
sleep()、wait()或者发起I/O操作时,线程会主动让出CPU。这里有个坑:sleep()不会释放锁,wait()会释放锁。我在做分布式锁时吃过这个亏,后面会细讲。 - 阻塞→就绪:阻塞条件解除后,线程回到就绪状态,重新参与调度。
- 死亡:
run()方法执行完毕或者抛出未捕获异常。线程一旦死亡,不能重新start(),否则会抛IllegalThreadStateException。
避坑指南:我曾经在日志收集系统里用 Thread.stop() 强制终止线程,结果导致共享数据结构损坏,整个进程都挂了。记住:stop()、suspend()、resume() 这些方法已经被标记为废弃,千万别用!正确的做法是通过标志位或 interrupt() 来优雅终止。
1.3 Java中的线程创建:不止两种方式
很多人说Java创建线程有两种方式:继承 Thread 和实现 Runnable。其实还有第三种——实现 Callable 接口,配合 FutureTask 使用。我个人更推荐后两种,因为Java是单继承,实现接口更灵活。
咱们直接看代码:
// 方式一:继承Thread(不推荐)
class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程运行中: " + Thread.currentThread().getName());
}
}
new MyThread().start();
// 方式二:实现Runnable(推荐)
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程运行中: " + Thread.currentThread().getName());
}
}
new Thread(new MyRunnable()).start();
// 方式三:实现Callable + FutureTask(带返回值)
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "线程返回结果: " + Thread.currentThread().getName();
}
}
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
new Thread(futureTask).start();
String result = futureTask.get(); // 阻塞等待结果
我在重构一个批量数据处理系统时,大量使用了 Callable + 线程池的方式。每个任务处理一批数据,返回处理结果,主线程统一汇总。这样既利用了多核CPU,又避免了手动管理线程的麻烦。
小技巧:Java 8以后,推荐用Lambda表达式简化线程创建:
new Thread(() -> System.out.println("Lambda线程")).start();
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
// 业务逻辑
return "done";
});
1.4 Python中的线程创建:GIL是个绕不开的坎
Python的线程创建比Java简单,但有个大坑——全局解释器锁(GIL)。说白了,GIL保证同一时刻只有一个线程在执行Python字节码。所以Python多线程在CPU密集型任务上基本是废的,但在I/O密集型任务上依然好用。
我做过一个爬虫项目,用Python多线程并发抓取100个网页,比单线程快了将近10倍。为什么?因为网络I/O等待时,线程会释放GIL,其他线程就能趁机执行。
import threading
import time
# 方式一:继承Thread
class MyThread(threading.Thread):
def run(self):
print(f"线程 {self.name} 运行中")
t = MyThread()
t.start()
# 方式二:直接传入target函数(推荐)
def worker(name):
print(f"线程 {name} 运行中")
time.sleep(1)
threads = []
for i in range(5):
t = threading.Thread(target=worker, args=(f"worker-{i}",))
threads.append(t)
t.start()
# 等待所有线程完成
for t in threads:
t.join()
print("所有线程执行完毕")
Python 3.2以后,GIL的实现做了优化,不再是简单的「拿到GIL才能执行」,而是引入了「超时机制」——一个线程如果连续执行一段时间(默认5毫秒)还没释放GIL,其他线程就有机会抢过来。但说实话,对于纯计算任务,还是建议用 multiprocessing 模块绕过GIL。
Python线程 vs 进程选择指南:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| I/O密集型(网络请求、文件读写) | 多线程 | I/O等待时释放GIL,线程切换开销小 |
| CPU密集型(计算、图像处理) | 多进程 | 每个进程有独立GIL,真正并行 |
| 混合型 | 多进程+协程 | 进程间用队列通信,协程处理I/O |
1.5 线程创建的最佳实践
说了这么多,总结几条我踩坑踩出来的经验:
- 永远不要直接创建线程:用线程池代替。Java用
Executors或ThreadPoolExecutor,Python用concurrent.futures.ThreadPoolExecutor。手动创建线程就像用手工拧螺丝——费时费力还不均匀。 - 给线程起个有意义的名字:排查问题时,看到「pool-1-thread-3」和「order-process-thread-3」完全是两种心情。
- 处理好异常:线程内抛出的异常不会传播到主线程。Java里可以用
UncaughtExceptionHandler,Python里在run()方法里加try-except。 - 注意线程安全:共享变量加锁,或者用线程安全的数据结构。我见过太多因为
HashMap并发put导致死循环的案例了——换成ConcurrentHashMap能省很多事。
我的习惯:在项目启动时,我会打印一份线程池的配置信息,包括核心线程数、最大线程数、队列类型、拒绝策略。这样线上出问题时,第一眼就能确认线程池配置是否合理。你也不妨试试。
好了,多线程的基础就回顾到这里。这些概念虽然基础,但就像盖房子的地基——地基不稳,后面再花哨的设计都是空中楼阁。下一节我们开始真正动手,看看怎么用这些知识去重构一个真实项目。