1. Java Foundations and Program Structure
This chapter establishes the ground every later chapter stands on: what the Java platform actually is, how source text becomes a running process, and the core syntax for data, expressions, control flow, and the two aggregate types you will touch daily — arrays and strings. None of this is object-oriented design yet, but writing good classes is impossible without fluency here, and most "mysterious" Java bugs trace back to a misunderstanding on one of these pages.
<a id="11-the-java-platform"></a>
1.1 The Java Platform
Java is not just a language; it is a language plus a virtual machine plus a standard library, and knowing which of the three you are talking about removes most of the confusion beginners have about how Java runs.
History and Design Goals of Java
In 1991 a team at Sun Microsystems led by James Gosling started the Green Project, aimed at software for consumer devices such as set-top boxes. The constraint was brutal: the code had to run on a zoo of chips from different manufacturers, and a crash in a living room appliance could not be patched easily. C and C++ were the obvious tools, but both compile to one specific processor, and both let a stray pointer corrupt memory silently.
The team's answer was to stop compiling to a real processor and start compiling to an imaginary one, then write a small program for each real device that pretends to be that imaginary processor. The language was first called Oak, renamed Java, and released in 1995 just as the web created enormous demand for code that could be shipped to unknown machines.
The original whitepaper listed a set of goals ("simple, object-oriented, distributed, robust, secure, architecture-neutral, portable, high-performance, multithreaded, dynamic"). Each one is a concrete decision, not marketing:
| Design goal | Concrete mechanism in Java |
|---|---|
| Simplicity | No manual memory management, no pointer arithmetic, no multiple class inheritance, no operator overloading |
| Robustness | Mandatory type checking, array bounds checks, null checks, exceptions |
| Security | Bytecode verification, classloader isolation, no direct memory access |
| Architecture-neutral | Compile to bytecode for a virtual machine, not to native code |
| Portability | Fixed sizes for primitive types, defined arithmetic semantics |
| High performance | Just-In-Time compilation of hot code to native instructions |
| Multithreaded | Threads, locks, and a memory model built into the language |
1991 Green Project starts (Gosling, Sheridan, Naughton) -- language "Oak"
1995 Java 1.0 announced; applets make it famous on the early web
1998 Java 2 (J2SE/J2EE/J2ME split); collections framework arrives
2004 Java 5: generics, annotations, enums, enhanced for -- a huge language jump
2006 Java open-sourced as OpenJDK
2010 Oracle acquires Sun
2014 Java 8: lambdas and streams -- functional programming goes mainstream
2017 Java 9: modules; six-month release cadence begins
2018+ LTS releases: 11 (2018), 17 (2021), 21 (2023), 25 (2025)
Two consequences of this history still shape daily work. First, Java is obsessive about backward compatibility: code compiled in 2005 generally still runs today, which is why some awkward APIs were never removed. Second, since 2017 a new feature release ships every six months, with a Long-Term Support (LTS) release roughly every two years; most companies target an LTS version such as 17, 21, or 25 rather than the newest release.
Key Takeaways
- Java was designed for heterogeneous consumer hardware, which is why it targets a virtual machine instead of a real CPU.
- Safety features (no pointer arithmetic, bounds checks, garbage collection) are deliberate trades of raw control for fewer catastrophic bugs.
- Backward compatibility is a core value, so old code keeps running and old APIs linger.
- Since Java 9, releases ship every six months; production systems normally sit on an LTS version.
🧪 Practice
- For each of the seven design goals in the table, name one bug class it prevents that a C program would be vulnerable to.
- Find which Java version introduced
var, records, and text blocks, then decide the minimum version a project using all three must target.- Interview: Why did Java choose a virtual machine instead of compiling directly to native code, and what does that cost at runtime? (Hint: think about what has to happen the first time a method is executed.)
JDK, JRE, and JVM Distinctions
Beginners routinely install the wrong thing or misread an error message because these three acronyms get used interchangeably. They are nested, and the distinction is about who needs what.
- The JVM (Java Virtual Machine) is the engine that executes bytecode. It
is a specification, and there are several implementations (HotSpot, OpenJ9,
GraalVM). It knows nothing about
.javafiles. - The JRE (Java Runtime Environment) is the JVM plus the standard class
library (
java.lang,java.util,java.io, ...). This is the minimum to run an application. - The JDK (Java Development Kit) is the JRE plus development tools:
javacthe compiler,javadoc,jar,jshell,jdb,javap, and more. This is the minimum to build an application.
The car analogy is worth the cliché: the JVM is the engine, the JRE is the complete drivable car, and the JDK is the car plus the workshop and tools you need to build another one.
+---------------------------------------------------------------+
| JDK (develop + run) |
| |
| javac javadoc jar jshell javap jdb jlink ... |
| |
| +-------------------------------------------------------+ |
| | JRE (run only) | |
| | | |
| | Standard library: java.lang, java.util, java.io, | |
| | java.net, java.time, ... | |
| | | |
| | +-----------------------------------------------+ | |
| | | JVM | | |
| | | class loader | bytecode verifier | | |
| | | interpreter | JIT compiler | GC | | |
| | +-----------------------------------------------+ | |
| +-------------------------------------------------------+ |
+---------------------------------------------------------------+
java -version # reports the runtime version; present in both JRE and JDK
javac -version # reports the compiler version; JDK only
jshell # interactive REPL for experimenting; JDK only (Java 9+)
# The classic symptom of installing only a runtime:
$ javac Hello.java
javac: command not found
One modern wrinkle: since Java 11, Oracle and most vendors no longer ship a
standalone JRE. You install a JDK, and if you need a slimmed-down runtime for
deployment you build one yourself with jlink, which bundles only the modules
your application uses. In containerised deployments this is how images get
small.
| Component | Contains | Who needs it |
|---|---|---|
| JVM | Execution engine only | Nobody installs it alone |
| JRE | JVM + standard library | End users running an app |
| JDK | JRE + compiler and tools | Every developer |
Key Takeaways
- JVM executes bytecode, JRE adds the standard library, JDK adds the tools.
javaclives in the JDK only; "command not found" almost always means a runtime-only installation or a brokenPATH.- The JVM is a specification with several competing implementations.
- Standalone JREs are obsolete; use
jlinkto produce a minimal runtime.
🧪 Practice
- Run
java -versionandjavac -versionon your machine and confirm they report the same version. If they differ, explain what that implies.- List which of these are JDK-only tools:
java,javac,jar,javap,jshell.- Interview: A colleague says "we only need the JRE on the production server." Is that still good advice in a modern Java version? (Hint: consider what vendors ship today and what
jlinkwas created for.)
Bytecode and Platform Independence
A C compiler produces instructions for one processor family; move the binary to a different architecture and it is meaningless. Java breaks that coupling by compiling to bytecode: a compact instruction set for a machine that does not physically exist. The only thing that must be ported to a new platform is the JVM itself, and that work is done once by the JVM vendor rather than by every application developer.
Bytecode lives in .class files. Each instruction is one byte of opcode plus
optional operands, and the machine model is a stack machine: operands are
pushed onto an operand stack and instructions consume them, rather than being
addressed in named registers.
public class Add {
public static int sum(int a, int b) {
return a + b;
}
}
$ javac Add.java
$ javap -c Add.class
public static int sum(int, int);
Code:
0: iload_0 // push local variable 0 (parameter a) onto the stack
1: iload_1 // push local variable 1 (parameter b) onto the stack
2: iadd // pop two ints, add them, push the result
3: ireturn // pop the int result and return it
Note the i prefix on every opcode: bytecode is typed, and the verifier
rejects a class that tries to, for example, add two references. This is a
large part of why the JVM can run untrusted code safely.
ONE compilation MANY runtimes
Hello.java ---javac---> Hello.class ------+---> JVM on Linux/x64
(source) (bytecode) +---> JVM on macOS/ARM
+---> JVM on Windows/x64
+---> JVM on Linux/ARM (a Pi)
The slogan is "write once, run anywhere". The honest version, coined by skeptics early on, is "write once, test everywhere": bytecode is portable, but file path separators, default character encodings, locale-dependent formatting, and thread scheduling can still differ between platforms. Portability of the instructions is guaranteed; portability of behaviour still requires care.
Key Takeaways
- Bytecode targets an abstract stack machine, so one compilation runs on every platform that has a JVM.
.classfiles are typed and verified before execution, which underpins Java's security model.javap -cdisassembles bytecode and is the definitive way to answer "what does this code actually compile to".- Portability covers instructions, not environment-dependent behaviour such as encodings, paths, and locales.
🧪 Practice
- Compile a class with a
forloop and runjavap -con it. Identify the comparison and the backward jump that implement the loop.- Write two small methods, one adding
intvalues and one addingdoublevalues, and compare the opcodes.- Interview: If bytecode is portable, why do JDK downloads still list an operating system and CPU architecture? (Hint: what exactly is being downloaded?)
Compilation and Execution Workflow
Understanding the pipeline from text to execution explains most build errors and a good deal of Java's performance behaviour.
The compiler javac performs syntax analysis, type checking, and a handful of
desugarings (enhanced for becomes an iterator loop, string concatenation
becomes builder or invokedynamic calls, generics are erased). It emits one
.class file per class, including nested and anonymous ones. Crucially,
javac does almost no optimisation; it leaves that to the runtime, which knows
things a static compiler cannot, such as which branches are actually taken.
At runtime, java starts the JVM, which:
- Loads the requested class through a classloader, searching the classpath or module path.
- Links it: verifies the bytecode is well-formed and type-safe, prepares static fields with default values, and resolves symbolic references.
- Initialises it: runs static initialisers, on first active use.
- Executes it, starting interpreted, while a profiler counts method invocations and loop iterations.
- Compiles hot code with the JIT compiler into native instructions, and may recompile at a higher optimisation tier or deoptimise if a speculative assumption turns out to be wrong.
Hello.java
|
| javac syntax check, type check, desugar
v
Hello.class (bytecode + constant pool + metadata)
|
| java Hello JVM starts
v
+-------------------------------------------------------------+
| class loader -> verifier -> prepare -> initialise |
| | |
| v |
| interpreter --- method gets "hot" ---> JIT (C1 then C2) |
| | | |
| +----------- deoptimise if needed <------+ |
| |
| garbage collector reclaims unreachable objects throughout |
+-------------------------------------------------------------+
javac Hello.java # produces Hello.class in the current directory
java Hello # note: the CLASS name, not the file name
javac -d out src/Hello.java # write class files into out/
java -cp out Hello # run with out/ on the classpath
java Hello.java # Java 11+: compile in memory and run, single file
This is why a Java program is often slower for its first second and then speeds up: the JIT has not yet compiled the hot paths. It is also why microbenchmarks written naively are worthless, a point revisited when performance is discussed in later chapters.
Key Takeaways
javactype-checks and desugars but barely optimises; the JIT does the real optimisation at runtime with profile data.- Class loading proceeds through load, verify, prepare, resolve, initialise, and initialisation is lazy.
javatakes a class name, not a file name, unless you use single-file source mode.- Performance warms up over time, which makes naive benchmarks misleading.
🧪 Practice
- Compile a class into a separate
outdirectory and run it using only-cp. Then delete the.classfile and observe the error.- Write a class whose
staticblock prints a message, and demonstrate that the message appears only when the class is first used.- Interview: A method runs 10x faster on its ten-thousandth call than on its first. Explain why. (Hint: consider what the JVM is doing while it counts invocations.)
Setting Up a Development Environment
A working setup needs three decisions: which JDK distribution, which version, and which editor. All three are reversible, so do not overthink them.
Distribution. OpenJDK is the reference implementation, and several vendors ship builds of it: Eclipse Temurin, Amazon Corretto, Azul Zulu, Microsoft Build of OpenJDK, and Oracle's own. For learning and for most production work these are functionally interchangeable; Temurin is a safe default because it is vendor-neutral and free for commercial use.
Version. Choose an LTS release unless you have a specific reason not to. Java 17, 21, and 25 are all reasonable in 2026; this guide assumes 17 or later and flags anything newer.
Verification. After installation, three checks:
java -version # expect: openjdk version "21.0.x" or similar
javac -version # expect: javac 21.0.x -- must match the runtime
echo $JAVA_HOME # should point at the JDK root, not the bin directory
JAVA_HOME matters because build tools (Maven, Gradle) and IDEs read it to
find the JDK. On Unix-like systems it is set in a shell profile:
# ~/.bashrc or ~/.zshrc -- adjust the path to your installation
export JAVA_HOME=/usr/lib/jvm/temurin-21-jdk
export PATH="$JAVA_HOME/bin:$PATH" # put the JDK's tools ahead of any others
If you juggle projects on different versions, a version manager such as SDKMAN!
(sdk install java 21.0.2-tem, sdk use java 17.0.10-tem) is far less painful
than editing paths by hand.
Editor. IntelliJ IDEA Community Edition, Eclipse, and VS Code with the Java extension pack all work. An IDE is not cheating: it gives you real-time type errors, refactoring, and a debugger, which accelerate learning considerably. That said, compile and run from the command line at least a few times, because an IDE hides exactly the pipeline described in the previous topic.
A conventional project layout, which build tools assume by default:
my-project/
├── src/
│ └── main/
│ ├── java/ # production sources, mirroring package names
│ │ └── com/example/app/Main.java
│ └── resources/ # config files, bundled into the artifact
├── test/ or src/test/java/ # test sources
├── out/ or target/ or build/ # compiled output (never committed)
└── pom.xml or build.gradle # build definition
Key Takeaways
- Any OpenJDK build will do; Temurin is a good vendor-neutral default.
- Prefer an LTS version (17, 21, or 25) for anything you intend to keep.
JAVA_HOMEmust point at the JDK root, and build tools depend on it.- Use an IDE for productivity, but compile from the command line at least once so the toolchain is not a black box.
🧪 Practice
- Install a JDK, then verify that
java,javac, andJAVA_HOMEall agree on the same installation.- Create the directory layout above by hand, put a class in package
com.example.app, and compile it withjavac -d outfrom the project root.- Install a second JDK version and switch between them, confirming with
java -versioneach time.
Anatomy of a Minimal Java Program
Java's "hello world" is famously verbose, and every token in it exists for a reason worth knowing early.
package com.example.intro; // 1. optional: namespace for this class
import java.time.LocalDate; // 2. optional: shorten a type's name
/**
* The classic first program, with one extra line so it does something.
*/
public class Greeter { // 3. class name must match Greeter.java
public static void main(String[] args) { // 4. the entry point
String name = args.length > 0 ? args[0] : "world";
System.out.println("Hello, " + name + "!");
System.out.println("Today is " + LocalDate.now());
}
}
Taking the numbered pieces in order:
packagedeclares the namespace. If present it must be the first statement, and the directory structure must mirror it:com/example/intro/Greeter.java. Omitting it puts the class in the default package, which is fine for scratch work and discouraged in real projects because such classes cannot be imported from a named package.importdoes not "include" code the way C's#includedoes. It only lets you writeLocalDateinstead ofjava.time.LocalDate. Everything injava.lang(includingStringandSystem) is imported implicitly.public class Greeterdeclares the type. A source file may hold only onepublictop-level class, and its name must match the file name exactly, case included. This rule is what lets the compiler find a class by name.public static void main(String[] args)is the entry point, recognised by exact signature.publicso the launcher can call it,staticso no instance is needed before the program starts,voidbecause there is no return value (exit codes come fromSystem.exit), and aString[]parameter for command-line arguments.
Greeter.java
│
├── package declaration at most one, first
├── import declarations zero or more, next
└── type declarations class / interface / enum / record
│
└── class Greeter
├── fields state
├── constructors initialisation
└── methods behaviour ── main() is just a method
with a special signature
$ javac -d out com/example/intro/Greeter.java
$ java -cp out com.example.intro.Greeter Ada
Hello, Ada!
Today is 2026-09-25
Note the fully qualified class name on the java command: once a class is in a
package, that package is part of its identity.
Key Takeaways
- Order within a file is fixed:
package, thenimport, then type declarations.- One public top-level class per file, named exactly like the file.
importis a naming convenience, not textual inclusion;java.langis imported automatically.mainis recognised by its exact signature, and a packaged class is run by its fully qualified name.
🧪 Practice
- Move
Greeterinto packagecom.example.intro, then compile and run it from the project root using onlyjavac -dandjava -cp.- Rename the class without renaming the file and read the compiler error carefully. Then do the reverse.
- Interview: Why must
mainbestatic? (Hint: what would the JVM have to do first if it were an instance method?)
<a id="12-syntax-fundamentals"></a>
1.2 Syntax Fundamentals
Before types and control flow come the mechanical rules of the language: what you may name things, how to document them, where a name is visible, and how a program talks to the outside world.
Identifiers, Keywords, and Naming Conventions
An identifier is any name you choose: classes, methods, variables, packages, labels. The compiler enforces a small set of rules, and the community enforces a much larger set of conventions. The compiler rules prevent code from compiling; the conventions prevent code from being unreadable, which in a long-lived codebase matters just as much.
Compiler rules. An identifier must start with a letter, $, or _, and
may then contain letters, digits, $, or _. It cannot be a keyword, and it
cannot be true, false, or null (these are literals, not keywords). Java
is case-sensitive and Unicode-aware, so café and наименование are legal.
By convention $ is reserved for generated code, and a lone _ has been
illegal as an identifier since Java 9.
int count; // fine
int _count; // legal, but unconventional in Java
int $count; // legal, reserved by convention for generated code
int 2ndPlace; // ERROR: cannot start with a digit
int class; // ERROR: reserved keyword
int my-count; // ERROR: hyphen is the minus operator
int _; // ERROR since Java 9: '_' alone is reserved
Keywords. Roughly fifty words are reserved. You do not need to memorise
the list; the IDE colours them and the compiler complains. Worth knowing is
that some are reserved but unused (goto, const), and that several recent
additions (var, record, sealed, permits, yield) are contextual
keywords: they are only special in certain positions, so existing code that
used them as variable names still compiles.
Conventions. These are near-universal in the Java ecosystem:
| Element | Convention | Example |
|---|---|---|
| Package | all lowercase, dotted | com.example.billing |
| Class / interface | UpperCamelCase, noun | InvoiceParser, Comparable |
| Method | lowerCamelCase, verb | calculateTotal() |
| Variable / field | lowerCamelCase, noun | unitPrice |
| Constant | UPPER_SNAKE_CASE | MAX_RETRIES |
| Type parameter | single capital letter | T, E, K, V |
| Boolean accessor | is / has prefix | isEmpty(), hasNext() |
// Conventional and self-documenting
public class OrderProcessor {
private static final int MAX_RETRY_COUNT = 3;
private double totalAmount;
public boolean isComplete() { ... }
}
// Legal, but every reader will resent it
public class order_processor {
private static final int maxretrycount = 3;
private double TotalAmount;
public boolean complete_check() { ... }
}
Name length should scale with scope. A loop counter living for three lines can
be i; a field read across a 300-line class should say exactly what it holds.
Key Takeaways
- Identifiers may contain letters, digits,
$, and_, but cannot start with a digit or be a keyword.true,false, andnullare literals, not keywords, and are still unusable as names.- Contextual keywords such as
varandrecordare only reserved in specific positions.- Conventions are not optional in practice: UpperCamelCase types, lowerCamelCase members, UPPER_SNAKE_CASE constants.
🧪 Practice
- Classify each as legal or illegal and say why:
_value,2fast,Value$,null,récord,my var,_.- Rewrite this declaration set to follow conventions:
class bank_account { int Balance; static final int maxwithdraw = 500; void GetBalance() {} }.- Interview: Why can
varbe used as a variable name even though it was added to the language in Java 10? (Hint: think about what breaking that rule would do to existing code.)
Comments and Javadoc Syntax
Java has three comment forms, and the third is a documentation language rather than a comment in the usual sense.
// Line comment: everything to the end of the line.
/*
Block comment: spans lines. These do NOT nest, so a /* inside
another block comment is a syntax error waiting to happen.
*/
/**
* Javadoc comment: consumed by the javadoc tool to produce HTML API docs.
* Attaches to the declaration immediately following it.
*/
The distinction that matters: line and block comments are stripped by the compiler and exist only for readers of the source. Javadoc comments are extracted into published documentation and are also displayed by IDEs on hover, so they are read by people who never open your source file.
A well-formed Javadoc comment starts with a summary sentence, then optional detail, then block tags:
/**
* Transfers funds between two accounts.
*
* <p>The transfer is atomic: either both balances change or neither does.
* Both accounts must be open and denominated in the same currency.
*
* @param from the account to debit; must not be {@code null}
* @param to the account to credit; must not be {@code null}
* @param amount the positive amount to move, in minor units
* @return the resulting transaction record
* @throws InsufficientFundsException if {@code from} lacks the balance
* @throws IllegalArgumentException if {@code amount} is not positive
* @see Account#getBalance()
* @since 2.1
*/
public Transaction transfer(Account from, Account to, long amount) { ... }
Common tags:
| Tag | Purpose |
|---|---|
@param | One per parameter, in declaration order |
@return | What the method yields; omit for void |
@throws | Each exception and the condition that triggers it |
@deprecated | Why it is obsolete and what replaces it |
@see | Cross-reference to a related element |
@since | Version in which the element appeared |
{@code x} | Inline literal, escapes HTML and angle brackets |
{@link T#m} | Inline hyperlink to another element |
javadoc -d docs -private com/example/billing/*.java # generate HTML docs
The first sentence is special: it becomes the summary in the method index, so write it as a complete, standalone statement ending in a period.
A note on style. Comments that restate the code (i++; // increment i) are
noise that rots as soon as the code changes. Comments that explain why
(// vendor API rejects batches over 500, see ticket OPS-4412) are the ones
worth writing, because that reasoning appears nowhere else.
Key Takeaways
- Line and block comments are for source readers; Javadoc is extracted into published API documentation.
- Block comments do not nest.
- The first Javadoc sentence is the summary and should stand alone.
- Document why, not what; the code already states what.
🧪 Practice
- Write a complete Javadoc comment for a method
int indexOf(int[] array, int target)that returns-1when absent.- Run
javadocon a small class and open the generated HTML. Find where your summary sentence and@paramtext appear.- Take a method you have written and delete every comment that merely restates the code. Note how many remain.
Statements, Blocks, and Scope
A statement is a single instruction terminated by a semicolon. A block is
a sequence of statements wrapped in braces, and it acts as one statement
wherever a statement is expected. That equivalence is why if, for, and
while can take either a single statement or a whole block.
Scope is the region of source in which a name is visible. In Java, scope is lexical and follows the braces: a local variable is visible from its declaration to the closing brace of the enclosing block, and not before.
public class ScopeDemo {
private int field = 1; // class scope: visible to all methods
public void demo(int parameter) { // parameter scope: the whole method
int local = 2; // visible from here to the method's end
{ // a bare block is legal and creates scope
int inner = 3;
System.out.println(field + parameter + local + inner); // all OK
}
// System.out.println(inner); // ERROR: inner is out of scope
for (int i = 0; i < 3; i++) { // i is scoped to the for statement
int perIteration = i * 2; // a fresh variable each iteration
}
// System.out.println(i); // ERROR: i is out of scope
if (parameter > 0) {
int conditional = 4;
}
// System.out.println(conditional); // ERROR: out of scope
}
}
class body ...................................... field
│
└── method body .............................. parameter, local
│
├── nested block ........................ inner
├── for statement ....................... i, perIteration
└── if block ............................ conditional
Visibility flows INWARD only: an inner block sees outer names,
never the reverse.
Two rules trip people up:
No shadowing of locals. Unlike C, Java forbids declaring a local variable
with the same name as another local still in scope. You can shadow a field,
which is why constructors conventionally write this.name = name.
int total = 0;
{
// int total = 5; // ERROR: total is already defined in this method
}
class Point {
private int x;
Point(int x) { // parameter x SHADOWS field x -- this is allowed
this.x = x; // 'this.' disambiguates: field = parameter
}
}
Definite assignment. A local variable has no default value. The compiler proves that every read is preceded by a write on every possible path, and rejects the code otherwise.
int value;
if (someCondition) {
value = 1;
}
System.out.println(value); // ERROR: variable value might not be initialized
Keeping scopes tight is a real design technique, not pedantry: a variable declared in the smallest block that needs it cannot be accidentally reused, cannot hold a stale value, and tells the reader its lifetime at a glance.
Key Takeaways
- A block is a statement, which is why control structures accept either.
- Local scope runs from declaration to the enclosing closing brace.
- Locals cannot shadow other locals, but parameters may shadow fields, hence
this.x = x.- Locals have no default value; the compiler enforces definite assignment.
🧪 Practice
- Write a method with a nested block, then try to use the inner variable outside it. Read the exact compiler error.
- Write code that fails definite-assignment analysis, then fix it two ways: by adding an
else, and by initialising at declaration.- Interview: Why does Java allow a parameter to shadow a field but not a local to shadow a local? (Hint: which of the two has an unambiguous way to refer to the hidden name?)
The main Method Signature
The JVM needs one specific entry point, and it identifies it by an exact signature rather than by a name alone. Get one token wrong and you get a runtime error, not a compile error, because the class compiles perfectly well; it simply cannot be launched.
public static void main(String[] args) { }
| Token | Why it must be there |
|---|---|
public | The launcher calls it from outside the class and its package |
static | No instance exists yet; the JVM cannot construct one first |
void | The process exit code comes from System.exit, not a return |
main | The name the launcher looks for |
String[] args | Command-line arguments; the parameter type is part of the match |
Permitted variations:
public static void main(String... args) { } // varargs compiles to String[]
static public void main(String[] args) { } // modifier order is free
public static void main(String[] arguments) { } // parameter name is free
public static final void main(String[] args) { } // extra modifiers allowed
Rejected variations and their symptoms:
public void main(String[] args) { } // not static
public static void main(String args) { } // wrong parameter type
public static int main(String[] args) { } // wrong return type
public static void Main(String[] args) { } // wrong name (Java is case-sensitive)
$ java Broken
Error: Main method not found in class Broken, please define the main method as:
public static void main(String[] args)
or a JavaFX application class must extend javafx.application.Application
A class may contain other methods named main with different signatures;
they are ordinary overloaded methods and the launcher ignores them. Likewise,
every class in a program may have a main, which is a common trick for giving
each class a small self-test; the launcher runs whichever class you name.
public class Calculator {
public static void main(String[] args) { // the entry point
System.out.println(main(2, 3)); // calls the overload below
}
public static int main(int a, int b) { // just a method named main
return a + b;
}
}
Since Java 21, preview features have explored simplified entry points for
beginners (instance main methods without String[]), but the classic
signature remains the one to know and the only one guaranteed everywhere.
Key Takeaways
mainis matched by exact signature:public static voidwith aString[]parameter.- A wrong signature fails at launch time, not compile time.
- Modifier order, parameter name, and varargs form are all flexible.
- Multiple classes may each define
main; the launcher runs the one named on the command line.
🧪 Practice
- Deliberately break
mainin four different ways and record the exact error message each produces.- Write a class with both
main(String[])and an overloadedmain(int, int), and confirm which the launcher uses.- Interview: Can
mainbe overloaded? Can it beprivate? Answer both and explain the consequence. (Hint: compiling and launching are separate events.)
Command-Line Arguments
args is an ordinary String[] holding the tokens typed after the class name.
Two details surprise newcomers: the array never contains the program name (as
argv[0] does in C), and it is never null — when no arguments are supplied
it is an empty array of length zero.
public class ArgsDemo {
public static void main(String[] args) {
System.out.println("Count: " + args.length);
for (int i = 0; i < args.length; i++) {
System.out.println(" [" + i + "] = " + args[i]);
}
}
}
$ java ArgsDemo alpha beta 42
Count: 3
[0] = alpha
[1] = beta
[2] = 42
$ java ArgsDemo
Count: 0 <- empty array, NOT null
$ java ArgsDemo "two words" 'single quoted'
Count: 2 <- the SHELL groups quoted tokens, not Java
Everything arrives as text, so numeric arguments must be parsed explicitly, and that parsing is the first place a real program meets bad input:
public static void main(String[] args) {
if (args.length < 1) { // always validate first
System.err.println("Usage: java Squarer <number>");
System.exit(1); // non-zero = failure
}
try {
int n = Integer.parseInt(args[0]); // String -> int, may throw
System.out.println(n + " squared is " + n * n);
} catch (NumberFormatException e) {
System.err.println("Not an integer: " + args[0]);
System.exit(2);
}
}
Note System.err for diagnostics and System.exit with a non-zero code for
failure: these are the conventions other programs and shell scripts rely on
when they invoke yours.
Arguments passed to the JVM itself are distinct from arguments passed to your program, and position decides which is which:
java -Xmx512m -Denv=prod MyApp --verbose input.txt
\___________________/ \___/ \__________________/
JVM options and | your args array
system properties main class
For anything beyond two or three parameters, hand-rolled parsing becomes a liability; real projects use a library such as picocli or Apache Commons CLI.
Key Takeaways
argsexcludes the program name and is empty rather thannullwhen no arguments are given.- Argument splitting and quote handling are done by the shell, before Java sees anything.
- All arguments are strings; convert with
Integer.parseIntand handleNumberFormatException.- JVM options precede the class name; program arguments follow it.
🧪 Practice
- Write a program that sums every argument as an integer and reports a clear error naming the first token that is not numeric.
- Demonstrate the difference between
java -Dkey=value Appandjava App -Dkey=valueby printing bothSystem.getProperty("key")andargs.- Write a program that accepts an optional
--upperflag anywhere in the arguments and applies it to the remaining words.
Console Input and Output Basics
System.out, System.err, and System.in are the three standard streams,
available without any import because System lives in java.lang.
Output. println appends a line separator, print does not, and printf
formats:
System.out.print("no newline");
System.out.println("with newline");
System.out.printf("%s is %d years old and %.2f m tall%n", "Ada", 36, 1.7);
// %s string, %d integer, %f float, %.2f two decimals, %n platform newline
// %-10s left-aligns in 10 columns, %08.3f zero-pads to width 8
System.err writes to the error stream, which is unbuffered and separately
redirectable. Use it for diagnostics so that program > output.txt captures
results without swallowing error messages.
Input. Scanner is the usual starting point:
import java.util.Scanner;
public class InputDemo {
public static void main(String[] args) {
try (Scanner scanner = new Scanner(System.in)) { // auto-closed
System.out.print("Name: ");
String name = scanner.nextLine();
System.out.print("Age: ");
int age = scanner.nextInt(); // reads the number, NOT the newline
scanner.nextLine(); // consume the leftover newline
System.out.print("City: ");
String city = scanner.nextLine(); // now reads correctly
System.out.printf("%s, %d, from %s%n", name, age, city);
}
}
}
The commented line is the single most common Scanner bug. nextInt() stops
at the end of the number and leaves the newline in the buffer, so the following
nextLine() returns an empty string unless you consume it.
| Method | Reads | Leaves newline in buffer |
|---|---|---|
nextLine() | Rest of the line | No |
next() | One whitespace-delimited word | Yes |
nextInt() | One integer | Yes |
nextDouble() | One floating-point number | Yes |
hasNextInt() | Nothing; tests the next token | n/a |
A robust read loop tests before it reads rather than catching exceptions:
System.out.print("Enter an integer: ");
while (!scanner.hasNextInt()) { // look before you leap
System.out.print("Not an integer, try again: ");
scanner.next(); // discard the offending token
}
int value = scanner.nextInt();
For performance-sensitive input, BufferedReader is considerably faster than
Scanner because it does no tokenising or regex work; Console (from
System.console()) additionally supports reading passwords without echo, but
returns null when the process is not attached to a terminal, such as inside
an IDE.
Key Takeaways
System.out,System.err, andSystem.inneed no import.printfuses%nfor the platform line separator;\nis always a line feed.- Mixing
nextInt()andnextLine()requires consuming the leftover newline.- Prefer
hasNextInt()over catching exceptions; preferBufferedReaderwhen input volume is large.
🧪 Practice
- Write a program that reads a name and an age and prints them, and make the
nextInt/nextLinebug appear, then fix it.- Write a menu loop that keeps prompting until the user enters a valid integer between 1 and 5.
- Use
printfto print a three-column table of products, prices, and quantities with the prices right-aligned to two decimal places.
<a id="13-primitive-types-and-variables"></a>
1.3 Primitive Types and Variables
Java is statically typed: every variable has a type fixed at compile time, and the compiler rejects operations that type does not permit. This subchapter covers the eight built-in types that are not objects, and the rules for declaring, initialising, and converting them.
The Eight Primitive Types
Most values in Java are objects, accessed through references. Eight types are
the exception: they hold their value directly, have no methods, and cannot be
null. They exist for performance — an int is a machine word, not a
heap-allocated object with a header.
A defining Java decision is that these sizes are fixed by the specification,
not by the platform. In C, int might be 16, 32, or 64 bits; in Java it is
always exactly 32 bits with two's-complement semantics. That guarantee is a
precondition of portability.
| Type | Size | Range | Default | Literal example |
|---|---|---|---|---|
byte | 8 bit | -128 to 127 | 0 | (byte) 100 |
short | 16 bit | -32,768 to 32,767 | 0 | (short) 1000 |
int | 32 bit | about -2.1e9 to 2.1e9 | 0 | 42 |
long | 64 bit | about -9.2e18 to 9.2e18 | 0L | 42L |
float | 32 bit | about 7 decimal digits of precision | 0.0f | 3.14f |
double | 64 bit | about 15 decimal digits of precision | 0.0d | 3.14 |
char | 16 bit | 0 to 65,535 (unsigned, a UTF-16 unit) | '\u0000' | 'A' |
boolean | unspec. | true or false | false | true |
byte fileFlags = 0b0010_1101; // binary literal, underscores for reading
short portOffset = 8080;
int population = 1_400_000_000; // underscores are ignored by the compiler
long nanoseconds = 9_000_000_000L; // L required: too big for int
float ratio = 0.75f; // f required: 0.75 alone is a double
double distance = 1.496e11; // scientific notation
char grade = 'A'; // single quotes; "A" is a String
boolean isActive = true; // no implicit int-to-boolean in Java
Four properties deserve emphasis.
char is unsigned and 16 bits. It stores one UTF-16 code unit, so
characters outside the Basic Multilingual Plane (many emoji, some CJK
extensions) need two char values. char participates in arithmetic:
'A' + 1 is the int 66.
boolean has no defined size and no conversion to or from numbers. if (x = 5) — the classic C bug — simply does not compile in Java unless x is a
boolean.
Floating-point types are binary, so values like 0.1 are not exactly
representable:
System.out.println(0.1 + 0.2); // 0.30000000000000004, not 0.3
System.out.println(0.1 + 0.2 == 0.3); // false
// For money, use long minor units (cents) or BigDecimal -- never double.
Every primitive has a wrapper class (Integer, Double, Character, ...)
for the times an object is required, such as inside collections. Conversion is
automatic in both directions ("autoboxing"), which is convenient and
occasionally expensive.
int primitive = 42;
Integer boxed = primitive; // autoboxing: int -> Integer
int unboxed = boxed; // unboxing: Integer -> int
Integer maybe = null;
int broken = maybe; // compiles, throws NullPointerException at runtime
PRIMITIVE REFERENCE (wrapper)
int x = 42; Integer y = 42;
stack stack heap
+--------+ +--------+ +-------------+
| x 42 | value lives here | y ---+----> | Integer: 42 |
+--------+ +--------+ +-------------+
no object, no null possible may be null, has identity
Key Takeaways
- Eight primitives hold values directly, have no methods, and cannot be
null.- Sizes are fixed by the specification, which is what makes arithmetic portable.
charis an unsigned 16-bit UTF-16 code unit and behaves numerically.booleandoes not convert to or from numbers.- Binary floating point cannot represent
0.1exactly; useBigDecimalor integer minor units for money.
🧪 Practice
Integer.MAX_VALUE + 1and explain the result in terms of two's-complement wraparound.- Write a loop that prints the
charandintvalue of every character from'a'to'z'.- Interview: Why does
0.1 + 0.2 != 0.3in Java, and what would you use for a currency field? (Hint: think about representing one tenth in base two.)
Variable Declaration and Initialization
A declaration binds a name to a type; an initialisation gives it a first value. Java distinguishes sharply between three kinds of variable, and the rules on default values differ between them — a distinction that causes real confusion.
public class VariableKinds {
static int classVariable; // static field: defaults to 0
int instanceVariable; // instance field: defaults to 0
void method(int parameter) { // parameter: always assigned by the caller
int localVariable; // local: NO default, must be assigned
// System.out.println(localVariable); // ERROR: might not be initialized
localVariable = 5; // now definitely assigned
System.out.println(localVariable);
}
}
Fields are zero-initialised: numeric types to 0, boolean to false,
char to '\u0000', and references to null. This is guaranteed by the JVM,
which wipes the memory for a new object before any code sees it.
Locals are not. The compiler performs definite assignment analysis and refuses to compile a read that is not preceded by a write on every path. This is deliberate: a silent default hides bugs, whereas an error at compile time does not.
Declaration forms:
int a; // declare only
int b = 10; // declare and initialise
int c = 1, d = 2, e = 3; // multiple in one statement (legal, often unclear)
int f = 0, g = f + 1; // later ones may use earlier ones
final int h = 100; // cannot be reassigned after initialisation
int[] values = {1, 2, 3}; // array initialiser shorthand
Assignment is an expression, not merely a statement, so it produces a value. That enables chaining and the read-and-test idiom:
int x, y, z;
x = y = z = 0; // assignment associates right to left
int line;
while ((line = reader.read()) != -1) { // assign, then compare the result
process(line);
}
Prefer declaring a variable at the point of first use rather than at the top of
a method. It shortens the variable's live range, makes the initialiser visible
next to the declaration, and often lets you mark it final.
// Less good: declared far from use, briefly meaningless
String result;
// ... twenty lines ...
result = compute();
// Better: declaration and meaning arrive together
// ... twenty lines ...
String result = compute();
Key Takeaways
- Fields (static and instance) are zero-initialised automatically; locals are not.
- Definite assignment is enforced at compile time for locals.
- Assignment is an expression whose value is the assigned value, enabling
while ((x = next()) != -1).- Declare variables at first use to keep scope and lifetime minimal.
🧪 Practice
- Write a class with an uninitialised
intfield and an uninitialisedintlocal, print the field, and try to print the local. Explain the difference in outcome.- Predict the output of
int i = 0; i = i++ + ++i;then verify it and work through the evaluation order.- Rewrite a method that declares all its variables at the top so each is declared at first use, marking as many
finalas possible.
Literals and Numeric Representations
A literal is a value written directly in source code. Java's rules about literal types are the source of several confusing compile errors.
The two default types. An integer literal is an int unless suffixed with
L. A floating-point literal is a double unless suffixed with f. Both
defaults cause errors when they are wrong:
long big = 3_000_000_000; // ERROR: the literal is an int and overflows
long ok = 3_000_000_000L; // correct
float f1 = 3.14; // ERROR: double cannot narrow to float silently
float f2 = 3.14f; // correct
long millis = 24 * 60 * 60 * 1000; // fine, fits in int
long micros = 24 * 60 * 60 * 1000 * 1000; // OVERFLOWS as int, then widens
long fixed = 24L * 60 * 60 * 1000 * 1000; // one L makes the whole expression long
The last pair is a classic production bug: the multiplication happens in int
arithmetic and wraps around before the widening to long takes place.
Radix prefixes. The same number can be written in four bases:
int decimal = 255;
int hexadecimal = 0xFF; // 0x or 0X
int octal = 0377; // leading zero -- a trap, see below
int binary = 0b1111_1111; // 0b or 0B, since Java 7
// All four are the same value: 255
The leading-zero octal form is a hazard worth remembering: int month = 08;
fails to compile because 8 is not an octal digit, and int code = 0123; is
83, not 123.
Underscores may appear between digits, purely for legibility, and are ignored by the compiler. They cannot start or end a literal or sit next to the decimal point or a suffix.
long cardNumber = 1234_5678_9012_3456L; // legal and readable
double pi = 3.141_592_653_589_793;
int bad1 = _100; // ERROR: that is an identifier
int bad2 = 100_; // ERROR: trailing underscore
double bad3 = 1._5; // ERROR: adjacent to the decimal point
Character and escape literals. A char literal is a single character in
single quotes, or an escape sequence:
char newline = '\n'; char tab = '\t';
char quote = '\''; char slash = '\\';
char unicode = 'é'; // é, by code point
char asInt = 65; // 'A' -- an int literal in char range is allowed
| Escape | Meaning | Escape | Meaning |
|---|---|---|---|
\n | Line feed | \' | Single quote |
\t | Tab | \" | Double quote |
\r | Carriage return | \\ | Backslash |
\b | Backspace | \uXXXX | Unicode by code point |
\f | Form feed | \0 | Null character |
Unicode escapes are processed extremely early, before the source is tokenised,
so \u000A inside a line comment actually terminates the comment and can
produce baffling errors. It is a curiosity, but it explains one of Java's
stranger puzzle questions.
Key Takeaways
- Integer literals default to
int, floating-point literals todouble; suffix withLandfwhen you need otherwise.- Arithmetic on
intliterals overflows before any widening assignment, so promote early withL.- A leading
0means octal;0xis hex and0bis binary.- Underscores improve readability and must sit strictly between digits.
🧪 Practice
- Explain why
long ms = 1000 * 60 * 60 * 24 * 365;gives a wrong result, and fix it with a single character change.- Write the value 1,000,000 as a decimal, hexadecimal, and binary literal, each with underscores, and verify all three are equal.
- Interview: What does
System.out.println(0123 + 1);print, and why? (Hint: look hard at the leading zero.)
Type Casting and Promotion
Converting between numeric types happens constantly, sometimes silently and sometimes only when you demand it. The rule is simple: Java converts automatically when no information can be lost, and requires an explicit cast when it can.
Widening (implicit). Moving to a type with a larger range is safe and automatic:
byte -> short -> int -> long -> float -> double
^
char -----+
int i = 100;
long l = i; // widening, automatic
double d = l; // widening, automatic
The two rightmost steps are safe in range but not in precision: long has 64
bits of integer precision while float has about 24 bits of mantissa, so large
values lose exactness:
long big = 123_456_789_123L;
float asFloat = big; // legal, but no longer exact
System.out.println((long) asFloat); // 123456790528 -- silently different
Narrowing (explicit). Moving to a smaller type requires a cast, and the cast is an assertion by you that the value fits. If it does not, the extra high-order bits are simply discarded:
int large = 300;
byte small = (byte) large; // 300 is 0b1_0010_1100; keeping 8 bits gives 44
System.out.println(small); // 44 -- no warning, no exception
double pi = 3.99;
int truncated = (int) pi; // 3 -- casting to an integer TRUNCATES toward zero
int rounded = (int) Math.round(pi); // 4 -- round explicitly if you mean to
Binary numeric promotion. In any arithmetic expression, operands are first
promoted to a common type by these rules, applied in order: if either operand
is double, both become double; else if either is float, both become
float; else if either is long, both become long; otherwise both become
int. That last clause is the surprising one — byte, short, and char
are always promoted to int before arithmetic:
byte a = 10, b = 20;
// byte sum = a + b; // ERROR: a + b is an int, cannot narrow implicitly
byte sum = (byte) (a + b); // explicit cast required
int fine = a + b; // or just accept the int
char c1 = 'A';
int code = c1 + 1; // 66
// char c2 = c1 + 1; // ERROR: int cannot narrow to char implicitly
char c2 = (char) (c1 + 1); // 'B'
Compound assignment operators contain a hidden cast, which is why this compiles when the equivalent explicit form does not:
byte counter = 10;
counter += 5; // legal: expands to counter = (byte) (counter + 5)
// counter = counter + 5; // ERROR: right side is int
byte overflow = 120;
overflow += 10; // compiles, silently yields -126
Integer division truncates, which is a constant source of off-by-one and zero results:
int half = 7 / 2; // 3, not 3.5
double wrong = 7 / 2; // 3.0 -- division happened in int first
double right = 7 / 2.0; // 3.5
double alsoOk = (double) 7 / 2; // 3.5
Key Takeaways
- Widening is implicit, narrowing requires an explicit cast that may silently truncate.
byte,short, andcharare promoted tointbefore any arithmetic.- Casting a floating-point value to an integer truncates toward zero; use
Math.roundto round.- Compound assignment hides a narrowing cast, so it can overflow silently.
- Integer division discards the remainder; promote one operand to get a fractional result.
🧪 Practice
- Predict then verify:
byte b = (byte) 130;,(int) -3.7,(char) 97,5 / 2 * 2.0.- Explain why
short s = 1; s += 1;compiles butshort s = 1; s = s + 1;does not.- Write a method computing a percentage from two
intcounts that returns the correct fractionaldouble, and explain each cast you needed.
Constants with final
final means "assigned exactly once". Applied to a variable, it prevents
reassignment; it does not make the referenced object immutable, and conflating
those two ideas is the single most common misunderstanding of the keyword.
final int maxAttempts = 3;
// maxAttempts = 4; // ERROR: cannot assign a value to final variable
final List<String> names = new ArrayList<>();
names.add("Ada"); // FINE: the list object is mutable
// names = new ArrayList<>(); // ERROR: the reference cannot be repointed
final reference, mutable object
names ==FINAL==> +----------------------+
(cannot be | ArrayList |
repointed) | ["Ada"] <- can |
| still |
| change |
+----------------------+
Blank finals. A final variable need not be initialised at its
declaration; it must simply be definitely assigned exactly once before use.
For fields, that means in every constructor or in an initialiser block.
public class Circle {
private final double radius; // blank final field
public Circle(double radius) {
this.radius = radius; // assigned exactly once, in the constructor
}
}
final int limit;
if (isProduction) {
limit = 1000;
} else {
limit = 10; // every path assigns it exactly once: legal
}
Constants. The idiomatic named constant is static final, named in
UPPER_SNAKE_CASE. static because one copy suffices for the whole class, and
final because it must not change.
public class Physics {
public static final double SPEED_OF_LIGHT = 299_792_458.0;
public static final int MAX_RETRIES = 3;
// A mutable "constant" is a bug waiting to happen:
public static final List<String> BAD = new ArrayList<>(); // mutable!
public static final List<String> GOOD = List.of("a", "b"); // immutable
}
A static final field of primitive or String type initialised with a
constant expression becomes a compile-time constant: the compiler inlines its
value at every use site. That has a real consequence — if a library changes
such a constant, dependent code must be recompiled, not merely relinked.
Other uses of final appear in later chapters: a final method cannot be
overridden, and a final class cannot be extended. A related rule matters for
lambdas: a local variable captured by a lambda or anonymous class must be
final or effectively final, meaning never reassigned after initialisation.
int counter = 0;
Runnable r = () -> System.out.println(counter); // OK: effectively final
// counter++; // adding this line breaks the lambda above
Marking locals and parameters final by default is a defensible style: it
documents intent and lets the compiler catch accidental reassignment. Many
teams skip it on locals as visual noise but apply it rigorously to fields.
Key Takeaways
finalprevents reassignment of the variable, not mutation of the object it points to.- A blank final must be definitely assigned exactly once before use.
- The constant idiom is
static finalin UPPER_SNAKE_CASE, and the value should itself be immutable.- Compile-time constants are inlined at use sites, so changing one requires recompiling dependents.
- Lambdas may only capture
finalor effectively final locals.
🧪 Practice
- Write a class with a
finalarray field, then demonstrate that you can change its elements but not reassign the field.- Create a blank final field assigned differently in two constructors, and then try to assign it twice in one constructor.
- Interview: Is
finalthe same as immutable? Give a concrete counterexample. (Hint: consider afinalfield holding aStringBuilder.)
Local Variable Type Inference with var
Java 10 introduced var, which lets the compiler infer a local variable's type
from its initialiser. This is not dynamic typing: the variable still has one
fixed static type, decided at compile time and unchangeable afterwards. The
only thing that changed is that you no longer have to type it out.
var message = "hello"; // inferred as String
var count = 10; // inferred as int
var list = new ArrayList<String>(); // inferred as ArrayList<String>
// message = 42; // ERROR: still a String, statically typed
The motivation is readability in cases where the type is long, obvious, or both:
// Before: the type is stated twice, and the line wraps
Map<String, List<CustomerOrder>> ordersByRegion = new HashMap<String, List<CustomerOrder>>();
// After: the information content is identical
var ordersByRegion = new HashMap<String, List<CustomerOrder>>();
// Especially clear in loops over a typed collection
for (var entry : ordersByRegion.entrySet()) {
process(entry.getKey(), entry.getValue());
}
Where var is not allowed. Inference needs an initialiser and a local
context, so these all fail:
var x; // ERROR: no initialiser, nothing to infer from
var y = null; // ERROR: null has no useful type
var z = { 1, 2, 3 }; // ERROR: array initialiser needs a target type
// class Foo { var field = 1; } // ERROR: fields cannot use var
// void m(var parameter) { } // ERROR: parameters cannot use var
// var method() { return 1; } // ERROR: return types cannot use var
var is restricted to local variables, indexes of enhanced for loops,
classic for loop variables, and resources in try-with-resources.
When it helps and when it hurts. The guideline used by the JDK team
themselves: var is appropriate when the reader can see the type immediately
from the right-hand side, and inappropriate when it hides information they
need.
Use var | Avoid var |
|---|---|
var users = new ArrayList<User>(); | var result = service.process(input); |
var entry : map.entrySet() | var x = 0; (int? long? double?) |
var reader = new BufferedReader(...) | var data = parse(raw); |
| Long generic types repeated on both sides | When the method name conveys no type |
Two subtle behaviours worth knowing. Inference is on the static type of the
initialiser, so var n = 0; gives int, not long or double. And for
diamond initialisers, var list = new ArrayList<>(); infers
ArrayList<Object>, which is almost never what you wanted:
var oops = new ArrayList<>(); // ArrayList<Object> -- probably a mistake
oops.add("text");
// String s = oops.get(0); // ERROR: element type is Object
var fine = new ArrayList<String>(); // be explicit on the right-hand side
var is also a contextual keyword rather than a reserved word, so legacy code
containing int var = 5; still compiles.
Key Takeaways
varis compile-time inference, not dynamic typing; the type is fixed and checked as usual.- It is limited to locals, loop variables, and try-with-resources; never fields, parameters, or return types.
- It requires an initialiser that is not
nulland not a bare array initialiser.- Use it when the right-hand side makes the type obvious, and avoid it when it hides a type the reader needs.
var list = new ArrayList<>();infersArrayList<Object>; state the type argument explicitly.
🧪 Practice
- Rewrite five verbose declarations using
var, then judge which two became harder to read and revert them.- Try each illegal use listed above and record the compiler message.
- Interview: Does
varmake Java dynamically typed? Justify the answer with a code example that fails to compile. (Hint: try reassigning a different type.)
<a id="14-operators-and-expressions"></a>
1.4 Operators and Expressions
Operators combine values into expressions, and every expression in Java has a type and produces a value. The rules here are mostly familiar from C-family languages, with a few Java-specific corrections that remove entire bug classes.
Arithmetic and Compound Assignment Operators
The five arithmetic operators are +, -, *, /, and %. Their subtleties
all come from the fact that they operate on fixed-width types.
int a = 17, b = 5;
System.out.println(a + b); // 22
System.out.println(a - b); // 12
System.out.println(a * b); // 85
System.out.println(a / b); // 3 <- integer division truncates
System.out.println(a % b); // 2 <- remainder
Division and remainder with negatives. Java truncates toward zero, so the remainder takes the sign of the dividend. This differs from mathematical modulo and bites when implementing circular indexing:
System.out.println( 7 / 2); // 3
System.out.println(-7 / 2); // -3 (not -4; truncation, not floor)
System.out.println( 7 % 3); // 1
System.out.println(-7 % 3); // -1 (not 2)
// Safe "always positive" modulo:
int index = ((-7 % 3) + 3) % 3; // 2
int alsoOk = Math.floorMod(-7, 3); // 2 -- clearer, since Java 8
Division by zero behaves differently for integers and floating point. Integer division by zero throws; floating point produces special values defined by IEEE 754:
// System.out.println(1 / 0); // throws ArithmeticException: / by zero
System.out.println(1.0 / 0.0); // Infinity
System.out.println(-1.0 / 0.0); // -Infinity
System.out.println(0.0 / 0.0); // NaN
System.out.println(Double.NaN == Double.NaN); // false -- NaN is unequal to itself
System.out.println(Double.isNaN(0.0 / 0.0)); // true -- test this way
Overflow is silent. Integer arithmetic wraps around in two's complement without any error:
int max = Integer.MAX_VALUE; // 2_147_483_647
System.out.println(max + 1); // -2147483648, silently wrapped
// When silent wraparound is unacceptable, ask for an exception:
System.out.println(Math.addExact(max, 1)); // throws ArithmeticException
+ is overloaded for strings. If either operand is a String, + means
concatenation, and the other operand is converted to its string form. Because
+ is left-associative, evaluation order changes the result:
System.out.println(1 + 2 + "3"); // "33" -- 1+2 first, then concatenation
System.out.println("1" + 2 + 3); // "123" -- concatenation all the way
System.out.println('a' + 1); // 98 -- char arithmetic, no String here
System.out.println("" + 'a' + 1); // "a1"
Increment and decrement. ++ and -- exist in prefix and postfix form.
Prefix updates then yields the new value; postfix yields the old value then
updates:
int i = 5;
System.out.println(i++); // prints 5, i becomes 6
System.out.println(++i); // i becomes 7, prints 7
int j = 5;
int k = j++ + ++j; // (5) + (7) = 12, j ends at 7
Expressions that modify the same variable twice are legal but unreadable; write them out as separate statements in real code.
Compound assignment. x += y is equivalent to x = (T) (x + y) where T
is the type of x — note the implicit cast, discussed earlier, which makes
these compile where the expanded form would not.
| Operator | Equivalent to | Operator | Equivalent to |
|---|---|---|---|
x += y | x = (T)(x + y) | x /= y | x = (T)(x / y) |
x -= y | x = (T)(x - y) | x %= y | x = (T)(x % y) |
x *= y | x = (T)(x * y) | x <<= y | x = (T)(x << y) |
Key Takeaways
- Integer division truncates toward zero, and
%takes the sign of the dividend; useMath.floorModfor a non-negative result.- Integer division by zero throws; floating-point division by zero yields
InfinityorNaN.NaNis not equal to itself; test withDouble.isNaN.- Integer overflow wraps silently;
Math.addExactand friends throw instead.+concatenates when either operand is aString, and left associativity decides the outcome of mixed expressions.
🧪 Practice
- Write a method
boolean isEven(int n)that works correctly for negative inputs, and explain whyn % 2 == 1fails.- Predict then verify:
"Result: " + 1 + 2,1 + 2 + " is the result",'A' + 'B',"" + 'A' + 'B'.- Interview: How would you detect that an
intaddition overflowed, without usingMath.addExact? (Hint: compare the signs of the operands and the result.)
Relational and Equality Operators
Relational operators compare magnitudes and always produce a boolean. There
is no implicit conversion from number to truth value, so the C bug of writing
= instead of == inside a condition is a compile error in Java.
int a = 5, b = 10;
System.out.println(a < b); // true
System.out.println(a <= b); // true
System.out.println(a > b); // false
System.out.println(a >= b); // false
System.out.println(a == b); // false
System.out.println(a != b); // true
// if (a = b) { } // ERROR: int cannot be converted to boolean
The critical Java-specific rule concerns == on reference types. For
primitives, == compares values. For references, == compares identities:
whether both variables point at the same object. Content comparison is the job
of equals.
String s1 = new String("hello");
String s2 = new String("hello");
System.out.println(s1 == s2); // false -- two distinct objects
System.out.println(s1.equals(s2)); // true -- same characters
String s3 = "hello";
String s4 = "hello";
System.out.println(s3 == s4); // true -- both refer to the pooled literal
s1 ---> +-------------+ s3 ---+
| String | | +-------------+
| "hello" | +---> | "hello" | (string pool)
+-------------+ | +-------------+
s2 ---> +-------------+ s4 ---+
| String |
| "hello" | s1 == s2 is false (different boxes)
+-------------+ s3 == s4 is true (same box)
That the pooled case happens to work is exactly what makes == on strings so
dangerous: code that tests fine with literals fails the moment a string arrives
from user input or a file.
The same trap exists for wrapper types, made worse by a caching optimisation.
The JVM caches Integer objects for values from -128 to 127, so small numbers
compare equal by identity and large ones do not:
Integer x = 127, y = 127;
System.out.println(x == y); // true -- both from the cache
Integer p = 128, q = 128;
System.out.println(p == q); // false -- outside the cache range
System.out.println(p.equals(q)); // true -- always use equals
Comparison also has a floating-point caveat: never test computed double
values for exact equality, because representation error accumulates.
double sum = 0.1 + 0.2;
System.out.println(sum == 0.3); // false
System.out.println(Math.abs(sum - 0.3) < 1e-9); // true -- compare within a tolerance
Finally, == on references of unrelated types is a compile error, which
catches some mistakes early:
String text = "5";
Integer number = 5;
// System.out.println(text == number); // ERROR: incomparable types
Key Takeaways
- Relational operators yield
boolean, and numbers never convert to booleans, soif (x = 5)will not compile.==compares values for primitives and identities for references.- Use
equalsfor content comparison; string literals share a pooled instance, which makes==deceptively work.- Wrapper caching makes
Integer==true for -128..127 and false beyond.- Compare floating-point values with a tolerance, not
==.
🧪 Practice
- Write a program demonstrating all four string comparison cases: pooled literals,
new String,intern(), andequals.- Show the
Integercache boundary by comparing 127 and 128 with==.- Interview: Two
Stringvariables print the same text but==is false. Explain how that happens and how you would compare them. (Hint: where did each string come from?)
Logical and Short-Circuit Operators
Java offers two families of boolean operators. && and || are
short-circuiting: they evaluate the right operand only when the result is
still undetermined. & and | always evaluate both operands.
boolean t = true, f = false;
System.out.println(t && f); // false -- AND
System.out.println(t || f); // true -- OR
System.out.println(!t); // false -- NOT
System.out.println(t ^ f); // true -- XOR: exactly one is true
| a | b | a && b | a || b | a ^ b | !a |
|---|---|---|---|---|---|
| true | true | true | true | false | false |
| true | false | false | true | true | false |
| false | true | false | true | true | true |
| false | false | false | false | false | true |
Short-circuiting is not merely an optimisation; it is a correctness tool. The
guard idiom below is ubiquitous, and it only works because && stops early:
String name = null;
if (name != null && name.length() > 3) { // safe: length() never runs on null
System.out.println(name);
}
if (name != null & name.length() > 3) { // NullPointerException: & evaluates both
System.out.println(name);
}
The same pattern with || short-circuits on the first true:
if (list == null || list.isEmpty()) { // safe
return "nothing to do";
}
a && b a || b
evaluate a evaluate a
| |
a == false? --yes--> false a == true? --yes--> true
| |
no no
| |
evaluate b, result is b evaluate b, result is b
The non-short-circuiting forms have two legitimate uses. First, when the right operand has a side effect you always want:
// Both validators run, so the user sees every error, not just the first
boolean valid = validateName(form) & validateEmail(form);
Second, &, |, and ^ double as bitwise operators on integer types, covered
next. On booleans they are logical; on integers they are bitwise.
Java has no ^^ operator, because XOR cannot short-circuit: both operands are
always needed to know the answer.
De Morgan's laws are worth internalising for simplifying conditions:
!(a && b) is equivalent to !a || !b
!(a || b) is equivalent to !a && !b
// Applied: this
if (!(age >= 18 && hasLicence)) { deny(); }
// reads better as
if (age < 18 || !hasLicence) { deny(); }
Key Takeaways
&&and||short-circuit;&and|always evaluate both operands.- Null-guard idioms depend on short-circuiting for correctness, not speed.
&,|,^are logical on booleans and bitwise on integers.- There is no
^^, because exclusive-or can never be decided from one operand.- De Morgan's laws let you push negations inward and simplify conditions.
🧪 Practice
- Write a method with a side effect that prints, then use it on both sides of
&&and&to observe which calls happen.- Rewrite
!(x > 0 && y > 0)and!(a == b || c == d)using De Morgan's laws.- Write a safe check that a string is non-null, non-empty, and starts with a digit, in a single condition.
Bitwise and Shift Operators
Bitwise operators work on the individual bits of integer types. They matter for flags packed into one value, low-level protocols, hashing, and performance- sensitive code — and they appear regularly in interviews.
| Operator | Name | Effect |
|---|---|---|
& | AND | 1 only where both bits are 1 |
| | OR | 1 where either bit is 1 |
^ | XOR | 1 where the bits differ |
~ | Complement | Flips every bit |
<< | Left shift | Shifts left, filling with 0 |
>> | Signed right shift | Shifts right, filling with the sign bit |
>>> | Unsigned right shift | Shifts right, filling with 0 |
int a = 0b1100; // 12
int b = 0b1010; // 10
System.out.println(Integer.toBinaryString(a & b)); // 1000 = 8
System.out.println(Integer.toBinaryString(a | b)); // 1110 = 14
System.out.println(Integer.toBinaryString(a ^ b)); // 0110 = 6
System.out.println(~a); // -13 (see below)
~a is -13 because Java integers are two's complement: ~x == -x - 1.
Shifts. Left shift by n multiplies by 2 to the power n; signed right
shift divides by 2 to the power n, rounding toward negative infinity.
int x = 8; // 0000...1000
System.out.println(x << 2); // 32 0000...100000
System.out.println(x >> 2); // 2 0000...0010
int neg = -8; // 1111...1000
System.out.println(neg >> 2); // -2 sign bit copied in: 1111...1110
System.out.println(neg >>> 2); // 1073741822 zeros shifted in
-8 as 32-bit two's complement: 11111111 11111111 11111111 11111000
>> 2 (arithmetic, keeps sign): 11111111 11111111 11111111 11111110 = -2
>>> 2 (logical, fills zeros) : 00111111 11111111 11111111 11111110 = 1073741822
Shift distances are taken modulo 32 for int and modulo 64 for long, so
1 << 32 is 1, not 0 — a genuine trap.
Bit flags. The classic application packs many booleans into one integer:
public class Permissions {
public static final int READ = 1 << 0; // 0001
public static final int WRITE = 1 << 1; // 0010
public static final int EXECUTE = 1 << 2; // 0100
public static final int DELETE = 1 << 3; // 1000
public static void main(String[] args) {
int perms = READ | WRITE; // set two flags: 0011
boolean canWrite = (perms & WRITE) != 0; // test a flag: true
perms |= EXECUTE; // add a flag: 0111
perms &= ~WRITE; // remove a flag: 0101
perms ^= READ; // toggle a flag: 0100
System.out.println(Integer.toBinaryString(perms)); // 100
}
}
Useful idioms worth recognising on sight:
boolean isEven = (n & 1) == 0; // test the lowest bit
boolean isPowerOf2 = n > 0 && (n & (n - 1)) == 0; // clears the lowest set bit
int swapped = a; a = a ^ b; b = a ^ b; a = a ^ b; // XOR swap (a curiosity)
int bitCount = Integer.bitCount(n); // population count, prefer the library
int highest = Integer.highestOneBit(n);
In application code, prefer EnumSet over hand-rolled bit flags: it is
type-safe, readable, and internally just as compact.
Key Takeaways
- Bitwise operators act per bit;
~xequals-x - 1under two's complement.>>preserves the sign,>>>fills with zeros; there is no<<<.- Shift distances wrap modulo 32 for
intand 64 forlong.- Flag sets use
|to add,& ~to remove,&to test, and^to toggle.EnumSetis the idiomatic alternative in application code.
🧪 Practice
- Write methods to set, clear, toggle, and test bit
nof anint.- Implement
isPowerOfTwo(int n)using a single bitwise expression and explain why it works.- Interview: What is the difference between
>>and>>>, and for which input do they produce the same result? (Hint: think about the sign bit.)
Ternary Conditional Operator
The conditional operator is Java's only ternary operator, taking three
operands. Unlike an if statement, it is an expression: it produces a value
and can therefore appear anywhere a value is expected.
condition ? valueIfTrue : valueIfFalse
int a = 5, b = 10;
int max = (a > b) ? a : b; // 10
// The same logic as a statement takes four lines and needs a mutable variable
int max2;
if (a > b) { max2 = a; } else { max2 = b; }
Its real advantage is enabling final declarations and inline use:
final String label = count == 1 ? "item" : "items"; // cannot do this with if
System.out.println("Found " + count + " " + label);
System.out.println("Status: " + (isActive ? "on" : "off")); // inline in an expression
return value != null ? value : "default"; // though Objects.requireNonNullElse is clearer
Only the selected branch is evaluated, so it short-circuits like &&:
String name = null;
int length = (name != null) ? name.length() : 0; // safe, length() is skipped
Nesting. Chained ternaries are legal and associate right to left. They remain readable only with disciplined formatting:
// Acceptable when formatted as a decision table
String grade = score >= 90 ? "A"
: score >= 80 ? "B"
: score >= 70 ? "C"
: score >= 60 ? "D"
: "F";
// Unacceptable: unformatted and mixing conditions on different variables
String s = a ? b ? "x" : "y" : c ? "z" : d ? "w" : "v";
The type trap. The result type is computed by promoting both branches to a common type, which can produce surprises when branch types differ:
Object o = true ? Integer.valueOf(1) : Double.valueOf(2.0);
System.out.println(o); // 1.0 -- not 1: both branches promoted to double
Integer i = null;
int result = true ? 0 : i; // fine here, but if the condition flips,
// unboxing null throws NullPointerException
Use a ternary when both branches produce a value of the same conceptual type
and each branch is short. Use an if statement when the branches perform
actions rather than produce values, or when either branch needs more than one
expression.
Java 14's switch expressions cover the multi-branch case far more legibly than a chain of ternaries, which is the topic that follows.
Key Takeaways
- The conditional operator is an expression, so it can initialise a
finalvariable and appear inline.- Only the chosen branch is evaluated.
- Chained ternaries associate right to left and need table-style formatting to stay readable.
- Mixed numeric branch types are promoted to a common type, which can change the printed result.
- Prefer
iffor actions and switch expressions for many branches.
🧪 Practice
- Rewrite an
if/elsethat assigns a variable as a ternary, then mark the variablefinal.- Write a ternary chain mapping a numeric score to a letter grade, formatted as a decision table.
- Interview: What does
true ? 1 : 2.0evaluate to, and what is its type? (Hint: both branches must share one type.)
Operator Precedence and Associativity
Precedence decides which operator binds tighter when an expression mixes them; associativity decides the order among operators of equal precedence. Java's table is inherited from C, with a few entries removed.
| Level | Operators | Associativity |
|---|---|---|
| 1 | () [] . :: method call | Left to right |
| 2 | ++ -- (postfix) | Left to right |
| 3 | ++ -- (prefix) + - (unary) ~ ! | Right to left |
| 4 | (cast) new | Right to left |
| 5 | * / % | Left to right |
| 6 | + - | Left to right |
| 7 | << >> >>> | Left to right |
| 8 | < <= > >= instanceof | Left to right |
| 9 | == != | Left to right |
| 10 | & | Left to right |
| 11 | ^ | Left to right |
| 12 | | | Left to right |
| 13 | && | Left to right |
| 14 | || | Left to right |
| 15 | ? : | Right to left |
| 16 | = += -= *= /= %= &= ^= |= <<= >>= >>>= | Right to left |
The rows that actually cause bugs are few, and they are worth memorising:
Bitwise operators bind looser than comparison. This is the single most common precedence bug in Java, inherited from C:
int flags = 0b0110;
// if (flags & 0b0010 != 0) { } // ERROR: parsed as flags & (0b0010 != 0)
if ((flags & 0b0010) != 0) { } // correct: parenthesise the mask
&& binds tighter than ||. So a condition mixing them groups the ANDs
first, exactly like * before +:
if (a || b && c) // means: a || (b && c)
if ((a || b) && c) // different meaning -- parentheses required
Assignment is right-associative and lowest, which makes chained assignment work and means the entire right-hand side is evaluated first:
int x, y, z;
x = y = z = 5; // z = 5, then y = z, then x = y
Unary operators bind tighter than arithmetic:
int i = 5;
int r = -i + 3; // (-i) + 3 = -2, not -(i + 3)
int s = 2 + 3 * 4; // 14, not 20
int t = (2 + 3) * 4; // 20
Worked examples, resolved step by step:
2 + 3 * 4 - 6 / 2
= 2 + 12 - 3 (* and / first, left to right)
= 14 - 3
= 11
true || false && false
= true || (false && false) (&& before ||)
= true || false
= true
1 + 2 + "x" + 1 + 2
= "3x" + 1 + 2 (+ is left-associative throughout)
= "3x1" + 2
= "3x12"
The practical rule: memorise the four or five relationships above, and parenthesise anything else. Explicit parentheses cost nothing at runtime, and a reader should never have to consult a precedence table to review your code.
Key Takeaways
- Precedence selects the binding operator; associativity orders equals.
- Bitwise
&,^,|bind looser than==and!=, so masks need parentheses.&&binds tighter than||, mirroring*before+.- Assignment and the conditional operator associate right to left.
- Parenthesise for the reader rather than relying on the table.
🧪 Practice
- Evaluate by hand, then verify:
10 - 4 - 3,2 * 3 % 4,true || true && false,1 + 2 + "3" + 4 + 5.- Find and fix the precedence bug in
if (value & MASK == MASK).- Interview: Why does
a || b && cnot mean(a || b) && c, and how would you make the intent unambiguous? (Hint: compare with arithmetic.)
<a id="15-control-flow"></a>
1.5 Control Flow
Control flow statements decide which code runs and how often. Java's set is small and conventional, with modern additions that turn some of them into expressions.
if, else if, and else
The if statement executes a block when a condition is true. The condition
must be a boolean expression — Java accepts nothing else, which eliminates
the C family's most infamous typo.
int temperature = 25;
if (temperature > 30) {
System.out.println("Hot");
} else if (temperature > 20) { // only tested if the first was false
System.out.println("Warm");
} else if (temperature > 10) {
System.out.println("Mild");
} else {
System.out.println("Cold");
}
There is no elseif keyword: else if is simply an if statement nested in
the else branch. The conventional formatting hides that nesting, which is
why long chains stay readable.
if (c1) { A } is really if (c1) { A }
else if (c2) { B } else {
else { C } if (c2) { B }
else { C }
}
Braces are optional for a single statement, and omitting them is a known
hazard. The compiler binds only the next statement to the if:
if (isValid)
log("valid");
process(); // ALWAYS runs -- indentation lies, this is not in the if
if (isValid) { // always use braces
log("valid");
process();
}
A dangling else binds to the nearest unmatched if, which is another reason
braces are not optional in practice:
if (a)
if (b) x();
else y(); // belongs to 'if (b)', despite what the indentation suggests
Conditions must be boolean, so these classic errors cannot occur:
int count = 0;
// if (count) { } // ERROR: int cannot be converted to boolean
// if (count = 1) { } // ERROR: the assignment yields int, not boolean
if (count == 1) { } // correct
if (flag = true) { } // compiles! both sides are boolean -- the one real trap
Style matters more here than anywhere else in the language, because deep nesting is the most common readability failure in Java code. Guard clauses flatten it:
// Nested: the happy path is buried four levels deep
public String process(Order order) {
if (order != null) {
if (order.isPaid()) {
if (order.hasStock()) {
return ship(order);
} else { return "out of stock"; }
} else { return "unpaid"; }
} else { return "no order"; }
}
// Guard clauses: handle failures first, keep the happy path unindented
public String process(Order order) {
if (order == null) return "no order";
if (!order.isPaid()) return "unpaid";
if (!order.hasStock()) return "out of stock";
return ship(order);
}
Comparing a boolean to a literal is redundant: write if (isReady), not
if (isReady == true).
Key Takeaways
- Conditions must be
boolean; numbers and references do not convert.else ifis a nestedif, not a distinct keyword.- Always use braces; without them only the next single statement is bound, and a dangling
elseattaches to the nearestif.if (flag = true)compiles whenflagis boolean, and is the one assignment-in-condition trap that survives.- Guard clauses flatten nesting and keep the primary path visible.
🧪 Practice
- Write a method classifying a
charas vowel, consonant, digit, or other, using anif/else ifchain.- Take a method nested three levels deep and rewrite it with guard clauses.
- Write code where omitting braces causes a bug, then fix it and describe what the indentation implied versus what the compiler did.
switch Statements and switch Expressions
switch selects among many branches by comparing one value against constant
labels. Java has two generations of it, and knowing both is necessary because
old code is everywhere.
The classic switch statement falls through from one case into the next
unless stopped by break. Fall-through was inherited from C, is occasionally
useful, and is far more often a bug:
int day = 3;
switch (day) {
case 1:
System.out.println("Monday");
break; // forget this and Tuesday prints too
case 2:
System.out.println("Tuesday");
break;
case 6:
case 7: // intentional fall-through: grouped labels
System.out.println("Weekend");
break;
default:
System.out.println("Midweek");
}
Permitted selector types are byte, short, char, int and their wrappers,
String (since Java 7), and enum. Notably absent are long, float,
double, and boolean. Case labels must be compile-time constants, so a
variable or a method call cannot be a label.
Switch expressions (standard since Java 14) fix the design. The arrow form takes no fall-through, requires exhaustiveness when producing a value, and can be assigned directly:
String dayType = switch (day) {
case 1, 2, 3, 4, 5 -> "Weekday"; // multiple labels, comma separated
case 6, 7 -> "Weekend";
default -> "Invalid"; // required: the switch must be exhaustive
}; // note the terminating semicolon
For a branch needing several statements, use a block and yield to supply the
value:
int numLetters = switch (day) {
case 1, 2 -> 6;
case 3 -> {
System.out.println("Computing for Wednesday"); // side effect
yield 9; // yield, not return
}
default -> 0;
};
| Aspect | Classic statement | Arrow expression |
|---|---|---|
| Fall-through | Yes, unless break | Never |
| Produces a value | No | Yes |
| Multiple labels | Stacked case lines | Comma-separated |
| Exhaustiveness | Not required | Required when returning a value |
| Scope of variables | Shared across all cases | Per branch |
| Multi-statement | Naturally | Use a block with yield |
The scope difference is worth seeing, because the classic form shares one scope across every case, so a variable declared in one case is visible (though unassigned) in the others:
switch (x) {
case 1:
int v = 10; // v is in scope for case 2 as well
break;
case 2:
// int v = 20; // ERROR: v is already defined
break;
}
With enum selectors the compiler knows every possible value, so an arrow
switch covering all constants needs no default — and if someone later adds a
constant, the code stops compiling, which is exactly the reminder you want:
enum Status { ACTIVE, SUSPENDED, CLOSED }
String describe(Status s) {
return switch (s) {
case ACTIVE -> "running";
case SUSPENDED -> "paused";
case CLOSED -> "finished";
// no default: adding a constant becomes a compile error here
};
}
Pattern matching for switch, which allows type patterns and guards in case
labels, builds on this and is covered with polymorphism in a later chapter.
Key Takeaways
- The classic switch falls through unless each case breaks; the arrow form never falls through.
- Switch expressions produce values, must be exhaustive, and use
yieldinside blocks.- Selector types are integral types,
String, andenum— notlong, floating point, orboolean.- Case labels must be compile-time constants.
- Omitting
defaultover anenumturns a future added constant into a compile error, which is desirable.
🧪 Practice
- Write a classic switch that deliberately relies on fall-through to group months by quarter, then rewrite it as an arrow expression.
- Convert an
if/else ifchain over aStringcommand into a switch expression.- Interview: Why can a switch not use
longordouble? (Hint: think about how labels are matched and what a jump table requires.)
while and do-while Loops
Both loops repeat while a condition holds. The difference is when the condition is tested, which decides whether the body can run zero times.
// while: test first, so the body may never execute
int i = 0;
while (i < 5) {
System.out.print(i + " "); // 0 1 2 3 4
i++; // forgetting this is the classic infinite loop
}
// do-while: body first, so it always runs at least once
int j = 10;
do {
System.out.println("runs once even though 10 < 5 is false");
j++;
} while (j < 5);
while do-while
+-> test condition +-> execute body
| | | |
| true| false -> exit | test condition
| v | |
+--- body +--true | false -> exit
while is the natural choice when the number of iterations is unknown in
advance, which is why it dominates input reading, searching, and consuming
iterators:
Scanner scanner = new Scanner(System.in);
String line;
while (!(line = scanner.nextLine()).equals("quit")) { // assign, then test
process(line);
}
// Consuming an iterator explicitly
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String value = it.next();
if (value.isEmpty()) it.remove(); // the only safe way to remove while iterating
}
do-while fits when at least one pass is required by the problem itself:
menus, retry loops, and input validation.
int choice;
do {
System.out.println("1) Deposit 2) Withdraw 3) Exit");
choice = scanner.nextInt();
} while (choice < 1 || choice > 3); // re-prompt until valid
Note the semicolon after the while in a do-while; it is required, and
omitting it is a common syntax error.
Infinite loops are written deliberately with while (true) and exited with
break or return. This is idiomatic for event loops and servers:
while (true) {
Request request = queue.take(); // blocks until work arrives
if (request.isShutdown()) break;
handle(request);
}
Three habits prevent most loop bugs: make sure some statement in the body can
change the condition, be careful that the change is reachable on every path
(an early continue that skips the increment causes a hang), and never test
double values for exact equality in a loop condition.
// Hangs: the accumulated value never equals 1.0 exactly
for (double d = 0.0; d != 1.0; d += 0.1) { }
// Correct: compare with an inequality
for (double d = 0.0; d < 1.0; d += 0.1) { }
Key Takeaways
whiletests before the body, so it may run zero times;do-whilealways runs at least once.do-whilerequires a terminating semicolon.whilesuits unknown iteration counts;do-whilesuits menus and validation.- Ensure the condition variable changes on every path, including after
continue.- Never use exact equality on floating-point loop conditions.
🧪 Practice
- Write the same countdown twice, once with
whileand once withdo-while, starting from a value that makes the condition initially false, and compare the output.- Write a number-guessing loop that keeps prompting until the guess is correct, reporting "higher" or "lower".
- Write a
whileloop with acontinueplaced so that the loop never terminates, then fix it.
Classic and Enhanced for Loops
The classic for loop packs initialisation, condition, and update into one
header, keeping the loop's mechanics in one place.
for (int i = 0; i < 5; i++) {
System.out.print(i + " "); // 0 1 2 3 4
}
// i is out of scope here
for ( init ; condition ; update ) { body }
1. init runs once, before anything else
2. condition tested before each iteration; false -> exit
3. body executes
4. update runs after the body
5. back to 2
All three parts are optional, and the initialiser and update may contain several comma-separated expressions:
for (int i = 0, j = 10; i < j; i++, j--) { // two counters converging
System.out.println(i + " " + j);
}
for (;;) { } // no parts at all: an infinite loop
The enhanced for loop (or "for-each", since Java 5) iterates over arrays
and anything implementing Iterable, removing the index bookkeeping entirely:
int[] numbers = {1, 2, 3, 4, 5};
for (int n : numbers) {
System.out.print(n + " ");
}
List<String> names = List.of("Ada", "Grace", "Alan");
for (String name : names) {
System.out.println(name);
}
Map<String, Integer> ages = Map.of("Ada", 36, "Alan", 41);
for (Map.Entry<String, Integer> entry : ages.entrySet()) { // iterate a map
System.out.println(entry.getKey() + " is " + entry.getValue());
}
It is pure syntactic sugar. The compiler desugars the collection form into an iterator loop and the array form into an indexed loop:
// for (String s : list) becomes:
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
...
}
That desugaring explains both its limitations and its one notorious runtime error:
List<String> items = new ArrayList<>(List.of("a", "b", "c"));
for (String item : items) {
if (item.equals("b")) {
items.remove(item); // ConcurrentModificationException
}
}
items.removeIf(item -> item.equals("b")); // correct and concise
| Situation | Loop to choose |
|---|---|
| Need the index | Classic for |
| Iterate backwards or by a step | Classic for |
| Modify the collection while looping | Classic for or iterator |
| Read every element in order | Enhanced for |
| Iterate several collections at once | Classic for |
The enhanced form also cannot modify the array or list element itself, because the loop variable is a copy of the value or reference:
int[] values = {1, 2, 3};
for (int v : values) { v *= 2; } // no effect on the array
for (int i = 0; i < values.length; i++) { values[i] *= 2; } // works
Prefer the enhanced form whenever it suffices: it cannot go out of bounds, it cannot get the termination condition wrong, and it states the intent plainly.
Key Takeaways
- The classic
forcollects init, condition, and update in one header and scopes its variable to the loop.- The enhanced
forworks on arrays andIterable, and is desugared to an iterator or indexed loop.- Structurally modifying a collection inside an enhanced
forthrowsConcurrentModificationException; useremoveIfor an explicit iterator.- The loop variable is a copy, so assigning to it does not change the source.
- Use the enhanced form by default, the classic form when you need the index.
🧪 Practice
- Print an array forwards with an enhanced
forand backwards with a classic one.- Trigger a
ConcurrentModificationException, then fix it three ways:removeIf, an explicitIterator, and collecting into a new list.- Write nested loops printing a multiplication table from 1 to 9, aligned with
printf.
break, continue, and Labeled Statements
break exits a loop or switch entirely; continue skips the rest of the
current iteration and proceeds to the next one. Both apply to the innermost
enclosing loop unless a label directs them elsewhere.
for (int i = 1; i <= 10; i++) {
if (i == 5) break; // leaves the loop at once
System.out.print(i + " "); // 1 2 3 4
}
for (int i = 1; i <= 10; i++) {
if (i % 2 == 0) continue; // skips the println for even numbers
System.out.print(i + " "); // 1 3 5 7 9
}
A critical detail: in a classic for loop, continue still runs the update
expression, so the counter advances. In a while loop it does not, which is
how continue causes infinite loops:
int i = 0;
while (i < 5) {
if (i == 2) continue; // INFINITE: i++ below is never reached when i == 2
System.out.print(i);
i++;
}
Labels name a statement so that break or continue can target an outer
loop. A label is an identifier followed by a colon, placed immediately before
the loop.
outer: // the label
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (i * j > 2) {
break outer; // leaves BOTH loops
}
System.out.println(i + "," + j);
}
}
search:
for (int[] row : matrix) {
for (int value : row) {
if (value == target) {
found = true;
break search; // stop scanning the whole matrix
}
}
}
break; break outer; continue outer;
+--outer------+ +--outer------+ +--outer------+
| +-inner-+ | | +-inner-+ | | +-inner-+ |
| | X----+--+ | | X-----+--+----> | | X-----+--+--+
| +-------+ | | +-------+ | exit | +-------+ | | next
| continues | | | | |<-+ outer
+-------------+ +-------------+ +-------------+ iteration
continue label is rarer and restarts the labelled loop's next iteration,
skipping the remainder of both bodies.
Labels are the closest thing Java has to goto — and goto is a reserved but
unused keyword precisely so that nobody adds it. Labels are constrained: they
can only break out of or continue an enclosing statement, never jump forward
into arbitrary code.
Use them sparingly. A labelled break out of a double loop is legitimate and
clear; three or more nested labelled jumps usually mean the inner loops should
become a method, where a plain return does the same job more legibly:
// Often better than a labelled break
private boolean contains(int[][] matrix, int target) {
for (int[] row : matrix) {
for (int value : row) {
if (value == target) return true; // returns from the method
}
}
return false;
}
One more use: break inside a switch nested in a loop exits the switch,
not the loop. To leave the loop from inside a switch, you need a label.
Key Takeaways
breakexits the innermost loop or switch;continueskips to the next iteration.- In a classic
for,continuestill runs the update; in awhileit does not, which can hang the loop.- Labels let
breakandcontinuetarget an enclosing loop.breakinside a switch within a loop exits only the switch.- Extracting a method and using
returnis usually clearer than deep labelled jumps.
🧪 Practice
- Write nested loops that find the first pair of numbers in two arrays summing to a target, stopping both loops immediately.
- Construct a
whileloop wherecontinueskips the increment, observe the hang, and fix it two different ways.- Write a loop containing a switch, and make
breakexit the loop rather than the switch.
Nested Control Structures
Real logic combines these constructs, and the combination is where readability is usually won or lost. Nesting itself is not the problem; unnecessary nesting is.
A standard nested loop walks a two-dimensional structure, with the outer loop over rows and the inner over columns:
for (int row = 1; row <= 3; row++) {
for (int col = 1; col <= 3; col++) {
System.out.printf("%4d", row * col);
}
System.out.println(); // end the line after each row
}
1 2 3
2 4 6
3 6 9
Complexity compounds multiplicatively: two nested loops over n items perform n squared iterations, three perform n cubed. A triple-nested loop over ten thousand records is a hundred trillion operations — which is why recognising nesting depth is a performance skill, not just a style one.
Combining loops with conditionals produces the classic drift toward the right margin. Four techniques reverse it.
1. Invert conditions and use continue to keep the body flat:
// Before
for (Order order : orders) {
if (order.isPaid()) {
if (order.getTotal() > 100) {
applyDiscount(order);
}
}
}
// After
for (Order order : orders) {
if (!order.isPaid()) continue;
if (order.getTotal() <= 100) continue;
applyDiscount(order);
}
2. Extract the inner loop into a method, which also names the operation:
for (Department dept : departments) {
totalPayroll += payrollFor(dept); // inner loop moved inside
}
private double payrollFor(Department dept) {
double sum = 0;
for (Employee e : dept.getEmployees()) {
sum += e.getSalary();
}
return sum;
}
3. Return early rather than nesting the remainder in an else.
4. Replace the loop with a stream when the operation is a filter, map, or reduce. Streams are covered in depth later, but the contrast is instructive:
double total = departments.stream()
.flatMap(d -> d.getEmployees().stream())
.filter(Employee::isActive)
.mapToDouble(Employee::getSalary)
.sum();
A worked example combining everything — validating a grid and reporting the first problem:
public static String validate(int[][] grid) {
if (grid == null || grid.length == 0) {
return "grid is empty"; // guard clause
}
for (int row = 0; row < grid.length; row++) {
if (grid[row].length != grid[0].length) {
return "row " + row + " has a different length"; // early return
}
for (int col = 0; col < grid[row].length; col++) {
int value = grid[row][col];
if (value < 0) {
return "negative value at " + row + "," + col;
}
}
}
return "valid";
}
As a rule of thumb, three levels of nesting inside a method is a signal to extract; four is a defect waiting to be found.
Key Takeaways
- Nested loops multiply their iteration counts, so depth is a performance concern as well as a readability one.
continuewith inverted conditions flattens filtering loops.- Extracting an inner loop into a well-named method both flattens and documents the code.
- Early returns remove
elsebranches entirely.- Treat three levels of nesting as a prompt to refactor.
🧪 Practice
- Print a right-angled triangle of asterisks of height n using nested loops.
- Take a triple-nested loop with two conditionals and flatten it using method extraction and
continue.- Write a method that finds the first duplicate in a two-dimensional array, returning its coordinates, and stop scanning as soon as it is found.
<a id="16-arrays-and-strings"></a>
1.6 Arrays and Strings
Arrays and strings are the two aggregate types you meet before learning to write classes. Both are objects, both are fixed in size once created, and both have behaviour that surprises people who assume otherwise.
One-Dimensional Array Creation and Access
An array is a fixed-length, ordered container of values that all share one
type. Unlike the primitives, an array is an object: it lives on the heap, a
variable holds a reference to it, and that reference can be null.
int[] numbers; // declares a reference, no array yet
numbers = new int[5]; // allocates 5 slots, all zero-initialised
int[] combined = new int[5]; // the usual one-line form
int[] literal = {10, 20, 30}; // initialiser shorthand, size inferred
int[] explicit = new int[]{10, 20, 30}; // needed when not at a declaration
String[] names = new String[3]; // three null references, no String objects
Put the brackets after the type, not the variable. int[] a is idiomatic Java;
int a[] is legal C-style syntax that Java tolerates and nobody uses.
int[] numbers = {10, 20, 30, 40, 50};
numbers ---> +------+------+------+------+------+
| 10 | 20 | 30 | 40 | 50 |
+------+------+------+------+------+
index 0 1 2 3 4
length = 5
last valid index = length - 1
Access is zero-based and bounds-checked. Every read and write verifies the index at runtime, and an out-of-range index throws rather than corrupting memory. That check is the reason Java does not have buffer overflow vulnerabilities in ordinary code.
int[] numbers = {10, 20, 30, 40, 50};
System.out.println(numbers[0]); // 10
System.out.println(numbers[4]); // 50
numbers[2] = 99; // arrays are mutable
System.out.println(numbers.length); // 5 -- a FIELD, not a method
// numbers[5]; // ArrayIndexOutOfBoundsException
// numbers[-1]; // same exception
Note length is a field on arrays, length() is a method on String, and
size() is a method on collections. The inconsistency is historical and you
simply have to remember it.
Fixed size. An array's length is set at creation and can never change. "Growing" an array means allocating a new one and copying:
int[] grown = Arrays.copyOf(numbers, numbers.length * 2); // new array, padded with 0
int[] slice = Arrays.copyOfRange(numbers, 1, 4); // indices 1,2,3
If you need a container that grows, use ArrayList, which does exactly this
copying internally.
Default values are the same zero values used for fields: 0, 0.0,
false, '\u0000', or null. An array of objects contains null references
until you fill it, which is a frequent source of NullPointerException:
String[] words = new String[3];
System.out.println(words[0]); // null
// System.out.println(words[0].length()); // NullPointerException
Arrays.fill(words, ""); // fill before use if you need non-null
Iteration uses either form of for, depending on whether you need the index:
for (int i = 0; i < numbers.length; i++) { // index available
System.out.println(i + ": " + numbers[i]);
}
for (int value : numbers) { // values only
System.out.println(value);
}
Key Takeaways
- Arrays are fixed-size heap objects holding one element type, and the reference can be
null.- Elements are zero-initialised; object arrays start full of
null.lengthis a field on arrays,length()a method onString,size()on collections.- Indexing is zero-based and bounds-checked, throwing
ArrayIndexOutOfBoundsException.- Resizing means allocating and copying, which is what
ArrayListautomates.
🧪 Practice
- Write methods computing the minimum, maximum, and average of an
int[], handling the empty array explicitly.- Reverse an array in place using two indices moving toward each other.
- Interview: Why is
array.lengtha field whilestring.length()is a method? (Hint: what kind of object is an array, and what can it declare?)
Multidimensional and Jagged Arrays
Java has no true multidimensional array. What it has is arrays of arrays, and understanding that distinction explains everything else about them.
int[][] matrix = new int[3][4]; // 3 rows, each an int[4]
matrix[1][2] = 7; // row 1, column 2
int[][] literal = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
matrix ---> +-----+-----+-----+
| • | • | • | an array of THREE references
+--|--+--|--+--|--+
| | |
v v v
[0]-> +---+---+---+---+
| 0 | 0 | 0 | 0 | each row is a separate int[4] object
+---+---+---+---+
[1]-> +---+---+---+---+
| 0 | 0 | 7 | 0 |
+---+---+---+---+
[2]-> +---+---+---+---+
| 0 | 0 | 0 | 0 |
+---+---+---+---+
Because rows are independent objects, they need not be the same length. An array with rows of differing length is called jagged (or ragged):
int[][] triangle = new int[4][]; // 4 row slots, no rows allocated yet
for (int i = 0; i < triangle.length; i++) {
triangle[i] = new int[i + 1]; // row i has i+1 columns
}
int[][] jagged = {
{1},
{2, 3},
{4, 5, 6}
};
This is why matrix.length is the number of rows and matrix[i].length is the
length of that specific row. Iterating correctly means asking each row for
its own length:
for (int row = 0; row < jagged.length; row++) {
for (int col = 0; col < jagged[row].length; col++) { // per-row length
System.out.print(jagged[row][col] + " ");
}
System.out.println();
}
// Enhanced form, which avoids the mistake entirely
for (int[] row : jagged) {
for (int value : row) {
System.out.print(value + " ");
}
System.out.println();
}
Only the leftmost dimensions may be specified when allocating, and they must be contiguous:
int[][][] cube = new int[2][3][4]; // fully allocated
int[][] partial = new int[3][]; // legal: rows allocated later
// int[][] invalid = new int[][4]; // ERROR: cannot skip a leading dimension
Printing needs the right helper, because an array's own toString prints a
type name and a hash code:
System.out.println(jagged); // [[I@1b6d3586 -- useless
System.out.println(Arrays.toString(jagged[0])); // [1]
System.out.println(Arrays.deepToString(jagged)); // [[1], [2, 3], [4, 5, 6]]
Performance note: because rows are separate objects, they can be scattered across the heap. Iterating row by row (following the memory layout) is faster than column by column for large matrices, an effect that grows with size.
Key Takeaways
- Multidimensional arrays are arrays of array references, not contiguous blocks.
- Rows may have different lengths, producing jagged arrays.
- Use
matrix[i].lengthfor the row length, never assume a uniform width.- Only leading dimensions may be given at allocation time.
- Use
Arrays.deepToStringto print nested arrays.
🧪 Practice
- Build and print Pascal's triangle for ten rows using a jagged array.
- Write a method that transposes a rectangular
int[][], and decide what it should do with a jagged input.- Interview: What does
new int[3][]create, and how does it differ fromnew int[3][4]? (Hint: count the objects allocated.)
Array Utility Methods
java.util.Arrays provides the operations arrays lack as objects. Knowing this
class saves writing loops that are easy to get subtly wrong.
import java.util.Arrays;
int[] data = {5, 2, 9, 1, 7};
Printing and comparing.
System.out.println(Arrays.toString(data)); // [5, 2, 9, 1, 7]
System.out.println(Arrays.deepToString(matrix)); // for nested arrays
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(a == b); // false -- different objects
System.out.println(a.equals(b)); // false -- Object.equals, identity again
System.out.println(Arrays.equals(a, b)); // true -- element by element
System.out.println(Arrays.deepEquals(nested1, nested2)); // for nested arrays
a.equals(b) returning false for identical contents catches almost everyone
once. Arrays do not override equals or hashCode, so they inherit identity
semantics from Object.
Sorting and searching.
Arrays.sort(data); // [1, 2, 5, 7, 9], in place
Arrays.sort(data, 1, 4); // sort a subrange only
String[] words = {"pear", "Apple", "fig"};
Arrays.sort(words, String.CASE_INSENSITIVE_ORDER); // custom Comparator
Arrays.sort(words, Comparator.comparing(String::length));
int pos = Arrays.binarySearch(data, 7); // 3 -- REQUIRES a sorted array
int missing = Arrays.binarySearch(data, 6); // negative: -(insertion point) - 1
binarySearch on an unsorted array does not throw; it returns a meaningless
result. Sorting first is your responsibility.
Filling, copying, and converting.
int[] filled = new int[5];
Arrays.fill(filled, 42); // [42, 42, 42, 42, 42]
Arrays.fill(filled, 1, 3, 0); // fill a subrange
int[] copy = Arrays.copyOf(data, 3); // first 3, or pad with 0
int[] range = Arrays.copyOfRange(data, 1, 4); // indices 1..3
System.arraycopy(data, 0, copy, 0, 3); // lower-level, fastest
List<String> list = Arrays.asList("a", "b", "c"); // FIXED-SIZE view, not a copy
// list.add("d"); // UnsupportedOperationException
List<String> mutable = new ArrayList<>(Arrays.asList("a", "b")); // real list
Streams and aggregates.
int sum = Arrays.stream(data).sum();
OptionalInt max = Arrays.stream(data).max();
double avg = Arrays.stream(data).average().orElse(0);
long evens = Arrays.stream(data).filter(n -> n % 2 == 0).count();
| Task | Use |
|---|---|
| Print contents | Arrays.toString / deepToString |
| Compare contents | Arrays.equals / deepEquals |
| Sort | Arrays.sort |
| Search a sorted array | Arrays.binarySearch |
| Resize | Arrays.copyOf |
| Fast block copy | System.arraycopy |
| Aggregate | Arrays.stream(...) |
One caution on Arrays.sort: primitives are sorted with a dual-pivot quicksort
(fast, not stable, which does not matter for primitives), while object arrays
use a stable merge sort called TimSort. The distinction matters when sorting
objects by several keys in sequence.
Key Takeaways
- Arrays do not override
equalsortoString, so useArrays.equalsandArrays.toString.binarySearchsilently misbehaves on unsorted input.Arrays.asListreturns a fixed-size view backed by the array, not an independent list.Arrays.copyOfresizes by allocating and copying;System.arraycopyis the fastest block copy.- Primitive sorts use quicksort; object sorts use a stable merge sort.
🧪 Practice
- Demonstrate the difference between
==,.equals, andArrays.equalson two arrays with identical contents.- Sort an array of strings by length and then alphabetically, using a comparator chain.
- Show what
Arrays.binarySearchreturns for an absent element and use that value to compute the correct insertion point.
String Immutability and the String Pool
A String is an object wrapping a sequence of characters, and its defining
property is that it is immutable: once created, the characters can never
change. Every method that appears to modify a string in fact returns a new one.
String s = "hello";
s.toUpperCase(); // creates "HELLO" and DISCARDS it
System.out.println(s); // hello -- unchanged
s = s.toUpperCase(); // reassigning the reference is the only way
System.out.println(s); // HELLO
Immutability is not an accident; it buys four concrete things. Strings can be
shared safely between threads with no synchronisation. Their hash code can be
computed once and cached, which makes them ideal HashMap keys. They can be
pooled and deduplicated. And security-sensitive values such as file paths or
class names cannot be changed by another thread between validation and use.
The string pool is a region of memory holding one shared instance of each distinct string literal. Because strings cannot change, sharing is safe.
String a = "java"; // creates the pooled instance
String b = "java"; // reuses the SAME pooled instance
String c = new String("java"); // explicitly forces a new heap object
String d = c.intern(); // returns the pooled instance
System.out.println(a == b); // true -- same object
System.out.println(a == c); // false -- different objects
System.out.println(a == d); // true -- intern() found the pooled one
System.out.println(a.equals(c)); // true -- contents are equal
String pool (inside the heap) Regular heap
+---------------------+ +---------------------+
| "java" <--- a | | "java" <--- c |
| <--- b | +---------------------+
| <--- d |
+---------------------+ a == b true
a == c false
a.equals(c) true
Compile-time constant expressions are folded and pooled; anything computed at runtime is not:
String x = "ja" + "va"; // folded by the compiler -> pooled literal
System.out.println(x == a); // true
String part = "ja";
String y = part + "va"; // computed at runtime -> new object
System.out.println(y == a); // false
System.out.println(y.equals(a)); // true
final String cpart = "ja"; // a compile-time constant
String z = cpart + "va"; // folded again
System.out.println(z == a); // true
The practical rule follows directly: compare strings with equals, never
with ==. For case-insensitive comparison use equalsIgnoreCase, and to
avoid a NullPointerException when the variable may be null, call equals on
the literal or use Objects.equals:
String input = null;
// input.equals("yes") // NullPointerException
"yes".equals(input); // false, safe
Objects.equals(input, "yes"); // false, safe and symmetrical
Since Java 9, strings are stored internally as a byte[] with an encoding flag
("compact strings"), using one byte per character for Latin-1 text instead of
two. This halved the memory footprint of typical applications and required no
code changes, a good illustration of why encapsulating the representation
matters.
Key Takeaways
- Strings are immutable; every "modifying" method returns a new string.
- Immutability enables safe sharing, cached hash codes, and pooling.
- Literals are pooled and shared;
new String(...)always allocates.- Compare with
equals;==compares identity and works with literals by coincidence.- Call
equalson the known-non-null side, or useObjects.equals.
🧪 Practice
- Write a program producing three strings with identical contents where
==gives different answers, and explain each case.- Show that
s.replace('a', 'b')leavessunchanged unless reassigned.- Interview: Why does Java make
Stringimmutable, and what would break inHashMapif it were not? (Hint: think about when a hash code is computed and where the entry is stored.)
Common String Operations
String exposes a large API. These are the methods that cover the vast
majority of real use, grouped by purpose.
Inspecting.
String s = "Hello, World";
s.length(); // 12 -- a method, unlike array.length
s.charAt(0); // 'H'
s.indexOf("World"); // 7, or -1 if absent
s.lastIndexOf('l'); // 10
s.contains("lo, W"); // true
s.startsWith("Hello"); // true
s.endsWith("!"); // false
s.isEmpty(); // false -- length() == 0
" ".isBlank(); // true -- Java 11: empty or whitespace only
Transforming (all return new strings).
s.toUpperCase(); // "HELLO, WORLD"
s.substring(7); // "World" -- from index to the end
s.substring(0, 5); // "Hello" -- end index is EXCLUSIVE
s.replace('l', 'L'); // "HeLLo, WorLd"
s.replace("World", "Java"); // "Hello, Java"
" padded ".trim(); // "padded" -- removes ASCII whitespace
" padded ".strip(); // "padded" -- Java 11, Unicode-aware
"ab".repeat(3); // "ababab" -- Java 11
The exclusive end index of substring is the single most common off-by-one
source: substring(a, b) yields b - a characters, and substring(i, i) is
the empty string.
Splitting and joining.
String csv = "red,green,blue";
String[] colours = csv.split(","); // takes a REGEX, not a literal
String[] parts = "a.b.c".split("\\."); // '.' must be escaped in regex
String[] limited = "a,b,c,d".split(",", 2); // ["a", "b,c,d"]
String joined = String.join(" | ", colours); // "red | green | blue"
String fromList = String.join(", ", List.of("x", "y"));
That split takes a regular expression, not a plain delimiter, is a frequent
bug: splitting on "." or "|" without escaping matches everything or
nothing.
Comparing.
"abc".equals("ABC"); // false
"abc".equalsIgnoreCase("ABC"); // true
"apple".compareTo("banana"); // negative: lexicographic ordering
"apple".compareToIgnoreCase("APPLE"); // 0
compareTo returns a negative number, zero, or a positive number — never
specifically -1 or 1, so test the sign rather than an exact value.
Formatting and conversion.
String formatted = String.format("%s scored %.1f%%", "Ada", 92.5);
String modern = "%s scored %.1f%%".formatted("Ada", 92.5); // Java 15
int n = Integer.parseInt("42"); // String -> int, throws on bad input
double d = Double.parseDouble("3.14");
String back = String.valueOf(42); // int -> String, null-safe
String also = Integer.toString(42);
char[] chars = s.toCharArray();
A chained example — normalising user input:
public static String normalise(String raw) {
if (raw == null) return "";
return raw.strip() // remove surrounding whitespace
.toLowerCase() // case-fold
.replaceAll("\\s+", " "); // collapse internal runs of whitespace
}
Each call returns a new string, so chaining reads naturally but allocates at every step. For a handful of calls that cost is irrelevant; inside a hot loop, it is not, which leads directly to the next topic.
Key Takeaways
substring's end index is exclusive, yieldingend - startcharacters.splitandreplaceAlltake regular expressions;replacetakes literals.compareToreturns a sign, not a fixed magnitude.- Use
stripovertrimfor Unicode correctness, andisBlankto test for whitespace-only strings.- Every transformation allocates a new string.
🧪 Practice
- Write a palindrome check that ignores case, spaces, and punctuation.
- Write a method counting the occurrences of a substring, and make sure overlapping matches are handled the way you intend.
- Split
"a.b.c"on the dot, observe the empty result, and fix the regex.
StringBuilder and Efficient Concatenation
Because strings are immutable, concatenation in a loop is quadratic: each +
allocates a new string and copies every character seen so far.
// Anti-pattern: for n iterations this copies roughly n squared characters
String result = "";
for (int i = 0; i < 10_000; i++) {
result += i + ","; // allocates a NEW String every iteration
}
iteration 1: "" + "0," -> "0," copy 0 chars
iteration 2: "0," + "1," -> "0,1," copy 2 chars
iteration 3: "0,1," + "2," -> "0,1,2," copy 4 chars
... total ~ n^2 / 2
StringBuilder holds a mutable char buffer and appends in place, growing the
buffer geometrically. The same loop becomes linear:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) {
sb.append(i).append(','); // append returns 'this', so calls chain
}
String result = sb.toString(); // one allocation at the end
On a ten-thousand-iteration loop the difference is typically three orders of magnitude.
The main API:
StringBuilder sb = new StringBuilder(); // default capacity 16
StringBuilder sized = new StringBuilder(1024); // pre-size when you know roughly
sb.append("Hello").append(", ").append(42).append(true); // any type
sb.insert(0, ">> "); // insert at an index
sb.replace(0, 2, "**"); // replace a range
sb.delete(0, 3); // delete a range
sb.deleteCharAt(0);
sb.reverse(); // in place
sb.setLength(0); // clear and reuse the buffer
sb.charAt(2); sb.length(); sb.indexOf("lo");
String out = sb.toString(); // produce an immutable String
When + is fine. The compiler optimises concatenation within a single
expression, so these are not a problem:
String message = "User " + name + " logged in at " + time; // one expression
Modern compilers compile this to an efficient invokedynamic call that builds
the result in one pass. The rule is therefore narrower than "never use +":
avoid + when concatenating repeatedly across separate statements,
typically in a loop.
| Situation | Use |
|---|---|
| Joining a few values in one line | + |
| Building in a loop | StringBuilder |
| Joining a collection with a delimiter | String.join or Collectors.joining |
| Shared across threads | StringBuffer (synchronised) |
StringBuffer is the thread-safe sibling with identical methods and
synchronised access. It predates StringBuilder (added in Java 5) and is
slower; use it only when a single builder genuinely is shared across threads,
which is rare because a better design usually avoids sharing it at all.
| Class | Mutable | Thread-safe | Typical use |
|---|---|---|---|
String | No | Yes (by immutability) | Values, keys, APIs |
StringBuilder | Yes | No | Local building |
StringBuffer | Yes | Yes | Legacy or shared building |
For the common case of joining a collection, prefer the dedicated tools, which handle the delimiter edge cases for you:
String csv = String.join(",", List.of("a", "b", "c")); // "a,b,c"
String fancy = list.stream().collect(Collectors.joining(", ", "[", "]"));
Key Takeaways
- Concatenating with
+inside a loop is quadratic because each step copies the whole accumulated string.StringBuilderappends into a growable buffer, making the same work linear.+within a single expression is compiled efficiently and is fine.- Pre-size the builder when the final length is roughly known.
- Use
StringBufferonly for genuine cross-thread sharing; preferString.joinfor delimited output.
🧪 Practice
- Time building a 100,000-character string with
+and withStringBuilder, and report the ratio.- Write a method reversing each word in a sentence while preserving word order, using
StringBuilder.- Interview: Is
"a" + "b" + "c"slow? Explain what the compiler does with it. (Hint: are those values known at compile time?)
Text Blocks and String Formatting
Multi-line text in source code used to require escape-laden concatenation. Text blocks, standard since Java 15, let you write it literally.
// Before: escapes, concatenation, and hard-to-see structure
String json = "{\n" +
" \"name\": \"Ada\",\n" +
" \"age\": 36\n" +
"}";
// After: a text block, delimited by three double quotes
String json2 = """
{
"name": "Ada",
"age": 36
}""";
The opening """ must be followed by a line terminator; content starts on the
next line. Closing delimiter placement controls the trailing newline: on its
own line, the string ends with \n; on the content's last line, it does not.
Incidental indentation is stripped automatically. The compiler finds the minimum indentation across all non-blank lines and the closing delimiter, then removes that much from every line. This lets the block stay indented with the surrounding code without polluting the value:
String sql = """
SELECT id, name <- 16 spaces
FROM users <- 16 spaces
WHERE active = true <- 16 spaces
"""; <- 16 spaces => strip 16 from each line
Result: "SELECT id, name\nFROM users\nWHERE active = true\n"
Moving the closing delimiter further left preserves more indentation, which is how you control it deliberately.
Two escapes are specific to text blocks:
String joined = """
This line continues \
onto the same line."""; // trailing \ suppresses the newline
String trailing = """
Keeps two trailing spaces\s\s
"""; // \s is a literal space, protected from stripping
Text blocks are ordinary String objects: same type, same methods, same
pooling for constant blocks. They are purely a source-code convenience, ideal
for SQL, JSON, HTML, and multi-line messages.
Formatting. Three interchangeable routes exist, and formatted reads best
with a text block:
String a = String.format("%s is %d years old", name, age);
String b = "%s is %d years old".formatted(name, age); // Java 15
System.out.printf("%s is %d years old%n", name, age); // writes directly
String query = """
SELECT * FROM users
WHERE country = '%s' AND age > %d
""".formatted("IT", 18);
Format specifier syntax is %[argument_index$][flags][width][.precision]conversion:
| Specifier | Meaning | Example | Output |
|---|---|---|---|
%s | Any value via toString | format("%s", 1.5) | 1.5 |
%d | Integer | format("%d", 42) | 42 |
%f | Floating point | format("%f", 3.14) | 3.140000 |
%.2f | Two decimal places | format("%.2f", 3.146) | 3.15 |
%10s | Right-align in 10 columns | format("%10s", "hi") | hi |
%-10s | Left-align in 10 columns | format("%-10s|", "hi") | hi | |
%,d | Group digits | format("%,d", 1000000) | 1,000,000 |
%05d | Zero-pad to width 5 | format("%05d", 42) | 00042 |
%x %o | Hex, octal | format("%x", 255) | ff |
%b | Boolean | format("%b", null) | false |
%n | Platform line separator | ||
%% | A literal percent sign |
// A formatted report, combining width, alignment, and grouping
System.out.printf("%-15s %10s %8s%n", "Product", "Price", "Qty");
System.out.printf("%-15s %,10.2f %8d%n", "Keyboard", 1299.5, 3);
System.out.printf("%-15s %,10.2f %8d%n", "Monitor", 24999.0, 1);
Product Price Qty
Keyboard 1,299.50 3
Monitor 24,999.00 1
A mismatch between a specifier and its argument, such as %d with a String,
throws IllegalFormatConversionException at runtime rather than failing to
compile, so format strings deserve a test.
Key Takeaways
- Text blocks start after a line break following
"""and strip incidental indentation based on the least-indented line and the closing delimiter.- Closing-delimiter placement decides whether a trailing newline is included.
\at end of line joins lines;\spreserves a trailing space.- A text block is an ordinary
Stringwith no runtime difference.formatted,String.format, andprintfshare the same specifier syntax; mismatches fail at runtime.
🧪 Practice
- Write an HTML fragment as a text block and print it, verifying that the surrounding code indentation does not appear in the output.
- Produce a formatted invoice table with right-aligned, comma-grouped amounts to two decimals.
- Interview: How does a text block decide how much indentation to remove, and how would you keep four leading spaces on every line? (Hint: the closing delimiter participates in the calculation.)
<a id="2-classes-objects-and-encapsulation"></a>