Chapter 6: Objects and Classes
Everything in Java revolves around objects. From the smallest string to the most complex data structure, every entity is represented as an instance of a class. Classes define the blueprint; objects are the living instances created from that blueprint. Understanding how they work together is the foundation of object-oriented programming in Java.
Java was designed from the ground up as an object-oriented language. This means that instead of writing disconnected functions and data, you describe types of things (classes) that combine both state (fields) and behavior (methods). When your program runs, it creates objects from those classes, each with its own data but sharing the same structure and abilities.
Object-oriented design helps you organize code logically, reduce duplication, and build systems that scale. Once you learn how to define classes, construct objects, and control their visibility and interaction, you will be able to write programs that model real-world systems cleanly and efficiently.
In this chapter, you will learn how to declare and instantiate classes, define constructors and fields, control access with modifiers, and design immutable or encapsulated objects. You will also explore the most common methods that all Java objects share, such as toString(), equals(), and hashCode().
Declaring classes and creating objects
In Java, a class defines the structure and behavior of objects. It describes what data each object can hold (its fields) and what actions it can perform (its methods). A class acts as a blueprint from which any number of objects can be created, each with its own independent state.
public class Car {
String model;
int year;
void start() {
System.out.println("Engine started");
}
}
This simple Car class defines two fields and one method. To use it, you create an object (an instance of that class) using the new keyword.
Car myCar = new Car();
myCar.model = "Tesla Model 3";
myCar.year = 2025;
myCar.start();
Here, myCar is an object of type Car. The new keyword allocates memory for the object and calls a special method called a constructor (which you will learn about in the next section). Each object created from the class can store different data in its fields, even though they all share the same structure and methods.
The anatomy of a class
A class definition can include several kinds of members:
- Fields (or attributes): variables that store object data.
- Methods: blocks of code that perform operations or return results.
- Constructors: special methods that initialize new objects.
- Access modifiers: keywords that control visibility to other classes.
Here is a slightly more complete example:
public class Book {
String title;
String author;
int year;
void describe() {
System.out.println(title + " by " + author + " (" + year + ")");
}
}
When you create a new Book object, each field starts with its default value (null for strings, 0 for integers) until you assign something explicitly:
Book b = new Book();
b.title = "This is Java";
b.author = "Robin Nixon";
b.year = 2025;
b.describe();
This is Java by Robin Nixon (2025)
Objects, references, and memory
In Java, variables like myCar or b do not store the actual object but rather a reference to it. The object itself lives on the heap, and the variable points to its memory location. When a variable no longer refers to an object, Java’s garbage collector automatically reclaims that memory.
Book a = new Book();
Book b = a;
b.title = "Shared Object";
System.out.println(a.title); // Prints "Shared Object"
Both a and b refer to the same object in memory. Changing one affects the other, because they share a reference.
Public and package-level classes
Java source files can contain multiple classes, but only one may be declared public, and its name must match the filename. For example, a file named Car.java must contain a public class named Car. Additional classes in the same file are automatically given package-private access, meaning they are visible only within their package.
// File: Car.java
public class Car { }
class Engine { }
This restriction ensures predictable compilation and organization across large projects, where each public class forms a clear entry point for its file.
Fields, methods, and access modifiers
Every class combines fields (to hold data) and methods (to perform actions). Together they define what an object is and what it can do. Access modifiers control how those members are seen and used from other parts of the program.
Fields: storing object state
Fields (also known as instance variables) hold the data that represents an object’s state. Each object has its own independent copy of these values. Fields are usually declared at the top of the class, optionally with access modifiers and initial values.
public class Person {
private String name;
private int age;
public void showInfo() {
System.out.println(name + " is " + age + " years old.");
}
}
Here, name and age are private, meaning they can only be accessed from within the Person class itself. This enforces encapsulation by hiding internal details from the outside world.
Methods: defining behavior
Methods are blocks of code that define what an object can do. They can perform calculations, modify fields, or return information. Most methods operate on the fields of their own object, using the implicit reference this to access them if needed.
public class Counter {
private int count;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
Each time you call increment(), it increases the value of count stored inside that particular Counter object. Another instance of the same class will have its own count value unaffected by others.
private. Access restrictions apply only between different classes, not within the same one.
Access modifiers
Java uses access modifiers to control visibility of class members (fields and methods). These keywords define how accessible each part of your class is from other parts of the program:
| Modifier | Visibility |
public | Accessible everywhere |
protected | Accessible in subclasses and same package |
| default (no keyword) | Accessible only within the same package |
private | Accessible only within the same class |
Access modifiers apply both to fields and methods, and can be mixed within the same class to create clear, layered visibility. The usual pattern is to keep fields private and expose only controlled access through public methods.
public class BankAccount {
private double balance;
public void deposit(double amount) {
if (amount > 0) balance += amount;
}
public double getBalance() {
return balance;
}
}
private) and open up only when necessary. This keeps internal details hidden and reduces unintended interactions between parts of your program.
Class-level vs. instance-level members
Java distinguishes between instance members and class (static) members. Instance members belong to specific objects, while static members belong to the class itself and are shared across all objects.
public class Student {
private String name;
private static int count = 0;
public Student(String name) {
this.name = name;
count++;
}
public static int getCount() {
return count;
}
}
Student s1 = new Student("Alice");
Student s2 = new Student("Bob");
System.out.println(Student.getCount()); // 2
Here, each Student object has its own name, but they all share the same static count. Static members are ideal for values or methods that conceptually belong to the class rather than any particular instance.
Combining fields and methods effectively
A well-designed class uses fields to represent data and methods to manage that data safely. The goal is to provide a clear public interface that lets users of the class perform necessary operations without exposing unnecessary internal details.
public class Light {
private boolean on;
public void turnOn() {
on = true;
}
public void turnOff() {
on = false;
}
public boolean isOn() {
return on;
}
}
This structure keeps the on state private but offers methods to control and query it. External code can interact with the object without ever needing to know how it works internally.
Encapsulation and immutability
Encapsulation and immutability are two of the most important ideas in object-oriented programming. They determine how data is protected inside objects and how predictable those objects remain once created. Together, they form the foundation of safe, maintainable Java design.
Encapsulation: hiding internal details
Encapsulation means keeping an object’s internal data private and exposing it only through controlled methods. This prevents external code from directly changing an object’s internal state, which helps maintain consistency and prevents bugs.
public class Account {
private double balance;
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
}
}
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
}
}
public double getBalance() {
return balance;
}
}
Here, the balance field is private and cannot be changed directly from outside the class. All updates must go through the deposit() and withdraw() methods, which enforce rules and protect data integrity.
Encapsulation is usually achieved through the combination of private fields and public getter and setter methods. This pattern gives flexibility to change internal details without breaking external code.
public class Temperature {
private double celsius;
public void setCelsius(double c) {
if (c < -273.15) c = -273.15;
celsius = c;
}
public double getCelsius() {
return celsius;
}
public double getFahrenheit() {
return (celsius * 9 / 5) + 32;
}
}
Even if the internal storage changes in the future (for example, switching to Fahrenheit internally), external code using the getter and setter will continue to work exactly the same way.
public access. Once a field is public, any code can change it freely, making your class difficult to control or maintain safely.
Immutability: preventing modification
Immutability takes encapsulation one step further. An immutable object’s state cannot change once it is created. Instead of modifying fields, you create new objects whenever different data is needed. This approach simplifies reasoning about code and prevents side effects.
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
public Point move(int dx, int dy) {
return new Point(x + dx, y + dy);
}
}
Each time move() is called, it returns a new Point object instead of modifying the existing one. The original remains unchanged. This is the same principle used in classes such as String, which are immutable in Java.
Point p1 = new Point(3, 4);
Point p2 = p1.move(2, -1);
System.out.println(p1.getX()); // 3
System.out.println(p2.getX()); // 5
Here, p1 remains unchanged, demonstrating that immutability protects original data from alteration, even when passed between methods or threads.
When to use mutable vs. immutable objects
Not every class needs to be immutable. Mutable objects are useful when you need to update data frequently, such as in collections or models representing real-world entities. Immutable objects, on the other hand, are ideal for values that should never change after creation, such as onfiguration data, coordinates, or string values.
- Use mutable objects for entities that evolve during program execution.
- Use immutable objects for stable values that represent fixed data.
Java’s standard library mixes both approaches: String, Integer, and LocalDate are immutable, while ArrayList and HashMap are mutable.
final and all fields private and final. Also, avoid providing setter methods or any way to change internal state after construction.
Combining encapsulation and immutability
Encapsulation and immutability often work best together. Encapsulation hides how data is stored, while immutability ensures that it cannot be altered once set. Together, they create objects that are predictable, robust, and easy to reason about.
public final class User {
private final String username;
private final int id;
public User(String username, int id) {
this.username = username;
this.id = id;
}
public String getUsername() { return username; }
public int getId() { return id; }
}
Such classes are safe to share, simple to test, and immune to most synchronization issues. They embody the clean, disciplined design principles that make Java code long-lasting and dependable.
toString(), equals(), and hashCode()
Every Java class inherits three fundamental methods from the base Object class: toString(), equals(), and hashCode(). These methods control how objects describe themselves, compare for equality, and interact with hash-based collections like HashMap or HashSet. Understanding and overriding them correctly is an essential part of designing reliable classes.
The toString() method
The toString() method returns a human-readable representation of an object. By default, it produces a technical string such as Person@2a139a55, which is not very informative. Overriding it lets you provide meaningful descriptions that help with debugging and logging.
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name + " (" + age + ")";
}
}
Person p = new Person("Mary", 42);
System.out.println(p); // Prints: Mary (42)
Whenever an object is printed, concatenated with a string, or logged, Java automatically calls toString() behind the scenes. Clear, concise implementations make your output much easier to interpret.
toString() for classes you plan to inspect or log. Include only relevant information that describes the object’s state in a readable way.
The equals() method
The equals() method determines whether two objects are considered logically equal. The default implementation inherited from Object compares memory references, meaning two variables are only equal if they refer to the same instance. Most real-world classes, however, should compare based on their field values.
public class Book {
private String title;
private String author;
public Book(String title, String author) {
this.title = title;
this.author = author;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Book)) return false;
Book other = (Book) obj;
return title.equals(other.title) && author.equals(other.author);
}
}
Now, two Book objects with the same title and author are considered equal, even if they occupy different memory locations.
Book a = new Book("This is Java", "Robin Nixon");
Book b = new Book("This is Java", "Robin Nixon");
System.out.println(a.equals(b)); // true
instanceof checks before casting inside equals(). Failing to do so can cause ClassCastException errors or incorrect comparisons.
The hashCode() method
The hashCode() method returns an integer value used by hash-based collections to group and retrieve objects efficiently. Whenever you override equals(), you must also override hashCode() to ensure consistency. Otherwise, objects that are logically equal may not behave correctly in HashMap or HashSet.
@Override
public int hashCode() {
return Objects.hash(title, author);
}
The Objects.hash() utility (introduced in Java 7) simplifies generating hash codes from multiple fields. It ensures that objects with the same field values produce identical hash codes.
Set<Book> library = new HashSet<>();
library.add(a);
library.add(b);
System.out.println(library.size()); // 1
equals() and hashCode() is strict: if two objects are equal according to equals(), they must have the same hash code. Breaking this rule causes unpredictable behavior in collections.
Because a and b are equal and have the same hash code, the set stores only one entry, as expected.
Putting them together
Well-designed classes usually override all three methods together. Doing so makes them easier to inspect, compare, and use in collections. A concise, consistent implementation improves both performance and readability.
public class User {
private final String username;
private final int id;
public User(String username, int id) {
this.username = username;
this.id = id;
}
@Override
public String toString() {
return username + " [" + id + "]";
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User)) return false;
User other = (User) obj;
return id == other.id && username.equals(other.username);
}
@Override
public int hashCode() {
return Objects.hash(username, id);
}
}
This class now behaves predictably in every situation where comparison or printing is involved, such as collection membership, debugging, or logging.
toString(), equals(), and hashCode() methods for you. Use these tools to save time, but always review the generated code for clarity and correctness.