JAVA多线程下并发竞态问题导致写表数据错乱的排查与修改
2026/9/15 9:53:24 网站建设 项目流程

**JAVA多线程下并发竞态问题导致写表数据错乱的排查与修改**

  • FileTypeStrategyContext 无状态化改造 —— 并发竞态修复实战笔记
    • 一、问题背景与现象
    • 二、根因定位
    • 三、修改前(问题代码)
    • 四、修改后(无状态化改造,零侵入)
    • 五、修改前后对比
    • 六、技术原理深入
      • 6.1 线程栈(私有) vs 堆(共享)
      • 6.2 Java 内存模型(JMM)与数据竞争
      • 6.3 `final` 的真正作用(安全发布 + 不可变性)
    • 七、比喻:黑板 vs 便签
    • 八、改动后的优势
    • 九、测试验证
    • 十、附:相邻的并发隐患修复(GatewayRecordService.realTimeDatas)
    • 十一、经验总结

FileTypeStrategyContext 无状态化改造 —— 并发竞态修复实战笔记

本文记录一次真实生产环境 Bug 的定位与修复全过程:SSCJ(出井信息)文件概率性被写入realtime_value实时表,根因是策略分发类的共享可变状态导致的并发竞态。本文同时从 Java 内存模型层面剖析了无状态化改造的原理,并附上生动比喻便于理解。


一、问题背景与现象

在煤矿人员定位数据采集平台中,系统按文件类型(SSCJ 出井 / SSWZ 位置 / SSFZ 分站 / RYXX 人员…)将数据路由到不同的处理器,写入不同的 MongoDB 表:

文件类型处理器正常写入表
SSCJ(出井信息)GatewayRecordServicegatewayRecord
SSWZ(实时位置)RealTimeServicerealtime_value
SSFZ(分站信息)RealTimeSubstationServicerealtime_value_substation

现象realtime_value表中偶发出现fileType = "SSCJ"的记录——SSCJ 数据被写进了实时位置表。问题是概率性的,高并发时出现的概率更高,难以稳定复现。


二、根因定位

调用链(BaseReceiverAbstractService.dealContent):

// 分发入口:两步非原子操作context.getStrategy(bean.getFileType()).save(list,bean.getCoalMineId(),bean.getFileType(),bean.getFileTime());

FileTypeStrategyContext是 Spring 单例(@Component),内部用共享可变字段保存"当前文件类型对应的处理器"。

竞态过程

线程A: getStrategy("SSCJ") → strategy = GatewayRecordService(写 gatewayRecord) 线程B: getStrategy("SSWZ") → strategy = RealTimeService ← 覆盖了 A 写入的值 线程A: save(...) → 实际调用 RealTimeService.excuteSave(SSCJ数据) → SSCJ 数据被写入 gcpps.realtime_value 表 ✅ 复现故障

getStrategy(写共享字段)与save(读共享字段)两步之间没有同步,多线程并发时互相覆盖,导致数据被错误处理器执行。


三、修改前(问题代码)

@Component@Slf4jpublicclassFileTypeStrategyContext{// ① 共享可变字段:保存"当前文件类型对应的处理器"privateIFileTypeStrategystrategy=null;privateIMapFileTypeStrategymapStrategy=null;privateIPicFileTypeStrategypicStrategy=null;// ② getStrategy:写共享字段,返回 thispublicFileTypeStrategyContextgetStrategy(StringfileType){if(Constants.FileName.txtList.contains(fileType)){this.strategy=(IFileTypeStrategy)...getBean(fileType);// 写 strategy}elseif(Constants.FileName.shpList.contains(fileType)){this.mapStrategy=(IMapFileTypeStrategy)...getBean(fileType);// 写 mapStrategy}else{fileType=fileType.substring(0,3);this.picStrategy=(IPicFileTypeStrategy)...getBean(fileType);// 写 picStrategy}returnthis;// 返回 this,供链式 .save()}// ③ save:读共享字段执行(含懒加载兜底)publicbooleansave(List<Document>list,StringmineName,StringfileType,StringfileTime){if(null==strategy){this.getStrategy(fileType);// 懒加载:依赖"上一次调用"的残留状态}returnstrategy.excuteSave(list,mineName,fileType,fileTime);}// saveImag / saveShp 同理}

问题点

  • 三个共享可变字段是堆上的单例字段,所有线程读写同一块内存
  • getStrategysave是两步非原子操作,中间窗口期可被其他线程覆盖
  • 懒加载if (null == strategy)在多线程首次并发时会加剧混乱

四、修改后(无状态化改造,零侵入)

@Component@Slf4jpublicclassFileTypeStrategyContext{// ① 无任何共享字段// ② getStrategy:纯函数,每次返回携带本次策略的调用器,无副作用publicStrategyInvokergetStrategy(StringfileType){if(Constants.FileName.txtList.contains(fileType)){IFileTypeStrategystrategy=(IFileTypeStrategy)...getBean(fileType);returnStrategyInvoker.forTxt(strategy);// 局部变量 → 封装进新对象返回}elseif(Constants.FileName.shpList.contains(fileType)){returnStrategyInvoker.forMap((IMapFileTypeStrategy)...getBean(fileType));}else{StringpicType=fileType.substring(0,3);returnStrategyInvoker.forPic((IPicFileTypeStrategy)...getBean(picType));}}// ③ 调用器:不可变,持有本次解析出的策略,方法局部、线程隔离publicstaticclassStrategyInvoker{privatefinalIFileTypeStrategytxtStrategy;// final,构造后不可变privatefinalIMapFileTypeStrategymapStrategy;privatefinalIPicFileTypeStrategypicStrategy;publicbooleansave(List<Document>list,StringmineName,StringfileType,StringfileTime){returntxtStrategy.excuteSave(list,mineName,fileType,fileTime);// 直接用本次策略}// saveImag / saveShp 同理}}

生产调用方零改动context.getStrategy(x).save(...)调用链形态完全不变,仅需重新编译即可上线)。


五、修改前后对比

维度修改前修改后
状态存储3 个共享可变字段(strategy/mapStrategy/picStrategy无字段,策略在StrategyInvoker局部对象中
getStrategy返回值this(写入共享字段后返回自身)新的StrategyInvoker(携带本次策略)
调用形态context.getStrategy(x).save(...)context.getStrategy(x).save(...)完全不变
线程安全❌ 竞态:多线程互相覆盖共享字段✅ 天然安全:局部对象栈隔离,无数据竞争
懒加载if (null == strategy)有,依赖"上一次调用的残留状态"无,逻辑完全确定
字段可变性可变,谁都能改final,构造后不可变
并发开销无锁但数据竞争(读到值不确定)无锁、无竞争、无死锁风险

六、技术原理深入

6.1 线程栈(私有) vs 堆(共享)

Java 内存中,数据存放位置决定了"谁看得到":

存放位置归属典型内容
堆(Heap)所有线程共享实例字段、静态字段、对象本体
线程栈(Stack)每个线程私有方法的局部变量、参数、临时返回值

修改前—— 共享的可变字段:

堆上单例 FileTypeStrategyContext 对象 ┌───────────────────────────────────┐ │ strategy 字段 ──→ 一块共享内存地址 │ └───────────────────────────────────┘ ▲ 写 ▲ 写 ▲ 读 线程A(GatewayRecord) 线程B(RealTime) 线程A(save) → B 把 A 刚写的值覆盖 → A 读到了 B 的值 → 错配

this.strategy实例字段,在堆上,所有线程读写的是同一块内存地址。线程 A 写完后,线程 B 的写入覆盖了同一地址;A 回头读时,拿到的是 B 的值。这就是竞态的本质:多线程写同一内存位置,且无同步

修改后—— 每次独立的局部对象:

线程A的栈帧: context.getStrategy("SSCJ") └→ new StrategyInvoker(引用) ← 引用只存在于A的栈 线程B的栈帧: context.getStrategy("SSWZ") └→ new StrategyInvoker(引用) ← 引用只存在于B的栈 (两个对象在堆上,但互不相干,各自持有自己的策略)

getStrategy每次执行都会new StrategyInvoker(...),返回值作为临时局部对象,引用存放在当前线程自己的栈帧里,随.save()调用链直接消费。线程 A 的 invoker 引用只存在 A 的栈/寄存器中,线程 B 物理上无法访问它

→ 没有共享的内存位置 →数据竞争在结构上不存在→ 不需要加锁、不需要volatile

6.2 Java 内存模型(JMM)与数据竞争

修改前是典型的数据竞争(Data Race):两个线程对同一内存位置(this.strategy)并发访问,至少一个是写,且无 happens-before 关系。JMM 下读到的值不确定:

  • 可能是线程 A 写的值
  • 可能是线程 B 写的值(覆盖)
  • 甚至可能因 CPU 缓存 / 指令重排序,读到过期或部分值

修改后:没有共享的可写内存位置,每个线程只读自己栈上的局部变量 →数据竞争在结构上不存在,无需synchronized/volatile

6.3final的真正作用(安全发布 + 不可变性)

需要澄清:真正防覆盖的不是final,而是"无共享 + 局部实例"final是辅助,负责两点:

  1. 不可变性:策略引用构造后不可变,杜绝"对象发布后再被修改"的可能。
  2. JMM 的 final 语义(安全发布)final字段在构造函数完成后即"冻结",任何线程只要拿到该对象的引用,必然能看到 final 字段的正确值,无需同步。这消除了普通字段在无同步时可能被其他线程看到"半构造/过期值"的隐患。

一句话:final保证"这个对象一旦给我,内容就是准的";而"不会串到别人那去"靠的是无共享的局部实例。


七、比喻:黑板 vs 便签

  • 修改前(共享字段):全班共用一个黑板。同学 A 写上"处理 SSCJ",同学 B 擦掉改成"处理 SSWZ",A 回来一看黑板——"处理 SSWZ"→ 用错了处理器。
  • 修改后(局部对象):每人发一张自己的便签(StrategyInvoker)。A 的便签写 SSCJ,B 的便签写 SSWZ,各拿各的,永远不冲突。final相当于"便签写完就塑封",保证内容不会中途被改。

八、改动后的优势

  1. 根治竞态(核心收益):从"共享可变状态 + 两步非原子操作"变为"每次调用独立解析、随调用链传递"。没有共享可变字段,就不存在数据竞争和可见性问题,SSCJ 数据绝不可能再被 SSWZ 处理器写入gcpps.realtime_value
  2. 零侵入、行为兼容:生产调用方三处调用链一字不改,仅需重新编译即可上线,风险最小。
  3. 顺带消除懒加载隐患:原if (null == strategy)在多线程首次并发时会让多个线程同时进入getStrategy,加剧覆盖。改造后该分支消失,行为完全确定。
  4. 无需锁、无性能开销、无死锁风险:相比加锁方案,不引入任何同步原语,高并发下吞吐不受影响。
  5. 更符合函数式/不可变设计原则getStrategy成为纯函数(相同输入必然相同输出、无副作用、可重入);StrategyInvoker不可变,代码更易推理和测试。

九、测试验证

通过并发回归测试验证竞态已解决:

// 双线程各 2000 次交替处理 SSCJ / SSWZ,检测"处理器与文件类型错配"// 修复后必然通过、不 flaky@TestpublicvoidshouldNeverRouteSSCJToRealTimeTable_underConcurrency()throwsException{AtomicBooleanmismatch=newAtomicBoolean(false);// ... 在 mock 的 excuteSave 中记录 fileType,与处理器类型比对 ...ThreadsscjThread=newThread(()->{/* 循环 2000 次 getStrategy("SSCJ").save(...) */});ThreadsswzThread=newThread(()->{/* 循环 2000 次 getStrategy("SSWZ").save(...) */});// 同时起跑、join ...assertFalse("并发下出现文件类型与处理器错配!",mismatch.get());}

运行结果Tests run: 2, Failures: 0, Errors: 0BUILD SUCCESS


十、附:相邻的并发隐患修复(GatewayRecordService.realTimeDatas)

排查过程中还发现一处相邻并发隐患,一并修复:

问题realTimeDatas使用非线程安全HashMap,SSCJ 处理线程并发put,定时任务线程(每 20 分钟)迭代remove,高并发下可能丢数据、偶发ConcurrentModificationException

修复

// ① HashMap → ConcurrentHashMap,保证 put/迭代安全privateMap<String,Document>realTimeDatas=newConcurrentHashMap<>();// ② 迭代删除改为"条件删除",避免迭代期间新写入的同 key 记录被误删while(iter.hasNext()){Map.Entry<String,Document>entry=iter.next();if(this.realTimeDatas.remove(entry.getKey(),entry.getValue())){// 值未被覆盖才删除datas.add(entry.getValue());}}

ConcurrentHashMap.remove(key, value)是原子条件删除(CAS),仅当值还是原对象时才删除;若期间被并发覆盖,则新值保留待下次处理,数据不丢失


十一、经验总结

  1. 单例对象要警惕"共享可变状态":Spring 单例 Bean 若含可变字段且被多线程访问,就是竞态温床。
  2. 两步非原子调用是竞态窗口a.doX().doY()中间任何时刻都可能被其他线程打断,除非doX().doY()整体无共享状态。
  3. 无状态化 > 加锁:加锁只保证互斥,仍有性能与死锁成本;无状态化让并发问题在结构上不存在,是最彻底的解法。
  4. final+ 局部对象 = 双保险:无共享保证"不会串",不可变保证"串了也改不动"(这里根本没有共享)。
  5. 概率性 Bug 用并发回归测试:把真实调度交错展开为确定性时序或并发压力循环,可稳定复现/验证。

本文基于真实项目修复记录整理,供学习交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询