Java's compatibility record is unusually strong: code compiled for Java 1.4 often still runs. That is not because nothing is ever removed. Since JDK 9 the platform has removed whole modules (Java EE and CORBA), an entire JavaScript engine (Nashorn), the RMI activation system and, in JDK 26, the Applet API, and it has disabled the Security Manager outright. What keeps upgrades survivable is a deliberate, signposted process: APIs are deprecated, then deprecated for removal, and only then removed, with tools that let you find every use in advance.
This article is about that process and how to work with it: what the two kinds of deprecation mean, what javac and the JDK tools report, which removals have actually happened and what replaces them, how to migrate the common cases with code, and how to deprecate your own APIs without hurting your users. For the release-by-release upgrade view, see Java LTS releases.
Two kinds of deprecation
JEP 277 (JDK 9) gave @Deprecated two elements: since, the version in which the API was deprecated, and forRemoval, a boolean. That split two very different meanings that used to share one annotation.
Ordinary deprecation (@Deprecated(since = "9")) means: do not use this in new code, there is something better. It carries no promise of removal and many such APIs have lived for over twenty years; java.util.Date's year/month constructors were deprecated in JDK 1.1 and are still there. Terminal deprecation (@Deprecated(since = "17", forRemoval = true)) means: this is going away in a future release, plan for it. The JDK has generally kept a terminally deprecated API for at least one feature release before removing it, and often several; the Applet API, for example, was terminally deprecated in JDK 17 and removed in JDK 26.
There is often a third, informal stage between the two: the API still exists, so code links, but it no longer does its job. Since JDK 20 Thread.stop() throws UnsupportedOperationException. JEP 486 permanently disabled the Security Manager in JDK 24: the classes are present, but you can no longer enable it at startup or install one at run time. Code that compiles fine and fails at run time is the most expensive kind of breakage, which is why tests on the new JDK matter as much as compiler warnings.
What javac tells you, and how to keep it honest
javac treats the two kinds differently. Uses of terminally deprecated APIs produce removal warnings that are on by default and printed individually. Uses of ordinarily deprecated APIs produce only a summary note (Note: Foo.java uses or overrides a deprecated API.) unless you compile with -Xlint:deprecation. The suppression tokens differ too:
// Silences ordinary deprecation warnings only.
@SuppressWarnings("deprecation")
Date legacy() { return new Date(124, 0, 1); }
// Silences removal warnings only. Use sparingly, and leave a ticket reference.
@SuppressWarnings("removal") // TODO(PLAT-412): drop SecurityManager checks before JDK 25
void legacyCheck() { System.getSecurityManager(); }Because the tokens are separate, a codebase full of @SuppressWarnings("deprecation") still surfaces terminal deprecations, which is exactly the intent. Since JEP 211 (JDK 9), import statements no longer trigger deprecation warnings, so warnings point at real uses rather than imports. A practical build policy is -Xlint:deprecation,removal -Werror on your own modules, with a ratchet: new code may not add uses, existing ones are listed and burned down. Be careful with -Werror on JDK upgrades, though; a new release that terminally deprecates something you use will break the build on the day you upgrade the toolchain, so pin the javac --release flag and treat the toolchain bump as its own change.
Scanning what the compiler cannot see
The compiler only sees your source. Your jars, your dependencies and code you cannot rebuild need the JDK's scanning tools. jdeprscan scans compiled classes for uses of deprecated JDK APIs as of a given release; jdeps finds dependencies on JDK internal APIs, which are not deprecated (they were never supported) but are just as likely to break:
# Which deprecated-for-removal JDK APIs does this jar use, judged against JDK 25?
jdeprscan --release 25 --for-removal build/libs/app.jar
# Scan the whole runtime classpath, including third-party jars
jdeprscan --release 25 --for-removal --class-path "$(cat cp.txt)" build/libs/app.jar
# List everything JDK 25 has deprecated for removal
jdeprscan --release 25 --list --for-removal
# Uses of JDK internals (sun.*, jdk.internal.*) and suggested replacements
jdeps --jdk-internals --multi-release 25 --class-path "$(cat cp.txt)" build/libs/app.jarNeither tool sees reflection: Class.forName("javax.xml.bind.JAXBContext") or a method looked up by name is invisible to static scanning. That is the gap the third check fills: run the full test suite, and ideally a canary of production traffic, on the target JDK, and read the run-time warnings. Recent JDKs warn at run time about uses that will become errors, such as dynamically loaded agents (JEP 451, JDK 21), sun.misc.Unsafe memory access (JEP 498, JDK 24) and restricted JNI and FFM calls (JEP 472, JDK 24). Grep the logs for WARNING: lines from the JVM after every upgrade.
What the JDK has actually removed
The removals and terminal deprecations most likely to touch a real codebase, with what to use instead:
| What | Status | Replacement |
|---|---|---|
| Java EE modules (JAXB, JAX-WS, JAF, javax.annotation) and CORBA | removed in JDK 11 (JEP 320) | Jakarta EE artifacts from Maven Central (jakarta.xml.bind-api plus an implementation); CORBA has none |
| Nashorn JavaScript engine | removed in JDK 15 (JEP 372) | standalone Nashorn project or GraalJS |
Primitive wrapper constructors such as new Integer(5) | deprecated for removal in JDK 16 (JEP 390) | Integer.valueOf(5) or autoboxing |
| RMI activation | removed in JDK 17 (JEP 407) | plain RMI or a modern RPC stack |
Finalization (finalize()) | deprecated for removal in JDK 18 (JEP 421) | try-with-resources and java.lang.ref.Cleaner |
sun.misc.Unsafe memory-access methods | terminally deprecated in JDK 23 (JEP 471); warn by default in JDK 24 (JEP 498) | VarHandle and the Foreign Function and Memory API |
| Security Manager | permanently disabled in JDK 24 (JEP 486) | process isolation, containers, OS sandboxing |
Applet API (java.applet, JApplet) | deprecated for removal in JDK 17 (JEP 398), removed in JDK 26 (JEP 504) | none; applets have not run in browsers for years |
For sun.misc.Unsafe, JEP 498's flag --sun-misc-unsafe-memory-access takes allow, warn, debug or deny. Running tests with deny today shows which libraries will break later; most Unsafe use sits in dependencies (serialization, networking, caching libraries), so the fix is usually a library upgrade rather than a code change.
Finalization: the removal hiding in your code
Finalization is the removal most likely to hide in your own code, because finalize() was the textbook way to release native resources. It runs at an unpredictable time, maybe never, on an unspecified thread, can resurrect objects and slows garbage collection. JDK 18 added --finalization=disabled so you can test the future today: with it, finalizers simply do not run. If a test then leaks file handles or native memory, you have found code to migrate. The replacement pattern is an explicit close() for the normal path plus a Cleaner as a safety net:
public final class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
// The state must not reference NativeBuffer, or it is never unreachable.
private record State(long address) implements Runnable {
public void run() { Native.free(address); }
}
private final State state;
private final Cleaner.Cleanable cleanable;
public NativeBuffer(int size) {
this.state = new State(Native.malloc(size));
this.cleanable = CLEANER.register(this, state);
}
@Override public void close() { cleanable.clean(); } // idempotent: runs at most once
}
try (var buf = new NativeBuffer(4096)) {
// use buf; freed deterministically here
}The common bug when porting is making the cleanup action an inner class or lambda that captures this; the object then stays reachable from its own cleaner and is never collected.
Worked example: clearing a service for a JDK upgrade
Worked example: an order service on JDK 17 heading to JDK 25. Step one, scan without touching code: jdeprscan --release 25 --for-removal over the application jar reports three uses: new Long(String) in a parser, finalize() in a JNI wrapper, and System.getSecurityManager() in an old permission helper. jdeps --jdk-internals flags a vendored copy of an old serialization library that calls sun.misc.Unsafe.
Step two, fix in order of risk. The wrapper constructor becomes Long.valueOf; note the subtle change that valueOf may return cached instances, so any == comparison on boxed values must become equals (search for it). The finalizer becomes the Cleaner pattern above, verified by running tests with --finalization=disabled and watching native memory. The Security Manager helper is deleted, since it has been a no-op since the Security Manager was disabled. The vendored library is replaced by its current release.
Step three, run the suite on JDK 25 with --sun-misc-unsafe-memory-access=deny and -Xlog:all=warning and read every JVM warning. One more surfaces: a test-only Java agent attached dynamically, which needs -XX:+EnableDynamicAgentLoading or, better, loading with -javaagent at startup. Only then bump the toolchain and the base image.
Deprecating your own APIs
The same machinery serves your own libraries. A deprecation that helps its users has four parts: the annotation with since (and forRemoval only if you mean it), a Javadoc @deprecated tag that names the replacement, a replacement that already exists, and a removal that waits for a major version.
/**
* Returns the order total in the default currency.
*
* @deprecated since 3.4, for removal in 4.0. Use {@link #total(Currency)}; the
* default currency is ambiguous for multi-region tenants.
*/
@Deprecated(since = "3.4", forRemoval = true)
public BigDecimal total() {
return total(defaultCurrency); // delegate: one implementation, no drift
}Keep the deprecated method delegating to the new one so behaviour cannot diverge. Do not change its semantics while deprecated; users who have not migrated yet should not get surprises. If callers are hard to find statically (plugins, reflection), log once per process on first use, with the caller's location, so operators can see who is still on the old path. And remove only in a major version, listing the removal in release notes alongside the replacement.
Failure modes
- Blanket suppression.
@SuppressWarnings("removal")on a class or package silences future terminal deprecations too. Suppress on the narrowest element and reference a ticket. - Reflection and configuration. Class names in XML, Spring configuration or service files never meet the compiler. Grep configuration for removed package names such as
javax.xml.bind. - Transitive dependencies. Most removal breakage comes from libraries. Scan the full runtime classpath, not just your jar, and upgrade libraries before the JDK.
- Ignored run-time warnings. JVM warnings go to stderr, which many log pipelines drop. Route them somewhere someone reads, or the warn stage passes unnoticed and the deny stage arrives as an outage.
- Internal APIs with no warning.
sun.*andjdk.internal.*were never supported and can change without deprecation. Strong encapsulation (JEP 403, JDK 17) already blocks most of them;--add-opensflags in launch scripts are a list of debts.
Trade-offs: upgrade cadence
The trade-off for the platform is speed of evolution against migration cost; for you it is upgrade cadence. Teams that move one LTS at a time take every removal in one large step. Teams that build and test against each six-monthly feature release, even while deploying only LTS, see each terminal deprecation as a small warning years before it becomes a removal. The cost is a CI job per release; the payoff is that no upgrade is ever a project. The same logic applies to your own APIs: deprecate early, remove late, and never remove without a replacement that has shipped. See the module system for how strong encapsulation changed what counts as public API, jlink for building runtimes that contain only the modules you use, and OpenJDK vendors for how long each release you depend on will receive fixes.
What to do next
- Run
jdeprscan --for-removal --release Nfor your target JDK over your jar and its full classpath; file a ticket per finding. - Run
jdeps --jdk-internalsand list every--add-opensand--add-exportsflag in your launch scripts as debt. - Compile your own modules with
-Xlint:deprecation,removaland add a ratchet so the count can only fall. - Run tests with
--finalization=disabledand--sun-misc-unsafe-memory-access=denyto see future breakage now. - Replace
finalize()withAutoCloseableplusCleaner, and boxed-value==withequals. - Grep configuration files for removed packages (
javax.xml.bind,javax.jws,java.applet). - Add a CI job that builds and tests on the latest feature release, not just your deployed LTS.
- For your own APIs: annotate with
since, document the replacement in@deprecated, delegate, and remove only in a major version.