Java Fundamentals for Interview & Production Code

Core Java, done properly: the language, the object model, the standard library, and the habits that separate code that compiles from code that's actually production-ready — the on-ramp before advanced DSA, LLD, and system-design interview prep.

9 Modules 9 Runnable Labs Java 8+

Java Language Foundations

The building blocks everything else in this course assumes you already have solid: how Java actually runs, why primitives and objects behave differently, and the immutability rules that shape idiomatic Java code.

JVM vs JRE vs JDK

The JDK (Java Development Kit) is what you install to write Java: it bundles the compiler (javac), debugging and profiling tools, and a full JRE. The JRE (Java Runtime Environment) is what you need to run Java: the JVM plus the standard class library (java.lang, java.util, and the rest) as compiled .class files. The JVM (Java Virtual Machine) is the actual execution engine — a specification, with implementations like HotSpot and OpenJ9, that loads bytecode, verifies it, interprets or JIT-compiles it to native machine code, and manages memory through garbage collection.

In practice: you develop against a JDK, you ship a JRE (or, increasingly, a slimmer custom runtime built with jlink containing only the modules your app needs), and the JVM is what's actually running when java -jar app.jar executes. This distinction is a real production concern, not just trivia — a slim container base image built with jlink doesn't need the full JDK on it, which is a genuine reduction in image size and attack surface.

import java.util.*;

public class Main {
    // javac compiles .java source to .class bytecode (a JDK tool)
    // java   launches the JVM, which loads and executes that bytecode
    /**
     * Nested class containing a demonstration main method.
     */
    public static class Hello {
        /**
         * Prints a message indicating the compilation and execution flow.
         * @param args command-line arguments (unused)
         */
        public static void main(String[] args) {
            // Output the demonstration message to standard output
            System.out.println("Compiled by javac, executed by the JVM");
        }
    }

    /**
     * Entry point that delegates to Hello.main to demonstrate nested class invocation.
     * @param args command-line arguments passed through to Hello.main
     */
    public static void main(String[] args) {
        // Delegate execution to the nested Hello class's main method
        Hello.main(args);
    }
}
Compilation, Bytecode, and JIT

javac doesn't produce machine code — it produces bytecode: a portable instruction set for a stack-based virtual machine, stored in .class files. When the JVM loads a class, a bytecode verifier checks it's well-formed and doesn't violate memory or type safety before anything executes. Initially the JVM interprets bytecode instruction by instruction, which is slow; the JIT (Just-In-Time) compiler in HotSpot watches which methods run often — "hot" methods — and compiles those to native machine code at runtime, applying optimizations like inlining and dead-code elimination that a purely static, ahead-of-time compiler couldn't, because the JIT has real runtime profile data (which branch actually gets taken, whether a call site is monomorphic).

This is why a long-running Java service often gets measurably faster in its first minutes of traffic as the JIT warms up, and why naive microbenchmarks that skip a warm-up phase produce misleading numbers.

javac Hello.java   // -> Hello.class (bytecode, not native machine code)
java Hello          // JVM loads Hello.class, verifies it, interprets/JIT-compiles it
Primitives, Wrappers, and Autoboxing Pitfalls

Java has eight primitive types (int, long, double, boolean, char, byte, short, float) that are not objects — no header, no identity, no null, stored directly on the stack or inline inside objects. Each has a corresponding wrapper class (Integer, Long, Double, ...) used wherever the language needs an Object: generics, collections, and anywhere "no value" needs to be representable. Autoboxing/unboxing converts between them automatically, and the automation hides two real bugs.

First, Integer caches boxed values from -128 to 127 (the Integer Cache), so two Integer variables both boxed from 100 can be == (same cached object), while the same code with 200 is not, because 200 falls outside the cache range and two distinct objects get allocated. Second, unboxing a null wrapper throws NullPointerException at the unboxing point — extremely common when a nullable Integer field (say, from a database column that allows NULL) gets used directly in primitive arithmetic.

import java.util.*;

/**
 * Demonstrates Integer cache behavior and null unboxing hazard.
 * - Values -128 to 127 are cached, so == works for 127.
 * - Values outside cache (e.g., 200) create distinct objects, so == fails.
 * - Unboxing a null Integer throws NullPointerException.
 */
public class Main {
    public static void main(String[] args) {
        // Integer cache demo: 127 is within [-128, 127] cache range
        Integer a = 127, b = 127;
        System.out.println(a == b); // true - both come from the cache

        // Integer cache demo: 200 is outside cache range, new objects allocated
        Integer c = 200, d = 200;
        System.out.println(c == d); // false - outside the cached range, distinct objects

        // Null unboxing hazard: null Integer used in primitive arithmetic
        Integer count = null;
        int total = count + 1; // throws NullPointerException at the unboxing
    }
}
String Immutability and the String Pool

String objects are immutable — every apparent "modification" (concatenation, substring, replace, toUpperCase) returns a brand-new String, leaving the original untouched. This is deliberate: immutability makes String safe to share across threads with no synchronization, safe to use as a HashMap key (its hashCode is computed once and cached, since content can never change), and safe to pool.

String literals are automatically interned into a shared pool, so two identical literals reference the exact same object; new String(...), on the other hand, forces a fresh heap allocation outside the pool even when the content is identical. Because of immutability, concatenating in a loop with + is a classic performance trap — every + allocates a new String — which is exactly why StringBuilder exists for building strings incrementally.

import java.util.*;

public class Main {
    // Demonstrates string interning and equality vs content equality
    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";
        System.out.println(a == b); // true - both point at the pooled literal

        String c = new String("hello");
        System.out.println(a == c);       // false - c is a distinct heap object
        System.out.println(a.equals(c));  // true - same content
    }
}
== vs .equals()

== on objects compares references — whether two variables point at the exact same object in memory. .equals() compares whatever a class defines as "meaningfully equal" — Object's default .equals() is just == unless a class overrides it, but String and the wrapper classes override it to compare content. This is why new String("x").equals("x") is true even though == between them is false.

The classic interview trap is comparing boxed Integer objects outside the cache range with == and being surprised by false — always use .equals() (or unbox to a primitive int, where == correctly compares values) when you want value equality. For your own classes, == remains reference identity unless you deliberately override equals(); using such a class in a HashSet or as a HashMap key without a correct equals()/hashCode() pair silently breaks lookups.

/**
 * Main class demonstrating the difference between reference equality (==)
 * and value equality (.equals()) for Strings and boxed Integers.
 */
public class Main {
    /**
     * Entry point that runs the comparison examples from the course snippet.
     */
    public static void main(String[] args) {
        String s1 = new String("abc");
        String s2 = new String("abc");
        System.out.println(s1 == s2);       // false: different objects
        System.out.println(s1.equals(s2));  // true: same content

        Integer i1 = 1000, i2 = 1000;
        System.out.println(i1 == i2);       // false: outside the Integer cache
        System.out.println(i1.equals(i2));  // true
    }
}
Arrays vs Collections

Arrays are fixed-size, can hold primitives directly (int[] stores raw ints inline, no boxing), and have a compact, cache-friendly memory layout with O(1) index access — but they cannot be resized, and a raw array of object references gives up nearly all the type safety and convenience methods a collection provides. Collections (ArrayList, HashMap, ...) are dynamically sized, hold only objects (primitives get autoboxed, costing memory and allocation overhead — an ArrayList<Integer> stores boxed Integer objects, not raw ints), and come with a large, well-tested API surface: sorting, searching, streaming, thread-safe variants.

Reach for an array when the size is known up front, you need maximum performance in a tight numeric loop, or you're working with a fixed-size low-level structure. Reach for a collection everywhere else — especially the common case where size isn't known ahead of time, or the data needs to grow, shrink, or pass through APIs expecting Collection/List types.

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

public class Main {
    public static void main(String[] args) {
        // Original snippet lines:
        int[] scores = new int[5];       // fixed size, primitive, fast, no autoboxing
        scores[0] = 100;

        List<Integer> dynamicScores = new ArrayList<>(); // grows, but boxes every int
        dynamicScores.add(100);
        dynamicScores.add(95);
        // Demo: print results
        System.out.println("scores[0] = " + scores[0]);
        System.out.println("dynamicScores size = " + dynamicScores.size());
        System.out.println("dynamicScores contents = " + dynamicScores);
    }
}