Hazelcast in Java: A Distributed In-Memory Grid for Spring Boot Applications
A practical, code-first tour of Hazelcast in Java — the in-memory data grid that doubles as a cache, a coordination layer, and a distributed compute engine for Spring Boot applications. Covers IMap, EntryProcessor, MapStore, Near Cache, and when to reach for it.
Most caches are a box you talk to over the network. Hazelcast is the network. That distinction is the whole point — and the whole pitch of this post.
What Hazelcast Actually Is
Most teams reach for a cache because the database is too slow for the read traffic. They install Redis, mark a few methods @Cacheable, and the dashboard goes green. That works fine — until you need more than caching. Until you need a counter that two services have to agree on. A queue you do not want to lose if a node restarts. A way to lock a record across the cluster while a long-running job touches it. The moment you need any of those, the simple cache stops being enough.
Hazelcast is what you get when you take "cache" and ask: what if the cache is the cluster? It is an in-memory data grid (IMDG) — a peer-to-peer network of JVMs that share data, run code together, and quietly handle the partitioning, replication, and failover. You get a distributed Map, a distributed Queue, a distributed Lock, an ExecutorService that runs tasks on the node where the data already lives, and Spring Boot integration that makes most of it look like ordinary caching.
This is a tour. We will go from "embed it in your Spring Boot app and use it as a cache" through to "use it as the coordination layer for a distributed system." Code first, opinions where they earn their keep.
Embedded vs Client-Server — Pick One Early
Hazelcast runs in two shapes, and the choice colours everything else.
Embedded mode. Hazelcast lives inside your application JVM. Each instance of your service is also a Hazelcast member, and they form a cluster automatically over the network. Co-location means data lookups are often a local memory access — no network hop. The trade-off: you cannot scale your data grid independently of your application. If your service scales from 3 to 30 nodes, your grid does too.
Client-server mode. Hazelcast runs as its own cluster of dedicated JVMs. Your application talks to it over a thin client. This is closer to how teams use Redis. You can size the data tier separately, restart application nodes without losing data, and run a polyglot stack against a single grid.
For most Spring Boot teams shipping a single Java service, embedded mode is the simplest entry. For multi-language stacks or where ops wants the cache as a separate tier, client-server is the right call.
Setting Up Hazelcast in Spring Boot
Spring Boot has built-in support — adding the dependency is enough for an embedded cluster on the classpath.
<!-- pom.xml -->
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast-spring</artifactId>
<version>5.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
A minimal config — you can drop a hazelcast.yaml on the classpath, or build the config from Java.
// src/main/resources/hazelcast.yaml
hazelcast:
cluster-name: orders-cluster
network:
port:
auto-increment: true
port: 5701
join:
multicast:
enabled: false
tcp-ip:
enabled: true
member-list:
- 10.0.0.10
- 10.0.0.11
- 10.0.0.12
map:
products:
time-to-live-seconds: 300
max-size:
policy: PER_NODE
max-size: 10000
eviction:
eviction-policy: LRU
Or in Java, if you prefer:
@Configuration
public class HazelcastConfig {
@Bean
public Config hazelcastConfig() {
Config config = new Config();
config.setClusterName("orders-cluster");
MapConfig productsMap = new MapConfig("products")
.setTimeToLiveSeconds(300)
.setEvictionConfig(new EvictionConfig()
.setEvictionPolicy(EvictionPolicy.LRU)
.setMaxSizePolicy(MaxSizePolicy.PER_NODE)
.setSize(10_000));
config.addMapConfig(productsMap);
return config;
}
}
Add @EnableCaching to your application class and Spring will use Hazelcast for @Cacheable automatically.
@SpringBootApplication
@EnableCaching
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication.run(OrdersApplication.class, args);
}
}
The Distributed Data Structures You Get
Hazelcast's value comes from the building blocks. They look like familiar Java APIs, but their state is shared across the cluster, partitioned for scale, and replicated for failover. Here are the ones you will reach for often.
IMap — The Distributed HashMap
IMap is the workhorse. It looks like a ConcurrentHashMap, but its keys are partitioned across the cluster, with each partition replicated to one or more backup nodes.
@Service
public class ProductCatalog {
private final HazelcastInstance hazelcast;
public ProductCatalog(HazelcastInstance hazelcast) {
this.hazelcast = hazelcast;
}
public Product get(String productId) {
IMap<String, Product> map = hazelcast.getMap("products");
return map.get(productId);
}
public void put(String productId, Product product) {
IMap<String, Product> map = hazelcast.getMap("products");
map.put(productId, product);
}
public Product getOrLoad(String productId) {
IMap<String, Product> map = hazelcast.getMap("products");
return map.computeIfAbsent(productId, this::loadFromDatabase);
}
private Product loadFromDatabase(String productId) {
// hits the DB
return null;
}
}
IMap also gives you predicates, indexes, and entry listeners for free. You can map.values(Predicates.equal("category", "books")) and Hazelcast pushes the predicate to every partition in parallel.
IQueue, ITopic — Cluster-Wide Messaging
Need a queue that survives a node restart and gets consumed by whichever member happens to be alive? IQueue is a distributed BlockingQueue.
IQueue<OrderEvent> queue = hazelcast.getQueue("order-events");
queue.put(new OrderEvent(orderId, "PLACED")); // producer
OrderEvent next = queue.take(); // consumer (any node)
For broadcast — every node hears every message — use ITopic:
ITopic<CacheInvalidate> topic = hazelcast.getTopic("cache-invalidations");
topic.addMessageListener(message -> {
String key = message.getMessageObject().getKey();
localCache.remove(key);
});
topic.publish(new CacheInvalidate("product:123"));
IAtomicLong, FencedLock — Coordination Primitives
Two services need to agree on the next sequence number. Or one of them needs an exclusive lock on a record while it does a long-running operation. Hazelcast gives you the same primitives you would use in a single JVM, but cluster-wide.
// Cluster-wide counter
IAtomicLong sequence = hazelcast.getCPSubsystem().getAtomicLong("invoice-seq");
long next = sequence.incrementAndGet();
// Cluster-wide lock (with timeout — never use the unbounded version in production)
FencedLock lock = hazelcast.getCPSubsystem().getLock("order:" + orderId);
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
// do the thing only one cluster member should be doing
} finally {
lock.unlock();
}
}
The CPSubsystem uses the Raft consensus algorithm — these primitives are correct across network partitions, at the cost of needing a quorum. Use them where correctness matters; the AP versions of the same APIs are faster but eventually consistent.
MultiMap and ReplicatedMap
MultiMap stores multiple values per key — useful for tag indexes or one-to-many lookups. ReplicatedMap stores the entire dataset on every node — perfect for tiny, very-frequently-read reference data, since every read is a local memory hit.
MultiMap<String, String> tagsByPost = hazelcast.getMultiMap("post-tags");
tagsByPost.put("post-1", "java");
tagsByPost.put("post-1", "hazelcast");
Collection<String> tags = tagsByPost.get("post-1"); // [java, hazelcast]
ReplicatedMap<String, String> countryByCode = hazelcast.getReplicatedMap("countries");
countryByCode.put("US", "United States"); // copied to every node
Spring @Cacheable Backed by Hazelcast
The thing most teams use first is also the simplest: annotate methods, let Spring do the work, treat Hazelcast as a drop-in cache.
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
@Cacheable(value = "products", key = "#productId")
public Product getProduct(String productId) {
log.info("Loading product {} from DB", productId);
return repository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
}
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return repository.save(product);
}
@CacheEvict(value = "products", key = "#productId")
public void deleteProduct(String productId) {
repository.deleteById(productId);
}
@CacheEvict(value = "products", allEntries = true)
public void clearAll() {
// wipes the products cache cluster-wide
}
}
What is different from a Redis-backed cache: when one node updates the product, every other node sees it instantly because the underlying IMap is the same distributed object. There is no "cache invalidation across services" problem to solve — the cache is shared.
Near Cache — When Reads Are Most of the Traffic
Even an in-memory grid pays a small cost when the data lives on a different partition. For very hot keys — a config table, a feature-flag map — the network hop adds up. Near Cache solves this by keeping a local copy of recently accessed entries, with the grid pushing invalidations when the source changes.
NearCacheConfig nearCacheConfig = new NearCacheConfig()
.setName("default")
.setInMemoryFormat(InMemoryFormat.OBJECT)
.setMaxIdleSeconds(60)
.setEvictionConfig(new EvictionConfig()
.setSize(1000)
.setEvictionPolicy(EvictionPolicy.LRU));
MapConfig featureFlags = new MapConfig("feature-flags")
.setNearCacheConfig(nearCacheConfig);
Reads now come straight from local memory after the first hit. Stale-data risk is bounded by the invalidation push and the max-idle. The right setting for "how stale is too stale" is a product question, not a technology one — pick based on how the data behaves, not the framework default.
MapStore — Read-Through and Write-Through to the Database
Sometimes you want the grid to be the access layer. The application calls map.get(id), and if the entry is not in memory, Hazelcast fetches it from the database, returns it, and caches it. On map.put(id, value) Hazelcast persists to the database for you. That is MapStore.
public class ProductMapStore implements MapStore<String, Product> {
private final JdbcTemplate jdbc;
public ProductMapStore(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Product load(String key) {
return jdbc.queryForObject(
"SELECT id, name, price FROM products WHERE id = ?",
new ProductRowMapper(),
key);
}
@Override
public Map<String, Product> loadAll(Collection<String> keys) {
// batch load
return jdbc.query(
"SELECT id, name, price FROM products WHERE id IN (...)",
new ProductRowMapper())
.stream()
.collect(Collectors.toMap(Product::getId, p -> p));
}
@Override
public void store(String key, Product value) {
jdbc.update(
"INSERT INTO products(id, name, price) VALUES(?, ?, ?) " +
"ON CONFLICT (id) DO UPDATE SET name = ?, price = ?",
value.getId(), value.getName(), value.getPrice(),
value.getName(), value.getPrice());
}
@Override
public void delete(String key) {
jdbc.update("DELETE FROM products WHERE id = ?", key);
}
}
Wired up in config:
MapStoreConfig mapStoreConfig = new MapStoreConfig()
.setEnabled(true)
.setImplementation(productMapStore)
.setWriteDelaySeconds(0); // 0 = synchronous write-through; >0 = write-behind batched
config.getMapConfig("products").setMapStoreConfig(mapStoreConfig);
Write-behind (writeDelaySeconds > 0) batches writes for throughput. It also means a node crash can lose writes that have not been flushed yet — explicit trade-off, document it where the team will see it.
EntryProcessor — Updates That Stay on the Data
The natural way to update an entry is "get, modify, put". In a distributed system this is two network round-trips and a window where two clients can clobber each other's changes. EntryProcessor sends the code to the partition that owns the data and runs it there, atomically.
public class IncrementStockProcessor implements EntryProcessor<String, Product, Integer> {
private final int delta;
public IncrementStockProcessor(int delta) { this.delta = delta; }
@Override
public Integer process(Map.Entry<String, Product> entry) {
Product product = entry.getValue();
if (product == null) return 0;
product.setStock(product.getStock() + delta);
entry.setValue(product);
return product.getStock();
}
}
// Usage
IMap<String, Product> products = hazelcast.getMap("products");
Integer newStock = (Integer) products.executeOnKey("sku-42", new IncrementStockProcessor(-1));
One round-trip, no race, no manual lock. For aggregations — "increment this counter on every entry that matches this predicate" — you would use executeOnEntries(processor, predicate) and Hazelcast runs the processor in parallel across all partitions.
Distributed Compute with IExecutorService
The grid does not just store data — it also runs code. IExecutorService is a cluster-aware ExecutorService that can submit a task to a specific member, to all members, or, most usefully, to "the member that owns this key" so the task runs on the data, not over the network.
IExecutorService executor = hazelcast.getExecutorService("default");
// Run on a specific key's owner — task arrives where the data already lives
Future<Integer> future = executor.submitToKeyOwner(new InventoryCheck("sku-42"), "sku-42");
// Or run on every member and aggregate
Map<Member, Future<Long>> results = executor.submitToAllMembers(new RowCountTask());
long total = results.values().stream()
.mapToLong(this::getQuietly)
.sum();
The task class needs to be on every member's classpath. For ad-hoc analytics or scheduled work that needs to scan a lot of data, this pattern beats pulling it all back to one node and processing centrally.
Production Considerations
Cluster discovery. Multicast works on a laptop and almost nowhere else in production. Use TCP/IP with an explicit member list, or for cloud deployments use the Kubernetes / AWS / Azure discovery plugins, which let members find each other through the platform's own service discovery.
Backups. By default IMap keeps one synchronous backup of every partition on a different member. For data you cannot afford to lose, raise backup-count to 2 — every partition is then on three nodes, and you can lose two without data loss. The cost is more memory and slightly slower writes.
Split-brain. If the network partitions and the cluster splits, both halves keep accepting writes. Hazelcast detects the split when connectivity returns and runs a merge policy to reconcile — pick one explicitly (LATEST_UPDATE, HIGHER_HITS, or a custom policy) rather than relying on the default. For data that must be consistent across partitions, use the CP subsystem.
Memory management. Off-heap (HD-Memory) lets you store data outside the Java heap so the GC has nothing to scan. For multi-gigabyte caches this is the difference between a snappy cluster and one that pauses for a second every minute.
Observability. Hazelcast publishes JMX metrics out of the box, and the Management Center gives you a UI for cluster health. Wire both into your monitoring; do not learn about a slow partition from a customer.
When to Reach For Hazelcast
Hazelcast earns its place in the toolbox when the problem is not just "make the database faster". You want it when you need:
- A cache that is also the source of truth for ephemeral state (sessions, leader election state, in-flight workflow state).
- Cluster-wide coordination — distributed locks, atomic counters, message broadcast — without standing up a separate ZooKeeper or etcd.
- Compute on data that is too large to ship around — sending the function to the data, not the data to the function.
- An embedded grid where each application instance is also a grid member, and you want sub-millisecond reads with no separate cache tier to operate.
If you only need a key-value cache and your team already runs Redis, Hazelcast may be more grid than the problem deserves. The opposite is also true: a team that picked Redis years ago and now sprinkles in distributed locks, queues, and Lua scripts to compensate for what Redis is not — that team often has a Hazelcast-shaped problem they are solving the hard way.
A Last Word
Pick the tool that matches the shape of the problem. The first time I used Hazelcast, I picked it because the official tutorial was friendly and the embedded mode meant one less thing to operate. The second time I used it, I picked it because we had three services trying to coordinate on a shared workflow and I was tired of inventing protocols on top of HTTP. The honest pattern: a cache that grows up into a coordination layer is exactly what the IMDG was designed for.
Frequently Asked Questions
Is Hazelcast just a Redis competitor?
It overlaps in caching, but it is a different category of tool. Redis is a remote key-value server with rich data types and a single-threaded core. Hazelcast is a distributed in-memory grid that runs as part of your JVMs (or as a dedicated cluster), supports parallel compute, and gives you Java-shaped concurrency primitives across the cluster. For pure caching they look similar; for distributed coordination, compute-with-data, and embedded scenarios, they are not equivalent.
Should I run Hazelcast in embedded or client-server mode?
Embedded is simpler operationally — your service and the grid scale together, deploy together, fail together. Client-server is right when ops wants to size the data tier separately, when application restarts cannot also be cache restarts, or when the grid is shared across multiple polyglot services. Most single-Java-service teams start embedded and move to client-server only if those constraints show up.
How is Hazelcast different from a JCache or Caffeine?
Caffeine is a fast local cache — one JVM, no replication, no cluster awareness. JCache (JSR 107) is a standard cache API that several products (including Hazelcast and Caffeine) implement. Hazelcast is the distributed system underneath, with cluster membership, partitioning, replication, and the rest. If your problem fits in one JVM, Caffeine is faster and simpler. If you need data shared across instances, Hazelcast is the bigger answer to the bigger problem.
What about Hazelcast Jet for stream processing?
Jet has been folded into the core product. The same nodes that hold your IMap can run a streaming pipeline that reads from a Kafka topic, joins against the map, and writes to another IMap — all without leaving the grid. For event-driven enrichment with low-latency state lookups, Jet is one of the more elegant answers in the JVM ecosystem.
Will Hazelcast survive my whole cluster restarting?
Out of the box, the data lives in memory and a full cluster restart loses it. To survive a full restart, configure Persistence (formerly Hot Restart Store) — entries are mirrored to local disk on each member, so when the cluster comes back, members reload their partitions and join with their data intact. For workloads that cannot tolerate any data loss, combine that with a MapStore writing through to a database — local persistence for fast recovery, the database for the durable record of truth.