DoubleCheckLockingHintProvider.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer - initial API and implementation
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.concurrency;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.IfStatement;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.NullLiteral;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.SynchronizedStatement;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.ui.text.java.IJavaCompletionProposal;
import org.eclipse.jdt.ui.text.java.correction.ASTRewriteCorrectionProposal;
import org.eclipse.swt.graphics.Image;
import org.sandbox.jdt.triggerpattern.api.Hint;
import org.sandbox.jdt.triggerpattern.eclipse.HintContext;
import org.sandbox.jdt.triggerpattern.api.PatternKind;
import org.sandbox.jdt.triggerpattern.api.TriggerPattern;
/**
* Hint provider for detecting the double-checked locking anti-pattern.
*
* <p>Double-checked locking is a concurrency pattern where a field is checked
* for null, then a synchronized block is entered, and the field is checked
* again. This pattern can be problematic in Java without proper memory
* visibility guarantees.</p>
*
* <p>Inspired by the
* <a href="https://github.com/apache/netbeans/blob/master/java/java.hints/src/org/netbeans/modules/java/hints/DoubleCheck.java">
* NetBeans DoubleCheck hint</a>.</p>
*
* <h3>Detected Pattern</h3>
* <pre>
* if (field == null) {
* synchronized (lock) {
* if (field == null) {
* field = new Something();
* }
* }
* }
* </pre>
*
* <h3>Suggested Fix</h3>
* <p>The fix removes the outer null check, keeping only the synchronized block
* with the inner null check. This eliminates the double-checked locking pattern
* at the cost of always entering the synchronized block:</p>
* <pre>
* synchronized (lock) {
* if (field == null) {
* field = new Something();
* }
* }
* </pre>
*
* @since 1.2.5
* @see <a href="https://en.wikipedia.org/wiki/Double-checked_locking">Double-checked locking (Wikipedia)</a>
*/
public class DoubleCheckLockingHintProvider {
/**
* Detects the double-checked locking pattern and suggests removing the outer
* null check to eliminate the anti-pattern.
*
* <p>The expression-level pattern {@code $field == null} is used as the entry
* point. When a null check is found, the method walks up the AST tree to verify
* the full double-checked locking structure: an outer {@code if} wrapping a
* {@code synchronized} block that contains an inner {@code if} with the same
* null check condition.</p>
*
* <p><b>Before:</b></p>
* <pre>
* if (field == null) {
* synchronized (lock) {
* if (field == null) {
* field = new Something();
* }
* }
* }
* </pre>
*
* <p><b>After:</b></p>
* <pre>
* synchronized (lock) {
* if (field == null) {
* field = new Something();
* }
* }
* </pre>
*
* @param ctx the hint context containing the match and AST information
* @return a completion proposal, or null if the pattern doesn't match
*/
@TriggerPattern(value = "$field == null", kind = PatternKind.EXPRESSION)
@Hint(displayName = "Double-checked locking",
description = "Detects double-checked locking pattern. "
+ "Suggests removing the outer null check to use plain synchronization instead.",
category = "concurrency",
suppressWarnings = "DoubleCheckedLocking")
public static IJavaCompletionProposal detectDoubleCheckLocking(HintContext ctx) {
ASTNode matchedNode = ctx.getMatch().getMatchedNode();
if (!(matchedNode instanceof InfixExpression)) {
return null;
}
// Walk up to find the enclosing IfStatement
IfStatement innerIf = findEnclosingIf(matchedNode);
if (innerIf == null || innerIf.getElseStatement() != null) {
return null;
}
// Check that this inner if is inside a synchronized block
SynchronizedStatement syncStmt = findEnclosingSynchronized(innerIf);
if (syncStmt == null) {
return null;
}
// Check that the synchronized block is inside an outer if with the same condition
IfStatement outerIf = findEnclosingIf(syncStmt);
if (outerIf == null || outerIf.getElseStatement() != null) {
return null;
}
// Verify both if conditions are null checks on the same expression
if (!isSameNullCheck(outerIf.getExpression(), innerIf.getExpression())) {
return null;
}
// Create the fix: replace the outer if with the synchronized block
ASTRewrite rewrite = ctx.getASTRewrite();
AST ast = rewrite.getAST();
SynchronizedStatement replacement = (SynchronizedStatement) ASTNode.copySubtree(ast, syncStmt);
rewrite.replace(outerIf, replacement, null);
// Create the proposal
String label = "Remove outer null check (double-checked locking)"; //$NON-NLS-1$
ASTRewriteCorrectionProposal proposal = new ASTRewriteCorrectionProposal(
label,
ctx.getICompilationUnit(),
rewrite,
10, // relevance
(Image) null
);
return proposal;
}
/**
* Finds the nearest enclosing {@link IfStatement} for a given node.
*
* @param node the starting node
* @return the enclosing IfStatement, or null if not found
*/
private static IfStatement findEnclosingIf(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof IfStatement ifStatement) {
return ifStatement;
}
if (current instanceof Block) {
current = current.getParent();
continue;
}
// Stop at other statement types
if (current instanceof Statement) {
return null;
}
current = current.getParent();
}
return null;
}
/**
* Finds the nearest enclosing {@link SynchronizedStatement} for a given node.
*
* @param node the starting node
* @return the enclosing SynchronizedStatement, or null if not found
*/
private static SynchronizedStatement findEnclosingSynchronized(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof SynchronizedStatement syncStatement) {
return syncStatement;
}
if (current instanceof Block) {
current = current.getParent();
continue;
}
// Stop at other statement types
if (current instanceof Statement) {
return null;
}
current = current.getParent();
}
return null;
}
/**
* Checks whether two expressions represent the same null check
* ({@code expr == null}).
*
* @param expr1 the first expression
* @param expr2 the second expression
* @return true if both are null checks on the same variable
*/
private static boolean isSameNullCheck(org.eclipse.jdt.core.dom.Expression expr1,
org.eclipse.jdt.core.dom.Expression expr2) {
if (!(expr1 instanceof InfixExpression infix1) || !(expr2 instanceof InfixExpression infix2)) {
return false;
}
if (infix1.getOperator() != InfixExpression.Operator.EQUALS
|| infix2.getOperator() != InfixExpression.Operator.EQUALS) {
return false;
}
// Check null on right side for both
if (!(infix1.getRightOperand() instanceof NullLiteral)
|| !(infix2.getRightOperand() instanceof NullLiteral)) {
return false;
}
// Compare the left operand (the field being checked) using structural comparison
return infix1.getLeftOperand().subtreeMatch(new ASTMatcher(), infix2.getLeftOperand());
}
}