Post

Creating a Lightweight EventBus in Java 17+

Learn how to implement a modern, lightweight EventBus in Java 17+ for decoupled communication between components. This guide covers event-driven design, modern concurrency patterns, virtual threads, and provides a complete example for building scalable, modular Java applications.

Creating a Lightweight EventBus in Java 17+

In many applications, components often need to communicate with each other in a decoupled way. This is where an event bus pattern comes in. In this post, we’ll build a lightweight EventBus system using modern Java 17+ features that allows components to register as listeners and publish events to topics without needing to know who is listening.

We’ll leverage modern Java features including records, sealed types, pattern matching, and discuss virtual threads (Java 21+) for optimal performance. By the end, you’ll understand how to integrate this pattern into your own applications using contemporary Java best practices.


🧠 What is an EventBus?

An EventBus is a messaging system within an application that lets objects communicate indirectly through events. It’s like a bulletin board — components post messages (events), and other components listen for specific types of messages.

Benefits of using an EventBus:

  • 📦 Loose coupling between components
  • 🔁 Reusability of components without tight dependencies
  • 🧪 Easier testing of individual parts

Architecture Overview

graph LR
    A[Publisher A] -->|publish event| E[EventBus]
    B[Publisher B] -->|publish event| E
    E -->|notify| L1[Listener 1]
    E -->|notify| L2[Listener 2]
    E -->|notify| L3[Listener 3]
    
    style E fill:#4CAF50,stroke:#333,stroke-width:3px,color:#fff
    style A fill:#2196F3,stroke:#333,stroke-width:2px,color:#fff
    style B fill:#2196F3,stroke:#333,stroke-width:2px,color:#fff
    style L1 fill:#FF9800,stroke:#333,stroke-width:2px,color:#fff
    style L2 fill:#FF9800,stroke:#333,stroke-width:2px,color:#fff
    style L3 fill:#FF9800,stroke:#333,stroke-width:2px,color:#fff

Publishers and listeners don’t know about each other — they only interact with the EventBus. This creates a clean separation of concerns.


🏗️ Overview of the EventBus Implementation

Let’s break down the core components of the EventBus:

  • Topics are string identifiers (e.g., "movies", "tvShows")
  • Events are immutable records with metadata (sender, eventType)
  • Listeners implement the IEventListener interface
  • Executor batches event delivery for efficiency

This modern Java 17+ implementation supports:

  • ✅ Registering and removing listeners with null safety
  • ✅ Publishing events asynchronously with batching
  • ✅ Thread-safe operations using ReentrantReadWriteLock (avoiding StampedLock pitfalls)
  • ✅ Virtual threads (Java 21+) for massive scalability
  • ✅ Immutable events using records
  • ✅ Optional sealed hierarchies for type-safe event handling
  • ✅ Pattern matching for exhaustive event processing

📦 The Event Class (Modern Java Records)

The Event is a record (introduced in Java 16) that encapsulates information about an action. Records provide immutable data carriers with automatic implementations of equals(), hashCode(), and toString().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public record Event(Object sender, String eventType) {
  
  // Compact constructor with validation (Java 16+)
  public Event {
    java.util.Objects.requireNonNull(sender, "sender cannot be null");
    java.util.Objects.requireNonNull(eventType, "eventType cannot be null");
    if (eventType.isBlank()) {
      throw new IllegalArgumentException("eventType cannot be blank");
    }
  }
  
  // Static factory methods for common event types
  public static Event createSaveEvent(Object sender) {
    return new Event(sender, "save");
  }
  
  public static Event createAddEvent(Object sender) {
    return new Event(sender, "add");
  }
  
  public static Event createRemoveEvent(Object sender) {
    return new Event(sender, "remove");
  }
}

Usage Examples

1
2
Event saveEvent = Event.createSaveEvent(this);
Event addEvent = Event.createAddEvent(myService);

Modern Features

  • Records provide automatic equals(), hashCode(), and toString() implementations
  • Compact constructor validates parameters at construction time
  • Immutability by default ensures thread-safety
  • Static factory methods for common event types: save, add, remove
  • Can be used in collections and as map keys safely

Advanced: Sealed Event Hierarchies (Java 17+)

For more complex scenarios, you can use sealed classes to create a type-safe event hierarchy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public sealed interface Event permits SaveEvent, AddEvent, RemoveEvent {
  Object sender();
  String eventType();
}

public record SaveEvent(Object sender) implements Event {
  @Override
  public String eventType() { return "save"; }
}

public record AddEvent(Object sender) implements Event {
  @Override
  public String eventType() { return "add"; }
}

public record RemoveEvent(Object sender) implements Event {
  @Override
  public String eventType() { return "remove"; }
}

This approach enables exhaustive pattern matching in Java 17+ switch expressions:

1
2
3
4
5
6
String description = switch (event) {
  case SaveEvent e -> "Saving from " + e.sender();
  case AddEvent e -> "Adding from " + e.sender();
  case RemoveEvent e -> "Removing from " + e.sender();
  // No default needed - compiler ensures exhaustiveness!
};

🧩 The Listener Interface

To respond to events, your component just needs to implement the IEventListener interface:

1
2
3
public interface IEventListener {
  void processEvent(Event event);
}

It’s a simple contract, but it gives flexibility. Any class implementing this can act as a subscriber for any topic.


⚙️ The EventBus Class

Here’s where the magic happens. This singleton class acts as the message hub, leveraging modern Java concurrency patterns.

Thread Safety in Modern Java

We use ReentrantReadWriteLock to manage concurrent access to listeners and events. This ensures safe modifications across threads without sacrificing performance. For Java 17+, we can also consider:

  • ConcurrentHashMap - for lock-free concurrent access patterns, reducing lock contention
  • Virtual Threads (Java 21+) - for massive scalability without resource overhead

⚠️ Important Note on StampedLock: While StampedLock might seem attractive for read-heavy workloads, it has critical issues that make it unsuitable for this EventBus pattern:

  1. Writer starvation - Readers can tag-team and prevent writers from ever acquiring the lock (the Java 5 ReentrantReadWriteLock behavior that was fixed in Java 6)
  2. Non-reentrant - Cannot acquire the same write lock twice from the same thread
  3. More complex - Requires careful use of optimistic reads and proper validation

For an EventBus that needs to handle both frequent reads (checking listeners) and writes (adding/removing listeners, publishing events), ReentrantReadWriteLock is the safer and more appropriate choice. It prevents writer starvation by blocking new read locks when a writer is waiting.

In Java 21+, you can dramatically improve scalability by using virtual threads instead of platform threads for event dispatch, which provides better scalability than trying to optimize with StampedLock.


Registering Listeners

1
EventBus.registerListener("movies", myListener);
1
2
3
4
public static void registerListener(String topic, IEventListener listener) {
  INSTANCE.readWriteLock.writeLock().lock();
  ...
}

🔑 We use a writeLock here to protect shared state during modifications.


Removing Listeners

1
EventBus.removeListener("movies", myListener);
1
2
3
4
public synchronized static void removeListener(String topic, IEventListener listener) {
  INSTANCE.readWriteLock.writeLock().lock();
  ...
}

This helps avoid memory leaks or unwanted notifications when components are destroyed or no longer interested.


Publishing Events

1
EventBus.publishEvent("movies", Event.createAddEvent(this));
1
public static void publishEvent(String topic, Event event)

🕒 Events are scheduled to be dispatched after 250ms using a ScheduledExecutorService. This slight delay helps batch rapid-fire events and ensures smoother performance in GUI or service-heavy apps.


⚙️ The EventBus Class (Full Implementation)

Here is the modernized implementation of the EventBus class for Java 17+:

Standard Implementation (Java 17+)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class EventBus {
  private static final EventBus INSTANCE = new EventBus();

  private final ReentrantReadWriteLock readWriteLock;
  private final Map<String, Set<IEventListener>> listeners;
  private final Map<String, Set<Event>> events;
  private final ScheduledExecutorService executor;

  private EventBus() {
    readWriteLock = new ReentrantReadWriteLock();
    listeners = new ConcurrentHashMap<>();  // Better for concurrent access
    events = new ConcurrentHashMap<>();     // Better for concurrent access
    executor = Executors.newSingleThreadScheduledExecutor(r -> {
      Thread thread = new Thread(r, "event-bus");
      thread.setDaemon(true);  // Prevent hanging on shutdown
      return thread;
    });
  }

  public static void registerListener(String topic, IEventListener listener) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(listener, "listener cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      Set<IEventListener> listeners = INSTANCE.listeners.computeIfAbsent(
        topic, k -> ConcurrentHashMap.newKeySet()
      );
      listeners.add(listener);
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }

  public static void removeListener(String topic, IEventListener listener) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(listener, "listener cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      var listeners = INSTANCE.listeners.get(topic);
      if (listeners != null) {
        listeners.remove(listener);
        if (listeners.isEmpty()) {
          INSTANCE.listeners.remove(topic);  // Clean up empty topics
        }
      }
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }

  public static void publishEvent(String topic, Event event) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(event, "event cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      INSTANCE.events.computeIfAbsent(topic, k -> new LinkedHashSet<>()).add(event);

      Runnable runnable = () -> {
        Set<Event> eventsToProcess = new LinkedHashSet<>();
        Set<IEventListener> listenersToNotify = new HashSet<>();

        try {
          INSTANCE.readWriteLock.writeLock().lock();
          var eventsForTopic = INSTANCE.events.get(topic);
          if (eventsForTopic != null && !eventsForTopic.isEmpty()) {
            eventsToProcess.addAll(eventsForTopic);
            eventsForTopic.clear();
          }
          var topicListeners = INSTANCE.listeners.get(topic);
          if (topicListeners != null) {
            listenersToNotify.addAll(topicListeners);
          }
        } finally {
          INSTANCE.readWriteLock.writeLock().unlock();
        }

        // Dispatch events
        for (Event e : eventsToProcess) {
          for (IEventListener listener : listenersToNotify) {
            try {
              listener.processEvent(e);
            } catch (Exception ex) {
              // Log but don't let one listener failure affect others
              System.err.println("Error processing event: " + ex.getMessage());
            }
          }
        }
      };

      INSTANCE.executor.schedule(runnable, 250, TimeUnit.MILLISECONDS);
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }
  
  // Graceful shutdown
  public static void shutdown() {
    INSTANCE.executor.shutdown();
    try {
      if (!INSTANCE.executor.awaitTermination(5, TimeUnit.SECONDS)) {
        INSTANCE.executor.shutdownNow();
      }
    } catch (InterruptedException e) {
      INSTANCE.executor.shutdownNow();
      Thread.currentThread().interrupt();
    }
  }
}

Enhanced Implementation with Virtual Threads (Java 21+)

For applications using Java 21 or later, you can leverage virtual threads for massive scalability. Note that we continue using ReentrantReadWriteLock rather than StampedLock because:

  • StampedLock suffers from writer starvation (readers can prevent writers from ever acquiring the lock)
  • StampedLock is not reentrant, which can cause deadlocks in certain scenarios
  • ReentrantReadWriteLock provides better fairness guarantees for this use case
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class EventBus {
  private static final EventBus INSTANCE = new EventBus();

  private final ReentrantReadWriteLock readWriteLock;
  private final Map<String, Set<IEventListener>> listeners;
  private final Map<String, Set<Event>> events;
  private final ScheduledExecutorService executor;

  private EventBus() {
    readWriteLock = new ReentrantReadWriteLock();
    listeners = new ConcurrentHashMap<>();
    events = new ConcurrentHashMap<>();
    
    // Use virtual thread executor (Java 21+)
    executor = Executors.newScheduledThreadPool(1, Thread.ofVirtual().factory());
  }

  public static void registerListener(String topic, IEventListener listener) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(listener, "listener cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      INSTANCE.listeners.computeIfAbsent(
        topic, k -> ConcurrentHashMap.newKeySet()
      ).add(listener);
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }

  public static void removeListener(String topic, IEventListener listener) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(listener, "listener cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      var listeners = INSTANCE.listeners.get(topic);
      if (listeners != null) {
        listeners.remove(listener);
        if (listeners.isEmpty()) {
          INSTANCE.listeners.remove(topic);
        }
      }
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }

  public static void publishEvent(String topic, Event event) {
    Objects.requireNonNull(topic, "topic cannot be null");
    Objects.requireNonNull(event, "event cannot be null");
    
    try {
      INSTANCE.readWriteLock.writeLock().lock();
      INSTANCE.events.computeIfAbsent(topic, k -> new LinkedHashSet<>()).add(event);

      // Dispatch using virtual threads for better scalability
      INSTANCE.executor.schedule(() -> {
        Set<Event> eventsToProcess = new LinkedHashSet<>();
        Set<IEventListener> listenersToNotify = new HashSet<>();

        try {
          INSTANCE.readWriteLock.writeLock().lock();
          var eventsForTopic = INSTANCE.events.get(topic);
          if (eventsForTopic != null && !eventsForTopic.isEmpty()) {
            eventsToProcess.addAll(eventsForTopic);
            eventsForTopic.clear();
          }
          var topicListeners = INSTANCE.listeners.get(topic);
          if (topicListeners != null) {
            listenersToNotify.addAll(topicListeners);
          }
        } finally {
          INSTANCE.readWriteLock.writeLock().unlock();
        }

        // Process each listener in parallel using virtual threads
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
          for (Event e : eventsToProcess) {
            for (IEventListener listener : listenersToNotify) {
              scope.fork(() -> {
                try {
                  listener.processEvent(e);
                  return null;
                } catch (Exception ex) {
                  System.err.println("Error processing event: " + ex.getMessage());
                  throw ex;
                }
              });
            }
          }
          scope.join();  // Wait for all to complete
        } catch (InterruptedException ex) {
          Thread.currentThread().interrupt();
        }
      }, 250, TimeUnit.MILLISECONDS);
    } finally {
      INSTANCE.readWriteLock.writeLock().unlock();
    }
  }
  
  public static void shutdown() {
    INSTANCE.executor.shutdown();
    try {
      if (!INSTANCE.executor.awaitTermination(5, TimeUnit.SECONDS)) {
        INSTANCE.executor.shutdownNow();
      }
    } catch (InterruptedException e) {
      INSTANCE.executor.shutdownNow();
      Thread.currentThread().interrupt();
    }
  }
}

⚠️ Note on ReentrantReadWriteLock Limitation with Virtual Threads:
ReentrantReadWriteLock has a maximum of 65,536 concurrent read locks due to using AbstractQueuedSynchronizer (32-bit state). While unattainable with platform threads, this can be hit with virtual threads. If your application needs millions of concurrent readers, consider:

  1. Redesigning to avoid holding locks during I/O operations
  2. Using message brokers for truly massive scale
  3. Waiting for JDK-8349031 which may migrate to AbstractQueuedLongSynchronizer

For most applications, 65K concurrent reads is more than sufficient, especially with proper lock management.

Key Improvements in Modern Java

  1. ConcurrentHashMap - Thread-safe without explicit locking for most operations
  2. var keyword (Java 10+) - Reduces verbosity with local variable type inference
  3. ConcurrentHashMap.newKeySet() - Thread-safe set implementation
  4. Virtual Threads (Java 21+) - Lightweight threads for massive scalability without thread pool tuning
  5. ReentrantReadWriteLock - Fairness guarantees and writer starvation prevention
  6. StructuredTaskScope (Java 21+ Preview) - Structured concurrency for better error handling
  7. Explicit null checks with Objects.requireNonNull() - Fail fast with clear error messages
  8. Daemon threads - Prevent hanging on application shutdown
  9. Exception handling - Prevents one failing listener from affecting others
  10. Resource cleanup - Proper shutdown mechanism

🧪 How It All Works Together

Let’s say you have a movie editor UI and a background service:

  1. The UI publishes a "save" event to "movies" when a movie is saved.
  2. A logging component and a database service have both registered as listeners for "movies".
  3. They each receive the event and perform their tasks independently.

Sequence Diagram

sequenceDiagram
    participant UI as Movie Editor UI
    participant EB as EventBus
    participant LOG as Logger
    participant DB as Database Service
    
    Note over LOG,DB: Listeners registered<br/>to "movies" topic
    
    UI->>EB: publishEvent("movies", SaveEvent)
    Note over EB: Event queued<br/>(250ms delay)
    
    EB->>EB: Batch events
    
    par Parallel Dispatch
        EB->>LOG: processEvent(SaveEvent)
        Note over LOG: Log save action
        LOG-->>EB: Complete
    and
        EB->>DB: processEvent(SaveEvent)
        Note over DB: Persist to database
        DB-->>EB: Complete
    end
    
    Note over UI,DB: No direct dependencies!

Key Points:

  • The UI doesn’t know about Logger or Database Service
  • Logger and Database Service don’t know about each other
  • Events are batched with a 250ms delay for efficiency
  • Listeners are notified in parallel (especially with virtual threads)

No hard dependencies. No spaghetti code. Just clean, event-driven design.


🔄 Modern Alternatives in the Java Ecosystem

While building your own EventBus is educational and provides full control, consider these modern alternatives for production applications:

Spring Events (Spring Framework 6+)

Spring’s event system is well-integrated with the framework and supports synchronous and asynchronous event processing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Define an event
public record UserRegisteredEvent(String username, LocalDateTime timestamp) {}

// Publish an event
@Component
public class UserService {
  private final ApplicationEventPublisher publisher;
  
  public void registerUser(String username) {
    // ... registration logic
    publisher.publishEvent(new UserRegisteredEvent(username, LocalDateTime.now()));
  }
}

// Listen to events
@Component
public class EmailNotificationService {
  
  @EventListener
  @Async  // Process asynchronously
  public void handleUserRegistered(UserRegisteredEvent event) {
    // Send welcome email
  }
}

Pros:

  • Deep integration with Spring ecosystem
  • Transaction-aware events
  • Built-in async support with @Async
  • Supports conditional listeners with SpEL

Project Reactor (Reactive Streams)

For reactive applications, Project Reactor provides powerful event-driven capabilities:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import reactor.core.publisher.Sinks;

public class ReactiveEventBus {
  private final Sinks.Many<Event> sink = Sinks.many().multicast().onBackpressureBuffer();
  
  public void publish(Event event) {
    sink.tryEmitNext(event);
  }
  
  public Flux<Event> subscribe() {
    return sink.asFlux();
  }
}

// Usage
eventBus.subscribe()
  .filter(event -> event.eventType().equals("save"))
  .buffer(Duration.ofSeconds(1))  // Batch events
  .subscribe(events -> processEvents(events));

Pros:

  • Built for reactive/non-blocking architectures
  • Powerful operators (filter, map, buffer, etc.)
  • Backpressure handling
  • Integrates with Spring WebFlux

Guava EventBus

Google’s Guava library provides a simple, annotation-based event bus:

1
2
3
4
5
6
7
8
9
10
11
12
EventBus eventBus = new EventBus();

// Register listener
eventBus.register(new Object() {
  @Subscribe
  public void handleEvent(MyEvent event) {
    // Handle event
  }
});

// Publish event
eventBus.post(new MyEvent());

Pros:

  • Simple annotation-based API
  • Battle-tested in production
  • Dead event handling
  • Small footprint

Comparison Table

Feature Custom EventBus Spring Events Reactor Guava EventBus
Dependencies None Spring Framework reactor-core guava
Learning Curve Low Medium High Low
Async Support Custom Built-in Native Limited
Type Safety Strong (with sealed classes) Strong Strong Weak (runtime)
Reactive Support No Limited Full No
Transaction Aware No Yes No No
Best For Learning, Small Apps Spring Apps Reactive Apps Simple Pub-Sub

⚡ Performance Considerations (Java 17+)

Memory Management

  1. Use weak references for listeners to prevent memory leaks:
    1
    
    private final Map<String, Set<WeakReference<IEventListener>>> listeners;
    
  2. Clean up empty topic maps to avoid memory bloat
  3. Consider event pooling for high-frequency events

Threading Strategy

Java 17-20:

  • Use ForkJoinPool for CPU-bound listeners
  • Use cached thread pool for I/O-bound listeners
  • ReentrantReadWriteLock is the best choice for read-write locking

Java 21+:

  • Virtual threads are perfect for event dispatch
  • Handle up to ~65K concurrent listeners without resource exhaustion (due to ReentrantReadWriteLock limit)
  • Simplifies code - no need for complex thread pool management
  • Avoid StampedLock - while it may seem attractive for performance, it suffers from writer starvation and non-reentrancy issues that make it unsuitable for EventBus patterns
1
2
3
4
5
// Java 21+ with virtual threads
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
listeners.forEach(listener -> 
  executor.submit(() -> listener.processEvent(event))
);

Virtual Threads vs Platform Threads

graph TB
  subgraph PT["Platform Threads (Java 17-20)"]
    E1[Event] --> TP1[Thread Pool<br/>Fixed/Cached]
    TP1 --> T1[Platform Thread 1]
    TP1 --> T2[Platform Thread 2]
    TP1 --> T3[Platform Thread 3]
    T1 --> L1[Listener 1]
    T2 --> L2[Listener 2]
    T3 --> L3[Listener 3]
    style TP1 fill:#c0392b,stroke:#333,stroke-width:2px,color:#fff
    style T1 fill:#f39c12,stroke:#333,stroke-width:2px,color:#000
    style T2 fill:#f39c12,stroke:#333,stroke-width:2px,color:#000
    style T3 fill:#f39c12,stroke:#333,stroke-width:2px,color:#000
  end
  
  note1["⚠️ Limited by thread pool size<br/>Heavy memory footprint<br/>~1MB per thread"]
  
  PT -.-> note1
graph TB
  subgraph VT["Virtual Threads (Java 21+)"]
    E2[Event] --> VTP[Virtual Thread<br/>Executor]
    VTP --> VT1[Virtual Thread 1]
    VTP --> VT2[Virtual Thread 2]
    VTP --> VT3[Virtual Thread 3]
    VTP --> VT4[Virtual Thread ...]
    VTP --> VT5[Virtual Thread N]
    VT1 --> VL1[Listener 1]
    VT2 --> VL2[Listener 2]
    VT3 --> VL3[Listener 3]
    VT4 --> VL4[Listener 4]
    VT5 --> VL5[Listener N]
    style VTP fill:#27ae60,stroke:#333,stroke-width:2px,color:#fff
    style VT1 fill:#3498db,stroke:#333,stroke-width:1px,color:#fff
    style VT2 fill:#3498db,stroke:#333,stroke-width:1px,color:#fff
    style VT3 fill:#3498db,stroke:#333,stroke-width:1px,color:#fff
    style VT4 fill:#3498db,stroke:#333,stroke-width:1px,color:#fff
    style VT5 fill:#3498db,stroke:#333,stroke-width:1px,color:#fff
  end
  
  note2["✅ Unlimited scaling<br/>Lightweight<br/>~1KB per thread"]
  
  VT -.-> note2

Platform Threads:

  • Limited by thread pool configuration
  • Heavy memory overhead (~1MB per thread)
  • Requires careful tuning (pool size, queue size, rejection policy)
  • Good for predictable, bounded workloads

Virtual Threads:

  • Create one per listener without resource concerns
  • Lightweight (~1KB per thread)
  • No pool tuning needed
  • Perfect for I/O-heavy listeners (logging, database, network calls)
  • Automatically managed by JVM

Lock Selection: Why Not StampedLock?

While StampedLock claims better performance for read-heavy workloads, it has critical issues:

  1. Writer Starvation: Continuous readers can completely starve writers, similar to Java 5’s ReentrantReadWriteLock behavior (fixed in Java 6). In an EventBus where you need to add/remove listeners and publish events, this is unacceptable.

  2. Non-Reentrant: A thread cannot reacquire a write lock it already holds, which can cause subtle deadlocks.

  3. Complexity: Optimistic reads require careful validation and can temporarily expose broken invariants.

For EventBus implementations, ReentrantReadWriteLock is the superior choice because it:

  • Prevents writer starvation by blocking new read locks when a writer is waiting
  • Supports reentrancy on both read and write locks
  • Has simpler, more predictable behavior
  • Has been battle-tested in production for decades

Reference: JavaSpecialists Newsletter #321 - StampedLock ReadWriteLock Dangers

When to Use This Pattern

Good fit:

  • Desktop applications (Swing/JavaFX)
  • Microservices with simple event needs
  • Plugin architectures
  • Applications with loose coupling requirements

Consider alternatives:

  • High-throughput systems → Use Project Reactor
  • Spring applications → Use Spring Events
  • Distributed systems → Use message brokers (Kafka, RabbitMQ)
  • Complex event workflows → Use workflow engines

🧠 Final Thoughts

Building your own EventBus with modern Java 17+ features is a powerful way to learn about:

  • Modern concurrency with virtual threads and structured concurrency
  • Type safety with sealed classes and pattern matching
  • Asynchronous programming and event-driven architecture
  • Design patterns for decoupled communication

While full-featured solutions like Spring Events or Project Reactor exist, this DIY approach is perfect for:

  • ✅ Minimal dependencies
  • ✅ Full control over behavior
  • ✅ Educational purposes
  • ✅ Small to medium applications
  • ✅ Understanding modern Java features

Extension Ideas

If you’re interested in extending this implementation, consider:

  • Wildcard topic support (e.g., "user.*" matches "user.created", "user.updated")
  • Listener priorities for ordered event processing
  • Event replay for late subscribers
  • Metrics and monitoring (event counts, processing times)
  • Dead letter queue for failed event processing
  • Async/sync mode toggle per topic
  • Hierarchical topics with inheritance

Hope this helps you build cleaner, more modular Java applications with modern best practices!

Further Reading



Want to help fuel more posts? You know what to do:

This post is licensed under CC BY 4.0 by the author.