Java Modifiers

Modifiers are keywords you attach to classes, methods, and fields to control two things: who can see and use them, and how they behave. Java splits modifiers into two families: access modifiers, which control visibility across classes and packages, and non-access modifiers, which change behavior — whether a member belongs to the class instead of an instance, whether it can be reassigned or overridden, and more. Understanding modifiers is essential to writing encapsulated, maintainable object-oriented code, because they are how you enforce the boundary between a class’s internal implementation and its public contract.

Overview: How Modifiers Work

Every class, field, method, and constructor in Java has an access level, even if you don’t write one explicitly — leaving it blank gives you default (package-private) access. The compiler checks these modifiers at compile time: if code outside the allowed scope tries to touch a member, compilation fails with an error like “has private access in.” This is not a runtime check or a convention — it is enforced by the Java Language Specification and verified by the compiler and the JVM’s bytecode verifier, so it cannot be bypassed by ordinary code (only reflection with `setAccessible(true)` can override it, and even that can be blocked by the module system in newer Java versions).

Access modifiers answer “who can see this?” Non-access modifiers answer “how does this member behave?” A field can carry one access modifier plus any number of compatible non-access modifiers at the same time — for example private static final int MAX = 100; combines all three ideas: hidden from other classes, shared by the whole class rather than per-instance, and unchangeable after initialization.

Access Modifiers

Java has four access levels. Three have keywords; the fourth, package-private, is what you get by omitting a keyword entirely.

Modifier Same class Same package Subclass (different package) World
public Yes Yes Yes Yes
protected Yes Yes Yes No
(default / package-private) Yes Yes No No
private Yes No No No

public members form the API of your class — anyone, anywhere, can use them. private members are implementation details, visible only inside the same class (useful for hiding internal fields behind getters/setters). protected is mainly for inheritance: it lets subclasses in other packages use a member while still hiding it from unrelated code. Default access (no keyword) is for things that should stay internal to one package, like helper classes used only by classes in that package.

Top-level classes can only be public or default — never private or protected, since those only make sense for members nested inside something.

Non-Access Modifiers

Modifier Applies to Effect
static fields, methods, nested classes Belongs to the class itself, not to any instance; shared across all objects.
final classes, methods, variables Class cannot be subclassed; method cannot be overridden; variable can only be assigned once.
abstract classes, methods Class cannot be instantiated directly; method has no body and must be implemented by a subclass.
synchronized methods, blocks Only one thread can execute the guarded code on a given object at a time.
transient fields Field is skipped by default Java serialization.
volatile fields Reads/writes go directly to main memory, ensuring visibility across threads.
native methods Method body is implemented in platform-specific code (e.g. C), not Java.

Syntax

[access modifier] [non-access modifiers] returnType methodName(parameters) {
    // body
}

[access modifier] [non-access modifiers] type fieldName = value;
  • access modifier — one of public, protected, private, or omitted for default.
  • non-access modifiers — any of static, final, abstract, synchronized, etc., written before the type.
  • At most one access modifier is allowed per member, but multiple non-access modifiers can combine (order is flexible but conventionally access comes first).

Examples

Example 1: Access modifiers and encapsulation

class BankAccount {
    private double balance;
    protected String owner;
    String accountType;
    public String bankName = "Global Bank";

    public BankAccount(String owner, double balance) {
        this.owner = owner;
        this.balance = balance;
        this.accountType = "Savings";
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount("Alice", 500.0);
        acc.deposit(250.0);
        System.out.println("Owner: " + acc.owner);
        System.out.println("Account type: " + acc.accountType);
        System.out.println("Bank: " + acc.bankName);
        System.out.println("Balance: " + acc.getBalance());
    }
}

Output:

Owner: Alice
Account type: Savings
Bank: Global Bank
Balance: 750.0

Here balance is private, so Main cannot read it directly and must go through the public getBalance() method — that’s encapsulation in action. Because Main and BankAccount live in the same file (same default package), the protected field owner and the package-private field accountType are both reachable directly, while bankName is public and reachable from anywhere.

Example 2: static vs. instance state, and final constants

class Counter {
    static int count = 0;
    final int id;

    Counter() {
        count++;
        id = count;
    }
}

public class Main {
    static final double PI_APPROX = 3.14159;

    public static void main(String[] args) {
        Counter c1 = new Counter();
        Counter c2 = new Counter();
        Counter c3 = new Counter();

        System.out.println("c1 id: " + c1.id);
        System.out.println("c2 id: " + c2.id);
        System.out.println("c3 id: " + c3.id);
        System.out.println("Total counters (static): " + Counter.count);
        System.out.println("PI_APPROX: " + PI_APPROX);
    }
}

Output:

c1 id: 1
c2 id: 2
c3 id: 3
Total counters (static): 3
PI_APPROX: 3.14159

count is static, so there is exactly one copy shared by every Counter instance — each constructor call increments the same memory location. In contrast, id is an instance field marked final: each object gets its own value, assigned once in the constructor and never changed again. PI_APPROX combines static and final to form a true class-wide constant.

Example 3: abstract classes and final methods

abstract class Shape {
    abstract double area();

    final void describe() {
        System.out.println("This shape has area: " + area());
    }
}

class Circle extends Shape {
    private double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    double area() {
        return Math.PI * radius * radius;
    }
}

class Rectangle extends Shape {
    private double width, height;

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    double area() {
        return width * height;
    }
}

public class Main {
    public static void main(String[] args) {
        Shape s1 = new Circle(3.0);
        Shape s2 = new Rectangle(4.0, 5.0);
        s1.describe();
        s2.describe();
    }
}

Output:

This shape has area: 28.274333882308138
This shape has area: 20.0

Shape is abstract, so it cannot be instantiated with new Shape() — only concrete subclasses like Circle and Rectangle that implement area() can be created. The describe() method is final, meaning every subclass shares the exact same implementation and cannot override it, while still calling each subclass’s own version of area() polymorphically.

Under the Hood

Access and non-access modifiers are stored as flags on each class, field, and method in the compiled .class file’s constant pool and member tables (visible if you run javap -p). When code calls a method or reads a field, the JVM’s bytecode verifier checks these flags before allowing the operation — this is why an illegal access attempt fails at compile time for normal Java code, and would also be rejected by the verifier if you tried to hand-craft bytecode that skipped the check. For static members, the JVM allocates storage once per class, in the method area, when the class is loaded — not per object, which is why all instances observe the same value. For final local variables and fields, the compiler enforces single-assignment through definite-assignment analysis; for final classes and methods, the JVM’s method resolution simply never looks for an overriding version in subclasses, which also lets the JIT compiler inline final methods more aggressively since it knows the implementation can’t change.

Common Mistakes

Mistake 1: Accessing a private field from outside its class.

class Account {
    private double balance = 100.0;
}

public class Main {
    public static void main(String[] args) {
        Account acc = new Account();
        System.out.println(acc.balance);
    }
}

This fails to compile with “balance has private access in Account.” The fix is to expose a public getter method instead of the raw field, keeping the field private:

class Account {
    private double balance = 100.0;

    public double getBalance() {
        return balance;
    }
}

Mistake 2: Reassigning a final variable.

final int MAX_USERS = 100;
MAX_USERS = 200;

Once a final variable is assigned, any later assignment is a compile error (“cannot assign a value to final variable”). If the value genuinely needs to change, don’t mark it final; if it’s meant to be a constant, give it its final value once, typically at declaration.

Mistake 3: Overusing public. Marking every field public to “save time” removes the ability to validate input or change internal representation later without breaking every caller. Default to the most restrictive access level (usually private) and only widen it when there’s a real need — this is the principle of least privilege applied to code.

Best Practices

  • Make fields private by default and expose behavior through public methods, not raw field access.
  • Use protected only for members genuinely meant to be extended by subclasses, not as a lazy alternative to public.
  • Mark constants static final and name them in UPPER_SNAKE_CASE.
  • Use final on classes and methods you never want overridden, especially for security- or correctness-critical logic.
  • Prefer package-private (default) access for helper classes that only your package should use.
  • Reach for abstract classes when subclasses share common state or partial implementation; prefer interfaces when you’re only defining a contract.
  • Use synchronized and volatile deliberately and sparingly — they solve specific concurrency problems and add overhead if misapplied.

Practice Exercises

  • Write a Person class with a private age field, a public constructor, and a public getAge() method. Try accessing age directly from another class and observe the compiler error, then fix it using the getter.
  • Create an abstract class Animal with an abstract method makeSound(), and two subclasses Dog and Cat that implement it differently. Call both through an Animal reference.
  • Write a class with a static field that counts how many objects have been created, plus a final instance field storing each object’s unique ID assigned from that counter. Create several objects and print both values.

Summary

  • Access modifiers (public, protected, default, private) control visibility across classes and packages, from most to least open.
  • Non-access modifiers like static, final, and abstract change behavior: shared state, immutability, or requiring subclass implementation.
  • private fields with public getters/setters are the foundation of encapsulation.
  • static members belong to the class and are shared by all instances; instance members belong to each object separately.
  • final prevents reassignment (variables), overriding (methods), and subclassing (classes).
  • abstract classes and methods force subclasses to provide concrete implementations before they can be instantiated.
  • All of this is enforced by the compiler and the JVM’s bytecode verifier, not just convention.