- 示例工程
- 教程
【免费下载链接】java-design-patterns
Design patterns implemented in Java
导读
Identity Map(身份映射表)是面向数据库访问场景的关键设计模式,核心思想是保证每个业务对象在整个会话内只从数据库加载一次,之后的所有引用都从内存中的映射表复用同一实例。本仓库 java-design-patterns 的 identity-map 模块以"按身份证号查询人员档案"为演示场景,完整实现了该模式。阅读本文后,你将掌握 Identity Map 的模式意图、类结构、缓存命中与未命中的查找流程、异常处理策略,以及如何通过单元测试验证缓存行为的正确性,并理解它与 Data Mapper、Unit of Work 等企业级持久化模式的关系。
Identity Map 模式类图
Identity Map 查找流程时序图
模式意图:为什么需要 Identity Map
Identity Map 模式的目的可以用一句话概括:通过把每个已加载的对象保存在一张 Map 中,确保每个对象只被加载一次;当再次引用该对象时,直接从 Map 中查找并返回(见 localization/es/identity-map/README.md)。
在典型的数据库应用中,反复查询同一条记录会产生三类问题:
- 重复加载:同一份数据在内存中被实例化成多个对象,白白浪费内存;
- 引用不一致:应用不同模块各自持有"同一行数据"的不同对象,一处修改另一处不可见,破坏引用完整性;
- 无谓的 IO:相同数据的重复查询增加了数据库调用次数,拖慢性能。
Wikipedia 对该模式的定义精准地概括了它的定位:
在 DBMS 的设计中,identity map 模式是一种数据库访问设计模式,用于通过提供特定上下文的内存缓存,避免从数据库中重复检索同一对象数据,从而提升性能。
用更朴素的语言来说:构建一张身份映射表,把最近在数据库中查过的记录存进去;下次再查同一条记录时,直接从映射表取,不再访问数据库。
现实世界的类比
设想你正在组织一场会议,签到台的工作人员第一次为与会者登记时,会把信息录入系统;当同一位与会者再次来到签到台,系统直接调出他的资料,无需重新提交。Identity Map 正是如此:每个对象只加载一次,并在整个应用生命周期内被反复复用。
模块结构:五个核心类的分工
在深入代码前,先了解本模块的完整文件布局(源码位于 identity-map/src/main/java/com/iluwatar/identitymap):
| 类 | 角色 | 职责 |
|---|---|---|
| Person.java | 实体 | 业务记录,personNationalId作为唯一标识 |
| IdentityMap.java | 缓存 | 以HashMap<Integer, Person>维护"已加载对象" |
| PersonFinder.java | 门面/查找器 | 对外提供查找入口,协调缓存与数据库 |
| PersonDbSimulator.java | 数据库接口 | 定义find/insert/update/delete契约 |
| PersonDbSimulatorImplementation.java | 模拟数据库 | 用内存List模拟数据表及 CRUD 操作 |
| IdNotFoundException.java | 异常 | 记录不存在时控制流程的运行时异常 |
类图(identity-map/etc/IdentityMap.png)清晰展示了PersonFinder同时持有IdentityMap与数据库对象的引用关系:它是连接缓存层与数据层的枢纽。
核心源码解读:实体、缓存与查找器
实体 Person:唯一标识决定"同一性"
Person.java 是一个不可变风格的实体类,其关键设计在于用 Lombok 注解精确定义"对象同一性":
@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Getter @Setter @AllArgsConstructor public final class Person implements Serializable { @Serial private static final long serialVersionUID = 1L; @EqualsAndHashCode.Include private int personNationalId; private String name; private long phoneNum; @Override public String toString() { return "Person ID is : " + personNationalId + " ; Person Name is : " + name + " ; Phone Number is :" + phoneNum; } }@EqualsAndHashCode(onlyExplicitlyIncluded = true)配合@EqualsAndHashCode.Include意味着:两个 Person 是否相等,只取决于personNationalId,与姓名、电话无关。这从语义上保证了"同一身份证号 = 同一人",也正是 Identity Map 判定缓存命中的理论基础——Map 的 key 就是personNationalId,任何两个同 ID 的 Person 实例都被视为同一逻辑对象。
缓存 IdentityMap:HashMap 的薄封装
IdentityMap.java 本质是对HashMap<Integer, Person>的封装,提供三个方法:
@Slf4j @Getter public class IdentityMap { private Map<Integer, Person> personMap = new HashMap<>(); public void addPerson(Person person) { if (!personMap.containsKey(person.getPersonNationalId())) { personMap.put(person.getPersonNationalId(), person); } else { // 防止 addPerson 更新已有记录,仅用于测试场景 LOGGER.info("Key already in Map"); } } public Person getPerson(int id) { Person person = personMap.get(id); if (person == null) { LOGGER.info("ID not in Map."); } return person; } public int size() { if (personMap == null) { return 0; } return personMap.size(); } }值得注意的细节是addPerson的防重复策略:当 key 已存在时只打印日志、不覆盖原记录。源码注释明确说明这是"确保addPerson不会更新已有记录"——缓存层只负责记录"首次加载",后续数据变更应由显式的更新流程处理,避免缓存被静默污染。
查找器 PersonFinder:缓存优先的核心流程
PersonFinder.java 是模式逻辑的集中体现,其类注释概括了整体行为:"先检查对象是否已缓存在 Identity Map 中并直接返回;否则访问数据库、将记录存入 Identity Map 后再返回,后续查找将命中缓存,使查询更快。"
@Slf4j @Getter @Setter public class PersonFinder { private static final long serialVersionUID = 1L; // 访问 Identity Map private IdentityMap identityMap = new IdentityMap(); private PersonDbSimulatorImplementation db = new PersonDbSimulatorImplementation(); public Person getPerson(int key) { // 先在 Identity Map 中查找 Person person = this.identityMap.getPerson(key); if (person != null) { LOGGER.info("Person found in the Map"); return person; } else { // 缓存未命中,去数据库查找 person = this.db.find(key); if (person != null) { this.identityMap.addPerson(person); LOGGER.info("Person found in DB."); return person; } LOGGER.info("Person with this ID does not exist."); return null; } } }这段代码完整实现了"缓存优先"的查找算法,可用三步概括:
- 查缓存:调用
identityMap.getPerson(key),命中则直接返回(日志:Person found in the Map); - 查数据库:未命中则调用
db.find(key),找到后先addPerson写入缓存再返回(日志:Person found in DB.); - 兜底处理:两处均未找到,打印提示并返回
null。
从调用链来看,getPerson的返回对象永远是同一实例——第二次查询虽然从 Map 拿到的是同一个Person引用,却省掉了一次数据库访问,这正是模式降低 IO、维持引用一致性的机理。
模拟数据库:真实的 CRUD 语义
为了让演示不依赖外部数据库,本模块用 PersonDbSimulatorImplementation.java 以内存List<Person>模拟数据表,personNationalId充当主键。接口 PersonDbSimulator.java 定义了四个操作:
- find(int personNationalId):按主键流式过滤查找,不存在时抛出
IdNotFoundException; - insert(Person person):主键已存在则提示
Record already exists.,否则追加到列表; - update(Person person):用同 ID 的新 Person 覆盖姓名与电话(源码中通过
elem.get().setName(...)实现),不存在则抛异常; - delete(int id):删除指定主键记录,不存在则抛异常。
异常类 IdNotFoundException.java 继承RuntimeException,其注释说明它是"在 Person ID 不存在时控制流程"的工具——即用异常而非空返回值标记"查无此人",使调用方(含测试)能够通过assertThrows明确断言失败场景。
完整可运行示例:从入库到缓存命中
App.java 是模块入口(mainClass已在 identity-map/pom.xml 中声明为com.iluwatar.identitymap.App),演示了完整的使用链路:
public static void main(String[] args) { // 构造 5 条虚拟人员记录 Person person1 = new Person(1, "John", 27304159); Person person2 = new Person(2, "Thomas", 42273631); Person person3 = new Person(3, "Arthur", 27489171); Person person4 = new Person(4, "Finn", 20499078); Person person5 = new Person(5, "Michael", 40599078); // 初始化数据库并入库 PersonDbSimulatorImplementation db = new PersonDbSimulatorImplementation(); db.insert(person1); db.insert(person2); db.insert(person3); db.insert(person4); db.insert(person5); // 初始化查找器并绑定数据库 PersonFinder finder = new PersonFinder(); finder.setDb(db); // 前三次查找:缓存未命中,从数据库加载 LOGGER.info(finder.getPerson(2).toString()); LOGGER.info(finder.getPerson(4).toString()); LOGGER.info(finder.getPerson(5).toString()); // 第四次查找:缓存命中,不再访问数据库 LOGGER.info(finder.getPerson(2).toString()); }运行后控制台输出(日志格式为 SLF4J/Logback 标准输出):
... INFO com.iluwatar.identitymap.IdentityMap -- ID not in Map. ... INFO com.iluwatar.identitymap.PersonDbSimulatorImplementation -- Person ID is : 2 ; Person Name is : Thomas ; Phone Number is :42273631 ... INFO com.iluwatar.identitymap.PersonFinder -- Person found in DB. ... INFO com.iluwatar.identitymap.App -- Person ID is : 2 ; Person Name is : Thomas ; Phone Number is :42273631 ... INFO com.iluwatar.identitymap.IdentityMap -- ID not in Map. ... INFO com.iluwatar.identitymap.PersonDbSimulatorImplementation -- Person ID is : 4 ; Person Name is : Finn ; Phone Number is :20499078 ... INFO com.iluwatar.identitymap.PersonFinder -- Person found in DB. ... INFO com.iluwatar.identitymap.App -- Person ID is : 4 ; Person Name is : Finn ; Phone Number is :20499078 ... INFO com.iluwatar.identitymap.IdentityMap -- ID not in Map. ... INFO com.iluwatar.identitymap.PersonDbSimulatorImplementation -- Person ID is : 5 ; Person Name is : Michael ; Phone Number is :40599078 ... INFO com.iluwatar.identitymap.PersonFinder -- Person found in DB. ... INFO com.iluwatar.identitymap.App -- Person ID is : 5 ; Person Name is : Michael ; Phone Number is :40599078 ... INFO com.iluwatar.identitymap.IdentityMap -- Person ID is : 2 ; Person Name is : Thomas ; Phone Number is :42273631 ... INFO com.iluwatar.identitymap.PersonFinder -- Person found in the Map ... INFO com.iluwatar.identitymap.App -- Person ID is : 2 ; Person Name is : Thomas ; Phone Number is :42273631逐行对比可以发现关键差异:查询 ID 为 2 的 Thomas 时,第一次经过IdentityMap -- ID not in Map.→PersonDbSimulatorImplementation(数据库)→Person found in DB.三跳;第二次则直接从IdentityMap返回对象并打印Person found in the Map,全程不触碰数据库。这直观验证了缓存命中的行为:相同 ID 的记录,数据库只被访问一次。
如何运行
在仓库根目录下,以 Maven 包装器执行即可编译、测试并运行该模块:
# 运行 identity-map 模块的 App 主类 ./mvnw -pl identity-map exec:java -Dexec.mainClass="com.iluwatar.identitymap.App" # 或仅运行该模块的测试 ./mvnw -pl identity-map test模块依赖关系(见 identity-map/pom.xml)非常精简:仅依赖slf4j-api、logback-classic(日志)与junit-jupiter-engine(测试),无任何第三方 ORM 框架,便于聚焦模式本身。
测试验证:缓存行为的自动化保障
模块提供了五组 JUnit 5 测试,其中与模式语义最相关的三组值得研读:
1. 命中缓存路径 —— PersonFinderTest.java
personFoundInIdMap测试完整复现了"首次入库、二次命中"的断言链:
// 确保 key=3 此刻不在缓存中 Assertions.assertFalse(personFinder.getIdentityMap().getPersonMap().containsKey(3)); // 首次查找:从数据库加载 Assertions.assertEquals(person3, personFinder.getPerson(3), "Finder returns incorrect record."); // 查找后确认该记录已被写入缓存 Assertions.assertTrue(personFinder.getIdentityMap().getPersonMap().containsKey(3)); // 再次查找:这次应从缓存命中,结果一致 Assertions.assertEquals(person3, personFinder.getPerson(3), "Finder returns incorrect record.");2. 缓存防重复 —— IdentityMapTest.java
addToMap测试构造了两个personNationalId同为 11 的 Person(电话号码不同),连续调用addPerson后断言:
// 5 次添加、4 个不同 ID → 缓存大小必须为 4(不允许重复) Assertions.assertEquals(4, idMap.size(), "Size of the map is incorrect"); // 且 addPerson 不得用后添加的同 ID 记录覆盖原记录(电话号码保持 27304159) Assertions.assertEquals(27304159, idMap.getPerson(11).getPhoneNum(), "Incorrect return value for phone number");这从测试层面印证了addPerson的"只写一次、不覆盖"语义,正是保证缓存数据稳定的关键约束。
3. 异常路径 —— 记录不存在
PersonDbSimulatorImplementationTest.java 对find、update、delete三种操作均断言了IdNotFoundException的抛出;PersonFinderTest.java 中的personNotFoundInDB则验证了查找器对"查无此人"的异常传导。
适用场景:何时使用 Identity Map
从模式定义和源码实现可以总结出以下适用条件(原文档 "Aplicabilidad" 部分的完整表述):
- 同一会话/事务内对同一数据存在多次访问:每次读取记录前先检查 Identity Map,若已检索过就直接返回内存中的现有引用,而不是新建对象——这是模式的首要价值,即维护引用完整性(referential integrity);
- 希望减少数据库调用次数:作为缓存的副作用,Identity Map 降低了检索对象的数据库访问次数,带来直接的性能提升。
从更广的行业视角看,Identity Map 在以下场景中尤其常见:
- ORM(对象关系映射)框架:Hibernate 等 Java ORM 框架在持久化上下文中正是借助 Identity Map 保证"同一持久化上下文内同一实体只有一个实例",从而支持脏检查与变更追踪;
- 企业级应用:在跨多个业务流程共享数据状态的场景中,Identity Map 帮助维持数据状态的一致性。
优点与权衡
优点:
- 减少内存占用:每个对象在内存中只保留一份副本;
- 防止更新不一致:应用各处引用同一实例,一处修改处处可见;
- 提升性能:避免对相同数据的重复数据库读取。
权衡:
- 增加复杂度:对象管理与持久化逻辑变得更复杂,需要额外维护缓存生命周期;
- 潜在脏数据风险:若缓存管理不当(尤其在并发环境中),可能读到过期数据。因此实践中通常需要与失效策略、事务边界配合使用。
与相关模式的关系
Identity Map 常与以下模式协同工作(两个模块均在本仓库中):
- Data Mapper(见>赞
- 示例工程
- 教程
【免费下载链接】java-design-patterns
Design patterns implemented in Java
相关推荐
Java 设计模式之 Identity Map(身份映射):用内存缓存杜绝重复对象加载
Java 设计模式之 Identity Map(身份映射):用内存缓存杜绝重复对象加载 Identity Map(身份映射)是 java design patt
示例工程教程Files.md架构决策记录ADRs精读:本地优先笔记应用中30多个决策背后的完整思路
Files.md架构决策记录ADRs精读:本地优先笔记应用中30多个决策背后的完整思路 Files.md 是一款以纯 .md 文件为唯一数据格式的本地优先笔记应
知识管理前端后端JTAppleCalendar内存优化案例:图片缓存与对象池实现
JTAppleCalendar内存优化案例:图片缓存与对象池实现 在iOS日历应用开发中,频繁的日期单元格创建与销毁常导致内存峰值过高和卡顿问题。JTApple
移动开发UI组件