RMCache is an off-heap cache for JVM applications. It stores all key/value data in native memory using the Java FFM API, eliminating GC pressure even when managing gigabytes of data.
Requirements: JDK 25+ (LTS), Gradle 8+
dependencies {
implementation 'com.codeabbot:rmcache:0.0.2'
}<dependency>
<groupId>com.codeabbot</groupId>
<artifactId>rmcache</artifactId>
<version>0.0.2</version>
</dependency>Add to your JVM startup arguments:
--enable-native-access=ALL-UNNAMED
For module-path applications:
--enable-native-access=com.codeabbot.rmcache
Add only what you need — all share the core version:
| Module | Artifact | For |
|---|---|---|
| Micrometer metrics | com.codeabbot:rmcache-micrometer |
Prometheus / Datadog / etc. via Micrometer |
| OpenTelemetry metrics | com.codeabbot:rmcache-opentelemetry |
OTel observable metrics |
| Latency sampling | com.codeabbot:rmcache-metrics |
Per-operation latency (MeteredOffHeapCache) |
| JCache (JSR-107) | com.codeabbot:rmcache-jcache |
Spring Cache / Hibernate L2 |
See Metrics and JCache Provider.
import com.codeabbot.rmcache.CacheBuilder;
import com.codeabbot.rmcache.OffHeapCache;
import com.codeabbot.rmcache.Units;
import com.codeabbot.rmcache.serializer.BuiltInSerializers;
// Build the cache
OffHeapCache<String, byte[]> cache = new CacheBuilder<String, byte[]>()
.maxEntries(100_000)
.offHeapMemory(Units.megabytes(512))
.keySerializer(BuiltInSerializers.STRING_KEY)
.valueSerializer(BuiltInSerializers.byteArray())
.build();
// Put and get
cache.put("user:42", data);
byte[] value = cache.get("user:42"); // null if not found or expired
// Remove
boolean wasPresent = cache.remove("user:42");
// Check existence
boolean exists = cache.contains("user:42");
// Always close (releases native memory)
cache.close();Use try-with-resources:
try (OffHeapCache<String, byte[]> cache = new CacheBuilder<String, byte[]>()
.maxEntries(100_000)
.offHeapMemory(Units.megabytes(512))
.keySerializer(BuiltInSerializers.STRING_KEY)
.valueSerializer(BuiltInSerializers.byteArray())
.build()) {
cache.put("k", value);
byte[] result = cache.get("k");
}
// native memory is freed automaticallyOffHeapCache<String, String> cache = new CacheBuilder<String, String>()
.maxEntries(1_000_000)
.offHeapMemory(Units.gigabytes(2))
.keySerializer(BuiltInSerializers.STRING_KEY_LATIN1) // fastest for ASCII keys
.valueSerializer(BuiltInSerializers.string())
.build();import com.codeabbot.rmcache.eviction.TTLPolicy;
import java.time.Duration;
// Global TTL via eviction policy
OffHeapCache<String, byte[]> cache = new CacheBuilder<String, byte[]>()
.maxEntries(100_000)
.offHeapMemory(Units.megabytes(256))
.keySerializer(BuiltInSerializers.STRING_KEY)
.valueSerializer(BuiltInSerializers.byteArray())
.build();
// Per-entry TTL on put
cache.put("session:abc", sessionData, Duration.ofMinutes(30));
cache.put("token:xyz", tokenBytes, Duration.ofHours(1));byte[] data = cache.computeIfAbsent("user:42", key -> loadFromDatabase(key));Not atomic. Under concurrent access, the loader may be called by multiple threads for the same key. The first result to win
putIfAbsentis returned; others are discarded. Use external per-key locking if duplicate computation is expensive.
import java.util.concurrent.CompletableFuture;
// Non-blocking put
CompletableFuture<Void> put = cache.putAsync("key", value);
// Non-blocking get
CompletableFuture<byte[]> get = cache.getAsync("key");
get.thenAccept(result -> processResult(result));Use a bounded executor to prevent unbounded task queueing at high throughput:
import java.util.concurrent.Executors;
OffHeapCache<String, byte[]> cache = new CacheBuilder<String, byte[]>()
.asyncExecutor(Executors.newFixedThreadPool(4))
.forByteArrayValues()
.build();// Put multiple entries
Map<String, byte[]> batch = Map.of("k1", v1, "k2", v2, "k3", v3);
cache.putAll(batch);
cache.putAll(batch, Duration.ofMinutes(10)); // with TTL
// Get multiple entries (only found entries are returned)
Map<String, byte[]> results = cache.getAll(List.of("k1", "k2", "missing"));MemoryEstimator.MemoryEstimate estimate = new CacheBuilder<String, byte[]>()
.maxEntries(1_000_000)
.averageKeySize(16)
.averageValueSize(256)
.estimateMemory();
System.out.printf("Total: %d MB%n", estimate.totalBytes() / 1_048_576);
System.out.printf("Per entry: %d bytes%n", estimate.bytesPerEntry());// Constrain index memory to 15% of off-heap pool
new CacheBuilder<String, byte[]>()
.maxEntries(1_000_000)
.offHeapMemory(Units.gigabytes(8))
.indexMemoryBudgetPercent(0.15)
.forByteArrayValues()
.build();| Metric | Typical Range |
|---|---|
| Entry overhead (header + index) | 28–50 bytes |
| Hash table overhead | ~8 bytes/slot at 60% load factor |
| LRU metadata | 8 bytes/entry |
| Frequency sketch | ~1 MB fixed (capped at 16M entries) |
# Minimal heap — native memory handles the data
java -Xmx256m \
--enable-native-access=ALL-UNNAMED \
-jar myapp.jarFor GC visibility of native memory allocations, add (optional):
-XX:+PrintGCDetails -Xlog:gc*- Eviction Policies — LRU, TTL, composite policies, custom filters
- Custom Serialization — zero-copy serialization for custom types
- Zero-Copy Access — large value access without heap allocation
- Heap Profile — understanding and minimizing heap usage
- Architecture Deep Dive — full configuration reference