Back to Blog
Java

Java Access Modifiers Explained

Understand Java access modifiers: public, private, protected, and package-private. Learn their scope, inheritance behavior, and how to choose the right level.

Javaaccess modifiersencapsulationvisibilityinheritancepackage-private
Illustration of Java access modifiers showing public, protected, package-private, and private visibility scopes with a class diagram metaphor.

When you declare a class, method, or field in Java, you must decide how visible it is to other parts of the program. That decision is made with Java access modifiers: public, protected, and private, plus the default package-private level when no modifier is written. The choice directly affects encapsulation, API design, and how easily the code can be changed later.

The Four Access Levels and Their Scope

Java defines four levels of access, each with a distinct visibility range. The table below summarizes them for a member declared in a class.

ModifierSame classSame packageSubclass (different package)Any class
publicYesYesYesYes
protectedYesYesYesNo
(none)YesYesNoNo
privateYesNoNoNo

The default (package-private) level is not a keyword; it is simply the absence of a modifier. This is a common source of confusion because many developers expect the default to be private, but it is actually visible to all classes in the same package.

How Access Modifiers Affect Inheritance and Overriding

Access modifiers interact with inheritance in ways that often surprise developers. A subclass can only override a method if the overriding method has the same or more permissive access. For example, a protected method can be overridden as protected or public, but not as private or package-private. This rule exists to preserve the contract that the parent class established.

Consider this example. The parent class is in its own file:

// Parent.java public class Parent { protected void greet() { System.out.println("Hello from Parent"); } }

The child class is in another file:

// Child.java public class Child extends Parent { @Override public void greet() { // allowed: widening access System.out.println("Hello from Child"); } }

If you try to narrow the access in the override, the compiler rejects it. This rule prevents a subclass from silently hiding a method that callers of the parent class rely on.

Access Modifiers on Members vs. Classes

A top-level class can only be public or package-private. You cannot declare a top-level class as private or protected. The private and protected modifiers are reserved for nested classes and members. This distinction is important when designing APIs: a public top-level class is part of the exported surface, while a package-private class is an implementation detail that can change without breaking external callers.

Nested classes follow the same visibility rules as other members. A private nested class is only accessible from the enclosing class, which is useful for hiding helper structures. A protected nested class follows the same protected rules: accessible within its package and to subclasses.

Common Mistakes and Misunderstandings

One frequent mistake is assuming that protected means 'visible to all subclasses regardless of package.' Package rules still apply: outside the package, a subclass can access a protected instance member only on a reference whose type is that subclass or one of its subclasses. Within the same package, any class can access protected members, even if it does not extend the class. Another mistake is using public fields when a getter would allow future validation or logging. Exposing fields directly makes it impossible to change the internal representation without breaking callers.

Another subtle issue arises with overriding and access. If a parent method is public and the child overrides it as protected, the code will not compile. The compiler enforces the widening rule, but developers often encounter this when refactoring an interface implementation into a class hierarchy.

Choosing the Right Access Level for Maintainability

The primary goal of access modifiers is to control what other code can depend on. A smaller public surface makes it easier to evolve the implementation. As a rule of thumb, start with the most restrictive level that works: private for fields and helper methods, package-private for internal collaboration within a package, protected for extension points, and public only for the API you intend to support.

This approach has a direct effect on maintainability. When a field is private, you can change its type, add constraints, or compute it lazily without affecting code outside the class. When a method is package-private, you can refactor its implementation as long as you keep the same package and signature. Overly broad visibility forces you to consider every external caller before making a change, which increases the cost of refactoring.

Interaction with Interfaces and Records

Interfaces and records have their own access rules. Non-private members of an interface are implicitly public; if the interface itself is package-private, those members are reachable only within that package. Since Java 9, an interface can also declare private methods to share code between default methods, but those private methods are not part of the public contract. Records, a standard feature since Java 16, have private final fields for their components and implicit public accessor methods. Record components cannot have access modifiers. This design preserves the value-based semantics of records.

When implementing an interface, the implementing method must be public because the interface method is public. If you try to implement it with a narrower modifier, the compiler rejects it. This is a common error when developers are used to package-private implementations in other contexts.

Access Modifiers and Reflection

Reflection can bypass access checks at runtime, but this is not a license to ignore modifiers. The setAccessible(true) call on a Field or Method can access private members, but it is fragile and can break under a security manager or in a modular environment. Relying on reflection to reach private state is a code smell that often indicates a design flaw. If you need to test private methods, consider extracting them into a package-private helper class instead of using reflection.

From a security perspective, access modifiers are not a security boundary. They are a compile-time contract for developers, not a runtime protection mechanism. Malicious code can use reflection or unsafe APIs to access private data. Therefore, never store sensitive information in a field and assume that private protects it from all access.

Practical Example: Building a Small API

Consider a simple BankAccount class. The balance must be read but not directly modified. Using private for the balance and a public method to deposit money enforces the invariant that the balance cannot become negative.

public class BankAccount { private double balance; public BankAccount(double initialBalance) { if (initialBalance < 0) { throw new IllegalArgumentException("Initial balance cannot be negative"); } this.balance = initialBalance; } public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit amount must be positive"); } this.balance += amount; } public double getBalance() { return balance; } }

Here, balance is private, so no external code can set it to an invalid value. The deposit method validates the amount before changing the state. This is the core benefit of access modifiers: they let you control the state transitions of your objects.

If you later need to add a withdrawal method, you can do so inside the class without affecting any existing caller. The public surface remains stable, and the internal logic can evolve independently.

Java Access Modifiers: Practical Usage and Code Examples | RYUSLOG DEV