这个java案例显示造越位成功几次?

wen java案例 7

Java陷阱实录:这个案例中“造越位”成功了几次?——深度拆解并发集合的隐蔽漏洞


目录导读

  1. 越位与并发:一个足球术语的Java隐喻
  2. 案例现场:一段看似无辜的“造越位”代码
  3. 真相大白:这个Java案例显示造越位成功几次?
  4. 深挖底层:为什么ConcurrentHashMap也会“漏人”?
  5. 防御手册:如何避免自己写出“越位”代码
  6. 问答环节:关于并发集合,你不得不知的五个问题

越位与并发:一个足球术语的Java隐喻

在足球比赛中,“造越位”是一种防守战术:后卫在传球瞬间集体前压,使对方接应球员处在越位位置,从而瓦解进攻,在Java并发编程中,这战术与技术中的“检查后执行”(check-then-act)模式惊人地相似——线程先检查某个条件(比如Map中是否存在Key),再执行动作(插入或删除),问题在于,这个“瞬间”并不原子,多个线程可能同时“压上”,导致防线崩溃

这个java案例显示造越位成功几次?

案例现场:一段看似无辜的“造越位”代码

我们来看一个经典的生产环境案例,代码意图是:如果缓存中没有某个用户,则从数据库加载并放入缓存,开发者得意地使用了“线程安全”的ConcurrentHashMap

public class UserCache {
    private final Map<String, User> cache = new ConcurrentHashMap<>();
    private final UserDao dao = new UserDao();
    public User getUser(String id) {
        // 第一道防线:检查是否越位(是否存在)
        User user = cache.get(id);
        if (user == null) {
            // 第二道防线:防守动作(查询DB并插入)
            user = dao.queryById(id);
            cache.put(id, user); // 这里可能犯规!
        }
        return user;
    }
}

表面上看,ConcurrentHashMapgetput都是线程安全的,但整个getUser方法并不是原子操作,当两个线程A和B同时调用getUser("100")时,可能发生以下时序:

  • T1时刻:线程A执行cache.get("100"),发现是null,正准备去数据库查询。
  • T2时刻:线程B也执行cache.get("100"),也发现是null,也准备去数据库查询。
  • T3~T5:AB先后查询数据库,先后执行cache.put()

最终结果:数据库被查询了两次,且后写入的覆盖了先写入的,更糟的是,如果该User对象内部状态需要基于耗时计算,则可能因并发覆盖产生脏数据。

真相大白:这个Java案例显示造越位成功几次?

答案:造越位成功(即防住了) 0 次,失败 2 次。 也就是说,两次并发get操作全部“穿透”了防线,没有一次被Map中的现有数据拦截,这种极端情况下,每次请求都会命中数据库,也就是俗称的缓存击穿

如果我们将问题改为“在10个线程同时请求同一个空Key时,这个案例中有多少个线程成功抢跑(造越位失败)?”——答案是最多10个(最坏情况),因为传统的check-then-act没有任何锁机制,所有未命中的线程都会涌入数据库查询。

深挖底层:为什么ConcurrentHashMap也会“漏人”?

ConcurrentHashMap的线程安全仅限于其单个方法getputcomputeIfAbsent等)内部对数据结构的修改,它保证的是数据结构本身不损坏(如链表不丢节点、扩容不脏读),而不是业务逻辑的原子性

getput之间隔着的是“业务操作”,比如查数据库、调用远程API,这中间有一段失控窗口期,任何其他线程都可以插入相同的Key。并发集合不是并发流程的万能护身符

防御手册:如何避免自己写出“越位”代码

要解决这个案例,只需将“检查”和“执行”合并为单个原子性方法,JUC(Java并发包)早已为你提供了战术板:

  • 战术A(推荐):使用computeIfAbsent,该方法保证如果Key不存在,则原子地执行映射函数并返回当前值(无论是否新创建)。
public User getUser(String id) {
    // 原子性防守:只有Key不存在时才会执行映射函数,且整个过程加锁
    return cache.computeIfAbsent(id, k -> dao.queryById(k));
}
  • 战术B:若需要更精细的控制(如防缓存穿透的“空值标记”),可使用双重检查锁(DCL)包裹putIfAbsent
User user = cache.get(id);
if (user == null) {
    synchronized (lock) {
        user = cache.get(id); // 二次检查
        if (user == null) {
            user = dao.queryById(id);
            User prev = cache.putIfAbsent(id, user);
            if (prev != null) return prev; // 被其他线程抢先了,丢弃自己的结果
        }
    }
}
  • 战术C:如果对并发级别有要求,可使用CaffeineGuavaLoadingCache,内置了原子加载与过期策略。

问答环节:关于并发集合,你不得不知的五个问题

问1:HashTable是线程安全的,为什么不用它? 答:HashTable使用全局锁,任何读写都串行化,性能极差(相当于全员防守,放弃反击)。ConcurrentHashMap采用分段锁/CAS,允许部分读写并发,吞吐量高一个量级。

问2:Collections.synchronizedMap()包装的Map和ConcurrentHashMap有何区别? 答:前者是对象锁,同样封锁整个Map,适合低并发场景;后者是桶级细粒度锁,适合高并发读多写少场景。

问3:使用computeIfAbsent时,如果映射函数内部又调用了put,会死锁吗? 答:不会死锁,但可能产生递归重入问题(ConcurrentHashMap不允许映射函数修改该Map,会抛IllegalStateException),建议映射函数中只做外部IO或纯计算。

问4:为什么putIfAbsent不能完全替代computeIfAbsent 答:putIfAbsent需要你先计算出昂贵的value(比如查库),即使并发时发现已有值,该次查库也是白做的,而computeIfAbsent只在确实缺失时才计算,能节省大量无效IO。

问5:这个案例中“造越位”失败,是否影响最终数据一致性? 答:如果数据库是唯一事实源(Source of Truth),那么最终两个线程都会写同一个Key,后写者覆盖先写者,最终一致,但如果两个线程读取的是不同版本的数据(比如先读前,后读后),则可能产生数据回退,更严重的是,如果查询数据库的副作用很大(如发邮件、扣积分),两次执行就会产生重复业务副作用


这个Java案例告诉我们,当你在多线程环境中听到“线程安全”时,请务必追问:是结构安全,还是逻辑安全? 造越位战术的核心不是“不丢人”,而是“统一行动、步调一致”,在代码里,那个“统一行动”的指挥棒,就是computeIfAbsent或显式的锁,下次当你看到get后跟put时,请自动在心中拉响防空警报——因为大概率,你的防线已经被攻破了。

抱歉,评论功能暂时关闭!