Java Classes & Objects

Series: Java Core for Beginners Lecture: 04 of 12 Topics: Class anatomy · Constructors · this keyword · Access modifiers · Static vs instance · Object lifecycle


From Primitives to Objects — Why We Need Classes

In the first three lectures, you worked with individual variables — a single int for a score, a String for a name, a double for a price. This works fine for simple programs, but real-world data is richer and more structured than that.

Consider a bank account. A bank account is not just a balance. It has an account number, an owner’s name, a balance, an account type, and behaviors — you can deposit money, withdraw money, and check the balance. To represent this in code, you need to group related data and the operations on that data together.

This is exactly what a class is: a blueprint that defines both the data (called fields) and the behavior (called methods) of a concept in your program.

An object is a concrete instance created from that blueprint. The class BankAccount is the design; a specific account belonging to Alice is an object — a live instance in memory with its own data.

This approach — bundling data and behavior into objects — is called Object-Oriented Programming (OOP). Java is built around this idea. Everything in Java (except primitive types) is an object, and understanding how to design good classes is the single most important skill in Java development.


Anatomy of a Class

A class in Java has four main components: fields, constructors, methods, and nested types (covered in later lectures). Let us build a BankAccount class step by step.

public class BankAccount {

    // --- Fields (instance variables) ---
    private String accountNumber;
    private String ownerName;
    private double balance;

    // --- Constructor ---
    public BankAccount(String accountNumber, String ownerName, double initialBalance) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = initialBalance;
    }

    // --- Methods ---
    public void deposit(double amount) {
        if (amount <= 0) {
            System.out.println("Deposit amount must be positive.");
            return;
        }
        balance += amount;
        System.out.println("Deposited: " + amount + ". New balance: " + balance);
    }

    public void withdraw(double amount) {
        if (amount <= 0) {
            System.out.println("Withdrawal amount must be positive.");
            return;
        }
        if (amount > balance) {
            System.out.println("Insufficient funds.");
            return;
        }
        balance -= amount;
        System.out.println("Withdrawn: " + amount + ". New balance: " + balance);
    }

    public double getBalance() {
        return balance;
    }

    public String getOwnerName() {
        return ownerName;
    }

    public String getAccountNumber() {
        return accountNumber;
    }

    // --- toString (used when printing an object) ---
    @Override
    public String toString() {
        return "BankAccount[" + accountNumber + ", owner=" + ownerName + ", balance=" + balance + "]";
    }
}

Fields

Fields (also called instance variables) are the data that each object carries. In BankAccount, every account has its own accountNumber, ownerName, and balance. These are declared at the class level, outside any method.

Fields have a type, a name, and optionally an initial value:

private String accountNumber;   // defaults to null
private double balance;         // defaults to 0.0
private boolean isActive = true; // explicit default

Unlike local variables inside a method, fields are automatically initialized to their type’s default value if you do not assign one (0 for numeric types, false for boolean, null for reference types).

Methods

Methods define what an object can do. They have a return type, a name, a parameter list, and a body:

public void deposit(double amount) {
    balance += amount;
}
  • public — visibility (covered in section 6)
  • void — this method returns nothing; use a concrete type (double, String, etc.) when the method should return a value
  • deposit — the method name (camelCase by convention)
  • (double amount) — the parameter list; amount is a local variable inside this method

A method that returns a value uses return:

public double getBalance() {
    return balance;
}

The return statement both specifies the value to hand back to the caller and immediately exits the method.

The toString method

toString() is a special method inherited from Object (Java’s root class). When you print an object with System.out.println(account), Java automatically calls toString() on it. If you do not override it, you get an ugly default like BankAccount@1b6d3586. Overriding it with something meaningful makes debugging far easier.

The @Override annotation above it tells the compiler you intend to override an inherited method — the compiler will catch a typo in the method name if you use @Override.


Creating Objects

A class is just a blueprint. To use it, you create an object (an instance) using the new keyword:

BankAccount aliceAccount = new BankAccount("ACC-001", "Alice", 1000.0);
BankAccount bobAccount   = new BankAccount("ACC-002", "Bob",   500.0);

Each call to new BankAccount(...) creates a separate object in memory with its own copy of accountNumber, ownerName, and balance. aliceAccount and bobAccount are independent — changing Alice’s balance does not affect Bob’s.

Calling methods on objects

Use the dot operator (.) to call a method on an object:

aliceAccount.deposit(500.0);       // Deposited: 500.0. New balance: 1500.0
aliceAccount.withdraw(200.0);      // Withdrawn: 200.0. New balance: 1300.0
System.out.println(aliceAccount.getBalance());  // 1300.0
System.out.println(aliceAccount);               // calls toString() automatically

Objects are reference types

Remember from Lecture 2: reference variables hold a memory address, not the object itself. This has important implications.

Assignment copies the reference, not the object:

BankAccount ref1 = new BankAccount("ACC-001", "Alice", 1000.0);
BankAccount ref2 = ref1;   // ref2 points to the SAME object as ref1

ref2.deposit(500.0);
System.out.println(ref1.getBalance());   // 1500.0 — ref1 sees the change too!

ref1 and ref2 are two variables pointing to the same object. There is only one BankAccount in memory.

null means no object:

BankAccount account = null;
account.deposit(100.0);   // NullPointerException — no object to call the method on

Always check for null before calling methods on a reference that might be unassigned.

Comparing objects with == compares references:

BankAccount a = new BankAccount("ACC-001", "Alice", 1000.0);
BankAccount b = new BankAccount("ACC-001", "Alice", 1000.0);

System.out.println(a == b);        // false — two different objects in memory
System.out.println(a.equals(b));   // false by default (unless you override equals)

To compare objects by their content, override the equals() method (covered in more advanced lectures).


Constructors

A constructor is a special method that runs automatically when an object is created with new. It sets up the initial state of the object.

Constructor rules

  • The constructor name must exactly match the class name
  • Constructors have no return type (not even void)
  • A class can have multiple constructors with different parameter lists (constructor overloading)

The default constructor

If you do not write any constructor, Java automatically provides a default constructor with no parameters:

public class Point {
    int x;
    int y;
    // Java provides: public Point() { } automatically
}

Point p = new Point();   // uses the default constructor

However, as soon as you define any constructor yourself, Java no longer provides the default. If you want a no-argument constructor alongside parameterized constructors, you must write it explicitly.

Constructor overloading

You can define multiple constructors as long as their parameter lists differ. This gives callers flexibility in how they create objects:

public class BankAccount {

    private String accountNumber;
    private String ownerName;
    private double balance;

    // Full constructor
    public BankAccount(String accountNumber, String ownerName, double initialBalance) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = initialBalance;
    }

    // Constructor with default balance of 0
    public BankAccount(String accountNumber, String ownerName) {
        this(accountNumber, ownerName, 0.0);   // delegates to the full constructor
    }

    // Constructor with a generated account number
    public BankAccount(String ownerName) {
        this("ACC-" + System.currentTimeMillis(), ownerName, 0.0);
    }
}

Notice this(...) in the last two constructors. This calls another constructor in the same class and must be the first statement in the constructor body. It avoids duplicating initialization logic.

Using constructors:

BankAccount a1 = new BankAccount("ACC-001", "Alice", 500.0);
BankAccount a2 = new BankAccount("ACC-002", "Bob");          // balance defaults to 0
BankAccount a3 = new BankAccount("Charlie");                  // number auto-generated

Constructor vs method

Aspect Constructor Method
Name Same as the class Any valid identifier
Return type None (not even void) Must declare a return type
Called by new keyword automatically Explicitly by the caller
Purpose Initialize the object’s state Define the object’s behavior

The this Keyword

this is a reference to the current object — the specific instance on which a method or constructor is being called. It has two primary uses.

Use 1: Resolving name conflicts between fields and parameters

When a constructor or method parameter has the same name as a field, this disambiguates:

public class Person {

    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;   // this.name = field; name = parameter
        this.age = age;
    }
}

Without this.name, writing name = name assigns the parameter to itself — the field is never set. This is a subtle, common bug if you forget this.

Use 2: Calling another constructor

As seen above, this(...) delegates to another constructor in the same class:

public BankAccount(String accountNumber, String ownerName) {
    this(accountNumber, ownerName, 0.0);   // must be first statement
}

Use 3: Passing the current object as an argument

Occasionally you need to pass the current object to another method or constructor:

public void registerWith(Bank bank) {
    bank.addAccount(this);   // passes the current BankAccount to the bank
}

Access Modifiers

Access modifiers control who can see and use a field, method, or constructor. Java has four levels:

Modifier Class Package Subclass Everywhere
private
(package-private)
protected
public

No modifier written = package-private (accessible only within the same package).

Encapsulation: private fields, public methods

The most important pattern in OOP is encapsulation: hide the internal data of a class behind private fields and expose controlled access through public methods.

public class BankAccount {

    private double balance;   // hidden — outside code cannot access directly

    // Controlled read access
    public double getBalance() {
        return balance;
    }

    // Controlled write access with validation
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit amount must be positive.");
        }
        balance += amount;
    }
}

Why does this matter? Consider what happens without encapsulation:

// Without encapsulation (field is public):
account.balance = -50000;   // sets a negative balance — no validation possible!

// With encapsulation (field is private):
account.balance = -50000;   // COMPILE ERROR — field is private
account.deposit(-50000);    // rejected by validation inside deposit()

Encapsulation enforces invariants — rules that the object’s data must always satisfy. No outside code can put the object into an invalid state because it can only interact through the class’s public interface.

Getters and setters

Methods that read a private field are called getters; methods that write to it are called setters. By convention:

// Getter — returns the field value
public String getOwnerName() {
    return ownerName;
}

// Setter — validates and sets the field value
public void setOwnerName(String ownerName) {
    if (ownerName == null || ownerName.isBlank()) {
        throw new IllegalArgumentException("Owner name cannot be blank.");
    }
    this.ownerName = ownerName;
}

// Boolean getter uses "is" prefix by convention
public boolean isActive() {
    return isActive;
}

Not every field needs a setter. If a field should not change after construction (like accountNumber), provide only a getter. Immutable fields make your objects safer and easier to reason about.


Static vs Instance Members

So far, every field and method you have seen belongs to a specific object — they are instance members. But sometimes data or behavior belongs to the class itself, not to any particular instance. These are static members.

Static fields (class variables)

A static field is shared across all instances of a class. There is only one copy in memory, regardless of how many objects exist:

public class BankAccount {

    private static int totalAccounts = 0;   // shared across all BankAccount objects
    private String accountNumber;
    private double balance;

    public BankAccount(String accountNumber, double balance) {
        this.accountNumber = accountNumber;
        this.balance = balance;
        totalAccounts++;   // increments the single shared counter
    }

    public static int getTotalAccounts() {
        return totalAccounts;
    }
}
BankAccount a1 = new BankAccount("ACC-001", 1000.0);
BankAccount a2 = new BankAccount("ACC-002", 500.0);
BankAccount a3 = new BankAccount("ACC-003", 750.0);

System.out.println(BankAccount.getTotalAccounts());   // 3

Notice that getTotalAccounts() is called on the class name (BankAccount.getTotalAccounts()), not on an instance. This is how static methods are typically called.

Static methods

A static method belongs to the class itself and can be called without creating an object. Static methods can only access other static members directly — they have no access to instance fields or instance methods because there is no this.

public class MathUtils {

    public static int add(int a, int b) {
        return a + b;
    }

    public static double circleArea(double radius) {
        return Math.PI * radius * radius;
    }
}
int sum = MathUtils.add(3, 4);           // 7
double area = MathUtils.circleArea(5.0); // 78.539...

This is the same pattern used by Java’s built-in Math class: Math.abs(), Math.max(), Math.sqrt() are all static methods.

Static constants

The most common use of static combined with final is defining constants that belong to the class:

public class Circle {

    public static final double PI = 3.141592653589793;
    public static final int MAX_RADIUS = 10_000;

    private double radius;

    // ...
}

Access them as Circle.PI and Circle.MAX_RADIUS. No object is needed.

When to use static vs instance

Use static when… Use instance when…
The data or behavior belongs to the class itself The data or behavior belongs to a specific object
No object state is needed to perform the operation The operation depends on the object’s fields
Utility or helper methods (Math, Collections) Core behavior of the class
Constants shared by all instances Per-object configuration

A common mistake is making everything static to avoid creating objects. Resist this — it defeats the purpose of object-oriented design and leads to code that is hard to test and extend.


Object Lifecycle and Garbage Collection

Understanding what happens to objects in memory helps you write more informed Java code.

Creation — the heap

When you write new BankAccount(...), Java allocates memory for the object on the heap — a large region of memory managed by the JVM. The new expression returns a reference (memory address) to the newly created object.

BankAccount account = new BankAccount("ACC-001", "Alice", 1000.0);
//                    ^---- object lives on the heap
//          ^----- reference lives on the stack (or in a field)

When objects become eligible for collection

An object is eligible for garbage collection when no variable in your program holds a reference to it — it is unreachable:

BankAccount account = new BankAccount("ACC-001", "Alice", 1000.0);
account = null;   // original object now unreachable — eligible for GC

account = new BankAccount("ACC-002", "Bob", 500.0);   // original still unreachable

Similarly, when a local variable goes out of scope (the method returns), any object it exclusively referenced becomes unreachable.

Garbage collection

The JVM’s garbage collector (GC) automatically reclaims memory from unreachable objects. You do not call free() or delete — Java handles this for you. The GC runs in the background, typically pausing application threads briefly to reclaim memory.

You cannot control exactly when the GC runs (and System.gc() is only a hint, not a command). This is acceptable for most applications. For systems with strict latency requirements, Java offers low-pause GC algorithms (ZGC, Shenandoah), but that is an advanced topic.

The key takeaway: you do not manage memory manually in Java. Create objects freely with new, use them, and let go of references when you are done. The GC does the rest.


Putting It Together — A Complete Example

Let us design a small Student class that applies everything covered in this lecture:

public class Student {

    // Static field — shared across all Student objects
    private static int totalStudents = 0;

    // Instance fields — private (encapsulation)
    private final String studentId;   // final: set once in constructor, never changes
    private String name;
    private double gpa;

    // Full constructor
    public Student(String studentId, String name, double gpa) {
        this.studentId = studentId;
        this.name = name;
        setGpa(gpa);   // reuse validation from the setter
        totalStudents++;
    }

    // Overloaded constructor — GPA defaults to 0.0
    public Student(String studentId, String name) {
        this(studentId, name, 0.0);
    }

    // Static factory method — alternative to constructor
    public static int getTotalStudents() {
        return totalStudents;
    }

    // Getters
    public String getStudentId() { return studentId; }
    public String getName()      { return name; }
    public double getGpa()       { return gpa; }

    // Setter with validation
    public void setGpa(double gpa) {
        if (gpa < 0.0 || gpa > 4.0) {
            throw new IllegalArgumentException("GPA must be between 0.0 and 4.0.");
        }
        this.gpa = gpa;
    }

    // Instance method — behavior
    public String getHonorsStatus() {
        if (gpa >= 3.9) return "Summa Cum Laude";
        if (gpa >= 3.7) return "Magna Cum Laude";
        if (gpa >= 3.5) return "Cum Laude";
        return "No honors";
    }

    // Override toString for readable output
    @Override
    public String toString() {
        return String.format("Student[id=%s, name=%s, gpa=%.2f, honors=%s]",
                studentId, name, gpa, getHonorsStatus());
    }
}

Using the Student class:

public class Main {

    public static void main(String[] args) {
        Student s1 = new Student("S001", "Alice", 3.92);
        Student s2 = new Student("S002", "Bob", 3.65);
        Student s3 = new Student("S003", "Charlie");   // GPA defaults to 0.0

        s3.setGpa(3.75);

        System.out.println(s1);   // Student[id=S001, name=Alice, gpa=3.92, honors=Summa Cum Laude]
        System.out.println(s2);   // Student[id=S002, name=Bob, gpa=3.65, honors=Cum Laude]
        System.out.println(s3);   // Student[id=S003, name=Charlie, gpa=3.75, honors=Magna Cum Laude]

        System.out.println("Total students: " + Student.getTotalStudents());   // 3

        // Attempting invalid GPA:
        // s1.setGpa(5.0);   // throws IllegalArgumentException
    }
}

Summary

This lecture introduced the core building blocks of object-oriented programming in Java.

  • A class is a blueprint defining the fields (data) and methods (behavior) of a concept. An object is a live instance of that class in memory, created with new.
  • Fields are the data each object carries. Methods define what an object can do. The toString() method provides a readable text representation.
  • Constructors initialize an object’s state when it is created. They can be overloaded to provide multiple ways of constructing an object. this(...) delegates between constructors.
  • The this keyword refers to the current object, primarily used to disambiguate field names from parameter names.
  • Access modifiers (private, public, protected, package-private) control visibility. Encapsulation — private fields + public methods — is the cornerstone of safe class design.
  • Static members belong to the class itself and are shared across all instances. Instance members belong to individual objects. Static methods have no this and cannot access instance state.
  • Java’s garbage collector automatically reclaims memory from unreachable objects. You do not manage memory manually.

Exercises

Exercise 1 — Rectangle class Design a Rectangle class with width and height fields (both private double). Include a full constructor, getters for both fields, and methods area(), perimeter(), and isSquare(). Override toString() to produce output like Rectangle[width=5.0, height=3.0]. Create three rectangles in main and print their area, perimeter, and whether they are squares.

Exercise 2 — Constructor overloading Extend the Rectangle class with a second constructor that takes a single argument and creates a square (width == height). Use this(...) to delegate. Verify both constructors work correctly.

Exercise 3 — Counter class with static field Create a Counter class with a private int count instance field and a private static int instanceCount static field. Provide methods increment(), decrement(), reset(), and getCount(). The instanceCount should track how many Counter objects have ever been created. Demonstrate with three separate Counter objects that each has its own count but they all share the same instanceCount.

Exercise 4 — Temperature converter Create a Temperature class that stores a value in Celsius. Provide:

  • A constructor that takes a double celsius value
  • toCelsius(), toFahrenheit(), and toKelvin() instance methods
  • A static factory method fromFahrenheit(double f) that creates a Temperature from a Fahrenheit value
  • A static factory method fromKelvin(double k)
  • A toString() that prints the temperature in all three scales

Exercise 5 — Encapsulation in practice Create a Person class with private fields name (String), age (int), and email (String). Add validation in the setters:

  • name must not be null or blank
  • age must be between 0 and 150
  • email must contain @

Each invalid input should print an error message (do not throw exceptions yet — that is Lecture 10). Demonstrate that invalid values are rejected while valid ones are accepted.


Up next: Lecture 5 — Inheritance & Polymorphism — where you will learn how classes can extend one another to share and specialize behavior, and how polymorphism lets you write code that works with many types through a single interface.

Leave a Reply

Your email address will not be published. Required fields are marked *