A Java class describes what an object holds and what it can do, and a constructor decides how each object starts life. Learn fields, this, constructors, private, static and final by running small programs.
A class describes a kind of thing: what data it holds and what it can do. An object is one of those things, made from the class with new. Most of the Java you’ll read is classes talking to objects, so the rules for making and protecting objects matter everywhere.
This post covers fields and methods, this, toString, constructors and how they chain, the Java 25 change that lets code run before super(...), private and the other access levels, static, and final. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
A class is a blueprint, and new makes an object
A class declares fields, which hold each object’s data, and methods, which act on that data. Here’s a Player with a name and a score:
class Player {
String name;
int score;
void addPoints(int points) {
score = score + points;
}
}
void main() {
var ana = new Player();
ana.name = "Ana";
ana.addPoints(10);
ana.addPoints(5);
var ben = new Player();
ben.name = "Ben";
ben.addPoints(7);
IO.println(ana.name + " has " + ana.score);
IO.println(ben.name + " has " + ben.score);
}
It prints:
Ana has 15
Ben has 7
new Player() creates a fresh object, and each object gets its own copy of name and score. Adding points to ana doesn’t touch ben. A new object’s fields start at a default value: 0 for numbers, false for boolean, and null for references like String.
Inside addPoints, score means the score of whichever object the method was called on. For ana.addPoints(10) that’s Ana’s score.
this is the object the method was called on
The keyword this names the current object, and you need it when a parameter has the same name as a field. Forget it and the code still compiles:
class Player {
String name;
void rename(String name) {
name = name;
}
void renameProperly(String name) {
this.name = name;
}
}
void main() {
var p = new Player();
p.rename("Ana");
IO.println("after rename: " + p.name);
p.renameProperly("Ana");
IO.println("after renameProperly: " + p.name);
}
It prints:
after rename: null
after renameProperly: Ana
In rename, both sides of name = name are the parameter. The parameter hides the field, so the method copies the parameter onto itself and the field stays null. Even javac -Xlint:all doesn’t warn about it. Writing this.name says “the field on this object”, and that’s the version that works.
Printing an object: override toString
When you print an object, Java calls its toString() method, and the default one isn’t meant for people. Print a Player with no toString and you get something like Main$Player@ followed by eight hex digits.
The part before @ is the class name. It reads Main$Player because, in a file with a bare void main(), your classes are placed inside a hidden class called Main. That detail comes back in the section on private. The part after @ is the object’s identity hash code in hex. It isn’t a memory address and it isn’t based on the fields. The JVM makes it up the first time something asks for it.
Because the JVM makes it up, you can’t rely on it. When we ran the same file five times with java Main.java, it printed the same hex digits each time. When we compiled the same file with javac and ran the class, the digits were different. A different JVM, a different launch mode, or one more object hashed earlier can change it. That’s why no output in this series ever shows one.
Write your own toString and printing becomes useful and predictable:
class Player {
String name;
int score;
Player(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Player[" + name + ", " + score + "]";
}
}
void main() {
var ana = new Player("Ana", 15);
IO.println(ana);
IO.println("winner: " + ana);
}
It prints:
Player[Ana, 15]
winner: Player[Ana, 15]
toString must be public, because it overrides a public method in Object, the class every class extends. @Override asks the compiler to check that you really are overriding something. Misspell it as toSting and the build fails instead of quietly printing the hash. String concatenation calls toString too, which is why "winner: " + ana works.
That program also used a constructor, Player(String name, int score). Constructors are next.
Constructors set up a new object
A constructor is the code that runs when you write new. It has the class’s name and no return type, and its job is to put the object into a valid starting state.
Overloading and chaining with this(...)
A class can have several constructors, as long as their parameter lists differ. That’s called overloading. One constructor can call another with this(...), so the real setup lives in one place:
class Pizza {
String size;
int slices;
Pizza(String size, int slices) {
this.size = size;
this.slices = slices;
}
Pizza(String size) {
this(size, size.equals("large") ? 12 : 8);
}
Pizza() {
this("medium");
}
@Override
public String toString() {
return size + " pizza, " + slices + " slices";
}
}
void main() {
IO.println(new Pizza("small", 6));
IO.println(new Pizza("large"));
IO.println(new Pizza());
}
It prints:
small pizza, 6 slices
large pizza, 12 slices
medium pizza, 8 slices
new Pizza() calls this("medium"), which calls this("medium", 8), which sets the fields. The compiler picks a constructor by the arguments you pass, the same way it picks between overloaded methods.
The free constructor disappears once you write one
If a class has no constructor at all, Java gives it an empty one that takes no arguments. That’s why new Player() worked in the first program. Write any constructor yourself and the free one is gone:
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
void main() {
var origin = new Point();
IO.println(origin.x);
}
The build fails with:
Main.java:12: error: constructor Point in class Main.Point cannot be applied to given types;
var origin = new Point();
^
javac adds that it required int,int and found no arguments. This is deliberate. Once you’ve said a Point needs two numbers, Java won’t make one without them. If you want both, write the no-argument constructor yourself, ideally as this(0, 0).
Validate in the constructor
A constructor is the one place every object passes through, so it’s the right place to reject bad input. Throw IllegalArgumentException and the object is never created:
class Temperature {
double celsius;
Temperature(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("below absolute zero: " + celsius);
}
this.celsius = celsius;
}
}
void main() {
var room = new Temperature(21.5);
IO.println("room is " + room.celsius);
var impossible = new Temperature(-300);
IO.println("never printed " + impossible.celsius);
}
It prints, then stops:
room is 21.5
Exception in thread "main" java.lang.IllegalArgumentException: below absolute zero: -300.0
-300 became -300.0 in the message because the parameter is a double. Nothing ever holds a Temperature below absolute zero, so no other method has to check.
Code before super(...): new in Java 25
A constructor in a subclass has to call a constructor of its parent class, with super(...). For most of Java’s history, that call had to be the very first statement. Java 25 made flexible constructor bodies final, and now you can run code before it.
That fixes an old annoyance. You often want to check an argument before handing it to the parent, and you used to have to squeeze the check into a static helper called inside the super(...) arguments. Now you write it plainly:
class Shape {
String name;
Shape(String name) {
this.name = name;
IO.println("Shape constructor for " + name);
}
}
class Square extends Shape {
int side;
Square(int side) {
if (side <= 0) {
throw new IllegalArgumentException("side must be positive: " + side);
}
String label = "square " + side;
super(label);
this.side = side;
IO.println("Square constructor, area " + side * side);
}
}
void main() {
var sq = new Square(3);
IO.println(sq.name + " is ready");
}
It prints:
Shape constructor for square 3
Square constructor, area 9
square 3 is ready
The check and the local variable label run first, then super(label) runs the Shape constructor, then the rest of Square‘s constructor. A Square(0) throws before Shape is ever involved. The same rule applies to this(...).
We checked that it’s really new. The same class, compiled with javac --release 24, fails with flexible constructors is not supported in -source 24. That test used a classic public class Main, because --release 24 also rejects the bare void main() form.
You still can’t touch this before super(...)
The code before super(...) can use parameters and local variables, but not the object being built. Its parent part doesn’t exist yet:
class Shape {
Shape(String name) {
IO.println("Shape " + name);
}
}
class Square extends Shape {
int side = 1;
Square(int side) {
IO.println("old side was " + this.side);
super("square");
this.side = side;
}
}
void main() {
new Square(3);
}
The build fails with:
Main.java:11: error: cannot reference this before supertype constructor has been called
IO.println("old side was " + this.side);
^
Reading a field, calling an instance method, or passing this somewhere are all refused before super(...). There’s one exception: you may assign a field there, as long as it has no initializer. That lets a subclass set its own fields before the parent constructor can see them.
private keeps the rules in one place
Java has four access levels, and private is the one you’ll use most for fields. A private member can only be used inside its own class. Here’s why that matters. A bank account’s balance should never go negative, and if anyone can write to balance, anyone can break that rule:
class BankAccount {
private int balance;
void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balance += amount;
}
boolean withdraw(int amount) {
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
int balance() {
return balance;
}
}
void main() {
var account = new BankAccount();
account.deposit(100);
IO.println("withdraw 30: " + account.withdraw(30));
IO.println("withdraw 500: " + account.withdraw(500));
IO.println("balance: " + account.balance());
}
It prints:
withdraw 30: true
withdraw 500: false
balance: 70
Every change to the balance goes through deposit or withdraw, and both check the rule. The balance() method lets anyone read it without letting them write it. That’s encapsulation: the class owns its data, and the only way in is through methods that keep it valid. If the rule changes, say to allow an overdraft, you change one class.
In an ordinary Java file, the compiler enforces this. This program uses the classic public class Main, so BankAccount is a real top-level class next to it:
class BankAccount {
private int balance = 70;
}
public class Main {
public static void main(String[] args) {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
}
The build fails with:
Main.java:8: error: balance has private access in BankAccount
account.balance = -1000;
^
A surprise in compact source files
Remove public class Main and use a bare void main(), and the same access compiles. This surprised us, so we ran it:
class BankAccount {
private int balance = 70;
}
void main() {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
It prints:
-1000
The reason is the hidden class from the toString section. In a compact source file, Java wraps everything, including BankAccount, in an implicit class. So BankAccount isn’t top-level at all. It’s a class nested inside that implicit class, which is why the default toString output starts with Main$. Java lets code anywhere inside one top-level class use the private members of every class nested in it.
So in the single-file programs in this series, private doesn’t stop main from reaching in. It still documents your intent, and it becomes a hard wall the moment the class moves to its own file. Keep writing it.
Package-private and public
The other levels matter once a program has several files. With no modifier, a member is package-private: any class in the same package can use it. public means any code at all can use it. protected sits between them and is covered with inheritance. A reasonable default is private fields, and methods only as visible as they need to be.
static belongs to the class, not to any object
A static field has one copy shared by the whole class, instead of one per object. A counter of how many objects were created is the classic case:
class Ticket {
static int created = 0;
int number;
Ticket() {
created++;
number = created;
}
static String summary() {
return created + " tickets so far";
}
}
void main() {
IO.println(Ticket.summary());
var a = new Ticket();
var b = new Ticket();
var c = new Ticket();
IO.println("a=" + a.number + " b=" + b.number + " c=" + c.number);
IO.println(Ticket.summary());
}
It prints:
0 tickets so far
a=1 b=2 c=3
3 tickets so far
Each ticket has its own number, but all three share one created. summary() is a static method, so you call it on the class, Ticket.summary(), and you can call it before any ticket exists. IO.println and Math.max are static methods too.
A static method has no this
A static method runs without an object, so there’s no this for it to use and no instance fields to read:
class Ticket {
int number;
static void show() {
IO.println(this.number);
}
}
void main() {
Ticket.show();
}
The build fails with:
Main.java:5: error: non-static variable this cannot be referenced from a static context
IO.println(this.number);
^
Writing plain number instead of this.number fails the same way, with non-static variable number. The question to ask is “which ticket’s number?”, and a static method has no answer.
Static factory methods
A static method that returns a new object is called a static factory, and modern Java uses them widely: List.of(...), Path.of(...), Duration.ofSeconds(...). Unlike a constructor, a factory gets a name that says what it does.
In a compact source file, the obvious first attempt doesn’t compile, and this one surprised us:
class Delay {
static Delay ofSeconds(int seconds) {
return new Delay();
}
}
void main() {
IO.println(Delay.ofSeconds(90) != null);
}
The build fails with:
Main.java:3: error: non-static variable this cannot be referenced from a static context
return new Delay();
^
There’s no this anywhere in that code, so the message looks wrong. It isn’t. In a compact source file, Delay is nested inside the hidden Main class, and a plain nested class is an inner class: each Delay object is tied to a Main object. Your void main() runs on such an object, so new Delay() works there. A static method has no Main object to tie the new Delay to, and that missing object is the this javac means.
The same class in its own file, or next to a classic public class Main, compiles fine. In a compact file, mark the class static and it no longer needs an outer object:
static class Delay {
private final int seconds;
private Delay(int seconds) {
this.seconds = seconds;
}
static Delay ofSeconds(int seconds) {
return new Delay(seconds);
}
static Delay ofMinutes(int minutes) {
return new Delay(minutes * 60);
}
@Override
public String toString() {
return seconds + "s";
}
}
void main() {
IO.println(Delay.ofSeconds(90));
IO.println(Delay.ofMinutes(2));
}
It prints:
90s
120s
ofSeconds(90) and ofMinutes(2) both take one int, so they couldn’t be two constructors: the parameter lists would be identical. The constructor is private, so in a normal project the factories are the only way to make one. The Ticket class earlier needed no static because its static method never created a Ticket.
Explain it like I’m ten
A class is a cookie cutter. Every object is a cookie you press out with it. The cutter decides the shape, but each cookie is its own cookie. You can put sprinkles on one, and the others stay plain. Those sprinkles are fields.
The constructor is the pressing. It’s the moment a cookie is made, and it’s your chance to refuse to make a broken one.
Something static is printed on the cutter itself, not on any cookie. Say you keep a tally on the cutter’s handle: one mark every time you press. There’s one tally, however many cookies you make, and you can read it before you’ve made a single cookie.
The precise version
A class is a type. new allocates an object of that type on the heap, sets every field to its default, runs the field initializers and the constructor, and returns a reference to the object. Instance fields live in each object. Instance methods get the object as a hidden parameter, which is this.
A static field lives once per class, not per object, and a static method receives no hidden object. Static members are reached through the class name. The class itself is loaded and initialized once, the first time it’s used.
Where the analogy breaks: a cookie cutter can’t stop you making a cookie with half its shape missing, but a constructor can, by throwing. And cookies are separate lumps of dough, but a Java variable doesn’t hold the cookie. It holds a reference to it, so two variables can point at the same object and see each other’s sprinkles.
final fields are set once
A final field must be given a value exactly once, either where it’s declared or in every constructor, and it can’t change after that. Try to change one later and the build fails:
class Money {
final long cents;
Money(long cents) {
this.cents = cents;
}
void addTip(long tip) {
cents = cents + tip;
}
}
void main() {
var bill = new Money(1250);
bill.addTip(200);
}
The build fails with:
Main.java:9: error: cannot assign a value to final variable cents
cents = cents + tip;
^
Immutable objects
If every field is final and none of them points to something mutable, the object can never change after construction. That’s an immutable object. Instead of changing it, methods return a new object:
class Money {
private final long cents;
private final String currency;
Money(long cents, String currency) {
if (cents < 0) {
throw new IllegalArgumentException("negative amount: " + cents);
}
this.cents = cents;
this.currency = currency;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(cents + other.cents, currency);
}
@Override
public String toString() {
return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
}
}
void main() {
var price = new Money(1250, "EUR");
var tip = new Money(200, "EUR");
var total = price.plus(tip);
IO.println("price: " + price);
IO.println("tip: " + tip);
IO.println("total: " + total);
}
It prints:
price: 12.50 EUR
tip: 2.00 EUR
total: 14.50 EUR
price.plus(tip) left price alone and handed back a third object. That’s a design choice with real payoffs. You can pass a Money to any method, store it anywhere, or share it between threads, and nobody can change it behind your back. The constructor checks the rules once, and they stay true forever.
Notice what final doesn’t do: on a field that holds a list, it stops you replacing the list, not adding to it. final fixes the reference, not the object it points at.
Writing Money took a lot of lines for two values. The part on records shows how Java writes that class for you in one.
The order things run in
A class can also have static blocks, which run once when the class is first used, and instance initializer blocks, which run for every new object. The order is easy to check:
class Robot {
static int built = 0;
static {
IO.println("1. static block: the class is initialized");
}
String name = "unnamed";
{
IO.println("2. instance block: name is " + name);
}
Robot(String name) {
IO.println("3. constructor: renaming to " + name);
this.name = name;
built++;
}
}
void main() {
IO.println("main starts");
new Robot("R1");
new Robot("R2");
IO.println("built: " + Robot.built);
}
It prints:
main starts
1. static block: the class is initialized
2. instance block: name is unnamed
3. constructor: renaming to R1
2. instance block: name is unnamed
3. constructor: renaming to R2
built: 2
The static block ran once, and only when Robot was first used, after main had already started. For each object, field initializers and instance blocks ran top to bottom, then the constructor body. Instance blocks are rare in real code. A constructor is almost always clearer.
What to remember
- A class declares fields and methods.
newmakes an object with its own copy of every instance field. Usethis.fieldwhen a parameter has the same name. - Override
toString. The defaultMain$Player@…output is an identity hash that can change between runs, launch modes and JVMs. - Once you write any constructor, the free no-argument one is gone. Chain constructors with
this(...)and validate arguments in the constructor. - Since Java 25, a constructor can run statements before
super(...)orthis(...), but it can’t usethisthere. - Make fields
privateand change them only through methods that keep the rules. In a compact source file, classes are nested in a hidden class, somaincan still reach private members. staticmembers belong to the class, and a static method has nothis. Static factories likeof(...)give constructors a name.- A
finalfield is assigned once. Objects whose fields are all final and immutable can’t change, so methods return new objects instead.
A constructor decides how an object starts, and
privateandfinaldecide what can change after that.