完整项目实战(四):测试与优化——单元测试、性能测试、内存泄漏检测

好,咱们终于走到这一步了。前面几章我们把单例、工厂、观察者模式揉进了一个完整的项目中,代码能跑,功能也全。但说实话,能跑和跑得好、跑得稳,中间还隔着十万八千里。

这一章,我就带你走一遍测试与优化的完整流程。说白了,就是给你的代码做一次「全身体检」。我自己带团队时最怕听到一句话:「本地没问题啊,上线就崩了。」嗯,这通常就是测试没做到位。

一、单元测试:给每个零件上保险

单元测试,说白了就是单独测试每个类、每个方法。我个人的习惯是:写实现代码之前,先把测试用例写好。这叫测试驱动开发(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]);
        }
    }
}
💡 我的经验: 多线程测试时,别只跑一次。我习惯循环跑 1000 次,因为竞态条件有时候是概率性的。曾经有个 bug,跑 999 次都正常,第 1000 次就崩了。

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");
        });
    }
}
⚠️ 注意: 工厂方法如果返回 null,调用方很容易出现空指针。我建议工厂永远不要返回 null,要么返回一个默认实现,要么直接抛异常。这样调用方就能第一时间发现问题。

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。基本流程是:

  1. 让应用运行一段时间,模拟用户操作
  2. 用 jmap 或工具生成堆转储(heap dump)
  3. 分析大对象、GC root 引用链
  4. 找出不该被引用却仍然存活的对象
// 生成堆转储的命令
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 就可以回收它,事件总线也不会阻止回收。

四、知识体系总览

下面这张图,我把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏什么。

测试与优化知识体系 单元测试 • 单例:唯一性 + 线程安全 • 工厂:创建逻辑 + 异常处理 • 观察者:通知覆盖 + 顺序 • 使用 Mock 对象隔离依赖 • 边界条件测试 • 异常路径测试 性能测试 • JMH 微基准测试 • 吞吐量 vs 延迟 • 预热与 JIT 优化 • 锁竞争分析 • GC 压力评估 • 异步 vs 同步对比 内存泄漏检测 • 堆转储 (Heap Dump) • MAT / JProfiler 分析 • GC Root 引用链追踪 • 弱引用 (WeakReference) • 观察者取消订阅 • 缓存过期策略 目标:功能正确 + 性能达标 + 内存安全 = 可上线的代码

五、写在最后

测试与优化,说白了就是给代码上三道保险。单元测试保证功能正确,性能测试保证响应速度,内存泄漏检测保证长期稳定。这三道关都过了,你的代码才算真正「能打」。

我个人有个习惯:每次提交代码前,都会跑一遍完整的测试套件。如果测试失败,绝不提交。这个习惯帮我避免了很多线上事故,也让我在团队里赢得了「靠谱」的评价。

嗯,这一章的内容就到这里。代码写完了,测试也做完了,优化也到位了。剩下的,就是把它部署到生产环境,接受真实用户的检验了。


公众号:蓝海资料掘金营,微信deep3321