标识映射模式:让每个对象都有唯一身份证
说实话,标识映射模式(Identity Map)这个名字听起来挺唬人的。我第一次接触它是在做ORM框架的时候,说白了就是一件事:保证同一个数据库记录在内存中只有一个对象实例。
你想想看,如果我从数据库里查了两次同一个用户,结果返回了两个不同的Java对象。这时候我改了一个对象的name属性,另一个对象还是旧的。这问题就大了——数据不一致啊!
标识映射模式就是来解决这个问题的。它维护了一张映射表,把对象的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;
}
}
线程安全:多线程环境下的坑
嗯,这里要注意。标识映射表通常是线程共享的,但不同线程可能操作同一个对象。我曾经踩过一个坑:线程A拿到了用户对象,线程B也拿到了同一个用户对象。如果两个线程同时修改,最后保存的时候就会互相覆盖。
解决方案有几种:
- 乐观锁:加个版本号字段,更新时检查版本号
- 悲观锁:操作对象时加锁,但性能会下降
- 线程隔离:每个线程有自己的标识映射副本
我个人比较推荐乐观锁的方式。代码大概长这样:
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可以共享同一个缓存。但这里有个问题:不同会话可能同时修改同一条记录。所以二级缓存需要配合缓存失效策略来使用。
我画了个图,帮大家理解这两者的关系:
实战中的选择策略
到底用一级缓存还是二级缓存?我给大家几个建议:
- 只读数据(比如配置表):用二级缓存,所有会话共享,性能最好
- 频繁修改的数据:只用一级缓存,避免缓存一致性问题
- 会话内的数据:一级缓存就够了,别折腾二级缓存
- 跨会话共享的数据:二级缓存+失效策略,比如设置过期时间
代码实现:完整的标识映射
最后,我给大家一个完整的实现示例。这个版本考虑了线程安全和缓存失效:
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框架、工作单元模式、甚至简单的缓存场景里用到它。记住一点:缓存虽好,但一致性更重要。