并发编程核心知识点笔记

一、基础概念:并发 vs 并行

这是并发编程最容易混淆的两个概念,也是理解一切的起点:

  • 并行:同一时刻,多个线程/进程在不同CPU核心上同时执行,彼此之间没有资源竞争,互不干扰。例如:8核CPU同时运行8个独立的计算任务。
  • 并发:同一时间段内,多个线程/进程在单个或多个CPU核心上交替执行,本质是CPU通过时间片轮转实现"同时运行"的假象,必然存在资源竞争问题。例如:单核CPU同时处理浏览器、音乐播放器和编辑器三个程序。

核心区别:并行是"真同时",依赖多核硬件;并发是"假同时",依赖操作系统的调度算法。

二、计算机硬件底层原理

所有并发问题的根源,都来自于计算机硬件的物理限制:

  1. 电信号的串行本质:CPU内部的指令是高低电压信号(高=1,低=0),电压信号无法同时传输,必须排队执行。计算机所有硬件(CPU、内存、硬盘、网卡)之间的指令传输都遵循这一规则,任何一个硬件同一时间只能执行一个任务
  2. 总线竞争:系统总线是连接各个硬件的"高速公路",同一时间只能有一个设备占用总线传输数据,这是硬件层面的资源竞争。
  3. 高速缓存的双刃剑
    • 作用:解决CPU运算速度与内存读写速度的巨大差距,将常用数据缓存到CPU高速缓存(L1/L2/L3),大幅提高CPU利用率。
    • 问题:多个CPU核心的高速缓存之间会出现数据不一致(相互覆盖),这是可见性问题的硬件根源。
    • 缓存行:高速缓存的最小存储单位是64字节,这意味着即使只修改1个字节,也会加载整个64字节的缓存行,可能导致"伪共享"问题。

三、线程状态与上下文切换

3.1 线程核心状态

所有编程语言的线程模型都基于操作系统的原生线程,核心状态统一:

  • 新建态:线程对象已创建,但未调用start()方法。
  • 就绪态:调用start()后,线程进入就绪队列,等待CPU调度。注意:进入就绪态的顺序不等于被CPU选中的顺序,操作系统会根据调度算法选择。
  • 运行态:CPU分配时间片给该线程,开始执行代码。
  • 阻塞态:线程因等待资源(锁、IO、sleep)而暂停执行,被移出就绪队列。
  • 死亡态:线程执行完毕或抛出未捕获异常,生命周期结束。

关键:方法拷贝入栈是操作系统层面的行为,与编程语言无关。

3.2 上下文切换

这是多线程编程最主要的性能开销来源:

  • 定义:CPU从一个线程切换到另一个线程时,需要保存当前线程的执行上下文(程序计数器、寄存器状态、栈信息),然后加载下一个线程的上下文。
  • 时间损耗:单次上下文切换耗时约几毫秒到几十毫秒,操作系统给每个线程分配的时间片通常也是这个量级。
  • 测量工具
    • Lmbench3:精确测量上下文切换的时长
    • vmstat:实时统计系统上下文切换的次数(输出中cs列)
  • 减小上下文切换的方法
    1. 无锁并发编程:通过数据分区避免资源竞争
    2. CAS算法:用乐观锁替代悲观锁,减少阻塞
    3. 使用最少线程:避免创建过多线程导致频繁切换
    4. 使用协程:用户态的轻量级线程,切换无需操作系统参与

四、多线程适用场景

多线程不是银弹,只有在特定场景下才能提升性能:

  • IO密集型任务非常适合多线程。例如:文件读写、网络请求、数据库操作。因为IO操作时CPU处于空闲状态,多线程可以让CPU在等待IO时处理其他任务,大幅提高CPU利用率。
  • CPU密集型任务不适合多线程。尤其是单核CPU,串行执行是最快的,多线程只会带来上下文切换的额外开销。即使是多核CPU,线程数也不应超过CPU核心数。

五、线程同步与锁机制

5.1 join()方法

作用:让调用线程等待目标线程执行完毕后再继续执行。

t1.start();
t2.start();
t1.join(); // 主线程阻塞,等待t1执行完
t2.join(); // 主线程继续阻塞,等待t2执行完

注意:上述代码中t1和t2是并行执行的,因为t2.start()t1.join()之前调用。join()只会阻塞调用它的主线程,不会影响已经启动的其他线程。

5.2 synchronized关键字

Java中最基础的悲观锁实现:

  • 锁的对象:只能对引用类型加锁,基本类型无法加锁(因为基本类型存储在栈中,没有对象头)。
  • 锁的范围:锁会保护整个同步代码块(大括号内的所有内容),只有当代码块执行完毕后才会释放锁。即使线程调用sleep()进入睡眠状态,也不会释放锁。
  • 特性:保证原子性、可见性和有序性,是"重量级"的同步机制。

5.3 sleep() vs wait()

这是面试高频考点,核心区别在于是否释放锁:

特性 sleep() wait()
所属类 Thread类的静态方法 Object类的方法
锁行为 让出CPU,但不释放锁 让出CPU,并且释放锁
唤醒方式 睡眠时间结束自动唤醒 其他线程调用notify()/notifyAll()
使用位置 任意位置 必须在同步代码块中

资源竞争失败的线程会进入阻塞队列,等待锁释放后被唤醒,重新进入就绪队列竞争CPU。

六、死锁与避免方法

死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行的状态。

避免死锁的四个原则:

  1. 避免一个线程同时获取多个锁:尽量让每个线程只持有一个锁。
  2. 避免一个线程在锁内同时占用多个资源:保证每个锁只保护一个资源。
  3. 使用定时锁:用Lock.tryLock(long timeout)替代synchronized,如果超时未获取到锁就放弃,避免无限等待。
  4. 数据库锁特殊注意:加锁和解锁必须在同一个数据库连接中进行,否则会出现解锁失败的情况。

七、内存模型与可见性

7.1 可见性问题

定义:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。

可见性问题的根源:

  • 每个线程有自己的工作内存(对应CPU高速缓存)
  • 线程修改共享变量时,先修改工作内存中的副本,再刷新到主内存
  • 其他线程读取时,从主内存加载到自己的工作内存

如果没有同步机制,一个线程的修改可能永远不会被其他线程看到。

7.2 volatile关键字

轻量级的同步机制,被称为"轻量级的synchronized":

  • 保证可见性:被volatile修饰的变量,修改后会立即刷新到主内存,读取时直接从主内存读取。
  • 禁止指令重排序:通过内存屏障实现,保证指令执行顺序与代码顺序一致。
  • 不保证原子性:多个线程同时修改volatile变量时,仍然会出现线程安全问题。

实现原理:基于总线嗅探机制,当一个线程修改了volatile变量,其他线程会通过总线观察到该变量的内存地址被修改,从而使自己工作内存中的副本失效,下次读取时重新从主内存加载。

7.3 内存屏障

内存屏障是CPU层面的指令,作用是:

  • 阻止屏障两边的指令重排序
  • 强制将工作内存中的数据刷新到主内存
  • 强制清空其他线程工作内存中的对应缓存行

八、原子操作与缓存机制

8.1 原子操作

定义:不可中断的一个或一系列操作,要么全部执行成功,要么全部执行失败,失败后会回滚到操作前的状态。

Java中的原子操作实现:

  • 基本类型原子类:AtomicIntegerAtomicLong
  • 引用类型原子类:AtomicReference
  • 底层基于CAS(Compare-And-Swap)算法实现,是一种乐观锁机制。

8.2 缓存相关概念

  • 缓存命中:处理器要处理的数据已经在高速缓存中,无需从内存读取,速度极快。
  • 写命中:处理器要写回的数据在高速缓存中存在,直接修改缓存中的数据。
  • 写缺失:处理器要写回的数据不在高速缓存中(可能被其他线程清理或覆盖),需要先从内存加载到缓存,再进行修改。

九、常用Linux命令

并发编程调试中常用的系统命令:

  • grep:文本筛选,用于过滤日志或命令输出
  • awk:文本统计与处理,常用于分析性能数据
  • sort:文本排序,配合其他命令使用
  • jstack:生成Java线程快照,查看线程状态和死锁
  • jmap:生成Java堆内存快照,分析内存泄漏

总结

并发编程的核心挑战是解决原子性、可见性和有序性问题。理解计算机硬件底层原理(CPU、缓存、总线)是掌握并发编程的关键。在实际开发中,不要盲目使用多线程,要根据任务类型(IO密集型/CPU密集型)选择合适的并发策略,同时注意避免死锁和减少上下文切换带来的性能损耗。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐