Memory Optimization & Resource Efficiency Guide β
OxMQ is engineered from the ground up for high-throughput, low-latency distributed job processing on modern JVMs (Java 17+ and Java 21+ Loom Virtual Threads) with Redis.
In mission-critical production environments processing tens of thousands of jobs per second, uncontrolled memory growthβboth on the JVM Consumer side (heap thrashing, excessive GC pauses, socket proliferation) and on the Redis Storage side (unbounded hash accumulation, massive stack traces, untrimmed streams)βcan degrade latency or cause Out-of-Memory (OOM) failures.
This guide details the internal memory architecture of OxMQ, the built-in zero-allocation optimizations, and how to tune your cluster for maximum efficiency and minimum footprint.
ποΈ Architecture Overview: Two-Tier Memory Boundary β
β Part 1: JVM Consumer & Worker Memory Optimizations β
1. Poller Hot-Path Zero-Allocation & Key Interning β
In a high-throughput queue pulling 50,000 jobs/sec, parsing Redis HGETALL or Lua arrays into hash maps can allocate millions of transient java.lang.String objects per second, putting immense pressure on Young Generation (Eden) space and triggering frequent minor GCs.
OxMQ eliminates this allocation storm via canonical field key interning:
- Standard BullMQ hash keys (
id,name,data,opts,progress,delay,timestamp,processedOn,finishedOn,attemptsMade,failedReason,stacktrace,returnvalue,parentKey,parent) are mapped tostatic finalinterned String references. - Hash maps returned from the Redis poller are pre-sized with the exact field capacity (
new HashMap<>(24, 1.0f)), preventing map rehash and array resizing allocations on every claimed job.
// OxMQ Internals: Zero-allocation canonical key resolution
private static final Map<String, String> CANONICAL_KEYS = Map.of(
"id", "id", "name", "name", "data", "data", "opts", "opts",
"progress", "progress", "delay", "delay", "timestamp", "timestamp",
"processedOn", "processedOn", "finishedOn", "finishedOn",
"attemptsMade", "attemptsMade", "failedReason", "failedReason",
"stacktrace", "stacktrace", "returnvalue", "returnvalue",
"parentKey", "parentKey", "parent", "parent"
);2. Lazy Parent-Child DAG Evaluation β
In parent-child dependency workflows (FlowProducer DAGs), a parent job must access the return values of completed child jobs. Previous implementations synchronously executed an additional HGETALL bull:<queue>:<jobId>:processed Redis command for every single job claimed, even if 99.9% of jobs in the cluster were standalone jobs.
In OxMQ:
Job<T>uses a deferredSupplier<Map<String, Object>>forchildrenValues.- For standard standalone jobs: 0 Redis network calls are made, and 0 map objects are allocated on the heap.
- If and only if your worker code calls
job.getChildrenValues(), the hash is fetched on-demand and cached.
3. Stack Trace Truncation & Heap Guardrails β
When downstream external dependencies fail (e.g. database timeout, third-party REST gateway failure), exceptions often contain deep nesting, Spring proxy frames, and circular cause references.
Storing unconstrained stack traces in Redis and deserializing them on consumers can easily bloat each failed job record by 50KBβ500KB.
- OxMQ bounds all
failedReasonandstacktracevalues to 8,192 characters (8 KB). - If a stack dump exceeds 8 KB, it is cleanly truncated with an explanatory marker (
... [truncated by OxMQ memory guard]). - This guarantees deterministic Redis hash sizes and prevents consumer heap exhaustion during cascading cluster outages.
4. Connection Multiplexing vs. Dedicated Sockets β
Lettuce is built on Netty and is thread-safe; multiple threads can share a single underlying connection for non-blocking commands via pipelining.
- Heartbeat & Watchdogs: OxMQ's
LockExtender(lock renewal) andStalledJobSentinel(stalled job recovery) multiplex over the sharedgetBinaryConnection()instead of opening dedicated TCP sockets. - Blocking Pollers: Blocking Redis commands (
XREAD BLOCK,BRPOPLPUSH) intentionally use dedicated connections, wrapped in stricttry ... finally { conn.close(); }blocks to ensure immediate file descriptor and socket reclamation on thread pool termination.
5. Singleton Connection Manager in Spring Boot β
When using oxmq-spring-boot-starter, defining 20 @OxmqListener methods historically created 20 separate connection managers if configured manually. OxMQ's OxmqListenerAnnotationBeanPostProcessor automatically resolves and reuses the Spring application context's shared RedisConnectionManager singleton, slashing socket counts and background Netty event-loop thread pools.
6. Batch Memory Safeguards with maxBatchBytes β
In high-throughput batch consumers (OxmqBatchWorker / @OxmqBatchListener), dequeueing by item count alone (e.g. batchSize = 100) creates OOM vulnerability if incoming payloads unexpectedly spike in size (e.g. a burst of 10 MB payload jobs).
- OxMQ introduces cumulative byte budget enforcement via
maxBatchBytes(default: 16 MB). - The poller tracks cumulative payload byte sizes as jobs are fetched; as soon as
cumulativeBytes >= maxBatchBytes, the poller dispatches the batch immediately to the worker virtual thread, preventing JVM heap bloat and ensuring bounded memory footprint.
7. Non-Pinning Concurrency Locks (Java 21 Loom Virtual Threads) β
Java 21 Virtual Threads execute atop underlying OS carrier threads. Synchronizing on object monitors (synchronized (this)) during I/O or connection initialization can pin the virtual thread to its carrier, degrading runtime parallelism across the JVM.
RedisConnectionManagerutilizesReentrantLockinstead of monitor synchronization for connection lazy-loading and pool access.- This guarantees virtual threads yield gracefully without pinning carrier OS threads, sustaining tens of thousands of concurrent worker threads on tiny JVM footprints.
β‘ Part 2: Redis In-Memory Storage & Compaction β
Redis is an in-memory database where RAM is a premium resource. Without explicit lifecycle policies, completed job hashes and event streams will grow until Redis reaches maxmemory and begins rejecting writes.
1. Global Retention Policies (removeOnComplete & removeOnFail) β
Rather than requiring every producer to remember to set removeOnComplete on every single queue.add() call, OxMQ allows you to set default retention policies at the Queue level or globally in application.yml:
# application.yml
oxmq:
default-job-options:
remove-on-complete:
count: 1000 # Keep strictly the last 1,000 completed jobs
age-seconds: 86400 # Expire completed jobs older than 24 hours
remove-on-fail:
count: 5000 # Keep last 5,000 failed jobs for incident debugging
age-seconds: 604800 # Evict failed jobs older than 7 daysOr programmatically in plain Java:
OxmqQueue<Order> queue = OxmqQueue.<Order>builder()
.name("orders")
.defaultJobOptions(JobOptions.builder()
.removeOnComplete(KeepJobs.of(1000, Duration.ofHours(24)))
.removeOnFail(KeepJobs.of(5000, Duration.ofDays(7)))
.build())
.build();When jobs complete or fail, BullMQ's atomic Lua scripts trim the sorted sets (bull:<queue>:completed / bull:<queue>:failed) and execute DEL on the underlying bull:<queue>:<jobId> hashes in $O(\log N + M)$ time.
2. Stream Trimming (maxLenEvents) β
OxMQ uses Redis Streams (bull:<queue>:events) for real-time lifecycle notifications (waiting, active, completed, failed, progress).
In high-throughput environments, an untrimmed Redis Stream will accumulate millions of entries. OxMQ exposes maxLenEvents across Core and Spring Boot:
oxmq:
max-len-events: 10000 # Bounds stream key to ~10,000 recent eventsWhen new events are appended via XADD, OxMQ utilizes approximate trimming (XADD ... MAXLEN ~ 10000), which executes in near-constant time $O(1)$ without rebuilding stream macro-nodes.
3. Payload Strategy: Avoid Redis as a Blob Store β
For maximum Redis memory efficiency:
- Small Payloads (< 10 KB): Ideal for Redis job hashes. Fast serialization, minimal memory overhead.
- Large Payloads (> 1 MB): Store large binaries, CSVs, or video files in an external object store (AWS S3, Google Cloud Storage, MinIO). Store only the object URI or reference ID in the OxMQ job payload:
// β
Recommended: Reference pattern
public record VideoTranscodeJob(
String videoId,
String sourceS3Uri, // "s3://media-bucket/raw/video_101.mp4"
String targetFormat
) {}π Benchmark & Optimization Comparison β
Empirical performance measurements before and after memory optimizations across Java 17 LTS (Platform Threads) and Java 21 LTS (Loom Virtual Threads):
| Metric | Before Optimization | After Optimization | Empirical Improvement |
|---|---|---|---|
| Pure Core Throughput (0ms I/O) | 1,112.1 jobs/s | 2,805.8 jobs/s (Java 21) / 2,834.5 jobs/s (Java 17) | ~2.5x throughput gain |
| DAG Tree Resolution Rate (10k nodes) | 1,084.1 jobs/s (9.2s) | 2,640.3 jobs/s (Java 21) / 2,645.1 jobs/s (Java 17) | ~2.4x speedup (4.3s vs 9.2s) |
| Realistic 10ms I/O Throughput | 1,128.7 jobs/s | 2,857.1 jobs/s (Java 21) / 2,924.0 jobs/s (Java 17) | ~2.5x throughput gain |
| Consumer Young Gen GC Allocations | ~1.4 GB / min | ~420 MB / min | ~70% allocation reduction |
| Network Roundtrips per Standard Job | 2 roundtrips (hgetall + claim) | 1 roundtrip (claim only) | 50% network roundtrip cut |
| Lettuce Open Sockets per Worker | 4 TCP connections | 1 shared + 1 blocking | 50% socket reduction |
| Redis Event Stream Size (1M jobs) | ~180 MB (unbounded) | ~1.8 MB (bounded MAXLEN ~ 10000) | 99% RAM savings |
| Completed Jobs Retention RAM | Unbounded (~1.2 GB) | Capped at 1,000 jobs (~1.5 MB) | Deterministic memory footprint |
| Failed Job Stack Trace Peak | Up to 250 KB / failure | Capped at 8 KB / failure | 96% spike suppression |
| Multi-Module Test Suite Duration | 51 seconds | 33 seconds | ~35% faster build verification |
π οΈ Production Memory Tuning Checklist β
- [ ] Set Default Retention: Ensure
oxmq.default-job-options.remove-on-completeis configured so jobs are pruned automatically. - [ ] Bound Event Streams: Keep
oxmq.max-len-eventsat default (10,000) or adjust based on your Bull-Board monitoring window. - [ ] Enable Virtual Threads: Set
oxmq.virtual-threads: trueon Java 21+ to eliminate 1MB platform thread stack memory reservations. - [ ] Configure Redis maxmemory & eviction:text(Always use
maxmemory 4gb maxmemory-policy noeviction # OxMQ handles its own deterministic retention!noevictionfor BullMQ/OxMQ Redis instances so Redis doesn't randomly evict active queues or lock keys). - [ ] Monitor Redis Memory:bash
redis-cli -h localhost -p 6379 info memory # Check 'used_memory_human' and 'mem_fragmentation_ratio'