ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

聊聊Java开发里那些容易被忽视的并发问题

聊聊Java开发里那些容易被忽视的并发问题 一个看似人畜无害的HashMap在并发环境下膨胀时可能把两个线程的指针同时指向同一个链表头然后各自写回导致其中一个线程的插入悄然丢失。更隐蔽的是当它触发resize时旧数组到新数组的迁移过程可能让链表形成环下一次get的那个key恰好掉进环里CPU瞬间飙到100%而你盯着日志里那行“无异常”的最终状态完全想不通系统是怎么死的。这类问题从不写在报错页面里它藏在代码被多线程触碰的一瞬间。本文就聊聊那些Java开发里极容易被忽视的并发问题它们不是锁的用法问题而是心智模型里缺了一块的必然结果。你以为是原子操作其实是三行字节码最经典的误区发生在count上。很多人觉得这一行代码是“原子的”因为在单线程里它从不出错。但在JVM里count被拆成读取、加一、写回三步。两个线程同时读到旧值5然后各自加一最后都写回6于是两次自增只生效一次。你用了AtomicInteger也许能躲过计数问题但如果是余额扣减呢先检查再扣减两个线程都通过余额检查然后一起扣款数据库里的金额就会变成负数。并发问题的第一性原理是可见性、原子性、有序性任何一个被破坏bug就潜伏在下一行看似正确的代码里。而更糟糕的是你大概不会在测试环境触发它。因为并发bug需要精确的时序竞争普通的功能测试根本压不出那个线程切换的窗口。很多开发者的应对方式是用synchronized把所有相关方法锁住。这确实解决了原子性但带来了另一个更隐蔽的问题——锁的粒度决定了你系统的吞吐量天花板而没人提醒你性能瓶颈往往不是数据库而是你精心放置的那把锁。当一百个线程都在等待同一个锁对象时你的应用看起来像在正常运行实际上QPS已经塌陷了只不过监控图表上的曲线是慢慢走平的不像宕机那么刺眼。volatile不是万能的它管不了复合操作volatile是Java并发里最容易被误解的关键字。它保证了可见性和一定程度的有序性也就是禁止指令重排。但很多文章没讲透的是volatile并不保证原子性。它只能保证一个线程修改了变量后其他线程立刻看到最新值。可对这个变量的“读取—判断—修改”这个复合流程它毫无办法。举个例子你有一个volatile boolean initialized线程A负责初始化资源然后置为true。线程B循环等待这个标志变成true才继续。这个场景volatile确实有效因为从写标志到读标志之间没有其他中间操作。但如果你有一个volatile int counter然后执行counter那问题依然存在。读counter、算新值、写回counter——这三步中的任何一步都可能被别的线程插一脚。看到这里你应该明白凡是“先读后写”的逻辑volatile都帮不上忙。它只适合那种“一个线程写其他线程只读”的状态发布场景。就这么简单的规则不知坑了多少从网上抄来“volatile保证线程安全”结论的新手。他们守着volatile变量做计数器、做库存扣减最后线上数据对不上账还以为是分布式事务的问题。线程池的“优雅关闭”是个伪命题每个Java程序员都用过ExecutorService也都会在应用关闭时调用shutdown()。但shutdown只是停止接收新任务已经提交的任务还会继续跑。你可能需要shutdownNow()来尝试中断正在执行的任务。但更核心的问题是当你的JVM进程即将退出时那些还没执行完的任务到底应该被强行终止、等它执行完、还是超时后放弃这三个决策里藏着极大的业务风险。假设你有一个订单超时任务线程池每个任务在处理用户退款。如果直接shutdownNow中断信号抛给正在跑的任务——一个业务方法里被InterruptedException打断如果代码没有正确恢复中断状态任务可能卡在某个中间状态订单被标记为“处理中”却永远没有下文。如果你等所有任务跑完再退出数据库连接池却已经开始销毁任务里的SQL全部抛连接异常你又得设计重试补偿。优雅关闭的核心难点根本不是关闭线程池本身而是如何协调线程池与它依赖的外部资源数据库连接池、MQ连接的生命周期。很多团队忽略这一点结果上线新版本时频繁出现“发布期间有少量订单状态异常”因为老进程还在消化内存里的任务新进程已经开始接受新流量两个进程同时操作同一批数据。你的幂等设计如果只防了分布式调用没防“同一个任务被两个进程各执行一次”那问题就会在每次发版时准时露面。锁重入的陷阱synchronized之外的ReentrantLockReentrantLock因为支持公平锁、可中断、支持超时常被当作synchronized的高配替代品。但你一旦用tryLock()方法就掉进了一个语义陷阱。tryLock无参版本是非公平的它会立即尝试抢锁抢不到就返回false。如果你在业务代码里写了个循环不断tryLock代码在锁竞争激烈时可能让某个线程永远抢不到锁这叫“线程饥饿”。你以为加了超时控制能避免结果第100次循环的tryLock恰恰在另一个线程释放锁的一瞬间前被系统调度于是又失败——这种概率事件最难查。另一个ReentrantLock的隐藏问题是你必须手动在finally里unlock。这是对开发纪律的极大考验。业务中任何一处的异常提前return忘了unlock锁就永远不释放。调试时你看不到任何锁相关的报错因为线程不会exception它只是悄悄阻塞在lock()方法上。如果你是配合Condition做等待唤醒忘了解锁会让调用await()的线程直接IllegalMonitorStateException但很多人会误以为是“业务逻辑状态不对”而排查半天。静态变量与ThreadLocal的管理失序静态变量天然被所有线程共享。很多人写了一个静态的SimpleDateFormat作为全局日期格式化工具因为SimpleDateFormat在单线程下表现良好。但并发环境下它的内部Calendar状态会被多个线程同时修改导致parse结果错乱甚至抛NumberFormatException。你有两种解法要么用ThreadLocal给每个线程一个独立实例要么用Java 8的DateTimeFormatter它是线程安全的。但ThreadLocal本身又是一个内存泄漏的温床。当你用线程池执行任务每个任务往ThreadLocal里塞数据任务结束后线程没有被销毁而是归还池中。ThreadLocal的内容依然在线程里扎根如果这些数据引用的是大对象或ClassLoader就会造成GC无法回收。很多Web应用重启后PermGen/Metaspace溢出就是因为框架的ThreadLocal没有及时remove。所以用ThreadLocal时永远要问自己这个线程什么时候结束如果它是个池化线程那我的数据什么时候清理双重检查锁与可见性的爱恨情仇单例模式的双重检查锁是教科书典范但如果你写的不是经典版本而是自己改了一版很可能踩到指令重排的雷。经典写法必须是private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; }注意instance必须声明为volatile。因为new Singleton()不是原子的它分为分配内存、调用构造器、将引用指向内存三步。JVM可能优化为先执行第三步引用赋值再执行第二步构造器调用。另一个线程此时进来看到instance不为null直接返回一个尚未完成构造的对象——如果你的构造函数里有依赖其他字段初始化的逻辑必然出错。这里最迷惑人的是不加volatile的单例在99%的运行场景都正常因为CPU缓存一致性协议偶然发挥作用但剩下1%的极端时序足以让你的支付回调里的单例回调处理器用半天初始化了一半的配置。这种bug是真正的地狱模式——你没法复现只能靠推理。大多数人的解决手段是直接用枚举或者静态内部类实现单例干脆避开DCL。但问题在于你的团队还有无数个用DCL手写的老代码它们就是无volatile版本就像一枚枚定时炸弹。非阻塞算法的ABA还有你根本不知道的版本号当你放弃锁使用AtomicStampedReference或AtomicMarkableReference来解决CAS的ABA问题时又引入了新的心智负担。ABA问题是指线程A读到变量值为X另一个线程B把它改成Y又改回XA的CAS操作会成功因为比较的是值而不是“中间被改过”这一事实。对不需要关心中间状态的数据比如计数器没问题但如果你在实现一个无锁栈/队列ABA会让某个线程把已出队的节点重新链接回链表中。很多开发者面对ABA的第一反应是“用AtomicStampedReference加版本号”。但版本号本身如果是用普通int维护的又会溢出。你想想System.currentTimeMillis()当版本号——它是不变的如果两次操作发生在同一毫秒内版本号根本没变化ABA照样发生。这很冷门但真实存在。所以设计并发数据结构时要么确保你的业务能容忍ABA要么用AtomicLong那个单调增长的内部version而不是想当然地拿时间戳。文件与I/O的并发一致性比内存更刺激Java开发里很多人对内存中的并发问题绷紧了弦却对文件I/O放松了警惕。多线程写同一个文件各自用FileWriter打开同一个路径底层操作系统会为每次open分配独立文件指针。两个线程写同一位置时后写的会覆盖先写的数据交错丢失。如果你用RandomAccessFile设置相同偏移量并发写结果可能是两个线程的内容以极小的粒度交织在一起产生一个完全损坏的文件。如果每个线程各自打开文件并追加写入OS级别的O_APPEND一般能保证单次write的原子性但Java里的BufferedWriter.write()不是一次write系统调用它可能把数据拆成多个字节块发送。这些块之间可能插入其他线程的写入。所以本地日志、消息落盘这些看起来“简单”的写文件在并发场景下需要你显式加锁或使用单一写入线程。没有人告诉你生产环境下的log文件错行、JSON截断多半不是磁盘坏了而是你自己多线程写同一个文件的骚操作。死锁不是只能靠jstack查它常以“活锁”的形式隐身两个线程互相持有所需的锁经典死锁直接导致线程永久阻塞。但死锁之外还有一种更隐蔽的“活锁”两个线程尝试获取同一对锁检测到冲突后各自释放自己的锁并重试然后再次同时冲突无限循环但线程状态始终是RUNNABLE。你的监控显示CPU很高线程没有BLOCKED于是你根本不会往锁的方向去想。活锁的典型场景出现在分布式锁的自动续期代码里。两个服务节点同时持有同一个资源的不同分片然后互相等待对方释放自己需要的锁——这种循环等待如果设置成遇到冲突就重试而且重试间隔相同就会同步震荡。破局方式是让每个线程在重试时加入随机退避就像以太网CSMA/CD那样。说了这么多真正想强调的是Java里的并发bug远远不止死锁、竞态、内存可见性那几个词条。它们更多地出现在你对某个同步原语的理解偏差上出现在线程的生命周期与共享资源的生命周期不匹配上出现在“单线程时正常的代码在多线程下就被撕碎”的认知断层里。这不是Java语言的错而是并发环境的本质一旦共享了可变状态你的单线程心智模型就破产了。要治好这个病只能强制自己在每次写共享变量时问三句话它能被多个线程看到吗它会被同时修改吗它的修改是否需要依赖之前读到的值如果任何一个回答为“是”你就必须放下“它看起来没问题”的直觉老老实实地用锁、原子类或不可变设计去应对。那才是Java并发世界里最容易被忽视的生存法则。
返回列表