diff --git a/.sdkmanrc b/.sdkmanrc
index d2635abfaf..8e05285ec6 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -1,3 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
-java=21.0.5-tem
+java=21.0.7-tem
diff --git a/src/main/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscore.java b/src/main/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscore.java
new file mode 100644
index 0000000000..43680fef6a
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscore.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.lang;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.Preconditions;
+import org.openrewrite.Recipe;
+import org.openrewrite.TreeVisitor;
+import org.openrewrite.java.JavaIsoVisitor;
+import org.openrewrite.java.RenameVariable;
+import org.openrewrite.java.search.SemanticallyEqual;
+import org.openrewrite.java.search.UsesJavaVersion;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.java.tree.Statement;
+import org.openrewrite.staticanalysis.VariableReferences;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@EqualsAndHashCode(callSuper = false)
+@Value
+public class ReplaceUnusedVariablesWithUnderscore extends Recipe {
+
+ private static final String UNDERSCORE = "_";
+
+ @Override
+ public String getDisplayName() {
+ return "Replace unused variables with underscore";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Replace unused variable declarations with underscore (_) for Java 22+. " +
+ "This includes unused variables in enhanced for loops, catch blocks, " +
+ "and lambda parameters where the variable is never referenced.";
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor() {
+ return Preconditions.check(new UsesJavaVersion<>(25), new JavaIsoVisitor() {
+ @Override
+ public J.ForEachLoop visitForEachLoop(J.ForEachLoop forLoop, ExecutionContext ctx) {
+ J.ForEachLoop l = super.visitForEachLoop(forLoop, ctx);
+ Statement variable = l.getControl().getVariable();
+ if (variable instanceof J.VariableDeclarations) {
+ for (J.VariableDeclarations.NamedVariable namedVariable : ((J.VariableDeclarations) variable).getVariables()) {
+ renameVariableIfUnusedInContext(namedVariable, l.getBody());
+ }
+ }
+ return l;
+ }
+
+ @Override
+ public J.Try.Catch visitCatch(J.Try.Catch _catch, ExecutionContext ctx) {
+ J.Try.Catch c = super.visitCatch(_catch, ctx);
+ for (J.VariableDeclarations.NamedVariable namedVariable : c.getParameter().getTree().getVariables()) {
+ renameVariableIfUnusedInContext(namedVariable, c.getBody());
+ }
+ return c;
+ }
+
+ @Override
+ public J.Lambda visitLambda(J.Lambda lambda, ExecutionContext ctx) {
+ J.Lambda l = super.visitLambda(lambda, ctx);
+ for (J param : l.getParameters().getParameters()) {
+ if (param instanceof J.VariableDeclarations) {
+ for (J.VariableDeclarations.NamedVariable namedVariable : ((J.VariableDeclarations) param).getVariables()) {
+ renameVariableIfUnusedInContext(namedVariable, l.getBody());
+ }
+ }
+ }
+ return l;
+ }
+
+ private void renameVariableIfUnusedInContext(J.VariableDeclarations.NamedVariable variable, J context) {
+ if (!UNDERSCORE.equals(variable.getName().getSimpleName()) &&
+ VariableReferences.findRhsReferences(context, variable.getName()).isEmpty() &&
+ !usedInModifyingUnary(variable.getName(), context)) {
+ doAfterVisit(new RenameVariable<>(variable, UNDERSCORE));
+ }
+ }
+
+ private boolean usedInModifyingUnary(J.Identifier identifier, J context) {
+ return new JavaIsoVisitor() {
+ @Override
+ public J.Unary visitUnary(J.Unary unary, AtomicBoolean atomicBoolean) {
+ if (unary.getOperator().isModifying() &&
+ SemanticallyEqual.areEqual(identifier, unary.getExpression())) {
+ atomicBoolean.set(true);
+ }
+ return super.visitUnary(unary, atomicBoolean);
+ }
+ }.reduce(context, new AtomicBoolean(false)).get();
+ }
+ });
+ }
+}
diff --git a/src/main/resources/META-INF/rewrite/java-version-25.yml b/src/main/resources/META-INF/rewrite/java-version-25.yml
index 1bc043cf76..6b12e6bb39 100644
--- a/src/main/resources/META-INF/rewrite/java-version-25.yml
+++ b/src/main/resources/META-INF/rewrite/java-version-25.yml
@@ -28,6 +28,7 @@ recipeList:
- org.openrewrite.java.migrate.UpgradeToJava21
- org.openrewrite.java.migrate.UpgradeBuildToJava25
- org.openrewrite.java.migrate.lang.MigrateProcessWaitForDuration
+ - org.openrewrite.java.migrate.lang.ReplaceUnusedVariablesWithUnderscore
- org.openrewrite.java.migrate.util.MigrateInflaterDeflaterToClose
- org.openrewrite.java.migrate.AccessController
- org.openrewrite.java.migrate.RemoveSecurityPolicy
diff --git a/src/test/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscoreTest.java b/src/test/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscoreTest.java
new file mode 100644
index 0000000000..070802055c
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/lang/ReplaceUnusedVariablesWithUnderscoreTest.java
@@ -0,0 +1,443 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.lang;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.java.JavaParser;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.openrewrite.java.Assertions.java;
+import static org.openrewrite.java.Assertions.javaVersion;
+
+class ReplaceUnusedVariablesWithUnderscoreTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec
+ .recipe(new ReplaceUnusedVariablesWithUnderscore())
+ .parser(JavaParser.fromJavaVersion())
+ .allSources(s -> s.markers(javaVersion(25)));
+ }
+
+ @DocumentExample
+ @Test
+ void replaceUnusedForEachVariable() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ int countOrders(List orders) {
+ int total = 0;
+ for (String order : orders) {
+ total++;
+ }
+ return total;
+ }
+ }
+ """,
+ """
+ import java.util.List;
+
+ class Test {
+ int countOrders(List orders) {
+ int total = 0;
+ for (String _ : orders) {
+ total++;
+ }
+ return total;
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceUsedForEachVariable() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ void processOrders(List orders) {
+ for (String order : orders) {
+ System.out.println(order);
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void replaceUnusedCatchVariable() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ class Test {
+ void parseNumber(String s) {
+ try {
+ Integer.parseInt(s);
+ } catch (NumberFormatException ex) {
+ System.out.println("Bad number: " + s);
+ }
+ }
+ }
+ """,
+ """
+ class Test {
+ void parseNumber(String s) {
+ try {
+ Integer.parseInt(s);
+ } catch (NumberFormatException _) {
+ System.out.println("Bad number: " + s);
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceUsedCatchVariable() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ class Test {
+ void parseNumber(String s) {
+ try {
+ Integer.parseInt(s);
+ } catch (NumberFormatException ex) {
+ System.out.println("Error: " + ex.getMessage());
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void replaceUnusedLambdaParameter() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.stream.Stream;
+ import java.util.stream.Collectors;
+
+ class Test {
+ void example() {
+ Stream.of("a", "b", "c")
+ .collect(Collectors.toMap(String::toUpperCase, item -> "NODATA"));
+ }
+ }
+ """,
+ """
+ import java.util.stream.Stream;
+ import java.util.stream.Collectors;
+
+ class Test {
+ void example() {
+ Stream.of("a", "b", "c")
+ .collect(Collectors.toMap(String::toUpperCase, _ -> "NODATA"));
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceUsedLambdaParameter() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.stream.Stream;
+ import java.util.stream.Collectors;
+
+ class Test {
+ void example() {
+ Stream.of("a", "b", "c")
+ .collect(Collectors.toMap(String::toUpperCase, item -> item.length()));
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void noChangeOnJava21() {
+ rewriteRun(
+ spec -> spec.allSources(s -> s.markers(javaVersion(21))),
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ int countOrders(List orders) {
+ int total = 0;
+ for (String order : orders) {
+ total++;
+ }
+ return total;
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void handleNestedLoops() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ void example(List> matrix) {
+ for (List row : matrix) {
+ for (String item : row) {
+ System.out.println();
+ }
+ }
+ }
+ }
+ """,
+ """
+ import java.util.List;
+
+ class Test {
+ void example(List> matrix) {
+ for (List row : matrix) {
+ for (String _ : row) {
+ System.out.println();
+ }
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void handleNestedLoopsBothUnused() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ void test(List> matrix) {
+ int count = 0;
+ for (List row : matrix) {
+ for (String item : matrix.get(0)) {
+ count++;
+ }
+ }
+ }
+ }
+ """,
+ """
+ import java.util.List;
+
+ class Test {
+ void test(List> matrix) {
+ int count = 0;
+ for (List _ : matrix) {
+ for (String _ : matrix.get(0)) {
+ count++;
+ }
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void replaceBiConsumerBothParametersUnused() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.function.BiConsumer;
+
+ class Test {
+ void test() {
+ BiConsumer consumer = (first, second) -> System.out.println();
+ }
+ }
+ """,
+ """
+ import java.util.function.BiConsumer;
+
+ class Test {
+ void test() {
+ BiConsumer consumer = (_, _) -> System.out.println();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceUsedInMethodReference() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.stream.Stream;
+
+ class Test {
+ void test() {
+ Stream.of("a").forEach(item -> System.out.println(item.toString()));
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void replaceUnusedInNestedLambda() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.stream.Stream;
+
+ class Test {
+ void test() {
+ Stream.of("a").forEach(outer -> {
+ Stream.of("b").forEach(inner -> System.out.println());
+ });
+ }
+ }
+ """,
+ """
+ import java.util.stream.Stream;
+
+ class Test {
+ void test() {
+ Stream.of("a").forEach(_ -> {
+ Stream.of("b").forEach(_ -> System.out.println());
+ });
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceWhenOuterVariableUsedInInnerLambda() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.stream.Stream;
+
+ class Test {
+ void test() {
+ Stream.of("a").forEach(outer -> {
+ Stream.of("b").forEach(inner -> System.out.println(outer));
+ });
+ }
+ }
+ """,
+ """
+ import java.util.stream.Stream;
+
+ class Test {
+ void test() {
+ Stream.of("a").forEach(outer -> {
+ Stream.of("b").forEach(_ -> System.out.println(outer));
+ });
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "++number; // Weird, but allowed",
+ "number++;",
+ })
+ void doNotReplaceModifyingOperator(String body) {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+ import java.util.stream.Stream;
+
+ class Test {
+ int forloop(List numbers) {
+ for (int number : numbers) {
+ %s
+ }
+ }
+ }
+ """.formatted(body)
+ )
+ );
+ }
+
+ @Test
+ void doNotReplaceUnrelatedVariables() {
+ rewriteRun(
+ //language=java
+ java(
+ """
+ import java.util.List;
+
+ class Test {
+ int countOrders(List orders) {
+ for (String order : orders) {
+ String o = order;
+ }
+ }
+ }
+ """
+ )
+ );
+ }
+}