Getting Started with Off-Heap Mapish
Learn how to use a high-performance, single-threaded, off-heap hash map built on Java's Foreign Function & Memory API.
1. Prerequisites
Before you begin, make sure you have the following installed:
- Java 21 or later — Off-Heap Mapish uses the Foreign Function & Memory (FFM) API, which became a preview feature in Java 19 and was finalized in Java 22. Java 21+ is the minimum supported version.
- Gradle — The library is distributed via GitHub Packages and uses Gradle for dependency management. Any recent version of Gradle with Kotlin DSL support will work.
You can verify your Java version by running:
java --version
# Expected: openjdk 21.0.x or later
2. Installation
Off-Heap Mapish is published to GitHub Packages. Add the repository and dependency to your
build.gradle.kts:
repositories {
mavenCentral()
maven {
url = uri("https://maven.pkg.github.com/bhf/mapish")
credentials {
username = System.getenv("GITHUB_ACTOR")
password = System.getenv("GITHUB_TOKEN")
}
}
}
dependencies {
implementation("com.bhf.mapish:off-heap-map:1.0-SNAPSHOT")
}
Note: You need a GitHub Personal Access Token (PAT) with the
read:packages scope.
Set the GITHUB_ACTOR and
GITHUB_TOKEN environment variables
before running Gradle.
3. Basic Usage
Creating an OffHeapMap is straightforward.
You provide a key serializer, a value serializer, and an initial capacity:
import com.bhf.mapish.OffHeapMap;
import com.bhf.mapish.serializers.StringSerializer;
// Create an off-heap map of String → String
OffHeapMap<String, String> map = new OffHeapMap<>(
new StringSerializer(100), // key serializer (max 100 bytes per key)
new StringSerializer(100), // value serializer (max 100 bytes per value)
2_000_000 // initial capacity (number of entries)
);
map.put("hello", "world");
String val = map.get("hello"); // returns "world"
System.out.println(map.size()); // 1
Constructor Parameters
| Parameter | Type | Description |
|---|---|---|
| keySerializer | Serializer<K> |
Defines how keys are written to and read from native memory. |
| valueSerializer | Serializer<V> |
Defines how values are written to and read from native memory. |
| capacity | int |
Initial number of slots. Rounded up to the nearest power of 2. The map resizes when 50% full. |
4. Using Different Serializers
Off-Heap Mapish ships with built-in serializers for all common Java types in the
com.bhf.mapish.serializers package:
| Serializer | Java Type | Size (bytes) |
|---|---|---|
| IntegerSerializer | Integer | 4 |
| LongSerializer | Long | 8 |
| DoubleSerializer | Double | 8 |
| FloatSerializer | Float | 4 |
| ShortSerializer | Short | 2 |
| ByteSerializer | Byte | 1 |
| BooleanSerializer | Boolean | 1 |
| CharacterSerializer | Character | 2 |
| StringSerializer(maxBytes) | String | 4 + maxBytes |
Mix and match serializers for any key-value combination:
// Integer keys → Double values
OffHeapMap<Integer, Double> scores = new OffHeapMap<>(
new IntegerSerializer(),
new DoubleSerializer(),
10_000
);
scores.put(42, 99.5);
scores.put(7, 88.3);
// Long keys → String values
OffHeapMap<Long, String> userNames = new OffHeapMap<>(
new LongSerializer(),
new StringSerializer(50),
100_000
);
userNames.put(1001L, "Alice");
userNames.put(1002L, "Bob");
5. Memory Lifecycle — Auto vs Confined
Because OffHeapMap stores data in native memory
(outside the Java heap), you need to think about when that memory gets released. The library offers
two modes:
Auto Mode (default)
Uses Arena.ofAuto() under the hood. The Java garbage collector
reclaims the native memory when the map becomes unreachable — just like any other Java object.
OffHeapMap<String, String> map = new OffHeapMap<>(
new StringSerializer(100),
new StringSerializer(100),
2_000_000
);
map.put("key", "value");
// No explicit closing needed.
// Native memory freed when the GC collects the map.
Confined Mode (deterministic)
Uses Arena.ofConfined() and implements
AutoCloseable. Native memory is released
immediately when the map is closed. Any access after closing throws an
IllegalStateException.
try (OffHeapMap<String, String> map = OffHeapMap.createConfined(
new StringSerializer(100),
new StringSerializer(100),
2_000_000)) {
map.put("key", "value");
String val = map.get("key");
} // Native memory is immediately unmapped and released here
When to Use Which?
| Scenario | Recommended Mode |
|---|---|
| Long-lived map in a service or cache | Auto |
| Quick convenience, prototyping | Auto |
| Large, short-lived map (batch job, import) | Confined |
| Deterministic memory control is required | Confined |
| Risk of native memory exhaustion before GC runs | Confined |
6. Full java.util.Map Contract
OffHeapMap fully implements
java.util.Map<K, V>.
Everything you expect from a standard Java map works here — iteration, views, bulk operations, and more:
put(),get(),remove(),containsKey()entrySet(),keySet(),values()clear(),size(),isEmpty()putAll(),containsValue()- Enhanced for-each loops and Stream API via
entrySet()
Here's a complete iteration example:
OffHeapMap<String, Integer> wordCounts = new OffHeapMap<>(
new StringSerializer(50),
new IntegerSerializer(),
1_000
);
wordCounts.put("hello", 5);
wordCounts.put("world", 3);
wordCounts.put("java", 42);
// Iterate with entrySet()
for (Map.Entry<String, Integer> entry : wordCounts.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
// Use keySet()
for (String key : wordCounts.keySet()) {
System.out.println(key + ": " + wordCounts.get(key));
}
// Stream API
wordCounts.entrySet().stream()
.filter(e -> e.getValue() > 4)
.forEach(e -> System.out.println(e.getKey()));
7. Writing a Custom Serializer
Need to store your own domain types off-heap? Implement the
Serializer<T> interface.
It has four methods:
public interface Serializer<T> {
/** Write the object into the memory segment at the given offset. */
void serialize(T obj, MemorySegment segment, long offset);
/** Read an object back from the memory segment at the given offset. */
T deserialize(MemorySegment segment, long offset);
/** The fixed size in bytes this type occupies in native memory. */
long sizeBytes();
/** Compare an object against the raw bytes without deserializing. */
boolean equals(T obj, MemorySegment segment, long offset);
}
Here's a concrete example — a serializer for a Point
record with x and
y integer coordinates:
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
public record Point(int x, int y) {}
public class PointSerializer implements Serializer<Point> {
// Layout: [4 bytes x] [4 bytes y] = 8 bytes total
@Override
public void serialize(Point p, MemorySegment segment, long offset) {
segment.set(ValueLayout.JAVA_INT, offset, p.x());
segment.set(ValueLayout.JAVA_INT, offset + 4, p.y());
}
@Override
public Point deserialize(MemorySegment segment, long offset) {
int x = segment.get(ValueLayout.JAVA_INT, offset);
int y = segment.get(ValueLayout.JAVA_INT, offset + 4);
return new Point(x, y);
}
@Override
public long sizeBytes() {
return 8; // 4 bytes for x + 4 bytes for y
}
@Override
public boolean equals(Point p, MemorySegment segment, long offset) {
if (p == null) return false;
return p.x() == segment.get(ValueLayout.JAVA_INT, offset)
&& p.y() == segment.get(ValueLayout.JAVA_INT, offset + 4);
}
}
Now use it like any other serializer:
OffHeapMap<Point, String> labels = new OffHeapMap<>(
new PointSerializer(),
new StringSerializer(64),
5_000
);
labels.put(new Point(10, 20), "Origin");
labels.put(new Point(30, 40), "Destination");
System.out.println(labels.get(new Point(10, 20))); // "Origin"
Tip: The equals() method is a performance
hot path. It's called during every get(),
put(), and
remove() operation. Compare raw memory values directly
instead of deserializing first — that's what makes off-heap lookups fast.
8. Performance Tips
Off-Heap Mapish is designed for high throughput with minimal GC pressure. Here are some guidelines to get the best performance out of it:
Pre-size your map
Resizing requires allocating a new contiguous memory segment and rehashing every entry. If you know
(or can estimate) the number of entries, pass that as the capacity
parameter. The internal capacity is rounded up to the nearest power of 2, and the map resizes at a
0.5 load factor — so a capacity of 2,000,000 holds up to 1,000,000 entries before resizing.
Use Confined arenas for large, short-lived maps
If you're allocating millions of entries in a batch job or import pipeline, use
OffHeapMap.createConfined() inside a try-with-resources.
This releases native memory immediately rather than waiting for an unpredictable GC cycle.
Keep StringSerializer maxBytes as small as possible
Every entry in the map reserves a fixed-size slot based on sizeBytes().
For StringSerializer, that's
maxBytes + 4 per key or value. Setting
maxBytes to 1000 when your strings are only 20 bytes
wastes ~980 bytes per entry. Multiply that by millions of entries and the overhead becomes significant.
Null keys and values are not supported
Calling put(null, value) or
put(key, null) throws a
NullPointerException. If you need to represent absence,
use a sentinel value in your domain.
Implement fast equals() in custom serializers
The equals(T obj, MemorySegment, long) method is called
on every probe during linear collision resolution. Comparing raw bytes (or primitive fields directly)
instead of deserializing into a Java object can dramatically reduce allocation pressure and latency.
Ready to go deeper?
Explore how Mapish works under the hood, compare it against other off-heap libraries, or view the benchmarks.