Tooling: IntelliJ, jshell, and the Build Pipeline
You've made it to the end of the bridge. Post 1 gave you the static-type mindset, post 2 the object model and collections, post 3 dependency management — and now the part nobody warns Python developers about: in Java, the toolchain is a bigger part of the job than the language.
In Python, the interpreter is the toolchain. You write app.py, run python app.py, and you're done. Packaging, docs, profiling, and the REPL are afterthoughts you bolt on later. In Java, the equivalent surface is a whole pipeline of small, sharp tools that ship with the JDK — plus an IDE culture and a build-pipeline culture that Python simply doesn't have. This post walks the full loop: edit → compile → run → debug, the REPL you didn't know Java had, the CLI toolkit, the IDE everyone actually uses, and the build pipeline that turns source into a shippable artifact.
The whole post in one diagram — the loop you'll run ten thousand times:
1. The edit → compile → run loop (vs Python's edit → run)
In Python, the loop you know is: edit discount.py, run python discount.py, see output. There is no step in between — the interpreter reads your source directly. In Java, the loop has an extra, non-negotiable step in the middle: javac translates .java source into .class bytecode, and java runs the bytecode on the JVM. Here is the real thing, end to end:
$ cat Discount.java
public class Discount {
public static void main(String[] args) {
double total = 0.0;
for (String a : args) {
double price = Double.parseDouble(a);
total += price;
}
double rate = args.length >= 5 ? 0.15 : 0.05;
System.out.printf("Subtotal: %.2f, discount rate: %.0f%%, due: %.2f%n",
total, rate * 100, total * (1 - rate));
}
}
$ javac Discount.java && java Discount 19.99 42.50 8.00 5.25 3.75 12.00
Subtotal: 91.49, discount rate: 15%, due: 77.77
$ ls
Discount.class Discount.java
Three things to notice, each a habit change:
javac Discount.javaproducesDiscount.class— bytecode, not machine code. You never run the.javafile; you run the class by name (java Discount, no.classsuffix). This is the single most common first-day stumble:java Discount.javaworks in recent JDKs (11+) as a convenience for single files, but it hides the real pipeline — learn the two-step form first.- Command-line args arrive as
String[] args— Java'ssys.argv[1:]. Parsing them is your job (Double.parseDouble, the strict cousin offloat()). System.out.printfisprintwith C-style formatting —%.2f, and%nfor a platform-correct newline (prefer it over\nin Java).
javac has no Python equivalent — it's a required ahead-of-time translation step, the closest analogy being a type-checker like mypy that you cannot skip. java is the analog of the python command itself: it launches the runtime (the JVM) and runs your program inside it.Multi-file projects: -d and the classpath
Real programs aren't one file. Here's a two-file program — an Order class and a Billing driver — compiled together, with compiled classes kept separate from source via -d:
$ javac -d out Order.java Billing.java && java -cp out Billing
Order subtotal: 70.49
-d out tells javac where to put the .class files (keeping source and build output separate — a hygiene habit from day one). -cp out (classpath) tells java where to find classes: it is Java's PYTHONPATH. javac resolves references between your source files automatically — you don't compile Order and Billing separately or in any order.
"Compilation errors are free tests"
Now the payoff of the extra step. Watch what happens when I pass a String where a double belongs — the exact bug from post 1's type-system lesson, this time caught by the machine:
$ javac -d out Order.java Billing.java
Billing.java:4: error: incompatible types: String cannot be converted to double
order.addItem("19.99"); // deliberate: String where double belongs
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error
In Python, order.add_item("19.99") sails through python billing.py and blows up at 2 AM in production — or worse, silently concatenates. In Java, the program cannot be built until the types line up. Every compile is a whole-program consistency check over every type, every method signature, every import: thousands of assertions you never had to write. That's the principle this track keeps circling:
2. jshell: the REPL you didn't know Java had
"But what about experimenting?" — the Python developer's first objection to a compiled language. Java's answer, since JDK 9, is jshell: a real read-eval-print loop that ships with every JDK. No build files, no main method, no ceremony. Here is a real session, pasted verbatim:
$ printf 'double price = 19.99;\n...' | jshell --no-startup --execution local
| Welcome to JShell -- Version 21.0.12.1
| For an introduction type: /help intro
jshell> price ==> 19.99
jshell> | created method total(double,int)
jshell> $3 ==> 64.7676
jshell> | created method label(String)
jshell> $5 ==> "HELLO JSHELL!"
jshell> | double price = 19.99
| double $3 = 64.7676
| String $5 = "HELLO JSHELL!"
jshell>
1 : double price = 19.99;
2 : double total(double p, int qty) { return p * qty * 1.08; }
3 : total(price, 3);
4 : String label(String s) { return s.toUpperCase() + "!"; }
5 : label("hello jshell");
jshell> | Goodbye
Variables persist, methods persist, bare expressions evaluate and get auto-named ($3, $5), /vars shows your state, /list shows your history. It feels like a REPL because it is one.
--execution local: jshell's default mode launches a separate execution JVM over a localhost socket, and this sandbox blocks that socket handshake. On any normal machine the plain jshell command works. The transcript above is otherwise exactly what you'd see.jshell vs the Python REPL: an honest comparison
| Capability | Python REPL | jshell |
|---|---|---|
| Variables & functions persist across lines | Yes | Yes |
| Define methods/classes inline | def works naturally | Yes — methods and even classes, semicolons optional |
| Forward references | No — name must exist when the line runs | Allowed. Define a method that calls another method you haven't written yet; jshell warns and fixes it up when the missing piece arrives |
| Tab completion | Basic (readline) | Excellent — completes identifiers, suggests imports, and Shift+Tab v/m/i turns an expression into a variable, a method, or auto-imports a type |
Magic commands (%timeit, ? docs) | IPython's whole superpower set | No magic system. /vars, /list, /drop, /open, /save — useful, but it's a tool, not a laboratory |
| Startup time | Instant | Measured here: ~4 seconds warm (time on a trivial session: real 0m4.053s). Noticeable, but this is JVM warmup, not something wrong |
| Best use | Exploration, data poking, debugging | Trying a JDK API, sketching an algorithm, checking a type's behavior — then the code moves into a real file |
3. The JDK CLI toolkit tour
The JDK ships with a toolbox that, taken together, covers most of what Python developers reach for third-party tools to do. The table maps each tool to its Python-world equivalent — learn these names and you'll never feel lost on a Java machine:
| Tool | What it does | Python equivalent |
|---|---|---|
javac | Compiles .java → .class bytecode | None required — closest is mypy, but optional there and mandatory here |
java | Launches the JVM and runs classes | The python command itself |
jshell | REPL for Java | python -i / IPython |
javadoc | Generates HTML API docs from /** ... */ comments | pydoc / Sphinx |
jar | Packages classes + resources into a .jar (a zip with a manifest) | zipapp / shiv / wheels — but jars are executable by the runtime |
javap | Disassembles .class files — see signatures and bytecode | The dis module |
jdeps | Analyzes class-level and module-level dependencies | pipdeptree / modulefinder |
jfr / jcmd | Flight Recorder profiling and JVM diagnostics on running processes | cProfile / py-spy |
keytool | Manages TLS certificates and keystores | openssl CLI habits |
Real run: javadoc
Java's doc comments (/** ... */ with @param/@return) aren't decoration — javadoc turns them into a browsable HTML API reference, the same reference style you see on every JDK class. Real run on a small documented class:
$ javadoc -quiet -d api -author DiscountPolicy.java
DiscountPolicy.java:9: warning: use of default constructor, which does not provide a comment
public class DiscountPolicy {
^
1 warning
$ ls api | head -5
DiscountPolicy.html
allclasses-index.html
allpackages-index.html
constant-values.html
copy.svg
Note the honest warning: javadoc even nags you about an undocumented default constructor. The generated DiscountPolicy.html renders your @param itemCount and @return text into a proper method contract. The lesson for a Python developer used to docstrings that only humans read: in Java, doc comments are compiled documentation — IDEs surface them as hover help, and publishing a library without them is considered rude.
Real run: jar — packaging and running an artifact
A .jar is a zip file with a manifest; a manifest naming a Main-Class makes it directly executable. This is Java's answer to "how do I ship it":
$ javac -d out BillingCli.java
$ printf 'Manifest-Version: 1.0\nMain-Class: BillingCli\n' > manifest.txt
$ jar cfm billing-cli.jar manifest.txt -C out .
$ jar tf billing-cli.jar
META-INF/
META-INF/MANIFEST.MF
BillingCli.class
$ java -jar billing-cli.jar 19.99 42.50 8.00
Billed: 70.49
java -jar billing-cli.jar — no classpath, no class name, just run. The Python analogy is python -m zipapp producing a .pyz, except jars are the universal Java distribution unit: libraries, tools, and services all ship as jars (or as wars/ears in enterprise-land). When section 7 talks about build pipelines producing "artifacts," this is the artifact.
4. Project layout: packages are directories
Python: cityops/billing.py with an __init__.py gives you cityops.billing. Java: the directory is the package — com/cityops/billing/Money.java declares package com.cityops.billing;, no __init__ files, and the directory structure must match the package name exactly or compilation fails. Real two-package project, compiled in one shot:
$ find packages -name '*.java'
packages/com/cityops/billing/Money.java
packages/com/cityops/reporting/Summary.java
$ find packages -name '*.java' | xargs javac -d packages/out
$ find packages/out -name '*.class'
packages/out/com/cityops/billing/Money.class
packages/out/com/cityops/reporting/Summary.class
$ java -cp packages/out com.cityops.reporting.Summary 19.99 42.50 8.00
Day total: $70.49 across 3 orders
Notice: you run the class by its fully qualified name (com.cityops.reporting.Summary), and javac -d recreated the package directory tree under out/ automatically. The convention you'll see in every real project builds on this:
my-project/
├── src/main/java/com/cityops/billing/Money.java # production code
├── src/test/java/com/cityops/billing/MoneyTest.java # tests mirror the tree
└── src/main/resources/application.properties # non-code resources
That src/main/java / src/test/java split isn't a suggestion — it's the Maven/Gradle standard layout (section 7), and every tool in the ecosystem assumes it. Tests live in a parallel tree with the same package names so they can access package-private members — Java's answer to "how do I test internals."
5. IntelliJ IDEA — why Java developers live in an IDE [NOT RUN]
Python developers often treat the IDE as a fancy text editor: VS Code + Pylance is lovely, but you could do the whole job in vim and lose little. In Java, the IDE is load-bearing infrastructure, and the reason is the payoff of post 1's lesson: static types make whole-program refactoring safe, and the IDE is the machine that performs it.
Refactoring: the superpower Python can't fully have
Rename a method in a Python codebase and you're doing a text search, hoping you caught every call site — dynamic dispatch means no tool can be sure. Rename a method in IntelliJ (Shift+F6) and the IDE rewrites every call site across the project provably correctly, because the compiler-grade type information tells it exactly which references are yours. Same for Extract Method, Change Signature (add a parameter — every caller updated), Move Class between packages (imports rewritten), and Inline Variable. This is why Java codebases stay malleable at hundreds of thousands of lines: the IDE + the type system make large-scale restructuring a ten-second, zero-fear operation.
The debugger: breakpoints beat print()
Python developers debug with print() and pdb. The IntelliJ debugger is pdb with the training wheels off and a jet engine attached: click a gutter to set a breakpoint (conditional breakpoints — "stop only when total > 1000"), and when it hits you get the full call stack, every local variable, and Evaluate Expression — run arbitrary Java against the live, paused program state. HotSwap even lets you edit a method body and keep debugging without restarting. Once you've evaluated an expression against a paused production-like state, going back to re-running with new print statements feels like sending letters.
Inspections: a linter with a compiler's brain
IntelliJ continuously runs hundreds of inspections — think ruff/flake8 crossed with mypy, with full type information: it flags the unreachable code, the Optional.get() without a presence check, the string concatenation in a loop, the resource you forgot to close, and offers one-click fixes. Many Java developers experience inspections as "the IDE pair-programming with me" — it catches the bug class from section 1's compile errors and keeps going into logic-level suggestions.
6. The build pipeline: Maven, Gradle, and the road to "release"
mvn test, the lifecycle phases) is standard, documented tool behavior.Post 3 introduced dependency management — declaring libraries instead of vendoring jars. Maven and Gradle are the machines that turn that declaration (plus your source) into a tested, packaged artifact, repeatably, on any machine. If pip + pyproject.toml is "install my deps," Maven/Gradle is "install my deps, compile everything, run all tests, and hand me a shippable jar" — the whole pipeline as one command.
Maven: convention over configuration
Maven's pom.xml ("project object model") declares coordinates (groupId:artifactId:version — the same vocabulary as post 3), dependencies, and build plugins. A minimal real-world-style pom.xml:
<!-- pom.xml — NOT RUN (Maven not installed in this lab) -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.cityops</groupId>
<artifactId>billing-cli</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Then the lifecycle — Maven's fixed pipeline of phases, each building on the last:
mvn validate # is the project structurally sound?
mvn compile # javac over src/main/java (what we did by hand in section 1)
mvn test # compile + run everything under src/test/java (JUnit)
mvn package # run the full pipeline, then jar it up → target/billing-cli-1.0.0.jar
mvn verify # package + integration checks
Running mvn package executes every earlier phase — you can't package what didn't compile, and you can't package what didn't pass tests. That ordering is the point: the build pipeline encodes "compilation errors are free tests" (section 1) into an enforced sequence. mvn test alone is the command Java developers run dozens of times a day — it's pytest, except the compile step runs first and for free.
Gradle: the same pipeline, scripted
Gradle covers the same ground with a Groovy/Kotlin DSL instead of XML, and an obsessive focus on incremental builds (only recompile what changed — big projects care enormously). The equivalent build.gradle:
// build.gradle — NOT RUN (Gradle not installed in this lab)
plugins {
id 'java'
}
group = 'com.cityops'
version = '1.0.0'
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
repositories {
mavenCentral() // where dependencies come from (post 3's vocabulary)
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3'
}
tasks.named('test') {
useJUnitPlatform()
}
gradle build ≈ mvn verify: compile, test, package into build/libs/billing-cli-1.0.0.jar. Which to learn? Maven for reading the enormous existing corpus (most enterprise Java is Maven); Gradle for Android and for greenfield projects where build speed matters. The concepts — coordinates, scopes, lifecycle, artifact — transfer completely.
From jar to "release": CI and artifacts
On your machine, mvn package makes a jar. In a team, a CI pipeline (typically GitHub Actions) runs the same lifecycle on every push — compile, test, package — so "it works on my machine" stops being a defense. A standard sketch:
# .github/workflows/build.yml — NOT RUN (conceptual sketch)
name: build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }
- run: mvn -B verify # the whole pipeline: compile → test → package
- uses: actions/upload-artifact@v4
with: { name: billing-cli, path: target/*.jar }
And "release" itself is a versioning discipline, not a button: versions like 1.0.0 are immutable once published to a repository (Maven Central for open source, a private Nexus/Artifactory in-house), while 1.1.0-SNAPSHOT means "the moving target that will become 1.1.0." Downstream projects depend on released versions — never on snapshots — which is how the ecosystem from post 3 stays reproducible. The jar that CI uploads is the release artifact: the exact bytes that get deployed.
pyproject.toml + pip covers dependencies; Maven/Gradle cover dependencies plus compilation, testing, and packaging in one reproducible lifecycle. The closest Python analog to the full pipeline is a CI workflow that runs pip install → pytest → python -m build — Java just standardized that sequence into the build tool itself, twenty years ago.7. Debugging workflow: read the stack trace first
When a Java program dies, it dies loudly and precisely — and the stack trace is the first tool in the debugging workflow, before the debugger, before print statements. Here's a real one, from a real run. This program dereferences a null record:
$ cat NullTrace.java
public class NullTrace {
record Item(String sku, double price) {}
public static void main(String[] args) {
Item item = null;
System.out.println(item.sku().toUpperCase());
}
}
$ javac NullTrace.java && java NullTrace
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "NullTrace$Item.sku()" because "<local1>" is null
at NullTrace.main(NullTrace.java:5)
Read it inside-out, the way experienced Java developers do:
- The exception type (
NullPointerException) tells you the category — the single most common Java runtime failure, and the reason post 2'sOptionalexists. - The message does the detective work for you: "Cannot invoke NullTrace$Item.sku() because <local1> is null". Since JDK 14, "helpful null messages" pinpoint which reference was null in a chained expression — compare Python's bare
AttributeError: 'NoneType' object has no attribute 'sku', which never tells you which link ina.b.cwasNone. - The stack frames (
at NullTrace.main(NullTrace.java:5)) give the exact file and line — read top frame first for where it blew up, then down the stack for how you got there. (In a real app there'd be dozens of frames; the application frames are at the top, framework internals below.)
The workflow, in order of escalation:
- Read the trace. Most NPEs and
IndexOutOfBoundsExceptions are solved right here — the message plus the line number is the diagnosis. - Reproduce in jshell. Paste the suspect expression into a REPL session and poke at it — five seconds, no rebuild.
- Reach for the IDE debugger (section 5) when the trace isn't enough: conditional breakpoint at the failing line, evaluate the expression against live state, watch which reference is null instead of guessing.
- Add a test capturing the failure, so the build pipeline (section 6) catches the regression forever. The loop closes: debug → test → the compiler and CI guard it from here on.
jdb. The JDK ships a command-line debugger (jdb), the analog of pdb. It works, and it's what you'd use over SSH with no GUI — but in practice, everyone uses the IDE debugger from section 5. Know jdb exists; reach for IntelliJ first.Field check
Before you close the track, prove the toolchain is yours — everything here runs on the plain JDK, no installs:
- Compile-loop drill: write a two-file program (any
Order/Billingpair of your own), compile withjavac -d out, run withjava -cp out. Then deliberately break a type, read the compiler error, and fix it. Notice how the error names the file, line, and the exact incompatible types. - jshell sketch: open
jshell, define a method, call it before defining a helper it uses (forward reference), then define the helper and watch the earlier method start working. Run/varsand/list. Time the startup — compare withpython3. - Docs + packaging: write a class with real
/** */doc comments, runjavadoc -d api, and open the HTML. Thenjarit with aMain-Classmanifest and run it withjava -jar. - Trace reading: take the
NullTraceprogram above, extend the chain (item.sku().toUpperCase().charAt(0)), and confirm the helpful-null message still names the null link. Then fix it withOptional(post 2) instead of a null check. - Not-run inventory: list the three things from this post you couldn't run here (Maven, Gradle, IntelliJ) and write one sentence for each on what you'd do first with it on a real machine. Knowing what you haven't touched is part of knowing the toolchain.
What's next — closing the bridge
That's the bridge crossed. Four posts, one arc:
- Post 1 — the static-type mindset: types as machine-checked documentation, and why the compiler catching your bug is a feature, not friction.
- Post 2 — objects, collections, and
Optional: Java's object model and standard library, mapped against the Python you already know. - Post 3 — dependency management: coordinates, repositories, and declaring libraries instead of vendoring them.
- Post 4 — this post: the toolchain that turns all of the above into a daily workflow — compile loop, jshell, JDK utilities, the IDE, the build pipeline, and reading stack traces.
You now know enough Java to be dangerous in the right direction: you can read Java code, write small programs, navigate the tooling, and — most importantly — you know what you don't know yet. The bridge got you across; now it's time to walk the road properly.
Your next step is the full curriculum: Continue: Java Learning Roadmap 2026 — the complete, ordered map of every track. Start at Core Java, post 1, and work the fundamentals the way Java developers actually learn them: one compiled, tested, packaged concept at a time.
See you on the other side — bring your compiler. It tests for free.
Comments
Post a Comment