
把以前写过的 Java 基础小项目翻出来重新过一遍是件挺有意思的事。我最近做的就是这件事——图书管理系统一个在 Java 入门圈子里被写烂了的项目但正因为常见它反而是检验基础扎实程度的试金石。这篇文章就是我的自用复盘把我重新梳理需求、拆解面向对象设计、排查历史代码问题的过程整理出来希望对正在学 Java、准备面试题或者打算做第一个完整练手项目的朋友有点实际帮助。你可能也遇到过这种情况教程看懂了语法背下来了一到自己动手写一个能跑起来的完整系统就不知道从哪下手。图书管理系统恰好能把这个断层补上——它小到一个人几周能写完又大到能覆盖集合、面向对象、异常、IO、日期处理这些 Java 基础的核心考点。复盘过程中我最大的感受是真正让我提升的不是写了多少行代码而是回头审问自己每一行代码为什么这么写。1. 为什么拿图书管理系统复盘 Java 基础1.1 一个项目把零散考点串成了线在重新打开这个项目之前我刚刷完一波 Java 面试题状态是那种每条题都眼熟但两两之间连不上的混沌感。比如集合框架里的 ArrayList 和 HashMap面试题会问你区别、底层结构、扩容机制语法题里会问你 equals 和 hashCode 为什么必须一起重写IO 部分会问字节流和字符流怎么选。这些单独拎出来我都能说上几句但让我用它们组装一个真实系统我发现自己其实是懵的。图书管理系统最妙的地方就在于它是天然的考点粘合剂。要管理图书列表、读者列表、借阅记录就必须用集合类而且不同场景会逼着你做选择。要用对象表达图书、读者、借阅记录就必须理解类和对象、封装、构造器。要处理借书日期、还书日期、超期天数就得碰时间 API。要把数据保存下来就得接触文件 IO 或序列化。用户输入不合法、借了一本不存在的书逼着你处理异常。这些内容散在 Java 基础教材的不同章节里刷题时像一颗颗珠子但做一个系统就是把它们串成一条链。这个过程会暴露学习中的假理解和真盲区。1.2 技术范围刻意收紧不引入任何框架做复盘的时候我不是没动过念头要不要直接上 Spring Boot毕竟现在出去面试问的最多的就是框架。但认真想了想这个项目的定位是复盘 Java 基础如果我引入 Spring Boot那就要处理依赖注入、自动配置、Maven 依赖管理、嵌入式容器这些内容反而会掩盖掉最本质的东西——纯 Java 语法、面向对象设计、集合与 IO 的实际运用。所以我故意把技术范围压到最低纯 JDK控制台菜单操作数据通过文件持久化不联网、不用数据库驱动、不引第三方包。这样当你运行 java 命令启动程序时运行的是最原汁原味的 Java SE 程序当你写一个 HashMap 查询时用的也不是框架里封装好的工具而是 Java 自带的核心类。我并不是说框架不重要而是说复盘基础时应当聚焦。如果直接用 MyBatis 把 Book 映射到数据库表你可能再也想不起来 ObjectInputStream 和 serialVersionUID 这种基础面试题在哪见过。先把根基夯实再谈上层建筑是这类自用复盘项目比较理性的原则。1.3 复盘的第一件事把项目的代码从能跑变成能讲这次复盘我做的最重要的一件事不是重构代码而是给每个模块写讲解稿——用口述的方式回答三个问题这段代码在做什么为什么用这种方式做换一种方式会有什么问题比如项目里有个根据图书编号查询的方法原来用的是 for 循环遍历 ArrayList 一个个比对编号。我就在旁边写下讲解稿O(n) 的时间复杂度数据量小无所谓但如果用 HashMap 以编号为 key、Book 为 value查询复杂度就变成 O(1)。写下来的过程就是逼自己把知道变成能讲明白的过程。后面我会专门用一节来说这类选择这里先不展开。整理完之后我对这个项目的定位有了清晰判断它不是拿来给简历加分的项目也不是复杂的商业系统而是我自己的 Java 基础训练场。这个判断直接影响后面所有删改——凡是偏离训练基础的功能都被砍掉凡是涉及基础考点的细节都被我刻意保留并强化。2. 需求边界自用项目要把功能收敛到哪一步2.1 核心功能清单与我的取舍复盘的第一步不是写代码而是重新列需求。很多人写项目一上来就开代码写到一半发现功能铺得太开到处是残缺逻辑。我的做法是先在纸上画出功能清单再根据复盘 Java 基础这个目标做一轮取舍。最后保留的功能如下功能模块具体说明图书入库录入编号、书名、作者、分类、库存总数图书查询按书名模糊查询、按编号精确查询图书下架下架时检查是否有未归还的借阅记录读者管理注册读者限制最大可借数量默认 3 本借书操作校验读者、校验库存、生成借阅记录、记录借书日期还书操作登记归还日期、自动计算超期天数借阅明细查看某本书或某读者的历史借阅记录数据持久化启动时加载文件每次变更后保存到文件被砍掉的功能也不少图书封面图片、按出版社筛选、图书排行榜、批量导入导出 Excel这些都挺好玩的但和Java 基础复盘的目标错位。它们不依赖核心语法反而会引入文件解析、GUI 或第三方库增加噪音。选择功能时要问自己一句话这个功能能不能让我更理解某个 Java 基础知识点如果答案是不能那就先放着。功能凹得越少每条代码路径越能被反复打磨这是自用项目和大项目最不同的一点。2.2 数据存储选型内存、文件还是数据库数据怎么存是每个写系统的人绕不开的题目。这个项目我最终选了文件持久化没有上数据库原因不是数据库难而是它太常见了容易掩盖问题。做个对比就明白了存储方式优点缺点对基础复盘的帮助纯内存ArrayList简单启动快重启丢数据能验证集合基本用法但不够完整文件持久化序列化/文本实现简单能练 IO 与异常不适合并发无查询优化完整覆盖 File、Stream、序列化考点关系型数据库MySQL可靠、查询强需要 JDBC 或框架环境复杂更多是 JDBC/框架知识偏离 SE 基础如果你做的是和朋友一起用的项目我肯定建议上数据库但复盘 Java 基础时文件持久化其实更有教学价值。你得自己设计存储格式自己写加载逻辑自己处理文件不存在、文件损坏、反序列化失败这些情况。在这个项目里我用的方案是对象序列化把 ListBook、ListReader、ListBorrowRecord 封装到一个 LibraryData 对象里启动时 ObjectInputStream.readObject() 读出来变更后 ObjectOutputStream.writeObject() 写回去。2.3 角色和权限简化到同一入口原本我还在脑补管理员登录、读者登录、密码加密这些需求后来全部砍掉了。原因很现实Java 基础阶段如果做权限系统最后大概率变成简单地 if (password.equals(123456))这种代码对业务逻辑没有帮助还容易引入学了个寂寞的挫败感。简化后的入口就是一个控制台菜单图书管理录入、查询、下架读者管理注册、查看借还书借阅记录查询退出系统没有登录不做鉴权所有操作由同一个用户一揽子执行。这当然不像真实系统但它让我们把注意力集中在对象建模、业务规则校验和异常处理上。一个自用复盘项目不需要在像一个真实系统这件事上过度用力。3. 面向对象建模图书、读者、借阅记录怎么设计3.1 三个实体类的职责划分第一次写这个项目时我差点把所有东西塞进一个 Book 类里库存、读者信息、借阅记录全用字段堆着。后来发现代码写得像一锅粥才慢慢理解职责单一的意义。最终我划分为三个核心类Book图书编号、书名、作者、分类、库存总数、当前可借数量。Reader读者编号、姓名、已借数量、最大可借数量。BorrowRecord借阅记录编号、图书编号、读者编号、借书日期、应还日期、实际归还日期。每个类内部只保留和它自身属性相关的数据。类与类之间不通过嵌套引用纠缠而是用编号关联这其实借鉴了关系型数据库的外键思想也让 BorrowRecord 能独立存在——你查某本书的历史记录时不需要从 Book 对象里再挖一个链表出来。对应的属性基本都是 private并提供 getter/setter。但这里我想提个容易被忽视的点封装不是为了让你写一堆 getter/setter而是为了控制外部对对象状态的修改路径。比如 Book 的库存字段我做了一个专门的 decreaseStock() 方法外部不能直接 stock 减 1必须通过方法内部校验当前可借数 0 才允许减。这就是把业务规则放进对象内部而不是让调用方随处写 book.setStock(book.getStock() - 1)。3.2 equals 和 hashCode 在 Book 上的实现复盘以前学习 equals 和 hashCode 时我对它的理解停留在面试要背的层面直到这次复盘才真正体会到它存在的必要性。项目里借书前要判断某个编号的书是否存在最直觉的做法是 contains 一个 Book 对象。但 contains 底层调用的是 equals如果 Book 类不重写 equals那它就退化成两个对象引用地址相同比较。你 new 了一个编号和已有书籍完全相同的 Book但 contains 却返回 false程序就会认为这本书不存在。这不是逻辑问题是没遵守对象等价规则。我最终在 Book 中按编号相同则相等来重写 equals 和 hashCodeOverride public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Book book (Book) o; return bookId.equals(book.bookId); } Override public int hashCode() { return Objects.hash(bookId); }同时重写它们的原因在于Java 规定两个对象 equals 相等时 hashCode 必须相等否则 HashMap、HashSet 里基于 hashCode 定位的存储结构就会失效。你猜会发生什么同一个编号的 Book 放进 HashSet因为 hashCode 不一样它会被当成两个不同的元素重复录入怎么查都查不出问题。这个知识点在面试题里天天见在项目里实际操作过一遍后印象完全不一样了。3.3 借书还书的逻辑为什么放进服务层而不放进实体类设计时有个纠结借书逻辑是不是写成 Book 的方法 borrow() 更直接后来我否定了。如果 borrow() 写在 Book 里那这个方法要操作 Reader 对象还要创建 BorrowRecord等于图书类知道了太多和它无关的类存在。一次借书至少要检查三个条件读者存在、图书可借数量足够、读者未超过借书上限。这些规则横跨三个实体放在任何一个实体类里都会让对方被迫破坏封装。所以我的结构是实体类只管数据一个 BookService 类专门编排业务规则。方法大概长这样public void borrowBook(String bookId, String readerId) { Book book findBookById(bookId); Reader reader findReaderById(readerId); if (book null) { throw new BusinessException(图书不存在); } if (book.getAvailable() 0) { throw new BusinessException(库存不足); } if (reader.getBorrowedCount() reader.getMaxBorrowLimit()) { throw new BusinessException(超出可借数量限制); } book.decreaseStock(); reader.increaseBorrowedCount(); recordList.add(new BorrowRecord(bookId, readerId, LocalDate.now())); }这种实体类只当数据容器服务类负责编写规则的分层方式以后学习 Spring 的 Service 层时会无缝衔接。我不是为了刻意引入专业词汇而是这个项目让我第一次理解了什么叫职责划分带来的可读性提升。4. 核心业务逻辑实现与关键考点4.1 图书查询ArrayList 和 HashMap 该怎么选这个项目的查询功能给我上了很好的一课。图书总量约几千本用 ArrayList 遍历查找完全够用代码也直观public Book findByBookId(String bookId) { for (Book book : bookList) { if (book.getBookId().equals(bookId)) { return book; } } return null; }但有一次我脑子抽风在读者列表做同样功能的查询时代码里又来了一份相似的遍历。两处逻辑写法非常像却没有任何复用。复盘时我把这两处统一了如果查询条件是按编号精确匹配就用 HashMap 以编号为 key、对象为 value如果查询条件是按书名模糊匹配则保留 List 遍历。private final MapString, Book bookMapById new HashMap(); // 键图书编号值Book 对象 public Book findByBookId(String id) { return bookMapById.get(id); }查编号的复杂度从 O(n) 变成 O(1)代码从三行变一行。更重要的是我由此理解了集合选型的真实场景遍历适合条件不固定的查找散列适合键确定的查找。写项目时不存在绝对正确只有适不适合当前场景。4.2 借书与还书的流程控制不变量校验是核心借书流程并不只是找到书、库存减一。对任何业务系统来说真正考验设计能力的是不变量校验——即保证系统在任何时刻都满足稳定规则。借书时有几个不变量每本被借出的书都必须有对应的借阅记录图书的可借数量不能变成负数读者的已借数量不能超过最大可借数量同一本书同一时间不能同时被借给两个读者这些规则我在设计时写成了独立的校验方法而不是散落在菜单分支里。还书时也要校验归还日期不能早于借书日期重复还书要提示错误。用 BusinessException 包装业务错误再在上层统一 catch 并打印中文提示控制台程序就不会动不动崩栈。这种边界检查第一、正常逻辑第二的写法在面试题里对应的名词是防御式编程但项目里你不需要背概念你背不出来你只是在做事时自然会把如果读者不存在怎么办如果日期是 null 怎么办这些问题都问一遍。4.3 日期与超期计算别再用 Date 了说到日期处理我第一次写这个项目用的是 java.util.Date也算是一次实打实的踩坑。用 Date 算超期天数时我最初把两个日期 getTime() 的毫秒差除以一天的毫秒数代码大致是long days (returnTime.getTime() - borrowTime.getTime()) / (1000 * 60 * 60 * 24);这个做法有大坑毫秒除法会忽略时区、夏令时这些因素如果项目运行环境涉及夏令时切换一天可能是 23 小时或 25 小时算出来会有偏差。对于图书借阅这种周期以天为单位的系统更好的做法是用 LocalDate Period日期不再和时间绑死精确稳定得多LocalDate borrowDate record.getBorrowDate(); LocalDate dueDate borrowDate.plusDays(30); LocalDate actualReturnDate record.getActualReturnDate() null ? LocalDate.now() : record.getActualReturnDate(); long overdueDays Period.between(dueDate, actualReturnDate).getDays();如果 actualReturnDate 晚于 dueDateoverdueDays 就是超期天数否则它是个负数取 0 即可。顺带一提应还日期我用 borrowDate.plusDays(30) 计算而不是存死值这样如果之后想改借期规则只需要改这一处。4.4 数据持久化序列化与反序列化文件持久化是最容易被能跑就行心态糊弄过去的部分。很多人写控制台程序玩退出就退出下次启动全乱套。我这次复盘把持久化完全补齐了。存储结构我上面提过用一个 LibraryData 对象包装三个 List。保存时public void saveData() { try (ObjectOutputStream oos new ObjectOutputStream( new FileOutputStream(DATA_FILE))) { LibraryData data new LibraryData(bookList, readerList, recordList); oos.writeObject(data); } catch (IOException e) { System.err.println(数据保存失败 e.getMessage()); } }加载时逆向操作但要额外处理两个问题文件不存在时返回空数据文件损坏或类结构变化时抛出的异常要捕获并提示不能直接让程序崩溃。这里有个隐藏考点序列化类必须声明 serialVersionUID。如果你没有写Java 会根据类结构自动生成一个默认值只要你不再改字段就不会有问题。但我这次复盘时给 Reader 增加了一个联系电话字段旧数据文件反序列化就报了 InvalidClassException。解决办法就是每个实体类都显式声明 serialVersionUID改字段后仍能优雅兼容。这个异常在面试题里被问到过但只有自己写出过这种崩溃才能记得格外牢。5. 复盘中踩过的坑五处最容易翻车的 Java 基础细节5.1 Integer 比较用 的翻车现场我的借书逻辑里有个判断读者已借数量是否达到上限。因为已借数量是 int而我从某个配置文件读出的上限是 Integer写成了if (reader.getBorrowedCount() maxBorrowLimit) { // 提示超出上限 }看着没问题但 maxBorrowLimit 是 Integer 对象int 与 Integer 比较时会自动拆箱成 int 再比较所以这个用法本身没出错。真正的坑在另一个地方我在统计书籍数量时用了两个 Integer 对象直接比较Integer total calculateTotal(); Integer expected 100; if (total expected) { ... }如果 total 和 expected 都在 -128 到 127 之间的缓存区间它竟然能比较成功一旦超出 127比如 total 是 128它就直接返回 false。这是因为 Integer 在 -128 到 127 之间有缓存池 比较的是地址而不是值。最终的教训很简单包装类型比较值一律用 equals 或先调 intValue()别和我说你记得住不写一次这种 bug 你记不住。5.2 for-each 循环里删元素的 ConcurrentModificationException删除某本书时最自然想到的写法是for (Book book : bookList) { if (book.getBookId().equals(id)) { bookList.remove(book); } }跑起来立刻抛出 ConcurrentModificationException。原因不是一边遍历一边删除就不允许而是 for-each 在循环内部使用的是迭代器每次 next() 都会检查 modCount 是否和预期一致。你直接调用 list.remove() 修改了列表结构迭代器发现 modCount 变了于是宁可错杀也不放过。两种正确写法// 方式一用迭代器自己的 remove IteratorBook it bookList.iterator(); while (it.hasNext()) { Book book it.next(); if (book.getBookId().equals(id)) { it.remove(); } } // 方式二JDK 8 的 removeIf bookList.removeIf(book - book.getBookId().equals(id));我最终选了 removeIf简练而且意图清晰。复盘时我顺手把为什么迭代器删除是安全的理了一遍因为 it.remove() 会同步修改迭代器内部的 expectedModCount让迭代器和集合的认识保持一致。5.3 Scanner 的 nextInt 和 nextLine 混用控制台菜单常用 Scanner于是一个经典问题出现了int choice scanner.nextInt(); String bookId scanner.nextLine(); // 结果读到的是空串nextInt 只读取数字不会消费数字后面的换行符nextLine 从换行符开始读读到的就是空内容。于是用户输入菜单选项后程序跳过了图书编号输入直接报错。我处理的方式有两种最终选了最稳的// 读整行再解析避免残留换行 int choice Integer.parseInt(scanner.nextLine());如果用户输入非数字Integer.parseInt 抛异常再被 catch 住提示重新输入。这个方法比nextLine 清空缓冲的可读性更好也不会因为 nextInt 和 nextLine 混用而产生边界 bug。5.4 重启数据丢失与写文件的时机最初版本我只在退出程序时保存数据。后来测试时不小心直接点了窗口关闭按钮或者程序抛了个异常提前退出所有数据全没保存。这让我意识到退出时保存并不可靠正确做法是每次数据变更后立即保存。于是我把保存逻辑从某个具体业务方法结束时抽成一个公共方法在 createBook、deleteBook、borrowBook、returnBook 这些方法末尾调用。牺牲一点性能换取稳定性对文件存储的体量来说完全值得。保存时机还有一个细节不要在对象状态改到一半时保存。比如借书方法是先检查、再改库存、再生成记录保存必须放在所有字段变化完成后。否则文件里存下来一个中间态数据下次启动加载后图书库存减少了但借阅记录没有生成业务数据就错乱了。这点和数据库事务里的最后提交思想一致。5.5 控制台中文乱码的尴尬项目在 IDE 里运行正常一拿到系统命令行运行就出现中文乱码这个问题困扰了我很久。最终发现是文件编码不一致IDE 默认用 UTF-8 保存而 Windows 命令行默认用 GBK 解码。虽然我不打算在命令行里长期运行这个控制台程序但乱码问题本身提醒我注意编码一致性。解决方式是在读写文件时明确指定字符集。不过用 ObjectOutputStream 序列化时字符串编码由 Java 内部机制处理乱码问题基本出现在打印输出阶段。稳妥的经验是IDE 统一设置 UTF-8保存和运行用同一种编码如果追求跨平台兼容尽量在涉及 Reader/Writer 的地方显式传入 UTF-8不要依赖平台默认编码。6. 复盘后的收获与可以接着升级的方向6.1 这次复盘帮我补齐了哪些知识点整个复盘下来收获最大的不是某个具体 API而是三个层面认识的转变第一对象等价性和集合存储机制是联动的关系。equals 怎么定义、hashCode 怎么实现直接决定了对象放进 HashSet、HashMap 后的行为。过去我总觉得这是两套知识现在知道它们是同一件事。第二异常处理不是try-catch 包起来就算会了。业务异常和系统异常要有所区分预期内的业务错误比如库存不足应该用自定义业务异常给用户友好提示IO 异常、序列化异常这类不可预期的问题要打印详细错误并保证程序不直接崩溃。两者在代码里不能混为一谈。第三方法命名和组织结构决定了代码能不能被讲清楚。重构后的方法名基本都是动词宾语比如 saveData、findBookById、borrowBook、returnBook。任何一段代码拿到面试官面前不需要额外解释对方扫一眼函数名就知道你的思路。这一点在真实工作里比任何骚操作都重要。6.2 如果重写一遍我会怎么改复盘必然少不了如果重来的假设。我的重写方案大概是这样首先包结构更清晰。将来若想做成 web 项目或者接数据库包结构可以先按 entity、service、dao、ui 四层拆。entity 放实体dao 放数据访问service 放业务规则ui 放控制台交互。现阶段用 Main 类直接驱动也能跑但会越写越乱。其次接口编程。给 BookService 定义一个接口再用 BookServiceImpl 实现。目前只有一个实现类确实看不出接口的价值但如果以后想把存储从文件换成数据库接口的存在可以让上层调用不用改动太多。我也理解一个道理接口不是为当前需求准备的是为变化点准备的。最后泛型和防御式编程再强一点。比如 borrowBook 方法的入参如果传值为空字符串或 null方法开始就要进行参数校验而不是等业务运行到一半才发现找不到数据。NullPointerException 是基础阶段最常遇到的错误但大多数情况下它可以在代码入口就避免。6.3 给同样在写 Java 基础项目的人几句实在话如果你也想拿图书管理系统做自己的复盘或练手项目我有几条实在建议一是不要追求功能多追求每个功能都能讲清楚。与其做十个半吊子功能不如把录入、查询、借还书这三个核心功能做到逻辑严谨、状态一致。所谓状态一致就是程序任何时候被强制中断重启后数据都不会出现自相矛盾。这个能力在真实系统里相当值钱。二是大胆用命令行交互。GUI 或 Web 界面会分散你对 Java 基础的注意力控制台反而让你专注在核心逻辑上。等你把核心逻辑理顺以后套一层界面大概率只是时间问题。三是写代码前先列计算和校验规则。比如借书至少要想清楚书不存在怎么办、库存为 0 怎么办、读者超限怎么办、重复借同一本书怎么办。把这些场景写在小本子上再对照代码看是否都覆盖了。这比背十道面试题管用。四是要保留一份复盘笔记。我专门建了一个 markdown 文件记录每次排查 bug 的根因、解决思路和相关知识点。这个文件的价值会在你三个月后回头看时迅速放大很多当时觉得折磨的问题后来反过来成了最牢固的记忆。说到底Java 基础并不在于你背了多少关键词而在于你能否用这些关键词搭出一个逻辑自洽的小世界。图书管理系统恰好是一个足够小又足够完整的世界。我在这次复盘中重新认识了 equals、hashCode、集合选型、序列化、时间 API 这些老面孔它们不再是孤立的面试题而是能在关键位置真正承担职责的零件。如果你也拿着类似的项目在复盘别急着堆功能先把自己代码里的为什么都答上收获一定会比多写几个界面大得多。