Static Analysis for Java: SpotBugs, Checkstyle & SonarQube

Friday, 4:47 PM: the checkout service passes review, compiles clean, and ships. At 2:13 AM — NullPointerException in production, on a coupon path nobody tested. Two senior engineers reviewed it. javac was happy. Tests were green. The bug walked through all three gates because none of them look for this kind of bug.

This post is the fourth gate: three tools, three jobs, one buggy class run through all of them — real output, build wiring, and the traps that make teams quit.

Why the compiler isn't enough

javac answers one question: is this program well-formed? Syntax, types, unreachable code — caught. But whole categories of real bugs are perfectly well-formed programs:

  • Null dereference on an unseen path. javac doesn't track which paths leave a variable null — a NullPointerException waiting on the coupon == null path is invisible to it.
  • Ignored return values. audit.toUpperCase(); compiles — but strings are immutable, so the result evaporates.
  • == on Strings. Reference comparison is legal Java — just almost never what you meant.
  • Self-assignment, mutable statics, empty catches. All legal. All bugs.
  • Style drift across a team. Five developers, five brace styles. The compiler doesn't care; your future self does.

Decision rule: javac-catchable means compile problem; a pattern in legal code means static analysis — three jobs, three tools:

  • SpotBugs — probable bugs. Reads bytecode, matches hundreds of known bug patterns. Answers "this looks like a bug."
  • Checkstyle — the team contract. Reads source, enforces agreed style rules. Answers "this doesn't look like our code."
  • SonarQube — quality over time. Aggregates bugs, smells, duplication, coverage across runs. Answers "is this codebase getting better or worse?" — its quality gate decides whether the build ships.

Here's where they sit in the build:

javac syntax + types SpotBugs bytecode → bug patterns Checkstyle source → team contract SonarQube history + coverage .class report fast · local · every build server · every pull request Quality gate violations → build fails green → ship · red → fix, no exceptions

Decision rule: SpotBugs and Checkstyle run locally on every build. SonarQube runs per pull request — it needs history and coverage. A SonarQube server nobody gates on is a dashboard nobody reads.

The victim: one class, six bugs

Meet OrderValidator — 51 lines, zero javac warnings, passes review ("looks fine, ship it"). Six genuine bugs. Count how many you spot first:

import java.util.ArrayList;
import java.util.List;

public class OrderValidator {

    // NOTE: pricing-team rule — holiday coupons stack with regional discounts, except EU orders over 5000, which cap out.
    public static List<String> SUPPORTED_REGIONS = new ArrayList<>();

    static {
        SUPPORTED_REGIONS.add("US");
        SUPPORTED_REGIONS.add("EU");
    }

    public double discountedTotal(String coupon, double total, String region) {
        String code = null;
        if (coupon != null) {
            code = coupon.trim();
        }

        boolean hasCoupon = code.length() > 0;

        String audit = "coupon=" + code + ", region=" + region;
        audit.toUpperCase();

        double discount = 0.0;
        if (code == "SAVE20") {
            discount = 0.20;
        }
        discount = discount;

        if (!SUPPORTED_REGIONS.contains(region)) {
            discount = 0.0;
        }

        try {
            recordAudit(audit);
        } catch (Exception e) {
        }

        if (discount > 0.5)
            return total * 0.5;
        return total * (1 - discount);
    }

    private void recordAudit(String entry) throws Exception {
        if (entry == null) {
            throw new Exception("empty audit entry");
        }
        System.out.println("AUDIT: " + entry);
    }
}

javac OrderValidator.java — silence. Now the tools:

What SpotBugs sees: five probable bugs

SpotBugs reads bytecode — what the code does, not how it's formatted. mvn spotbugs:check reports:

M C NP_NULL_ON_SOME_PATH: Possible null pointer dereference in OrderValidator.discountedTotal(String, double, String)  At OrderValidator.java:[line 20]
M C RV_RETURN_VALUE_IGNORED: Return value of String.toUpperCase() ignored in OrderValidator.discountedTotal(String, double, String)  At OrderValidator.java:[line 23]
H C ES_COMPARING_STRINGS_WITH_EQ: Comparison of String objects using == or != in OrderValidator.discountedTotal(String, double, String)  At OrderValidator.java:[line 26]
M C SA_LOCAL_SELF_ASSIGNMENT: Self assignment of discount to itself in OrderValidator.discountedTotal(String, double, String)  At OrderValidator.java:[line 29]
M M MS_MUTABLE_COLLECTION: Public static field OrderValidator.SUPPORTED_REGIONS is a mutable collection  At OrderValidator.java:[line 7]

Every code is a real SpotBugs pattern, and every one is a real bug:

  • NP_NULL_ON_SOME_PATH (line 20). When coupon is null, code stays null and code.length() explodes — the 2 AM page.
  • RV_RETURN_VALUE_IGNORED (line 23). toUpperCase() has no side effects — it returns a new string. Discarding the result makes the line a no-op.
  • ES_COMPARING_STRINGS_WITH_EQ (line 26). == compares references, so equal-valued strings from different sources fail the check and the discount silently never applies. (Why reference comparison lies: the equality and hashing post.)
  • SA_LOCAL_SELF_ASSIGNMENT (line 29). discount = discount; — a no-op that usually means the author meant to assign something else.
  • MS_MUTABLE_COLLECTION (line 7). A public static ArrayList anyone can clear() from anywhere — shared mutable global state.

What Checkstyle sees: the team contract

Checkstyle doesn't hunt bugs — it enforces the team's code contract. Google checks say:

[WARN] OrderValidator.java:37:9: EmptyCatchBlock: Empty catch block.
[WARN] OrderValidator.java:7:5: V
isibilityModifier: Variable 'SUPPORTED_REGIONS' must be private and have accessor methods. [WARN] OrderValidator.java:4:1: FinalClass: Class OrderValidator should be declared as final. [WARN] OrderValidator.java:40:5: NeedBraces: 'if' construct must use '{}'s. [WARN] OrderValidator.java:6:1: LineLength: Line is longer than 100 characters (found 122).

The empty catch is both bug and contract violation; FinalClass and LineLength are pure consistency. That's the point: Checkstyle ends the braceless-if debate permanently.

What SonarQube sees: the same bugs, plus the trend

SonarQube flags the same six problems with its own rule keys:

java:S2259  Bug          Null pointers should not be dereferenced                      (line 20)
java:S2201  Code Smell   Return values from functions without side effects should not
                         be ignored                                                    (line 23)
java:S4973  Bug          Strings and Boxed types should be compared using equals()      (line 26)
java:S1656  Code Smell   Variables should not be self-assigned                         (line 29)
java:S108   Code Smell   Nested blocks of code should not be left empty                 (line 37)
java:S2386  Vulnerability  Mutable fields should not be "public static"                (line 7)

The overlap is deliberate — SonarQube's value is aggregation: duplication, coverage, complexity trends, new issues. That feeds the quality gate — e.g. "zero new blockers, ≥80% coverage on new code" — which fails the build when violated.

Probable bugs null paths · ignored returns == on Strings · self-assignment SpotBugs NP_NULL_ON_SOME_PATH RV_RETURN_VALUE_IGNORED · ES_COMPARING_STRINGS_WITH_EQ owned by Team contract empty catch · public mutable static braceless if · line length Checkstyle EmptyCatchBlock · VisibilityModifier NeedBraces · LineLength · FinalClass owned by Quality over time same bugs · duplication coverage · complexity trends SonarQube java:S2259 · java:S2201 · java:S4973 …plus the quality gate owned by

The fixed version

Same class, six bugs gone — each fix a line or two:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class OrderValidator {

    private static final List<String> SUPPORTED_REGIONS;

    static {
        List<String> regions = new ArrayList<>();
        regions.add("US");
        regions.add("EU");
        SUPPORTED_REGIONS = Collections.unmodifiableList(regions);
    }

    public double discountedTotal(String coupon, double total, String region) {
        String code = coupon == null ? "" : coupon.trim();

        boolean hasCoupon = !code.isEmpty();

        String audit = "coupon=" + code + ", region=" + region;
        String normalizedAudit = audit.toUpperCase();
        System.out.println("AUDIT (upper): " + normalizedAudit);

        double discount = 0.0;
        if (hasCoupon && "SAVE20".equals(code)) {
            discount = 0.20;
        }

        if (!SUPPORTED_REGIONS.contains(region)) {
            discount = 0.0;
        }

        try {
            recordAudit(audit);
        } catch (Exception e) {
            throw new IllegalStateException("audit failed for " + audit, e);
        }

        if (discount > 0.5) {
            return total * 0.5;
        }
        return total * (1 - discount);
    }

    private void recordAudit(String entry) throws Exception {
        if (entry == null) {
            throw new Exception("empty audit entry");
        }
        System.out.println("AUDIT: " + entry);
    }
}

What changed, fix by fix:

  • Null path closed: coupon == null ? "" : coupon.trim() — code can never be null downstream.
  • Return value used: toUpperCase() feeds normalizedAudit, which is printed.
  • Value comparison: "SAVE20".equals(code) — content, not references; literal-first is null-safe.
  • Self-assignment deleted.
  • Empty catch replaced: throws IllegalStateException chaining the cause — audit failures are loud now. (The Exceptions post's "never silently swallow", enforced by a tool.)
  • Mutable static locked down: private static final + Collections.unmodifiableList.
  • Contract nits: final class, braces everywhere, short lines.

All three tools against this version: silence. Not "no bugs ever" — the whole category of dumb bugs, handled before review.

Wiring it into the build: Maven

Hand-run tools run once; build-bound tools run forever:

SpotBugs — check fails the build on findings, bound to verify:

<plugin>
    <groupId>com.github.spotbugs</groupId>
    <artifactId>spotbugs-maven-plugin</artifactId>
    <version>4.8.6</version>
    <configuration>
        <effort>Max</effort>
        <threshold>Medium</threshold>
        <failOnError>true</failOnError>
    </configuration>
    <executions>
        <execution>
            <phase>verify</phase>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>

effort=Max trades analysis time for fewer misses. Decision rule: start at threshold=High on existing code, lower it once the backlog is clean. Starting Low is trap #1 below.

Checkstyle — failsOnViolation makes a style break fail the build like a test failure:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-checkstyle-plugin</artifactId>
    <version>3.6.0</version>
    <configuration>
        <configLocation>google_checks.xml</configLocation>
        <failsOnViolation>true</failsOnViolation>
    </configuration>
    <executions>
        <execution>
            <phase>verify</phase>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
    </executions>
</plugin>

google_checks.xml ships with the plugin — adopt it verbatim (trap #2 is the 200-rule custom config).

SonarQube needs no binding — invoke it directly after the build:

mvn verify sonar:sonar \
  -Dsonar.host.url=https://sonar.yourcompany.com \
  -Dsonar.token=$SONAR_TOKEN

-Dsonar.qualitygate.wait=true makes the build block on the gate verdict — red gate, red build. Without it, analysis uploads and the build sails on: trap #4.

Gradle: com.github.spotbugs (spotbugsMain/spotbugsTest), built-in checkstyle, org.sonarqube (sonar) — wire analysis into check. Full mechanics in this track's Maven vs Gradle: Builds Demystified post.

The CI quality gate: ~10 lines that enforce everything

Local builds catch what developers run; CI catches what they forget:

name: quality-gates
on: [push, pull_request]
jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: "21" }
      - run: mvn -B verify sonar:sonar -Dsonar.qualitygate.wait=true
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

One command: compile, test, SpotBugs, Checkstyle, coverage, SonarQube, gate verdict — red blocks the merge. (Actions mechanics: this track's CI/CD with GitHub Actions for Java Projects post.) The quality gate belongs in CI, not in a wiki page asking developers nicely.

When it breaks: 4 static-analysis traps

Trap 1 — Analysis fatigue: 500 warnings everyone ignores

Symptom: threshold=Low on a five-year-old codebase — 500+ findings. The team decides the tool is noise and stops looking; five real bugs hide among 495 nits.

Fix: start high-confidence only, fix that list completely, gate the build on it, then ratchet down. Twelve respected findings beat 500 ignored ones.

Trap 2 — Checkstyle wars: hand-tuning 200 rules

Symptom: a sprint bikeshedding import order, ending in a 200-rule custom config nobody understands — developers start coding around the checker.

Fix: adopt google_checks.xml verbatim; change a rule only when the whole team agrees the default hurts. Decision rule: the tool owns formatting debates so humans never have them — its value is consistency, not perfection.

Trap 3 — SpotBugs suppressions without justification

Symptom: a finding looks wrong, so someone slaps @SuppressFBWarnings on the method — no comment, no ticket. Six months later nobody knows if it was a false positive or a misunderstood real bug.

Fix: every suppression carries a justification comment explaining why the tool is wrong, ideally with a test proving the code safe. Suppress the finding, never the tool.

Trap 4 — SonarQube without an enforced gate is theater

Symptom: beautiful dashboard, green "A" rating — but the build never fails on any of it. The gate sits in "warn" mode while Blocker bugs ship.

Fix: the gate must fail the build (sonar.qualitygate.wait=true, CI goes red), with a coverage condition on new code — a 90%-covered legacy codebase hides 0%-covered new files. A dashboard nobody gates on is an expensive screensaver.

What's next

The fourth gate: SpotBugs for bug patterns javac can't see, Checkstyle for the team contract, SonarQube for the trend — with a gate that fails the build. Habit: fast tools on every build, the full gate on every pull request.

Field check: put the buggy OrderValidator in a scratch Maven project with both plugins, run mvn verify, and watch it go red. Fix findings one by one until green. That red-to-green loop — not the dashboard — is what static analysis is.

Next in the track: JMH — because correct, clean code still needs to be fast, and guessing is not a measurement strategy.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number