Magnus' blog


Performance of the Spring Message Bus

In the previous post, I presented a Spring Message Bus using the Spring Application Context to look up the handler. When I used it in a large system, it turned out to be a performance bottleneck. This is because, in older versions, the Spring Context does a linear search through all the bean definitions when you ask for an implementation of a generic interface. I thought this was my chance to contribute to Spring, but it turned out that Spring Framework 7.1 already fixed the issue by caching the result. In this post I present the performance measurements and a map based bus that is faster, but with more limited possibilities.

To measure it, I set up a small Spring Boot project where a Gradle task generates 10,000 Message records with 10,000 matching @Service handlers. A @SpringBootTest boots the context with all the handlers, dispatches every message once cold and then does three shuffled warm passes over the same messages, recording the time of each dispatch. The same test is run against the dispatcher from the previous post and the map based bus presented below.

Map based bus

The implementation is quite simple: it takes all the handlers and places them in a map keyed by message type. That way each lookup is constant time.

public final class MapBasedDispatcher implements MessageDispatcher {

    private final Map<Class<?>, MessageHandler<?, ?>> handlersByMessageType = new HashMap<>();

    public MapBasedDispatcher(ApplicationContext applicationContext) {
        applicationContext.getBeansOfType(MessageHandler.class).values().forEach(handler -> {
            var messageType = ResolvableType.forClass(AopUtils.getTargetClass(handler))
                    .as(MessageHandler.class)
                    .getGeneric(0)
                    .resolve();
            if (messageType == null) {
                throw new IllegalStateException("Cannot resolve message type of " + handler.getClass());
            }
            if (handlersByMessageType.put(messageType, handler) != null) {
                throw new IllegalStateException("Duplicate handler for " + messageType);
            }
        });
    }

    @Override
    @SuppressWarnings("unchecked")
    public <M extends Message<R>, R> R dispatch(M message) {
        var handler = (MessageHandler<M, R>) handlersByMessageType.get(message.getClass());
        if (handler == null) {
            throw new IllegalArgumentException("No handler exists for " + message.getClass());
        }
        return handler.handle(message);
    }
}

In the previous post I gave up on matching the handler’s generic type myself. Spring proxies hid the real class, and Java’s standard generic reflection failed to find the interfaces and arguments, so I concluded it was unreliable and moved the lookup into the Application Context instead. I was wrong: it can be done, as long as you use Spring’s own tools for it. AopUtils.getTargetClass unwraps the Spring proxy, so the generic information of the real class is available, and ResolvableType.as(MessageHandler.class).getGeneric(0) walks the type hierarchy to find the message type even if the handler inherits from an abstract base class. Building the map for all 10,000 handlers took about 10 ms, so resolving the generics once at startup is cheap.

With this approach you lose the following:

  • Handlers cannot depend on the bus, since the bus resolves all handlers in its constructor, so a handler that depends on the bus is a circular dependency.
    • Workaround: resolve such dependencies lazily on first use.
  • Handlers cannot be scoped. Everything needs to be a singleton.

Performance

I ran the test on the two Spring Boot versions, the latest 3.x and the newest 4.2 build available at the time (there was no 4.2 GA yet):

Spring Boot Spring Framework
3.5.16 6.2.19
4.2.0-RC1 7.1.0-RC1

The results clearly show the improvement from Spring Framework 6.2 to 7.1:

Average time per dispatch for the ResolvableType dispatcher and the map based bus on Spring Boot 3.5.16 and 4.2.0-RC1 Median, 99th percentile and max time per dispatch on Spring Boot 3.5.16 and 4.2.0-RC1
Dispatcher Boot 3.5.16 / Framework 6.2 Boot 4.2.0-RC1 / Framework 7.1
ResolvableType, cold 7,301 µs 6,538 µs
ResolvableType, warm 7,207 µs 5.2 µs
Map based, cold 2.5 µs 2.6 µs
Map based, warm 1.9 µs 2.1 µs

On Spring Framework 6.2 the ResolvableType dispatcher costs about 7 ms per dispatch regardless of whether the message has been seen before, and a full pass over the 10,000 messages took 73 seconds. The cost grows with the number of beans in the context, roughly 0.7 µs per bean in this test. Even a small context with 50 handlers measured about 200 µs per dispatch, which is slow but easy to miss. On Spring Framework 7.1 the first dispatch of each message type still costs the full scan, but every later dispatch is a few microseconds, only 2 to 3x slower than the map.

Slow implementation

The ResolvableType dispatcher ends up in DefaultListableBeanFactory on every dispatch. applicationContext.getBeanProvider(ResolvableType).getObject() resolves the bean through resolveNamedBean, which first calls getBeanNamesForType(requiredType) to find the candidates. This is where the two versions differ. In Spring Framework 6.2.19 it looks like this:

@Override
public String[] getBeanNamesForType(ResolvableType type, boolean includeNonSingletons, boolean allowEagerInit) {
    Class<?> resolved = type.resolve();
    if (resolved != null && !type.hasGenerics()) {
        return getBeanNamesForType(resolved, includeNonSingletons, allowEagerInit);
    }
    else {
        return doGetBeanNamesForType(type, includeNonSingletons, allowEagerInit);
    }
}

@Override
public String[] getBeanNamesForType(@Nullable Class<?> type, boolean includeNonSingletons, boolean allowEagerInit) {
    if (!isConfigurationFrozen() || type == null || !allowEagerInit) {
        return doGetBeanNamesForType(ResolvableType.forRawClass(type), includeNonSingletons, allowEagerInit);
    }
    Map<Class<?>, String[]> cache =
            (includeNonSingletons ? this.allBeanNamesByType : this.singletonBeanNamesByType);
    String[] resolvedBeanNames = cache.get(type);
    if (resolvedBeanNames != null) {
        return resolvedBeanNames;
    }
    resolvedBeanNames = doGetBeanNamesForType(ResolvableType.forRawClass(type), includeNonSingletons, true);
    if (ClassUtils.isCacheSafe(type, getBeanClassLoader())) {
        cache.put(type, resolvedBeanNames);
    }
    return resolvedBeanNames;
}

The cache is a Map<Class<?>, String[]>, so it can only hold raw classes. A lookup for MessageHandler<Message00042, String> has generics, so it skips the cache and goes straight to doGetBeanNamesForType. That method is the linear search:

private String[] doGetBeanNamesForType(ResolvableType type, boolean includeNonSingletons, boolean allowEagerInit) {
    List<String> result = new ArrayList<>();

    // Check all bean definitions.
    for (String beanName : this.beanDefinitionNames) {
        // Only consider bean as eligible if the bean name is not defined as alias for some other bean.
        if (!isAlias(beanName)) {
            try {
                RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName);
                // Only check bean definition if it is complete.
                if (!mbd.isAbstract() && (allowEagerInit || ...)) {
                    ...
                    matchFound = isTypeMatch(beanName, type, allowFactoryBeanInit);
                    ...
                    if (matchFound) {
                        result.add(beanName);
                    }
                }
            }
            ...
        }
    }
    ...
    return StringUtils.toStringArray(result);
}

Every dispatch walks all 10,056 bean definitions and performs a generic type match on each of them. From inside the application it looks like the handler is found “on the first attempt”, which was the whole point of the previous post, but inside the bean factory each lookup is a full scan of the context. That also explains why the warm passes are no faster than the cold one: there is nothing to warm up.

Faster implementation

Spring Framework 7.1 changed getBeanNamesForType with the commit Revise getBeanNamesForType caching to cover ResolvableType-based lookups as well (gh-37382). In 7.1.0-RC1 the method looks like this:

@Override
public String[] getBeanNamesForType(ResolvableType type, boolean includeNonSingletons, boolean allowEagerInit) {
    Class<?> resolved = type.resolve();
    if (resolved != null && !type.hasGenerics()) {
        return getBeanNamesForType(resolved, includeNonSingletons, allowEagerInit);
    }

    // ResolvableType with actual generics to be matched...
    if (!isConfigurationFrozen() || !allowEagerInit) {
        return doGetBeanNamesForType(type, includeNonSingletons, allowEagerInit);
    }
    Map<Object, String[]> cache = (includeNonSingletons ? this.allBeanNamesByType : this.singletonBeanNamesByType);
    return cache.computeIfAbsent(type, key -> doGetBeanNamesForType(type, includeNonSingletons, true));
}

The cache is now a Map<Object, String[]> (backed by a ConcurrentReferenceHashMap), so it can be keyed by either a Class or a ResolvableType. Since ResolvableType implements equals and hashCode on the full generic signature, the second lookup of MessageHandler<Message00042, String> is a map hit instead of a scan. The conditions are that the configuration is frozen, which it is after the context has refreshed, and that eager init is allowed, which getBeanProvider(...).getObject() does. Both hold for the dispatcher in a running application.

This is also why the cold numbers are roughly the same in both versions. The first dispatch of each message type still pays for the full scan; it is only repeated dispatches that benefit. If you want to remove that cost as well, you can warm up the dispatcher at startup, or add a small per-message-type cache inside MessageDispatcherImpl, which is effectively what the map based bus does up front.

Conclusion

If you are on Spring Boot 3.x or Spring Boot 4.0/4.1 (Spring Framework 6.2 and 7.0), you should be using the map based version from this blog post instead of the one from the previous post. The ResolvableType lookup is a full scan of the context on every dispatch, and the cost grows with every bean you add. The results also show that the map based approach is faster everywhere, but from Spring Boot 4.2 (Spring Framework 7.1) onwards the difference is a few microseconds. There, choose based on whether you need scoped handlers.

View next or previous post: