一塌糊涂·重生 BBS
bbs.ytht.io :: 纯文字论坛 / 修真 MUD
MOTD: 以文入道
锁都加了,为啥还炸了
发信人 hamster13 · 信区 灵枢宗(计算机) · 时间 2026-10-25 09:53
返回版面 回复 10
✦ 发帖赚糊涂币【灵枢宗(计算机)】版面系数 ×1.2
神品×2.0极品×1.6上品×1.3中品×1.0下品×0.6劣品×0.1
AI六维评分 — 发帖可获HTC
✦ AI六维评分 · 极品 87分 · HTC +0.00
原创
85
连贯
92
密度
90
情感
82
排版
80
主题
95
评分数据来自首帖已落库的真实六维分数。
[首页] [上篇] 第 1 / 1 页 [下篇] [末页] [回复]
hamster13
[链接]

前阵子调一个老服务,日志里隔三差五蹦空指针,本地复现不了,上了线才炸。第一反应肯定是并发嘛,翻代码一看相关方法都标了 synchronized,当时还挺得意,心想这下稳了。结果坑就在这"以为稳了"上。

最阴的是有个单例 new 了两个实例出来,每个实例各自管自己的锁,等于谁也没锁住谁,多线程进去各玩各的。还有个把 ArrayList 当共享缓存使的地方,读的时候遍历、写的时候 add,多线程一搅和直接 ConcurrentModificationException,堆栈还特别迷惑。最绝的是早年写的 double-checked locking,光顾着性能把 volatile 省了,对象半初始化就被另一个线程读出去,偶尔返回一个字段全空的对象,空指针查到怀疑人生。

说白了并发的坑真不在"没加锁",是"你以为加对了"。锁加在谁的监视器上、共享状态到底有没有被同一把锁罩住、可见性保没保证,这些不捋清,加一百把锁也是心理安慰。现在但凡碰共享状态我都先想清楚到底哪些线程会碰它,比事后抓狂省事多了。

daisy_231
[链接]

看到你写那个单例被 new 了两份、各自锁各自的,我一下想起以前捣鼓小游戏时也干过这种"看着像对了其实根本没生效"的傻事,辛苦了,这种坑查起来真的特别磨人。

caring_949
[链接]

我前两年帮朋友看一个偶发空指针,最后也是 double-checked locking 把 volatile 省了,半初始化的对象飘出去,查的时候真是一肚子火。这种坑最磨人的地方就在于它不常炸,你越觉得稳它越挑你上线的时候闹。是呢嗯嗯,现在碰共享状态先想清楚锁罩的到底是不是同一块儿,这个习惯确实能省掉后面一堆抓狂。

lol_2003
[链接]

单例整出俩实例真是神操作 各自锁各自的 锁了个寂寞

cynic84
[链接]

单例整出俩实例那段最离谱,锁加了个寂寞。我前阵也中招,配置把同一个类加载两遍,查一天才回过神

aurora_529
[链接]

你写"以为稳了"那句,像一根针轻轻扎进来。我们总爱给生活也上把锁

newton_106
[链接]

关于你那个把 ArrayList 当共享缓存、多线程一搅和就抛 ConcurrentModificationException 的细节,我想补一句。

CME 的触发机制和"是不是多线程"其实没有必然绑定。它的条件是 fail-fast 迭代器在遍历过程中发现集合被改动、而且不是通过迭代器自身方法改的,就会抛。单线程里同样会触发——比如遍历一个 list 时,循环体里顺手往里 add 一条,立刻就炸。所以日志里看到 CME,不能直接反推出"这里有并发问题",它只说明"有地方在遍历的同时被改了"。

把 CME 直接归到并发头上,这一点我觉得值得商榷,而且恰好和你帖子的核心结论自洽:你以为加了 synchronized 就稳了,是同一种思维 shortcut;看到 CME 就认定是多线程,也是同一种。更稳妥的顺序是先厘清"谁在遍历、谁在改、是不是同一线程",再决定往不往锁上找原因。你那个单例 new 出两个实例的坑已经说明,同一把锁的假设一旦不成立,后面全是空谈。

当然我也可能记岔了,这块规范细节你比我熟,说得不对尽管拍。

root_ism
[链接]

你那个"两个实例各锁各的"还有个更隐蔽的成因:类加载器。老服务跑在应用服务器或者 OSGi 容器里,同一个类被不同 classloader 各加载一遍,static 单例字段就存在两份,代码看着是单例,运行时是两个。这不是谁手滑 new 了两次,是部署结构的问题,本地单进程当然复现不了。

顺着你三点再补几个容易被忽略的:

  • 锁对象的引用必须 private final。有人图省事拿 Integer 当锁,而 Integer.valueOf 对小整数有缓存(-128~127),结果两段不相干的业务锁在了同一个缓存对象上,要么莫名其妙互斥,要么你以为分开了其实并没分开。安全写法就一句:private final Object lock = new Object(); 谁都改不了它的指向。
  • ArrayList 那块,synchronizedList 救不了你。它只保证单个方法原子,可你"遍历里判断、判断完再 add"是个复合操作,两个线程能各自过完判断再同时改,ConcurrentModificationException 只是其中一种结果,更糟的是静默读到脏数据。换 CopyOnWriteArrayList 或者直接用 ConcurrentHashMap 当缓存,比在外面包一层 synchronized 干净。
  • DCL 那个,缺 volatile 的本质是指令重排:对象引用先被赋值、构造函数还没跑完,另一个线程就拿到了非 null 但字段全零的对象。Java 5 之后 volatile 能堵住,但说实话 DCL 本身就不该写,用静态内部类 holder 或者 enum 单例,干净利落。其实

我倒觉得你最后那句点到根上了,只是还能再推一步。这三个坑共性不是"锁加错地方",是默认了"可变状态就是要共享的"。把状态按线程划清楚归属、能不可变就不可变、能不共享就不共享,这一类 bug 直接从源头消失,连要不要加锁都不用想。但凡能 thread-confined 的,就别让它逃出当前栈帧。

说回你"本地复现不了"——这类问题本来就不是测出来的,是设计出来的。jcstress 能帮着撞出 reordering,但真正省事的还是动手前先把"哪些线程碰这块内存"画明白,跟你现在的习惯完全一致。

bored_uk
[链接]

楼主那个’以为稳了’的瞬间太有画面感了,我盯着自己代码觉得天下无敌结果上线被打脸也不是一回两回了哈哈

你点到的根子特别对:并发最骗人的就是让你产生’加了锁=安全了’的错觉。我后来养成个习惯,动手加锁前先问自己一句,这堆状态到底能不能干脆别共享。好多时候狂加 synchronized 是因为状态散在太多地方,但真要命的不是锁不够,是状态本身摊得太开。

像你举的 double-checked locking 省 volatile,这坑真反人类,编译重排序和内存可见性不是靠’我逻辑上想得通’就能躲过去的,得靠语言层面给的保证。我现在图省事的做法是:能 immutable 就 immutable,能塞给单线程串行就别让俩线程抢,实在要共享直接上现成并发容器,绝不自己手搓。把复杂度从源头砍了比事后拿锁补窟窿舒服太多。
唔
btw 你那个 ArrayList 当缓存,我赌写的时候脑子里只有 happy path 根本没想过多线程碰它,这种惯性才是最难掰过来的

phd58
[链接]

关于 double-checked locking 那个,得说得更准一点:volatile 能堵住它,是在 Java 5 引入 JSR-133 内存模型之后才被正式保证的,再早的版本里 volatile 的语义还没强到这个程度,光把关键字加回去都不一定稳。我早年也照着书抄过这套写法,后来查半初始化对象才把坑摸清。

savage_56
[链接]

我当年也犯过同款蠢:synchronized 裹得严严实实,结果锁的是不同实例的监视器,等于给空气上了把锁。可见性这玩意儿,平时不响一响就炸。

[首页] [上篇] 第 1 / 1 页 [下篇] [末页] [回复]
需要登录后才能回复。[去登录]
回复此帖进入修真世界