做后台开发这些年,我面试过的人少说也有几百位,聊到多线程时,能讲清楚的人真不多。不是大家不会用,而是多线程这摊水太深——线程生命周期、锁的粒度、并发容器的取舍、线程池参数的调优,每一块单独拎出来都够写一本书。这篇文章我就想把多线程从底层原理到实战应用,从创建线程到面试考点,一次性说透。不管你是刚接触编程的新手,还是准备跳槽的资深开发,都能从中找到自己需要的答案。

多线程不是一个"学了就能用"的知识点,它更像一套思维方式。单线程里你只需要关心"做什么",多线程里你要额外关心"什么时候做""谁先做""做完了怎么同步",这三个问题如果没有想清楚,代码跑起来就是一团乱麻。我见过太多线上事故,都是因为线程安全问题导致的——数据错乱、死锁、OOM、CPU飙高,每一个都能让你在半夜被电话叫醒。

这篇文章不会只讲概念,我会把每个知识点掰开揉碎,告诉你底层原理是什么、实际场景中怎么用、踩过的坑有哪些,最后还会整理一份面试高频考点清单。内容会比较长,但每一段都值得认真看。

1. 先从根上理解:进程和线程到底差在哪

1.1 一个生活化的类比

很多人对进程和线程的区别背得滚瓜烂熟,但真正遇到问题时就蒙了。我给你打个比方:进程就像一家餐厅,线程就是餐厅里的服务员。餐厅有自己的场地、厨房、仓库(内存空间),服务员在餐厅里工作,共享这些资源。如果你想开第二家餐厅,得重新租场地、买设备、招人,这就是创建新进程的代价。而在一家餐厅里多雇几个服务员,共享同一个厨房和仓库,这就是创建线程的代价。

所以进程和线程的第一个区别就出来了: 进程是资源分配的最小单位,线程是CPU调度的最小单位 。进程之间相互独立,一个进程崩了不影响另一个;同一进程内的线程共享内存空间,一个线程崩了可能把整个进程带崩。

1.2 为什么线程的创建成本远低于进程

进程之间是隔离的,每个进程都有独立的地址空间、页表、文件描述符表等资源。操作系统创建一个新进程时,需要分配这些资源,建立地址映射关系,这个开销是比较重的。创建一个线程则简单得多——线程共享进程的地址空间和大部分资源,只需要分配独立的栈空间和线程控制块(TCB),所以线程的创建速度比进程快一个数量级。

这也是为什么在高并发场景下,我们优先选择多线程而不是多进程。不过要注意,Python这种有GIL的语言是个例外,后面会专门说。

1.3 进程间通信和线程间通信的差异

进程之间因为隔离,通信必须借助操作系统提供的机制,比如管道、消息队列、共享内存、信号量、Socket等,数据还得做序列化拷贝,效率低且复杂。线程之间共享内存,通信直接读写变量就行,高效又直观。

但"高效"是把双刃剑——共享内存意味着多个线程同时读写同一个变量时会发生竞争。这个竞争如果不加控制,轻则数据不对,重则程序崩溃。我见过一个经典事故:两个线程同时对同一个ArrayList做add操作,结果数组越界直接抛异常,服务就挂了。解决思路就是加锁,或者用线程安全的容器,这个后面详细展开。

2. 线程的完整生命周期和底层调度机制

2.1 线程的五种状态及转换

很多人学线程状态时靠死记硬背,其实理解了转换条件就能轻松记住。线程从创建到销毁,一共有五种状态:

  • 新建(New) :创建了一个线程对象,还没调用start()方法。此时线程还没有真正运行,只是在JVM/操作系统中分配了一些元数据。
  • 就绪(Runnable) :调用了start()方法后,线程进入就绪队列等待CPU分配时间片。就绪状态的线程随时可以被调度器选中执行。
  • 运行(Running) :线程获得了CPU时间片,正在执行run()方法中的代码。
  • 阻塞(Blocked/Waiting/Timed_Waiting) :线程因为某种原因放弃了CPU使用权,比如等待获取锁、调用了sleep()/wait()/join()方法。阻塞结束后会回到就绪状态。
  • 死亡(Terminated) :run()方法执行完毕,或者抛出了未捕获的异常,线程生命周期结束。

这里有个新手常犯的错:调用了sleep()后线程不会进入阻塞状态,而是进入定时等待状态(Timed_Waiting)。但面试时你说"阻塞"也基本能过关,关键是能说清楚"线程在等待什么、放弃CPU后怎么恢复"。

2.2 线程调度:时间片轮转是怎么回事

现代操作系统大多使用 抢占式调度 ,也就是说线程能跑多久,不是自己说了算,而是由操作系统决定。系统把CPU时间切成一个个小片段(通常十几毫秒到几十毫秒),按一定策略分给各个线程。线程用完自己的时间片,无论任务有没有做完,都得暂停,让出CPU给其他线程。

这就是 上下文切换 (Context Switch)。切换的代价不小——操作系统要把当前线程的寄存器、程序计数器、栈指针等状态保存起来,再加载下一个线程的状态。线程越多,切换越频繁,真正花在"干活"上的CPU时间占比就越低。所以不是线程开得越多越好,线程数超过CPU核心数后,大量时间会耗在切换上,整体性能反而下降。

2.3 GDB调试多线程的实际操作

多线程调试是很多人的痛点。代码跑着跑着就"卡死"了,不知道是死锁还是死循环,用GDB可以很方便地排查。

先用 gdb 启动程序(或者 gdb attach 进程PID 挂载到正在运行的进程上)。

# 查看当前进程下所有线程的列表
(gdb) info threads

# 输出类似:
# 3 Thread 0x7f8b9c327700 (LWP 23452) 0x00007f8b9c1b7a3d in syscall ()
# 2 Thread 0x7f8b9c129700 (LWP 23450) pthread_cond_wait@@GLIBC_2.3.2 ()
# 1 Thread 0x7f8b9ccc2700 (LWP 23449) main ()

# 切换到指定线程,查看其堆栈
(gdb) thread 2
(gdb) bt
# 就能看到这个线程卡在哪个函数上了

如果是死锁,你会发现两个线程都停在 pthread_mutex_lock 或者 pthread_cond_wait 上,互相等待对方释放锁。如果是一个线程CPU飙高但程序没响应,切到对应线程看堆栈,往往能看到一个死循环。

调试多线程还有一个技巧:用 set scheduler-locking on 可以让GDB在单步调试时只运行当前线程,其他线程全部暂停,这样才不会出现"单步走着走着跑偏了"的情况。

3. 线程创建方式全对比

3.1 Java创建线程的三种方式

Java里创建线程,核心就三种姿势:

继承Thread类 :重写run()方法,然后new出来调start()。继承方式简单直接,但Java只支持单继承,继承了Thread就不能继承别的类了,扩展性差。

实现Runnable接口 :把任务逻辑放进run()方法,然后传给Thread构造器。比第一种好,因为接口可以多实现。但Runnable的run()方法没有返回值,也抛不出受检异常。

实现Callable接口 + FutureTask :Callable能返回结果,能抛异常,配合FutureTask可以拿到异步执行结果。这是实际开发中最推荐的方式。

import java.util.concurrent.*;

public class ThreadDemo {
    public static void main(String[] args) throws Exception {
        // 方式一:继承Thread(不推荐)
        Thread t1 = new Thread() {
            @Override
            public void run() {
                System.out.println("方式一:继承Thread");
            }
        };
        t1.start();

        // 方式二:实现Runnable(常用)
        Thread t2 = new Thread(() -> System.out.println("方式二:实现Runnable"));
        t2.start();

        // 方式三:Callable + FutureTask(推荐,可以获得返回值)
        FutureTask<Integer> task = new FutureTask<>(() -> {
            Thread.sleep(1000);
            return 1 + 2;
        });
        Thread t3 = new Thread(task);
        t3.start();
        Integer result = task.get();  // 这里会阻塞等待任务完成
        System.out.println("方式三返回结果:" + result);
    }
}

注意: task.get() 会阻塞当前线程直到子线程任务完成。如果你不想阻塞,可以先用 isDone() 轮询,或者用 get(long timeout, TimeUnit unit) 设置超时时间。

3.2 Python多线程的两种标准和GIL这个"坑"

Python创建线程的两种标准方式是 threading.Thread 和 concurrent.futures.ThreadPoolExecutor 。前者适合简单的"开一个线程跑任务",后者适合批量提交任务并统一获取结果。

import threading
import time

def worker(name):
    print(f"线程{name}开始")
    time.sleep(2)
    print(f"线程{name}结束")

# 方式一:直接创建线程
t = threading.Thread(target=worker, args=("A",))
t.start()
t.join()  # 等待线程结束,否则主线程可能先退出

Python有一个绕不开的话题:GIL(全局解释器锁)。GIL让同一时刻只有一个线程能执行Python字节码,所以Python多线程在CPU密集型任务上不但不能加速,反而会因为线程切换带来额外开销。但在IO密集型任务上(网络请求、文件读写、数据库查询),线程等待IO时会让出GIL,切换到其他线程,所以依然能获得很好的并发效果。

如果你的任务是CPU密集型的,Python里应该使用 多进程 ( multiprocessing 模块)而不是多线程,每个进程有独立的Python解释器,绕开GIL限制,才能利用多核CPU。

3.3 C++和C#的线程创建要点

C++11标准库正式加入了线程支持,用法是 std::thread :

#include <iostream>
#include <thread>

void worker(int id) {
    std::cout << "线程 " << id << " 运行中" << std::endl;
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

    t1.join();  // 等待t1结束
    t2.join();  // 等待t2结束
    return 0;
}

C++线程需要注意的地方比较多: std::thread 对象析构时如果线程还在运行,程序会直接终止,所以必须调用 join() 或 detach() ;线程函数传引用参数时要用 std::ref 包装,否则会被拷贝。

C#的线程创建更简洁, Task.Run() 是现代C#推荐的方式:

Task.Run(() => Console.WriteLine("子线程运行中"));

Task 底层使用的是线程池,创建和调度开销远小于手动 new Thread ,是.NET平台上的首选方案。

3.4 Qt多线程:继承QThread还是用QtConcurrent

Qt开发中多线程有两条路线:继承 QThread 重写 run() 方法,适合需要在独立线程中执行长任务的场景;用 QtConcurrent::run 把任务丢到线程池里跑,适合任务多且相对短小的场景。

QThread 还有一个容易被忽略的细节:信号槽连接方式中,默认的 AutoConnection 会检查"信号发射线程"和"槽函数所在线程"是否一致,不一致时自动切换为队列连接,也就是槽函数会在接收者所在线程执行。理解了这点,就不会写出"子线程里直接操作UI控件"这种导致崩溃的代码了——UI操作必须放回主线程执行。

4. 线程安全问题的根源与解决方案

4.1 原子性、可见性、有序性——并发三大特性

线程安全问题,归根结底是三个特性被破坏。

原子性 :一个操作要么全部执行完,要么完全不执行,不能被中断。比如 count++ 在底层其实是"读取→加1→写回"三步,不是原子的,两个线程同时执行就可能把加1操作覆盖掉。解决办法是加锁(synchronized、ReentrantLock)、使用 AtomicInteger 等原子类、或者用CAS(比较并交换)机制。

可见性 :一个线程修改了共享变量,其他线程不一定能立刻看到。因为CPU有缓存,线程读的可能还是自己缓存里的旧值。 volatile 关键字可以解决可见性问题——保证每次读都能拿到最新值,但它不保证原子性。所以 volatile int count 配合 count++ 依然是线程不安全的。

有序性 :编译器和CPU为了优化性能,可能会对指令进行重排。单线程下重排不影响结果,但多线程下可能出现意想不到的顺序问题。加锁和 volatile (禁止重排序)都能解决。

4.2 synchronized和ReentrantLock怎么选

Java里最常用的两种锁是 synchronized 关键字和 ReentrantLock 类。

synchronized 是JVM层面的锁,使用简单,自动释放(即使抛异常也会释放锁),在低竞争场景下性能已经很好了。JDK 6之后做了大量优化(偏向锁、轻量级锁、锁粗化等),日常开发中优先用 synchronized 就够了。

ReentrantLock 是API层面的锁,功能更强大:支持公平锁/非公平锁、可以响应中断、支持超时获取锁、可以绑定多个条件队列(Condition)实现精确唤醒。

ReentrantLock lock = new ReentrantLock();
try {
    lock.lock();
    // 临界区代码
} finally {
    lock.unlock();  // 必须手动释放,放在finally里防止死锁
}

我的个人经验:追求简单可靠用 synchronized ,需要超时控制、可中断、多条件唤醒这些高级特性时再换 ReentrantLock 。不要一上来就无脑用 ReentrantLock ,多一行手动释放锁的代码多一分死锁风险。

4.3 数据库事务和本地锁不是一回事

这一点特别容易混淆。多线程环境下的锁控制的是"内存中代码块"的执行互斥,数据库的事务控制的是"数据库数据"的ACID特性。两者所处层级完全不同。

一个典型误区是:数据库行锁能防并发修改,那代码里不用加锁了。实际上,一个请求在查完数据库、还没写完数据库之间,可能有另一线程插进来改了数据。你不加代码锁,只靠数据库锁,就会产生"先读后写覆盖"的问题。正确做法是代码层用分布式锁(Redis的SETNX或ZooKeeper节点)来控制操作顺序,数据库锁则用来兜底保证最终一致性。

5. 线程池:生产环境真正的主角

5.1 手动创建线程的代价

很多新手习惯在需要时 new Thread(...) ,任务结束线程就销毁了。这有两个问题:频繁创建销毁线程,操作系统要不断做资源分配和回收,开销很大;同时无限制创建线程,内存迟早被耗尽——每个线程默认栈空间1MB,1000个线程就是1GB的内存开销。

线程池的思路是预先创建一定数量的线程放在池子里,任务来了就分配给池中的空闲线程执行,任务执行完线程不销毁,回归池中等待下一个任务。重用线程,避免重复创建销毁,还能通过队列缓冲任务,实现流量削峰。

5.2 七参数面试必考题与配置建议

Java的 ThreadPoolExecutor 构造函数有七个核心参数,也是面试官最喜欢考的点:

  • corePoolSize(核心线程数) :即使线程空闲也保留的线程数。
  • maximumPoolSize(最大线程数) :线程池中最多允许的线程数。
  • keepAliveTime(空闲存活时间) :非核心线程空闲超过这个时间就会被回收(如果设置了 allowCoreThreadTimeOut ,核心线程也会被回收)。
  • unit :keepAliveTime的时间单位。
  • workQueue(任务队列) :当线程数达到corePoolSize后,新任务会放到队列里等待。
  • threadFactory(线程工厂) :用于创建线程,可以指定线程名字、是否是守护线程等,便于排查问题。
  • handler(拒绝策略) :当线程数达到maximumPoolSize且队列也满了时,新任务的处理策略。

参数配置必须结合业务场景。IO密集型任务(文件读写、网络调用),线程大部分时间在等待,可以设置多一些线程(推荐CPU核数乘以2左右);CPU密集型任务,线程基本一直在跑,设置成CPU核数+1即可。

我见过某个团队把corePoolSize设成50,但服务器只有4核CPU,任务全是CPU密集计算——结果50个线程疯狂争抢4个核心,大量时间耗在上下文切换上。后来调成5,性能反而上去了。 线程池参数没有银弹,必须压测后调整 。

5.3 四种拒绝策略

当线程池和队列都满了,再提交新任务时触发的策略有四种:

AbortPolicy (默认):直接抛出 RejectedExecutionException ,主调方可以感知到任务被拒。 CallerRunsPolicy :还在调用线程里执行这个任务,不让任务丢失,但会阻塞调用方,起到自然限流的作用。 DiscardPolicy :静默丢弃任务,不适合需要可靠处理的场景。 DiscardOldestPolicy :丢弃队列中等待最久的任务,然后重试提交当前任务。

实际项目中,我比较推荐 CallerRunsPolicy ,它把压力回传给调用方,既不会丢数据,也能反向压慢生产者的速度,形成一种朴素的背压机制。但如果业务要求任务绝对不能丢,那就得把拒绝的任务持久化到数据库或者消息队列,后续补偿处理。

6. 死锁:所有并发问题的噩梦

6.1 死锁的四个必要条件

死锁的产生必须同时满足四个条件,任何一个被破坏,死锁都不会发生:

  • 互斥条件 :资源同一时刻只能被一个线程持有。
  • 持有并等待 :持有资源的同时,还去申请别的资源。
  • 不可剥夺条件 :资源只能被持有者主动释放,不能被其他线程强行抢走。
  • 循环等待条件 :多个线程之间形成一条首尾相连的等待链,比如线程A持有锁1等锁2,线程B持有锁2等锁1。

6.2 排查死锁的实用手段

用Java的 jstack 工具排查死锁非常方便:

jps          # 查看正在运行的Java进程PID
jstack 进程PID   # 导出线程堆栈信息

如果存在死锁, jstack 输出末尾会明确提示"Found one Java-level deadlock",并且列出互相等待的锁和线程堆栈。这时去查代码里谁先持有了锁A又释放了锁B,调整加锁顺序即可。

另外, JConsole 和 VisualVM 也能图形化地检测死锁:线程面板里选择"检测死锁"按钮,会直接高亮出死锁的线程和它们等待的锁。

6.3 从设计上杜绝死锁的四个思路

  1. 破坏"持有并等待" :一次性的用 tryLock 获取所有需要的锁(Java的 ReentrantLock.tryLock 带超时机制,获取不到全部锁就把已获取的释放掉)。
  2. 破坏"循环等待" :给所有锁编号,要求线程必须按序号从小到大获取锁,避免交叉。
  3. 减少锁粒度 :把一个对象上的大锁拆成多个细粒度锁,降低锁竞争的概率。
  4. 使用无锁数据结构 :比如 ConcurrentHashMap 、 AtomicInteger ,CAS本身就不会产生死锁。

实际项目里,最粗暴也最有效的办法是 缩小锁的作用范围 ——能锁方法中的几行代码就别锁整个方法。锁的代码越少,互相嵌套等待的可能性就越低。

7. 多线程下载与断点续传的技术原理

7.1 分块下载的底层逻辑

多线程下载的核心原理就一句话: 把一个文件拆成多个小块,每个线程负责下载其中一块,全部下载完成后再拼接成完整文件 。

实现这个功能的关键是HTTP协议里的 Range 请求头。客户端向服务器请求时可以指定想要哪一段字节:

GET /file.zip HTTP/1.1
Host: example.com
Range: bytes=0-1048575

服务器如果支持分段下载,会返回 206 Partial Content ,并在响应头里带上 Content-Range: bytes 0-1048575/10485760 表示本次返回的是整个文件的哪个部分。如果服务器不支持,会返回200和整个文件。

有了 Range 头,多线程下载器的逻辑就清晰了:

  1. 先发一个HEAD请求拿到文件总大小。
  2. 按总大小和线程数计算出每个线程要下载的起始和结束偏移量。
  3. 每个线程发一个带上自己偏移量范围的GET请求。
  4. 线程下载完自己的分段数据后,按照偏移量写入本地文件对应位置(使用 RandomAccessFile 的 seek 方法)。
  5. 所有线程完成后,文件就拼装完整了。

7.2 断点续传和临时文件记录的细节

断点续传是在多线程下载基础上增加"记忆"能力。如果下载到一半程序崩了,重启后怎么知道哪些分片已经下完了?最简单的做法是记录下载进度到本地元数据文件,比如JSON格式:

{
  "fileName": "file.zip",
  "fileSize": 10485760,
  "chunks": [
    {"index": 0, "start": 0, "end": 1048575, "finished": true},
    {"index": 1, "start": 1048576, "end": 2097151, "finished": false}
  ]
}

重启后读取这个进度文件,只对 finished 为false的分片发起下载请求就行。下载完成后再把分片合并,删除临时文件和进度文件。

有个细节容易忽略:服务器返回的数据必须严格按照偏移量写入文件,不能像顺序下载那样"把流写进文件就行"。用 RandomAccessFile.seek(offset) 定位写入位置,然后用 write(byte[]) 把分片数据写进去,才能保证文件拼装正确。只要有一个分片写错位置,整个文件就损坏了。

7.3 写一个最小可用的多线程下载器

这里我用Python写一个精简版的多线程下载器,结构清晰,适合学习原理:

import threading
import requests

def download_chunk(url, start, end, file_handle, lock):
    headers = {"Range": f"bytes={start}-{end}"}
    resp = requests.get(url, headers=headers, stream=True)
    file_handle.seek(start)
    for chunk in resp.iter_content(chunk_size=8192):
        with lock:
            file_handle.write(chunk)

def main():
    url = "https://example.com/large-file.zip"
    thread_count = 4
    
    # 先获取文件总大小(只发HEAD请求,不下载内容)
    head_resp = requests.head(url)
    file_size = int(head_resp.headers.get("Content-Length", 0))
    
    chunk_size = file_size // thread_count
    lock = threading.Lock()
    
    with open("output.zip", "wb") as f:
        threads = []
        for i in range(thread_count):
            start = i * chunk_size
            end = (i + 1) * chunk_size - 1 if i < thread_count - 1 else file_size - 1
            t = threading.Thread(target=download_chunk, 
                                 args=(url, start, end, f, lock))
            threads.append(t)
            t.start()
        for t in threads:
            t.join()
    
    print("下载完成")

if __name__ == "__main__":
    main()

注意文件指针的移动: file_handle.seek(start) 必须先于写数据,而且写操作要用 lock 保护,防止多个线程同时写同一个文件时文件指针互相干扰。

这套分片下载的原理,其实和很多企业级下载工具、浏览器下载管理器是相通的。明白底层逻辑后,如果再看到类似"HTTP断点续传"或"多线程下载插件"的实现,基本一眼就能看懂。

8. 多线程面试高频考点总结

8.1 基础概念必问题

多线程面试题里,基础概念考察最密集的是这几类:

第一类是"进程和线程的区别",答题要点是资源分配与调度单位的区别、独立性与共享性、创建开销、通信方式。把本文第一章讲的内容答出来,基本就能过关。

第二类是"创建线程的几种方式",Java里是三选一(Thread、Runnable、Callable),要能说出各自的优缺点,重点突出Callable的优势。

第三类是"sleep和wait的区别"。核心是: sleep() 不释放锁, wait() 释放锁; sleep() 是Thread的静态方法, wait() 是Object的方法; sleep() 需要捕获InterruptedException, wait() 要在同步代码块/同步方法中调用。还要能说出 wait() 必须在 synchronized 代码块里调用是因为它要操作"当前线程的监视器锁状态"。

8.2 进阶考点:并发工具与数据结构

面试官问你"ConcurrentHashMap怎么保证线程安全"时,不要只背答案。你要能说出JDK 7用的是分段锁(Segment + ReentrantLock),JDK 8改成了CAS + synchronized锁Node节点(锁链表头或红黑树根节点),这个过程是从锁竞争优化到细粒度同步的演进逻辑。

CountDownLatch 和 CyclicBarrier 的区别也是高频题:前者是"计数器递减"模式,等待的线程阻塞直到计数为0,一次性使用;后者是"栅栏"模式,一组线程互相等待都到达后同时执行,可循环复用。

volatile 与 synchronized 的区别也是必问: volatile 只保证可见性和有序性,不保证原子性; synchronized 兼顾三者。如果面试官追问"什么时候用volatile",典型场景是状态标记位——一个线程修改flag,其他线程读flag,不需要复合操作,用volatile就够了。

8.3 项目经验怎么答不露怯

面试官最爱问"你项目里怎么用多线程的",这道题考察的不是你会不会背API,而是有没有真正的工程经验。

比较好的回答套路是: 场景→方案→权衡→问题 。比如"我们有个推送服务,要给十万用户发消息,如果用串行方式要跑很久。我引入了线程池,核心线程数根据CPU核数和IO等待耗时估算设为16,最大线程数设为32,队列容量设成1000。上线后发现线程数超过32后任务排队时延很高,后来了解了Tomcat线程模型,改用异步回调方式减少线程阻塞,性能提升了3倍。"

这个回答提到了"场景""参数""压测""问题"和"解决方案",面试官会认为你确实踩过坑、动过脑。如果你只说"我用了线程池",那基本就在面试评价表上打上"经验浅薄"了。

9. 多线程编程的避坑经验清单

9.1 线程数量的"黄金法则"真的存在吗

网上流传了很多线程数计算公式:CPU密集型用"CPU核数+1",IO密集型用"CPU核数×2"。这些公式有参考价值,但千万别死记硬套。现代系统的瓶颈五花八门:数据库连接数、第三方接口限流、网络带宽、内存大小,任何一个都可能成为真正的瓶颈。

我个人的做法是:先根据经验估算一个初始值,然后用压力测试工具(比如JMeter、wrk)压出性能曲线,观察吞吐量和响应时延的拐点,多次调整后再确定最终值。线上运行一段时间后再根据监控数据微调。 没有一劳永逸的参数,只有不断调优的过程 。

9.2 线程安全容器的选择建议

Java里线程安全的集合类很多,容易选错。我的建议:

  • 读多写少的Map用 ConcurrentHashMap ,不要用 Hashtable (全表加锁,性能差)。
  • 需要按插入顺序遍历的Map用 Collections.synchronizedMap(new LinkedHashMap<>()) 。
  • 需要做队列操作时,单生产者单消费者用 ConcurrentLinkedQueue ,多生产者多消费者用 ArrayBlockingQueue 或 LinkedBlockingQueue 。
  • List的线程安全使用 CopyOnWriteArrayList ,适合读多写极少(比如白名单配置)。

Python里则要记住 list 、 dict 在CPython下因为有GIL保护,某些操作本身是原子的,但多个操作组合在一起(比如 d["count"] += 1 )依然不安全,该加锁还是得加锁。

9.3 真实线上事故给我的三个教训

第一个教训是: 不要在持锁时做耗时操作 。有一次我排查线上卡顿,发现某个接口把网络请求都放在了 synchronized 块里,一个线程卡在第三方接口超时上,其他线程全在锁上排队,整个服务响应全部变慢。后来把网络请求挪出锁,只把"读变量→更新变量"这一步加锁,问题立刻解决。

第二个教训是: 全局静态变量是实现并发的最大坑 。一个变量只要被多个线程同时写,就一定有竞争。我见过一个案例,开发用了 public static SimpleDateFormat 做日期格式化,生产环境不定时报错。原因是 SimpleDateFormat 线程不安全,多线程调用 format() 时内部Calendar状态混乱。改成 ThreadLocal<SimpleDateFormat> 后问题消失。

第三个教训是: 线程池的线程名一定要自定义 。线上排查问题,如果所有线程都叫"pool-1-thread-1",根本定位不了问题是哪个业务触发的。用 ThreadFactory 设置业务相关的线程名,比如"order-push-thread-1",出问题后看日志线程名就能锁定链路。

这三条其实都是老生常谈,但越基础的东西越容易在忙乱中忘掉。写并发代码时,时刻问自己三个问题:共享了什么?在哪里互斥?谁先执行谁后执行?想清楚了再动手,能省掉后面大量的排查时间。

更多推荐