简介:面向武汉理工大学《面向对象与多线程综合实验》课程,这份档案管理系统项目资源适合正在完成综合实验或复习相关技术的在校生。系统围绕系统管理员、档案管理员、普通用户三类角色展开,覆盖用户注册登录、权限控制、档案增删改查、多线程并发查询、日志记录等完整功能链条,可用于理解OOP设计模式与synchronized等并发同步机制的实际落地。资源共432个文件,以Java与class文件为主(96个java及294个class),另含28个xml配置、少量jar依赖与Kotlin模块,压缩包大小3.21MB,结构上便于对比源码与编译产物。已有2212人学习,适合需要参考完整项目代码、梳理迭代思路或调试异常的同学。 档案管理系统这个实验题,我当年在做武汉理工的面向对象与多线程综合实验时也踩过不少坑,现在回头看,这个题目选得是真经典。它表面是个课程设计,实际上把“对象建模”“并发编程”“线程安全”三个硬骨头全揉在一起了,做完一遍,比单独刷十道八股文都管用。这篇东西就围绕我当年的完整实现思路来写:题目怎么拆、对象怎么设计、多线程用在哪、锁怎么加、最后怎么把代码写得既好看又不容易崩。正在做类似实验的同学可以直接参考,想复习面向对象和多线程基础的朋友也能从中看到一些实际落地的写法。
1. 项目整体设计思路拆解
1.1 为什么档案管理系统适合做综合实验
课程实验最怕选题太抽象,比如单纯写个“学生管理系统”或者“图书管理系统”,功能就是增删改查,面向对象的部分勉强能体现,但多线程完全无从下手。档案管理系统不一样,它的业务场景天然带“并发属性”:多个管理员同时录入档案、大批量导入历史数据、多个查询请求同时访问目录索引、年底归档时统计报表,这些都是真实世界里的并发操作。把多线程嵌进去,不是生硬地“为了用而用”,而是业务本身就需要。
另一个原因是档案这个领域对象结构足够复杂。一份档案有基础字段(编号、标题、类型、密级、归档时间),还有不同介质形态(纸质档案、电子档案、影像档案)。这就逼着你必须抽象父类、构建继承体系,否则代码会写得像一锅粥。我当时就是冲着这个题目“既考验建模又考验并发”的特点选的它。
1.2 整体架构与模块划分
整体架构我分了三层,不复杂,但职责很清晰:
- 实体层(domain):定义档案类、用户类、操作日志类、档案类型枚举等,负责数据结构和业务规则。
- 服务层(service):面向接口设计,提供档案的入库、查询、批处理、导出等核心操作入口,所有的多线程调度都由这一层发起。
- 并发调度层(concurrent):线程池管理、并发任务拆分、结果汇总、异常处理,单独拆出来便于复用和测试。
这里想和很多同学的写法做个对比:有人喜欢把线程池写在main方法里,或者在界面按钮的事件里new一个Thread,结果逻辑全缠在一起,测试没法写,改起来也痛苦。把并发调度独立成一层之后,服务层只管业务,调度层只管“怎么跑”,main或者界面只负责“点一下”,分工明确,后面调试的时候省了大力气。
2. 面向对象:类的设计与职责边界
2.1 实体类怎么划分
档案系统里最核心的实体是Archive,但直接new一个Archive没法覆盖所有档案类型。我当时的设计是这样:
- Archive作为抽象基类,持有公共字段:档案编号、标题、归档日期、密级、存储位置。
- ElectronicArchive继承Archive,扩展文件格式、文件大小、校验码。
- PaperArchive继承Archive,扩展页数、物理位置、保管期限。
- ImageArchive继承Archive,扩展分辨率、扫描批次、影像数量。
这段继承设计在答辩时很好讲,因为它不是纸上谈兵,而是真的源于“介质不同,属性不同”这个业务事实。不过有一点要提醒:继承深度不要超过两层。我见过有人把Archive下面再拆出“涉密档案”“非涉密档案”,然后“涉密电子档案”又继承“涉密档案”,结果一旦公共字段改了,全链路跟着改,非常痛苦。组合优于继承,这句话在真实项目里会反复应验。
除了档案类本身,还需要User、OperationLog。OperationLog是个容易被忽略的点,但对于档案系统这种强调“可追溯”的业务,操作日志必须单独建模。我会在LogService里记录谁在什么时间执行了什么操作,多线程环境下尤其重要,调并发问题时一翻日志就能定位到是哪个任务出了问题,省去大量盲猜时间。
2.2 用封装和接口把系统做稳
面向对象不是“写个类就完事”,封装和接口设计才是关键。我的底线是:多线程共享的数据结构不允许直接暴露给外部。比如档案列表,如果直接返回一个ArrayList给外部,外部哪里都能改,改着改着就出并发问题。我统一封装在ArchiveService内部,对外只提供方法是addArchive、queryByTitle、batchImport(List records),内部怎么加锁、用哪个容器,调用方不需要关心。
接口方面,我定义了ArchiveService接口和ArchiveRepository接口。这里有个实用技巧:定义接口之后,先写一个单线程实现跑通业务,再写一个多线程实现优化性能。这样做有两个好处,一是业务逻辑和并发逻辑可以分开验证,二是你可以对比两种实现的正确性,多线程版本出BUG时,跑一遍单线程版本就知道是不是并发引入的问题。这个“对照调试法”在实验报告里也是加分项。
3. 多线程:哪些环节值得并发
3.1 任务拆与线程池选型
很多同学拿到这个题目,第一反应是“所有地方都上多线程”,这其实是误区。多线程不是免费的,它带来线程切换开销、锁竞争开销,用得不对反而更慢。我当时梳理了一遍业务场景,只锁定三个值得并发的点:
- 批量录入历史档案:几百上千条档案从文件或旧库导入,每条记录独立且无依赖,非常适合并行。
- 档案目录索引重建:需要遍历全部档案并生成关键词索引,属于CPU密集型任务。
- 批量报告导出或统计报表:按年份、按部门统计档案数量,数据可以分片聚合,典型的分治场景。
这三个场景有个共同点:任务之间互不依赖,结果可以各自独立计算后再合并。这正是多线程能提速的大前提。相反,那些有强依赖的操作,比如“先更新档案状态再写日志再通知审批”,就不适合硬拆成多线程,强行拆了不仅要处理复杂同步,还容易把业务逻辑搞乱。
线程池我强烈建议手写,不要简单用Executors.newFixedThreadPool。虽然实验里用它问题不大,但面试问起来容易露馅。手动声明ThreadPoolExecutor的好处是每个参数你都心里有数:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲线程存活时间 new LinkedBlockingQueue<>(200), // 任务队列 new ThreadFactoryBuilder().setNameFormat("archive-pool-%d").build(), // 给线程起名,方便日志排查 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );线程数为什么定为4到8?我当时结合机器核数(4核8线程)和任务性质(IO和CPU混合)选的,核心线程数取N+1公式附近,最大线程数翻倍留缓冲。这不是精确公式,但比拍脑袋强。队列长度给200,防止无界队列把内存撑爆。线程命名是我特别想强调的:如果不用有意义的线程名,线上排查的时候看到“pool-3-thread-1”,你根本不知道它属于哪个业务模块。
3.2 等所有任务完成这件事
批量导入最核心的问题是“怎么知道所有档案都处理完了”。很多人会用join(),用CountDownLatch,都可以,但我强烈推荐CompletableFuture,它设计得更优雅,尤其是需要同时“等结果”和“拿结果”的时候。
我当时写的核心调度逻辑是:
List<CompletableFuture<ImportResult>> futures = partitions.stream() .map(partition -> CompletableFuture.supplyAsync(() -> importer.batchInsert(partition), executor)) .collect(Collectors.toList()); CompletableFuture<Void> allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); allDone.get(30, TimeUnit.SECONDS); List<ImportResult> results = futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());这段代码的关键点是allOf().get()负责等待所有任务完成,之后用join()汇总每个任务的结果。这里有个很多人忽略的细节:如果某个分片导入失败,CompletableFuture会把异常吞进任务结果里,直接get()会抛出CompletionException,而不会自动回滚别的分片。所以我在每个分片内部都做了try-catch,把失败原因和成功数量封装进ImportResult,避免一个分片出错导致整个主线程崩溃。
3.3 并行度调优要有数据支撑
实验报告里如果只有代码,答辩时老师往往会追问一句“你确定多线程更快吗”。我建议做一个串行和并行的对照实验,数据不用多,500条档案导入就够了,记录下两种方式的耗时填入表格。我实测下来,4线程并行比单线程快了大概3倍左右,听着不错,但线程数从4加到8之后提速就不明显了,反而是线程切换和锁竞争消耗了额外时间。这说明并行度不是越高越好,这个结论放在实验报告里,会让整篇报告显得有真实做过实验的分量。
4. 线程安全与同步:最容易翻车的地方
4.1 数据竞争和锁的粒度
并发编程最大的坑是共享可变状态。档案列表、统计计数、日志队列,这些只要是多个线程同时读写的对象,都必须考虑线程安全。我用容器时遵循一个简单原则:能用并发容器就别自己加锁。
- 档案目录存储:用ConcurrentHashMap<String, Archive>,key是档案编号,天然支持并发读。
- 待处理任务列表:用LinkedBlockingQueue,兼顾线程安全和阻塞特性。
- 统计计数:用LongAdder,高并发下比AtomicLong性能更好。
讲到计数器,这里有个经典错误。我曾经用volatile int successCount,以为加了volatile就安全了,其实volatile只保证可见性,不保证原子性。多个线程同时执行successCount++,仍然会丢数据。后来改成LongAdder,再也没出过统计对不上的问题。LongAdder的底层思路是分段累加、最后汇总,类似“每个线程各算各的账,最后对总账”,这种思路在并发设计里很值得借鉴。
锁的粒度是另一个容易忽略的点。我见过有人为了图省事,在所有公共方法上都加synchronized,结果本来可以并行的查询操作全被串行化了,多线程的优势荡然无存。我的做法是:锁只保护“写”操作和“先检查后执行”这类复合操作,读操作尽量走ConcurrentHashMap的无锁读路径。比如批量导入时,每插入一条档案都要先判断编号是否存在,这个“先判断再写入”的复合操作必须同步,否则两个线程可能同时通过检查,插入两份重复档案。我是把检查逻辑和插入逻辑放在同一个方法里,并且对这个方法加锁。
4.2 避免死锁的实操习惯
死锁在课程实验里不那么容易遇到,但一旦遇到,程序会彻底卡死,而且很难复现。我总结下来,避免死锁最实用的两条经验:
一是固定加锁顺序。如果业务里同时需要修改档案表和操作日志表,就约定永远先锁档案表、再锁日志表,全局保持一致。这样即使两个线程持有了对方的下一把锁,也不会因为“你等我、我等你”而卡死。二是能用tryLock就不硬等。Java的ReentrantLock提供了tryLock(timeout)方法,拿不到锁就超时返回,配合失败重试策略,比synchronized的死等要灵活得多。
接着说一下线程间唤醒的问题。C++11里,condition_variable的wait和notify_one/notify_all是常规操作;Java里对应的是Object的wait/notifyAll,或者Condition的await/signalAll。很多同学在使用唤醒时有个误区:调用notifyAll()之后立即释放锁,然后试图用共享变量传数据,结果对端还没醒,共享变量已经被下一个线程改了。正确的做法是等条件真正发生变化后再唤醒,而且永远用while循环而不是if去判断条件变量,这是经典的“虚假唤醒”防御,Java官方文档也是这么要求的。实验中你可以写一个生产者-消费者模块来验证:生产者往队列放档案,消费者从队列取档案做索引,生产速度大于消费速度时,消费者的阻塞和唤醒逻辑必须正确,否则会出现莫名其妙的空数据。
5. 关键实现与代码落地
5.1 档案实体和服务层代码
先看实体层的Archive基类:
public abstract class Archive { protected String archiveNo; protected String title; protected LocalDate archiveDate; protected SecretLevel secretLevel; public Archive(String archiveNo, String title, LocalDate archiveDate, SecretLevel secretLevel) { this.archiveNo = archiveNo; this.title = title; this.archiveDate = archiveDate; this.secretLevel = secretLevel; } public abstract String getStorageDesc(); // getter/setter 省略 }子类ElectronicArchive实现getStorageDesc时,返回“电子档案存储于OSS路径xxx,校验码为xxx”;PaperArchive则返回“纸质档案存放于xxx柜xxx层”。这个抽象方法的设计让档案展示逻辑能针对不同介质做差异化处理,同时外部调用方完全不用关心具体类型。
服务层我写了ArchiveService的批量导入部分:
public class ArchiveService { private final ArchiveRepository repository; private final ThreadPoolExecutor executor; private final LongAdder successCount = new LongAdder(); private final LongAdder failCount = new LongAdder(); public ImportResult batchImport(List<Archive> archives) { List<List<Archive>> partitions = Lists.partition(archives, 100); List<CompletableFuture<ImportResult>> futures = new ArrayList<>(); for (List<Archive> partition : partitions) { CompletableFuture<ImportResult> future = CompletableFuture.supplyAsync(() -> { int success = 0; int fail = 0; for (Archive archive : partition) { try { repository.save(archive); successCount.increment(); success++; } catch (DuplicateArchiveException e) { failCount.increment(); fail++; } } return new ImportResult(success, fail); }, executor); futures.add(future); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); int successTotal = futures.stream().mapToInt(f -> f.join().getSuccess()).sum(); return new ImportResult(successTotal, archives.size() - successTotal); } }关键点在于分片大小的选择。我把100条作为一批,既不会太少导致线程调度开销占比过高,也不会太多导致单个线程处理时间过长、整体负载不均。分片之后,每个线程处理一个独立分区,互不干扰。而统计用的successCount因为存在多个线程同时写入,用了LongAdder保证原子性和高性能,这一点即使答辩时老师追问也能答出深度。
5.2 等待任务结果的两种写法对比
前面提到了CompletableFuture,这里再补充一个更基础的写法。用CountDownLatch也能实现类似效果:
int threadCount = 4; CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { executor.submit(() -> { try { doBatchInsert(); } finally { latch.countDown(); } }); } latch.await(30, TimeUnit.SECONDS);这个方法的好处是简单直观,逻辑一目了然。但它的缺点是:如果某个任务抛了异常,latch.countDown()必须在finally里保证执行,否则主线程会永远等下去。再有就是主线程只能知道“任务都跑完了”,却没法方便地收集每个任务的返回值,要汇总结果还得额外定义共享容器。相比之下,CompletableFuture把“异步执行”“结果等待”“异常处理”“结果汇聚”都融进了API设计,代码更紧凑,语义更清晰。如果你在大学实验里展现出对这种演进关系的理解,而不是只会背API,老师通常会很认可。
6. 实验中的常见问题与排查实录
6.1 并发执行结果不对
典型表现:批量导入500条档案,最后统计成功数只有497条,但日志里也没看到异常。这个现象最常见的根源是复合操作缺少同步。比如“先检查编号是否存在再保存”这两步被拆开,两个线程同时通过了编号检查,后写入的一方覆盖了先写入的一方。解决方法是把检查过程纳入临界区,或者依赖唯一索引在数据库层面兜底。这里我有个排查习惯:遇到并发结果不对,先用单线程跑一遍,如果单线程结果正确,就说明问题大概率出在数据竞争上;然后把共享变量的读写代码重新逐行审一遍,重点看“先读后写”的模式。
6.2 程序卡住不退出
这个经验几乎每个多线程项目都会用到。卡住不退出,十有八九是线程池里还有非守护线程在运行,或者某个任务永久阻塞在等待条件上。我排查时会先jstack抓线程快照,看线程处于什么状态。如果是WAITING状态,基本就是等待某个锁或条件;如果是RUNNABLE但长时间不动,可能是在做耗时操作,也可能是死循环。另一个常见原因是忘了调用executor.shutdown(),导致JVM不退出。我一般会在main方法结束前统一关闭线程池,并且用awaitTermination()等所有任务真正结束:
executor.shutdown(); if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); }这段收尾代码虽然不起眼,但实验课检查时,程序能干干净净退出,比“运行完还挂在那边”要给老师留下好印象得多。
6.3 列表遍历抛并发修改异常
这个问题的典型场景是:一个线程在遍历档案目录做统计,另一个线程在批量插入新档案,结果遍历期间集合被修改了,抛出ConcurrentModificationException。解决方案很明确:遍历和修改不要碰同一个容器。要么遍历时用ConcurrentHashMap提供的迭代器(弱一致性,不会抛异常),要么给容器加读写锁,让写入和遍历互斥。我在系统里用的是前者,因为档案目录本身读多写少,弱一致性的表现足够满足需求,而且性能更好。顺带提一句,如果你的实验要求用集合作为共享数据,一定要学会画一张“线程操作共享数据”的时序图,这比贴代码更能展现对并发场景的理解。
6.4 常见问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 计数结果偏小 | volatile无法保证原子性,++操作丢失更新 | 改用LongAdder或加锁 |
| 程序不退出 | 线程池未关闭,或有线程阻塞 | 调用shutdown和awaitTermination |
| 遍历报ConcurrentModificationException | 读写同一个普通集合 | 使用并发容器或写时复制集合 |
| 任务全部完成但结果汇总为空 | 异常被任务吞掉,未传递到主线程 | 用CompletableFuture的whenComplete或get捕获异常 |
| 数据大面积重复插入 | “先检查后写入”不是原子操作 | 把检查+写入整体加锁,或加唯一索引 |
以上问题基本都是多线程开发的共性坑,做完这个实验再遇到别的并发项目,能省不少排查时间。
最后再分享一个小技巧。把WholeProject跑通之后,你可以故意写一个“错误版本”——比如不加锁、不用LongAdder,然后统计出错误结果,放到实验报告的“对比分析”一节里。我当年就是这么做的:一边写上锁版本,一边写有问题的版本,用数据说明并发安全的必要性。这个做法在答辩时效果非常好,比单纯说自己“用了ConcurrentHashMap”要有说服力得多。做实验的核心不是把功能跑通,而是把“为什么这么做、不这么做会怎样”想明白。这个档案管理系统做完,你对面向对象和多线程的理解,会真的上一个台阶。
本文还有配套的精品资源,点击获取