Guide

Java fundamentals explained

A payment processor settles thousands of card transactions per minute. Each charge must be idempotent, auditable, and consistent across three data centers. The service that does this work is often written in Java: a statically typed, object-oriented language that compiles to portable bytecode and runs on the Java Virtual Machine (JVM). Java’s longevity is not nostalgia — the JVM delivers mature garbage collection, excellent JIT optimization, decades of battle-tested libraries, and a hiring pool that spans banking, Android, and cloud-native backends. Modern Java (17 LTS and 21 LTS) adds records, pattern matching, and virtual threads that make concurrent I/O far cheaper than the thread-per-request model of the 2000s. This guide covers the JVM and JDK, classes and interfaces, generics and the Collections Framework, exception handling, Streams and modern syntax, build tools, concurrency basics, a Harbor Payments ledger worked example, a language decision table, common pitfalls, and a production checklist — alongside our Go, Python, and TypeScript language guides.

What Java is: JVM, JDK, and bytecode

Java source (.java files) compiles to bytecode (.class files) via javac. The JVM executes bytecode on any supported OS — the original “write once, run anywhere” pitch still holds for server-side code shipped as JAR files or container images. The JDK (Java Development Kit) bundles the compiler, runtime, standard library, and tools like jlink for custom runtimes. The JRE (runtime only) is largely subsumed by modular JDK images since Java 11.

Long-term support (LTS) releases matter in enterprise: Java 17 and Java 21 are the current adoption targets. Non-LTS releases (18–20, 22+) preview features that may land in the next LTS. Pin your production JDK version in CI, Docker base images, and deployment manifests — mixing 11 and 21 on the same cluster causes subtle bytecode and library incompatibilities.

Why the JVM still wins workloads

  • JIT compilation — hot methods compile to native code after profiling; throughput rivals hand-tuned C++ on long-running services.
  • Garbage collection — generational and low-latency collectors (G1, ZGC, Shenandoah) trade memory for developer velocity.
  • Ecosystem depth — JDBC drivers, Kafka clients, gRPC, Spring, Hibernate, and observability agents are first-class.
  • Polyglot JVM — Kotlin, Scala, and Clojure compile to the same bytecode and call Java libraries directly.

Classes, objects, and the type system

Everything in Java (except primitives) is an object referenced by pointer-like handles. A class defines fields and methods; new Order("ord_42", 1999) allocates on the heap and returns a reference. Access modifiers (public, protected, package-private, private) control visibility across packages.

public record PaymentIntent(String id, long amountCents, String currency) {
    public PaymentIntent {
        if (amountCents <= 0) throw new IllegalArgumentException("amount");
    }
}

Records (Java 16+) are immutable data carriers with generated constructors, accessors, equals, and hashCode — use them for DTOs and domain events instead of verbose POJOs. Sealed classes restrict which types may extend a hierarchy, pairing well with pattern matching in switch (Java 21) for exhaustive domain modeling.

Primitives vs wrappers

Primitives (int, long, double, boolean) live on the stack or inline in objects; wrapper classes (Integer, Long) box values for generics and nullable fields. Autoboxing hides the conversion but adds allocation pressure in hot loops — prefer primitives in performance-critical code and OptionalInt-style APIs where absence is meaningful.

Inheritance, interfaces, and composition

Java supports single inheritance of classes (extends) and multiple inheritance of type through interfaces (implements). Favor composition over inheritance: inject dependencies through constructors rather than deep class hierarchies that break when requirements shift.

public interface LedgerRepository {
    void saveEntry(LedgerEntry entry);
    List<LedgerEntry> findByAccount(String accountId);
}

public final class PostgresLedgerRepository implements LedgerRepository {
    private final DataSource dataSource;
    public PostgresLedgerRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    // ...
}

Abstract classes share code among related subclasses; interfaces define contracts. Default methods on interfaces (Java 8+) let you evolve APIs without breaking implementors. Keep interfaces small — the interface segregation principle applies to Java service boundaries as much as HTTP resources.

Generics and the Collections Framework

Generics add compile-time type parameters: List<PaymentIntent> prevents adding a raw String at compile time. Type erasure removes generic type information at runtime — you cannot do new T() without reflection or factories. Wildcards (? extends T, ? super T) express producer/consumer variance when writing utility methods over collections.

The Collections Framework in java.util is the default toolkit:

  • List — ordered, indexed (ArrayList for random access, LinkedList rarely wins today).
  • Set — unique elements (HashSet, LinkedHashSet, TreeSet for sorted).
  • Map — key-value (HashMap, ConcurrentHashMap for thread-safe maps).
  • Queue / Deque — FIFO work queues (ArrayDeque, LinkedBlockingQueue).

Prefer interfaces in field types (Map<String, Order>) and concrete classes in constructors. Immutable collections via List.of(...) and Map.copyOf(...) (Java 9+) reduce accidental mutation when passing data across threads.

Exception handling: checked vs unchecked

Java distinguishes checked exceptions (must be declared or caught, e.g. IOException) from unchecked RuntimeException subclasses (e.g. IllegalArgumentException). Checked exceptions force callers to handle failure modes but can clutter APIs with throws clauses. Modern libraries and Spring often wrap checked exceptions in unchecked ones; domain code should throw meaningful runtime exceptions with context rather than swallowing errors.

try (Connection conn = dataSource.getConnection()) {
    // JDBC auto-closes via try-with-resources (Java 7+)
    insertEntry(conn, entry);
} catch (SQLException ex) {
    throw new LedgerPersistenceException("save failed: " + entry.id(), ex);
}

Try-with-resources closes AutoCloseable objects reliably — always use it for JDBC connections, streams, and HTTP clients. Never catch Exception broadly without rethrowing or logging; empty catch blocks hide production incidents.

Streams, lambdas, and modern Java syntax

Java 8 introduced lambda expressions and the Stream API for functional-style collection processing:

long totalCents = payments.stream()
    .filter(p -> p.currency().equals("USD"))
    .mapToLong(PaymentIntent::amountCents)
    .sum();

Streams can be sequential or parallel; parallel streams help CPU-bound transforms on large in-memory datasets but rarely beat a database GROUP BY for aggregation. Use Optional<T> for return types that may be absent — never pass Optional as a method parameter (overloads or nullable annotations are clearer).

Text blocks (Java 15+) simplify multiline SQL and JSON templates. var (local variable type inference, Java 10+) reduces boilerplate when the right-hand type is obvious: var entries = repository.findByAccount(id);

Build tools: Maven and Gradle

Most Java projects use Maven (pom.xml) or Gradle (build.gradle.kts). Both resolve dependencies from Maven Central, compile sources, run tests, and package JARs. Maven favors convention over configuration; Gradle offers a programmable DSL and faster incremental builds in large monorepos.

  • CoordinatesgroupId:artifactId:version pin library versions; use a BOM (Bill of Materials) for Spring ecosystems.
  • Multi-module — split API, domain, and infrastructure modules so business logic does not depend on web frameworks.
  • Reproducible builds — commit lockfiles or use Gradle dependency locking; scan with OWASP Dependency-Check or Snyk in CI.

Package fat JARs with spring-boot-maven-plugin or Gradle’s bootJar task for executable microservices; use jlink for trimmed CLI tools where image size matters.

Concurrency and virtual threads

Traditional Java servers allocated one platform thread per request — simple but memory-heavy at tens of thousands of concurrent connections. Virtual threads (Java 21, Project Loom) are lightweight threads scheduled on a small pool of carrier threads; blocking I/O no longer wastes an OS thread.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> processWebhook(payload));
}

Use java.util.concurrent utilities (ExecutorService, CompletableFuture, ConcurrentHashMap) for structured concurrency. Avoid manual thread creation in request handlers. For CPU-bound work, size thread pools to available cores; for I/O-bound work with virtual threads, focus on backpressure and downstream timeouts instead of thread count.

Synchronization primitives (synchronized, ReentrantLock, volatile) still matter for shared mutable state — immutability and message-passing (queues) reduce lock contention in payment and ledger systems.

Worked example: Harbor Payments ledger service

Harbor Payments needs a double-entry ledger: every card capture writes a debit and credit pair, idempotent on webhook replay, queryable by account for support dashboards.

  1. Domain model — immutable record LedgerEntry(UUID id, String debitAccount, String creditAccount, long amountCents, Instant postedAt) and a LedgerService that validates balanced entries before persistence.
  2. RepositoryLedgerRepository interface with a JDBC implementation using PostgreSQL and an INSERT ... ON CONFLICT DO NOTHING idempotency key on webhook event ID.
  3. HTTP layer — Spring Boot or plain com.sun.net.httpserver for a POST /webhooks/capture endpoint; validate HMAC signature before parsing JSON body.
  4. Transactions — wrap debit/credit inserts in a single JDBC transaction; rollback on any constraint violation.
  5. Observability — structured JSON logs with correlation IDs; Micrometer metrics for entries/sec and duplicate webhook rate.
  6. Deploy — fat JAR in a Docker image on Eclipse Temurin 21; health check on /actuator/health; horizontal scale behind a load balancer with sticky sessions disabled (stateless handlers).

Result: a service that survives webhook retries without duplicate postings, exposes clear seams for testing (mock repository in unit tests, Testcontainers Postgres in integration tests), and matches the reliability expectations of card networks.

Language decision table

NeedPrefer JavaConsider instead
Enterprise microservices with Spring ecosystemYes — mature tooling and hiring poolKotlin on JVM for less boilerplate
Android mobile appsYes (with Kotlin as modern default)Kotlin-first for new projects
Small static binary CLINo — JVM startup and image sizeGo or Rust
Data science / ML notebooksNoPython
High-throughput HTTP with minimal depsMaybe with virtual threadsGo for simpler deploy artifacts
Browser / full-stack TypeScriptNoNode.js / TypeScript
Hard real-time or embeddedNoC, C++, Rust
Long-running batch on JVM (Spark, Kafka streams)Yes — Scala/Java native fitPython for small ETL scripts

Common pitfalls

  • NullPointerException by default — validate inputs early; use records and Optional returns; enable static analysis (NullAway, Checker Framework).
  • Mutating shared collections — defensive copies on getters; prefer immutable collections for cross-thread handoff.
  • Raw typesList list = new ArrayList() loses type safety; always parameterize generics.
  • Parallel stream misuse — parallelizing small lists or I/O-bound work adds overhead without benefit.
  • Logging PII or card data — mask account numbers; never log full PAN or CVV in payment services.
  • Default timezone assumptions — store Instant in UTC; convert at display with explicit ZoneId.
  • Classpath dependency conflicts — duplicate SLF4J bindings or conflicting Guava versions cause subtle runtime failures; use dependency convergence in Maven Enforcer.
  • Ignoring GC and heap tuning — monitor pause times; choose G1 or ZGC for latency-sensitive services.

Production checklist

  • Pin LTS JDK (17 or 21) in CI, Docker, and production; run jlink or distroless Temurin images.
  • Multi-module layout separating domain logic from framework adapters.
  • Unit tests (JUnit 5) plus integration tests with Testcontainers for Postgres/Kafka.
  • Structured logging (Logback JSON encoder); correlation IDs propagated from HTTP headers.
  • Metrics via Micrometer/Prometheus; traces via OpenTelemetry agent.
  • Dependency scanning in CI; renovate or Dependabot for security patches.
  • Graceful shutdown hooks flushing buffers and draining executor queues.
  • Connection pools (HikariCP) sized to database limits; statement timeouts on JDBC.
  • Secrets from environment or vault — never hard-coded in application.properties committed to git.
  • Load test with realistic webhook replay and idempotency scenarios before peak traffic.

Key takeaways

  • Java compiles to portable JVM bytecode with a deep enterprise ecosystem and modern language features through Java 21.
  • Use records, interfaces, and immutability to keep domain models clear; prefer composition over deep inheritance.
  • The Collections Framework and Streams handle most in-memory data transforms; push heavy aggregation to SQL.
  • Virtual threads make blocking I/O cheap again — ideal for webhook and REST services without reactive complexity.
  • Choose Java when JVM maturity, hiring, and library breadth outweigh the deploy footprint of lighter runtimes.

Related reading