标识映射模式:让每个对象都有唯一身份证

说实话,标识映射模式(Identity Map)这个名字听起来挺唬人的。我第一次接触它是在做ORM框架的时候,说白了就是一件事:保证同一个数据库记录在内存中只有一个对象实例

你想想看,如果我从数据库里查了两次同一个用户,结果返回了两个不同的Java对象。这时候我改了一个对象的name属性,另一个对象还是旧的。这问题就大了——数据不一致啊!

标识映射模式就是来解决这个问题的。它维护了一张映射表,把对象的ID和对象实例关联起来。每次查询的时候,先看看这张表里有没有,有就直接返回,没有才去数据库查。

核心思想:每个业务对象在同一个会话中只有一个实例,通过ID来标识唯一性。

映射表的设计

映射表说白了就是个Map。但这里有个讲究——用什么做key?我个人习惯用Class + ID的组合。为什么呢?因为不同表可能有相同ID的记录,比如用户表ID=1和订单表ID=1,这完全是两码事。

public class IdentityMap {
    // 两层Map:外层按类型分,内层按ID存
    private Map<Class<?>, Map<Object, Object>> map = new ConcurrentHashMap<>();
    
    @SuppressWarnings("unchecked")
    public <T> T get(Class<T> type, Object id) {
        Map<Object, Object> typeMap = map.get(type);
        if (typeMap == null) return null;
        return (T) typeMap.get(id);
    }
    
    public <T> void put(Class<T> type, Object id, T object) {
        map.computeIfAbsent(type, k -> new ConcurrentHashMap<>())
           .put(id, object);
    }
    
    public void remove(Class<?> type, Object id) {
        Map<Object, Object> typeMap = map.get(type);
        if (typeMap != null) typeMap.remove(id);
    }
}

我在项目中遇到过一个问题:如果直接用HashMap,多线程环境下就炸了。所以这里我用了ConcurrentHashMap,保证线程安全。

对象标识:怎么才算同一个对象?

这个问题其实挺微妙的。Java里有==equals()两种比较方式。标识映射模式关心的是数据库标识,也就是主键ID。

我建议这样设计:

  • 每个实体类都有一个getId()方法
  • Class + getId()作为Map的key
  • 重写equals()hashCode(),基于ID来判断
public abstract class DomainObject {
    protected Long id;
    
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        DomainObject that = (DomainObject) o;
        return id != null && id.equals(that.id);
    }
    
    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : 0;
    }
}
经验之谈:新创建的对象还没保存到数据库时,ID是null。这时候equals和hashCode要小心处理。我一般会先给个临时ID,或者用UUID生成一个唯一标识。

线程安全:多线程环境下的坑

嗯,这里要注意。标识映射表通常是线程共享的,但不同线程可能操作同一个对象。我曾经踩过一个坑:线程A拿到了用户对象,线程B也拿到了同一个用户对象。如果两个线程同时修改,最后保存的时候就会互相覆盖。

解决方案有几种:

  1. 乐观锁:加个版本号字段,更新时检查版本号
  2. 悲观锁:操作对象时加锁,但性能会下降
  3. 线程隔离:每个线程有自己的标识映射副本

我个人比较推荐乐观锁的方式。代码大概长这样:

public class OptimisticLockUser extends DomainObject {
    private String name;
    private int version;  // 版本号
    
    public boolean updateName(String newName) {
        // 更新前检查版本号
        int currentVersion = this.version;
        // 执行SQL: UPDATE users SET name=?, version=version+1 
        //         WHERE id=? AND version=?
        // 如果影响行数为0,说明版本号变了,有人抢先一步
        int rows = executeUpdate(newName, this.id, currentVersion);
        if (rows == 0) {
            throw new OptimisticLockException("数据已被其他人修改");
        }
        this.name = newName;
        this.version++;
        return true;
    }
}
注意:乐观锁不是万能的。高并发场景下,冲突频繁会导致大量重试。这时候可以考虑用分布式锁或者队列来串行化操作。

一级缓存与二级缓存

说到缓存,很多框架都有这个概念。我拿Hibernate来举例,大家可能更熟悉。

缓存级别 作用范围 生命周期 典型实现
一级缓存 Session(会话) 会话开始到结束 Identity Map
二级缓存 SessionFactory(进程) 应用启动到关闭 Ehcache、Redis

一级缓存其实就是标识映射模式。每个Session维护一个Identity Map,保证同一个会话中数据的一致性。Session关闭,缓存就清空。

二级缓存是跨会话的。多个Session可以共享同一个缓存。但这里有个问题:不同会话可能同时修改同一条记录。所以二级缓存需要配合缓存失效策略来使用。

我画了个图,帮大家理解这两者的关系:

一级缓存与二级缓存架构 数据库 二级缓存(进程级) 加载 持久化 Session 1 一级缓存(Identity Map) Session 2 一级缓存(Identity Map) 查询/写入 查询/写入 应用线程 1 应用线程 2 每个Session有独立的一级缓存,共享同一个二级缓存

实战中的选择策略

到底用一级缓存还是二级缓存?我给大家几个建议:

  • 只读数据(比如配置表):用二级缓存,所有会话共享,性能最好
  • 频繁修改的数据:只用一级缓存,避免缓存一致性问题
  • 会话内的数据:一级缓存就够了,别折腾二级缓存
  • 跨会话共享的数据:二级缓存+失效策略,比如设置过期时间
我的习惯:先只用一级缓存,等真的遇到性能瓶颈了,再考虑加二级缓存。过早优化是万恶之源,这话一点没错。

代码实现:完整的标识映射

最后,我给大家一个完整的实现示例。这个版本考虑了线程安全和缓存失效:

public class ThreadSafeIdentityMap<T extends DomainObject> {
    private final ConcurrentHashMap<Class<T>, ConcurrentHashMap<Long, T>> cache;
    private final ConcurrentHashMap<Class<T>, Long> expireTimes;
    private final long ttl;  // 缓存过期时间(毫秒)
    
    public ThreadSafeIdentityMap(long ttlMillis) {
        this.cache = new ConcurrentHashMap<>();
        this.expireTimes = new ConcurrentHashMap<>();
        this.ttl = ttlMillis;
    }
    
    public T get(Class<T> type, Long id) {
        ConcurrentHashMap<Long, T> typeCache = cache.get(type);
        if (typeCache == null) return null;
        
        T obj = typeCache.get(id);
        if (obj == null) return null;
        
        // 检查是否过期
        Long expireTime = expireTimes.get(type);
        if (expireTime != null && System.currentTimeMillis() > expireTime) {
            typeCache.remove(id);
            return null;
        }
        return obj;
    }
    
    public void put(Class<T> type, Long id, T object) {
        ConcurrentHashMap<Long, T> typeCache = 
            cache.computeIfAbsent(type, k -> new ConcurrentHashMap<>());
        typeCache.put(id, object);
        
        // 更新过期时间
        expireTimes.put(type, System.currentTimeMillis() + ttl);
    }
    
    public void evict(Class<T> type, Long id) {
        ConcurrentHashMap<Long, T> typeCache = cache.get(type);
        if (typeCache != null) {
            typeCache.remove(id);
        }
    }
    
    public void clear() {
        cache.clear();
        expireTimes.clear();
    }
}

这个实现里,我加了TTL过期机制。为什么要加?因为二级缓存里的数据可能被其他进程修改了,我们不能无限期地信任缓存。设置一个合理的过期时间,比如5分钟,既能提升性能,又能保证数据不会太旧。

好了,标识映射模式就讲到这里。说白了就是一张聪明的Map,帮我们管理对象的唯一性。实际项目中,你可以在ORM框架、工作单元模式、甚至简单的缓存场景里用到它。记住一点:缓存虽好,但一致性更重要