ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

多线程与线程安全:从计数器事故到Java并发工具全解析

多线程与线程安全:从计数器事故到Java并发工具全解析 我第一次被并发问题打脸是在一个非常简单的计数器上。两个线程同时往同一个共享变量里累加跑了一晚上结果比预期少了几千。最烦的是本地复现不出来只有把每次写入打上日志才能看到中间值互相覆盖的痕迹。从那以后我才明白多线程安全不是一个背几个锁就能解决的问题而是一整套关于“内存、指令、调度、共享资源”的工程问题。这篇文章想聊透一个核心话题多线程以及怎么把线程安全工作做到位。我会用 Java 作为主线因为 Java 的并发工具最丰富也是面试中问最多的场景同时把 Python、Qt 里的多线程典型玩法带出来最后把 AtomicInteger、生产者消费者、线程池参数、死锁排查这些高频考点串成一条线。如果你是刚接触并发的开发人员准备面试或者线上服务已经出现过数据错乱但一直没找到根因这篇文章都值得完整看一遍。我会把踩过的坑、线上排查的手法、实际参数怎么定这些不会写在官方文档里的经验一并放出来。1. 线程安全到底是什么先搞清楚这句话1.1 先从一次计数器事故说起很多初学者对线程安全的判断标准是“有没有加锁”但这个标准太粗了。看下面这个计数器public class Counter { private int count 0; public void increment() { count; } }看起来人畜无害但它在线程并发下是百分百不安全的。count在字节码层面至少要拆成三步读 count → 把值加 1 → 把结果写回 count。两个线程真正常见的交错顺序是线程 A 读到 count 10。线程 B 也读到 count 10。线程 A 加 1 后写回 11。线程 B 加 1 后也写回 11。两次自增操作只产生了 10 到 11 的变化白白丢了一次。这个现象叫竞态条件Race Condition结果取决于多个线程相对调度的顺序。最要命的是竞态条件的出现概率不是由代码本身决定的而是由操作系统线程切换时机决定的。你以为“多跑几次没问题”那是没有足够大的压力或者没有足够的并发窗口。所以判断一段代码是否线程安全不能只问“这段代码有没有问题”而要问多个线程同时访问同一个共享数据无论执行顺序怎么交错数据最终是否都能保持一致如果答案是不一定那就存在线程安全问题。1.2 线程安全的三个维度原子性、可见性、有序性真正要保护的共享变量安全其实包含三个方面原子性。一个操作不能被打断要么全部执行完要么不执行。count不是原子的所以会丢失更新。原子类型、锁、CAS 这类机制都是为了解决原子性问题。可见性。一个线程修改了共享变量其他线程是否能立刻看到这次修改。现代 CPU 有多级缓存JIT 编译器也可能做优化导致线程 B 读到的旧值。经典例子是boolean stopped false; // 线程 A while (!stopped) { // 一直空转 } // 线程 B stopped true;这段代码里线程 A 有很大概率永远循环下去因为stopped的新值对线程 A 的内存视图不一定可见。解决方法是把stopped声明为volatile保证变量在读的时候不会拿到缓存里的旧值。有序性。编译器和 CPU 为了性能可能对指令重排序。单线程下重排序不会影响结果但多线程环境下重排序可能让另一个线程观察到“还没发生”的状态。Java 内存模型通过 happens-before 规则来约束锁、volatile、原子类都带有内存屏障能够阻断危险的重排序。这三个维度合起来才是一个完整的线程安全定义。很多人只在原子性上死磕却忽略了可见性和有序性最后导致线上诡异到无法解释的问题。1.3 别把线程安全问题都归到“锁不够”问题并不只有竞态条件一种。我见过不少团队出了并发故障就无脑加锁其实不同问题的应对方式完全不同。我整理成了一张速查表问题类型典型触发点解决方向竞态条件共享变量上的“读-改-写”操作交错锁、原子类、同步容器内存可见性线程间变量修改后不可见volatile、锁、final、原子类指令重排序对象发布时机不对volatile、锁、不可变对象死锁多个锁加锁顺序不一致固定顺序、tryLock、避免嵌套活锁锁竞争失败后不断重试状态不停变化随机退避、睡眠线程饥饿非公平锁长期被抢占公平锁、合理线程池配置其中死锁和活锁在面试里问得最多但线上最隐蔽的其实是可见性和重排序。因为后两者不会导致程序直接报错只会让数据在某些极端场景下静默出错。排查这类问题常规日志逻辑很难看出来更多是靠对内存模型的理解去推断。2. Java 里保护共享资源的常用手段2.1 synchronized 到底锁的是什么synchronized 是 Java 里最简单也最容易被误解的锁。很多初学者以为它锁的是“某段代码”其实锁的一定是一个对象。三种常见写法分别对应不同的锁对象修饰实例方法锁的是当前对象this。修饰静态方法锁的是当前类的 Class 对象。包裹代码块锁的是括号里指定的对象。如果两个线程同时访问同一个对象的两个 synchronized 方法它们互斥。如果是两个不同的对象实例那就不互斥。这个区别在面试里很常被问到。实际代码里我建议尽量用代码块并且使用一个独立的、私有的、不可替换的对象作为锁public class SafeCounter { private final Object lock new Object(); private int count 0; public void increment() { synchronized (lock) { count; } } }这里有个特别容易踩的坑不要用字符串常量作为锁。比如synchronized (abc)如果多个模块都用了同一个字符串常量锁就在莫名其妙的地方互相阻塞排查起来极为痛苦。更不要用Integer这种可能触发缓存的对象。锁对象的最佳选择是private final Object lock它只服务于当前类对外不可见也不会被别的代码替换掉。2.2 Lock 体系比 synchronized 多出来的能力JDK 5 之后提供了Lock接口最常用的是ReentrantLock。它和 synchronized 的区别不是“谁更快”而是谁的控制力更强。能力synchronizedReentrantLock加锁 / 解锁自动手动必须配合 finally超时等待不支持tryLock(2, TimeUnit.SECONDS)中断响应不支持lockInterruptibly()公平性非公平可以指定公平/非公平多个等待条件只能配合内置锁条件newCondition()可以有多个条件队列实际工作中我大部分情况下仍然会用 synchronized因为它简单、不易出错。只有在需要超时控制、可中断场景、读多写少场景时才升级到ReentrantLock或ReentrantReadWriteLock。比如一个热数据缓存读操作频率远高于写操作那么用读写锁可以显著降低竞争private final ReentrantReadWriteLock lock new ReentrantReadWriteLock(); private final Lock readLock lock.readLock(); private final Lock writeLock lock.writeLock(); public Value read(String key) { readLock.lock(); try { // 读缓存 } finally { readLock.unlock(); } } public void write(String key, Value value) { writeLock.lock(); try { // 写缓存 } finally { writeLock.unlock(); } }记住一个原则锁的范围要小锁的粒度要细锁的持有时间要短。别把整个业务方法甚至整个流程都塞进临界区否则线程安全是保住了系统的吞吐量也跟着被锁死了。2.3 volatile 管可见性管不了原子性volatile 是一个非常容易理解的机制它让变量直接在主存层面做读写并且禁止 CPU 对该变量做指令重排序。所以它非常适合做“状态开关”比如上面那个stopped标志。但它不能解决问题是复合操作的原子性。我见过有人把共享计数器声明成volatile int count然后照样count结果并发压测依然丢数据。原因很简单volatile 保证了读是最新值但“读-改-写”这三步仍然可能交错。两个线程同时读到 count10各自加 1写回最后依然是 11。所以在做并发代码时一定要把问题拆开问我这里是需要原子操作还是只需要可见性如果要原子性优先考虑原子类或者锁如果只是需要一个开关信号volatile 足够了。2.4 AtomicInteger 线程安全吗CAS 与 ABA这正是面试和实际项目中最高频的问题AtomicInteger 线程安全吗答案是安全它的线程安全建立在硬件原语 CASCompare And Swap上。incrementAndGet()内部大致逻辑是不断读取当前值计算新值然后调用compareAndSet(expect, update)。CAS 会把内存中的值和 expect 比较一致才写入 update否则循环重试。这个操作由 CPU 指令保证原子性所以单次自增是线程安全的。AtomicInteger counter new AtomicInteger(); counter.incrementAndGet();但要注意CAS 安全的是“单方法原子性”不是“复合逻辑的原子性”。如果你在业务代码里先get()再根据结果做判断再执行另一个compareAndSet()整个判断链路依然可能被其他线程插队。还有一个经典问题叫 ABA线程 A 读到值为 1被线程 B 改成 2 又改回 1线程 A 再 CAS 时发现值仍是 1于是认为没人动过实际上已经发生过变化。解决 ABA 需要带版本号例如AtomicStampedReference。所以面试官问 AtomicInteger 线程安全吗最完整的回答结构是先说线程安全再说底层机制是 CAS volatile再补充它在高争用场景下的自旋代价以及复合操作不一定安全。这样才不是背答案。3. 并发容器、生产者消费者与跨语言对照3.1 Java 并发容器为什么能说自己线程安全除了加锁Java 还提供了一整套并发容器它们比“给一个 HashMap 套 synchronized”更高效。原因是它们对锁粒度做了精细处理ConcurrentHashMapJDK 8 之前按 Segment 分段锁JDK 8 之后改为桶粒度 CAS 加 synchronized只在哈希冲突时才有竞争。CopyOnWriteArrayList读操作完全无锁写操作时复制一个新数组。适合读多写少、数据量不大的场景。BlockingQueue数组和链表两种实现内部封装了条件队列就是专门给生产者消费者用的。很多人问并发容器和同步包装容器有什么区别。Collections.synchronizedMap(new HashMap())是把整个 Map 外面罩了一把大锁任何读和写都全局串行。而ConcurrentHashMap尽量只锁住冲突分支读操作大多数时候根本不加锁。同样的线程规模下两者性能差距可以在一个数量级。选择容器之前先问自己数据量多大读多还是写多是否需要强一致的迭代并发容器不是万能比如CopyOnWriteArrayList在写多场景下会因为频繁复制数组导致内存和 GC 压力巨大。很多线上内存问题都是把写多的列表用 CopyOnWrite 造成的。3.2 生产者消费者经典实现与队列参数取舍生产者消费者模式是所有线程协作场景里的基础款。关键在于中间放一个线程安全的阻塞队列生产者和消费者完全不需要知道对方的存在。下面是一个简洁实现import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; public class ProducerConsumerDemo { static final int CAPACITY 100; static final BlockingQueueString queue new ArrayBlockingQueue(CAPACITY); static volatile boolean done false; static class Producer implements Runnable { Override public void run() { try { for (int i 0; i 1000; i) { queue.put(data- i); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done true; } } } static class Consumer implements Runnable { Override public void run() { try { while (true) { String data queue.poll(200, TimeUnit.MILLISECONDS); if (data null done) { break; } if (data ! null) { process(data); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } static void process(String data) { // 模拟下游耗时处理 } }队列容量怎么定这是我最常被问到的问题。容量太小消费者容易并发空闲生产者频繁阻塞容量太大消息延迟高内存占用也高。一个比较实用的经验是先用公式估算峰值积压量等于“单位时间最大生产速率 × 平均处理耗时”。比如每秒生产 1000 条消费者平均处理一条要 200ms那队列容量至少要有 200留一定余量后可以设 500。再根据峰值内存做压测观察生产者和消费者的等待时间占比最终确定最优值。3.3 Python 的多线程GIL 与真实瓶颈很多人以为 Python 有 GIL多线程就一定安全这是完全错误的。GIL 只是保证同一时刻只有一个线程执行 Python 字节码但字节码执行过程中仍可能被切换。更重要的是Python 层的一段代码对应很多条字节码指令切换照样会造成丢失更新。import threading counter 0 lock threading.Lock() def worker(): global counter for _ in range(100_000): with lock: counter 1 threads [threading.Thread(targetworker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print(counter) # 不加锁一定小于 800000GIL 真正的价值在于简化了 CPU 密集型 Python 解释器的实现但它并不是并发程序的保护伞。Python 里处理多线程时官方推荐的套路是ThreadPoolExecutor搭配queue.Queue在任务间传递消息而不是共享可变状态。对 IO 密集型任务这个方案很舒服。但遇到 CPU 密集型的计算任务多线程在 GIL 下并不能带来多少加速更合适的做法是用multiprocessing或者ProcessPoolExecutor走多进程。3.4 Qt 里做多线程最容易被坑的地方Qt 的多线程和其他框架最大的不同在于它有明确的线程亲和性。特别是 GUI 应用程序所有 UI 控件都必须在主线程中操作。如果你在子线程里直接label-setText()轻则画面不刷新重则崩溃。Qt 官方推荐的做法是把耗时任务放到一个 QObject 子类中用moveToThread把它挪到独立线程再通过信号槽把结果传回主线程更新界面。class Worker : public QObject { Q_OBJECT public slots: void doWork(const QString input) { // 耗时操作 emit resultReady(output); } signals: void resultReady(const QString result); }; // 启动时 QThread *thread new QThread; Worker *worker new Worker; worker-moveToThread(thread); connect(thread, QThread::started, worker, [worker]() { // 通过事件循环调用 worker 的槽 }); connect(worker, Worker::resultReady, uiObject, MainWindow::updateUi);在 Qt 里QMutex 和 QReadWriteLock 也能保护共享资源但更推荐“少共享、多传递”的思路。信号槽机制天然就把数据从一个线程搬运到另一个线程不需要到处加锁逻辑也更清楚。还有一个经常翻车的是在子线程里调用等待类型的wait()、sleep()阻塞事件循环导致槽函数无法被调度。Qt 并发编程的核心思想是不要让任何一个线程被自己阻塞住。4. 高并发场景下线程池与 ThreadLocal 的正确姿势4.1 线程池核心参数怎么定我给你一个计算套路线程池并不是创建得越大越好。在 Java 里用ThreadPoolExecutor时必须明确五个参数核心线程数、最大线程数、空闲存活时间、任务队列、拒绝策略。这里有一个业内常用的估算公式线程数 CPU 核数 × 期望 CPU 利用率 ×1 等待时间 / 计算时间公式里的等待时间表示任务等待 IO、网络、锁等的时间计算时间表示纯 CPU 计算的时间。举个例子服务器是 8 核目标是 CPU 利用率打到 80%每个任务平均等待 30ms计算耗时 10ms那么线程数 ≈ 8 × 0.8 ×1 30 / 10≈ 25.6所以初始可以用 26 个核心线程再通过压测调整。这里有个非常常见的错误不管什么场景都套一个newFixedThreadPool(200)结果明明每个任务都要等待远程接口返回200 个线程还是把下游打挂或者排队等木桶。还有极端情况下任务无限堆积在无界队列里最终内存溢出。我建议队列一律用有界队列并在创建时设置一个能容纳峰值积压的容量同时想清楚拒绝策略AbortPolicy会抛异常CallerRunsPolicy会让提交任务的线程自己执行DiscardOldestPolicy会丢弃最旧的任务。线上取舍是宁可丢弃一部分可重试的任务也不能让队列无限膨胀。4.2 ThreadLocal 和线程池的隐藏雷区ThreadLocal 本身是线程安全的因为每个线程都持有一份独立的变量副本。但它和线程池结合时有个很隐蔽的问题线程池中的线程会被重复使用如果 ThreadLocal 里的值没有清理下一次任务执行时会读到上一个任务的残留数据。更严重的是内存泄漏。ThreadLocalMap 的 key 是弱引用但 value 是强引用。如果线程长期存活在线程池里而 key 被 GC 回收value 就无法通过正常路径被访问却一直挂在线程上积少成多就成了内存泄漏。最典型的场景是存放用户登录信息、链路追踪 ID、数据库连接等较大对象。实际操作时我的准则是在 finally 块里直接清理private static final ThreadLocalRequestContext context new ThreadLocal(); try { context.set(new RequestContext(...)); // 业务处理 } finally { context.remove(); }另外还要注意不要尝试用 ThreadLocal 做父子线程之间的数据传递它根本传不过去。如果要传递要么用InheritableThreadLocal要么更稳的做法是把上下文显式放在任务对象里传给线程池。4.3 线上并发问题定位的三板斧并发问题最难的不是修复而是“发现根因”。我排查线上问题时基本按这个顺序来看线程状态用jstack pid导出线程栈重点看 BLOCKED、WAITING 状态的线程因为死锁和长时间阻塞都会在这里留下痕迹。如果输出里出现明确的Found one Java-level deadlock那就直接定位到了。抓系统指标观察 CPU 使用率、线程数量和任务队列积压。CPU 跑满但应用无响应通常是有线程死循环或者自旋 CAS 过于激烈。做最小化复现并发 bug 很难在本地一次复现。可以先定向加日志在关键共享资源访问前后打印线程名、时间戳和值然后靠压测模拟真实并发环境。如果加上随机 sleep往往能放大竞态窗口。日志里给每个线程起一个有意义的名字非常重要。默认的 Thread-1、Thread-2 在堆栈里毫无信息量但queue-consumer-3这种名字能让你一眼看出是哪个业务模块的问题。5. 面试场上的线程安全“送命题”要怎么接5.1 AtomicInteger 这个高频面试题的回答框架面试官问 AtomicInteger 线程安全吗就是想考察三个点基础概念、底层原理、对局限性的理解。一个合格的回答是这样的AtomicInteger 线程安全。它内部持有一个 volatile int 变量保证可见性更新时使用 CAS 循环保证原子性。CAS 是 CPU 提供的比较并交换原语AtomicInteger 在自旋过程中不断重读期望值如果线程很多、竞争激烈自旋次数会明显增高。另外它保证的只是单个方法的原子性如果需要多个操作组合成复合逻辑仍然需要添加锁。高竞争场景下建议考虑 LongAdder它把热点拆分成多个累加单元能在写多读少场景下进一步降低竞争。顺着这个回答面试官大概率会追问“CAS 是什么ABA 是怎么回事”。所以你自己至少要准备一个小例子来支撑回答。5.2 线程、任务、锁和线程池的考点串联多线程面试题里还有一个常考连环问如何创建一个线程很多人的答案永远停在“继承 Thread”和“实现 Runnable”但这其实是在自断前程。完整的回答应该包含继承 Thread 重写 run。实现 Runnable配合 new Thread。实现 Callable 并配合 FutureTask 获取返回值。实际项目中更常用 ExecutorService 线程池通过 execute 或 submit 提交任务。再往下追问还会衔接 wait 和 sleep 的区别wait 释放锁sleep 不释放wait 必须在同步块中调用sleep 不用。如果继续深挖就要说出 CountDownLatch、CyclicBarrier、Semaphore 的使用场景以及 BlockingQueue 为什么比手写 wait/notify 更可靠。手写 wait/notify 太容易踩到“条件判断错误导致线程永久挂起”的坑所以我个人的观点是能用并发工具就绝不用原生 wait。5.3 死锁排查与避免的现场回答模板死锁是面试中的标配。面试官想听的是条理不是把“锁嵌套”这个词抛出来。你可以按下面这个模板答死锁产生需要四个必要条件互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。要避免死锁最简单也是最实用的办法就是破坏循环等待让所有线程以统一的全局顺序获取锁。其次可以给获取锁的动作加超时比如 tryLock 拿不到锁就释放已持有锁并回退不许无限阻塞。最后缩小锁的范围、减少锁的嵌套从源头上降低死锁概率。如果让我在项目实践里补充那就是代码评审阶段重点看锁嵌套的写法避免在一个 synchronized 代码块里继续调用外部方法。因为外部方法可能包含未知的锁等于把一个不可控的循环等待引进了系统。6. 最后再分享一个我自己的习惯我自己在并发编码里坚持的第一原则是“能不用锁就不用锁能少共享就少共享”。不可变对象、线程封闭、并发容器、原子类这些方案优先于 synchronized。被迫加锁时我会在代码注释里写清楚锁的对象是什么、保护的是哪个变量、获取顺序是什么。给线程和线程池起名字给锁起名字这看起来只是细节但线上排查时能省好几个小时。线程安全不是靠一道工序保证的它来自每次写并发代码时对可见性、原子性、有序性的反复推敲。这个习惯比任何并发工具都值钱。
返回列表