完整项目实战(四):测试与优化——单元测试、性能测试、内存泄漏检测
好,咱们终于走到这一步了。前面几章我们把单例、工厂、观察者模式揉进了一个完整的项目中,代码能跑,功能也全。但说实话,能跑和跑得好、跑得稳,中间还隔着十万八千里。
这一章,我就带你走一遍测试与优化的完整流程。说白了,就是给你的代码做一次「全身体检」。我自己带团队时最怕听到一句话:「本地没问题啊,上线就崩了。」嗯,这通常就是测试没做到位。
一、单元测试:给每个零件上保险
单元测试,说白了就是单独测试每个类、每个方法。我个人的习惯是:写实现代码之前,先把测试用例写好。这叫测试驱动开发(TDD),虽然一开始有点别扭,但用久了真香。
1.1 单例模式的单元测试
单例模式的核心是「全局唯一实例」。测试时,我们要验证两件事:
- 多次获取实例,返回的是同一个对象
- 多线程环境下,不会创建出多个实例
// 使用 JUnit 5 测试单例
public class SingletonTest {
@Test
public void testSingleInstance() {
ConfigManager instance1 = ConfigManager.getInstance();
ConfigManager instance2 = ConfigManager.getInstance();
// 验证内存地址相同
assertSame(instance1, instance2);
}
@Test
public void testThreadSafety() throws Exception {
final int THREAD_COUNT = 100;
ConfigManager[] instances = new ConfigManager[THREAD_COUNT];
Thread[] threads = new Thread[THREAD_COUNT];
for (int i = 0; i < THREAD_COUNT; i++) {
final int index = i;
threads[i] = new Thread(() -> instances[index] = ConfigManager.getInstance());
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
// 所有线程获取的实例必须一致
for (int i = 1; i < THREAD_COUNT; i++) {
assertSame(instances[0], instances[i]);
}
}
}
1.2 工厂模式的单元测试
工厂模式测试的重点是「创建逻辑是否正确」。说白了,你传什么参数,它就该返回什么类型的对象。
public class LoggerFactoryTest {
@Test
public void testCreateFileLogger() {
Logger logger = LoggerFactory.createLogger("file");
assertTrue(logger instanceof FileLogger);
}
@Test
public void testCreateConsoleLogger() {
Logger logger = LoggerFactory.createLogger("console");
assertTrue(logger instanceof ConsoleLogger);
}
@Test
public void testInvalidType() {
assertThrows(IllegalArgumentException.class, () -> {
LoggerFactory.createLogger("database");
});
}
}
1.3 观察者模式的单元测试
观察者模式的核心是「通知机制」。测试时要验证:事件发生后,所有订阅者是否都收到了通知,并且顺序是否正确。
public class EventBusTest {
@Test
public void testNotifyAllSubscribers() {
EventBus bus = new EventBus();
MockSubscriber sub1 = new MockSubscriber();
MockSubscriber sub2 = new MockSubscriber();
bus.subscribe(sub1);
bus.subscribe(sub2);
Event event = new Event("ORDER_CREATED", "订单 #12345");
bus.publish(event);
// 两个订阅者都应该收到事件
assertEquals(1, sub1.getReceivedCount());
assertEquals(1, sub2.getReceivedCount());
assertEquals("ORDER_CREATED", sub1.getLastEvent().getType());
}
// 模拟订阅者,用于测试
private static class MockSubscriber implements EventListener {
private int count = 0;
private Event lastEvent;
@Override
public void onEvent(Event event) {
count++;
lastEvent = event;
}
public int getReceivedCount() { return count; }
public Event getLastEvent() { return lastEvent; }
}
}
二、性能测试:别让代码跑成蜗牛
单元测试通过,只说明功能对了。但性能呢?你的单例在高并发下会不会成为瓶颈?工厂创建对象的速度够不够快?观察者模式的通知链路会不会阻塞主线程?
我见过一个项目,上线后用户一多就卡死。查了半天,发现是观察者模式的通知是同步的,一个慢订阅者拖垮了整个系统。这就是性能测试没做到位的典型后果。
2.1 使用 JMH 做微基准测试
Java 世界里,做性能测试我首选 JMH(Java Microbenchmark Harness)。它能帮你排除 JVM 预热、即时编译等干扰因素,测出真实性能。
@BenchmarkMode(Mode.Throughput) // 测吞吐量
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1) // 预热 3 轮
@Measurement(iterations = 5, time = 1) // 正式测试 5 轮
@Fork(1)
public class SingletonBenchmark {
@Benchmark
public ConfigManager testGetInstance() {
return ConfigManager.getInstance();
}
@Benchmark
public Logger testCreateLogger() {
return LoggerFactory.createLogger("file");
}
@Benchmark
public void testPublishEvent(Blackhole blackhole) {
EventBus bus = new EventBus();
bus.subscribe(event -> blackhole.consume(event));
bus.publish(new Event("TEST", "payload"));
}
}
📊 典型测试结果(仅供参考):
| 测试项 | 吞吐量(ops/ms) | 平均延迟(μs) |
|---|---|---|
| 单例获取 | 约 50,000,000 | 0.02 |
| 工厂创建对象 | 约 10,000,000 | 0.1 |
| 事件发布(10个订阅者) | 约 2,000,000 | 0.5 |
你看,单例获取几乎不花时间,因为只是返回一个已经存在的引用。工厂创建对象稍微慢一点,因为有 new 对象的开销。事件发布最慢,因为要遍历所有订阅者并逐个调用。
2.2 性能瓶颈分析与优化
如果测试发现性能不达标,怎么办?别急着优化,先定位瓶颈。我常用的方法是:
- 用 profiler 工具:比如 VisualVM、JProfiler,看哪个方法 CPU 占用最高
- 检查锁竞争:单例的双重检查锁、观察者的同步列表,都可能是瓶颈
- 看 GC 压力:频繁创建对象会导致 GC 频繁,影响吞吐量
举个例子,如果观察者模式的通知太慢,可以考虑:
- 把同步通知改成异步(用线程池)
- 使用 CopyOnWriteArrayList 代替同步列表,减少读锁竞争
- 对高频事件做合并或采样,不要每个事件都通知
三、内存泄漏检测:看不见的杀手
内存泄漏,说白了就是对象用完了没释放,占着茅坑不拉屎。Java 虽然有垃圾回收,但如果你不小心保留了对象的引用,GC 就没办法回收它。
我曾经接手过一个系统,运行 3 天就 OOM 了。查了三天三夜,最后发现是观察者模式里,订阅者注册后忘了取消订阅。事件总线一直持有订阅者的引用,导致订阅者无法被回收。嗯,从那以后,我每次写观察者模式都会加一个「自动取消订阅」的机制。
3.1 常见的内存泄漏场景
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 单例持有大对象 | 单例生命周期 = 应用生命周期,内部缓存不释放 | 使用弱引用(WeakReference)或设置最大容量 |
| 观察者未取消订阅 | 订阅者被事件总线强引用,无法 GC | 使用 WeakHashMap 存储订阅者,或提供 unsubscribe 方法 |
| 工厂缓存无限增长 | 工厂内部缓存了所有创建过的对象,从不清理 | 使用 LRU 缓存或设置过期时间 |
| 内部类持有外部类引用 | 匿名内部类隐式持有外部类实例 | 使用静态内部类,或及时置空引用 |
3.2 使用工具检测内存泄漏
我常用的工具是 Eclipse Memory Analyzer (MAT) 和 JProfiler。基本流程是:
- 让应用运行一段时间,模拟用户操作
- 用 jmap 或工具生成堆转储(heap dump)
- 分析大对象、GC root 引用链
- 找出不该被引用却仍然存活的对象
// 生成堆转储的命令
jmap -dump:live,format=b,file=heap.hprof <pid>
// 或者用 jcmd(推荐)
jcmd <pid> GC.heap_dump heap.hprof
live 参数,只保留存活对象,这样文件更小,分析起来也更快。我一般会在应用运行高峰期和低谷期各 dump 一次,对比着看。
3.3 修复观察者模式的内存泄漏
前面说了,观察者模式最容易出现内存泄漏。这里给一个修复方案:
public class SafeEventBus {
// 使用 WeakHashMap,订阅者如果没有其他强引用,会被自动回收
private final Map<EventListener, Boolean> subscribers
= new WeakHashMap<>();
public void subscribe(EventListener listener) {
subscribers.put(listener, true);
}
public void unsubscribe(EventListener listener) {
subscribers.remove(listener);
}
public void publish(Event event) {
// 遍历时过滤掉已经被 GC 的订阅者
for (EventListener listener : subscribers.keySet()) {
if (listener != null) {
listener.onEvent(event);
}
}
}
}
你看,把强引用改成弱引用,问题就解决了。订阅者如果不再被其他地方引用,GC 就可以回收它,事件总线也不会阻止回收。
四、知识体系总览
下面这张图,我把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏什么。
五、写在最后
测试与优化,说白了就是给代码上三道保险。单元测试保证功能正确,性能测试保证响应速度,内存泄漏检测保证长期稳定。这三道关都过了,你的代码才算真正「能打」。
我个人有个习惯:每次提交代码前,都会跑一遍完整的测试套件。如果测试失败,绝不提交。这个习惯帮我避免了很多线上事故,也让我在团队里赢得了「靠谱」的评价。
嗯,这一章的内容就到这里。代码写完了,测试也做完了,优化也到位了。剩下的,就是把它部署到生产环境,接受真实用户的检验了。