多线程优化血亏教训!这坑 99% 的人都踩过

开发 前端
多线程优化就像走钢丝,看着简单,其实处处都是陷阱。咱得把基础打扎实,多在实践中总结经验,遇到问题别慌,用调试工具和性能分析工具慢慢排查。希望大家看完这篇文章,能避开这些坑,在多线程优化的路上少走弯路,写出高效、稳定的代码。

兄弟们,今天咱来唠唠多线程优化这事儿。说起来都是泪啊,当年我在项目里搞多线程优化,那叫一个自信满满,觉得自己吃透了Java并发编程,结果硬生生踩了一堆坑,差点被领导拎去祭天。咱今天就把这些血亏教训掰开了揉碎了,让大家少走弯路。

一、线程池参数拍脑袋设置?坑你没商量

刚入行那会,听说线程池能提高效率,嘿,那必须用啊!上来就是newFixedThreadPool(100),心想100个线程同时干活,这效率不得起飞?结果上线没两天,服务器直接卡死,GC日志跟下雨似的哗哗往外冒。

1. 问题出在哪?

咱先看看FixedThreadPool的源码,它用的是无界队列LinkedBlockingQueue。我设置了100个核心线程,想着处理100个任务够了吧?可现实是任务源源不断过来,全往队列里塞,队列无限增长,内存直接爆掉。就好比你开了个餐厅,雇了100个服务员(核心线程),结果来了1000个客人,你让他们全在大厅等着(无界队列),大厅挤爆了也不管,最后只能关门大吉。

2. 正确姿势是啥?

咱得根据任务类型来设置参数。要是CPU密集型任务,线程数一般设为CPU核心数+1,为啥加1呢?因为线程切换也需要时间,多一个线程可以在某个线程阻塞时顶上。要是IO密集型任务,那就可以多设点,比如CPU核心数*2。而且队列最好用有界队列,比如ArrayBlockingQueue,防止内存溢出。还有拒绝策略,不能默认用AbortPolicy,直接抛异常,咱可以用CallerRunsPolicy,让调用者线程来处理任务,给系统一个缓冲机会。

我后来在一个文件处理系统里优化线程池,根据服务器是8核CPU,任务是IO密集型,设置核心线程数为16,最大线程数32,队列大小200,拒绝策略用CallerRunsPolicy,结果处理速度提升了3倍,内存也稳定了。

二、锁滥用:以为加锁就安全,性能直接扑街

在处理共享资源时,大家都知道要加锁,可我之前就犯了个傻,在一个高频调用的方法里,不管三七二十一,直接给整个方法加了synchronized锁。想着这下安全了,结果性能测试的时候,吞吐量直接腰斩,线程竞争特别激烈。

1. 锁的粒度没控制好

整个方法加锁,相当于把整个房间都锁起来,每次只能一个人进去,其他人都得在外面等着。其实咱可以缩小锁的范围,只对共享资源加锁。比如有个订单处理类,里面有个共享的订单号生成变量,我之前给整个处理订单的方法加锁,后来改成只在生成订单号的代码块上加锁,性能立马提升了50%。

2. 锁的类型没选对

synchronized是独占锁,竞争激烈时效率不高。咱可以用ReentrantLock,它支持公平锁和非公平锁,还能尝试获取锁。比如在一个高并发的库存扣减场景,用ReentrantLock的tryLock方法,避免线程长时间阻塞。还有读写锁ReadWriteLock,读多写少的场景用它,读的时候可以多个线程同时读,写的时候才加锁,效率杠杠的。

我之前在一个缓存系统里,用synchronized来控制对缓存的读写,结果读操作都被阻塞了。换成ReadWriteLock后,读操作的吞吐量提升了80%,写操作虽然稍微慢了点,但整体性能大幅提升。

三、盲目追求高并发:线程越多越好?图样图森破

那时候总觉得线程越多,并行度越高,性能就越好。于是在一个任务处理系统里,开了200个线程去处理任务,结果任务处理速度不仅没提升,反而下降了,CPU利用率倒是100%,但系统就是卡得不行。

1. 上下文切换惹的祸

CPU核心数是有限的,比如8核CPU,同时只能运行8个线程。开了200个线程,CPU就得在这200个线程之间频繁切换,每次切换都需要保存当前线程的状态,加载下一个线程的状态,这就叫上下文切换。大量的上下文切换消耗了大量的CPU资源,真正处理任务的时间反而少了。就好比你雇了200个工人,但只有8台机器,工人要不断地抢机器,抢来抢去,真正干活的时间没多少。

2. 怎么确定合适的线程数?

咱可以用一个公式:线程数 = CPU核心数 * (1 + 平均等待时间 / 平均工作时间)。比如一个任务,平均工作时间是1ms,平均等待IO的时间是9ms,那么线程数 = 8 * (1 + 9/1) = 80。这样可以让CPU在等待IO的时候,去处理其他线程的任务,提高利用率。

后来我在那个任务处理系统里,把线程数从200降到80,结果任务处理速度提升了2倍,CPU利用率也降到了合理范围,系统终于不卡了。

四、伪共享:缓存行对齐没考虑,性能偷偷溜走

这是个比较隐蔽的坑,我也是在做性能分析的时候,通过工具才发现的。有两个共享变量,本来以为没啥关系,结果它们被放到同一个缓存行里,导致频繁的缓存失效,性能下降。

1. 啥是伪共享?

CPU缓存是以缓存行为单位的,通常是64字节。如果两个不同的变量被放到同一个缓存行里,当一个线程修改了其中一个变量,另一个变量所在的缓存行也会失效,导致另一个线程不得不从主内存重新读取数据。比如有两个long类型的变量,每个8字节,放在一起就是16字节,一个缓存行可以放8个这样的变量。如果两个线程分别修改这两个变量,就会导致缓存行频繁失效。

2. 怎么解决?

咱可以在变量之间填充一些无用的变量,让它们不在同一个缓存行里。比如Java里可以用@Contended注解,不过需要在JVM启动时加上-XX:EnableContended参数。或者自己手动填充,比如定义一个类,里面有几个long类型的变量,把需要避免伪共享的变量隔开。

我在一个计数器类里,有两个计数器变量count1和count2,被多个线程分别修改,结果发现它们在同一个缓存行里。后来在中间填充了7个long类型的变量,让每个变量单独占一个缓存行,性能提升了30%。

五、volatile用错地方:以为能保证原子性,结果出大问题

知道volatile能保证可见性,就以为它能保证原子性,在一个自增操作里用了volatile变量,结果并发情况下,数值还是不对。

1. volatile的特性

volatile只能保证可见性,不能保证原子性。比如i++这个操作,实际上分为读取、加1、写入三个步骤,这三个步骤不是原子的,在多线程情况下,可能会出现丢失更新的情况。

2. 正确使用场景

volatile适合用在状态标志位,比如一个线程等待另一个线程完成某个操作,用volatile变量来通知。如果需要原子性操作,还是得用synchronized或者AtomicInteger等原子类。

我之前在一个状态机里,用volatile变量来控制状态转换,结果在并发情况下,状态转换出现了混乱。后来换成用synchronized来保护状态转换的代码块,问题就解决了。

六、线程安全的单例模式:双重检查锁定,锁的对象错了

写单例模式的时候,想着用双重检查锁定来提高效率,结果锁的对象用了类的实例,而不是类的Class对象,导致出现多个实例的情况。

1. 正确的双重检查锁定

正确的做法是锁的对象是类的Class对象,而且实例变量要用volatile修饰,防止指令重排。比如:

public class Singleton {
    privatevolatilestatic Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

2. 为啥锁对象不能是实例

如果锁的对象是实例,在实例还没创建的时候,多个线程同时进入,都去创建实例,就会出现多个实例的情况。而用Class对象,不管实例有没有创建,锁都是唯一的。

我之前就是锁的对象错了,导致系统里出现了多个单例实例,引发了一系列奇怪的问题,排查了好久才发现是这个问题。

七、总结:多线程优化的正确姿势

说了这么多坑,咱来总结一下多线程优化的正确姿势:

  1. 线程池参数别拍脑袋,根据任务类型和系统资源仔细计算,用有界队列和合适的拒绝策略。
  2. 锁的粒度要小,类型要选对,能不用锁就不用锁,能用原子类就用原子类。
  3. 线程数不是越多越好,考虑上下文切换的开销,用公式计算合适的线程数。
  4. 注意伪共享问题,尤其是在高并发场景下,用 @Contended 或者手动填充来避免。
  5. volatile 和 synchronized、原子类的适用场景要分清,别搞错了。
  6. 写线程安全的代码时,细节很重要,比如单例模式的双重检查锁定,锁的对象和 volatile 修饰符都不能少。

多线程优化就像走钢丝,看着简单,其实处处都是陷阱。咱得把基础打扎实,多在实践中总结经验,遇到问题别慌,用调试工具和性能分析工具慢慢排查。希望大家看完这篇文章,能避开这些坑,在多线程优化的路上少走弯路,写出高效、稳定的代码。

责任编辑:武晓燕 来源: 石杉的架构笔记
相关推荐

2024-09-29 09:27:10

2024-09-27 09:31:25

2021-10-15 06:49:37

MySQL

2021-09-25 13:05:10

MYSQL开发数据库

2025-04-03 12:30:00

C 语言隐式类型转换代码

2019-10-30 14:44:41

Prometheus开源监控系统

2015-03-24 16:29:55

默认线程池java

2024-10-08 08:14:08

用户生命周期分析服务

2022-04-26 21:49:55

Spring事务数据库

2024-04-01 08:05:27

Go开发Java

2024-01-22 09:16:47

多线程性能优化

2017-07-17 15:46:20

Oracle并行机制

2023-12-14 17:34:22

Kubernetes集群K8s

2025-02-06 07:45:44

2019-09-25 15:30:15

2024-06-26 10:37:05

2018-01-10 13:40:03

数据库MySQL表设计

2024-05-06 00:00:00

缓存高并发数据

2023-03-13 13:36:00

Go扩容切片

2018-09-11 09:14:52

面试公司缺点
点赞
收藏

51CTO技术栈公众号