Skip to content

Commit e1547f5

Browse files
authored
Merge pull request #36 from SLNE-Development/optimization/use-hidden-classes
perf: Introduce JVM hidden class dispatch for event and request handlers
2 parents 64c5846 + c73e4a7 commit e1547f5

13 files changed

Lines changed: 666 additions & 66 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,31 @@ These APIs:
322322

323323
---
324324

325+
## Handler Dispatch (Internals)
326+
327+
Event handlers (`@OnRedisEvent`) and request handlers (`@HandleRedisRequest`) are dispatched
328+
through **JVM hidden classes** generated at registration time.
329+
330+
Each handler method is resolved into a `MethodHandle`, which is then embedded as a
331+
`static final` constant in a hidden class implementing `RedisEventInvoker` or
332+
`RedisRequestHandlerInvoker`. This allows the JIT compiler to constant-fold and inline the
333+
entire dispatch path — significantly outperforming raw `MethodHandle.invoke()` polymorphic calls.
334+
335+
Hidden classes are:
336+
337+
* **Not discoverable** by name — no classloader pollution
338+
* **Garbage-collectible** when no longer referenced
339+
* **JIT-friendly** — the dispatch target is a compile-time constant
340+
341+
This approach combines the flexibility of reflection-based handler discovery with
342+
near-direct-call performance at runtime.
343+
344+
> [!NOTE]
345+
> This is an implementation detail. The public API for registering handlers
346+
> (`@OnRedisEvent`, `@HandleRedisRequest`) remains unchanged.
347+
348+
---
349+
325350
## Guarantees & Non-Guarantees
326351

327352
Guaranteed:

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ kotlin.stdlib.default.dependency=false
33
org.gradle.parallel=true
44
#org.gradle.caching=true
55
#org.gradle.configureondemand=true
6-
version=1.21.11-1.4.0
6+
version=1.21.11-1.4.1

surf-redis-api/src/main/kotlin/dev/slne/surf/redis/event/RedisEventBus.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import java.io.Closeable
1919
* ## Dispatch
2020
* Event handlers are invoked synchronously on a Redisson/Reactor thread (see [OnRedisEvent]).
2121
*
22-
* For invocation, the implementation uses `MethodHandle`s (via `java.lang.invoke`) rather than
23-
* JVM-generated lambda factories to avoid classloader-related issues.
22+
* For invocation, the implementation generates **JVM hidden classes** at registration time.
23+
* Each hidden class wraps a `MethodHandle` as a `static final` constant, enabling the JIT
24+
* compiler to constant-fold and inline the dispatch target. This provides near-direct-call
25+
* performance while retaining the flexibility of reflection-based handler discovery.
2426
*
2527
* ## Lifecycle
2628
* The owning [RedisApi] initializes the bus during [RedisApi.connect] via [init] and closes it during
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.slne.surf.redis.invoker;
2+
3+
/**
4+
* Internal utility class providing helper methods for the invoker infrastructure.
5+
*
6+
* <p>This class is package-private and not intended for external use.
7+
*/
8+
final class InvokerUtils {
9+
private InvokerUtils() {
10+
}
11+
12+
/**
13+
* Re-throws the given {@link Throwable} without requiring a checked exception declaration.
14+
*
15+
* <p>This leverages Java's type-erasure: the unchecked cast tricks the compiler into
16+
* treating any {@code Throwable} as an unchecked exception. This is used by the hidden
17+
* class templates to propagate exceptions from {@code MethodHandle.invokeExact()} calls
18+
* without wrapping them.
19+
*
20+
* @param <T> inferred as {@link RuntimeException} by the compiler, but actually the
21+
* original throwable type at runtime
22+
* @param t the throwable to re-throw
23+
* @throws T always (the original throwable, unwrapped)
24+
*/
25+
@SuppressWarnings("unchecked")
26+
static <T extends Throwable> void sneakyThrow(final Throwable t) throws T {
27+
throw (T) t;
28+
}
29+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package dev.slne.surf.redis.invoker;
2+
3+
import dev.slne.surf.redis.event.RedisEvent;
4+
import dev.slne.surf.redis.event.RedisEventInvoker;
5+
import org.jspecify.annotations.NullMarked;
6+
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.lang.invoke.MethodHandles;
10+
import java.lang.invoke.MethodType;
11+
import java.lang.reflect.Method;
12+
import java.util.Objects;
13+
14+
/**
15+
* Factory for creating {@link RedisEventInvoker} instances backed by JVM hidden classes.
16+
*
17+
* <p>This factory reads the bytecode of {@link RedisEventInvokerTemplate} at class-load time
18+
* and uses it as a template for
19+
* {@link MethodHandles.Lookup#defineHiddenClassWithClassData(byte[], Object, boolean, MethodHandles.Lookup.ClassOption...)}
20+
* calls. Each invocation of {@link #create(Object, Method, Class)} produces a new hidden class
21+
* instance whose {@code static final} fields (MethodHandle, Method, event class) are initialized
22+
* from the supplied class data.
23+
*
24+
* <h2>Why hidden classes?</h2>
25+
* <p>Raw {@link java.lang.invoke.MethodHandle#invoke(Object...)} goes through a polymorphic
26+
* signature that the JIT cannot inline across. By embedding the {@code MethodHandle} as a
27+
* {@code static final} constant in a hidden class, the JIT treats the target as a compile-time
28+
* constant and can inline the entire dispatch chain.
29+
*
30+
* <h2>Thread safety</h2>
31+
* <p>This class is stateless after initialization and safe for concurrent use.
32+
* The template bytecode is loaded once in a static initializer.
33+
*
34+
* @see RedisEventInvoker
35+
* @see RedisEventInvokerTemplate
36+
* @see RedisHiddenInvokerUtil
37+
*/
38+
@NullMarked
39+
public final class RedisEventInvokerFactory {
40+
41+
/** Pre-loaded bytecode of {@link RedisEventInvokerTemplate} used as the hidden class template. */
42+
private static final byte[] TEMPLATE_CLASS_BYTES;
43+
44+
/** Lookup used for defining hidden classes. */
45+
private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
46+
47+
static {
48+
try (final InputStream is = RedisEventInvokerFactory.class.getResourceAsStream(RedisEventInvokerTemplate.class.getSimpleName() + ".class")) {
49+
Objects.requireNonNull(is, "RedisEventInvokerTemplate.class not found");
50+
TEMPLATE_CLASS_BYTES = is.readAllBytes();
51+
} catch (IOException e) {
52+
throw new AssertionError("Failed to load RedisEventInvokerTemplate.class", e);
53+
}
54+
}
55+
56+
private RedisEventInvokerFactory() {
57+
throw new UnsupportedOperationException("RedisEventInvokerFactory is a utility class and cannot be instantiated");
58+
}
59+
60+
/**
61+
* Checks whether a hidden class invoker can be created for the given listener and method.
62+
* Validates that privateLookupIn succeeds and the method can be unreflected.
63+
*
64+
* @return true if {@link #create} will succeed, false if the module does not open the package
65+
*/
66+
public static boolean canAccess(final Object listener, final Method method) {
67+
try {
68+
MethodHandles.privateLookupIn(listener.getClass(), LOOKUP).unreflect(method);
69+
return true;
70+
} catch (IllegalAccessException e) {
71+
return false;
72+
}
73+
}
74+
75+
/**
76+
* Creates a new {@link RedisEventInvoker} for the given listener and handler method.
77+
*
78+
* <p>A new hidden class is defined from the pre-loaded template bytecode. The hidden class
79+
* receives the listener instance, event class, method, and a private lookup as class data.
80+
* Its static initializer resolves these into a bound {@code MethodHandle} stored as a
81+
* {@code static final} field.
82+
*
83+
* @param listener the listener object that owns the handler method
84+
* @param method the handler method annotated with {@code @OnRedisEvent}
85+
* @param redisEventClass the concrete {@link RedisEvent} subclass accepted by the handler
86+
* @return a hidden-class-backed invoker ready for dispatch
87+
* @throws AssertionError if hidden class creation fails
88+
*/
89+
public static RedisEventInvoker create(final Object listener, final Method method, final Class<? extends RedisEvent> redisEventClass) {
90+
try {
91+
return RedisHiddenInvokerUtil.createInvoker(LOOKUP, TEMPLATE_CLASS_BYTES, RedisEventInvoker.class, listener, method, redisEventClass);
92+
} catch (ReflectiveOperationException e) {
93+
throw new AssertionError("Failed to create RedisEventInvoker for " + method, e);
94+
}
95+
}
96+
97+
/**
98+
* Loads and assembles the class data for a hidden event invoker class.
99+
*
100+
* <p>This method is called from the static initializer of the hidden class
101+
* (via {@link RedisEventInvokerTemplate}) to extract the class data that was
102+
* passed during {@link MethodHandles.Lookup#defineHiddenClassWithClassData}.
103+
*
104+
* @param lookup the lookup of the hidden class requesting its class data
105+
* @return a {@link RedisHiddenInvokerUtil.ClassData} containing the resolved MethodHandle,
106+
* the original Method, and the event payload class
107+
* @throws AssertionError if class data retrieval or MethodHandle resolution fails
108+
*/
109+
static RedisHiddenInvokerUtil.ClassData<RedisEvent> classData(MethodHandles.Lookup lookup) {
110+
try {
111+
return RedisHiddenInvokerUtil.loadClassData(lookup, MethodType.methodType(void.class, RedisEvent.class), RedisEvent.class);
112+
} catch (ReflectiveOperationException e) {
113+
throw new AssertionError("Failed to retrieve class data for RedisEventInvoker", e);
114+
}
115+
}
116+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package dev.slne.surf.redis.invoker;
2+
3+
import dev.slne.surf.redis.event.RedisEvent;
4+
import dev.slne.surf.redis.event.RedisEventInvoker;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
import java.lang.invoke.MethodHandle;
8+
import java.lang.invoke.MethodHandles;
9+
import java.lang.reflect.Method;
10+
11+
/**
12+
* Hidden class template for Redis event handler invocation.
13+
*
14+
* <p>This class is designed to be used with
15+
* {@link MethodHandles.Lookup#defineHiddenClassWithClassData(byte[], Object, boolean, MethodHandles.Lookup.ClassOption...)}.
16+
* Initializing it directly will fail due to missing {@code classData}.
17+
*
18+
* <p>The {@code classData} must be a {@link java.util.List} containing:
19+
* <ol>
20+
* <li>The listener instance ({@link Object})</li>
21+
* <li>The Redis event class ({@link Class})</li>
22+
* <li>The handler {@link Method}</li>
23+
* <li>A {@link MethodHandles.Lookup} with access to the listener's class (via {@code privateLookupIn})</li>
24+
* </ol>
25+
*
26+
* <p>The MethodHandle is resolved in the static initializer and stored as a
27+
* {@code static final} field, allowing the JIT compiler to treat it as a
28+
* compile-time constant and fully inline the call.
29+
*/
30+
final class RedisEventInvokerTemplate implements RedisEventInvoker {
31+
private static final Method METHOD;
32+
private static final MethodHandle HANDLE;
33+
private static final Class<? extends RedisEvent> REDIS_EVENT_CLASS;
34+
35+
static {
36+
final MethodHandles.Lookup lookup = MethodHandles.lookup();
37+
final RedisHiddenInvokerUtil.ClassData<RedisEvent> classData = RedisEventInvokerFactory.classData(lookup);
38+
39+
METHOD = classData.method();
40+
HANDLE = classData.methodHandle();
41+
REDIS_EVENT_CLASS = classData.payloadClass();
42+
}
43+
44+
45+
@Override
46+
public void invoke(@NotNull RedisEvent event) {
47+
if (!REDIS_EVENT_CLASS.isInstance(event)) return;
48+
try {
49+
HANDLE.invokeExact(event);
50+
} catch (Throwable t) {
51+
InvokerUtils.sneakyThrow(t);
52+
}
53+
}
54+
55+
@Override
56+
public String toString() {
57+
return "RedisEventInvokerTemplate{" + METHOD + "}";
58+
}
59+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package dev.slne.surf.redis.invoker;
2+
3+
import org.jspecify.annotations.NullMarked;
4+
5+
import java.lang.constant.ConstantDescs;
6+
import java.lang.invoke.MethodHandle;
7+
import java.lang.invoke.MethodHandles;
8+
import java.lang.invoke.MethodType;
9+
import java.lang.reflect.Method;
10+
import java.util.List;
11+
12+
/**
13+
* Shared utility for creating and initializing hidden class–based invokers.
14+
*
15+
* <p>This class encapsulates the low-level JVM hidden class machinery used by
16+
* {@link RedisEventInvokerFactory} and {@link RedisRequestHandlerInvokerFactory}.
17+
*
18+
* <h2>Hidden class lifecycle</h2>
19+
* <ol>
20+
* <li>{@link #createInvoker} packs the target instance, payload class, method, and a private
21+
* lookup into a {@link List} and passes it as class data to
22+
* {@link MethodHandles.Lookup#defineHiddenClassWithClassData(byte[], Object, boolean, MethodHandles.Lookup.ClassOption...)}.</li>
23+
* <li>The hidden class's static initializer calls back to the factory's {@code classData()} method,
24+
* which delegates to {@link #loadClassData} to unpack the class data.</li>
25+
* <li>{@link #loadClassData} extracts the individual components via
26+
* {@link MethodHandles#classDataAt}, resolves the method into a bound {@link MethodHandle},
27+
* and returns them as a {@link ClassData} record.</li>
28+
* </ol>
29+
*
30+
* <p>This class is package-private and not intended for external use.
31+
*/
32+
@NullMarked
33+
final class RedisHiddenInvokerUtil {
34+
35+
private RedisHiddenInvokerUtil() {
36+
throw new UnsupportedOperationException();
37+
}
38+
39+
/**
40+
* Defines a new hidden class from the given template bytecode and returns a new instance
41+
* of the specified invoker interface.
42+
*
43+
* <p>The class data passed to the hidden class consists of:
44+
* <ol>
45+
* <li>The target handler/listener instance</li>
46+
* <li>The payload class (event or request type)</li>
47+
* <li>The handler {@link Method}</li>
48+
* <li>A {@link MethodHandles.Lookup} with private access to the target's class</li>
49+
* </ol>
50+
*
51+
* @param <I> the invoker interface type (e.g. {@code RedisEventInvoker})
52+
* @param lookup the lookup used to define the hidden class
53+
* @param templateBytes the bytecode of the template class
54+
* @param invokerInterface the interface the hidden class implements
55+
* @param target the handler/listener instance to bind the MethodHandle to
56+
* @param method the handler method to invoke
57+
* @param payloadClass the concrete event or request class the handler accepts
58+
* @return a new instance of the hidden class, cast to {@code I}
59+
* @throws ReflectiveOperationException if hidden class definition or instantiation fails
60+
*/
61+
static <I> I createInvoker(MethodHandles.Lookup lookup, byte[] templateBytes, Class<I> invokerInterface, Object target, Method method, Class<?> payloadClass) throws ReflectiveOperationException {
62+
final MethodHandles.Lookup privateLookupIn = MethodHandles.privateLookupIn(target.getClass(), lookup);
63+
final List<Object> classData = List.of(target, payloadClass, method, privateLookupIn);
64+
final MethodHandles.Lookup hiddenClassLookup = lookup.defineHiddenClassWithClassData(templateBytes, classData, true);
65+
66+
return hiddenClassLookup.lookupClass()
67+
.asSubclass(invokerInterface)
68+
.getDeclaredConstructor()
69+
.newInstance();
70+
}
71+
72+
/**
73+
* Immutable carrier for the resolved class data of a hidden invoker class.
74+
*
75+
* @param <P> the payload supertype ({@link dev.slne.surf.redis.event.RedisEvent}
76+
* or {@link dev.slne.surf.redis.request.RedisRequest})
77+
* @param method the original handler method
78+
* @param methodHandle the resolved and bound {@link MethodHandle} for the handler
79+
* @param payloadClass the concrete payload class the handler accepts
80+
*/
81+
record ClassData<P>(Method method, MethodHandle methodHandle, Class<? extends P> payloadClass) {
82+
}
83+
84+
/**
85+
* Extracts and resolves the class data that was passed to
86+
* {@link MethodHandles.Lookup#defineHiddenClassWithClassData}.
87+
*
88+
* <p>This method is called from within the static initializer of the hidden class to
89+
* unpack the target, payload class, method, and private lookup. The method is then
90+
* unreflected and bound to the target, producing a type-erased {@link MethodHandle}
91+
* matching the given {@code methodType}.
92+
*
93+
* @param <P> the payload supertype
94+
* @param lookup the hidden class's own lookup (provides access to its class data)
95+
* @param methodType the expected method type for the resolved MethodHandle
96+
* @param payloadSuperType the expected supertype of the payload class
97+
* @return a {@link ClassData} record containing the method, bound handle, and payload class
98+
* @throws ReflectiveOperationException if class data extraction or handle resolution fails
99+
*/
100+
static <P> ClassData<P> loadClassData(MethodHandles.Lookup lookup, MethodType methodType, Class<P> payloadSuperType) throws ReflectiveOperationException {
101+
final Object target = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Object.class, 0);
102+
final Class<?> payload = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Class.class, 1);
103+
final Method method = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, Method.class, 2);
104+
final MethodHandles.Lookup privateLookupIn = MethodHandles.classDataAt(lookup, ConstantDescs.DEFAULT_NAME, MethodHandles.Lookup.class, 3);
105+
106+
final MethodHandle handle = privateLookupIn.unreflect(method).bindTo(target).asType(methodType);
107+
return new ClassData<P>(method, handle, payload.asSubclass(payloadSuperType));
108+
}
109+
}

0 commit comments

Comments
 (0)