sun.misc.Unsafe Memory Access Is Going Away: Find and Fix It in Your Dependencies
JDK 25 already prints a warning when a library calls sun.misc.Unsafe memory-access methods, and JEP 471 plans for them to throw and then disappear. What each –sun-misc-unsafe-memory-access value does on JDK 25, how jdeps, a bytecode scan and JFR find the callers in real jars (Netty, Guava, Kryo, Hadoop, Cassandra, Protobuf and more), and CAS, off-heap and byte[] code migrated to VarHandle and the FFM API with a before/after equivalence test.
You upgrade to JDK 25, the service starts, the tests pass, and then stderr carries four lines that name a class in a jar you have never opened: “A terminally deprecated method in sun.misc.Unsafe has been called.” Nothing is broken yet. But those four lines are the first visible step of the sun.misc.Unsafe deprecation, and the plan behind them ends with the methods being removed and, one step earlier, throwing UnsupportedOperationException by default. This article shows what each step looks like on a real JDK 25, how to find out which of your dependencies are responsible (with three tools that disagree with each other for good reasons), and how to replace the calls in your own code with VarHandle and the Foreign Function & Memory API, with a before/after check that proves the swap changed nothing.
Versions. Everything ran on Temurin 25.0.5+8 (LTS). For the “before” picture I used Ubuntu OpenJDK 21.0.12.1; for the “has the default changed yet?” question I also ran Temurin 26.0.2.1+1 and 27.0.1.0.2+1. The change itself is JEP 471 (deprecation for removal, JDK 23) plus JEP 498 (run-time warning, JDK 24), both listed as Closed / Delivered on openjdk.org. Library versions are the latest non-milestone entries in each artifact’s maven-metadata.xml on 2026-11-06, every jar sha1-checked against Maven Central.
What the warning looks like, and why it only names one caller
Start with the smallest possible Unsafe user: allocate eight bytes off-heap, write a long, read it back, free it. Probe does that twice. Source: Probe.java, and UnsafeAccess.java for the reflective theUnsafe lookup that practically every library uses to get an instance. On JDK 25, with no flags:
$ java -cp build Probe (JDK 25 default)
got Unsafe instance
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by Probe (file:<build>/c25/)
WARNING: Please consider reporting this to the maintainers of class Probe
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
read back 42
allocate/put/get/free all succeeded
second use read 7
That is output 03 from output/03-flag-values-jdk25.txt. Eight Unsafe calls ran, one warning appeared, and it blames allocateMemory because that was the first. This is the default warn mode: JEP 471 says it issues “a warning on the first occasion that any memory-access method is used”, so “at most one warning is issued regardless of which memory-access methods are used”. If five libraries use Unsafe, you see one of them. Fixing that one and rerunning reveals the next. The same file shows the mode that fixes this, debug, which prints a line and a stack trace for every call:
$ java --sun-misc-unsafe-memory-access=debug -cp build Probe
got Unsafe instance
WARNING: sun.misc.Unsafe::allocateMemory called by Probe (file:<build>/c25/)
at Probe.main(Probe.java:12)
WARNING: sun.misc.Unsafe::putLong called by Probe (file:<build>/c25/)
at Probe.main(Probe.java:13)
The flag does not exist on JDK 21. The same class on JDK 21 runs silently, and adding the flag does not merely do nothing, it stops the JVM from starting: Unrecognized option: --sun-misc-unsafe-memory-access=deny followed by Could not create the Java Virtual Machine (output/04). If one shared JVM-options file feeds both a JDK 21 and a JDK 25 environment, the flag breaks the older one. Put it only where the JDK is 23 or newer.
All four flag values, side by side (and the system property)
The JEP defines four values. The table has the behaviour I observed on JDK 25 in output/03.
Value
What Probe did on JDK 25
Default in, per JEP 471/498
allow
all eight calls succeeded, no output
JDK 23
warn
one four-line warning, all calls succeeded
JDK 24
debug
a warning plus stack trace per call, all calls succeeded
not a default
deny
UnsupportedOperationException: allocateMemory on every attempt
“JDK 26 or later”
The JVM also publishes the chosen value as the system property sun.misc.unsafe.memory.access; with no flag it is null. A library can read it. That turns out to matter in the Netty section below.
What the JEPs promise, and what I measured
JEP 471 splits the 79 memory-access methods into on-heap (field offsets, array elements), off-heap (raw addresses) and “bimodal” (an Object parameter that is either a heap object or null for off-heap). It then lays out five phases, each in its own JDK release: deprecate for removal; warn at run time; throw by default; remove the on-heap methods; remove the off-heap and bimodal methods. The on-heap methods go first because standard replacements have existed since JDK 9; the others have only had them since JDK 22.
The picture is the argument: the first two phases are behind us, phase 3 is the one that breaks applications, and the JEP schedules it for “JDK 26 or later”, not for a named release. I ran the same Probe classes on Temurin 26.0.2.1 and 27.0.1.0.2 and both still print the warn text by default (output/04):
$ java -cp build Probe (openjdk version "27.0.1.0.2" 2026-10-21)
got Unsafe instance
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by Probe (file:<build>/c25/)
WARNING: Please consider reporting this to the maintainers of class Probe
I looked for a JEP or release page that names the release for phase 3 and did not find one; the project pages I could fetch for JDK 26 and 27 were stale snapshots, so treat the measurement, not my reading of a schedule, as the evidence. JEP 471’s text was last updated 2026/01/09 and still says “JDK 26 or later”. Practically: warnings today, a flag flip whenever phase 3 lands.
What deny does to code that calls Unsafe
deny is the phase-3 behaviour, available now. Every memory-access call, direct or reflective, throws UnsupportedOperationException with the method name:
$ java --sun-misc-unsafe-memory-access=deny -cp build Probe
got Unsafe instance
FAILED: java.lang.UnsupportedOperationException: allocateMemory
second use FAILED: java.lang.UnsupportedOperationException: allocateMemory
Getting the Unsafe object still works, so a library that only checks “can I get one?” proceeds happily and fails on first real use. The worse shape is a library that touches Unsafe in a static initializer. My own UnsafeFieldCounter reads objectFieldOffset in a static {} block, so under deny the first reference to the class fails like this (output/09):
$ java --sun-misc-unsafe-memory-access=deny -cp build Equivalence (the Unsafe version under deny)
Exception in thread "main" java.lang.ExceptionInInitializerError
at Equivalence.main(Equivalence.java:25)
Caused by: java.lang.UnsupportedOperationException: objectFieldOffset
Run your test suite once with deny. It is the cheapest way to learn what phase 3 will do to you, and it fails loudly on exactly the code paths your tests exercise. JEP 471 also states that in phase 3 you will be able to go back from deny to warn, but “it will not be possible to use allow to avoid the warning”. The silent setting has a shelf life.
Compile time: your own code announces itself
For code you compile, javac on JDK 25 emits a [removal] warning per deprecated call, on top of the older “internal proprietary API” warning that JEP 471 says “cannot be suppressed”. JDK 21 emits only the old one:
broken/Probe.java:12: warning: [removal] allocateMemory(long) in Unsafe has been deprecated and marked for removal
broken/Probe.java:13: warning: [removal] putLong(long,long) in Unsafe has been deprecated and marked for removal
14 warnings
6 warnings
The first two lines and “14 warnings” are the JDK 25 compile; the last line is the JDK 21 compile of the same two files. Full text is in output/02-compile-warnings.txt, including -Xlint:removal -Werror, which turns the same calls into a failed build with exit code 1. That is the right CI gate for code you own, and it does nothing for code in jars.
Finding Unsafe in your dependencies: three tools, three blind spots
Jars are the real problem, because the compiler never sees them. Three techniques exist and each is wrong in a different direction:
The diagram says what to do with disagreement: a mismatch between the tools is information about how the library uses Unsafe. Now each tool on real jars.
jdeps: cheap, coarse, and its advice is stale
jdeps --jdk-internals reports every class that references sun.misc.Unsafe. On netty-common-4.1.140.Final.jar it lists 27 classes, including Netty’s shaded copy of JCTools. Its footer is the surprise (output/07):
JDK Internal API Suggested Replacement
---------------- ---------------------
sun.misc.Unsafe See https://openjdk.org/jeps/260
JEP 260, “Encapsulate Most Internal APIs” (JDK 9), is the proposal that kept “critical internal APIs” such as sun.misc.Unsafe accessible. It is not a guide to replacing Unsafe; JEP 471’s replacement table is. The same footer appears when you run jdeps on your own classes (output/05).
A bytecode scan: which methods, how many call sites
jdeps cannot tell a class that stores an Unsafe from one that calls putOrderedLong in a hot loop. ScanJar.java reads class files with the java.lang.classfile API (final in JDK 24), asks the running JDK which Unsafe overloads carry @Deprecated(forRemoval = true), and counts matching call instructions. I ran it over 17 jars from Maven Central via scan-libs.sh. The numbers below are in output/06:
Library (jar scanned)
Classes calling a removal-deprecated method
Call sites
netty-common 4.2.20.Final
21
125
netty-common 4.1.140.Final
21
122
netty-common 4.1.100.Final
14
107
protobuf-java 4.36.2
4
210
cassandra-all 5.0.9
6
152
jctools-core 4.0.7
57
136
kryo 5.7.1
15
100
spark-unsafe_2.13 4.3.0
1
35
guava 32.1.3-jre
9
34
guava 33.8.0-jre
5
22
hadoop-common 3.3.6 and 3.5.0
1 each
4 each
lucene-core 9.12.3
0 (one class mentions the name sun.misc.Unsafe)
0
snappy-java 1.1.10.12
0 (one class mentions the name)
0
lucene-core 10.6.0, agrona 2.7.1, caffeine 3.3.1
0, and no reference at all
0
Two trends are visible without interpretation: Netty is not shrinking (the newest 4.2 jar has more call sites than the newest 4.1 jar, which has more than 4.1.100), Guava’s count fell between 32.1.3-jre and 33.8.0-jre, and Lucene, Agrona and Caffeine show no calls in the versions I scanned. Which class in each jar is in the accordion below. What the scan cannot see: reflective calls (Method.invoke on a name held in a string), other jars shaded into the one scanned, and transitive dependencies I did not fetch. Hadoop is one class because I scanned only hadoop-common; I did not scan HDFS, YARN or the shaded third-party jars.
Which classes make the calls (Guava, Hadoop, Cassandra, Protobuf, Kryo, Spark)
From output/11-scan-callers.txt, which has every class and its call count:
The names say what each is for: byte comparison, hashing, future state, off-heap memory utilities, serialization schemas. Kryo’s calls are in its unsafe package (UnsafeInput, UnsafeOutput, UnsafeUtil) and UnsafeField serializers, and I did not run Kryo, so I cannot say which of those paths a default application takes. The single mention in lucene-core-9.12.3 is in MappedByteBufferIndexInputProvider and in snappy-java it is DirectByteBuffers$1; both hold the class name but call no removal-deprecated method directly, and I did not trace them further.
Run time: Netty and Guava, same JDK, different stories
A static count says “has calls”. Only a run says “uses them”. NettyProbe.java allocates one direct ByteBuf, writes a long and reads it back. With Netty 4.1.140.Final on JDK 25 the default run warns, and names a Netty class, not the application:
$ java -cp netty-4.1.140.Final NettyProbe (JDK 25 default)
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::allocateMemory has been called by io.netty.util.internal.PlatformDependent0$2 (file:<lib>/netty-common-4.1.140.Final.jar)
WARNING: Please consider reporting this to the maintainers of class io.netty.util.internal.PlatformDependent0$2
WARNING: sun.misc.Unsafe::allocateMemory will be removed in a future release
netty sees Unsafe: true
direct=true hasMemoryAddress=true readLong=cafebabe
Under deny the same program does not fail. Netty notices, switches Unsafe off, and carries on:
So for a well-behaved library deny means a silent change of code path, not an exception; whether that costs you speed I did not measure. The newer line is more cautious. Netty 4.2.20.Final on JDK 25 with no flag already reports netty sees Unsafe: false, and only allow flips it to true (output/08). I did not dig into why. What I can say is that the class PlatformDependent0 in both versions contains the string sun.misc.unsafe.memory.access, which is the system property from the first section, so Netty reads the JVM’s setting. One oddity worth knowing: with Netty 4.1.140.Final, debug also gave false, so on this version debug will not show Netty’s Unsafe calls at all, while warn did.
Guava makes the opposite point. The scan finds Unsafe call sites in both versions, but the run (output/12, GuavaProbe.java) uses three features with an Unsafe fast path: hashing, byte-array comparison, and a settable future. Guava 32.1.3-jre warns on JDK 25 and picks its UnsafeComparator; under deny it switches to PureJavaComparator and the output is identical. Guava 33.8.0-jre prints no warning even under debug, and its comparator is ArraysCompareUnsignedComparator:
Presence is not use. The scan reported Unsafe calls in Guava 33.8.0-jre, yet three representative features never invoked them on JDK 25. Conversely a warning on warn proves a code path ran but not that it is the only one. Treat the static scan as “where to look” and the run as “what happens”, and when they disagree, read the library’s release notes before upgrading in a panic.
JFR: finding callers even with allow
JEP 471 documents one more tool: when JDK Flight Recorder is started on the command line, a jdk.DeprecatedInvocation event is recorded for each call to a terminally deprecated method. It works with =allow, meaning the application stays silent while you collect evidence:
That is an excerpt of output/10. The full output shows four events, one per deprecated method, with the first call site of each (lines 12 to 15); lines 21 to 24, which call the same four methods again, produced none. So JFR tells you which methods and where first, not how often. JEP 471 notes the event is not recorded when JFR is started later with jcmd or the API.
The scanner, and what “removal-deprecated” means on each JDK
The heart of ScanJar is one condition. Source: ScanJar.java.
if (el instanceof InvokeInstruction ii && ii.owner().asInternalName().equals(UNSAFE)) {
ref = true;
String key = ii.name().stringValue() + ii.typeSymbol().descriptorString();
if (removal.contains(key)) { calls++; methods.merge(ii.name().stringValue(), 1, Integer::sum); }
The removal set comes from reflection on the JDK that runs the scanner, so the classification is the JDK’s own, not mine. ListUnsafe.java prints it (output/01):
== openjdk version "25.0.5" 2026-10-20 LTS
java.version=25.0.5
public methods declared by sun.misc.Unsafe: 86
deprecated for removal: 83
not deprecated for removal: 3
allocateInstance
pageSize
throwException
JDK 21 reports 88 public methods and only 2 deprecated for removal; JDK 26 and 27 report the same 86 / 83 / 3 as 25. The three survivors are exactly the three JEP 471 names under “Future Work”: pageSize and throwException, which it says will be deprecated and removed separately, and allocateInstance, “the only method in sun.misc.Unsafe in the medium term” because some serialization libraries use it. Two caveats on the counts. Unsafe.getUnsafe() exists in the JDK 25 class (javap shows it) but reflection does not list it, so it is outside these numbers; the scan reports it separately as “other” in Guava and Cassandra. And the JEP speaks of 79 memory-access methods out of 87, while reflection here finds 83 deprecated for removal; I did not reconcile the difference (the fences and similar non-memory methods are a possible cause, unverified).
Replacing the calls: a CAS field becomes a VarHandle
Now the part you do when the Unsafe is in your own code. The most common use is a compare-and-swap on a field. Before, in UnsafeFieldCounter.java:
private static final Unsafe U = UnsafeAccess.get();
private static final long VALUE;
static {
try { VALUE = U.objectFieldOffset(UnsafeFieldCounter.class.getDeclaredField("value")); }
catch (NoSuchFieldException e) { throw new ExceptionInInitializerError(e); }
}
private volatile long value;
public boolean cas(long expect, long update) { return U.compareAndSwapLong(this, VALUE, expect, update); }
public long getAndAdd(long delta) { return U.getAndAddLong(this, VALUE, delta); }
private static final VarHandle VALUE;
static {
try { VALUE = MethodHandles.lookup().findVarHandle(VarHandleFieldCounter.class, "value", long.class); }
catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
}
private volatile long value;
public boolean cas(long expect, long update) { return VALUE.compareAndSet(this, expect, update); }
public long getAndAdd(long delta) { return (long) VALUE.getAndAdd(this, delta); }
The structure is identical: a static handle, resolved once, and the field itself is named by string. VarHandle has existed since JDK 9, so this migration works on every JDK you are likely to run, including 21, and it removes the Unsafe dependency entirely. The mapping is mechanical: compareAndSwapX becomes compareAndSet, getAndAddX becomes getAndAdd, putOrderedX becomes setRelease, and the volatile variants become getVolatile and setVolatile. If you want more on what those access modes mean, the Java Atomics and VarHandle article builds CAS loops from them. For a field you only increment, AtomicLongFieldUpdater or simply AtomicLong is shorter still.
Off-heap memory becomes a MemorySegment
The other common use is raw memory: allocateMemory, putLong(address, v), freeMemory. The replacement is java.lang.foreign, final since JDK 22. The key difference is that an address is no longer a naked long; it is a MemorySegment with a size and an Arena that owns its lifetime. Before, from UnsafeOffHeapLongs.java:
this.address = U.allocateMemory(8L * length);
U.setMemory(address, 8L * length, (byte) 0);
public long get(int i) { return U.getLongVolatile(null, at(i)); }
public boolean cas(int i, long e, long u) { return U.compareAndSwapLong(null, at(i), e, u); }
public void close() { if (!closed) { closed = true; U.freeMemory(address); } }
private static final VarHandle LONG = ValueLayout.JAVA_LONG.varHandle();
private final Arena arena = Arena.ofShared(); // shared: other threads may touch the segment; closing is thread-safe
this.seg = arena.allocate(8L * length, 8); // zero-initialised, 8-byte aligned
public long get(int i) { return (long) LONG.getVolatile(seg, 8L * i); }
public boolean cas(int i, long e, long u) { return LONG.compareAndSet(seg, 8L * i, e, u); }
public void close() { arena.close(); }
The diagram is the migration checklist for the three patterns this module covers; the complete table is in JEP 471 and in the accordion below. Two things change beyond the syntax. First, lifetime: Arena.close() invalidates the segment, so a later access throws instead of reading freed memory. Second, bounds: every access is checked. The third pattern is a long read out of a byte[] at an arbitrary offset, which used the “bimodal” getLong(Object, long). It becomes a byte-array view VarHandle (ViewBytes.java, replacing UnsafeBytes.java):
private static final VarHandle VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.nativeOrder());
public static long get(byte[] a, int off) { return (long) VIEW.get(a, off); }
Note ByteOrder.nativeOrder(): Unsafe reads in native order, so the view must too or the numbers will differ on a byte-order mismatch. For external native memory (not just malloc-style buffers) and calling C, see the jextract and FFM SQLite example.
More rows of the JEP 471 mapping, as written in the JEP
Unsafe method
Replacement named in JEP 471
reallocateMemory
downcall to realloc()
invokeCleaner(ByteBuffer)
MemorySegment::asByteBuffer
putOrdered[Type](Object, long, x)
VarHandle::setRelease
getAndSet[Type](Object, long, x)
VarHandle::getAndSet
setMemory(Object, ...)
MemorySegment::fill or Arrays::fill
copyMemory(Object, ...)
MemorySegment::copy or System::arrayCopy
addressSize()
ValueLayout.ADDRESS.byteSize()
The JEP is also candid about one gap: random access to array elements without bounds checking “is not a use case that needs to be supported by a standard API”, and it says the standard replacements show “a small loss of performance” for on-heap access. This module contains no benchmark, so I have no number to add to that sentence.
Proving the swap changed nothing
A migration you cannot compare is a hope. Equivalence.java runs the same scripted operations (a seeded 2,000-step mix of set, CAS and getAndAdd) against the Unsafe version and its replacement and requires every observable result to match; then four threads each add 100,000 and the total must be exactly 400,000; then bulk copy, clear, and byte[] reads at every offset from 0 to 32. It is a correctness check, deliberately not a benchmark. On JDK 25 with =allow (output/09):
$ java --sun-misc-unsafe-memory-access=allow -cp build Equivalence (Unsafe, VarHandle and FFM side by side)
field CAS: Unsafe and the replacement gave the same 2000-step trace
field getAndAdd: 4 threads x 100000 -> total 400000 on every implementation
off-heap array: Unsafe and the replacement gave the same 2000-step trace
off-heap getAndAdd: 4 threads x 100000 -> sum 400000 on every implementation
copyFrom / clear: identical on every implementation
byte[] long access at offsets 0..32: Unsafe and VarHandle view agree
ALL 170 CHECKS PASSED
The replacement-only run is the one that matters for the future. Under deny, with --new-only, it passes every check without Unsafe:
$ java --sun-misc-unsafe-memory-access=deny -cp build Equivalence --new-only (replacements only)
byte[] long access at offsets 0..32: VarHandle view only
VarHandle view at offset 36 of a 40-byte array: ArrayIndexOutOfBoundsException
FFM read of element 4 in a 4-element segment: IndexOutOfBoundsException
FFM read after close: IllegalStateException: Already closed
ALL 67 CHECKS PASSED (new APIs only)
Those three exceptions are the behavioural differences you buy: where Unsafe would read past the end of an array or a segment, or read memory after its owner freed it, the replacements throw. My Unsafe version has its own closed flag only because I wrote it; real Unsafe code frequently does not. A migration can therefore surface a latent out-of-bounds bug as an exception. That is the intended trade.
The first --new-only attempt failed, and that is the lesson. My first version kept the old byte[] helper and the new one in a single class. Under deny the whole run died with ExceptionInInitializerError ... arrayBaseOffset, because that class’s static initializer called Unsafe even though the replacement path never needed it. I split it into UnsafeBytes and ViewBytes. When you migrate, delete the Unsafe-calling static initializer in the same commit; leaving even one call such as arrayBaseOffset in it keeps the class unusable under deny.
Run it yourself, and what the module contains
Everything is plain javac and java; no Maven. The module is unsafe-migration in the javademos repository, and scripts/run-all.sh regenerates every file under output/. The commands for the two most useful runs are below; the Unsafe flag values are the same ones used in every transcript above.
java --sun-misc-unsafe-memory-access=debug -jar your-app.jar # every call, with a stack trace
java --sun-misc-unsafe-memory-access=deny -jar your-app.jar # what phase 3 will do
Those two lines are the pattern used by run-all.sh (the module runs Probe and Equivalence rather than a jar). Steps 06 to 08 and 11 to 12 download from Maven Central; the rest is offline. Timings are not recorded anywhere, so every output is deterministic apart from JFR timestamps, which the script filters out.
Should you do this at all?
Mostly: upgrade your dependencies, do not rewrite them. If your own code never imports sun.misc.Unsafe, there is nothing to migrate; there is a version to bump. Run your tests once with deny and once with debug to find which jars need a newer release. If a library is the culprit, update it (Guava 33.8.0-jre was silent in my probe where 32.1.3-jre was not) and use =allow only as a short-term bridge, since JEP 471 says allow stops being available for avoiding the warning once phase 3 lands. Migrate your own Unsafe code only if you maintain a library or a hot path that really needs off-heap memory or field CAS; for most application code AtomicLong, ByteBuffer or a plain array is simpler than either replacement. And if you do move to MemorySegment, benchmark it: the JEP itself concedes a small loss for on-heap access, and I did not measure either side.
What I did not do. I scanned the jar for each library, not its dependency tree; I ran Netty and Guava, but not Kryo, Hadoop, Cassandra, Protobuf or Spark; and I read JEP 471 and 498 on openjdk.org but found no other JEP that changes the schedule.
No Comments yet!