文章 585
评论 5
浏览 226854
面试被问倒的 3 个并发问题:线程池参数、锁升级、伪共享

面试被问倒的 3 个并发问题:线程池参数、锁升级、伪共享

一、引言

并发编程是后端面试的必考题,但很多开发者只是死记硬背答案,遇到深入追问就露馅了。今天我要拆解三个高频并发问题,不仅告诉你答案,更要讲清楚背后的原理。

核心观点:理解并发问题的关键在于从底层原理出发——线程池参数要结合业务场景,锁升级要理解对象头结构,伪共享要明白 CPU 缓存机制。


二、线程池参数:核心线程数到底怎么算?

2.1 错误认知:CPU 核数 + 1

很多人背的答案是"CPU 核心数 + 1",但这只适用于特定场景。线程池参数的设置需要结合任务类型来决定。

2.2 任务类型分类

任务类型特点典型场景
CPU 密集型任务主要消耗 CPU 资源,几乎不等待数据计算、排序、加密解密
IO 密集型任务大部分时间在等待 IO 操作数据库查询、网络请求、文件读写
混合型CPU 计算和 IO 操作都有Web 服务处理、API 网关

2.3 计算公式

CPU 密集型任务

核心线程数 = CPU 核心数 + 1

理由:当一个线程被暂停(如页缺失),额外的一个线程可以利用这段时间继续执行,最大化 CPU 利用率。

IO 密集型任务

核心线程数 = CPU 核心数 × (1 + 等待时间 / 计算时间)

简化公式

核心线程数 = CPU 核心数 × 2

理由:IO 密集型任务大部分时间在等待,多个线程可以交替执行,充分利用 CPU。

2.4 实战计算示例

假设 CPU 核心数 = 8,一个 HTTP 请求的处理时间为 100ms,其中计算时间 10ms,等待时间 90ms:

核心线程数 = 8 × (1 + 90/10) = 8 × 10 = 80

2.5 完整线程池配置

public class ThreadPoolConfig {

    public static ExecutorService createThreadPool() {
        int cpuCores = Runtime.getRuntime().availableProcessors();
        
        int corePoolSize = cpuCores * 2;
        int maxPoolSize = cpuCores * 4;
        long keepAliveTime = 60L;
        TimeUnit unit = TimeUnit.SECONDS;
        
        LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(1000);
        
        ThreadFactory threadFactory = new ThreadFactory() {
            private final AtomicInteger counter = new AtomicInteger(0);
            
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r, "business-thread-" + counter.incrementAndGet());
                thread.setDaemon(false);
                return thread;
            }
        };
        
        RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();
        
        return new ThreadPoolExecutor(
            corePoolSize,
            maxPoolSize,
            keepAliveTime,
            unit,
            queue,
            threadFactory,
            handler
        );
    }
}

2.6 参数调优建议

参数调优原则
corePoolSize根据任务类型计算,IO 密集型可设为 CPU 核数的 2~4 倍
maxPoolSize通常为 corePoolSize 的 2 倍,防止线程过多导致上下文切换
queueCapacity避免过大导致内存溢出,建议设为有界队列
keepAliveTime非核心线程的空闲存活时间,根据业务特性调整
handler生产环境建议使用 CallerRunsPolicy 而非默认的 AbortPolicy

三、锁升级:synchronized 的三级锁

3.1 锁的类型和性能对比

锁类型状态标志性能特点适用场景
无锁01无锁竞争,性能最好单线程访问
偏向锁01 (biased)无 CAS 操作,几乎零开销同一线程重复获取
轻量级锁00CAS 自旋,无系统调用短时间、低竞争
重量级锁10操作系统互斥锁,有系统调用开销长时间、高竞争

3.2 对象头 Mark Word 结构

在 64 位 JVM 中,对象头的 Mark Word 占 8 字节(64 位),结构如下:

┌─────────────────────────────────────────────────────────────────┐
│                      Mark Word (64 bits)                        │
├─────────┬─────────┬─────────┬───────────────────────────────────┤
│ unused  │ hash    │ unused  │ lock                               │
│ (25bit) │ (31bit) │ (1bit)  │ (2bit)                            │
└─────────┴─────────┴─────────┴───────────────────────────────────┘

锁标志位 (lock) 含义:
- 01:无锁或偏向锁(取决于 biased bit)
- 00:轻量级锁
- 10:重量级锁
- 11:GC 标记

偏向锁模式下的额外字段:
┌─────────────────────────────────────────────────────────────────┐
│                      Mark Word (biased)                          │
├─────────────────────────────────────────────────────────────────┤
│                    Thread ID (54bit)                             │
├─────────────────────────────────────────────────────────────────┤
│ epoch (2bit) │ biased (1bit) │ lock (2bit = 01)                  │
└─────────────────────────────────────────────────────────────────┘

3.3 锁升级路径

阶段 1:无锁 → 偏向锁

public class LockUpgradeDemo {
    private Object lock = new Object();
    
    public void method() {
        // 第一次进入,Mark Word 从无锁升级为偏向锁
        // 将当前线程 ID 写入 Mark Word
        synchronized (lock) {
            // 偏向锁状态:lock=01, biased=1
            // Thread ID = 当前线程 ID
        }
    }
}

阶段 2:偏向锁 → 轻量级锁

当另一个线程尝试获取同一个锁时:

public class LockUpgradeDemo {
    private Object lock = new Object();
    
    public void method() {
        synchronized (lock) {
            // 线程 A 获取偏向锁后
            
            // 线程 B 尝试获取锁,发现偏向锁已被占用
            // 偏向锁撤销,升级为轻量级锁
            // 线程 A 和 B 开始 CAS 自旋
        }
    }
}

阶段 3:轻量级锁 → 重量级锁

当自旋超过一定次数(默认 10 次)或线程数超过 CPU 核心数的一半时:

public class LockUpgradeDemo {
    private Object lock = new Object();
    
    public void method() {
        synchronized (lock) {
            // CAS 自旋失败,升级为重量级锁
            // 线程阻塞,进入等待队列
            // 触发操作系统的 mutex lock
        }
    }
}

3.4 锁升级流程图

┌──────────────────────┐
                    │    线程访问对象       │
                    └──────────┬───────────┘
                               │
                    ┌──────────▼───────────┐
                    │    无锁状态 (01)     │
                    └──────────┬───────────┘
                               │ 第一次获取
                    ┌──────────▼───────────┐
                    │   偏向锁 (01,biased) │
                    │   写入 Thread ID     │
                    └──────────┬───────────┘
                               │ 其他线程竞争
                    ┌──────────▼───────────┐
                    │   轻量级锁 (00)      │
                    │   CAS 自旋           │
                    └──────────┬───────────┘
                               │ 自旋失败/超时
                    ┌──────────▼───────────┐
                    │   重量级锁 (10)      │
                    │   OS mutex lock      │
                    └──────────────────────┘

3.5 关键知识点

偏向锁撤销条件

  • 其他线程尝试获取锁
  • 调用 hashCode() 方法(因为 hashCode 会写入 Mark Word)
  • 对象被 GC

轻量级锁自旋条件

  • 线程数 ≤ CPU 核心数的一半
  • 自旋次数 ≤ 10 次(可通过 -XX:PreBlockSpin 调整)

锁消除:JIT 编译时如果检测到某个锁对象没有逃逸出方法,会自动消除锁。


四、伪共享:CPU 缓存行的陷阱

4.1 什么是伪共享

CPU 的缓存是以缓存行(Cache Line)为单位的,通常是 64 字节。当多个线程访问不同的数据但这些数据恰好位于同一个缓存行时,就会发生伪共享

┌────────────────────────────────────────────────────────────────┐
│                    CPU Cache Line (64 bytes)                   │
├────────────────────────────────────────────────────────────────┤
│  Thread A: counter1 (8 bytes) | Thread B: counter2 (8 bytes)  │
│  ... (其他数据) ...                                             │
└────────────────────────────────────────────────────────────────┘

问题:Thread A 修改 counter1,导致整个缓存行失效
     Thread B 访问 counter2 时,需要重新从主存加载
     即使 counter1 和 counter2 是独立的数据!

4.2 伪共享的性能影响

public class FalseSharingDemo {
    
    public static class Counter {
        public volatile long value = 0L;
    }
    
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        
        Thread t1 = new Thread(() -> {
            for (long i = 0; i < 100000000L; i++) {
                counter.value++;
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (long i = 0; i < 100000000L; i++) {
                counter.value++;
            }
        });
        
        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long end = System.currentTimeMillis();
        
        System.out.println("耗时: " + (end - start) + "ms");
        System.out.println("结果: " + counter.value);
    }
}

现象:两个线程操作同一个 volatile 变量,由于缓存一致性协议(MESI),每次写操作都会导致缓存行失效,性能极差。

4.3 使用 @Contended 解决伪共享

JDK 8

import sun.misc.Contended;

public class FalseSharingFixed {
    
    @Contended
    public volatile long counter1 = 0L;
    
    @Contended
    public volatile long counter2 = 0L;
}

JDK 9+

import jdk.internal.vm.annotation.Contended;

public class FalseSharingFixed {
    
    @Contended
    public volatile long counter1 = 0L;
    
    @Contended
    public volatile long counter2 = 0L;
}

注意事项

  1. JDK 8:需要添加 -XX:-RestrictContended JVM 参数
  2. JDK 9+:需要添加 --add-opens java.base/jdk.internal.vm.annotation=ALL-UNNAMED
  3. 包路径变化:JDK 9+ 从 sun.misc.Contended 移动到 jdk.internal.vm.annotation.Contended

4.4 手动填充缓存行

如果不想依赖内部 API,可以手动添加填充字段:

public class CacheLinePaddedCounter {
    
    // 填充前面的缓存行(7 个 long = 56 字节)
    private long p1, p2, p3, p4, p5, p6, p7;
    
    public volatile long value = 0L;
    
    // 填充后面的缓存行
    private long p8, p9, p10, p11, p12, p13, p14;
    
    public void increment() {
        value++;
    }
    
    public long get() {
        return value;
    }
}

原理:每个 long 占 8 字节,7 个 long = 56 字节,加上 value 的 8 字节正好占满一个缓存行。

4.5 使用继承方式填充

public class BasePadding {
    protected long p1, p2, p3, p4, p5, p6, p7;
}

public class Counter extends BasePadding {
    public volatile long value = 0L;
    
    public void increment() {
        value++;
    }
}

注意:JIT 编译器可能会优化掉未使用的字段,需要确保这些字段被引用。

4.6 性能对比测试

public class FalseSharingBenchmark {
    
    public static void main(String[] args) throws InterruptedException {
        int iterations = 100000000;
        
        // 伪共享版本:两个计数器在同一个对象中,可能共享缓存行
        FalseSharingCounters shared = new FalseSharingCounters();
        long sharedTime = runFalseSharingBenchmark(shared, iterations);
        
        // 缓存行填充版本:两个计数器各自独立,不会共享缓存行
        CacheLinePaddedCounters padded = new CacheLinePaddedCounters();
        long paddedTime = runPaddedBenchmark(padded, iterations);
        
        System.out.println("伪共享版本耗时: " + sharedTime + "ms");
        System.out.println("缓存行填充版本耗时: " + paddedTime + "ms");
        System.out.println("性能提升: " + String.format("%.2f", sharedTime * 1.0 / paddedTime) + "倍");
    }
    
    // 伪共享测试:线程1操作counter1,线程2操作counter2,两者在同一对象中
    private static long runFalseSharingBenchmark(FalseSharingCounters counters, int iterations) 
            throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < iterations; i++) {
                counters.counter1++;
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < iterations; i++) {
                counters.counter2++;
            }
        });
        
        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        return System.currentTimeMillis() - start;
    }
    
    // 缓存行填充测试:两个计数器各自独立
    private static long runPaddedBenchmark(CacheLinePaddedCounters counters, int iterations) 
            throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < iterations; i++) {
                counters.counter1.increment();
            }
        });
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < iterations; i++) {
                counters.counter2.increment();
            }
        });
        
        long start = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        return System.currentTimeMillis() - start;
    }
    
    // 伪共享计数器:两个volatile变量在同一对象中,可能共享缓存行
    static class FalseSharingCounters {
        volatile long counter1 = 0;
        volatile long counter2 = 0;
    }
    
    // 缓存行填充计数器:每个计数器都有独立的缓存行
    static class CacheLinePaddedCounters {
        CacheLinePaddedCounter counter1 = new CacheLinePaddedCounter();
        CacheLinePaddedCounter counter2 = new CacheLinePaddedCounter();
    }
    
    // 单个缓存行填充的计数器
    static class CacheLinePaddedCounter {
        long p1, p2, p3, p4, p5, p6, p7;
        volatile long value = 0;
        long p8, p9, p10, p11, p12, p13, p14;
        
        void increment() {
            value++;
        }
    }
}

关键区别

  • 伪共享版本:线程1和线程2分别操作同一个对象的不同字段(counter1counter2),由于它们在内存中相邻,可能共享同一个缓存行
  • 缓存行填充版本:每个计数器都是独立对象,且通过填充字段确保每个 value 都独占一个缓存行

预期结果:缓存行填充版本的性能通常是伪共享版本的 3~5 倍


五、总结

5.1 线程池参数

任务类型核心线程数公式关键要点
CPU 密集型N + 1最大化 CPU 利用率
IO 密集型N × (1 + WT/ST)充分利用等待时间
混合型根据实际比例调整建议压测确定

5.2 锁升级

核心路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁

关键要点

  • 偏向锁:消除同一线程的锁获取开销
  • 轻量级锁:CAS 自旋,避免系统调用
  • 重量级锁:操作系统互斥锁,线程阻塞

5.3 伪共享

核心问题:多个线程访问同一缓存行的不同数据,导致缓存行频繁失效。

解决方案

  • @Contended 注解(需 JVM 参数)
  • 手动填充缓存行(7 个 long 字段)
  • 使用继承方式填充

💡 互动话题:你在面试中遇到过哪些并发相关的问题?你是怎么回答的?欢迎在评论区分享你的经历!


标题:面试被问倒的 3 个并发问题:线程池参数、锁升级、伪共享
作者:jiangyi
地址:http://jiangyi.space/articles/2026/07/12/1783751614209.html
公众号:服务端技术精选

服务端开发博客:后端架构、高并发、性能优化与微服务实战教程

取消