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)
IntegerSerializerInteger4
LongSerializerLong8
DoubleSerializerDouble8
FloatSerializerFloat4
ShortSerializerShort2
ByteSerializerByte1
BooleanSerializerBoolean1
CharacterSerializerCharacter2
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:

1

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.

2

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.

3

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.

4

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.

5

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.