当前位置: 首页 > web >正文

JAVA并发根源问题的讨论与思考

JAVA并发根源问题的讨论与思考

我认为并发根源问题出现的有三个部分:可见性、原子性、有序性。

了解这三个问题,以及背后的原因和处理机制,可以有效的梳理整个并发过程

可见性(CPU缓存)

可见性是由CPU缓存引起的,在了解CPU缓存之前,应该先了解JMM内存模型。

JMM模型

内存模型如下:
在这里插入图片描述
JMM 是 Java 对多线程环境下内存访问行为的抽象模型,它定义了线程如何与**主内存(共享内存)本地内存(工作内存&线程私有)**交互。

解释一下

  • 主内存:所有线程共享的内存区域,存储变量原始值
  • 工作内存:每个线程私有的内存区域,存储主内存中变量的副本

有两个点注意一下:

  • 线程对变量的所有操作(读/写)必须在工作内存中进行,不能直接操作主内存
  • 线程间通信需通过主内存(隐式或显式同步)

那么刚刚说了JMM他是一个抽象模型,他抽象的是什么?本质上抽象的是硬件架构(这就是语言跨平台的魅力)

他是对不同CPU的架构(如 x86-TSO、ARM-弱内存模型)的抽象,屏蔽底层差异。(这里又感觉可以和架构的单一职责有关,注意点要分离)

CPU 缓存一致性协议 && MESI内存嗅探机制

好的,我们扩展的再讨论一下。那么你肯定很奇怪,这个JMM模型,是怎么实现的呢?这句话就是如何保证多核CPU缓存一致性,实现细节就是MESI嗅探机制。

就是这张图
在这里插入图片描述

状态含义
Modified缓存行被修改,与主内存不一致。
Exclusive缓存行独占,与主内存一致。
Shared缓存行被多个核心共享,与主内存一致。
Invalid缓存行无效(需重新加载)。

当某核心写缓存时,通过总线广播使其他核心的对应缓存行失效(Invalid),强制其重新加载最新值

嗯,差不多到这里就可以了,不需要特别深入,嗯,在深入下去我也不会了。(当然第一次认知到这个MESI的时候,我就觉得他很难,然后就差不读一直只了解到了这一层)

Store Buffers缓存队列

大概示意图就长这样
在这里插入图片描述
很明显这里面,搞了一个store buffer的东西,因为cpu底层他不希望来一个指令,我就刷一下内存。要知道批量刷盘,要好过一条条数据刷。所以他搞了一个buffer缓冲池,读取的时候优先读取buffer,然后定时批量刷到工作内存。

嗯,如果仅仅只是思考到这一步,我觉得是不可以的。

看到这个模型,我第一反应是什么?这个很像我的mysql的buffer缓冲池。

第二反应是什么?我记得redis的RDB在备份的时候,也有一个fork子线程,在进行冲刷备份中的数据。

所以,这个本质是什么?

缓冲与异步刷盘

核心目标

  • 减少高频操作的开销:避免直接操作慢速设备(如磁盘、网络、内存总线)。
  • 提升吞吐量:批量处理数据,减少碎片化操作。
  • 解耦生产与消费:允许生产者和消费者以不同速度工作(如写操作与持久化)

这就是一些典型的实现

系统/场景缓冲机制刷盘策略一致性保证
CPU Store Buffer暂存写操作,异步刷回缓存内存屏障触发刷写最终一致性(需显式同步)
MySQL Buffer Pool缓存数据页,减少磁盘I/OCheckpoint、LRU淘汰、事务提交刷盘ACID(依赖redo log、双写缓冲等)
Redis RDBFork子进程写快照,主进程继续COW(写时复制)+ 增量AOF缓冲最终一致性(RDB期间增量数据存AOF)
Kafka Producer批量聚合消息,减少网络请求时间/大小阈值触发发送At-Least-Once(需ACK机制)

好的,不要觉得这个玩意很虚,我们落地一下,这个过程体现了设计的思路:“通过缓冲和异步化提升性能,再通过同步机制(如屏障、日志、锁)解决一致性副作用”。

而这种东西,我们可以将其应用到具体的软件工程设计中呀。内存态与硬件持久化,接口短RT要求,TCC下的BASE理论,都体现了这样的设计哲学。

我觉得,思考应该挖掘,能够应用最好。

那么可见性问题总结一下就是,A 线程对变量的修改未及时同步到主内存(或者B线程未从主内存(或其他线程的缓存)读取最新值)

单核CPU可见性问题的思考

那么其实在理解完这个过程中,单核CPU到底是否会存在可见性问题,应该是显而易见的。

会有的,因为有StroeBuffer会未及时刷新到主内存,导致其他线程数据未获取到最新的(他们可能会从主内存或者缓存中获取,但是此时线程1还没来得及刷,只是暂存于storeBuffer)。

要明白,工作内存的维度是线程级别的,是线程私有的。

场景多核 CPU单核 CPU
可见性根源Store Buffer + 缓存一致性协议延迟Store Buffer + 寄存器缓存未同步
触发频率高(并行写操作)低(依赖线程切换时机)
解决手段必须显式同步(内存屏障、锁)理论无需同步,但代码优化可能导致问题

如何解决(Happens-Before)

可见性问题的原因,我们之前也讲了。那如何处理它,这里面其实可以想一下,不就是其他线程读取不到吗?

那只要我“有序的”读取不就可以了吗?当然这里的有序,并不是真正的有序,而是一种思考方式。

于是就出来了Happens-Before 原则:他的核心逻辑是:如果操作 A Happens-Before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。

这里面需要强调,HB 并不要求实际执行顺序与代码顺序一致(允许指令重排序),但必须保证可见性。

常见 Happens-Before 规则
  1. 程序顺序规则:单线程内代码顺序天然 HB。
  2. volatile 变量规则
    • volatile 变量的写操作 HB 于后续对它的读操作。
  3. 锁规则
    • 解锁操作 HB 于后续的加锁操作。
  4. 线程启动规则
    • 线程的 start() 调用 HB 于该线程内的任何操作。
  5. 传递性规则
    • 若 A HB B,B HB C,则 A HB C。
内存屏障

如果说Happens-before是基于JMM的抽象模型,那么内存屏障就是他的底层实现机制。

他是通过限制指令重排序和强制内存可见性,确保 HB 规则的语义。JMM 定义了以下四类屏障:

屏障类型作用典型场景
LoadLoad禁止屏障后的读操作重排序到屏障前的读操作之前。volatile 读后插入,防止后续读操作重排序。
StoreStore禁止屏障后的写操作重排序到屏障前的写操作之前。volatile 写前插入,防止之前的写操作重排序。
LoadStore禁止屏障后的写操作重排序到屏障前的读操作之前。较少直接使用,通常由其他屏障隐含。
StoreLoad强制刷新所有 Store Buffer 到内存,并加载最新值(全能屏障,开销最大)。volatile 写后插入,确保写操作对其他线程可见。

原子性(分时复用)

分时复用的解释

这个名词看起来很牛啊,其实本质上就是cpu资源是有限的,但是任务又很多,所以cpu就搞了一个标识位,一直切过来切过去进行干活。

像极了我,正在处理A任务的时候,领导插活,这个很急,这个明天就要,于是我就开始干这个急的活。等到了明天,开始loading,我代码写到哪里了?

对这就是分时复用

比较官方的解释

首先原子性问题是这样的:一个或多个操作要么全部执行且不被中断,要么完全不执行。

问题根源

  • 线程切换:CPU 分时复用导致操作中途被其他线程打断(如 i++ 的非原子性)。
  • 非原子指令:看似单行的代码可能对应多条底层指令(如读取-修改-写入三步)。

解决方案

解决这个问题的思路极其简单,我把他变成原子性的不就可以了吗?就像redis搞了一个Lua脚本,简单,粗暴,但丑陋(主要复杂的业务可读性太差)

但实际上,这个简单解决思路,实现起来却又不是那么的简单。究其本质是我需要考虑,哪些东西属于非原子性。

他的核心思想就是通过互斥锁将非原子操作变为原子操作。

// synchronized 示例
public synchronized void increment() {i++;
}// Lock 示例
private final Lock lock = new ReentrantLock();
public void increment() {lock.lock();try {i++;} finally {lock.unlock();}
}
锁机制深入:synchronized vs Lock

(1) synchronized 的锁升级

  • 无锁 → 偏向锁
    首个线程访问时标记偏向,避免 CAS 开销。
  • 偏向锁 → 轻量级锁
    发生竞争时,通过 CAS 自旋尝试获取锁。
  • 轻量级锁 → 重量级锁
    自旋失败后升级为操作系统级互斥锁(Mutex)。
Lock 的灵活性与 AQS
  • 优势

    • 可中断锁(lockInterruptibly())。
    • 超时获取锁(tryLock(timeout))。
    • 公平锁(new ReentrantLock(true))。
  • AQS(AbstractQueuedSynchronizer)

    • 核心组件:CLH 队列(双向链表)管理等待线程。
    • 实现原理:通过 state 变量表示锁状态,CAS 操作控制竞争。

    注意一下 AQS有两个队列我们常常说的是CLH虚拟双端队列,还有个Condition Queue,他就是一个单链表了,就是CLH里面塞不下了,就往里面扔。所以本质上这种数据结构就能支持公平锁与非公平锁,不过我记得好像默认实现方式是非公平锁。

其实讲道理,AQS和synchronized可以写很多很多,包括我也有很多图,但是我觉得没有必要,只要大致了解就可以了。提纲挈领的知道各个关键节点的思路是什么就可以了,这就像写程序一样,重要的是骨架,是思路,是如何把线下的场景在线上实现。细节的东西没必要聊太多,而且网上到处都是。

可重入锁(ReentrantLock)

  • 核心特性:同一线程可重复获取同一把锁。
  • 作用:防止递归或嵌套调用导致的死锁。
  • 实现:通过计数记录重入次数(state++)。
原子类(Atomic Classes)

利用 CAS硬件指令与自旋操作,实现无锁原子操作。

很好,你一定有这样的疑问,我既然已经有了锁,为啥要搞Atomic,讲道理我直接复用不就可以了吗?

嗯,是的,但本质上,它涉及到一个杀鸡焉用牛刀的问题,同时你不应该整体的看待锁,就以synchronized为例,他还有锁升级。

这里额外插入一句,我觉得无锁并发编程才是我们需要努力的目的,锁只是没有办法的情况下,保证资源的有序执行。

  • 典型类

    • AtomicIntegerAtomicReference
    • LongAdder(分段 CAS 优化高并发场景)。
  • ABA 问题

    • 现象:变量从 A 改为 B 再改回 A,CAS 误认为未变化。
    • 解决方案AtomicStampedReference(附加版本号)。
  • 自旋开销

    • 问题:CAS 失败时循环重试可能导致 CPU 占用飙升。
    • 优化:限制自旋次数或退化为锁(如 synchronized)。
并发容器的原子性设计

首先是ConcurrentHashMap

版本实现机制优缺点
JDK 1.7分段锁(Segment)降低锁粒度,但段数固定,高并发下仍可能竞争激烈。
JDK 1.8CAS + synchronized 锁节点更细粒度锁(链表头节点),链表转红黑树优化查询效率(O(n)
使用场景对比
  • Hashtable:全表锁,并发性能差。
  • Collections.synchronizedMap:包装类锁整个实例。
  • ConcurrentHashMap:高并发场景首选。

总结:原子性问题的应对策略

场景解决方案适用层级
简单原子操作(如计数器)Atomic 类无锁,高性能
复杂业务逻辑synchronized/Lock代码块级互斥
高并发容器操作ConcurrentHashMap细粒度锁优化
灵活锁需求(超时、公平)ReentrantLock显式锁控制
一些额外的思考
  • 关于分布式场景下的锁:

单独的锁,现在其实没有太大意义,就好像你单独说事务也没有太大意义。

分布式场景下,redis锁是首选,zk锁也能实现(但我没用过)。redis锁的时候,建议使用Redission,毕竟红锁可以自动看门狗续期。

忽然想起来,你说,mq的有序队列能不能作为锁的一个补充?

想来是可以的,比如对于任务型的,并发要求没有那么高的,但是对执行顺序要求强有序的,我觉得可以使用mq。

特性Redis 分布式锁ZooKeeper 临时节点MQ 有序队列
实时性高(基于内存)高(Watcher 机制)中(依赖消费速度)
公平性依赖 RedLock 或排队逻辑天然有序(临时顺序节点)天然有序(FIFO 队列)
可靠性依赖持久化配置高(CP 设计)依赖 MQ 的持久化和 HA
复杂度中(需处理锁续期)高(需维护 Session)高(消息生命周期管理)
适用场景高频短期锁低频长期锁异步任务调度、批量锁分配
  • 无锁编程

通过 ThreadLocal、不可变对象(Immutable,final)避免竞争。

  • 性能权衡

CAS 在低竞争下高效,高竞争时可能劣于锁。

有序性(指令重排序)

嗯,之前讨论到关于并发根源问题的时候,讲到运行期指令还会重排序。嗯,比如JIT编译期间。

于是执行顺序的混乱,最终导致了数据的异常。

那他的解决思路和可见性一模一样,让指令“有序的”执行不就可以了吗
于是问题的处理方案 回到了可见性上,也就是HB与内存屏障

那么差异呢?

(1) 可见性问题

  • 本质:线程对变量的修改未及时对其他线程可见(Store Buffer、缓存未同步)。
  • 解决思想:通过内存屏障强制刷新缓存,确保修改后的值对其他线程可见。

(2) 有序性问题(指令重排序)

  • 本质:编译器或处理器优化导致代码执行顺序与程序顺序不一致(如 a=1; b=1; 可能先执行 b=1)。
  • 解决思想:通过内存屏障限制重排序,保证关键操作的执行顺序。

Happens-Before再讨论

Happens-Before(HB)不仅是可见性规则,也是有序性规则,它通过以下方式统一解决两类问题:

  1. 定义操作顺序:若操作 A HB 操作 B,则 JVM 必须保证:
    • A 的结果对 B 可见(解决可见性)。
    • A 的执行顺序在 B 之前(解决有序性)。
  2. 指导编译器和处理器:HB 规则约束编译器和 CPU 的优化行为,禁止破坏 HB 关系的重排序。
volatile int x = 0;
int y = 0;void write() {y = 1;      // 普通写操作x = 1;      // volatile 写操作(插入 StoreStore + StoreLoad 屏障)
}void read() {if (x == 1) { // volatile 读操作(插入 LoadLoad + LoadStore 屏障)assert y == 1; // 由于 HB 规则,y 的值一定为 1}
}

内存屏障再讨论

内存屏障是 HB 规则的具体实现手段,针对不同重排序类型插入屏障:

重排序类型示例屏障类型作用
写-写重排序a=1; b=1;b=1; a=1;StoreStore禁止屏障前的写重排序到屏障后的写之后。
读-读/读-写重排序load a; load b;load b; load a;LoadLoad + LoadStore禁止屏障后的读/写重排序到屏障前的读之前。
写-读重排序a=1; load b;load b; a=1;StoreLoad强制刷写 Store Buff

JIT指令重排序

JIT(Just-In-Time)编译器在优化时可能重排序代码,但必须遵守 HB 规则:

  • 单线程规则:单线程内不改变程序语义的重排序是允许的(As-If-Serial 语义)。
  • 多线程规则:跨线程的重排序若破坏 HB 关系,则必须通过内存屏障禁止。
经典案例:双重检查锁定(DCL)
public class Singleton {private static Singleton instance; // 错误:未用 volatilepublic static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton(); // 可能重排序!}}}return instance;}
}
对象初始化的底层步骤

instance = new Singleton() 在 JVM 中实际分为以下三步:

memory = allocate();  // 1. 分配对象的内存空间
ctorInstance(memory); // 2. 调用构造方法初始化对象
instance = memory;    // 3. 将引用指向内存地址

关键问题:步骤 2(初始化)和步骤 3(赋值引用)可能被 重排序

问题的产生

假设线程 A 执行 instance = new Singleton() 时发生重排序:

  1. 先执行步骤 3(instance 指向未初始化的内存)。
  2. 此时线程 B 调用 getInstance(),发现 instance != null,直接返回该引用。
  3. 线程 B 使用未初始化的 Singleton 对象(可能触发空指针异常或逻辑错误)。
解决方案

通过 volatile 修饰 instance 变量,禁止步骤 2 和步骤 3 的重排序:

  • volatile 写操作前插入 StoreStore 屏障,禁止之前的写重排序。
  • volatile 写操作后插入 StoreLoad 屏障,强制刷新缓存。
一种更好的方案

静态内部类:利用类加载机制保证线程安全。

public class Singleton {private static class Holder {static final Singleton INSTANCE = new Singleton();}public static Singleton getInstance() {return Holder.INSTANCE;}
}

枚举单例:天然线程安全且防反射攻击。

public enum Singleton {INSTANCE;
}
关于As-If-Serial

As-If-Serial(看上去像是顺序执行的) 是 Java 内存模型(JMM)对单线程程序行为的保证:

  • 无论编译器和处理器如何重排序指令(优化执行顺序),单线程程序的执行结果必须与代码顺序执行的结果一致。

  • 这种语义的目的是让开发者无需关心单线程内的指令重排序,只需关注代码逻辑的正确性。

As-If-Serial 允许重排序,但必须满足以下条件:

  • 数据依赖性:若两个操作存在数据依赖(如写后读、写后写、读后写),则它们不能被重排序。
  • 结果一致性:重排序后的执行结果必须与代码顺序执行的结果完全一致。
As-If-Serial 与多线程的差异
场景单线程(As-If-Serial)多线程
重排序自由度允许无数据依赖的任意重排序可能破坏跨线程操作的可见性,需内存屏障限制
开发者关注点无需关注执行顺序需通过 volatile、锁等机制显式控制顺序
优化目标提升单线程性能平衡性能与线程安全性

嗯,差不多了,我想下班了,就这些吧。
然后整个过程中,我没有很细致的聊细节,因为网上到处都是,我也是在刷面试题的时候,灵机一动,要不完整的思路写一下,于是就有了这一篇文章。
我始终是更加相信无锁并发编程的,其实还可以扯一些有的没的,比如ForkJoinPool与ThreadPoolExecutor的差异,这里面又体现了分治算法与workingStealing的工作窃取。
比如协程,进程间的通讯,JUC还是很庞大的一个东西,好了不扯了,下班下班。

http://www.xdnf.cn/news/1451.html

相关文章:

  • 2024沈阳区域赛,D - Dot Product Game
  • Visual Studio2022 配置 SDL3及拓展库
  • 从一个简单的HelloWorld来完整介绍Java的类加载过程
  • Python——流程控制
  • 代码分享:python实现svg图片转换为png和gif
  • linux软硬连接
  • 3.1 Agent定义与分类:自主Agent、协作Agent与混合Agent的特点
  • Vue3祖先后代组件数据双向同步实现方法
  • 基于STM32、HAL库的MAX5402EUA数字电位器驱动程序设计
  • Qt creator 16.0.1 语言家失效解决方法
  • 洛谷5318C语言题解
  • AIGC(生成式AI)试用 31 -- AI做软件程序测试 2
  • JEnv-for-Windows​管理JDK版本
  • web刷题笔记
  • 基于deepseek的模型微调
  • HCIA-Access V2.5_18_网络管理基础_3_ 华为接入网络网络管理系统概览
  • 2025年04月23日Github流行趋势
  • Byte-Buddy系列 - 第3讲 byte-buddy与jacoco agent冲突问题
  • Qt Creator中自定义应用程序的可执行文件图标
  • node.js 实战——(path模块 知识点学习)
  • 计算机视觉基础
  • 编程实现ESP8266分别作为服务端 客户端
  • 集结号海螺捕鱼服务器调度与房间分配机制详解:六
  • nginx部署前端项目时,正常访问前端页面成功后,浏览器刷新报404解决访问
  • ​​OSPF核心机制精要:选路、防环与设计原理​
  • 一篇文章学会开发第一个ASP.NET网页
  • 金融租赁质检的三重业务困境 质检LIMS系统的四大价值赋能场景
  • “时间”,在数据处理中的真身——弼马温一般『无所不能』(DeepSeek)
  • MCU开发学习记录11 - ADC学习与实践(HAL库) - 单通道ADC采集、多通道ADC采集、定时器触发连续ADC采集 - STM32CubeMX
  • Python jsonpath库终极指南:json数据挖掘的精准导航仪