<< Home

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.

💡 Think of a class as a template or recipe, and an object as the finished dish. You can create many objects from the same class, each with its own unique values but identical structure and behavior.

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().

⚠️ Although Java allows procedural code through static methods, real power comes from mastering its class and object model. Once you think in terms of objects (how they represent things and interact with one another) your code becomes far more modular, maintainable, and expressive.

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.

💡 You can think of an object as a container that holds both data and behavior. The fields hold information about the object, while the methods define what it can do.

The anatomy of a class

A class definition can include several kinds of members:

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.

⚠️ Assigning one object variable to another does not create a copy of the object. It only copies the reference. To make an actual duplicate, you must explicitly create a new object and copy its fields.

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.

💡 It is common practice for each class to live in its own file, with the filename matching the class name. This keeps code modular and easier to navigate.

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.

💡 Fields represent the long-term memory of an object. When a program manipulates an object, it’s usually reading or updating one or more of its fields.

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.

⚠️ Methods can access all fields of their own object, even if those fields are 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:

ModifierVisibility
publicAccessible everywhere
protectedAccessible in subclasses and same package
default (no keyword)Accessible only within the same package
privateAccessible 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;
  }
}
💡 Start with the most restrictive access level (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.

⚠️ Static methods cannot directly access instance fields, because they operate without an object context. They can only use other static fields or methods.

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.

💡 The cleanest classes expose what they can do, not how they do it. By pairing fields with well-defined methods, you make your code robust, flexible, and easy to maintain.

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 not just about hiding data—it’s about maintaining control. By defining how other code interacts with your objects, you keep your program reliable and easier to extend later.

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.

⚠️ Avoid exposing fields directly with 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.

💡 Immutable objects are naturally thread-safe because their state never changes. They can be freely shared between parts of a program without the risk of interference.

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.

Java’s standard library mixes both approaches: String, Integer, and LocalDate are immutable, while ArrayList and HashMap are mutable.

⚠️ If you make a class immutable, remember to mark it 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.

💡 The best rule of thumb is this: make objects immutable whenever possible, and encapsulate data rigorously when immutability is not practical.

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.

💡 Always override 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
⚠️ Always use 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
💡 The contract between 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.

⚠️ Avoid basing equality on mutable fields. If an object’s fields change after being placed in a hash-based collection, its hash code may also change, making it effectively unreachable. This can cause serious and subtle bugs.

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.

💡 Modern IDEs can automatically generate toString(), equals(), and hashCode() methods for you. Use these tools to save time, but always review the generated code for clarity and correctness.