How to Use JCache (JSR-107) with Valkey or Redis
Caching is one of the highest-leverage changes you can make to a Java application, but it comes with a familiar trap: every cache library has its own API, and migrating from one to another means rewriting code. JCache — the standard Java caching API defined by JSR-107 — was created to solve exactly that. You write against a single, vendor-neutral javax.cache API, and you can swap the implementation underneath without touching your application logic.
The catch is that the implementation underneath matters a great deal. An in-process JCache provider gives you a local cache that disappears on restart and isn't shared between application nodes. Backing JCache with Valkey or Redis turns it into a distributed cache: shared across every instance of your service, durable across restarts and deployments, and able to scale far beyond a single JVM's heap.
In this article, we'll focus on implementing JCache (JSR-107) on Valkey & Redis using Redisson — from a minimal quick start to read-through/write-through integration, event listeners, asynchronous APIs, and production scaling. For a plain-language definition of the specification itself, see our glossary entry on JCache; here we'll concentrate on putting it to work.
Why Run JCache on Valkey & Redis?
The JCache specification deliberately says nothing about where cached entries live. That's left to the provider, and it's the most consequential decision you'll make. A reference or in-heap implementation is fine for a single process, but most real systems run multiple instances behind a load balancer, and they want a cache that all of those instances see consistently.
Running JCache on Valkey or Redis gives you a distributed cache with a standard API on top:
- Shared state. Every node reads and writes the same cache, so a value computed by one instance is instantly available to the others.
- Survives restarts and deploys. Cached data lives in Valkey/Redis, not in a JVM that you're about to redeploy.
- Scales past the heap. Your cache capacity is bounded by your Valkey/Redis cluster, not by a single application's memory.
It's worth being clear about why this needs a specific client. Most Valkey & Redis Java clients — including Jedis and Lettuce — don't implement JCache at all; they expose Redis commands, not the javax.cache API. Redisson is the client that provides a JCache implementation, and it's a TCK-certified one: it passes the full JCache Technology Compatibility Kit, so it behaves exactly as the specification requires. That certification is your guarantee that "standard JCache" really means standard.
Choosing a JCache Provider
JCache has several well-known providers — Ehcache, Hazelcast, Apache Ignite, Infinispan, and Caffeine among them. The practical difference is where the data lives. Caffeine and Ehcache are primarily in-process caches; Hazelcast, Ignite, and Infinispan are data grids with their own clustering to run and operate. Redisson is the provider that backs the standard JCache API with Valkey or Redis — so you get a distributed cache on infrastructure you very likely already run, without standing up a separate grid. If your system of record already sits behind Valkey/Redis, a Redis-backed JCache keeps your caching layer on the same platform.
Prerequisites and Dependencies
You'll need a running Valkey or Redis instance (a local single node is fine for this walkthrough) and a Java project with Redisson on the classpath. Redisson brings in the JSR-107 API (javax.cache:cache-api) transitively, but it's good practice to declare it explicitly.
Maven:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>4.6.1</version> <!-- or the latest release -->
</dependency>
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.1.1</version>
</dependency>
Gradle:
implementation 'org.redisson:redisson:4.6.1' // or the latest release
implementation 'javax.cache:cache-api:1.1.1'
Note that JCache lives in the javax.cache package even on Jakarta EE — JSR-107 was never moved to the jakarta namespace, so this import is correct regardless of your EE version.
Quick Start
The fastest way to see JCache working is to let Redisson use its default configuration. When you don't pass a configuration explicitly, Redisson looks for a redisson-jcache.yaml (or redisson-jcache.json) file on the classpath.
import javax.cache.Cache;
import javax.cache.CacheManager;
import javax.cache.Caching;
import javax.cache.configuration.MutableConfiguration;
MutableConfiguration<String, String> config = new MutableConfiguration<>();
CacheManager manager = Caching.getCachingProvider().getCacheManager();
Cache<String, String> cache = manager.createCache("namedCache", config);
cache.put("user:42", "Ada Lovelace");
String name = cache.get("user:42"); // "Ada Lovelace"
That's the entire JCache lifecycle: obtain a CacheManager from the CachingProvider, create a named Cache, and use it. Nothing in this snippet references Redisson, Valkey, or Redis directly — that's the point of the standard. The only Valkey/Redis-specific part is the configuration, which we'll look at next.
Configuring the Connection
You have two ways to point JCache at your Valkey/Redis deployment: a configuration file loaded by URI, or a programmatic Config object.
File-based configuration. Load any JSON or YAML Redisson config from a URI:
import java.net.URI;
URI configUri = getClass().getResource("redisson-jcache.yaml").toURI();
CacheManager manager = Caching.getCachingProvider()
.getCacheManager(configUri, null);
Cache<String, String> cache = manager.createCache("namedCache",
new MutableConfiguration<String, String>());
A minimal redisson-jcache.yaml for a single node looks like this:
singleServerConfig:
address: "redis://127.0.0.1:6379"
Programmatic configuration. When you'd rather build the connection in code — for example, to inject credentials from your environment — use RedissonConfiguration.fromConfig to bridge a Redisson Config and a JCache configuration:
import org.redisson.config.Config;
import org.redisson.jcache.configuration.RedissonConfiguration;
import javax.cache.configuration.Configuration;
MutableConfiguration<String, String> jcacheConfig = new MutableConfiguration<>();
Config redissonCfg = new Config();
redissonCfg.useSingleServer()
.setAddress("redis://127.0.0.1:6379");
Configuration<String, String> config =
RedissonConfiguration.fromConfig(redissonCfg, jcacheConfig);
CacheManager manager = Caching.getCachingProvider().getCacheManager();
Cache<String, String> cache = manager.createCache("namedCache", config);
The same approach works for every topology Redisson supports — a Valkey/Redis Cluster, Sentinel, replicated, or master/slave setup, as well as managed services like AWS ElastiCache and MemoryDB. You only change the Redisson Config (useClusterServers(), useSentinelServers(), and so on); your JCache code is untouched. The full set of connection options is covered in the Redisson documentation.
Core Operations
Once you have a Cache, the JSR-107 API gives you the operations you'd expect, with atomic variants that map cleanly onto Valkey/Redis:
cache.put("k1", "v1");
String v = cache.get("k1");
boolean added = cache.putIfAbsent("k1", "v2"); // false — already present
String previous = cache.getAndPut("k1", "v3"); // returns "v1"
boolean replaced = cache.replace("k1", "v3", "v4"); // compare-and-set
boolean removed = cache.remove("k1");
cache.putAll(Map.of("a", "1", "b", "2"));
Map<String, String> many = cache.getAll(Set.of("a", "b"));
One spec detail worth knowing: JCache defaults to store-by-value semantics, meaning the cache holds a copy of your object rather than a reference to it. Redisson serializes entries to Valkey/Redis, which naturally satisfies store-by-value and means your cached keys and values need to be serializable by the configured codec.
Expiry Policies
Caches need a way to forget. JCache models this with an ExpiryPolicy, configured when you create the cache. The most common is a time-to-live measured from when an entry is created:
import javax.cache.expiry.CreatedExpiryPolicy;
import javax.cache.expiry.Duration;
import java.util.concurrent.TimeUnit;
MutableConfiguration<String, String> config = new MutableConfiguration<String, String>()
.setExpiryPolicyFactory(
CreatedExpiryPolicy.factoryOf(new Duration(TimeUnit.MINUTES, 10)));
JCache also provides AccessedExpiryPolicy, ModifiedExpiryPolicy, and TouchedExpiryPolicy if you'd rather reset the clock on reads or updates. Redisson enforces expiry on the Valkey/Redis side and cleans up expired entries with a background eviction task, so stale data doesn't accumulate. For more on the trade-offs here, see our note on cache invalidation.
Atomic Updates With EntryProcessor
A common caching pitfall is the read-modify-write race: two nodes read the same value, both modify it locally, and the second write clobbers the first. JCache solves this with EntryProcessor, which runs your logic atomically against a single entry. With Redisson, the processor executes server-side on Valkey/Redis, so the whole operation is atomic across every client:
import javax.cache.processor.EntryProcessor;
import javax.cache.processor.MutableEntry;
long newCount = cache.invoke("page-views", (MutableEntry<String, Long> entry, Object... args) -> {
long current = entry.exists() ? entry.getValue() : 0L;
long updated = current + 1;
entry.setValue(updated);
return updated;
});
This is the JCache-standard way to express counters, conditional updates, and other compound operations without resorting to client-side locking.
Read-Through, Write-Through, and Write-Behind
One of JCache's most useful features is integration with your system of record. Instead of manually checking the cache and falling back to a database, you register a CacheLoader and CacheWriter and let the cache mediate.
-
Read-through: on a cache miss, the cache calls your
CacheLoaderto fetch the value from the database, stores it, and returns it. -
Write-through: every cache write is synchronously persisted by your
CacheWriterbefore the call returns. - Write-behind: writes are persisted asynchronously in batches, trading strict durability for throughput.
import javax.cache.configuration.FactoryBuilder;
MutableConfiguration<Long, User> config = new MutableConfiguration<Long, User>()
.setReadThrough(true)
.setWriteThrough(true)
.setCacheLoaderFactory(FactoryBuilder.factoryOf(new UserCacheLoader()))
.setCacheWriterFactory(FactoryBuilder.factoryOf(new UserCacheWriter()));
Your loader and writer are ordinary JSR-107 components — UserCacheLoader implements CacheLoader<Long, User> and UserCacheWriter implements CacheWriter<Long, User> — so they're portable across any compliant provider. For a deeper look at the durability trade-offs between these strategies, see write-through and write-behind caching.
Reacting to Changes With Event Listeners
JCache can notify your application when entries are created, updated, removed, or expired. Register a listener through the cache configuration:
import javax.cache.configuration.MutableCacheEntryListenerConfiguration;
config.addCacheEntryListenerConfiguration(
new MutableCacheEntryListenerConfiguration<>(
FactoryBuilder.factoryOf(new MyEntryListener()),
null, // optional event filter
false, // old value not required
true)); // synchronous delivery
MyEntryListener implements whichever of the CacheEntryCreatedListener, CacheEntryUpdatedListener, CacheEntryRemovedListener, or CacheEntryExpiredListener interfaces you care about. Because Redisson propagates these events through Valkey/Redis, a change made on one node can be observed by listeners on every node — useful for keeping derived state or local views in sync.
Asynchronous, Reactive, and RxJava3 APIs
The JCache specification is synchronous only. That's often fine, but high-throughput and non-blocking services frequently need more. Redisson extends a JCache Cache with asynchronous, Reactive Streams, and RxJava3 interfaces that you obtain by unwrapping the cache:
import org.redisson.api.CacheAsync;
import org.redisson.api.RFuture;
CacheAsync<String, String> asyncCache = cache.unwrap(CacheAsync.class);
RFuture<Void> future = asyncCache.putAsync("k1", "v1");
future.whenComplete((res, ex) -> { /* ... */ });
The same unwrap pattern gives you CacheReactive and CacheRx for Project Reactor and RxJava3 respectively. This lets you keep the standard JCache API where it's convenient and reach for non-blocking calls on the hot paths that need them — without changing cache providers.
Local Caching for JCache
Even with Valkey/Redis a millisecond away, the fastest read is the one that never leaves the JVM. Redisson PRO adds a near-cache (local cache) layer for JCache: frequently accessed entries are held in application memory and kept consistent with Valkey/Redis through pub/sub invalidation messages, so reads hit local memory while writes still propagate cluster-wide. For read-heavy workloads this serves reads up to 45x faster than a round-trip to the server and takes load off your cluster.
The key point is that enabling it costs you nothing in application code — you swap the configuration object you pass to createCache, and every JCache call stays exactly the same:
import org.redisson.jcache.configuration.LocalCacheConfiguration;
LocalCacheConfiguration<String, String> config = new LocalCacheConfiguration<String, String>()
.setEvictionPolicy(EvictionPolicy.LFU)
.setCacheSize(1000)
.setTimeToLive(48, TimeUnit.MINUTES)
.setMaxIdle(24, TimeUnit.MINUTES);
CacheManager manager = Caching.getCachingProvider().getCacheManager();
Cache<String, String> cache = manager.createCache("namedCache", config);
You can tune the local store's eviction policy (LFU, LRU, SOFT, WEAK, or NONE), size, and synchronization strategy. For the invalidation mechanics behind this, see our article on client-side caching for Valkey and Redis.
Scaling a Single Cache With Data Partitioning
By default, a JCache instance lives on a single Valkey/Redis master, so one cache is bounded by one node's memory and throughput. Redisson PRO's data partitioning spreads a single JCache instance across all the master nodes in a cluster, scaling its memory, read/write operations, and eviction horizontally. As with local caching, this is purely a configuration swap — your JCache code doesn't change:
import org.redisson.jcache.configuration.ClusteredConfiguration;
ClusteredConfiguration<String, String> config = new ClusteredConfiguration<>();
CacheManager manager = Caching.getCachingProvider().getCacheManager();
Cache<String, String> cache = manager.createCache("namedCache", config);
The two features compose: ClusteredLocalCacheConfiguration gives you a partitioned cache and a local near-cache at once. Full configuration options are in the cache API implementations documentation.
Framework and Spring Boot Integration
If you're on Spring Boot, you can wire Redisson's JCache implementation into Spring's caching abstraction by setting a single property:
spring.cache.type=jcache
With that in place, the standard JSR-107 annotations — @CacheResult, @CachePut, @CacheRemove, and @CacheRemoveAll — work against Valkey/Redis, and Spring's own @Cacheable/@CacheEvict annotations are backed by the same store. (Spring's caching abstraction has supported the JSR-107 annotations since version 4.1.) For a broader look at caching options in Spring, see our overview of Spring Cache.
Redisson also provides JCache-friendly integrations for the major microservice frameworks, including Micronaut, Quarkus, and Helidon. A common real-world use of JCache on Valkey/Redis is HTTP session persistence — our guide to Open Liberty and WebSphere Liberty session persistence with JCache and Redis is a worked example.
JCache is also the bridge for using Valkey/Redis as a Hibernate second-level cache: because Hibernate can use any JSR-107 provider via its jcache region factory, pointing that factory at Redisson lets Hibernate's L2 cache live in Valkey/Redis with no custom code.
Production Considerations
A few things are worth setting up before you ship:
- Graceful degradation. Redisson PRO offers a fallback mode for JCache: if Valkey/Redis becomes temporarily unavailable, cache calls don't throw and your application keeps serving requests rather than failing hard. Decide whether that behavior fits your consistency requirements.
-
Eviction strategy. Redisson offers different eviction implementations for JCache — scripted (
MutableConfiguration), native (NativeConfiguration), and advanced (V2Configuration) — with a background scheduler that removes expired entries in batches. The default batch size is tunable if your expiry volume is high. - Observability. Track hit rate, latency, and eviction counts in production. Redisson exposes client metrics and tracing so you can see how the cache behaves under real traffic.
Conclusion
JCache gives you a single, standard caching API for Java; Valkey and Redis give you a distributed store that's shared across nodes and survives restarts. Redisson is what connects them — and, unlike Jedis or Lettuce, it actually implements JSR-107, as a fully TCK-certified provider. You get the entire JCache feature set (expiry policies, EntryProcessor, read-through/write-through, event listeners) plus capabilities the spec never defined: asynchronous and reactive APIs, near-cache acceleration, and horizontal partitioning of a single cache.
The result is a cache you can write once, against a vendor-neutral API, and run anywhere from a single Valkey node to a large Redis cluster.
To go further, browse the Redisson documentation, compare editions on the feature comparison page, or start a free Redisson PRO trial.