2. Classes, Objects, and Encapsulation
This chapter is where Java stops being a procedural language with nice syntax and becomes object-oriented. It covers how to define a type, how objects are created and initialised, how methods receive and return data, and how access control turns a loose bag of fields into a component with an enforced contract. Everything in the remaining chapters — inheritance, interfaces, collections, frameworks — is built from the ideas established here.
<a id="21-object-oriented-thinking"></a>
2.1 Object-Oriented Thinking
Before any syntax, it is worth understanding what problem objects solve, because the syntax only makes sense once the motivation is clear.
Procedural versus Object-Oriented Paradigms
In procedural programming, data and the functions that act on it are separate. You define record structures, then write functions that take them as arguments. This works well at small scale and degrades badly at large scale, for one specific reason: nothing stops any function anywhere from modifying any field. When a value is wrong, the list of suspects is every line of code in the program.
Consider a bank account written procedurally in Java-like syntax:
// PROCEDURAL: data is a passive record, logic lives elsewhere
class AccountData {
String owner;
double balance; // anyone can write anything here
}
class AccountOperations {
static void deposit(AccountData account, double amount) {
account.balance += amount; // no validation enforced anywhere
}
static void withdraw(AccountData account, double amount) {
account.balance -= amount; // allows a negative balance
}
}
// Somewhere else, three months later, in a hurry:
account.balance = -5000; // perfectly legal, nothing prevents it
The rule "a balance may never go negative" exists only in the programmer's head and in whichever functions remember to check. The object-oriented version moves the data and its rules into one unit, and closes the door on everything else:
// OBJECT-ORIENTED: the data and the rules that protect it live together
class Account {
private String owner; // private: unreachable from outside
private double balance;
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit must be positive");
}
balance += amount; // the ONLY way balance can increase
}
public void withdraw(double amount) {
if (amount > balance) {
throw new IllegalStateException("Insufficient funds");
}
balance -= amount; // the ONLY way balance can decrease
}
public double getBalance() { return balance; }
}
// account.balance = -5000; // now a COMPILE error, not a runtime disaster
The gain is not that the code is shorter, because it is not. The gain is that the set of places capable of producing a negative balance shrank from "the whole program" to "these twenty lines". That reduction is the entire point.
PROCEDURAL OBJECT-ORIENTED
+----------+ +----------+ +------------------------+
| data | | data | | Account |
+----------+ +----------+ | - balance (private) |
^ ^ ^ | |
| | | | + deposit() |
+----+ +-+---+ +----+----+ | + withdraw() |
| fn | | fn | | fn | | + getBalance() |
+----+ +-----+ +---------+ +------------------------+
^
any function may touch any field |
outside code can ONLY call methods
| Aspect | Procedural | Object-oriented |
|---|---|---|
| Unit of decomposition | Function | Class |
| Data and behaviour | Separate | Bundled together |
| Who may change data | Any function with a reference | Only the owning class |
| Invariants | Enforced by convention | Enforced by the compiler |
| Growth behaviour | Coupling grows with the codebase | Coupling is bounded by interfaces |
| Good fit | Scripts, small tools, pure algorithms | Domains with rules and many collaborators |
Neither paradigm is universally superior. A short data-transformation script is
usually clearer written procedurally, and Java's own Math class is a purely
procedural collection of static functions. Object orientation earns its overhead
when the same data is touched by many parts of a system over a long time.
Key Takeaways
- Procedural code separates data from behaviour, so any function can violate a data rule.
- Object orientation bundles data with the operations permitted on it.
- The benefit is a smaller set of code that can break a given invariant, not fewer lines.
- Access control is what turns the bundling from a convention into a compiler-enforced guarantee.
- Small scripts and pure algorithms are often better procedural; long-lived domains benefit from objects.
🧪 Practice
- Write a procedural
Temperaturerecord with acelsiusfield plus free functions, then rewrite it as a class that rejects values below absolute zero.- Find a class in your own code with public mutable fields and list every place in the program that could put it into an invalid state.
- Interview: Name one situation where a procedural design is preferable to an object-oriented one in Java, and justify it. (Hint: think about whether there is any state to protect.)
Objects as State and Behavior
An object is the pairing of state (the data it currently holds) with behaviour (the operations it can perform). A third property, identity, distinguishes two objects even when their state is identical.
The everyday analogy is a physical device. A television has state (channel, volume, on or off) and behaviour (change channel, adjust volume, power on). You interact with it through buttons, never by reaching into the circuitry. Two identical televisions in a shop have the same state but are distinct objects: switching one off does not affect the other.
public class Television {
// STATE: what this particular television currently knows
private boolean on = false;
private int channel = 1;
private int volume = 10;
// BEHAVIOUR: what this television can be asked to do
public void powerOn() { on = true; }
public void powerOff() { on = false; }
public void setChannel(int newChannel) {
if (!on) return; // behaviour depends on state
if (newChannel < 1 || newChannel > 999) {
throw new IllegalArgumentException("No such channel: " + newChannel);
}
channel = newChannel;
}
public void volumeUp() {
if (on && volume < 100) volume++; // state constrains behaviour
}
public String status() {
return on ? "Channel " + channel + ", volume " + volume : "Off";
}
}
Television living = new Television();
Television kitchen = new Television();
living.powerOn();
living.setChannel(42);
System.out.println(living.status()); // Channel 42, volume 10
System.out.println(kitchen.status()); // Off -- independent state
System.out.println(living == kitchen); // false -- distinct identities
Each object has its OWN copy of every instance field.
living ---> +-------------------+ kitchen ---> +-------------------+
| Television | | Television |
| on = true | | on = false |
| channel = 42 | | channel = 1 |
| volume = 10 | | volume = 10 |
+-------------------+ +-------------------+
\ /
\____ both share ONE copy of ________/
the METHOD code
That diagram captures a crucial implementation detail: every object gets its own
field values, but method code exists once per class. When you call
living.setChannel(42), the JVM runs the single shared method body with a
hidden reference to living (accessible as this) so it knows whose fields to
modify.
The three properties interact in ways worth naming:
| Property | Question it answers | Java mechanism |
|---|---|---|
| State | What does it know? | Instance fields |
| Behaviour | What can it do? | Instance methods |
| Identity | Which one is it? | The reference; compared with == |
Good object design keeps state and behaviour aligned: an object should own the data its methods need and expose methods that mean something in the problem domain. An object with fields but no behaviour is a record, and an object with behaviour but no state is a function collection. Both are legitimate, but if you find them everywhere, the design has probably drifted back to procedural.
Key Takeaways
- An object combines state, behaviour, and identity.
- Every object has its own instance fields; method code is shared by the class.
thisis the hidden reference that tells a shared method which object's state to use.- State can constrain behaviour, which is how objects enforce rules.
- Objects that are all state or all behaviour are a signal to re-examine the design.
🧪 Practice
- Model a
Lampwith state for brightness and power, where brightness can only be adjusted while the lamp is on.- Create two objects of the same class with identical field values and show that
==reports false.- Write a
Counterclass and demonstrate that two counters increment independently.
Classes as Blueprints
A class is a type definition: it specifies what fields every instance will have and what methods every instance will support. It is not itself the thing. The distinction between class and object is the single most important one for a beginner to internalise.
The standard analogy is architectural. A blueprint describes a house: three bedrooms, a kitchen of a certain size, a front door. From one blueprint you can build any number of houses. Each house has its own furniture and its own occupants, but all share the same layout. You cannot live in a blueprint.
CLASS (the blueprint) OBJECTS (the houses)
class House { +------------------+
String address; new | 12 Oak Street |
String colour; -------> | blue |
int occupants; | 3 occupants |
} +------------------+
| +------------------+
| new | 47 Elm Road |
+--------------------------> | white |
| 1 occupant |
+------------------+
One definition, compiled once. Many instances, each with its own state.
public class House {
String address; // every House will have these three fields
String colour;
int occupants;
void describe() {
System.out.println(address + " is " + colour
+ " with " + occupants + " occupants");
}
}
House first = new House(); // instantiation: build a house from the blueprint
first.address = "12 Oak Street";
first.colour = "blue";
first.occupants = 3;
House second = new House(); // a second, entirely independent house
second.address = "47 Elm Road";
second.colour = "white";
second.occupants = 1;
first.describe(); // 12 Oak Street is blue with 3 occupants
second.describe(); // 47 Elm Road is white with 1 occupants
Some vocabulary that gets used interchangeably and should not be:
| Term | Meaning |
|---|---|
| Class | The definition of a type |
| Object | A thing built from that definition, existing at runtime |
| Instance | Synonym for object, emphasising which class it came from |
| Instantiation | The act of creating an object with new |
| Member | Any field or method declared in a class |
| Reference | A variable holding the address of an object |
A class exists at compile time and is loaded once by the JVM; objects exist at
runtime and there may be millions of them. This explains why static members,
covered later in this chapter, belong to the class rather than to any instance:
there is exactly one class, so there is exactly one copy.
The blueprint analogy has one limit worth flagging. A blueprint is purely
passive, whereas a Java class is itself an object at runtime (of type Class),
can hold static state, and can execute code in static initialisers. That
nuance matters later; for now, "blueprint" is the right mental model.
Key Takeaways
- A class defines a type; an object is an instance of it created with
new.- One class definition supports any number of independent instances.
- Classes exist at compile time and are loaded once; objects live at runtime.
- Instance fields are per-object; the class itself is shared.
- "Object" and "instance" are synonyms; "instantiation" is the act of creation.
🧪 Practice
- Write a
Bookclass with title, author, and year, then create three distinct books and print each.- Explain in one sentence each what is created at compile time and what at runtime when you write
new Book().- Interview: If a class is a blueprint, what does a
staticfield correspond to in that analogy? (Hint: what belongs to the blueprint itself rather than to any building?)
The Four Pillars Overview
Object orientation is conventionally summarised by four principles. This section introduces all four so the vocabulary is available; each receives full treatment later, three of them in the next chapter.
Encapsulation — bundling data with the methods that operate on it, and restricting direct access to that data. It is the subject of section 2.5.
public class Thermostat {
private double target; // hidden
public void setTarget(double celsius) { // guarded entry point
if (celsius < 5 || celsius > 30) {
throw new IllegalArgumentException("Out of safe range");
}
target = celsius;
}
}
Abstraction — exposing what something does while hiding how it does it. A
caller of list.add(x) does not know or care whether an array is being resized
underneath. Encapsulation is the mechanism; abstraction is the resulting
simplification for the caller.
List<String> names = new ArrayList<>(); // the interface is the abstraction
names.add("Ada"); // the resizing algorithm is hidden
Inheritance — defining a new class in terms of an existing one, reusing its fields and methods. Covered in chapter 3.
class Animal {
void breathe() { System.out.println("breathing"); }
}
class Dog extends Animal { // Dog gains breathe() automatically
void bark() { System.out.println("woof"); }
}
Polymorphism — one reference type serving many concrete types, with the actual method chosen at runtime. Also chapter 3.
Animal pet = new Dog(); // a Dog referred to through an Animal variable
pet.breathe(); // works; the runtime picks Dog's version if overridden
ENCAPSULATION ABSTRACTION
+--------------+ "add an element" <- what the caller sees
| private data | |
| guarded by | v
| public methods| [ resize array, shift, insert ] <- hidden how
+--------------+
INHERITANCE POLYMORPHISM
Animal Animal ref ---> could be Dog, Cat, or Bird
/ \ |
Dog Cat call speak(); the object decides which runs
| Pillar | Question it answers | Primary Java mechanism |
|---|---|---|
| Encapsulation | Who is allowed to change this? | private fields, accessors |
| Abstraction | What do I need to know to use this? | Interfaces, abstract classes |
| Inheritance | What can I reuse from an existing type? | extends |
| Polymorphism | Which implementation runs? | Overriding, dynamic dispatch |
The pillars are frequently recited without being understood, so it is worth stating what each actually buys you. Encapsulation limits the blast radius of a bug. Abstraction limits how much a caller must learn. Inheritance limits duplication. Polymorphism limits the number of places that must change when a new case appears. All four are about limiting something, which is why they matter most in large systems and can feel like bureaucracy in small ones.
A common exam distinction: encapsulation and abstraction are related but not identical. Encapsulation is about access (keeping data private); abstraction is about complexity (keeping details irrelevant). A class can be encapsulated but a poor abstraction if its public methods expose implementation concepts.
Key Takeaways
- The four pillars are encapsulation, abstraction, inheritance, and polymorphism.
- Encapsulation restricts access; abstraction hides complexity; they are related but distinct.
- Inheritance reuses an existing type; polymorphism selects behaviour at runtime.
- Each pillar exists to limit something: blast radius, required knowledge, duplication, or change sites.
🧪 Practice
- For a
BankAccountclass, name one concrete way each of the four pillars could apply.- Give an example of a class that is well encapsulated but is nevertheless a leaky abstraction.
- Interview: What is the difference between abstraction and encapsulation? (Hint: one is about permission, the other about relevance.)
Identifying Classes from Requirements
Knowing the syntax does not tell you which classes to write. A practical starting technique, useful in interviews and in real design sessions, is noun and verb analysis: nouns in the requirements suggest candidate classes and fields, verbs suggest methods.
Take a requirement statement:
A library lends books to members. Each book has a title, an author, and an ISBN. A member can borrow up to five books at a time. A loan records which member borrowed which book and when it is due. Overdue loans incur a fine of 20 cents per day.
Extracting the nouns and classifying them:
| Noun | Candidate | Decision |
|---|---|---|
| Library | Class | Yes — it coordinates lending |
| Book | Class | Yes — has its own identity and attributes |
| Member | Class | Yes — has identity and a borrowing limit |
| Loan | Class | Yes — connects member and book, has its own data |
| Title | Field | No — an attribute of Book, a String |
| Author | Field? | Maybe — a class if authors need their own data |
| ISBN | Field | Attribute of Book, though a value type is arguable |
| Fine | Computed | Not a class — derived from a Loan and a date |
| Due date | Field | Attribute of Loan, a LocalDate |
Verbs map to behaviour, and the important question is which class owns each verb:
| Verb | Belongs to | Reasoning |
|---|---|---|
| lends / borrows | Library | It knows about both members and books |
| is overdue | Loan | It owns the due date |
| calculates fine | Loan | Derived from its own data |
| has capacity | Member | The five-book limit is a member rule |
public class Book {
private final String isbn; // identity: an ISBN identifies a book
private final String title;
private final String author;
private boolean available = true;
// constructor, accessors
}
public class Member {
private final String memberId;
private final String name;
private final List<Loan> activeLoans = new ArrayList<>();
public boolean canBorrow() { // the rule lives with the data
return activeLoans.size() < MAX_LOANS;
}
private static final int MAX_LOANS = 5;
}
public class Loan {
private final Book book;
private final Member member;
private final LocalDate dueDate;
public boolean isOverdue(LocalDate today) {
return today.isAfter(dueDate); // Loan owns its own due date
}
public long fineInCents(LocalDate today) {
if (!isOverdue(today)) return 0;
long daysLate = ChronoUnit.DAYS.between(dueDate, today);
return daysLate * FINE_PER_DAY_CENTS; // money in minor units, not double
}
private static final long FINE_PER_DAY_CENTS = 20;
}
public class Library {
private final Map<String, Book> catalogue = new HashMap<>();
public Loan lend(Member member, String isbn, LocalDate today) {
if (!member.canBorrow()) { // delegate the rule to Member
throw new IllegalStateException("Borrowing limit reached");
}
Book book = catalogue.get(isbn);
// ... availability checks, create and return the Loan
}
}
Four heuristics make this analysis more reliable than mechanical noun-hunting:
- A class should have one reason to change. If a class would change both when tax rules change and when the database changes, it is probably two classes.
- Behaviour belongs with the data it uses. A method that reads only
Loan's fields belongs onLoan, not on aLoanService. Pulling data out of an object to compute something elsewhere is the "anemic domain model" smell. - Not every noun is a class. Attributes, computed values, and transient concepts are not. Creating a class per noun produces bloat.
- Watch for missing nouns.
Loandoes not appear as a highlighted noun in some phrasings, yet it is essential. Relationships with their own data almost always deserve a class.
Key Takeaways
- Nouns suggest classes and fields; verbs suggest methods, and the key question is which class owns each verb.
- Put behaviour on the class that owns the data it needs.
- Not every noun becomes a class; attributes and derived values do not.
- Relationships that carry their own data, such as a loan, usually deserve their own class.
- A class with two unrelated reasons to change should be split.
🧪 Practice
- Perform noun and verb analysis on: "A cinema shows films in screens. Customers book seats for a showing. A booking can be cancelled up to one hour before the start."
- Take the library example and decide whether
Authorshould be a class, justifying the answer from the requirements.- Interview: You are given a
Loanclass and aLoanServicethat computes fines by reading the loan's getters. What is the design problem, and how would you fix it? (Hint: which object owns the due date?)
<a id="22-defining-classes"></a>
2.2 Defining Classes
With the motivation established, this section covers the mechanics: how to declare a class, what its members look like, and what actually happens in memory when you create an instance.
Class Declaration Syntax
A class declaration names a type and lists its members. The full form has more parts than a beginner needs, so it helps to see the skeleton first and then the options.
public class Rectangle { // modifier, keyword, name
// fields, constructors, methods, nested types go here
}
The general shape, with optional parts in brackets:
[modifiers] class ClassName [extends Superclass] [implements Interface, ...] {
[fields]
[constructors]
[methods]
[nested types]
[initializer blocks]
}
A complete example using most of the available slots:
package com.example.geometry; // must be first, at most one
import java.util.Objects; // imports come next
public class Rectangle { // 'public' -> visible everywhere
private static int instanceCount; // static field: one per class
private final double width; // instance field, immutable
private final double height;
public Rectangle(double width, double height) { // constructor
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Dimensions must be positive");
}
this.width = width;
this.height = height;
instanceCount++;
}
public double area() { // instance method
return width * height;
}
public static int getInstanceCount() { // static method
return instanceCount;
}
@Override
public String toString() { // overriding an inherited method
return "Rectangle(" + width + " x " + height + ")";
}
private static class Corner { } // nested type
}
Class modifiers that may appear on a top-level class:
| Modifier | Effect |
|---|---|
public | Visible from any package; requires a matching file name |
| (none) | Package-private: visible only within its own package |
final | Cannot be extended |
abstract | Cannot be instantiated; may contain abstract methods |
sealed | Only the listed classes may extend it (Java 17+) |
strictfp | Legacy floating-point rule; obsolete since Java 17 |
private and protected are not permitted on a top-level class, because there
would be no scope in which they meant anything. They are legal on nested
classes.
File rules, which trip up beginners more than the modifiers do:
// File: Rectangle.java
public class Rectangle { } // OK: public class matches the file name
class Helper { } // OK: package-private classes may share the file
// public class Other { } // ERROR: a second public class in one file
A source file may contain at most one public top-level class, and its name
must match the file name exactly including case. Other top-level classes in the
same file must be package-private. In practice, one class per file is the
convention regardless.
Member ordering is not enforced by the compiler but is strongly conventional, and reviewers will expect it:
1. static fields (constants first)
2. instance fields
3. constructors
4. static factory methods
5. instance methods (public before private, or grouped by purpose)
6. nested types
Unlike C or C++, Java has no separate declaration and definition: there are no header files, and a member may be used before it is declared in the file, because the compiler reads the whole class before resolving references.
public class Order {
public double total() {
return computeSubtotal() + tax; // both declared BELOW; this is fine
}
private double tax = 5.0;
private double computeSubtotal() { return 100.0; }
}
Key Takeaways
- A class declaration is
[modifiers] class Name [extends] [implements] { members }.- At most one
publictop-level class per file, named exactly like the file.privateandprotectedare illegal on top-level classes but legal on nested ones.- Member order is conventional, not enforced; follow the standard ordering for readability.
- Java has no headers and no forward declarations; members may be used before they appear.
🧪 Practice
- Create a
Temperatureclass in packagecom.example.weatherwith a private field, a constructor, and one method, and compile it from the command line.- Put two classes in one file, one public and one package-private, then add a second public class and read the error.
- Reorder a class's members to follow the conventional ordering and note whether readability improved.
Instance Fields and Default Values
Instance fields hold the state that makes each object distinct. Every object created from a class receives its own complete set of them, allocated on the heap when the object is constructed.
public class Employee {
private String name; // reference field -> defaults to null
private int age; // numeric field -> defaults to 0
private double salary; // -> defaults to 0.0
private boolean active; // -> defaults to false
private char grade; // -> defaults to '\u0000'
private int[] scores; // array reference -> defaults to null
}
Unlike local variables, fields are automatically initialised to a defined default before any constructor runs. The JVM guarantees this by zeroing the memory it allocates for the object, which means a field is never garbage.
| Field type | Default value |
|---|---|
byte, short, int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
char | '\u0000' |
boolean | false |
| Any reference type | null |
Employee e = new Employee();
System.out.println(e.getAge()); // 0 -- not an error, not garbage
System.out.println(e.getName()); // null -- the field exists, points nowhere
// System.out.println(e.getName().length()); // NullPointerException
The null default is where most beginner bugs originate. A reference field is
initialised to "points at nothing", so any method call on it fails until it is
assigned. Three common remedies:
public class Employee {
private String name = "Unknown"; // 1. field initialiser
private List<String> skills = new ArrayList<>(); // 2. never-null collection
public Employee(String name) { // 3. require it at construction
this.name = Objects.requireNonNull(name, "name must not be null");
}
}
Initialising collection fields at declaration is a particularly valuable habit:
a method that returns an empty list is far easier for callers than one that
returns null.
Field initialisers run in declaration order, before the constructor body, and may call methods or reference earlier fields:
public class Config {
private int base = 10;
private int doubled = base * 2; // 20 -- 'base' is already assigned
// private int broken = later + 1; // ERROR: illegal forward reference
private int later = 5;
}
Fields versus local variables, a comparison worth memorising:
| Aspect | Instance field | Local variable |
|---|---|---|
| Declared in | Class body | Method, constructor, or block |
| Default value | Yes, zero-equivalent | None; must be assigned |
| Lifetime | As long as the object lives | Until the block exits |
| Memory location | Heap, inside the object | Stack frame |
| Access modifiers | Allowed | Not allowed |
| Scope | The whole class | The enclosing block |
A final note on design: the number of instance fields is a rough measure of a class's complexity. A class with fifteen fields is usually several classes that have not been separated yet, because most methods will touch only a subset of them.
Key Takeaways
- Every object gets its own copy of each instance field.
- Fields are zero-initialised automatically; locals are not.
- Reference fields default to
null, which is the origin of most beginnerNullPointerExceptions.- Initialise collection fields at declaration so they are never
null.- Field initialisers run in declaration order and cannot reference fields declared later.
🧪 Practice
- Write a class with one field of each primitive type plus a
String, and print all of them immediately after construction.- Cause a
NullPointerExceptionby calling a method on an uninitialised reference field, then fix it three different ways.- Interview: Why do fields have defaults while local variables do not? (Hint: think about what the JVM does with memory when allocating an object, versus what a stack frame contains.)
Instance Methods and Return Types
An instance method is behaviour that operates on a particular object's state. It is declared inside the class body and called on a reference.
[modifiers] returnType methodName([parameters]) [throws ExceptionType] {
// body
}
public class Circle {
private double radius;
public double area() { // no parameters, returns double
return Math.PI * radius * radius;
}
public void scale(double factor) { // parameter, returns nothing
if (factor <= 0) {
throw new IllegalArgumentException("Factor must be positive");
}
radius *= factor; // mutates this object's state
}
public boolean isLargerThan(Circle other) { // takes another object
return this.area() > other.area(); // may read other's private fields
}
public Circle enlarged(double factor) { // returns a NEW object
Circle copy = new Circle();
copy.radius = this.radius * factor; // same class: private access allowed
return copy;
}
}
That last pair illustrates an important design choice. scale mutates the
receiver and returns nothing; enlarged leaves the receiver untouched and
returns a new object. The second style composes better and is safer in
concurrent code, which is why the standard library moved toward it (String,
LocalDate, and the stream API are all built this way).
Return types. Every method declares exactly one return type, or void.
public void log(String message) { // void: no value produced
System.out.println(message);
return; // optional: exits early
}
public int size() {
return items.length; // MUST return an int on every path
}
public String describe(boolean verbose) {
if (verbose) {
return "a long description";
}
return "short"; // compiler requires a return on all paths
// omitting this line gives: "missing return statement"
}
The compiler proves that a non-void method returns on every reachable path. Throwing counts as an exit, so this compiles:
public int parse(String input) {
if (input == null) {
throw new IllegalArgumentException("null input"); // a valid exit
}
return Integer.parseInt(input);
}
Returning objects returns references, not copies, which has consequences explored in the defensive-copying topic later:
public List<String> getSkills() {
return skills; // the CALLER can now modify this object's internal list
}
Method chaining is enabled by returning this, a style used widely in
builders:
public class Query {
private final StringBuilder sql = new StringBuilder();
public Query select(String cols) { sql.append("SELECT ").append(cols); return this; }
public Query from(String table) { sql.append(" FROM ").append(table); return this; }
public Query where(String cond) { sql.append(" WHERE ").append(cond); return this; }
public String build() { return sql.toString(); }
}
String sql = new Query().select("id, name").from("users").where("active").build();
| Return style | Example | Characteristic |
|---|---|---|
void mutator | list.add(x) | Changes state, returns nothing |
| Value query | list.size() | Reads state, changes nothing |
| New-object producer | str.toUpperCase() | Leaves receiver unchanged |
| Fluent builder | query.select(...) | Returns this for chaining |
A widely used guideline, "command-query separation", says a method should
either change state or return a value, not both. It is a guideline rather than
a law — Map.put deliberately violates it by returning the previous value —
but methods that quietly mutate while appearing to be queries are a real source
of confusion.
Key Takeaways
- Instance methods operate on one object's state and are called on a reference.
- A non-void method must return a value on every reachable path; throwing counts as an exit.
- Returning an object returns a reference, so callers can mutate what they receive.
- Mutating methods and new-object-producing methods are different designs; prefer the latter for value-like types.
- Returning
thisenables fluent chaining.
🧪 Practice
- Write a
Moneyclass with anaddmethod that returns a newMoneyrather than mutating, and explain one benefit.- Write a method with an
if/elsewhere both branches return, then delete one return and read the compiler error.- Build a small fluent
PizzaBuilderwith three chained methods and a terminalbuild().
Creating Objects with new
The new operator is the standard way to bring an object into existence. A
single expression triggers four distinct steps, and knowing them explains
several behaviours that otherwise look arbitrary.
Employee e = new Employee("Ada", 36);
Employee e = new Employee("Ada", 36);
|________| |__| |_________________|
| | |
| | +-- constructor invocation with arguments
| +--------------- allocate memory on the heap, zero it
+-------------------------- a reference variable on the stack
The four steps, in order:
- Class loading. If
Employeehas not been used before, the JVM loads, verifies, and initialises the class, running its static initialisers. This happens once, on first use. - Allocation. Memory for all instance fields is reserved on the heap and zeroed, giving every field its default value.
- Initialisation. Field initialisers and instance initialiser blocks run in declaration order, then the constructor body executes.
- Reference assignment. The address of the finished object is stored in the variable.
public class Demo {
static { System.out.println("1. class initialised (once)"); }
private int x = printAndReturn("2. field initialiser");
{ System.out.println("3. instance initialiser block"); }
public Demo() { System.out.println("4. constructor body"); }
private int printAndReturn(String s) { System.out.println(s); return 0; }
public static void main(String[] args) {
System.out.println("--- first object ---");
new Demo();
System.out.println("--- second object ---");
new Demo(); // note: step 1 does NOT repeat
}
}
--- first object ---
1. class initialised (once)
2. field initialiser
3. instance initialiser block
4. constructor body
--- second object ---
2. field initialiser
3. instance initialiser block
4. constructor body
Every new creates a distinct object, even with identical arguments:
Employee a = new Employee("Ada", 36);
Employee b = new Employee("Ada", 36);
System.out.println(a == b); // false -- two separate heap objects
System.out.println(a.equals(b)); // false unless equals() is overridden
Objects can be created without a named variable, which is common when the object is used immediately or passed directly:
System.out.println(new Employee("Ada", 36).getName()); // used and discarded
list.add(new Employee("Grace", 45)); // passed straight in
process(new int[]{1, 2, 3}); // array creation
Alternatives to new exist and are often preferable:
| Mechanism | Example | Why |
|---|---|---|
| Static factory method | List.of("a"), Integer.valueOf(1) | Can cache, can return a subtype |
| Builder | new Builder().a().b().build() | Readable with many parameters |
| Cloning | array.clone() | Copies an existing object |
| Deserialization | Reading an object stream | Reconstructs without a constructor |
| Reflection | clazz.getDeclaredConstructor().newInstance() | Frameworks, when the type is dynamic |
Static factory methods deserve a mention here because they are so common in the
modern standard library. Integer.valueOf(5) may return a cached object rather
than a new one, and List.of(...) returns an immutable implementation whose
class you never name. Both are impossible with a bare constructor.
Finally, note that Java has no delete. An object becomes eligible for garbage
collection when it is no longer reachable from any live reference, which is
covered in chapter 10.
Employee e = new Employee("Ada", 36);
e = null; // the object is now unreachable and may be collected later
Key Takeaways
newloads the class if needed, allocates and zeroes memory, runs initialisers and the constructor, then yields a reference.- Static initialisation happens once per class; instance initialisation happens per object.
- Every
newproduces a distinct object, so==on two equal-looking objects isfalse.- Static factories and builders are frequently better than a direct constructor.
- There is no
delete; unreachable objects are collected automatically.
🧪 Practice
- Write a class with a static block, a field initialiser, an instance block, and a constructor, then create two instances and predict the output before running it.
- Create two objects with identical constructor arguments and show that
==is false.- Interview: Name two things a static factory method can do that a constructor cannot. (Hint: think about the return value and about caching.)
Object References versus Values
Java variables hold one of two things: a primitive value, or a reference to an object. Almost every confusing assignment or mutation bug traces back to not distinguishing the two.
int a = 10;
int b = a; // COPIES the value 10
b = 20;
System.out.println(a); // 10 -- unaffected
Point p = new Point(1, 2);
Point q = p; // COPIES the reference, not the object
q.x = 99;
System.out.println(p.x); // 99 -- both variables point at the SAME object
PRIMITIVES: independent values REFERENCES: shared object
a: +----+ b: +----+ p: +-----+ +--------------+
| 10 | | 20 | | •--+----->| Point |
+----+ +----+ q: +-----+ / | x = 99 |
| •--+-+ | y = 2 |
two separate storage locations +-----+ +--------------+
two references, ONE object
This distinction determines what == means, as seen in chapter 1: for
primitives it compares values, for references it compares which object is being
pointed at.
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
Point p3 = p1;
System.out.println(p1 == p2); // false -- different objects
System.out.println(p1 == p3); // true -- same object
Aliasing is the term for two references pointing at one object, and it is neither good nor bad in itself — it is how objects are shared efficiently. It becomes a hazard when one holder mutates an object another holder assumed was stable:
List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> alias = original; // NOT a copy
alias.add("c");
System.out.println(original); // [a, b, c] -- surprising if you expected a copy
List<String> copy = new ArrayList<>(original); // an actual copy
copy.add("d");
System.out.println(original); // [a, b, c] -- unaffected
Copying comes in two depths, a distinction that matters constantly:
class Person {
String name;
List<String> hobbies;
}
// SHALLOW copy: new Person object, but the SAME hobbies list
Person shallow = new Person();
shallow.name = original.name;
shallow.hobbies = original.hobbies; // shared!
// DEEP copy: new Person AND a new hobbies list
Person deep = new Person();
deep.name = original.name; // String is immutable, safe to share
deep.hobbies = new ArrayList<>(original.hobbies);
SHALLOW COPY DEEP COPY
original --> [Person] original --> [Person]
hobbies --+ hobbies --> [List A]
|
shallow --> [Person] | deep --> [Person]
hobbies --+ hobbies --> [List B]
one shared list two independent lists
Immutable objects sidestep the whole problem. Because a String or a
LocalDate cannot change, sharing a reference to one is always safe, and a
"copy" is unnecessary. This is a major reason immutability is favoured in
modern Java.
| Concept | Primitive | Reference |
|---|---|---|
| Variable holds | The value | An address |
| Assignment copies | The value | The address, not the object |
== compares | Values | Identity |
Can be null | No | Yes |
| Stored in | Stack (as a local) | Object on the heap |
Key Takeaways
- Primitive variables hold values; object variables hold references.
- Assigning a reference copies the address, so both variables see the same object.
- Two references to one object are aliases; mutation through either is visible to both.
- A shallow copy shares nested objects; a deep copy duplicates them.
- Immutable objects make aliasing harmless, which is why they are preferred.
🧪 Practice
- Write a method that takes a
StringBuilder, appends to it, and demonstrate that the caller sees the change.- Create a class with a
Listfield, make a shallow copy, mutate the list, and show both objects change. Then fix it with a deep copy.- Interview: After
List<String> b = a;andb.add("x"), what doesacontain, and why? (Hint: how many list objects exist?)
The null Reference and NullPointerException
null is a special reference meaning "points at no object". It is the default
for every uninitialised reference field, the conventional "absent" return value
in older APIs, and the cause of by far the most common runtime exception in
Java.
Its inventor, Tony Hoare, called it his "billion-dollar mistake", because the
type system permits null wherever an object is expected, so the compiler
cannot warn you.
String text = null;
System.out.println(text); // "null" -- printing is safe
System.out.println(text + "!"); // "null!" -- concatenation is safe
System.out.println(text == null); // true -- comparison is safe
System.out.println(text.length()); // NullPointerException -- any CALL fails
The rule is simple: you may assign, compare, and print a null reference, but
you may not dereference it — that is, access any field or call any method on
it.
Where nulls come from:
private String name; // 1. uninitialised field
map.get("missing"); // 2. lookup miss returns null
System.getenv("NOT_SET"); // 3. absent environment or config
list.get(0); // 4. element that was itself null
someMethod(); // 5. a method that returns null on failure
Reading the exception. Since Java 14, helpful messages pinpoint exactly which part of an expression was null, which removes most of the guesswork:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.length()" because the return value of
"java.util.Map.get(Object)" is null
at com.example.Demo.main(Demo.java:12)
Before this, a chained expression such as a.getB().getC().getD() produced a
line number and nothing else.
Defences, roughly in order of preference:
// 1. Do not produce nulls. Return an empty collection instead.
public List<String> getTags() {
return tags == null ? List.of() : tags;
}
// 2. Reject nulls at the boundary, failing fast with a clear message.
public void setName(String name) {
this.name = Objects.requireNonNull(name, "name must not be null");
}
// 3. Use Optional for a genuinely absent RESULT (not for fields or parameters).
public Optional<Customer> findById(String id) {
return Optional.ofNullable(database.get(id));
}
customer.map(Customer::getName).orElse("unknown");
// 4. Null-safe library helpers.
Objects.equals(a, b); // handles either side being null
Objects.toString(value, "default");
Objects.requireNonNullElse(value, "fallback");
// 5. Guard with short-circuit evaluation where a null is legitimately possible.
if (name != null && name.startsWith("A")) { ... }
| Approach | Use when |
|---|---|
| Empty collection / default | The absence has an obvious neutral value |
Objects.requireNonNull | Null indicates a programming error |
Optional return type | Absence is an expected, normal outcome |
Null check with && | Null is legitimate and locally handled |
@Nullable annotations | Documenting intent for static analysers |
Two closing cautions. First, autoboxing hides dereferences: int x = map.get(k)
throws if the key is missing, because unboxing calls intValue() on null.
Second, Optional is designed as a return type; using it for fields or
parameters is widely considered misuse, since it adds an allocation and a layer
of indirection without removing the underlying question.
Key Takeaways
nullmeans "no object"; assigning, comparing, and printing it are safe, dereferencing it is not.- Helpful
NullPointerExceptionmessages since Java 14 name the exact expression that was null.- Prefer designs that never produce null: empty collections, defaults, and
Optionalreturns.Objects.requireNonNullfails fast at the boundary with a clear message.- Autoboxing an absent map value throws, because unboxing dereferences.
🧪 Practice
- Write code producing a
NullPointerExceptionfrom a map lookup, then fix it withgetOrDefault.- Rewrite a method that returns
nullfor "not found" so that it returns anOptional, and update a caller.- Interview: Why is returning an empty list usually better than returning
null? (Hint: consider what every caller has to write otherwise.)
<a id="23-constructors-and-initialization"></a>
2.3 Constructors and Initialization
A constructor's job is to leave a new object in a valid state. This section covers the forms constructors take, how they delegate to each other, and the exact order in which everything runs.
Default and Parameterized Constructors
A constructor is a special member invoked by new. It has the same name as the
class, declares no return type at all (not even void), and runs after field
initialisers to complete the object's setup.
public class Book {
private String title;
private String author;
private int year;
public Book(String title, String author, int year) { // parameterized
this.title = title; // 'this.' distinguishes field from parameter
this.author = author;
this.year = year;
}
}
Book b = new Book("Dune", "Herbert", 1965);
The default constructor. If a class declares no constructor at all, the compiler inserts a no-argument constructor that does nothing beyond calling the superclass constructor. It has the same access level as the class.
public class Point {
int x, y;
// compiler generates: public Point() { super(); }
}
Point p = new Point(); // works: uses the generated default
The moment you declare any constructor, the default is no longer generated:
public class Point {
int x, y;
public Point(int x, int y) { this.x = x; this.y = y; }
}
// Point p = new Point(); // ERROR: no no-arg constructor exists
Point p = new Point(3, 4); // the only available form
This is a frequent source of confusion when adding a parameterized constructor
to an existing class breaks code elsewhere that called new Point(). If both
forms are needed, declare both.
Constructors are not methods. The differences are worth listing because the similarity is superficial:
| Aspect | Constructor | Method |
|---|---|---|
| Name | Must match the class exactly | Any valid identifier |
| Return type | None declared, not even void | Required |
| Invocation | Only via new, this(), super() | Called on a reference or class |
| Inheritance | Not inherited | Inherited |
| Overriding | Cannot be overridden | Can be |
static | Never | Allowed |
| Implicit version | Generated if none declared | Never generated |
A classic trap exploits exactly that first row:
public class Trap {
private int value;
public void Trap(int value) { // NOT a constructor -- it has a return type
this.value = value; // it is a method that happens to be named Trap
}
}
Trap t = new Trap(); // compiles! uses the generated default constructor
// value stays 0; the "constructor" was never called
Adding a return type silently turns a constructor into an ordinary method. The compiler cannot warn you because the result is perfectly legal code.
Access modifiers on constructors control who may instantiate the class:
public class Service {
public Service() { } // anyone may construct
}
public class Helper {
private Helper() { } // nobody may construct: utility class idiom
public static void help() { }
}
public class Session {
Session() { } // package-private: only this package
public static Session create() { return new Session(); } // controlled creation
}
A private constructor is the standard way to prevent instantiation of a
utility class, and the standard way to force creation through a static factory
method when you want to control caching or return a subtype.
Key Takeaways
- A constructor shares the class name and declares no return type.
- The compiler generates a no-argument constructor only when none is declared.
- Declaring any constructor removes the generated default, which can break existing callers.
- Writing a return type turns a would-be constructor into a silent ordinary method.
- A
privateconstructor blocks instantiation and enables factory-only creation.
🧪 Practice
- Write a class with only a parameterized constructor, try to call
new ClassName(), and read the error.- Add a return type to a constructor and demonstrate that the object's fields remain at their defaults.
- Write a utility class with a private constructor and one static method, and explain why the private constructor is there.
Constructor Overloading
Overloading a constructor means providing several versions with different parameter lists, so callers can supply only what they have. The compiler selects one at compile time based on the argument types and count.
public class Pizza {
private String size;
private String crust;
private List<String> toppings;
public Pizza(String size, String crust, List<String> toppings) {
this.size = size;
this.crust = crust;
this.toppings = new ArrayList<>(toppings);
}
public Pizza(String size, String crust) { // no toppings
this(size, crust, List.of()); // delegate, see next topic
}
public Pizza(String size) { // size only
this(size, "classic");
}
public Pizza() { // all defaults
this("medium");
}
}
new Pizza(); // medium, classic, no toppings
new Pizza("large"); // large, classic, no toppings
new Pizza("large", "thin"); // large, thin, no toppings
new Pizza("large", "thin", List.of("olives")); // fully specified
Overloads must differ in their parameter list — the number, types, or order of parameters. They cannot differ only by parameter names, because names are not part of the signature:
public Rectangle(double width, double height) { }
// public Rectangle(double height, double width) { } // ERROR: same signature
This restriction causes a genuine design problem when a class has several constructors taking the same types with different meanings:
public class Temperature {
// public Temperature(double celsius) { }
// public Temperature(double fahrenheit) { } // ERROR: identical signature
private Temperature(double celsius) { this.celsius = celsius; }
public static Temperature ofCelsius(double c) { return new Temperature(c); }
public static Temperature ofFahrenheit(double f) {
return new Temperature((f - 32) * 5 / 9);
}
}
Static factory methods solve it cleanly because they can have distinct, descriptive names. This is one of their main advantages over constructors.
Ambiguity arises when more than one overload matches equally well, and the compiler refuses to guess:
public class Ambiguous {
public Ambiguous(int a, long b) { }
public Ambiguous(long a, int b) { }
}
// new Ambiguous(1, 1); // ERROR: reference to Ambiguous is ambiguous
// 1,1 could widen either way
Too many overloads is a design smell. A constructor with five parameters of the same type is unreadable at the call site, and the overload chain multiplies the problem:
// What do these arguments mean? The reader must consult the declaration.
new Account("Ada", "Lovelace", true, false, true, 3, 0);
// The builder pattern makes each value self-describing:
Account a = new Account.Builder("Ada", "Lovelace")
.active(true)
.overdraftAllowed(false)
.maxCards(3)
.build();
The rule of thumb used by many teams: up to three or four parameters, use overloaded constructors; beyond that, use a builder.
Key Takeaways
- Overloaded constructors differ in the number, type, or order of parameters.
- Parameter names are not part of the signature, so two constructors cannot differ by name alone.
- Static factory methods work around signature clashes and document intent.
- Equally-matching overloads produce a compile-time ambiguity error.
- Beyond three or four parameters, prefer a builder to a long constructor.
🧪 Practice
- Write a
Rectangleclass with constructors for width and height, for a square, and for a unit square.- Try to declare two constructors that differ only in parameter names and read the error.
- Interview: A class needs to be constructible from either a radius or a diameter, both
double. How would you design it? (Hint: constructors are not your only option.)
Constructor Chaining with this()
Duplicating initialisation logic across overloaded constructors is a
maintenance hazard: a rule added to one and forgotten in another creates objects
that are valid only some of the time. this(...) lets one constructor delegate
to another, so the real work lives in exactly one place.
public class Employee {
private final String name;
private final String department;
private final double salary;
// The "primary" constructor: all validation and assignment happens here
public Employee(String name, String department, double salary) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name is required");
}
if (salary < 0) {
throw new IllegalArgumentException("Salary cannot be negative");
}
this.name = name;
this.department = department;
this.salary = salary;
}
public Employee(String name, String department) {
this(name, department, 30_000); // delegate; validation is inherited
}
public Employee(String name) {
this(name, "Unassigned"); // chains to the two-arg version,
} // which chains to the primary
}
new Employee("Ada")
|
+--> Employee(String)
| this("Ada", "Unassigned")
v
Employee(String, String)
| this("Ada", "Unassigned", 30000)
v
Employee(String, String, double) <- all the real work, once
The rules for this(...):
- It must be the first statement in the constructor. Nothing may precede it, not even a field assignment or a log line.
- Only one of
this(...)orsuper(...)may appear, never both, since each must be first. - The chain must terminate; recursive delegation is a compile error.
public class Example {
private int value;
public Example() {
// System.out.println("before"); // ERROR: must be first statement
this(0);
System.out.println("after"); // fine: statements may FOLLOW
}
public Example(int value) { this.value = value; }
}
public class Cycle {
public Cycle() { this(1); } // ERROR: recursive constructor
public Cycle(int x) { this(); } // invocation
}
Why delegation beats duplication is easiest to see by adding a requirement. Suppose employees now need a non-null department. With chaining, the check goes in the primary constructor and every other form gets it automatically. Without chaining, you must find and update every constructor, and any one you miss becomes a latent bug.
// WITHOUT chaining -- validation duplicated and already drifting
public Employee(String name, String department, double salary) {
if (name == null) throw new IllegalArgumentException();
...
}
public Employee(String name, String department) {
if (name == null) throw new IllegalArgumentException(); // copy 2
this.name = name;
this.department = department;
this.salary = 30_000; // forgot the salary check that exists above
}
A useful convention is to designate one constructor as primary — usually the one with the most parameters — and have every other constructor eventually reach it. Some teams mark the others with a comment saying so.
Note the interaction with final fields: a final field must be assigned
exactly once. Delegation satisfies this correctly, because only the terminal
constructor performs the assignment.
Key Takeaways
this(...)delegates to another constructor of the same class.- It must be the first statement, and cannot coexist with
super(...).- Recursive delegation chains are a compile error.
- Designate one primary constructor holding all validation, and chain the rest to it.
- Delegation keeps
finalfield assignment to exactly one place.
🧪 Practice
- Refactor a class with three constructors that duplicate validation so that all delegate to one primary constructor.
- Try placing a statement before
this(...)and read the compiler message.- Write two constructors that delegate to each other and observe the recursive-invocation error.
Instance Initializer Blocks
An instance initializer is a bare block in the class body, outside any method or
constructor. Its contents are copied by the compiler into the start of every
constructor, after any super() call and after field initialisers in source
order.
public class Widget {
private List<String> parts;
{ // instance initializer block
parts = new ArrayList<>(); // runs before EVERY constructor body
parts.add("frame");
}
public Widget() { parts.add("default"); }
public Widget(String extra) { parts.add(extra); }
}
new Widget().getParts(); // [frame, default]
new Widget("wheel").getParts(); // [frame, wheel]
Their purpose is to hold initialisation common to all constructors that cannot be expressed as a single field initialiser — typically because it needs several statements or a try-catch.
public class Config {
private final Properties properties;
{ // multi-statement setup, shared
properties = new Properties();
properties.setProperty("mode", "default");
properties.setProperty("retries", "3");
}
public Config() { }
public Config(String mode) { properties.setProperty("mode", mode); }
}
Rules and ordering. Multiple blocks are permitted and run in source order, interleaved with field initialisers:
public class Order {
private int a = print("1. field a");
{ print("2. first block"); }
private int b = print("3. field b");
{ print("4. second block"); }
public Order() { print("5. constructor body"); }
private static int print(String s) { System.out.println(s); return 0; }
}
// Output: 1, 2, 3, 4, 5 -- strictly source order, constructor body last
When to use them, and when not to. In practice, instance initializer blocks are rare in application code, because constructor chaining usually expresses the same thing more clearly and more visibly.
| Situation | Better choice |
|---|---|
| One-expression field setup | Field initialiser |
| Logic shared by several constructors | Constructor chaining |
| Multi-statement setup shared by all constructors, no parameters needed | Instance initializer |
| Initialisation depending on parameters | Constructor body |
Their main legitimate appearance in modern code is inside anonymous classes, which cannot declare constructors at all — they have no name to give one:
Map<String, Integer> ages = new HashMap<>() {{ // double-brace initialisation
put("Ada", 36); // inner braces are an instance initializer
put("Alan", 41); // of an anonymous subclass of HashMap
}};
This "double-brace" idiom is clever and generally discouraged: it creates an
extra anonymous subclass for every use, holds a hidden reference to the
enclosing instance (a memory-leak risk), and breaks equals against ordinary
maps. Map.of(...) or an explicit builder is better.
Key Takeaways
- An instance initializer is a bare block copied into the start of every constructor.
- Field initialisers and instance blocks run in source order, before the constructor body.
- They suit multi-statement setup common to all constructors, but constructor chaining is usually clearer.
- Anonymous classes cannot declare constructors, so initializer blocks are their only option.
- Avoid the double-brace idiom: it creates a subclass and can leak the enclosing instance.
🧪 Practice
- Write a class with two field initialisers and two instance blocks interleaved, and predict the output order before running it.
- Convert a class that uses an instance initializer into one that uses constructor chaining, and judge which is clearer.
- Interview: Why can an anonymous class not have a constructor, and what is used instead? (Hint: what would you name it?)
Initialization Order of a Class
Putting the previous topics together yields a single sequence that explains every "why is this field still zero?" question. The order is fixed by the Java Language Specification and never varies.
Static initialisation, once per class, on first active use:
- Static fields are given default values (zero, false, null).
- Static field initialisers and static blocks run in source order.
Instance initialisation, on every new:
- Instance fields are given default values.
- The superclass constructor runs to completion (implicitly or via
super()). - Instance field initialisers and instance blocks run in source order.
- The constructor body runs.
class Parent {
static { System.out.println("1. Parent static block"); }
{ System.out.println("4. Parent instance block"); }
Parent() { System.out.println("5. Parent constructor"); }
}
class Child extends Parent {
static { System.out.println("2. Child static block"); }
private int field = print("6. Child field initialiser");
{ System.out.println("7. Child instance block"); }
Child() {
super(); // implicit if omitted
System.out.println("8. Child constructor");
}
private static int print(String s) { System.out.println(s); return 0; }
public static void main(String[] args) {
System.out.println("3. --- creating first Child ---");
new Child();
System.out.println("--- creating second Child ---");
new Child();
}
}
1. Parent static block <- static, once, superclass first
2. Child static block <- static, once
3. --- creating first Child ---
4. Parent instance block <- superclass instance init runs first
5. Parent constructor
6. Child field initialiser
7. Child instance block
8. Child constructor
--- creating second Child ---
4. Parent instance block <- steps 4-8 repeat; 1-2 do not
5. Parent constructor
6. Child field initialiser
7. Child instance block
8. Child constructor
The consequence that bites hardest is step 4 preceding step 5: the superclass constructor completes before the subclass's fields are initialised. If a superclass constructor calls an overridable method, the subclass version runs against uninitialised fields:
class Base {
Base() {
init(); // DANGER: calls the overridden version
}
void init() { }
}
class Derived extends Base {
private String value = "assigned";
@Override
void init() {
System.out.println(value); // prints null! The field initialiser
} // has not run yet (step 5 comes later)
}
new Derived(); // prints: null
The rule that follows: never call an overridable method from a constructor.
Make such methods private, static, or final.
TIME ---->
[class loading] [new Child()] [new Child()]
static init instance init instance init
(once) Parent ctor, Child ctor Parent ctor, Child ctor
A second consequence concerns final fields: because step 5 runs after step 4,
a final field is definitely assigned only once the subclass constructor has
finished, which is why the object should not be published to other threads
before that point.
Key Takeaways
- Static initialisation happens once per class, superclass first, on first use.
- Instance initialisation runs the superclass constructor before the subclass's field initialisers.
- Field initialisers and instance blocks run in source order, then the constructor body.
- Calling an overridable method from a constructor exposes uninitialised subclass fields.
- Make constructor-called methods
private,static, orfinal.
🧪 Practice
- Reproduce the numbered example and verify the output order matches your prediction.
- Write a superclass constructor that calls an overridden method and observe the
nullfield, then fix it by making the methodfinal.- Interview: A field with an initialiser prints as
nullinside a method called from the superclass constructor. Explain why. (Hint: order the six steps.)
Defensive Copying in Constructors
A constructor that stores a caller-supplied mutable object keeps a reference to something the caller still holds. The caller can then change the object's state from outside, bypassing every validation the class performs. Defensive copying closes that hole.
public class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if (start.after(end)) {
throw new IllegalArgumentException("start must precede end");
}
this.start = start; // BROKEN: stores the caller's object
this.end = end;
}
public Date getStart() { return start; } // BROKEN: hands it back out
}
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end); // validated: start precedes end
end.setYear(70); // the caller mutates it afterwards
// p now represents a period that ends before it starts, despite the check
WITHOUT defensive copy WITH defensive copy
caller's date --+ caller's date --> [Date A]
|
v Period.start --> [Date B] (a copy)
Period.start ---> [Date]
mutating A cannot affect B
mutating it changes the Period
The fix has two halves, and both are required:
public final class Period { // final: no subclass can subvert it
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime()); // 1. COPY FIRST
this.end = new Date(end.getTime());
if (this.start.after(this.end)) { // 2. VALIDATE THE COPIES
throw new IllegalArgumentException("start must precede end");
}
}
public Date getStart() { return new Date(start.getTime()); } // 3. copy on the way out
public Date getEnd() { return new Date(end.getTime()); }
}
Note the ordering in step 1 and 2. Copying before validating is essential: validating the original and then copying leaves a window in which another thread could change the object between the check and the copy. This is a time-of-check-to-time-of-use race, and it is a real security issue, not a theoretical one.
Collections need the same treatment, and this is by far the more common case in practice:
public class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = new ArrayList<>(members); // copy the caller's list
// or: List.copyOf(members) for an immutable copy (Java 10+)
}
public List<String> getMembers() {
return Collections.unmodifiableList(members); // read-only view out
// or: List.copyOf(members) for a snapshot
}
}
When copying is unnecessary. Immutable objects cannot be changed, so sharing them is always safe. This is a substantial practical argument for immutability:
public class Meeting {
private final LocalDateTime start; // immutable: no copy needed
private final String title; // immutable: no copy needed
private final List<String> attendees;
public Meeting(LocalDateTime start, String title, List<String> attendees) {
this.start = start; // safe to share
this.title = title; // safe to share
this.attendees = List.copyOf(attendees); // mutable input: copy
}
}
| Parameter type | Defensive copy needed? |
|---|---|
String, Integer, other wrappers | No, immutable |
LocalDate, LocalDateTime, Instant | No, immutable |
Date, Calendar | Yes, mutable legacy |
| Arrays | Yes, always mutable |
List, Set, Map implementations | Yes, unless already immutable |
List.of(...) results | No, already immutable |
| Any of your own mutable classes | Yes |
Note that Collections.unmodifiableList returns a view, not a copy: the
returned list rejects modification, but it still reflects later changes to the
backing list. List.copyOf takes an independent snapshot. Which you want
depends on whether callers should see subsequent changes.
Defensive copying costs an allocation. For hot paths with large collections, teams sometimes document the sharing contract instead and skip the copy. That is a deliberate trade-off, and the key word is documented.
Key Takeaways
- Storing a caller's mutable object lets the caller change your object's state afterwards.
- Copy first, then validate the copy, to close the time-of-check-to-time-of-use window.
- Copy on the way out as well, or return an unmodifiable view.
- Immutable parameter types need no copying, which is a strong argument for using them.
unmodifiableListis a live view;List.copyOfis an independent snapshot.
🧪 Practice
- Write a class storing a
Datewithout copying, then break its invariant from the calling code.- Fix it with defensive copies on both the constructor and the getter, and verify the attack no longer works.
- Interview: Why must you validate the copy rather than the original? (Hint: imagine a second thread running between the two statements.)
<a id="24-methods-in-depth"></a>
2.4 Methods in Depth
Methods were introduced as behaviour attached to objects. This section examines how arguments are actually passed, how the compiler chooses between overloads, and what happens on the call stack.
Parameter Passing Semantics in Java
Java is always pass-by-value. This single sentence is correct, frequently disputed, and the source of endless confusion — because for object parameters, the value being copied is a reference.
public static void modify(int number) {
number = 99; // modifies the LOCAL copy only
}
int x = 5;
modify(x);
System.out.println(x); // 5 -- unchanged
That case is uncontroversial. The interesting case is an object:
public static void rename(StringBuilder sb) {
sb.append(" modified"); // follows the reference, changes the OBJECT
}
StringBuilder text = new StringBuilder("original");
rename(text);
System.out.println(text); // "original modified" -- the object changed
People conclude from this that objects are passed by reference. They are not, and the following experiment proves it:
public static void replace(StringBuilder sb) {
sb = new StringBuilder("replaced"); // reassigns the LOCAL copy of the reference
sb.append("!"); // affects only the new object
}
StringBuilder text = new StringBuilder("original");
replace(text);
System.out.println(text); // "original" -- caller's variable UNCHANGED
If Java were pass-by-reference, the caller's variable would now point at the new object. It does not, because the method received a copy of the reference.
BEFORE THE CALL INSIDE replace()
text: +-----+ text: +-----+
| •--+---> [ "original" ] | •--+---> [ "original" ]
+-----+ +-----+
sb: +-----+
| •--+---> [ "replaced!" ]
+-----+
sb was a COPY; reassigning it
cannot repoint 'text'
The precise rule, worth memorising in this form:
| What is passed | What the method can do |
|---|---|
| A primitive | Change its own copy; caller sees nothing |
| A reference | Mutate the pointed-to object; caller sees it |
| A reference | Reassign its own copy; caller sees nothing |
So a method can change an object's contents but never the caller's variable.
Practical consequences. First, there is no way to write a swap method for
two caller variables:
public static void swap(int a, int b) { // does nothing useful
int temp = a; a = b; b = temp;
}
public static void swap(Integer a, Integer b) { // also does nothing:
// Integer is immutable, so there is no mutation available either
}
// The only options: return a result, use an array or holder object, or swap inline.
public static void swap(int[] pair) {
int temp = pair[0]; pair[0] = pair[1]; pair[1] = temp; // mutates the array
}
Second, passing a mutable object is an invitation for the method to change it, which is why immutable parameter types make code easier to reason about:
public void process(List<String> items) {
items.clear(); // legal, and the caller's list is now empty
}
If a method should not modify what it receives, either document that or take an immutable type. Defensive copying, covered earlier, is the other half of the same concern.
Third, final on a parameter prevents reassignment inside the method but does
nothing about mutation:
public void handle(final List<String> items) {
// items = new ArrayList<>(); // ERROR: cannot reassign
items.add("still mutable"); // perfectly legal
}
Key Takeaways
- Java is always pass-by-value; for objects, the value copied is the reference.
- A method can mutate the object a reference points to, and the caller sees that.
- A method cannot repoint the caller's variable, which is what pass-by-reference would allow.
- No
swapmethod for two caller variables is possible.finalon a parameter blocks reassignment, not mutation.
🧪 Practice
- Write methods that attempt to reassign and to mutate an array parameter, and show which one the caller observes.
- Explain why a method taking a
Stringcan never change the caller's string, using two reasons.- Interview: Is Java pass-by-value or pass-by-reference? Defend your answer with a code example. (Hint: try reassigning the parameter inside the method.)
Method Overloading and Resolution Rules
Overloading gives several methods the same name with different parameter lists.
It exists so that conceptually identical operations read identically regardless
of argument type — println accepts a dozen types and you never think about it.
public class Printer {
public void print(int value) { System.out.println("int: " + value); }
public void print(double value) { System.out.println("double: " + value); }
public void print(String value) { System.out.println("String: " + value); }
public void print(int a, int b) { System.out.println("two ints"); }
}
The signature consists of the method name plus the ordered list of parameter types. Return type and thrown exceptions are not part of it:
public int compute(int x) { return x; }
// public double compute(int x) { return x; } // ERROR: same signature
Resolution is a compile-time decision based on the static types of the arguments. The compiler works through three phases, stopping at the first that finds a match:
- Exact match or widening, without boxing or varargs.
- Boxing or unboxing allowed.
- Varargs considered.
public class Resolution {
static void f(long x) { System.out.println("long"); }
static void f(Integer x) { System.out.println("Integer"); }
static void f(Object x) { System.out.println("Object"); }
static void f(int... x) { System.out.println("varargs"); }
public static void main(String[] args) {
f(5); // "long" -- phase 1: widening int->long wins
} // Integer would need boxing (phase 2), varargs is phase 3
}
Remove the long overload and the answer becomes Integer; remove that too and
it becomes Object; remove that and only varargs remains. This ordering
explains most "why did it call that one?" surprises.
Most specific wins when several overloads match in the same phase:
static void g(Object o) { System.out.println("Object"); }
static void g(String s) { System.out.println("String"); }
g("text"); // "String" -- String is more specific than Object
g(null); // "String" -- null matches both; the most specific is chosen
That g(null) case becomes an error if two equally specific unrelated types are
available, since neither is more specific:
static void h(String s) { }
static void h(Integer i) { }
// h(null); // ERROR: reference to h is ambiguous
// h((String) null); // resolved by casting
Overloading with autoboxing produces the classic interview puzzle:
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // removes at INDEX 1 -> removes 20
list.remove(Integer.valueOf(20)); // removes the OBJECT 20
List declares both remove(int index) and remove(Object o). The literal 1
matches the primitive overload exactly in phase 1, so index removal wins.
Overloading versus overriding, a distinction examined fully in chapter 3:
| Aspect | Overloading | Overriding |
|---|---|---|
| Where | Same class (or inherited) | Subclass |
| Signature | Must differ | Must be identical |
| Return type | May differ freely | Same or covariant |
| Resolved | At compile time, static type | At runtime, actual object type |
| Also called | Static polymorphism | Dynamic polymorphism |
The guidance from the JDK designers is to overload conservatively. Because resolution uses static types and can surprise, avoid overloads with the same number of parameters where the types are related or where one is a primitive and another a wrapper. Distinct names are often clearer than an overload.
Key Takeaways
- A signature is the name plus ordered parameter types; return type is excluded.
- Resolution happens at compile time using static types, in three phases: widening, then boxing, then varargs.
- When several candidates match, the most specific wins; ties are ambiguity errors.
list.remove(1)removes by index,list.remove(Integer.valueOf(1))by value.- Overloading is compile-time, overriding is runtime.
🧪 Practice
- Write four overloads taking
int,long,Integer, andObject, call each with anintliteral, and explain which runs.- Demonstrate the
List.removeambiguity and both ways of resolving it.- Interview: Can two methods differ only by return type? Why or why not? (Hint: consider how the compiler would resolve a call whose result is discarded.)
Varargs Methods
Varargs, introduced in Java 5, let a method accept any number of arguments of a given type. Before it, callers had to build an array by hand for every call.
public static int sum(int... numbers) { // three dots
int total = 0;
for (int n : numbers) { // 'numbers' IS an int[]
total += n;
}
return total;
}
sum(); // 0 -- empty array, NOT null
sum(1); // 1
sum(1, 2, 3, 4, 5); // 15
sum(new int[]{1, 2, 3}); // 6 -- an array may be passed directly
The compiler turns the call site into an array creation: sum(1, 2, 3) becomes
sum(new int[]{1, 2, 3}). Inside the method, the parameter is an ordinary
array with a length field.
The rules:
- Only one varargs parameter per method.
- It must be the last parameter.
- The array is never
null; with no arguments it has length zero.
public static void log(String level, String... messages) { } // valid
// public static void bad(String... parts, String suffix) { } // ERROR: not last
// public static void bad(int... a, double... b) { } // ERROR: two varargs
A typical use combining a required parameter with optional ones:
public static void log(String level, String... details) {
System.out.print("[" + level + "]");
for (String d : details) {
System.out.print(" " + d);
}
System.out.println();
}
log("INFO"); // [INFO]
log("ERROR", "file not found", "retrying"); // [ERROR] file not found retrying
Ambiguity with overloads. Because varargs is the last resolution phase, a fixed-arity overload always wins when it matches:
static void f(int a, int b) { System.out.println("two ints"); }
static void f(int... values) { System.out.println("varargs"); }
f(1, 2); // "two ints" -- the fixed version wins
f(1, 2, 3); // "varargs" -- nothing else matches
f(); // "varargs"
Two varargs overloads that could both match are a compile error, so keep them distinct.
The generics pitfall. A varargs parameter of a generic type produces an "unchecked generic array creation" warning, because arrays and generics do not mix cleanly (the reasons are covered in chapter 6):
@SafeVarargs // asserts the method does not misuse the array
public static <T> List<T> listOf(T... elements) {
return Arrays.asList(elements);
}
@SafeVarargs suppresses the warning and is permitted only on methods that
cannot be overridden — static, final, or private — because a subclass
could otherwise violate the promise.
A performance note. Every varargs call allocates an array. In a tight loop this is measurable, which is why the standard library sometimes provides fixed overloads alongside the varargs form:
// The JDK does exactly this for List.of:
static <E> List<E> of(E e1);
static <E> List<E> of(E e1, E e2);
// ... up to ten fixed-arity overloads ...
static <E> List<E> of(E... elements); // fallback for more
Avoid Object... parameters where a specific type would do, since they accept
literally anything and defer every type error to runtime.
Key Takeaways
- A varargs parameter is an array at runtime, never
null, empty when no arguments are given.- Only one varargs parameter, and it must come last.
- Fixed-arity overloads win over varargs during resolution.
- Generic varargs need
@SafeVarargs, which is allowed only on non-overridable methods.- Each varargs call allocates an array, which is why the JDK adds fixed overloads for common sizes.
🧪 Practice
- Write a
maxmethod accepting any number of ints and handling the zero-argument case explicitly.- Write overloaded methods with a fixed two-argument version and a varargs version, and determine which is called for two, three, and zero arguments.
- Write a varargs method and call it by passing an existing array directly.
Recursion and Call Stack Behavior
A recursive method calls itself with a smaller version of the problem, until it reaches a case simple enough to answer directly. It suits problems that are naturally self-similar: trees, nested structures, and divide-and-conquer algorithms.
Every correct recursion has two parts:
- A base case that returns without recursing.
- A recursive case that moves measurably closer to the base case.
public static long factorial(int n) {
if (n < 0) throw new IllegalArgumentException("n must be non-negative");
if (n <= 1) return 1; // BASE CASE
return n * factorial(n - 1); // RECURSIVE CASE: n-1 approaches 1
}
The call stack. Each invocation gets a stack frame holding its parameters, locals, and return address. Frames are pushed on entry and popped on return:
factorial(4)
push factorial(4) -> needs factorial(3)
push factorial(3) -> needs factorial(2)
push factorial(2) -> needs factorial(1)
push factorial(1) -> BASE CASE, returns 1
pop 2 * 1 = 2
pop 3 * 2 = 6
pop 4 * 6 = 24
pop result 24
stack depth peaked at 4 frames
Stack overflow. The stack is finite (typically a few hundred kilobytes to a megabyte). Recursion that is too deep, or that never reaches a base case, exhausts it:
public static int broken(int n) {
return broken(n - 1); // no base case
}
broken(10); // StackOverflowError after thousands of frames
StackOverflowError is an Error, not an Exception: it signals a condition
you are not expected to recover from. Typical depth limits are around 10,000 to
20,000 frames, which means recursion over a large list is unsafe while recursion
over a balanced tree (depth about log n) is fine.
Recursion versus iteration. Any recursion can be rewritten iteratively, and the iterative version is usually faster and always bounded in stack use:
public static long factorialIterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result; // constant stack space
}
| Aspect | Recursion | Iteration |
|---|---|---|
| Memory | One stack frame per call | Constant |
| Speed | Call overhead per level | Faster |
| Readability | Excellent for self-similar data | Better for linear work |
| Risk | StackOverflowError | Infinite loop |
| Natural fit | Trees, graphs, divide and conquer | Sequences, counters |
Where recursion genuinely wins is tree-shaped data, where the iterative version needs an explicit stack and is markedly less clear:
public static int depth(TreeNode node) {
if (node == null) return 0; // base case
return 1 + Math.max(depth(node.left), depth(node.right));
}
Naive recursion can be exponentially slow. The classic example recomputes the same subproblems repeatedly:
public static long fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // fib(40) makes ~331 million calls
}
// Memoisation collapses it to linear time:
public static long fibMemo(int n, Map<Integer, Long> cache) {
if (n <= 1) return n;
return cache.computeIfAbsent(n, k -> fibMemo(k - 1, cache) + fibMemo(k - 2, cache));
}
A final note: Java does not perform tail-call optimisation. A tail-recursive method still consumes one frame per call, so languages that guarantee that optimisation permit recursion depths that Java does not.
Key Takeaways
- Every recursion needs a base case and progress toward it.
- Each call consumes a stack frame; exhausting the stack raises
StackOverflowError.- Java has no tail-call optimisation, so deep linear recursion is unsafe.
- Recursion is clearest for tree-shaped and divide-and-conquer problems.
- Naive recursion may recompute subproblems exponentially; memoise when it does.
🧪 Practice
- Write recursive and iterative versions of summing an integer array, then find the input size at which the recursive one fails.
- Write a recursive method that reverses a string, and identify its base case.
- Interview: Why does
fib(50)written naively take minutes while the memoised version is instant? (Hint: count how many timesfib(10)is evaluated.)
Method Signatures and Return Contracts
A method's signature and documented behaviour together form a contract: a promise to callers about what inputs are acceptable and what will be produced. Callers depend on it, so changing it breaks them.
The signature is the method name plus the ordered parameter types. Formally,
that is all — return type, parameter names, modifiers, and throws clauses are
excluded, though they matter in other ways.
public String format(int value, boolean pad) throws IOException
^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| |
| +-- part of the signature: name + (int, boolean)
+--------- NOT part of the signature
The contract has three parts, borrowed from design-by-contract:
| Part | Meaning | Expressed by |
|---|---|---|
| Precondition | What must hold before the call | Parameter types, Javadoc, validation |
| Postcondition | What holds after a successful call | Return type, Javadoc |
| Invariant | What holds before and after | Class design, final, validation |
/**
* Withdraws the given amount from this account.
*
* @param amount the amount to withdraw; must be positive and no greater
* than the current balance <- PRECONDITION
* @return the new balance, always non-negative <- POSTCONDITION
* @throws IllegalArgumentException if amount is not positive
* @throws IllegalStateException if amount exceeds the balance
*/
public long withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
if (amount > balance) throw new IllegalStateException("insufficient funds");
balance -= amount;
return balance; // INVARIANT preserved: balance >= 0
}
Return contracts worth designing deliberately:
// 1. Never return null for a collection -- return empty.
public List<Order> findOrders(String customerId) {
List<Order> found = repository.search(customerId);
return found == null ? List.of() : found;
}
// 2. Use Optional when absence is a normal outcome.
public Optional<Customer> findById(String id) { ... }
// 3. Return an immutable or defensive copy when the value is internal state.
public List<String> getTags() { return List.copyOf(tags); }
// 4. Be consistent about failure: either throw or return a sentinel, not both.
public int indexOf(String item) { return -1; } // documented sentinel
What breaks callers when you change a published method:
| Change | Source compatible | Binary compatible |
|---|---|---|
| Rename a parameter | Yes | Yes |
| Add an overload | Usually | Yes |
Widen a parameter type (int to long) | Usually | No |
| Narrow the return type (covariant) | Usually | No |
Add a checked exception to throws | No | Yes |
| Rename the method | No | No |
| Reorder parameters of the same types | Compiles, silently wrong | No |
That last row is worth pausing on: swapping two String parameters produces
code that still compiles at every call site and does the wrong thing. This is a
strong argument for distinct types over primitive obsession — a CustomerId
type and an OrderId type cannot be swapped by accident, while two Strings
can.
Designing a good signature, in short: as few parameters as possible;
specific types rather than String and int for everything; no boolean flags
that change behaviour (setVisible(true) reads worse than show()); and a
return type that makes the absence case explicit.
// Poor: what do these mean at the call site?
public void update(String a, String b, boolean flag, int mode) { }
// Better: types carry meaning, behaviour is named, no mode flag
public void rename(CustomerId id, CustomerName newName) { }
public void archive(CustomerId id) { }
Key Takeaways
- The signature is the method name plus ordered parameter types; return type and
throwsare not part of it.- A contract covers preconditions, postconditions, and invariants, documented in Javadoc and enforced by validation.
- Prefer empty collections and
Optionalovernullreturns.- Reordering same-typed parameters compiles and silently breaks callers.
- Distinct domain types beat
Stringandinteverywhere, and boolean flags usually want to be separate methods.
🧪 Practice
- Write a full Javadoc contract for a
divide(int, int)method, covering the zero-divisor case.- Take a method with four
Stringparameters and redesign it using small domain types.- Interview: Your library method currently returns
nullwhen nothing is found. What are the options for improving it, and what does each cost callers? (Hint: consider source compatibility.)
<a id="25-encapsulation-and-access-control"></a>
2.5 Encapsulation and Access Control
Encapsulation is the pillar this chapter has been building toward: deciding what a class exposes, what it hides, and how it guarantees that its data stays valid.
Access Modifiers: private, default, protected, public
Java has four access levels controlling where a member may be referenced. They form a strict ordering from most to least restrictive.
| Modifier | Same class | Same package | Subclass (other package) | Anywhere |
|---|---|---|---|---|
private | Yes | No | No | No |
| (default) | Yes | Yes | No | No |
protected | Yes | Yes | Yes | No |
public | Yes | Yes | Yes | Yes |
"Default" (also called package-private) is the absence of any modifier, not a keyword.
package com.example.shapes;
public class Shape {
private String secret = "only this class";
/*default*/ String internal = "this package";
protected String inherited = "package + subclasses anywhere";
public String open = "everyone";
private void hiddenHelper() { }
public void render() { hiddenHelper(); } // fine: same class
}
package com.example.shapes;
class Neighbour { // same package
void test(Shape s) {
// s.secret; // ERROR: private
System.out.println(s.internal); // OK: same package
System.out.println(s.inherited);// OK: protected includes package access
System.out.println(s.open); // OK
}
}
package com.example.other; // DIFFERENT package
import com.example.shapes.Shape;
class Circle extends Shape {
void test(Shape other) {
// other.internal; // ERROR: different package
System.out.println(this.inherited); // OK: protected, through inheritance
// other.inherited; // ERROR: protected access is through
// 'this', not an arbitrary instance
System.out.println(other.open); // OK
}
}
That last restriction surprises people. protected grants a subclass access to
its own inherited member, not to the member of any other instance of the
superclass. The rule exists so a subclass cannot reach into unrelated sibling
objects.
Access widens outward
+---------------------------------------------+
| public anywhere |
| +---------------------------------------+ |
| | protected package + subclasses | |
| | +---------------------------------+ | |
| | | default same package | | |
| | | +---------------------------+ | | |
| | | | private same class only | | | |
| | | +---------------------------+ | | |
| | +---------------------------------+ | |
| +---------------------------------------+ |
+---------------------------------------------+
Where each modifier is legal:
| Element | Allowed modifiers |
|---|---|
| Top-level class | public or default only |
| Nested class | All four |
| Field, method, constructor | All four |
| Local variable | None (they have no access level) |
| Interface members | public implicitly; private allowed since Java 9 |
The default choice should be private. Start every member private and widen
only when a concrete need appears. Widening later is easy; narrowing later
breaks callers, and once a member is public in a published library it is
effectively permanent.
public class Cache {
private final Map<String, String> entries = new HashMap<>(); // hidden
private int hits; // hidden
public String get(String key) { // the deliberate public surface
String value = entries.get(key);
if (value != null) hits++;
return value;
}
public void put(String key, String value) { entries.put(key, value); }
int hitCount() { return hits; } // package-private: for tests
}
That last member shows a common, pragmatic use of package-private: exposing something to tests in the same package without making it part of the public API.
protected deserves caution. It is part of your public API as far as
subclasses are concerned, meaning any protected field becomes a compatibility
commitment. Prefer protected methods over protected fields, so the
representation remains free to change.
Key Takeaways
- Four levels:
private, default (package-private),protected,public.- Default is the absence of a keyword, not a keyword itself.
protectedallows a subclass access throughthis, not through arbitrary instances of the superclass.- Top-level classes may only be
publicor package-private.- Start with
privateand widen deliberately;protectedfields become a permanent commitment.
🧪 Practice
- Create two packages and a four-member class, then verify each access rule from a same-package class and from a subclass in the other package.
- Try to declare a top-level class
privateand read the error.- Interview: Why can a subclass in another package not access
otherInstance.protectedField? (Hint: think about what protected is meant to enable.)
Information Hiding Principles
Access modifiers are the mechanism; information hiding is the principle. The goal is that a class's implementation can change without any caller noticing, which is only possible if callers never depended on it in the first place.
The classic illustration is a class whose representation changes:
// VERSION 1: temperature stored in Celsius, fields public
public class Weather {
public double celsius; // callers write: w.celsius = 21.0;
}
Now a requirement arrives: store the temperature in Kelvin internally for scientific calculations. Every caller breaks, because every caller referenced the field directly. With the field hidden, the change is invisible:
// VERSION 2: internal representation changed, public surface identical
public class Weather {
private double kelvin; // representation changed
public double getCelsius() { return kelvin - 273.15; }
public void setCelsius(double c) { kelvin = c + 273.15; }
public double getKelvin() { return kelvin; } // new capability, free
}
The public surface of a class is a promise; the private part is a free hand. Every public member you add shrinks the freedom you retain.
What to hide, in decreasing order of importance:
- Fields. Essentially always private. A public mutable field surrenders all control over the class's state.
- Implementation types. Return
List, notArrayList, so you can switch implementations. - Helper methods. If it only exists to support a public method, it is private.
- Constructors. Making them private and exposing factories keeps creation under your control.
- Nested classes. If a nested class is an implementation detail, it should be private.
public class OrderBook {
private final List<Order> orders = new ArrayList<>(); // 1. field hidden
public List<Order> getOrders() { // 2. interface type returned
return List.copyOf(orders); // and a snapshot, not the field
}
public void add(Order order) {
validate(order); // 3. helper is private
orders.add(order);
}
private void validate(Order order) { ... }
private static final class Index { ... } // 5. internal structure hidden
}
The leaky abstraction problem. A class can have all-private fields and still expose its implementation through its method names and types:
// Encapsulated, but leaks its implementation in the API
public class UserCache {
private HashMap<String, User> map = new HashMap<>();
public HashMap<String, User> getMap() { return map; } // leaks BOTH the
} // type and the object
// Better: the API talks about users, not about maps
public class UserCache {
private final Map<String, User> byId = new HashMap<>();
public Optional<User> find(String id) { return Optional.ofNullable(byId.get(id)); }
public void store(User user) { byId.put(user.getId(), user); }
public int size() { return byId.size(); }
}
The second version can switch to a TreeMap, add an eviction policy, or move
to a database without changing a single caller.
A useful test. For each public member, ask: "if I changed how this class works internally, would I have to keep this member working exactly as it does now?" If the answer is yes and the member is about how rather than what, it should not be public.
| Symptom | What it leaks |
|---|---|
public fields | The entire representation |
| Getter returning an internal collection | The ability to mutate your state |
Return type ArrayList instead of List | Your choice of implementation |
Method named getHashMap() | The data structure you chose |
protected fields | Representation, to all subclasses |
Key Takeaways
- Information hiding lets the implementation change without breaking callers.
- Every public member is a permanent commitment; keep the surface small.
- Return interface types, not implementation types.
- A class can be encapsulated and still leak through method names and return types.
- Ask whether each public member describes what the class does or how it does it.
🧪 Practice
- Write a class storing a full name in one field, then change it to first and last name fields without breaking any caller.
- Find a getter that returns an internal collection and make it safe, then explain the trade-off you chose.
- Interview: A class has all-private fields but a method
public HashMap<String,User> getMap(). Is it encapsulated? (Hint: what can a caller do with the returned object?)
Getter and Setter Design
Getters and setters are the conventional way to expose controlled access to state. They are also routinely overused: generating a getter and a setter for every field reproduces public fields with extra typing and none of the benefit.
The convention:
public class Person {
private String name;
private boolean active;
public String getName() { return name; } // getX for most types
public void setName(String n) { this.name = n; }
public boolean isActive() { return active; } // isX for boolean
public void setActive(boolean a) { this.active = a; }
}
The get/is/set naming is not a language rule; it is the JavaBeans
convention, and frameworks such as Jackson, Hibernate, and Spring rely on it for
reflection-based mapping. Deviating from it breaks those tools.
What a getter or setter is actually for is adding behaviour to access:
public class Account {
private long balanceCents;
private String email;
public void setEmail(String email) {
if (email == null || !email.contains("@")) { // validation
throw new IllegalArgumentException("Invalid email: " + email);
}
this.email = email.toLowerCase(); // normalisation
auditLog.record("email changed"); // side effect
}
public double getBalance() {
return balanceCents / 100.0; // computed, not stored
}
public List<Transaction> getTransactions() {
return List.copyOf(transactions); // defensive copy
}
}
If a setter does nothing but assign, it provides no value over a public field except JavaBeans compatibility and the ability to add behaviour later without breaking callers. That last point is the real justification in library code, and is weaker in an application you control entirely.
Prefer not to generate setters at all. The modern default is immutability: set everything in the constructor and omit setters. A class with no setters cannot enter an invalid state after construction, is thread-safe for free, and is safe to use as a map key.
// Mutable: five ways to be half-initialised
public class Order {
private String id;
private Customer customer;
public void setId(String id) { this.id = id; }
public void setCustomer(Customer c) { this.customer = c; }
}
// Immutable: valid from birth, or it does not exist
public final class Order {
private final String id;
private final Customer customer;
public Order(String id, Customer customer) {
this.id = Objects.requireNonNull(id);
this.customer = Objects.requireNonNull(customer);
}
public String getId() { return id; }
public Customer getCustomer(){ return customer; }
}
Records make this the path of least resistance for pure data carriers, and are covered fully in chapter 4:
public record Point(int x, int y) { } // accessors x() and y() generated
Note that record accessors are named x() and y(), not getX(), which is a
deliberate departure from the JavaBeans convention.
Design guidance:
| Guideline | Reason |
|---|---|
| Do not add a setter unless something must change it | Immutability by default |
| Validate in setters and constructors alike | Invariants must hold always |
| Return copies of mutable internal state | Prevent external mutation |
| Name methods for the domain, not the field | deposit() beats setBalance() |
| Avoid a getter for every field reflexively | Getters are API surface too |
That fourth row deserves emphasis: account.setBalance(account.getBalance() + 100) moves the business rule out of the class and back into the caller, which
is exactly what encapsulation was meant to prevent. account.deposit(100) keeps
it where it belongs.
Key Takeaways
get/is/setis the JavaBeans convention, required by many frameworks, not a language rule.- A trivial setter adds value only as a future extension point.
- Prefer immutability: assign in the constructor and omit setters.
- Return defensive copies of mutable internal state.
- Domain-meaningful methods beat setter-plus-getter arithmetic in the caller.
🧪 Practice
- Write a
Productclass where the price setter rejects negatives and the getter returns a formatted string.- Convert a mutable class with five setters into an immutable one, and note what the callers had to change.
- Interview: Is
getBalance()followed bysetBalance()in the caller a problem? (Hint: where does the rule about minimum balance now live?)
Immutable Class Construction
An immutable object cannot change after construction. This sounds restrictive
and turns out to remove entire categories of bugs, which is why String,
Integer, LocalDate, and the modern collection factories all work this way.
The five rules for a correctly immutable class:
- Make the class
final, so no subclass can add mutable state or override a method to lie about the state. - Make every field
privateandfinal. - Provide no setters or any other mutating method.
- Defensively copy mutable objects on the way in.
- Defensively copy mutable objects on the way out.
public final class Reservation { // 1. final class
private final String guestName; // 2. private final
private final LocalDate date; // (immutable type)
private final List<String> requests; // (mutable type!)
public Reservation(String guestName, LocalDate date, List<String> requests) {
this.guestName = Objects.requireNonNull(guestName);
this.date = Objects.requireNonNull(date); // LocalDate is immutable: share
this.requests = List.copyOf(requests); // 4. copy on the way in
}
public String getGuestName() { return guestName; }
public LocalDate getDate() { return date; }
public List<String> getRequests() {
return requests; // 5. already immutable, safe
}
// 3. No setters. Changes produce a NEW object:
public Reservation withDate(LocalDate newDate) {
return new Reservation(guestName, newDate, requests);
}
}
The withX naming convention for "a copy with one thing changed" is standard
across the JDK (LocalDate.withYear, String.replace conceptually).
What immutability buys:
| Benefit | Why it follows |
|---|---|
| Thread safety | No writes means no race conditions, no synchronisation |
| Safe sharing | No defensive copying needed by callers |
| Safe as a map key | The hash code cannot change after insertion |
| Simpler reasoning | A reference's value is fixed at every point in the program |
| Failure atomicity | A failed operation leaves no half-modified object |
The map-key point is concrete and worth seeing:
// A mutable key silently corrupts a HashMap
class MutablePoint { int x, y; /* equals and hashCode based on x, y */ }
Map<MutablePoint, String> map = new HashMap<>();
MutablePoint key = new MutablePoint(1, 2);
map.put(key, "found me");
key.x = 99; // the hash code changes AFTER insertion
System.out.println(map.get(key)); // null -- it is in the wrong bucket now
The cost is allocation: every modification creates an object. For small
objects this is negligible, since modern JVMs allocate cheaply and collect
short-lived objects almost for free. For a large structure modified in a tight
loop, a mutable builder is the standard remedy — which is precisely the
String and StringBuilder relationship from chapter 1.
Common mistakes:
public final class Broken {
private final List<String> items;
private final Date created;
public Broken(List<String> items, Date created) {
this.items = items; // MISTAKE: caller keeps a reference
this.created = created; // MISTAKE: Date is mutable
}
public List<String> getItems() {
return items; // MISTAKE: caller can now call add()
}
public Date getCreated() {
return created; // MISTAKE: caller can call setTime()
}
}
Note that final on a field is necessary but not sufficient: it prevents
reassignment of the reference, not mutation of the object, as established in
chapter 1. The whole point of rules 4 and 5 is to cover that gap.
Records, introduced in Java 16, generate most of this automatically but do not
solve the mutable-component problem: a record holding a List field still needs
List.copyOf in a compact constructor.
Key Takeaways
- Immutable classes are
final, withprivate finalfields, no mutators, and defensive copies in both directions.finalon a field prevents reassignment, not mutation of the referenced object.- Immutability gives thread safety, safe sharing, and valid map keys for free.
- Modification is expressed as a
withXmethod returning a new instance.- Records automate the boilerplate but still require copying mutable components.
🧪 Practice
- Write an immutable
Moneyclass with amount and currency, plusplusandminusmethods returning new instances.- Take the
Brokenclass above and fix all four mistakes.- Interview: Why is a mutable object dangerous as a
HashMapkey? (Hint: what does the map use to choose a bucket, and when?)
Class Invariants and Validation
A class invariant is a condition that must be true of every instance at every moment an outside caller can observe it. "A balance is never negative", "start precedes end", "the list never contains null" are all invariants. Encapsulation exists to make them enforceable.
An invariant holds if two things are true: the constructor establishes it, and every method that changes state preserves it. Since the fields are private, no other code can break it.
public final class DateRange {
private final LocalDate start;
private final LocalDate end;
// ESTABLISH the invariant: start is never after end
public DateRange(LocalDate start, LocalDate end) {
this.start = Objects.requireNonNull(start, "start");
this.end = Objects.requireNonNull(end, "end");
if (start.isAfter(end)) {
throw new IllegalArgumentException(
"start (" + start + ") must not be after end (" + end + ")");
}
}
// PRESERVE it: every derived instance is validated by the same constructor
public DateRange extendTo(LocalDate newEnd) {
return new DateRange(start, newEnd); // re-validates automatically
}
public long days() {
return ChronoUnit.DAYS.between(start, end) + 1; // relies on the invariant
}
}
The payoff is in days(): because the invariant is guaranteed, the method needs
no defensive checks and can never return a negative count.
Where to validate:
| Location | What belongs there |
|---|---|
| Constructor | Everything needed to establish the invariant |
| Setter / mutator | Everything needed to preserve it |
| Private helper | Shared checks, called from both |
| Static factory | Validation plus the choice of what to return |
Choosing the right exception communicates whose fault the failure is:
public void withdraw(long amount) {
// Bad ARGUMENT from the caller
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be positive, was " + amount);
}
// Bad STATE of this object for this operation
if (amount > balance) {
throw new IllegalStateException("Balance " + balance + " is less than " + amount);
}
// Missing required input
Objects.requireNonNull(currency, "currency must be set first");
balance -= amount;
}
| Exception | Use when |
|---|---|
IllegalArgumentException | A parameter value is unacceptable |
NullPointerException | A parameter or field is null when it must not be |
IllegalStateException | The object is in the wrong state for the call |
IndexOutOfBoundsException | An index parameter is out of range |
| A custom checked exception | The caller can reasonably recover |
Fail fast. Validate at the boundary, before any state changes. A method that modifies three fields and then discovers a problem leaves the object half-updated:
// BAD: fails after mutating -- the object is now inconsistent
public void update(String name, int age) {
this.name = name;
if (age < 0) throw new IllegalArgumentException("age"); // too late
this.age = age;
}
// GOOD: validate everything first, then mutate -- failure atomicity
public void update(String name, int age) {
Objects.requireNonNull(name, "name");
if (age < 0) throw new IllegalArgumentException("age must be non-negative");
this.name = name;
this.age = age;
}
This property is called failure atomicity: a failed method call leaves the object exactly as it was. Immutable classes get it for free, since a failed construction simply produces no object.
Useful validation helpers in the standard library:
Objects.requireNonNull(value, "message");
Objects.requireNonNullElse(value, fallback);
Objects.checkIndex(index, length); // Java 9+
if (!condition) throw new IllegalArgumentException("...");
Error messages should include the offending value. "Invalid age" forces a
debugging session; "age must be between 0 and 150, was -3" does not.
Key Takeaways
- An invariant is a condition true of every observable instance state.
- Constructors establish invariants; mutators preserve them; privacy makes that enforceable.
IllegalArgumentExceptionblames the argument,IllegalStateExceptionblames the object's state.- Validate before mutating so a failed call leaves the object unchanged.
- Include the offending value in every validation message.
🧪 Practice
- Write a
Percentageclass whose invariant is 0 to 100 inclusive, and enforce it in the constructor.- Write a method that violates failure atomicity, demonstrate the half-updated object, then fix it.
- Interview: What is the difference between
IllegalArgumentExceptionandIllegalStateException, and which fits "withdrawing more than the balance"? (Hint: is the amount itself invalid?)
<a id="26-static-members-and-class-level-state"></a>
2.6 Static Members and Class-Level State
Everything so far has belonged to individual objects. static members belong to
the class itself, existing once regardless of how many instances are created.
Static Fields and Shared State
A static field is stored once per class, not once per object. Every instance
sees the same variable, and it exists even when no instance has been created.
public class Counter {
private static int totalCreated; // ONE copy, shared by all instances
private final int id; // one copy PER instance
public Counter() {
totalCreated++; // every construction updates the shared field
this.id = totalCreated;
}
public int getId() { return id; }
public static int getTotal() { return totalCreated; }
}
Counter a = new Counter();
Counter b = new Counter();
Counter c = new Counter();
System.out.println(a.getId()); // 1
System.out.println(c.getId()); // 3
System.out.println(Counter.getTotal()); // 3 -- accessed through the CLASS
CLASS-LEVEL (one copy) INSTANCE-LEVEL (one per object)
Counter.totalCreated = 3 a --> [ id = 1 ]
^ ^ ^ b --> [ id = 2 ]
| | | c --> [ id = 3 ]
a b c all share it
Access through the class name, not an instance. Java permits
a.getTotal(), but it is misleading because it suggests instance-specific data,
and most style checkers flag it.
Counter.getTotal(); // correct and clear
a.getTotal(); // compiles, but misleading -- avoid
The dominant legitimate use is constants:
public class Physics {
public static final double SPEED_OF_LIGHT = 299_792_458.0;
public static final int MAX_RETRIES = 3;
}
static final with an immutable value is unambiguously good: one copy, never
changes, no state to corrupt. The JDK is full of these (Integer.MAX_VALUE,
Math.PI).
Mutable static state is where trouble begins. It is effectively a global variable, with all the classic drawbacks:
public class BadRegistry {
public static Map<String, User> users = new HashMap<>(); // several problems
}
| Problem | Consequence |
|---|---|
| Global mutability | Any code anywhere can change it |
| Not thread-safe | Concurrent access corrupts a HashMap without locking |
| Test pollution | State persists between tests, causing order dependence |
| Hidden dependency | A class using it has a dependency invisible in its API |
| Lifetime | Lives as long as the class loader, a common leak source |
The test-pollution point is the one developers meet first:
@Test void testA() { BadRegistry.users.put("x", user); /* ... */ }
@Test void testB() { assertTrue(BadRegistry.users.isEmpty()); } // fails if A ran first
Legitimate uses of mutable static state do exist, and share a property: the state is genuinely global to the process and access is controlled.
public class IdGenerator {
private static final AtomicLong counter = new AtomicLong(); // thread-safe
public static long next() { return counter.incrementAndGet(); }
}
public class Logger {
private static final Logger INSTANCE = new Logger(); // singleton instance
private Logger() { }
public static Logger get() { return INSTANCE; }
}
Note that both keep the field private and expose controlled methods, and both
use a thread-safe type. A public static mutable field has essentially no
defence.
The modern alternative in application code is dependency injection: instead of a class reaching for a static registry, it receives what it needs through its constructor. That makes the dependency visible, replaceable in tests, and independently scoped.
// Hidden dependency on global state
public class OrderService {
public void process(Order o) { BadRegistry.users.get(o.getUserId()); }
}
// Explicit dependency, easy to substitute in a test
public class OrderService {
private final UserRepository users;
public OrderService(UserRepository users) { this.users = users; }
public void process(Order o) { users.findById(o.getUserId()); }
}
Key Takeaways
- A
staticfield exists once per class and is shared by every instance.- Access static members through the class name, not through an instance.
static finalconstants with immutable values are safe and idiomatic.- Mutable static state is a global variable: unsafe for threads, polluting for tests, and a hidden dependency.
- Prefer constructor injection over static registries.
🧪 Practice
- Write a class counting how many instances have been created, and verify the count from three objects.
- Create a mutable
public staticlist, modify it from two different classes, and describe why this is hard to debug.- Interview: Why is mutable static state a problem for unit tests? (Hint: what persists between test methods?)
Static Methods and Utility Classes
A static method belongs to the class rather than to any instance. It can be
called without creating an object, and consequently it has no this.
public class MathUtils {
public static int square(int n) { return n * n; }
public static double average(int... values) {
if (values.length == 0) return 0;
int sum = 0;
for (int v : values) sum += v;
return (double) sum / values.length;
}
}
int result = MathUtils.square(5); // no object required
When a method should be static: when it does not read or write instance state. If a method never touches a field and never calls an instance method, it is a function, not behaviour, and marking it static says so.
public class Order {
private List<Item> items;
public double total() { // uses instance state -> instance method
return items.stream().mapToDouble(Item::price).sum();
}
public static double taxFor(double amount, double rate) { // pure function
return amount * rate; // -> static
}
}
A static method cannot access instance members directly, because there is no instance to access:
public class Example {
private int instanceField = 10;
private static int staticField = 20;
public static void staticMethod() {
System.out.println(staticField); // OK
// System.out.println(instanceField); // ERROR: non-static variable
// this.instanceField; // ERROR: no 'this' in a static context
Example e = new Example();
System.out.println(e.instanceField); // OK: through an explicit instance
}
public void instanceMethod() {
System.out.println(staticField); // OK: instances can see static members
System.out.println(instanceField); // OK
}
}
The utility class idiom groups related static methods in a class that is never instantiated:
public final class StringUtils { // final: nothing to extend
private StringUtils() { // private: nothing to instantiate
throw new AssertionError("No instances"); // blocks reflection too
}
public static boolean isBlank(String s) {
return s == null || s.isBlank();
}
public static String reverse(String s) {
return new StringBuilder(s).reverse().toString();
}
}
The three elements — final class, private constructor, all-static members —
are the standard recipe. The JDK's own Math, Arrays, Collections, and
Objects follow it.
Static methods and polymorphism do not mix. A static method cannot be overridden; a subclass declaring the same signature hides it instead, and the version called is chosen by the static type:
class Parent {
static void greet() { System.out.println("Parent"); }
}
class Child extends Parent {
static void greet() { System.out.println("Child"); } // HIDES, not overrides
}
Parent p = new Child();
p.greet(); // "Parent" -- chosen by the STATIC type of p
This is examined again in chapter 3; the practical advice is not to hide static methods, because the result confuses every reader.
Testability. Static methods are hard to substitute in tests, since there is
no reference to replace. A pure function such as Math.max is fine, but a
static method that performs I/O or reads configuration becomes an obstacle:
// Hard to test: no way to substitute the clock or the file system
public static Report generate() {
LocalDate today = LocalDate.now();
String config = Files.readString(Path.of("/etc/app.conf"));
...
}
The rule of thumb: static is appropriate for pure functions of their arguments, and inappropriate when the method has dependencies you might want to replace.
Key Takeaways
- A static method belongs to the class, has no
this, and cannot access instance members directly.- Make a method static when it uses no instance state.
- The utility class recipe is a
finalclass with a private constructor and only static members.- Static methods are hidden, not overridden, and dispatch on the static type.
- Static methods with dependencies are hard to substitute in tests; pure functions are fine.
🧪 Practice
- Write a
ValidationUtilsclass following the full utility-class recipe.- Try to access an instance field from a static method and read the exact error.
- Interview: Why can a static method not be overridden? (Hint: overriding needs an object to dispatch on.)
Static Initializer Blocks
A static initializer is a block marked static in the class body. It runs once,
when the class is initialised, and exists for setup that needs more than a
single expression.
public class Registry {
private static final Map<String, String> CODES = new HashMap<>();
static { // runs once, at class initialisation
CODES.put("IT", "Italy");
CODES.put("FR", "France");
CODES.put("DE", "Germany");
}
public static String lookup(String code) { return CODES.get(code); }
}
Without the block, populating that map would require a helper method or the discouraged double-brace idiom.
When class initialisation happens. The JVM initialises a class lazily, on first active use:
| Triggers initialisation | Does NOT trigger it |
|---|---|
Creating an instance with new | Declaring a variable of the type |
| Calling a static method | Accessing a compile-time constant |
| Reading or writing a non-constant static field | Loading the class with Class.forName(..., false, ...) |
| Reflective instantiation | Referencing an array type of the class |
| Initialising a subclass |
public class Lazy {
static { System.out.println("Lazy initialised"); }
public static final int CONSTANT = 42; // compile-time constant
public static int mutable = 42;
}
Lazy ref; // nothing printed: declaration only
System.out.println(Lazy.CONSTANT); // nothing printed: inlined at compile time
System.out.println(Lazy.mutable); // "Lazy initialised" printed now
That middle case catches people out: a static final primitive or String
initialised with a constant expression is inlined by the compiler, so reading it
does not touch the class at all.
Multiple blocks run in source order, interleaved with static field initialisers:
public class Order {
static int a = print("1. static field a");
static { print("2. first static block"); }
static int b = print("3. static field b");
static { print("4. second static block"); }
private static int print(String s) { System.out.println(s); return 0; }
}
Exception handling. A static block cannot throw a checked exception out of
the class; it must handle it. An uncaught exception during static
initialisation is wrapped in ExceptionInInitializerError, and the class is
permanently marked unusable for the rest of the JVM's life:
public class Fragile {
private static final Properties CONFIG = new Properties();
static {
try (InputStream in = Fragile.class.getResourceAsStream("/app.properties")) {
CONFIG.load(in); // IOException is checked
} catch (IOException e) {
throw new ExceptionInInitializerError(e); // fail loudly and clearly
}
}
}
Any later attempt to use Fragile throws NoClassDefFoundError, whose message
does not mention the original problem. That is why the first failure must be
logged clearly — it is the only comprehensible error you will get.
Alternatives. For a single expression, a field initialiser is simpler. For lazy expensive initialisation, the holder idiom uses lazy class loading to get thread-safe laziness with no synchronisation:
public class Heavy {
private static class Holder { // not loaded until referenced
static final Heavy INSTANCE = new Heavy();
}
public static Heavy get() { return Holder.INSTANCE; } // triggers loading here
}
The JVM guarantees that class initialisation is thread-safe and happens exactly once, which is what makes this idiom correct without any explicit locking.
Key Takeaways
- A static block runs once, at class initialisation, for multi-statement setup.
- Initialisation is lazy and triggered by first active use, not by declaration.
- Compile-time constants are inlined, so reading one does not initialise the class.
- Static blocks and static field initialisers run in source order.
- A failure during static initialisation raises
ExceptionInInitializerErrorand permanently breaks the class.
🧪 Practice
- Write a class with a static block and prove it runs only once no matter how many instances you create.
- Demonstrate that reading a
static final intconstant does not trigger the static block, while reading a non-final static field does.- Interview: What happens if a static initializer throws an exception, and what error do later callers see? (Hint: the class never becomes usable.)
Static versus Instance Context
Many beginner compile errors reduce to one question: does this code have an object to work with? Static context does not; instance context does.
public class Example {
private int instanceField;
private static int staticField;
public void instanceMethod() {
instanceField = 1; // OK: 'this' exists
staticField = 2; // OK: static members are always reachable
staticMethod(); // OK
helper(); // OK
}
public static void staticMethod() {
// instanceField = 1; // ERROR: non-static variable cannot be referenced
staticField = 2; // OK
// helper(); // ERROR: non-static method cannot be referenced
// this.toString(); // ERROR: 'this' cannot be used in a static context
}
private void helper() { }
}
| From \ To | Instance field | Instance method | Static field | Static method | this |
|---|---|---|---|---|---|
| Instance method | Yes | Yes | Yes | Yes | Yes |
| Static method | No | No | Yes | Yes | No |
| Instance block | Yes | Yes | Yes | Yes | Yes |
| Static block | No | No | Yes | Yes | No |
| Constructor | Yes | Yes | Yes | Yes | Yes |
The asymmetry has one cause: static code may run before any instance exists, so there is nothing for an instance reference to point at.
TIMELINE
class loaded ----> static init ----> [ maybe never any instance ]
|
| at this moment there are ZERO objects,
| so 'instanceField' has no meaning
v
new Example() ----> instance init ----> instance methods
|
+-- now 'this' exists
The most common beginner error is calling an instance method from main,
which is static:
public class App {
private int counter;
public void run() { counter++; }
public static void main(String[] args) {
// run(); // ERROR: non-static method run() cannot be
// referenced from a static context
new App().run(); // correct: create an instance first
}
}
Static can be reached from instance context freely, because the class is always initialised before any instance exists. The reverse is what needs an explicit object:
public static void process(Example e) {
System.out.println(e.instanceField); // fine: we were GIVEN an instance
}
Choosing between them comes down to a single question, asked of the method:
Does this method read or write instance state?
|
yes | no
|
instance Does it belong conceptually to the type rather than a value?
|
yes | no
|
static probably belongs in another class entirely
Common static members in practice: factory methods (List.of), pure functions
(Math.abs), constants (Integer.MAX_VALUE), and main. Common instance
members: anything that reads or modifies the object's own data.
A final subtlety worth knowing: a nested class declared static follows the
same logic. It has no reference to an enclosing instance, which is why it can be
created independently. That is the next topic.
Key Takeaways
- Static context has no
this, because it may run before any object exists.- Instance code can reach static members freely; static code needs an explicit reference to reach instance members.
- Calling an instance method from
mainrequires creating an object first.- Make a method static exactly when it does not use instance state.
- The same reasoning explains static nested classes.
🧪 Practice
- Write a
mainmethod that tries to call an instance method directly, read the error, then fix it two different ways.- Build a table for your own class showing which of its members each other member can access.
- Interview: Why can
mainnot access instance fields directly? (Hint: when doesmainrun relative to object creation?)
Static Nested Classes
A class declared inside another class is a nested class. Nested classes come in two flavours, and the distinction is exactly the static-versus-instance one from the previous topic.
public class Outer {
private int outerField = 10;
private static int staticField = 20;
static class StaticNested { // NO reference to an Outer instance
void show() {
System.out.println(staticField); // OK: static member
// System.out.println(outerField); // ERROR: needs an Outer instance
}
}
class Inner { // HOLDS a reference to an Outer
void show() {
System.out.println(outerField); // OK: reaches the enclosing instance
System.out.println(staticField); // OK
}
}
}
// A static nested class is created independently
Outer.StaticNested nested = new Outer.StaticNested();
// An inner class requires an enclosing instance
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner(); // unusual syntax, rarely seen
STATIC NESTED INNER (non-static)
[ Outer.StaticNested ] [ Outer instance ]
(independent) ^
| hidden reference
[ Inner instance ]
no link to any Outer object cannot exist without an Outer object
| Aspect | Static nested | Inner (non-static) |
|---|---|---|
| Enclosing instance | None | Required |
| Can access outer instance fields | No | Yes |
| Creation | new Outer.Nested() | outer.new Inner() |
| Can declare static members | Yes | Yes, since Java 16 |
| Memory overhead | None | One hidden reference |
| Typical use | Helper types, builders, nodes | Iterators, tight coupling to the outer object |
Prefer static nested classes. The hidden reference held by an inner class is a genuine memory-leak risk: if an inner-class instance outlives its intended scope, it keeps the entire enclosing object alive.
public class DataHolder {
private byte[] hugeArray = new byte[100_000_000];
class Listener { } // each Listener pins hugeArray in memory
static class SafeListener { } // independent, no such risk
}
This is exactly why Android tooling warns about non-static inner classes used as callbacks.
The canonical uses of static nested classes:
// 1. A builder for its enclosing type
public class Pizza {
private final String size;
private Pizza(Builder b) { this.size = b.size; }
public static class Builder {
private String size = "medium";
public Builder size(String s) { this.size = s; return this; }
public Pizza build() { return new Pizza(this); }
}
}
Pizza p = new Pizza.Builder().size("large").build();
// 2. An internal data structure
public class LinkedStack<E> {
private Node<E> top;
private static class Node<E> { // needs no LinkedStack instance
E value;
Node<E> next;
}
}
// 3. A grouped, closely related type
public class Http {
public static class Request { }
public static class Response { }
}
Note Node being both private and static: private because it is an
implementation detail, static because a node has no reason to reference the
stack that holds it.
The other two nested forms are local classes, declared inside a method, and anonymous classes, declared and instantiated in one expression. Both are covered in chapter 4 alongside lambdas, which have largely replaced them.
Key Takeaways
- A static nested class has no reference to an enclosing instance; an inner class does.
- Inner classes keep their enclosing object alive, which risks memory leaks.
- Prefer static nested classes unless you genuinely need the outer instance.
- Common uses are builders, private data structures such as nodes, and grouping related types.
- Creating an inner class requires the
outer.new Inner()syntax.
🧪 Practice
- Write an
Outerclass with both a static nested and an inner class, and try to access an outer instance field from each.- Implement a singly linked list with a private static
Nodeclass.- Interview: Why can a non-static inner class cause a memory leak? (Hint: what does each instance hold a reference to?)
The this Keyword
this is a reference to the object whose method is currently executing. Every
instance method and constructor receives it implicitly, which is how a single
shared method body knows whose fields to touch.
Use 1: disambiguating a shadowed field. This is the most common use by far.
public class Point {
private int x;
private int y;
public Point(int x, int y) { // parameters SHADOW the fields
this.x = x; // this.x = the field, x = the parameter
this.y = y;
}
public void setX(int x) {
this.x = x;
// x = x; // legal and useless: assigns the parameter
} // to itself; the field never changes
}
Without shadowing, this. is optional and usually omitted:
public double distanceFromOrigin() {
return Math.sqrt(x * x + y * y); // 'this.' implied
return Math.sqrt(this.x * this.x + this.y * this.y); // identical meaning
}
Use 2: passing the current object to something else.
public class Button {
public void register(EventBus bus) {
bus.subscribe(this); // hand over a reference to myself
}
public boolean sameAs(Button other) {
return this == other; // identity comparison
}
}
Use 3: constructor delegation, covered earlier in this chapter:
public Employee(String name) {
this(name, "Unassigned"); // must be the first statement
}
Use 4: returning the receiver for fluent chaining:
public class Query {
public Query where(String cond) { /* ... */ return this; }
public Query orderBy(String col) { /* ... */ return this; }
}
new Query().where("active").orderBy("name");
Use 5: reaching the enclosing instance from an inner class, where the
qualified form is needed because the inner class has its own this:
public class Outer {
private String name = "outer";
class Inner {
private String name = "inner";
void print() {
System.out.println(name); // "inner" -- nearest scope
System.out.println(this.name); // "inner" -- the Inner instance
System.out.println(Outer.this.name); // "outer" -- qualified this
}
}
}
Where this does not exist:
public static void staticMethod() {
// this.something; // ERROR: no object in a static context
}
A caution: do not leak this from a constructor. Registering an object
before its constructor finishes publishes a half-built object, which other code
(or another thread) may observe in an inconsistent state:
public class Leaky {
private final int value;
public Leaky(EventBus bus) {
bus.register(this); // DANGER: 'this' escapes before construction ends
this.value = compute(); // another thread could see value == 0
}
}
// Safer: finish construction, then register via a factory
public static Leaky create(EventBus bus) {
Leaky instance = new Leaky();
bus.register(instance); // fully constructed before anyone sees it
return instance;
}
This is the same hazard as calling an overridable method from a constructor, seen earlier: in both cases, code runs against an object that is not yet finished.
Key Takeaways
thisrefers to the object executing the current instance method or constructor.- Its main use is distinguishing a field from a parameter that shadows it.
this(...)delegates to another constructor and must come first.Outer.thisreaches the enclosing instance from an inner class.- Never publish
thisfrom a constructor; the object is not yet fully built.
🧪 Practice
- Write a constructor whose parameters shadow the fields, omit
this., and demonstrate that the fields stay at their defaults.- Write a fluent builder with three methods that each return
this.- Interview: What does
Outer.thismean, and when do you need it? (Hint: how manythisreferences are in scope inside an inner class?)
<a id="27-packages-and-code-organization"></a>
2.7 Packages and Code Organization
Classes need somewhere to live. Packages provide namespacing, an access-control boundary, and the primary structure of a Java project.
Package Declaration and Directory Structure
A package is a namespace for related types. It prevents name collisions, groups code by purpose, and defines the scope of package-private access.
package com.example.billing; // must be the FIRST statement in the file
public class Invoice { }
The directory rule. The package name must mirror the directory path, with each dot corresponding to a directory level:
project/
└── src/
└── com/
└── example/
├── billing/
│ ├── Invoice.java package com.example.billing;
│ └── TaxCalculator.java package com.example.billing;
└── shipping/
└── Parcel.java package com.example.shipping;
# Compile from the source root, writing classes into out/
javac -d out src/com/example/billing/Invoice.java
# Run using the FULLY QUALIFIED name
java -cp out com.example.billing.Invoice
The compiler enforces this for output: -d out recreates the package
directories under out. It is more lenient about input paths, but every build
tool and IDE assumes the mirror structure, so treat it as mandatory.
The fully qualified name is the package plus the class name, and it is the type's true identity. Two classes with the same simple name in different packages are entirely distinct:
java.util.Date // one class
java.sql.Date // a completely different class
The default package. Omitting the package declaration places a class in
the unnamed default package. This works for scratch files and is unsuitable for
anything else, because a class in the default package cannot be imported by any
packaged class:
// File: Helper.java, no package declaration
public class Helper { }
// File: com/example/App.java
package com.example;
// import Helper; // IMPOSSIBLE: there is no syntax for this
One public class per file, named identically to the file, as established earlier. Combined with the directory rule, this means the compiler can locate any class from its fully qualified name alone — no include paths, no header files, no build-order declarations.
com.example.billing.Invoice
|__________________| |_____|
| |
directory path file name
com/example/billing/ Invoice.java
Packages do not nest for access purposes. This surprises people: despite the
dotted names, com.example.billing and com.example.billing.internal are two
unrelated packages. A package-private member of one is invisible to the other.
package com.example.billing;
class Helper { } // package-private
package com.example.billing.internal;
// Helper h; // ERROR: not visible, despite the name
There is no "subpackage" concept in the access rules. The Java Platform Module System, discussed at the end of this section, was created partly to address this gap.
Key Takeaways
- The
packagedeclaration comes first and must mirror the directory structure.- The fully qualified name is the class's real identity; same-named classes in different packages are unrelated.
- Classes in the default package cannot be imported and should not be used beyond scratch work.
- The directory-name correspondence lets the compiler find any class from its name alone.
- Subpackages are not nested for access control; each package is independent.
🧪 Practice
- Create two packages with a class each, compile both with
javac -d out, and run one by its fully qualified name.- Put a class in the default package and try to use it from a packaged class.
- Create
com.example.aandcom.example.a.b, then verify that a package-private class in the first is invisible to the second.
Import Statements and Static Imports
An import does not copy code. It is purely a naming convenience, letting you
write a simple class name instead of a fully qualified one. The compiled result
is identical either way.
package com.example.app;
import java.util.List; // single-type import
import java.util.ArrayList;
import java.util.*; // on-demand import: all types in java.util
public class Demo {
List<String> names = new ArrayList<>(); // thanks to the imports
java.util.List<String> other = new java.util.ArrayList<>(); // always valid
}
Single-type versus on-demand. The wildcard form imports every type in a package, not subpackages, and has no runtime cost. Most style guides still prefer explicit imports, because they document dependencies and avoid the ambiguity below:
import java.util.*;
import java.sql.*;
// Date d = new Date(); // ERROR: ambiguous, both packages define Date
java.util.Date d = new java.util.Date(); // resolved by qualifying
// Or import one explicitly: a single-type import wins over a wildcard
import java.util.*;
import java.sql.*;
import java.util.Date; // this one now takes precedence
Date d = new Date(); // java.util.Date
java.lang is imported automatically, which is why String, System,
Integer, Math, and Object need no import. Classes in the same package need
no import either.
Static imports bring in static members rather than types:
import static java.lang.Math.PI;
import static java.lang.Math.sqrt;
import static java.util.Arrays.asList;
double circumference = 2 * PI * radius; // instead of Math.PI
double d = sqrt(x * x + y * y); // instead of Math.sqrt
List<String> l = asList("a", "b");
They shine in tests, where they make assertions read like prose:
import static org.junit.jupiter.api.Assertions.*;
assertEquals(expected, actual); // instead of Assertions.assertEquals(...)
assertTrue(list.isEmpty());
Used carelessly, they hurt: a bare max(a, b) gives the reader no clue whether
it came from Math, a utility class, or the current class. The guideline is to
static-import only where the origin is obvious from context, and never for
members with generic names.
| Import form | Example | Imports |
|---|---|---|
| Single type | import java.util.List; | One class |
| On demand | import java.util.*; | All types in a package |
| Single static member | import static java.lang.Math.PI; | One static member |
| Static on demand | import static java.lang.Math.*; | All static members |
What import cannot do:
// import java.util; // ERROR: cannot import a package itself
// import java.*.List; // ERROR: no wildcards in the middle
// import java.util.*.*; // ERROR: wildcards do not recurse into subpackages
That last one matters: import java.util.* does not import java.util.concurrent,
because subpackages are separate packages.
Ordering convention, enforced by most formatters:
package com.example.app; // 1. package
import java.util.List; // 2. java and javax
import java.util.Map;
import org.springframework.Bean; // 3. third-party libraries
import com.example.other.Thing; // 4. your own project
import static java.util.Arrays.asList; // 5. static imports last
Unused imports are harmless at runtime but are flagged by every linter, because they misrepresent a file's dependencies.
Key Takeaways
importis a naming shortcut with no runtime effect; fully qualified names always work.- Wildcard imports cover one package's types, never its subpackages.
java.langand the current package are imported implicitly.- A single-type import takes precedence over a conflicting wildcard import.
- Static imports suit tests and obvious constants, and obscure origins elsewhere.
🧪 Practice
- Write a class using both
java.util.Dateandjava.sql.Date, resolving the conflict two different ways.- Rewrite a class that uses
Mathrepeatedly to use static imports, and judge whether readability improved.- Interview: Does an unused import slow a program down? (Hint: what does the compiler actually emit for an import?)
Classpath Fundamentals
The classpath tells the JVM and the compiler where to look for .class files
and JARs. Nearly every "class not found" error is a classpath problem.
# A single directory
java -cp out com.example.App
# Several entries: colon-separated on Unix, semicolon on Windows
java -cp out:lib/gson.jar com.example.App
java -cp "out;lib\gson.jar" com.example.App
# Every JAR in a directory
java -cp "out:lib/*" com.example.App
# The default when -cp is omitted is the current directory
java com.example.App # equivalent to -cp .
How lookup works. Given the fully qualified name com.example.App, the JVM
searches each classpath entry in order for com/example/App.class:
-cp out:lib/gson.jar
looking for com.example.App
|
+--> out/com/example/App.class <- found here, search stops
+--> lib/gson.jar!/com/example/App.class
Order matters: the FIRST match wins.
That ordering rule is the cause of "JAR hell": two JARs containing different
versions of the same class produce whichever the classpath lists first, often
with confusing NoSuchMethodError failures later.
The two errors and what they mean:
| Error | Meaning |
|---|---|
ClassNotFoundException | A reflective lookup failed: Class.forName("...") |
NoClassDefFoundError | The class was present at compile time but missing at runtime, or its static initialiser failed earlier |
$ java -cp out com.example.App
Error: Could not find or load main class com.example.App
Caused by: java.lang.ClassNotFoundException: com.example.App
The usual causes, in order of frequency: the class name is not fully qualified, the classpath root is wrong (pointing at the package directory instead of the source root), or the code was never compiled.
# WRONG: pointing at the package directory
java -cp out/com/example App
# RIGHT: point at the ROOT, name the class fully
java -cp out com.example.App
JARs. A JAR is a ZIP archive of class files plus a manifest. An executable JAR names its entry point in the manifest:
jar --create --file app.jar --main-class com.example.App -C out .
java -jar app.jar # NOTE: -jar IGNORES -cp entirely
The -jar flag overrides the classpath, so dependencies must be declared in the
manifest's Class-Path attribute or bundled into the JAR. This catches people
out constantly.
In practice, build tools manage the classpath. Maven and Gradle resolve declared dependencies, download them, and construct the classpath for compilation, tests, and packaging:
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
Understanding the raw mechanism still matters, because when a build tool produces a classpath problem, the diagnosis happens at this level.
Key Takeaways
- The classpath lists directories and JARs searched for compiled classes.
- Entries are searched in order and the first match wins, which is the root of version conflicts.
- Point the classpath at the package root, and name classes fully.
ClassNotFoundExceptionis a failed reflective lookup;NoClassDefFoundErrormeans it vanished after compilation or failed to initialise.java -jarignores-cp; dependencies must be in the manifest or bundled.
🧪 Practice
- Compile a packaged class into
out, then try running it with the classpath pointing at the package directory and observe the error.- Build an executable JAR and run it with
-jar.- Interview: What is the difference between
ClassNotFoundExceptionandNoClassDefFoundError? (Hint: one involves a name resolved at runtime.)
Naming Conventions for Packages
Package names are the one part of Java naming that is globally shared, so the conventions exist to prevent collisions between organisations as well as within a project.
The rules:
- All lowercase, no underscores or camel case.
- Start with your organisation's domain name reversed.
- Continue with project and module names.
package com.example.billing.invoice;
|_________| |_____| |_____|
| | |
reversed domain project component
Organisation domain: example.com
Reversed: com.example
Plus project: com.example.shopfront
Plus component: com.example.shopfront.checkout
Reversing the domain works because domain ownership is already globally unique,
so two organisations cannot accidentally collide. If you own no domain, io.github.username
is the conventional choice for open-source projects.
Reserved and special prefixes:
| Prefix | Meaning |
|---|---|
java.* | Reserved for the Java Platform; do not use |
javax.* | Standard extensions, historically |
jdk.* | JDK-specific implementation packages |
sun.*, com.sun.* | Internal, unsupported, may vanish |
Writing a class in a java.* package is actively blocked by the classloader at
runtime, not merely discouraged.
Handling awkward domain names. Domains can contain characters illegal in identifiers, and may start with a digit or be a Java keyword:
| Domain | Package | Reason |
|---|---|---|
my-company.com | com.my_company | Hyphen becomes underscore |
123shop.com | com._123shop | Cannot start with a digit |
int.example.org | org.example._int | int is a keyword |
Structuring packages within a project. Two broad approaches exist:
BY LAYER (technical) BY FEATURE (functional)
com.example.app com.example.app
├── controller ├── billing
│ ├── OrderController │ ├── Invoice
│ └── UserController │ ├── InvoiceController
├── service │ └── InvoiceRepository
│ ├── OrderService ├── shipping
│ └── UserService │ ├── Parcel
└── repository │ ├── ShippingController
├── OrderRepository │ └── ShippingRepository
└── UserRepository └── shared
Packaging by feature is generally preferred for anything beyond a small project. Each package contains everything one capability needs, so a change is local, package-private access becomes genuinely useful (the repository can be hidden from other features), and the structure communicates what the application does rather than which frameworks it uses.
Packaging by layer spreads every change across three packages and makes package-private access nearly worthless, since collaborating classes are always in different packages.
Further conventions worth following: singular nouns (com.example.order,
not orders), short segments, and no deep hierarchies for their own sake. A
package named util or common tends to become a dumping ground; prefer a name
that says what the contents have in common.
Key Takeaways
- Package names are lowercase and begin with a reversed domain you control.
java.*is reserved and enforced at runtime;sun.*andcom.sun.*are internal.- Illegal characters become underscores, and digits or keywords need an underscore prefix.
- Package by feature rather than by layer for all but the smallest projects.
- Avoid
utilandcommonpackages; name by what the contents share.
🧪 Practice
- Derive the correct package name for an organisation owning
data-labs.co.ukbuilding a project called "atlas".- Restructure a small layered project into feature packages and note which classes could become package-private.
- Interview: Why does the convention reverse the domain name? (Hint: what problem does global uniqueness solve?)
Introduction to the Java Module System
Packages solve namespacing but not encapsulation between JARs. Any public class in any JAR on the classpath is callable by anything else, which means a library cannot have public internal code without exposing it. The Java Platform Module System, added in Java 9, addresses this.
The two problems it solves:
- No strong encapsulation across JARs. A class must be public to be used by another package in the same library, and public means public to the whole world.
- No reliable dependency declaration. The classpath is a flat list; missing or duplicated JARs are discovered at runtime, often late.
A module is a named collection of packages with an explicit declaration,
placed in a file called module-info.java at the source root:
module com.example.billing {
requires java.sql; // dependencies, checked at startup
requires transitive com.example.core; // consumers also get com.example.core
exports com.example.billing.api; // visible to everyone
exports com.example.billing.spi
to com.example.plugins; // visible to named modules only
// com.example.billing.internal is NOT exported: inaccessible outside,
// even though its classes are public
provides InvoiceFormatter // service registration
with com.example.billing.internal.PdfFormatter;
uses PaymentGateway; // service consumption
}
CLASSPATH MODULE PATH
+---------------------------+ +--------------------------------+
| everything.jar | | module com.example.billing |
| all public classes | | exports ...api |
| reachable by anything | | hides ...internal |
+---------------------------+ +--------------------------------+
| requires
dependencies discovered at +--------------------------------+
runtime, when something fails | module com.example.core |
+--------------------------------+
dependencies verified at startup
| Directive | Effect |
|---|---|
requires | This module depends on another |
requires transitive | Dependents automatically read that module too |
requires static | Needed at compile time, optional at runtime |
exports | Makes a package readable by other modules |
exports ... to | Qualified export to named modules only |
opens | Permits deep reflection, for frameworks |
provides ... with | Registers a service implementation |
uses | Declares consumption of a service |
javac -d out --module-source-path src $(find src -name "*.java")
java --module-path out --module com.example.billing/com.example.billing.Main
The practical situation in 2026. The JDK itself is fully modularised, which
is what makes jlink able to produce a runtime containing only the modules your
application uses — the basis of small container images. Adoption in application
code has been much more limited, and most projects still build on the classpath,
where they run in the unnamed module with the old rules intact.
The opens directive exists because frameworks such as Hibernate and Jackson
use reflection to access private members. Strong encapsulation blocks that by
default, which caused significant friction during the migration from Java 8.
For a class in this chapter's scope, the essential points are: modules add a layer above packages, they make internal packages genuinely internal, and they declare dependencies explicitly. Whether to modularise an application is a project-level decision, and using the classpath remains entirely legitimate.
Key Takeaways
- Modules add strong encapsulation between JARs, which packages alone cannot provide.
module-info.javadeclaresrequiresdependencies andexportspackages.- A public class in a non-exported package is inaccessible from other modules.
- Dependencies are verified at startup rather than discovered on failure.
- The JDK is modular, enabling
jlink; most application code still runs on the classpath in the unnamed module.
🧪 Practice
- Write a
module-info.javafor a module exporting one API package and hiding an internal one, then compile and run it.- Try to use a class from the non-exported package from another module and read the error.
- Interview: Why is
publicinsufficient for encapsulation across JARs, and what does a module add? (Hint: who can see a public class on the classpath?)
<a id="3-inheritance-and-polymorphism"></a>