Defining a class

A class is a blueprint for objects with the same shape and behavior. The general ideas (objects, encapsulation, inheritance, polymorphism) are explained in Object-Oriented Concepts; this page shows how Java does it. Java is object-oriented to the core: every method lives in a class, and even a compact source file with a bare void main() is compiled into a hidden class.

A class lists its members. A field is a variable declared in the class body: each object gets its own copy (an instance field, also called an instance variable), and it lives as long as the object does. A method is a function declared in the class that works on the object's fields. You create an object with new and reach its members with a dot.

public class Main {
    public static void main(String[] args) {
        Meter delft = new Meter();
        delft.id = "E-001";
        delft.city = "Delft";
        delft.addReading(18412);
        delft.addReading(18745);

        System.out.println(delft.id + " in " + delft.city + ": " + delft.usage() + " kWh");
    }
}

class Meter {
    String id;
    String city;
    int firstReading;
    int lastReading;
    int count;

    void addReading(int kwh) {
        if (count == 0) {
            firstReading = kwh;
        }
        lastReading = kwh;
        count++;
    }

    int usage() {
        return lastReading - firstReading;
    }
}
E-001 in Delft: 333 kWh

The file holds two classes: Main with the main method that starts the program, and Meter below it. Inside a method, the fields of the current object are available by name (count); you may write this.count to make that explicit. By convention, class names use PascalCase (Meter), and fields, methods and local variables use camelCase (firstReading, addReading).

Default field values

Fields that you do not initialize get a default value: 0 for numbers, false for boolean, '\u0000' for char, and null for every reference type, such as String. Local variables inside a method get no default: reading one before assigning it is a compile error (see Variables, Types and Operators).

public class Main {
    public static void main(String[] args) {
        Meter m = new Meter();
        System.out.println(m.id);
        System.out.println(m.count);
        System.out.println(m.active);
    }
}

class Meter {
    String id;
    int count;
    boolean active;
}
null
0
false

A field that is still null is the classic source of a NullPointerException later on; the constructor is the place to give every field a real value (see Null, Optional and NullPointerException).

Variables hold references

A class type is a reference type: the object lives on the heap, and a variable holds a reference to it. Assigning one variable to another copies the reference, not the object, so both names point to the same meter. And == on two objects asks "is this the same object?", not "do they contain the same data?"

public class Main {
    public static void main(String[] args) {
        Meter a = new Meter();
        a.id = "E-001";
        Meter b = a;
        b.id = "E-099";
        System.out.println(a.id);

        Meter c = new Meter();
        c.id = "E-099";
        System.out.println(a == b);
        System.out.println(a == c);
    }
}

class Meter {
    String id;
}
E-099
true
false
Stack and heap: the local variables a and b on the stack both refer to one Meter object with id E-099 on the heap; the variable c refers to a second Meter object, also with id E-099. Stack (main) Heap a b c Meter id = "E-099" Meter id = "E-099" a == b is true (same object); a == c is false (equal data, two objects)
After Meter b = a; there is still one object. Changing b.id changes what a sees, because both references lead to the same place.

To compare objects by their content, a class overrides equals; that is covered further down this page. Passing an object to a method copies the reference in the same way, which is why a method can change the object you passed but cannot make your variable point somewhere else (see Methods and Parameters).

Constructors

A constructor is a special method with the same name as the class and no return type, not even void. It runs once, when new creates the object, and its job is to leave the object in a valid state: check the arguments and set every field that needs a value. When a parameter has the same name as a field, this.name means the field and plain name the parameter.

A class can have several constructors with different parameter lists (overloading, see Methods and Parameters). this(...) as the first statement calls another constructor of the same class, so the checks live in one place.

public class Main {
    public static void main(String[] args) {
        Customer home = new Customer("C-10042", "Mei Chen", "Utrecht");
        Customer unknown = new Customer("C-10043", "Ravi Kumar");

        System.out.println(home.describe());
        System.out.println(unknown.describe());
        new Customer("C-10044", " ");
    }
}

class Customer {
    String id;
    String name;
    String city;

    Customer(String id, String name, String city) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("customer " + id + " needs a name");
        }
        this.id = id;
        this.name = name;
        this.city = city;
    }

    Customer(String id, String name) {
        this(id, name, "unknown");
    }

    String describe() {
        return id + " " + name + " (" + city + ")";
    }
}
C-10042 Mei Chen (Utrecht)
C-10043 Ravi Kumar (unknown)
Exception in thread "main" java.lang.IllegalArgumentException: customer C-10044 needs a name

Throwing IllegalArgumentException from a constructor means no half-built customer ever exists: new either returns a valid object or fails. Exceptions are explained on Exceptions and Error Handling.

The default constructor

A class without any constructor gets a default constructor: an invisible constructor without parameters, added by the compiler, which only calls the parent class's no-argument constructor. That is why new Meter() worked in the first examples. As soon as you write any constructor yourself, the default one is gone, and new Customer() becomes a compile error: the class now insists on a name.

public class Main {
    public static void main(String[] args) {
        Customer c = new Customer();
        System.out.println(c.name);
    }
}

class Customer {
    String name;

    Customer(String name) {
        this.name = name;
    }
}
Main.java:3: error: constructor Customer in class Customer cannot be applied to given types;

If you still want a no-argument constructor next to the others, write it explicitly. Frameworks that create objects for you (for example JSON libraries and persistence frameworks) often need one, which is why you meet empty constructors in real code.

Access modifiers and encapsulation

An access modifier is the keyword in front of a class or member that decides which code may use it. Java has three keywords and a fourth level without a keyword. Access modifiers are how Java enforces encapsulation: the compiler refuses access from outside, so a class can protect its data and change its internals later without breaking callers.

Modifier Who can use the member
public Any code.
protected Code in the same package, and subclasses in any package (see Inheritance, Interfaces and Polymorphism).
(none) Code in the same package only: package-private access. The default.
private Only code inside the same class.

Package-private access is what a member or class gets when you write no modifier at all: every class in the same package may use it, and nothing outside. That is a deliberate middle ground for helper classes that cooperate inside one package, but it is not private. A top-level class can only be public or package-private, and a source file may contain at most one public top-level class, named like the file (Main in Main.java).

public class Main {
    public static void main(String[] args) {
        Meter meter = new Meter("E-001", "KE-7781-0042");
        System.out.println(meter.id);
        System.out.println(meter.serial);
    }
}

class Meter {
    final String id;
    private final String serial;

    Meter(String id, String serial) {
        this.id = id;
        this.serial = serial;
    }
}
Main.java:5: error: serial has private access in Meter

meter.id compiles because Main and Meter are in the same (unnamed) package; meter.serial does not. The final on both fields means they must be assigned exactly once, in the constructor, and can never change afterward.

Getters and setters

Java has no properties. Data that other classes may read is exposed through ordinary methods: a getter named getName() (or isActive() for a boolean) and, only when the value may change, a setter named setName(...). This naming convention comes from the JavaBeans specification, and many frameworks and libraries rely on it to find your data.

import java.math.BigDecimal;

public class Main {
    public static void main(String[] args) {
        Tariff t = new Tariff("Green Fixed", new BigDecimal("0.31"));
        System.out.println(t.getName() + ": " + t.getPricePerKwh() + " per kWh");
        t.setPricePerKwh(new BigDecimal("0.29"));
        System.out.println(t.getName() + ": " + t.getPricePerKwh() + " per kWh");
        t.setPricePerKwh(new BigDecimal("-0.05"));
    }
}

class Tariff {
    private final String name;
    private BigDecimal pricePerKwh;

    Tariff(String name, BigDecimal pricePerKwh) {
        this.name = name;
        this.pricePerKwh = checkPrice(pricePerKwh);
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPricePerKwh() {
        return pricePerKwh;
    }

    public void setPricePerKwh(BigDecimal pricePerKwh) {
        this.pricePerKwh = checkPrice(pricePerKwh);
    }

    private static BigDecimal checkPrice(BigDecimal price) {
        if (price.signum() < 0) {
            throw new IllegalArgumentException("price cannot be negative: " + price);
        }
        return price;
    }
}
Green Fixed: 0.31 per kWh
Green Fixed: 0.29 per kWh
Exception in thread "main" java.lang.IllegalArgumentException: price cannot be negative: -0.05

The name has a getter but no setter, so it can never change. The price can change, but only through setPricePerKwh, which runs the same check as the constructor. That is the point of a setter: one place where every change passes a rule. Money is a BigDecimal, not a double, for the reasons on Variables, Types and Operators.

Static members

Most members belong to an object: each meter has its own id. A static field or method belongs to the class itself and is shared by all objects; you reach it through the class name (Ticket.created()), not through an instance. The keyword itself is explained with static methods on Methods and Parameters.

public class Main {
    public static void main(String[] args) {
        Ticket first = new Ticket("No power in Leiden");
        Ticket second = new Ticket("Smart meter offline");

        System.out.println(first.number + ": " + first.summary);
        System.out.println(second.number + ": " + second.summary);
        System.out.println(Ticket.created() + " tickets");
        System.out.println(Ticket.PREFIX);
    }
}

class Ticket {
    static final String PREFIX = "INC-";
    private static int nextNumber = 40112;
    private static int created = 0;

    final String number;
    final String summary;

    Ticket(String summary) {
        this.number = PREFIX + nextNumber++;
        this.summary = summary;
        created++;
    }

    static int created() {
        return created;
    }
}
INC-40112: No power in Leiden
INC-40113: Smart meter offline
2 tickets
INC-

There is one nextNumber for the whole class, so every new ticket takes the next number. A static method has no this: it cannot touch instance fields unless it is given an object. That is also why the classic public static void main cannot call an instance method of Main directly.

A static final field is the Java way to write a constant; by convention its name is in UPPER_SNAKE_CASE. A class that only holds static helpers, like the JDK's Math, usually gets a private constructor so nobody can create an instance of it.

toString: printing objects

Every class inherits from java.lang.Object, and with it three methods you will often override: toString(), equals(Object) and hashCode(). The toString() method returns the text form of an object; System.out.println and string concatenation with + call it automatically. The version inherited from Object returns the class name, an @ and a hexadecimal hash code, which differs from run to run and tells a reader nothing:

Meter@5ca881b5

Override it to give objects a useful text form. The @Override line is an annotation that asks the compiler to check you really replace an inherited method (see Overriding).

public class Main {
    public static void main(String[] args) {
        Meter meter = new Meter("E-001", 412.5);
        System.out.println(meter);
        System.out.println("Last reading: " + meter);
    }
}

class Meter {
    private final String id;
    private final double kwh;

    Meter(String id, double kwh) {
        this.id = id;
        this.kwh = kwh;
    }

    @Override
    public String toString() {
        return id + " (" + kwh + " kWh)";
    }
}
E-001 (412.5 kWh)
Last reading: E-001 (412.5 kWh)

toString() is the Java counterpart of Python's __str__. Keep it short and free of side effects, because log statements and debuggers call it too, and leave secrets such as passwords out of it: toString() output ends up in log files.

equals and hashCode

== compares references. For "do these two objects mean the same thing?", a class overrides equals(Object). Two customer objects with the same customer number should count as the same customer, even when they were loaded separately.

Hash-based collections such as HashSet and HashMap (see Arrays and Collections) use a second method first. The hashCode() method returns an int that a hash table uses to pick a bucket; only objects in the same bucket are compared with equals. The contract is: objects that are equal according to equals must return the same hash code. Override only equals, and the inherited hashCode() still gives every object its own value:

import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Customer a = new Customer("C-10042", "Mei Chen");
        Customer b = new Customer("C-10042", "Mei Chen");
        System.out.println(a == b);
        System.out.println(a.equals(b));

        Set<Customer> vip = new HashSet<>(List.of(a));
        System.out.println(vip.contains(b));
    }
}

class Customer {
    private final String id;
    private final String name;

    Customer(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Customer other && id.equals(other.id);
    }
}
false
true
false

equals says yes, yet the set says it does not contain the customer: it looked in the wrong bucket and never called equals. The fix is a hashCode() built from the same fields that equals compares; Objects.hash(...) combines them for you.

import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Customer a = new Customer("C-10042", "Mei Chen");
        Customer b = new Customer("C-10042", "Mei Chen");
        System.out.println(a.equals(b));
        System.out.println(a.hashCode() == b.hashCode());

        Set<Customer> vip = new HashSet<>(List.of(a));
        System.out.println(vip.contains(b));
    }
}

class Customer {
    private final String id;
    private final String name;

    Customer(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Customer other)) return false;
        return id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
true
true
true

The o instanceof Customer other test checks the type and gives you a typed variable in one step (pattern matching, see Control Flow and Switch Expressions); it is also false for null, so equals(null) correctly returns false. Base both methods on fields that do not change: if a field used in hashCode() changes while the object sits in a HashSet, the set can no longer find it.

import java.util.HashSet;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        Customer a = new Customer("C-10042");
        Customer b = new Customer("C-10042");
        System.out.println(a.equals(b));

        Object asObject = b;
        System.out.println(a.equals(asObject));
        System.out.println(new HashSet<>(List.of(a)).contains(b));
    }
}

class Customer {
    final String id;

    Customer(String id) {
        this.id = id;
    }

    public boolean equals(Customer other) {
        return other != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}
true
false
false

Writing these three methods by hand is repetitive and easy to get wrong. For classes that are just data, a record generates equals, hashCode and toString from its components, and IDEs can generate them for ordinary classes.

Designing a class

A few habits keep classes easy to use and hard to misuse:

  • Make invalid states impossible. Check arguments in the constructor and in setters, and give every field a valid value, so an object is correct from the moment it exists.
  • Expose as little as possible. Start with private fields and no setters; widen access only when another class really needs it.
  • Prefer immutable objects. Make fields final when they should not change after creation. Immutable objects are simpler to reason about and safe to share between threads.
  • Change state through methods with a meaning. ticket.assignTo("Amara Okafor") says what happens and keeps related fields consistent; two separate setter calls do not.
  • One class, one job. A Meter that also sends invoices and writes log files is hard to test and to change; split responsibilities over small classes.