An interface in Java is a contract. It defines what a class must do, but not how it does it. When a class implements an interface, it promises to provide concrete implementations for every method declared in that interface.
An interface is not a class. It cannot be instantiated, it has no constructors, and (prior to Java 8) it contained no method bodies. Think of it as a blueprint of behavior — a set of method signatures that any implementing class must fulfill.
Real-world analogy: Consider a USB port. The USB specification is an interface — it defines the shape of the connector, the voltage, the data transfer protocol. Any device manufacturer (keyboard, mouse, flash drive, phone charger) that implements the USB interface can plug into any USB port. The computer does not need to know the internal details of each device. It only needs to know that the device conforms to the USB contract.
This is exactly how Java interfaces work. Your code can depend on the interface (the contract) rather than a specific implementation, making your programs flexible, testable, and easy to extend.
// The interface defines WHAT a class must do
public interface Drawable {
void draw(); // Every Drawable must know how to draw itself
double area(); // Every Drawable must know its area
}
// Circle implements the Drawable contract -- it defines HOW
public class Circle implements Drawable {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public void draw() {
System.out.println("Drawing a circle with radius " + radius);
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
// Rectangle also implements Drawable -- with its own HOW
public class Rectangle implements Drawable {
private double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public void draw() {
System.out.println("Drawing a rectangle " + width + "x" + height);
}
@Override
public double area() {
return width * height;
}
}
// Usage -- code depends on the interface, not the implementation
public class Main {
public static void main(String[] args) {
Drawable shape1 = new Circle(5.0);
Drawable shape2 = new Rectangle(4.0, 6.0);
shape1.draw(); // Drawing a circle with radius 5.0
shape2.draw(); // Drawing a rectangle 4.0x6.0
System.out.println("Circle area: " + shape1.area()); // 78.539...
System.out.println("Rectangle area: " + shape2.area()); // 24.0
}
}
Interfaces are one of the most powerful tools in Java. Here is why experienced developers rely on them heavily:
Interfaces let you hide implementation details. The caller only sees what operations are available, not how they work internally. This simplifies complex systems because each layer only needs to know about the contracts it depends on.
Java does not allow a class to extend more than one class (single inheritance). However, a class can implement multiple interfaces. This is Java’s solution to the multiple inheritance problem — you get the flexibility of inheriting behavior from multiple sources without the ambiguity that comes with multiple class inheritance in languages like C++.
When your code depends on an interface rather than a concrete class, you can swap implementations without changing the calling code. This is the foundation of the Dependency Injection pattern used in frameworks like Spring Boot.
This is one of the most important principles in software design (from the Gang of Four Design Patterns book). When you declare variables and parameters using interface types, your code becomes more flexible and easier to test.
Interfaces make unit testing straightforward. You can create mock or stub implementations that simulate behavior during tests, without needing a real database, network connection, or external service.
// BAD -- tightly coupled to a specific implementation
public class OrderService {
private MySqlOrderRepository repository = new MySqlOrderRepository();
public void placeOrder(Order order) {
repository.save(order); // What if we switch to PostgreSQL? We must change this class.
}
}
// GOOD -- depends on an interface (loose coupling)
public class OrderService {
private OrderRepository repository; // interface type
public OrderService(OrderRepository repository) {
this.repository = repository; // injected -- could be MySQL, Postgres, or a mock
}
public void placeOrder(Order order) {
repository.save(order); // Works with ANY implementation
}
}
// Now we can easily swap implementations or test with a mock
OrderService prodService = new OrderService(new MySqlOrderRepository());
OrderService testService = new OrderService(new InMemoryOrderRepository());
An interface is declared using the interface keyword. Here is the basic structure:
// Declaring an interface
public interface Animal {
// Constant -- automatically public, static, and final
String KINGDOM = "Animalia";
// Abstract method -- automatically public and abstract
void eat();
// Another abstract method
String speak();
// Method with a parameter and return type
boolean canFly();
}
A class implements an interface using the implements keyword. It must provide concrete implementations for every abstract method in the interface, or the class itself must be declared abstract.
public class Dog implements Animal {
@Override
public void eat() {
System.out.println("Dog eats kibble");
}
@Override
public String speak() {
return "Woof!";
}
@Override
public boolean canFly() {
return false;
}
}
public class Eagle implements Animal {
@Override
public void eat() {
System.out.println("Eagle eats fish");
}
@Override
public String speak() {
return "Screech!";
}
@Override
public boolean canFly() {
return true;
}
}
// Using the interface type for polymorphism
public class Main {
public static void main(String[] args) {
Animal myPet = new Dog();
Animal myBird = new Eagle();
System.out.println(myPet.speak()); // Woof!
System.out.println(myBird.speak()); // Screech!
System.out.println(myBird.canFly()); // true
// Accessing the constant through the interface
System.out.println(Animal.KINGDOM); // Animalia
}
}
Java interfaces follow a specific set of rules. Understanding these rules prevents common mistakes and compilation errors.
| Rule | Details |
|---|---|
Methods are public abstract by default |
You do not need to write public abstract — the compiler adds them automatically. You cannot make interface methods protected or private (abstract methods). |
Fields are public static final by default |
All fields in an interface are constants. You must initialize them at declaration. You cannot have instance variables. |
| Cannot be instantiated | You cannot write new MyInterface(). However, you can use anonymous classes or lambdas (for functional interfaces). |
| No constructors | Interfaces cannot have constructors since they cannot be instantiated. |
| A class can implement multiple interfaces | class Dog implements Animal, Pet, Trainable is valid. |
| An interface can extend multiple interfaces | interface SmartPhone extends Phone, Camera, GPS is valid. |
| Must implement all abstract methods | If a class does not implement all methods, it must be declared abstract. |
Methods can have default bodies (Java 8+) |
Use the default keyword to provide a method body in the interface. |
Methods can be static (Java 8+) |
Static methods belong to the interface, not the implementing class. |
Methods can be private (Java 9+) |
Private methods share code between default methods within the interface. |
// Demonstrating interface rules
public interface Vehicle {
// This field is automatically public, static, and final
int MAX_SPEED_MPH = 200; // Must be initialized here
// This method is automatically public and abstract
void start();
// You can write it explicitly, but it is redundant
public abstract void stop();
// INVALID -- cannot have instance variables
// int currentSpeed; // Compilation error: must be initialized (and will be static final)
// INVALID -- cannot have constructors
// Vehicle() { } // Compilation error
}
// A class implementing multiple interfaces
public interface Insurable {
double calculatePremium();
}
public interface Leasable {
double monthlyPayment(int termMonths);
}
public class Car implements Vehicle, Insurable, Leasable {
@Override
public void start() {
System.out.println("Car engine started");
}
@Override
public void stop() {
System.out.println("Car engine stopped");
}
@Override
public double calculatePremium() {
return 1200.00;
}
@Override
public double monthlyPayment(int termMonths) {
return 35000.0 / termMonths;
}
}
Before Java 8, adding a new method to an interface was a breaking change — every class that implemented the interface would fail to compile until it provided an implementation for the new method. This was a major problem for library authors. Imagine adding a new method to the List interface — every custom List implementation in the world would break.
Default methods solve this problem. They allow you to add a method with a body directly in the interface using the default keyword. Classes that implement the interface inherit the default behavior but can override it if they need to.
This is how the Java team added forEach(), stream(), and spliterator() to the Iterable and Collection interfaces in Java 8 without breaking millions of existing implementations.
public interface Logger {
// Abstract method -- must be implemented
void log(String message);
// Default method -- provides a default implementation
default void logInfo(String message) {
log("[INFO] " + message);
}
default void logWarning(String message) {
log("[WARNING] " + message);
}
default void logError(String message) {
log("[ERROR] " + message);
}
}
// ConsoleLogger only needs to implement log() -- it gets the others for free
public class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println(message);
}
// logInfo, logWarning, logError are inherited with default behavior
}
// FileLogger overrides logError to add special behavior
public class FileLogger implements Logger {
@Override
public void log(String message) {
// In a real app, this would write to a file
System.out.println("[FILE] " + message);
}
@Override
public void logError(String message) {
log("[ERROR] " + message);
log("[ERROR] Stack trace saved to error.log");
// Additional error-specific logic
}
}
public class Main {
public static void main(String[] args) {
Logger console = new ConsoleLogger();
console.logInfo("Application started"); // [INFO] Application started
console.logError("Null pointer"); // [ERROR] Null pointer
Logger file = new FileLogger();
file.logInfo("Application started"); // [FILE] [INFO] Application started
file.logError("Null pointer"); // [FILE] [ERROR] Null pointer
// [FILE] [ERROR] Stack trace saved to error.log
}
}
When a class implements two interfaces that both have a default method with the same signature, you get a compilation error. Java forces you to resolve the conflict explicitly by overriding the method and choosing which version to use (or providing your own).
public interface InterfaceA {
default void greet() {
System.out.println("Hello from A");
}
}
public interface InterfaceB {
default void greet() {
System.out.println("Hello from B");
}
}
// This will NOT compile without resolving the conflict
// public class MyClass implements InterfaceA, InterfaceB { }
// Solution: override the method and resolve the conflict
public class MyClass implements InterfaceA, InterfaceB {
@Override
public void greet() {
// Option 1: Call one of the interface defaults
InterfaceA.super.greet(); // Hello from A
// Option 2: Call the other
// InterfaceB.super.greet();
// Option 3: Provide entirely new behavior
// System.out.println("Hello from MyClass");
}
}
public class Main {
public static void main(String[] args) {
MyClass obj = new MyClass();
obj.greet(); // Hello from A
}
}
Java 8 also introduced static methods in interfaces. These are utility methods that belong to the interface itself, not to any implementing class. They are called using the interface name, just like static methods on a class.
Key rules for static methods in interfaces:
MyInterface.myStaticMethod()A great example from the JDK is Comparator.naturalOrder(), Comparator.reverseOrder(), and Comparator.comparing() — all static methods on the Comparator interface that create commonly needed comparators.
public interface StringUtils {
// Static utility method -- called via StringUtils.isNullOrEmpty()
static boolean isNullOrEmpty(String str) {
return str == null || str.trim().isEmpty();
}
static String capitalize(String str) {
if (isNullOrEmpty(str)) {
return str;
}
return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase();
}
static String repeat(String str, int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append(str);
}
return sb.toString();
}
}
// Static factory method pattern
public interface Shape {
double area();
String name();
// Factory method -- creates shapes without exposing implementation classes
static Shape circle(double radius) {
return new Shape() {
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public String name() {
return "Circle(r=" + radius + ")";
}
};
}
static Shape square(double side) {
return new Shape() {
@Override
public double area() {
return side * side;
}
@Override
public String name() {
return "Square(s=" + side + ")";
}
};
}
}
public class Main {
public static void main(String[] args) {
// Using static utility methods
System.out.println(StringUtils.isNullOrEmpty("")); // true
System.out.println(StringUtils.isNullOrEmpty("hello")); // false
System.out.println(StringUtils.capitalize("jAVA")); // Java
System.out.println(StringUtils.repeat("ha", 3)); // hahaha
// Using static factory methods
Shape c = Shape.circle(5);
Shape s = Shape.square(4);
System.out.println(c.name() + " area: " + c.area()); // Circle(r=5.0) area: 78.539...
System.out.println(s.name() + " area: " + s.area()); // Square(s=4.0) area: 16.0
}
}
Java 9 added private methods in interfaces. Their purpose is simple: they let you share code between multiple default methods within the same interface, reducing duplication.
Before Java 9, if two default methods had common logic, you had to duplicate the code or extract it into a separate utility class. Private methods keep that shared logic inside the interface where it belongs.
Rules for private methods in interfaces:
abstract -- they must have a bodyprivate (instance) or private staticprivate methods can be called from default methodsprivate static methods can be called from static methods and default methodspublic interface Reportable {
String getData();
// Default methods with shared formatting logic
default String generateHtmlReport() {
return wrapInTag("html",
wrapInTag("body",
wrapInTag("h1", "Report") +
wrapInTag("p", getData())
)
);
}
default String generateXmlReport() {
return wrapInTag("report",
wrapInTag("title", "Report") +
wrapInTag("data", getData())
);
}
// Private method -- shared by both default methods above
// Not visible to implementing classes
private String wrapInTag(String tag, String content) {
return "<" + tag + ">" + content + "" + tag + ">";
}
}
public class SalesReport implements Reportable {
@Override
public String getData() {
return "Total sales: $50,000";
}
}
public class Main {
public static void main(String[] args) {
Reportable report = new SalesReport();
System.out.println(report.generateHtmlReport());
// Report
Total sales: $50,000
System.out.println(report.generateXmlReport());
// Report Total sales: $50,000
// report.wrapInTag("div", "text"); // Compilation error -- private method
}
}
A functional interface is an interface that has exactly one abstract method. Functional interfaces are the foundation for lambda expressions and method references in Java 8+.
You can mark an interface with the @FunctionalInterface annotation. This annotation is optional but recommended -- it tells the compiler to enforce the single-abstract-method rule and gives a clear signal to other developers.
Any interface with exactly one abstract method can be used as a lambda expression target, even without the annotation. Default methods and static methods do not count toward the limit because they already have implementations.
// Custom functional interface @FunctionalInterface public interface Transformer{ T transform(T input); // Default methods are allowed -- they do not break the functional interface contract default Transformer andThen(Transformer after) { return input -> after.transform(this.transform(input)); } } public class Main { public static void main(String[] args) { // Using a lambda expression (because Transformer is a functional interface) Transformer toUpperCase = s -> s.toUpperCase(); Transformer addExclamation = s -> s + "!"; Transformer trim = s -> s.trim(); System.out.println(toUpperCase.transform("hello")); // HELLO System.out.println(addExclamation.transform("wow")); // wow! // Chaining transformations using the default method Transformer shout = trim.andThen(toUpperCase).andThen(addExclamation); System.out.println(shout.transform(" hello world ")); // HELLO WORLD! } }
Java provides a rich set of functional interfaces in the java.util.function package. You should use these before creating your own. Here are the four most important ones:
| Interface | Abstract Method | Input | Output | Use Case |
|---|---|---|---|---|
Predicate<T> |
test(T t) |
T | boolean | Filtering, validation, conditional checks |
Function<T, R> |
apply(T t) |
T | R | Transformation, mapping one type to another |
Consumer<T> |
accept(T t) |
T | void | Performing actions (logging, saving, printing) |
Supplier<T> |
get() |
none | T | Lazy creation, factory methods, providing values |
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class FunctionalInterfaceDemo {
public static void main(String[] args) {
List names = Arrays.asList("Alice", "Bob", "Charlie", "Dave", "Eve");
// Predicate -- test a condition
Predicate longerThan3 = name -> name.length() > 3;
List longNames = names.stream()
.filter(longerThan3)
.collect(Collectors.toList());
System.out.println("Long names: " + longNames); // [Alice, Charlie, Dave]
// Function -- transform data
Function nameLength = String::length;
List lengths = names.stream()
.map(nameLength)
.collect(Collectors.toList());
System.out.println("Lengths: " + lengths); // [5, 3, 7, 4, 3]
// Consumer -- perform an action
Consumer printUpperCase = name -> System.out.println(name.toUpperCase());
names.forEach(printUpperCase);
// ALICE
// BOB
// CHARLIE
// DAVE
// EVE
// Supplier -- provide a value (lazy initialization)
Supplier> defaultNames = () -> Arrays.asList("Unknown");
List result = names.isEmpty() ? defaultNames.get() : names;
System.out.println("Result: " + result); // [Alice, Bob, Charlie, Dave, Eve]
// Composing predicates
Predicate startsWithA = name -> name.startsWith("A");
Predicate longAndStartsWithA = longerThan3.and(startsWithA);
names.stream()
.filter(longAndStartsWithA)
.forEach(System.out::println); // Alice
// Chaining functions
Function toUpper = String::toUpperCase;
Function addGreeting = name -> "Hello, " + name;
Function greetLoudly = toUpper.andThen(addGreeting);
System.out.println(greetLoudly.apply("alice")); // Hello, ALICE
}
}
This is one of the most common interview questions and a fundamental design decision. Both interfaces and abstract classes define contracts that subclasses must fulfill, but they serve different purposes and have different capabilities.
| Feature | Interface | Abstract Class |
|---|---|---|
| Methods (before Java 8) | Only abstract methods | Abstract and concrete methods |
| Methods (Java 8+) | Abstract, default, static, and private (Java 9+) | Abstract and concrete methods |
| Fields | public static final only (constants) |
Any access modifier, instance and static fields |
| Constructors | No | Yes |
| Multiple inheritance | A class can implement many interfaces | A class can extend only one abstract class |
| State | Cannot hold instance state | Can hold instance state (instance variables) |
| Access modifiers on methods | Abstract methods are always public |
Methods can be public, protected, or package-private |
| When to use | To define a contract (what to do) | To define a base template with shared state/behavior |
| Keyword | implements |
extends |
| Relationship | "can do" (capability) | "is a" (identity) |
Comparable, Serializable, Runnable -- these are capabilities, not identities.AbstractList shares field management and common logic across ArrayList, LinkedList, etc.// Interface -- defines a capability
public interface Flyable {
void fly();
double maxAltitude();
}
// Abstract class -- defines a base identity with shared state
public abstract class Bird {
protected String species;
protected double wingSpan;
public Bird(String species, double wingSpan) {
this.species = species;
this.wingSpan = wingSpan;
}
// Concrete method -- shared behavior
public void breathe() {
System.out.println(species + " is breathing");
}
// Abstract method -- subclasses define their own
public abstract String habitat();
}
// Penguin IS A Bird but CANNOT fly
public class Penguin extends Bird {
public Penguin() {
super("Penguin", 0.3);
}
@Override
public String habitat() {
return "Antarctic";
}
}
// Eagle IS A Bird AND CAN fly
public class Eagle extends Bird implements Flyable {
public Eagle() {
super("Eagle", 2.0);
}
@Override
public String habitat() {
return "Mountains";
}
@Override
public void fly() {
System.out.println("Eagle soars through the sky");
}
@Override
public double maxAltitude() {
return 10000; // feet
}
}
// An Airplane is NOT a Bird but CAN fly
public class Airplane implements Flyable {
@Override
public void fly() {
System.out.println("Airplane flies with engines");
}
@Override
public double maxAltitude() {
return 40000; // feet
}
}
// This shows why interfaces represent capabilities, not identity
// Both Eagle and Airplane can fly, but they are not related by inheritance
A marker interface is an interface with no methods or constants. It is an empty interface whose sole purpose is to "mark" or "tag" a class as having a certain property. The JVM or framework checks whether a class implements the marker interface and behaves accordingly.
Well-known marker interfaces in the JDK:
java.io.Serializable -- Marks a class as safe for serialization (converting to a byte stream). The ObjectOutputStream will throw NotSerializableException if the object's class does not implement this interface.java.lang.Cloneable -- Marks a class as safe for cloning via Object.clone(). Without it, clone() throws CloneNotSupportedException.java.util.RandomAccess -- Marks a List implementation as supporting fast random access (like ArrayList). Algorithms can check this to choose between index-based or iterator-based traversal.Since Java 5, annotations have largely replaced marker interfaces for metadata tagging. Annotations are more flexible because they can carry data, be applied to methods/fields (not just classes), and do not affect the type hierarchy. However, marker interfaces still have one advantage: they define a type, which means you can use them in method signatures to enforce constraints at compile time.
import java.io.Serializable;
// Serializable is a marker interface -- no methods to implement
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
@Override
public String toString() {
return "User{name='" + name + "', email='" + email + "'}";
}
}
// Creating a custom marker interface
public interface Auditable {
// No methods -- just marks classes that should be audited
}
// Service that checks for the marker
public class AuditService {
public void save(Object entity) {
if (entity instanceof Auditable) {
System.out.println("AUDIT LOG: Saving " + entity.getClass().getSimpleName());
}
// ... save logic
System.out.println("Saved: " + entity);
}
}
// A class that is auditable
public class Order implements Auditable {
private String orderId;
public Order(String orderId) {
this.orderId = orderId;
}
@Override
public String toString() {
return "Order{id='" + orderId + "'}";
}
}
// A class that is NOT auditable
public class TempData {
private String value;
public TempData(String value) {
this.value = value;
}
@Override
public String toString() {
return "TempData{value='" + value + "'}";
}
}
public class Main {
public static void main(String[] args) {
AuditService auditService = new AuditService();
auditService.save(new Order("ORD-001"));
// AUDIT LOG: Saving Order
// Saved: Order{id='ORD-001'}
auditService.save(new TempData("temp"));
// Saved: TempData{value='temp'} (no audit log)
}
}
Interfaces can extend other interfaces using the extends keyword. Unlike classes, an interface can extend multiple interfaces at once. This lets you build rich type hierarchies by composing smaller, focused interfaces into larger ones.
// Small, focused interfaces (Interface Segregation Principle)
public interface Readable {
String read();
}
public interface Writable {
void write(String data);
}
public interface Closeable {
void close();
}
// Composed interface -- extends multiple interfaces
public interface ReadWriteStream extends Readable, Writable, Closeable {
// Inherits read(), write(), and close()
// Can add its own methods too
long size();
}
// A class implementing the composed interface must implement ALL methods
public class FileStream implements ReadWriteStream {
private StringBuilder buffer = new StringBuilder();
private boolean open = true;
@Override
public String read() {
if (!open) throw new IllegalStateException("Stream is closed");
return buffer.toString();
}
@Override
public void write(String data) {
if (!open) throw new IllegalStateException("Stream is closed");
buffer.append(data);
}
@Override
public void close() {
open = false;
System.out.println("Stream closed");
}
@Override
public long size() {
return buffer.length();
}
}
// You can use any level of the interface hierarchy as a type
public class Main {
public static void main(String[] args) {
ReadWriteStream stream = new FileStream();
stream.write("Hello, ");
stream.write("World!");
System.out.println(stream.read()); // Hello, World!
System.out.println(stream.size()); // 13
stream.close(); // Stream closed
// Use the narrowest type needed
Readable reader = new FileStream();
// reader.write("test"); // Compilation error -- Readable has no write()
}
}
When an interface inherits a default method from two parent interfaces, the same diamond problem applies. The sub-interface must override the conflicting method to resolve the ambiguity.
public interface Printable {
default String format() {
return "Plain text format";
}
}
public interface Exportable {
default String format() {
return "Export format";
}
}
// This interface extends both -- must resolve the conflict
public interface Document extends Printable, Exportable {
@Override
default String format() {
// Choose one, combine both, or define new behavior
return "Document: " + Printable.super.format();
}
}
public class Report implements Document {
// Inherits the resolved format() from Document
}
public class Main {
public static void main(String[] args) {
Report report = new Report();
System.out.println(report.format()); // Document: Plain text format
}
}
Interfaces are at the heart of most design patterns. Here are three patterns you will encounter frequently in professional Java codebases.
The Strategy pattern lets you define a family of algorithms, encapsulate each one behind an interface, and make them interchangeable. The client chooses which strategy to use at runtime.
// Strategy interface public interface SortStrategy> { void sort(List list); String name(); } // Concrete strategy: Bubble Sort public class BubbleSort > implements SortStrategy { @Override public void sort(List list) { int n = list.size(); for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - i - 1; j++) { if (list.get(j).compareTo(list.get(j + 1)) > 0) { T temp = list.get(j); list.set(j, list.get(j + 1)); list.set(j + 1, temp); } } } } @Override public String name() { return "Bubble Sort"; } } // Concrete strategy: Java's built-in sort public class BuiltInSort > implements SortStrategy { @Override public void sort(List list) { Collections.sort(list); } @Override public String name() { return "Built-in Sort (TimSort)"; } } // Context class that uses the strategy public class DataProcessor > { private SortStrategy strategy; public DataProcessor(SortStrategy strategy) { this.strategy = strategy; } public void setStrategy(SortStrategy strategy) { this.strategy = strategy; } public List process(List data) { System.out.println("Sorting with: " + strategy.name()); List copy = new ArrayList<>(data); strategy.sort(copy); return copy; } } // Usage public class Main { public static void main(String[] args) { List data = Arrays.asList(5, 2, 8, 1, 9, 3); DataProcessor processor = new DataProcessor<>(new BubbleSort<>()); System.out.println(processor.process(data)); // [1, 2, 3, 5, 8, 9] // Switch strategy at runtime processor.setStrategy(new BuiltInSort<>()); System.out.println(processor.process(data)); // [1, 2, 3, 5, 8, 9] } }
The Repository pattern abstracts data access behind an interface. This is the most common pattern in enterprise Java applications (Spring Boot, Jakarta EE). It separates your business logic from database-specific code.
// Domain entity
public class Product {
private Long id;
private String name;
private double price;
public Product(Long id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
// Getters
public Long getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
@Override
public String toString() {
return "Product{id=" + id + ", name='" + name + "', price=" + price + "}";
}
}
// Repository interface -- the contract for data access
public interface ProductRepository {
Product findById(Long id);
List findAll();
List findByPriceRange(double min, double max);
Product save(Product product);
void deleteById(Long id);
}
// Implementation for production -- talks to a real database
public class JpaProductRepository implements ProductRepository {
@Override
public Product findById(Long id) {
// In a real app: entityManager.find(Product.class, id)
System.out.println("JPA: Finding product with id " + id);
return new Product(id, "Widget", 9.99);
}
@Override
public List findAll() {
System.out.println("JPA: Querying all products from database");
return Arrays.asList(
new Product(1L, "Widget", 9.99),
new Product(2L, "Gadget", 24.99)
);
}
@Override
public List findByPriceRange(double min, double max) {
System.out.println("JPA: Querying products between $" + min + " and $" + max);
return findAll().stream()
.filter(p -> p.getPrice() >= min && p.getPrice() <= max)
.collect(Collectors.toList());
}
@Override
public Product save(Product product) {
System.out.println("JPA: Saving " + product);
return product;
}
@Override
public void deleteById(Long id) {
System.out.println("JPA: Deleting product with id " + id);
}
}
// Implementation for testing -- in-memory, no database needed
public class InMemoryProductRepository implements ProductRepository {
private Map store = new HashMap<>();
private long nextId = 1;
@Override
public Product findById(Long id) {
return store.get(id);
}
@Override
public List findAll() {
return new ArrayList<>(store.values());
}
@Override
public List findByPriceRange(double min, double max) {
return store.values().stream()
.filter(p -> p.getPrice() >= min && p.getPrice() <= max)
.collect(Collectors.toList());
}
@Override
public Product save(Product product) {
if (product.getId() == null) {
product = new Product(nextId++, product.getName(), product.getPrice());
}
store.put(product.getId(), product);
return product;
}
@Override
public void deleteById(Long id) {
store.remove(id);
}
}
In enterprise applications, the service layer sits between the controller (HTTP layer) and the repository (data layer). Defining it as an interface allows you to swap implementations, add decorators (caching, logging, transactions), and test in isolation.
// Service interface
public interface ProductService {
Product getProduct(Long id);
List getAllProducts();
List getAffordableProducts(double maxPrice);
Product createProduct(String name, double price);
}
// Production implementation
public class ProductServiceImpl implements ProductService {
private final ProductRepository repository;
public ProductServiceImpl(ProductRepository repository) {
this.repository = repository;
}
@Override
public Product getProduct(Long id) {
Product product = repository.findById(id);
if (product == null) {
throw new RuntimeException("Product not found: " + id);
}
return product;
}
@Override
public List getAllProducts() {
return repository.findAll();
}
@Override
public List getAffordableProducts(double maxPrice) {
return repository.findByPriceRange(0, maxPrice);
}
@Override
public Product createProduct(String name, double price) {
if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
return repository.save(new Product(null, name, price));
}
}
// Usage -- everything wired through interfaces
public class Main {
public static void main(String[] args) {
// Production setup
ProductRepository prodRepo = new JpaProductRepository();
ProductService prodService = new ProductServiceImpl(prodRepo);
// Test setup -- swap to in-memory, no database needed
ProductRepository testRepo = new InMemoryProductRepository();
ProductService testService = new ProductServiceImpl(testRepo);
// Same code works with both
testService.createProduct("Widget", 9.99);
testService.createProduct("Gadget", 24.99);
System.out.println(testService.getAllProducts());
// [Product{id=1, name='Widget', price=9.99}, Product{id=2, name='Gadget', price=24.99}]
}
}
These guidelines come from years of building and maintaining production Java applications. Follow them to write interfaces that are clean, flexible, and easy to evolve.
The Interface Segregation Principle (the "I" in SOLID) states: no class should be forced to implement methods it does not use. If your interface has 15 methods but most implementors only need 3, break it into smaller, focused interfaces.
// BAD -- fat interface forces all implementors to deal with methods they do not need
public interface SmartDevice {
void turnOn();
void turnOff();
void setTemperature(double temp); // Not all devices have thermostats
void playMusic(String song); // Not all devices play music
void takePhoto(); // Not all devices have cameras
void makeCall(String number); // Not all devices can call
}
// GOOD -- small, focused interfaces
public interface Switchable {
void turnOn();
void turnOff();
}
public interface Thermostat {
void setTemperature(double temp);
double getTemperature();
}
public interface MusicPlayer {
void playMusic(String song);
void stop();
}
// Each class implements only what it needs
public class SmartLight implements Switchable {
@Override
public void turnOn() { System.out.println("Light on"); }
@Override
public void turnOff() { System.out.println("Light off"); }
}
public class SmartSpeaker implements Switchable, MusicPlayer {
@Override
public void turnOn() { System.out.println("Speaker on"); }
@Override
public void turnOff() { System.out.println("Speaker off"); }
@Override
public void playMusic(String song) { System.out.println("Playing: " + song); }
@Override
public void stop() { System.out.println("Music stopped"); }
}
public class SmartThermostat implements Switchable, Thermostat {
private double temp = 72.0;
@Override
public void turnOn() { System.out.println("Thermostat on"); }
@Override
public void turnOff() { System.out.println("Thermostat off"); }
@Override
public void setTemperature(double temp) { this.temp = temp; }
@Override
public double getTemperature() { return temp; }
}
Follow these established Java naming patterns:
Comparable, Serializable, Iterable, Closeable, RunnableList, Map, Set, Iterator, RepositoryIUserService). This is a C#/.NET convention, not Java. In Java, name the interface naturally (UserService) and suffix the implementation (UserServiceImpl) or name it descriptively (JpaUserService).Declare variables, parameters, and return types using the interface type, not the implementation type. This makes your code flexible and easy to change.
// BAD -- locked to a specific implementation ArrayListnames = new ArrayList<>(); HashMap scores = new HashMap<>(); // GOOD -- programmed to the interface List names = new ArrayList<>(); Map scores = new HashMap<>(); // Now you can switch to LinkedList, TreeMap, etc. without changing any other code List names = new LinkedList<>(); // No other code needs to change Map scores = new TreeMap<>(); // Automatically sorted // BAD method signature -- locked to ArrayList public ArrayList getActiveUsers() { ... } // GOOD method signature -- returns the interface type public List getActiveUsers() { ... }
When designing interfaces, think about how they will evolve over time:
| Practice | Why It Matters |
|---|---|
| Keep interfaces small and focused | Avoids forcing classes to implement unnecessary methods (ISP) |
| Use descriptive names | Makes the contract clear without reading the code |
| Declare variables using interface types | Enables swapping implementations without code changes |
| Favor interfaces over abstract classes | Allows multiple inheritance and greater flexibility |
| Add default methods for backward compatibility | Lets you evolve interfaces without breaking existing code |
Use @FunctionalInterface for single-method interfaces |
Enables lambda expressions and communicates intent |
| Document contracts with Javadoc | Helps implementors understand what is expected of them |
Let us bring everything together with a realistic example: a payment processing system. This example demonstrates interfaces, default methods, static factory methods, functional interfaces, the strategy pattern, and polymorphism -- all working together in a clean, extensible design.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.Predicate;
import java.util.stream.Collectors;
// ============================================================
// 1. Core interface with abstract, default, static, and private methods
// ============================================================
public interface PaymentProcessor {
// Abstract methods -- every processor must implement these
boolean processPayment(double amount, String currency);
boolean refund(String transactionId, double amount);
String getProcessorName();
// Default method -- shared behavior with a sensible default
default String generateTransactionId() {
return getProcessorName().substring(0, 3).toUpperCase()
+ "-" + System.currentTimeMillis();
}
// Default method -- logging with a common format
default void logTransaction(String transactionId, double amount, String status) {
String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
System.out.println(formatLog(timestamp, transactionId, amount, status));
}
// Private method (Java 9+) -- shared formatting logic
private String formatLog(String timestamp, String txnId, double amount, String status) {
return String.format("[%s] [%s] Transaction %s: $%.2f - %s",
timestamp, getProcessorName(), txnId, amount, status);
}
// Static factory method -- creates processors without exposing implementation details
static PaymentProcessor createProcessor(String type) {
switch (type.toLowerCase()) {
case "credit_card": return new CreditCardProcessor();
case "paypal": return new PayPalProcessor();
case "crypto": return new CryptoProcessor();
default: throw new IllegalArgumentException("Unknown processor: " + type);
}
}
// Static utility method
static boolean isValidAmount(double amount) {
return amount > 0 && amount <= 1_000_000;
}
}
// ============================================================
// 2. Supplementary interface -- for processors that support subscriptions
// ============================================================
public interface SubscriptionCapable {
boolean createSubscription(String plan, double monthlyAmount);
boolean cancelSubscription(String subscriptionId);
}
// ============================================================
// 3. Functional interface -- for payment validation
// ============================================================
@FunctionalInterface
public interface PaymentValidator {
boolean validate(double amount, String currency);
// Default method to compose validators
default PaymentValidator and(PaymentValidator other) {
return (amount, currency) -> this.validate(amount, currency)
&& other.validate(amount, currency);
}
}
// ============================================================
// 4. Concrete implementations
// ============================================================
public class CreditCardProcessor implements PaymentProcessor, SubscriptionCapable {
@Override
public boolean processPayment(double amount, String currency) {
if (!PaymentProcessor.isValidAmount(amount)) {
logTransaction("INVALID", amount, "REJECTED");
return false;
}
String txnId = generateTransactionId();
// Simulate credit card processing
System.out.println("Processing credit card payment...");
System.out.println("Contacting bank for authorization...");
logTransaction(txnId, amount, "APPROVED");
return true;
}
@Override
public boolean refund(String transactionId, double amount) {
logTransaction(transactionId, amount, "REFUNDED");
return true;
}
@Override
public String getProcessorName() {
return "CreditCard";
}
@Override
public boolean createSubscription(String plan, double monthlyAmount) {
System.out.println("Credit card subscription created: " + plan
+ " at $" + monthlyAmount + "/month");
return true;
}
@Override
public boolean cancelSubscription(String subscriptionId) {
System.out.println("Subscription " + subscriptionId + " cancelled");
return true;
}
}
public class PayPalProcessor implements PaymentProcessor, SubscriptionCapable {
@Override
public boolean processPayment(double amount, String currency) {
if (!PaymentProcessor.isValidAmount(amount)) {
logTransaction("INVALID", amount, "REJECTED");
return false;
}
String txnId = generateTransactionId();
System.out.println("Redirecting to PayPal...");
System.out.println("PayPal payment confirmed.");
logTransaction(txnId, amount, "APPROVED");
return true;
}
@Override
public boolean refund(String transactionId, double amount) {
System.out.println("PayPal refund initiated (3-5 business days)");
logTransaction(transactionId, amount, "REFUNDED");
return true;
}
@Override
public String getProcessorName() {
return "PayPal";
}
@Override
public boolean createSubscription(String plan, double monthlyAmount) {
System.out.println("PayPal subscription created: " + plan);
return true;
}
@Override
public boolean cancelSubscription(String subscriptionId) {
System.out.println("PayPal subscription " + subscriptionId + " cancelled");
return true;
}
}
public class CryptoProcessor implements PaymentProcessor {
// Does NOT implement SubscriptionCapable -- crypto does not support subscriptions
@Override
public boolean processPayment(double amount, String currency) {
if (!PaymentProcessor.isValidAmount(amount)) {
logTransaction("INVALID", amount, "REJECTED");
return false;
}
String txnId = generateTransactionId();
System.out.println("Waiting for blockchain confirmation...");
logTransaction(txnId, amount, "CONFIRMED");
return true;
}
@Override
public boolean refund(String transactionId, double amount) {
System.out.println("Crypto refunds require manual wallet transfer");
logTransaction(transactionId, amount, "REFUND_PENDING");
return false;
}
@Override
public String getProcessorName() {
return "Crypto";
}
}
// ============================================================
// 5. Payment service -- ties everything together
// ============================================================
public class PaymentService {
private final List processors = new ArrayList<>();
public void registerProcessor(PaymentProcessor processor) {
processors.add(processor);
}
public boolean pay(String processorType, double amount, String currency,
PaymentValidator validator) {
// Validate first using the functional interface
if (!validator.validate(amount, currency)) {
System.out.println("Payment validation failed!");
return false;
}
// Find the right processor (programming to interface)
PaymentProcessor processor = processors.stream()
.filter(p -> p.getProcessorName().equalsIgnoreCase(processorType))
.findFirst()
.orElseThrow(() -> new RuntimeException("No processor: " + processorType));
return processor.processPayment(amount, currency);
}
// Check if a processor supports subscriptions using instanceof
public boolean subscribe(String processorType, String plan, double monthly) {
PaymentProcessor processor = processors.stream()
.filter(p -> p.getProcessorName().equalsIgnoreCase(processorType))
.findFirst()
.orElseThrow(() -> new RuntimeException("No processor: " + processorType));
if (processor instanceof SubscriptionCapable) {
return ((SubscriptionCapable) processor).createSubscription(plan, monthly);
} else {
System.out.println(processorType + " does not support subscriptions");
return false;
}
}
}
// ============================================================
// 6. Main -- running the complete example
// ============================================================
public class Main {
public static void main(String[] args) {
// --- Setup ---
PaymentService service = new PaymentService();
// Using static factory method to create processors
service.registerProcessor(PaymentProcessor.createProcessor("credit_card"));
service.registerProcessor(PaymentProcessor.createProcessor("paypal"));
service.registerProcessor(PaymentProcessor.createProcessor("crypto"));
// --- Validators using functional interfaces and lambdas ---
PaymentValidator amountCheck = (amount, currency) -> amount > 0 && amount < 10000;
PaymentValidator currencyCheck = (amount, currency) ->
Set.of("USD", "EUR", "GBP").contains(currency);
// Compose validators using the default 'and' method
PaymentValidator fullValidation = amountCheck.and(currencyCheck);
// --- Process payments ---
System.out.println("=== Credit Card Payment ===");
service.pay("CreditCard", 99.99, "USD", fullValidation);
System.out.println("\n=== PayPal Payment ===");
service.pay("PayPal", 49.50, "EUR", fullValidation);
System.out.println("\n=== Crypto Payment ===");
service.pay("Crypto", 250.00, "USD", fullValidation);
System.out.println("\n=== Invalid Payment (bad currency) ===");
service.pay("CreditCard", 50.00, "XYZ", fullValidation);
// Payment validation failed!
System.out.println("\n=== Subscriptions ===");
service.subscribe("CreditCard", "Premium", 19.99);
// Credit card subscription created: Premium at $19.99/month
service.subscribe("Crypto", "Premium", 19.99);
// Crypto does not support subscriptions
}
}
// === Sample Output ===
// === Credit Card Payment ===
// Processing credit card payment...
// Contacting bank for authorization...
// [2026-02-28 10:30:15] [CreditCard] Transaction CRE-1709125815000: $99.99 - APPROVED
//
// === PayPal Payment ===
// Redirecting to PayPal...
// PayPal payment confirmed.
// [2026-02-28 10:30:15] [PayPal] Transaction PAY-1709125815001: $49.50 - APPROVED
//
// === Crypto Payment ===
// Waiting for blockchain confirmation...
// [2026-02-28 10:30:15] [Crypto] Transaction CRY-1709125815002: $250.00 - CONFIRMED
//
// === Invalid Payment (bad currency) ===
// Payment validation failed!
//
// === Subscriptions ===
// Credit card subscription created: Premium at $19.99/month
// Crypto does not support subscriptions
| Concept | Where It Appears |
|---|---|
| Interface as a contract | PaymentProcessor defines what all processors must do |
| Multiple interface implementation | CreditCardProcessor implements PaymentProcessor, SubscriptionCapable |
| Default methods | generateTransactionId() and logTransaction() in PaymentProcessor |
| Private methods (Java 9+) | formatLog() in PaymentProcessor |
| Static methods | PaymentProcessor.createProcessor() and isValidAmount() |
| Functional interface + lambdas | PaymentValidator used with lambda expressions |
| Composing functional interfaces | amountCheck.and(currencyCheck) |
| Polymorphism | List<PaymentProcessor> holds different implementations |
| instanceof check | Checking if a processor supports SubscriptionCapable |
| Strategy pattern | Swappable processors selected at runtime |
| Factory method pattern | PaymentProcessor.createProcessor("type") |
| Programming to interfaces | All variables use interface types, not concrete classes |
| Interface Segregation | Subscription is a separate interface -- crypto does not need it |
| Loose coupling | PaymentService depends on interfaces, not implementations |
Java interfaces are one of the most important features in the language. They enable clean architecture, testable code, and flexible designs that can evolve over time. Here is what we covered:
| Topic | Key Takeaway |
|---|---|
| What is an interface | A contract that defines what a class must do, not how |
| Why use interfaces | Abstraction, loose coupling, testability, multiple inheritance |
| Syntax and rules | Methods are public abstract by default, fields are public static final |
| Default methods (Java 8+) | Add method bodies for backward compatibility |
| Static methods (Java 8+) | Utility and factory methods that belong to the interface |
| Private methods (Java 9+) | Share code between default methods without exposing it |
| Functional interfaces | Single abstract method -- enables lambdas and method references |
| Interface vs abstract class | Interfaces for capabilities, abstract classes for shared state |
| Marker interfaces | Empty interfaces that tag classes (Serializable, Cloneable) |
| Interface inheritance | Interfaces can extend multiple interfaces |
| Design patterns | Strategy, Repository, and Service Layer all rely on interfaces |
| Best practices | Keep them small, name them well, program to interfaces |
The best way to internalize these concepts is to practice. Take any class in your codebase and ask: "Would this be more flexible if it depended on an interface instead of a concrete class?" More often than not, the answer is yes.
A class is a blueprint for creating objects. It defines what data an object holds (fields) and what actions an object can perform (methods). Think of a class as an architectural blueprint for a house: the blueprint itself is not a house, but it describes exactly how to build one. You can use the same blueprint to build many houses, each with its own paint color, furniture, and residents.
In Java, everything revolves around classes. Every piece of code you write lives inside a class. Understanding classes deeply is the gateway to mastering object-oriented programming (OOP).
The distinction between a class and an object is one of the most important concepts in Java:
| Class | Object |
|---|---|
| A blueprint / template / definition | A concrete instance created from the blueprint |
| Defines fields and methods | Holds actual values in those fields |
| Exists at compile time | Exists at runtime (lives in memory) |
| Declared once | Can be instantiated many times |
| No memory allocated for fields | Memory allocated on the heap for each instance |
Example: Dog class |
Example: a specific dog named “Rex” |
Let us map a real-world concept to a Java class. Consider a Dog:
Attributes (fields) — the data that describes a dog: breed, color, age, name, weight
Behaviors (methods) — the actions a dog can perform: bark, fetch, sleep, eat
The Dog class is the general idea of “what a dog is.” An object is a specific dog, like a 3-year-old golden retriever named Max.
// The class: a blueprint for dogs
public class Dog {
// Attributes (fields) -- what a dog HAS
String name;
String breed;
String color;
int age;
// Behaviors (methods) -- what a dog DOES
public void bark() {
System.out.println(name + " says: Woof! Woof!");
}
public void fetch(String item) {
System.out.println(name + " fetches the " + item);
}
public void sleep() {
System.out.println(name + " is sleeping... Zzz");
}
}
// Creating objects (instances) from the blueprint
Dog dog1 = new Dog();
dog1.name = "Max";
dog1.breed = "Golden Retriever";
dog1.age = 3;
dog1.bark(); // Max says: Woof! Woof!
dog1.fetch("ball"); // Max fetches the ball
Dog dog2 = new Dog();
dog2.name = "Luna";
dog2.breed = "German Shepherd";
dog2.age = 5;
dog2.bark(); // Luna says: Woof! Woof!
dog2.sleep(); // Luna is sleeping... Zzz
A well-organized Java class follows a consistent structure. Here is the complete anatomy of a class, from top to bottom:
1. Package declaration — what package this class belongs to
2. Import statements — external classes this class depends on
3. Class declaration — the class name with access modifier
4. Static fields (constants first) — shared across all instances
5. Instance fields — unique to each object
6. Constructors — how to create instances
7. Public methods — the class’s API
8. Private/helper methods — internal implementation details
9. Nested classes (if any) — classes defined inside this class
This ordering is a widely followed convention (used by Google, Oracle, and most enterprise codebases). It makes classes predictable and easy to navigate.
package com.lovemesomecoding.model; // 1. Package declaration
import java.util.Objects; // 2. Import statements
import java.time.LocalDate;
public class Employee { // 3. Class declaration
// 4. Static fields (constants first)
private static final String COMPANY = "LoveMeSomeCoding";
private static int employeeCount = 0;
// 5. Instance fields
private String firstName;
private String lastName;
private String email;
private double salary;
private LocalDate hireDate;
// 6. Constructors
public Employee(String firstName, String lastName, String email, double salary) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.salary = salary;
this.hireDate = LocalDate.now();
employeeCount++;
}
// 7. Public methods
public String getFullName() {
return firstName + " " + lastName;
}
public double getAnnualSalary() {
return salary * 12;
}
// 8. Private/helper methods
private String formatEmail() {
return email.toLowerCase().trim();
}
// 9. toString, equals, hashCode (standard overrides)
@Override
public String toString() {
return "Employee{name='" + getFullName() + "', email='" + email + "'}";
}
}
Fields (also called instance variables or member variables) are the data that each object carries. Every time you create a new object with new, Java allocates memory for all the fields declared in the class, and each object gets its own independent copy.
Fields are declared inside the class body but outside any method or constructor. The syntax is:
accessModifier type fieldName = optionalInitialValue;
public class Product {
// Field declarations with explicit initial values
private String name = "Unknown";
private double price = 0.0;
private int quantity = 0;
private boolean inStock = false;
// Field declaration without initial values (uses defaults)
private String category; // default: null
private double discount; // default: 0.0
private int reviewCount; // default: 0
private boolean featured; // default: false
}
Unlike local variables (which must be initialized before use), instance fields receive default values automatically if you do not assign one. This is a critical difference.
| Field Type | Default Value |
|---|---|
byte, short, int, long |
0 |
float, double |
0.0 |
char |
'\u0000' (null character) |
boolean |
false |
| Any object type (String, arrays, etc.) | null |
You control who can see and modify a field using access modifiers:
| Modifier | Same Class | Same Package | Subclass | Everywhere |
|---|---|---|---|---|
private |
Yes | No | No | No |
| (default / no modifier) | Yes | Yes | No | No |
protected |
Yes | Yes | Yes | No |
public |
Yes | Yes | Yes | Yes |
Best practice: Always make fields private and provide public getters and setters. This is the principle of encapsulation — you control how your data is accessed and modified. We will cover this in detail in the Getters and Setters section.
Java field names follow camelCase: start with a lowercase letter, capitalize each subsequent word. Choose names that clearly describe the data they hold.
// Good -- descriptive, camelCase private String firstName; private double accountBalance; private int totalOrderCount; private boolean isActive; // boolean fields often start with "is", "has", "can" private boolean hasPermission; // Bad -- vague, wrong conventions private String s; // too short, meaningless private double Account_Balance; // wrong: uses underscores and uppercase start private int x; // what does x represent? private boolean flag; // flag for what?
A constructor is a special method that is called when you create a new object. Its job is to initialize the object’s fields with meaningful values so the object is ready to use immediately after creation.
Key facts about constructors:
void. If you add a return type, Java treats it as a regular method, not a constructornew keywordIf you do not write any constructor, Java provides a default no-argument constructor automatically. It does nothing except create the object with default field values. However, the moment you write any constructor yourself, Java stops providing the default one.
// Java provides a default constructor automatically here
// because we did not write any constructor
public class User {
private String name;
private int age;
}
// This works because the default constructor exists
User user = new User();
// user.name is null, user.age is 0 (default values)
A parameterized constructor accepts arguments so you can set field values at the time of object creation. This is the most common kind of constructor in real-world code.
public class User {
private String name;
private String email;
private int age;
// Parameterized constructor
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
}
// Creating an object with the parameterized constructor
User user = new User("Folau", "folau@email.com", 30);
// This will NOT compile -- no default constructor exists anymore
// User user2 = new User(); // Compilation error!
You can define multiple constructors with different parameter lists. This gives callers flexibility in how they create objects. Java determines which constructor to call based on the arguments you pass.
public class User {
private String name;
private String email;
private int age;
// Constructor 1: all fields
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
// Constructor 2: name and email only, default age
public User(String name, String email) {
this.name = name;
this.email = email;
this.age = 0; // default
}
// Constructor 3: no arguments
public User() {
this.name = "Unknown";
this.email = "none@example.com";
this.age = 0;
}
}
// All three ways to create a User
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Lisa", "lisa@email.com");
User user3 = new User();
this()When you have multiple constructors, you often see repeated initialization code. Constructor chaining solves this by having one constructor call another using this(). The this() call must be the first statement in the constructor.
The idea is to funnel all constructors through a single “primary” constructor that contains the actual initialization logic. This avoids code duplication.
public class User {
private String name;
private String email;
private int age;
// Primary constructor -- all initialization happens here
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
System.out.println("User created: " + name);
}
// Chains to the primary constructor with a default age
public User(String name, String email) {
this(name, email, 0); // calls User(String, String, int)
}
// Chains to the two-argument constructor, which chains to the primary
public User() {
this("Unknown", "none@example.com"); // calls User(String, String)
}
}
// All three constructors eventually call the primary constructor
User user1 = new User("Folau", "folau@email.com", 30); // User created: Folau
User user2 = new User("Lisa", "lisa@email.com"); // User created: Lisa
User user3 = new User(); // User created: Unknown
A copy constructor creates a new object by copying the field values from an existing object. Java does not provide this automatically (unlike C++), but it is a useful pattern when you need a duplicate of an object.
public class User {
private String name;
private String email;
private int age;
// Regular constructor
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
// Copy constructor -- creates a new User from an existing one
public User(User other) {
this.name = other.name;
this.email = other.email;
this.age = other.age;
}
}
User original = new User("Folau", "folau@email.com", 30);
User copy = new User(original); // independent copy
System.out.println(original == copy); // false -- different objects in memory
// But they have the same field values
new KeywordYou create an object (an instance of a class) using the new keyword. Here is what happens step by step when you write User user = new User("Folau", "folau@email.com", 30):
1. Memory allocation — Java allocates space on the heap (the area of memory for objects) large enough to hold all the instance fields of the User class.
2. Field initialization — All fields are set to their default values (null, 0, false).
3. Constructor execution — The matching constructor runs and sets the fields to the values you provided.
4. Reference returned — The new expression returns a reference (a memory address) to the newly created object. This reference is stored in the variable user.
The variable user does not contain the object itself. It contains a reference (like a pointer) to where the object lives on the heap.
// Creating objects
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Lisa", "lisa@email.com", 28);
// user1 and user2 are separate objects on the heap
System.out.println(user1 == user2); // false -- different objects
A variable of a class type holds a reference to an object, not the object itself. This has important consequences.
// Two references pointing to the SAME object
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = user1; // user2 now points to the same object as user1
// Modifying through user2 also affects user1 -- they share the same object
user2.setName("Folau K.");
System.out.println(user1.getName()); // Output: Folau K.
System.out.println(user1 == user2); // Output: true -- same reference
// Null references
User user3 = null; // user3 does not point to any object
// Calling a method on null throws NullPointerException
// user3.getName(); // NullPointerException at runtime!
// Always check for null before using a reference
if (user3 != null) {
System.out.println(user3.getName());
} else {
System.out.println("user3 is null"); // Output: user3 is null
}
Think of references like remote controls and objects like TVs. The remote (reference) lets you interact with the TV (object), but the remote is not the TV. You can have two remotes controlling the same TV, and changing the channel with either remote affects the same TV. Setting a remote to null is like throwing away the remote — the TV still exists until Java’s garbage collector reclaims it.
// Three references, but how many objects?
User a = new User("Alice", "alice@email.com", 25); // Object 1 created
User b = new User("Bob", "bob@email.com", 30); // Object 2 created
User c = a; // c points to Object 1 (same as a)
// Answer: 2 objects, 3 references
// a and c -> Object 1
// b -> Object 2
a = null; // a no longer points to Object 1, but c still does
// Object 1 is NOT garbage collected because c still references it
c = null; // Now nothing references Object 1
// Object 1 is eligible for garbage collection
this KeywordThe this keyword refers to the current object — the specific instance on which a method or constructor is being called. It is one of the most frequently used keywords in Java class code.
The most common use of this is to distinguish between a field and a constructor/method parameter that share the same name.
public class User {
private String name;
private int age;
public User(String name, int age) {
// Without "this", Java thinks you are assigning the parameter to itself
// name = name; // WRONG: does nothing useful
// "this.name" refers to the field, "name" refers to the parameter
this.name = name;
this.age = age;
}
public void setName(String name) {
this.name = name; // this.name = field, name = parameter
}
}
this for Method Chaining (Fluent API)A powerful pattern is to return this from setter methods. This enables method chaining, also called a fluent API, where you can call multiple methods in a single statement. Many popular libraries (builders, query builders, testing frameworks) use this pattern.
public class UserBuilder {
private String name;
private String email;
private int age;
public UserBuilder setName(String name) {
this.name = name;
return this; // return the current object
}
public UserBuilder setEmail(String email) {
this.email = email;
return this;
}
public UserBuilder setAge(int age) {
this.age = age;
return this;
}
public User build() {
return new User(name, email, age);
}
}
// Method chaining -- clean and readable
User user = new UserBuilder()
.setName("Folau")
.setEmail("folau@email.com")
.setAge(30)
.build();
this as an ArgumentYou can pass this as an argument to another method or constructor when the other object needs a reference to the current object.
public class Order {
private int orderId;
public Order(int orderId) {
this.orderId = orderId;
}
public void process(OrderProcessor processor) {
// Pass the current Order object to the processor
processor.process(this);
}
public int getOrderId() {
return orderId;
}
}
public class OrderProcessor {
public void process(Order order) {
System.out.println("Processing order #" + order.getOrderId());
}
}
// Usage
Order order = new Order(1001);
OrderProcessor processor = new OrderProcessor();
order.process(processor); // Output: Processing order #1001
When you declare a class at the top level (not nested inside another class), you have two options:
| Modifier | Visibility | Rules |
|---|---|---|
public |
Accessible from any other class, any package | The file name must match the class name. Only one public class per .java file. |
| (default / no modifier) | Package-private — accessible only within the same package | File name does not have to match. You can have multiple default classes in one file. |
// File: User.java
// This public class MUST be in a file named "User.java"
public class User {
private String name;
public User(String name) {
this.name = name;
}
}
// This package-private class can also be in User.java
// but it is only visible within the same package
class UserValidator {
public boolean isValid(User user) {
return user != null;
}
}
// Classes in other packages CANNOT see UserValidator
// Only User is accessible everywhere
Encapsulation is the practice of keeping fields private and providing controlled access through public methods called getters and setters. This is one of the four pillars of object-oriented programming.
If you make fields public, any code anywhere can change them to anything, including invalid values. There is no way to:
By using getters and setters, you put a gatekeeper in front of your data.
Java follows strict conventions for getter and setter names. These conventions are used by many frameworks (Spring, Jackson, Hibernate) to automatically discover properties.
| Field Type | Getter Pattern | Setter Pattern |
|---|---|---|
| Non-boolean fields | getFieldName() |
setFieldName(value) |
| Boolean fields | isFieldName() |
setFieldName(value) |
public class User {
private String name;
private String email;
private int age;
private boolean active;
public User(String name, String email, int age) {
this.name = name;
setEmail(email); // use the setter for validation even in the constructor
setAge(age);
this.active = true;
}
// --- Getters ---
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public int getAge() {
return age;
}
// Boolean getter uses "is" prefix
public boolean isActive() {
return active;
}
// --- Setters with Validation ---
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be null or blank");
}
this.name = name;
}
public void setEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
this.email = email.toLowerCase().trim();
}
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150, got: " + age);
}
this.age = age;
}
public void setActive(boolean active) {
this.active = active;
}
}
// Usage
User user = new User("Folau", "Folau@Email.com", 30);
System.out.println(user.getEmail()); // Output: folau@email.com (trimmed and lowercased)
System.out.println(user.isActive()); // Output: true
// Validation prevents invalid data
// user.setAge(-5); // throws IllegalArgumentException: Age must be between 0 and 150, got: -5
// user.setEmail("bad"); // throws IllegalArgumentException: Invalid email: bad
Every class in Java inherits from java.lang.Object, which provides default implementations of toString(), equals(), and hashCode(). The defaults are almost never what you want, so you should override them in your classes.
The default toString() returns something like User@1a2b3c4d (class name + memory address hash). That is useless for debugging. Override it to return a meaningful string representation of the object.
public class User {
private String name;
private String email;
private int age;
// Constructor omitted for brevity
@Override
public String toString() {
return "User{name='" + name + "', email='" + email + "', age=" + age + "}";
}
}
User user = new User("Folau", "folau@email.com", 30);
// Without override: User@1a2b3c4d
// With override:
System.out.println(user); // Output: User{name='Folau', email='folau@email.com', age=30}
The default equals() compares memory addresses (same as ==). Two different objects with identical field values would be considered “not equal.” You override equals() to compare the actual field values.
The contract: If you override equals(), you must also override hashCode(). Objects that are equal must return the same hash code. If you break this contract, objects will behave incorrectly in HashMap, HashSet, and other hash-based collections.
The easiest way to implement these correctly is to use Objects.equals() and Objects.hash() from java.util.Objects.
import java.util.Objects;
public class User {
private String name;
private String email;
private int age;
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
@Override
public boolean equals(Object obj) {
// 1. Same reference? Must be equal.
if (this == obj) return true;
// 2. Null or different class? Cannot be equal.
if (obj == null || getClass() != obj.getClass()) return false;
// 3. Cast and compare fields
User other = (User) obj;
return age == other.age
&& Objects.equals(name, other.name)
&& Objects.equals(email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(name, email, age);
}
@Override
public String toString() {
return "User{name='" + name + "', email='" + email + "', age=" + age + "}";
}
}
// Demonstration
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Folau", "folau@email.com", 30);
User user3 = new User("Lisa", "lisa@email.com", 28);
System.out.println(user1 == user2); // false -- different objects in memory
System.out.println(user1.equals(user2)); // true -- same field values
System.out.println(user1.equals(user3)); // false -- different field values
// Now they work correctly in collections
Set users = new HashSet<>();
users.add(user1);
users.add(user2); // duplicate, not added
System.out.println(users.size()); // Output: 1
The static keyword means “this belongs to the class, not to any specific instance.” Static members are shared across all instances of the class. There is exactly one copy in memory, regardless of how many objects you create.
A static field is shared by all instances. A common use case is tracking how many instances have been created, or storing a constant value that applies to the whole class.
public class User {
// Static field -- shared across ALL User objects
private static int userCount = 0;
// Static constant -- shared and immutable
public static final int MAX_USERS = 10000;
// Instance fields -- unique to each object
private String name;
private int userId;
public User(String name) {
this.name = name;
userCount++; // increment the shared counter
this.userId = userCount; // assign a unique ID
}
// Static method -- access through the class name, not an object
public static int getUserCount() {
return userCount;
}
public String getName() {
return name;
}
public int getUserId() {
return userId;
}
}
User alice = new User("Alice");
User bob = new User("Bob");
User charlie = new User("Charlie");
System.out.println(User.getUserCount()); // Output: 3 (shared counter)
System.out.println(User.MAX_USERS); // Output: 10000
System.out.println(alice.getUserId()); // Output: 1
System.out.println(bob.getUserId()); // Output: 2
System.out.println(charlie.getUserId()); // Output: 3
A static method belongs to the class and can be called without creating an instance. Static methods cannot access instance fields or use this, because there is no “current object” when you call a method on a class.
public class MathHelper {
// Static method -- called on the class, not an instance
public static int add(int a, int b) {
return a + b;
}
public static double celsiusToFahrenheit(double celsius) {
return (celsius * 9.0 / 5.0) + 32;
}
public static boolean isEven(int number) {
return number % 2 == 0;
}
}
// Called using the class name -- no object needed
int sum = MathHelper.add(10, 20); // 30
double temp = MathHelper.celsiusToFahrenheit(100); // 212.0
boolean even = MathHelper.isEven(7); // false
A static block runs once when the class is first loaded into memory, before any objects are created. It is used to initialize complex static fields.
public class AppConfig {
private static final Map CONFIG;
// Static block -- runs once when the class loads
static {
CONFIG = new HashMap<>();
CONFIG.put("app.name", "LoveMeSomeCoding");
CONFIG.put("app.version", "2.0");
CONFIG.put("app.env", "production");
System.out.println("AppConfig loaded.");
}
public static String get(String key) {
return CONFIG.getOrDefault(key, "unknown");
}
}
// The static block runs when this class is first referenced
String name = AppConfig.get("app.name"); // Output (first call): AppConfig loaded.
System.out.println(name); // Output: LoveMeSomeCoding
String version = AppConfig.get("app.version");
System.out.println(version); // Output: 2.0
| Use Static When… | Example |
|---|---|
| The value is the same for all instances | Constants (MAX_SIZE, PI) |
| The method does not need instance data | Utility methods (Math.abs(), Collections.sort()) |
| You need a class-level counter or registry | Instance counters, object pools |
| You need to run initialization code once | Loading configuration, setting up caches |
Do not use static when: the data or behavior is different per object. If two users have different names, name must be an instance field, not static.
Java allows you to define a class inside another class. These are called inner classes or nested classes. They are useful for grouping helper classes with the class that uses them, improving encapsulation and readability.
A static nested class is declared with the static keyword. It does not have access to the outer class’s instance fields or methods — it behaves like a regular top-level class that happens to be scoped inside another class.
public class User {
private String name;
private Address address;
public User(String name, Address address) {
this.name = name;
this.address = address;
}
// Static nested class -- does NOT need a User instance
public static class Address {
private String street;
private String city;
private String state;
private String zip;
public Address(String street, String city, String state, String zip) {
this.street = street;
this.city = city;
this.state = state;
this.zip = zip;
}
@Override
public String toString() {
return street + ", " + city + ", " + state + " " + zip;
}
}
@Override
public String toString() {
return name + " - " + address;
}
}
// Creating the static nested class -- no User instance needed
User.Address address = new User.Address("123 Main St", "Orem", "UT", "84058");
User user = new User("Folau", address);
System.out.println(user); // Output: Folau - 123 Main St, Orem, UT 84058
A non-static inner class has access to the outer class’s instance fields and methods, including private ones. It requires an instance of the outer class to exist.
public class ShoppingCart {
private String customerName;
private List- items = new ArrayList<>();
public ShoppingCart(String customerName) {
this.customerName = customerName;
}
public Item addItem(String productName, double price, int quantity) {
Item item = new Item(productName, price, quantity);
items.add(item);
return item;
}
public double getTotal() {
double total = 0;
for (Item item : items) {
total += item.getSubtotal();
}
return total;
}
// Non-static inner class -- has access to ShoppingCart's fields
public class Item {
private String productName;
private double price;
private int quantity;
public Item(String productName, double price, int quantity) {
this.productName = productName;
this.price = price;
this.quantity = quantity;
}
public double getSubtotal() {
return price * quantity;
}
@Override
public String toString() {
// Can access the outer class's customerName field
return customerName + "'s item: " + productName
+ " ($" + price + " x " + quantity + ")";
}
}
}
ShoppingCart cart = new ShoppingCart("Folau");
ShoppingCart.Item item1 = cart.addItem("Java Book", 49.99, 1);
ShoppingCart.Item item2 = cart.addItem("Coffee Mug", 12.50, 3);
System.out.println(item1); // Output: Folau's item: Java Book ($49.99 x 1)
System.out.println(item2); // Output: Folau's item: Coffee Mug ($12.5 x 3)
System.out.println(cart.getTotal()); // Output: 87.49
An anonymous class is a one-time-use class defined and instantiated in a single expression. You will commonly see them used with interfaces or abstract classes when you need a quick implementation without creating a separate file.
// Suppose we have this interface
public interface Greeting {
void greet(String name);
}
// Anonymous class -- define and instantiate in one expression
Greeting formalGreeting = new Greeting() {
@Override
public void greet(String name) {
System.out.println("Good day, " + name + ". How do you do?");
}
};
formalGreeting.greet("Folau"); // Output: Good day, Folau. How do you do?
// Since Java 8, for single-method interfaces, you can use a lambda instead:
Greeting casualGreeting = name -> System.out.println("Hey, " + name + "!");
casualGreeting.greet("Folau"); // Output: Hey, Folau!
| Type | When to Use | Example |
|---|---|---|
| Static nested class | Helper class that does not need outer instance data | Builder pattern, DTO grouping (User.Address) |
| Non-static inner class | Tightly coupled to outer class, needs outer instance | Iterator inside a collection class |
| Anonymous class | One-time implementation of an interface/abstract class | Event handlers, comparators, callbacks |
Writing a class that works is one thing. Writing a class that is clean, maintainable, and professional is what separates junior developers from senior ones. Here are the practices used in production codebases.
A class should have one reason to change. If your class handles user data, email sending, database queries, and PDF generation, it is doing too much. Break it into focused classes: User, EmailService, UserRepository, PdfGenerator.
Always declare fields as private. Expose them through getters and setters only when necessary. If a field does not need to be changed from outside, do not provide a setter.
Never allow an object to be created in an invalid state. Validate parameters in constructors and throw IllegalArgumentException for bad input.
The default toString() is useless for debugging. Override it in every class to show meaningful state.
If your objects will be compared or placed in collections, always override both. Use Objects.equals() and Objects.hash() for clean, null-safe implementations.
An immutable class cannot be changed after creation. Immutable objects are inherently thread-safe, easy to reason about, and safe to share. To make a class immutable:
final (cannot be subclassed)private finalClass names should be nouns in PascalCase: User, OrderService, BankAccount. Avoid generic names like Data, Info, Manager unless truly appropriate. The name should tell you what the class represents.
Follow the order described in Section 2 (constants, static fields, instance fields, constructors, public methods, private methods). Every class in your codebase should follow the same pattern.
// Example: An immutable class following best practices
public final class Money {
private final double amount;
private final String currency;
public Money(double amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative: " + amount);
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("Currency cannot be null or blank");
}
this.amount = amount;
this.currency = currency.toUpperCase();
}
// Only getters -- no setters (immutable)
public double getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
// Operations return NEW objects instead of modifying this one
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot add " + this.currency + " and " + other.currency
);
}
return new Money(this.amount + other.amount, this.currency);
}
@Override
public String toString() {
return String.format("%s %.2f", currency, amount);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Money other = (Money) obj;
return Double.compare(other.amount, amount) == 0
&& Objects.equals(currency, other.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
Money price = new Money(29.99, "usd");
Money tax = new Money(2.40, "usd");
Money total = price.add(tax);
System.out.println(price); // Output: USD 29.99
System.out.println(tax); // Output: USD 2.40
System.out.println(total); // Output: USD 32.39
// price, tax, and total are all separate, immutable objects
// None of them can ever be changed after creation
Let us put everything together in a complete, production-style class that demonstrates all the concepts from this tutorial: fields, multiple constructors, constructor chaining, encapsulation with getters/setters, validation, toString/equals/hashCode, static members, and best practices.
import java.util.Objects;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class BankAccount {
// --- Static fields ---
private static final double MINIMUM_BALANCE = 0.0;
private static final double MAX_WITHDRAWAL = 10000.0;
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static int accountCounter = 0;
// --- Instance fields ---
private final String accountNumber;
private String ownerName;
private String email;
private double balance;
private boolean active;
private final LocalDateTime createdAt;
private final List transactionHistory;
// --- Constructors ---
// Primary constructor -- all initialization happens here
public BankAccount(String ownerName, String email, double initialDeposit) {
if (ownerName == null || ownerName.isBlank()) {
throw new IllegalArgumentException("Owner name cannot be null or blank");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
if (initialDeposit < 0) {
throw new IllegalArgumentException(
"Initial deposit cannot be negative: " + initialDeposit
);
}
accountCounter++;
this.accountNumber = "ACC-" + String.format("%06d", accountCounter);
this.ownerName = ownerName;
this.email = email.toLowerCase().trim();
this.balance = initialDeposit;
this.active = true;
this.createdAt = LocalDateTime.now();
this.transactionHistory = new ArrayList<>();
this.transactionHistory.add(formatTransaction("Account opened", initialDeposit));
}
// Constructor chaining -- zero initial deposit
public BankAccount(String ownerName, String email) {
this(ownerName, email, 0.0);
}
// Copy constructor
public BankAccount(BankAccount other) {
this(other.ownerName, other.email, other.balance);
}
// --- Static methods ---
public static int getTotalAccounts() {
return accountCounter;
}
public static double getMinimumBalance() {
return MINIMUM_BALANCE;
}
// --- Public methods (business logic) ---
public void deposit(double amount) {
validateActive();
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive: " + amount);
}
balance += amount;
transactionHistory.add(formatTransaction("Deposit", amount));
}
public void withdraw(double amount) {
validateActive();
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
}
if (amount > MAX_WITHDRAWAL) {
throw new IllegalArgumentException(
"Withdrawal exceeds max limit of $" + MAX_WITHDRAWAL + ": $" + amount
);
}
if (balance - amount < MINIMUM_BALANCE) {
throw new IllegalStateException(
"Insufficient funds. Balance: $" + balance + ", Requested: $" + amount
);
}
balance -= amount;
transactionHistory.add(formatTransaction("Withdrawal", -amount));
}
public void transfer(BankAccount target, double amount) {
if (target == null) {
throw new IllegalArgumentException("Target account cannot be null");
}
this.withdraw(amount);
target.deposit(amount);
this.transactionHistory.add(
formatTransaction("Transfer to " + target.accountNumber, -amount)
);
target.transactionHistory.add(
formatTransaction("Transfer from " + this.accountNumber, amount)
);
}
public void deactivate() {
this.active = false;
transactionHistory.add(formatTransaction("Account deactivated", 0));
}
// --- Getters ---
public String getAccountNumber() {
return accountNumber;
}
public String getOwnerName() {
return ownerName;
}
public String getEmail() {
return email;
}
public double getBalance() {
return balance;
}
public boolean isActive() {
return active;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
// Return an unmodifiable view -- defensive copy for encapsulation
public List getTransactionHistory() {
return Collections.unmodifiableList(transactionHistory);
}
// --- Setters (only for fields that can change) ---
public void setOwnerName(String ownerName) {
if (ownerName == null || ownerName.isBlank()) {
throw new IllegalArgumentException("Owner name cannot be null or blank");
}
this.ownerName = ownerName;
}
public void setEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
this.email = email.toLowerCase().trim();
}
// No setters for accountNumber, balance, createdAt -- these are
// controlled internally or are immutable
// --- Private helper methods ---
private void validateActive() {
if (!active) {
throw new IllegalStateException("Account " + accountNumber + " is deactivated");
}
}
private String formatTransaction(String type, double amount) {
return String.format("[%s] %s: $%.2f | Balance: $%.2f",
LocalDateTime.now().format(FORMATTER), type, amount, balance);
}
// --- toString, equals, hashCode ---
@Override
public String toString() {
return "BankAccount{" +
"accountNumber='" + accountNumber + '\'' +
", owner='" + ownerName + '\'' +
", balance=$" + String.format("%.2f", balance) +
", active=" + active +
'}';
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
BankAccount other = (BankAccount) obj;
return Objects.equals(accountNumber, other.accountNumber);
}
@Override
public int hashCode() {
return Objects.hash(accountNumber);
}
}
Here is a complete main method that exercises every feature of the BankAccount class.
public class BankApp {
public static void main(String[] args) {
// --- Creating accounts (constructors) ---
BankAccount folau = new BankAccount("Folau", "folau@email.com", 5000.00);
BankAccount lisa = new BankAccount("Lisa", "lisa@email.com", 3000.00);
BankAccount guest = new BankAccount("Guest", "guest@email.com"); // zero balance
System.out.println("=== Accounts Created ===");
System.out.println(folau);
System.out.println(lisa);
System.out.println(guest);
System.out.println("Total accounts: " + BankAccount.getTotalAccounts());
// --- Deposits and withdrawals ---
System.out.println("\n=== Transactions ===");
folau.deposit(1500.00);
System.out.println("After deposit: " + folau.getBalance()); // 6500.00
folau.withdraw(2000.00);
System.out.println("After withdrawal: " + folau.getBalance()); // 4500.00
// --- Transfer ---
System.out.println("\n=== Transfer ===");
folau.transfer(lisa, 1000.00);
System.out.println("Folau's balance: $" + folau.getBalance()); // 3500.00
System.out.println("Lisa's balance: $" + lisa.getBalance()); // 4000.00
// --- Encapsulation: setter validation ---
System.out.println("\n=== Update Email ===");
folau.setEmail(" FOLAU.K@Email.com ");
System.out.println("New email: " + folau.getEmail()); // folau.k@email.com
// --- toString ---
System.out.println("\n=== toString ===");
System.out.println(folau);
// --- equals and hashCode ---
System.out.println("\n=== equals ===");
BankAccount folauCopy = new BankAccount(folau); // copy constructor
System.out.println("folau == folauCopy: " + (folau == folauCopy));
System.out.println("folau.equals(folauCopy): " + folau.equals(folauCopy));
// false -- copy constructor creates a new account number
// --- Transaction history (defensive copy) ---
System.out.println("\n=== Transaction History ===");
for (String tx : folau.getTransactionHistory()) {
System.out.println(" " + tx);
}
// --- Deactivation ---
System.out.println("\n=== Deactivation ===");
guest.deactivate();
System.out.println(guest);
// This would throw IllegalStateException:
// guest.deposit(100.00); // Account ACC-000003 is deactivated
// --- Static members ---
System.out.println("\n=== Static Info ===");
System.out.println("Total accounts created: " + BankAccount.getTotalAccounts());
System.out.println("Minimum balance: $" + BankAccount.getMinimumBalance());
}
}
/*
* Output:
* === Accounts Created ===
* BankAccount{accountNumber='ACC-000001', owner='Folau', balance=$5000.00, active=true}
* BankAccount{accountNumber='ACC-000002', owner='Lisa', balance=$3000.00, active=true}
* BankAccount{accountNumber='ACC-000003', owner='Guest', balance=$0.00, active=true}
* Total accounts: 3
*
* === Transactions ===
* After deposit: 6500.0
* After withdrawal: 4500.0
*
* === Transfer ===
* Folau's balance: $3500.0
* Lisa's balance: $4000.0
*
* === Update Email ===
* New email: folau.k@email.com
*
* === toString ===
* BankAccount{accountNumber='ACC-000001', owner='Folau', balance=$3500.00, active=true}
*
* === equals ===
* folau == folauCopy: false
* folau.equals(folauCopy): false
*
* === Transaction History ===
* [2026-02-28 10:00:00] Account opened: $5000.00 | Balance: $5000.00
* [2026-02-28 10:00:01] Deposit: $1500.00 | Balance: $6500.00
* [2026-02-28 10:00:01] Withdrawal: $-2000.00 | Balance: $4500.00
* [2026-02-28 10:00:01] Transfer to ACC-000002: $-1000.00 | Balance: $3500.00
*
* === Deactivation ===
* BankAccount{accountNumber='ACC-000003', owner='Guest', balance=$0.00, active=false}
*
* === Static Info ===
* Total accounts created: 4
* Minimum balance: $0.0
*/
A Java class is the fundamental building block of object-oriented programming. Here is what you should take away from this tutorial:
A class is a blueprint that defines what data an object holds (fields) and what actions it can perform (methods). An object is a concrete instance created from that blueprint using the new keyword.
Fields should always be private. Use getters and setters to provide controlled access with validation. This is the principle of encapsulation.
Constructors initialize objects. Use constructor overloading for flexibility and constructor chaining (this()) to avoid code duplication.
The this keyword refers to the current object. Use it to disambiguate fields from parameters, enable method chaining, and pass the current object to other methods.
Override toString() for readable debugging output. Override equals() and hashCode() together if your objects will be compared or stored in hash-based collections.
Static members belong to the class, not to instances. Use them for shared constants, utility methods, and counters.
Inner classes help group related classes. Use static nested classes for helpers that do not need outer instance data, and non-static inner classes when tight coupling with the outer class is required.
Follow best practices: single responsibility, meaningful names, immutable objects where possible, consistent class structure, and always validate input. Write classes as if the next developer to read your code is a senior engineer reviewing your pull request -- because they probably are.
A method is a named block of code that performs a specific task. You define it once and then call (invoke) it as many times as you need. Methods are the fundamental building blocks of behavior in Java — they describe what an object can do.
Think of it in real-world terms: if a class represents a noun (a Car, a Student, a BankAccount), then its methods represent the verbs — the actions that noun can perform. A Car can start(), accelerate(), and brake(). A BankAccount can deposit(), withdraw(), and getBalance().
calculateTax() method instead of copying and pasting the formula ten times.Collections.sort(list) without knowing the sorting algorithm used internally.calculateDiscount(price, percentage) returns the correct value without running the entire application.Every Java program starts executing at a method: public static void main(String[] args). By the end of this tutorial, you will understand every keyword in that signature and be able to design well-structured methods of your own.
Every method in Java follows a specific structure. Here is the complete anatomy:
accessModifier [static] [final] returnType methodName(parameterList) [throws ExceptionType] {
// method body - the statements that execute when the method is called
return value; // required if returnType is not void
}
Let us break down each component with a concrete example:
public static double calculateTax(double price, double taxRate) {
double tax = price * taxRate;
return tax;
}
| Component | In Our Example | Purpose |
|---|---|---|
| Access Modifier | public |
Controls who can call this method (public, private, protected, or default) |
| Static Modifier | static |
Optional. Makes the method belong to the class itself rather than an instance |
| Return Type | double |
The data type of the value the method sends back to the caller |
| Method Name | calculateTax |
A descriptive name following camelCase convention |
| Parameter List | (double price, double taxRate) |
Input values the method needs to do its work. Can be empty: () |
| Method Body | { double tax = price * taxRate; return tax; } |
The code that executes when the method is called, enclosed in curly braces |
| Return Statement | return tax; |
Sends a value back to the caller. Omitted only if return type is void |
The combination of a method’s name and its parameter list is called the method signature. The method signature is what Java uses to distinguish one method from another. For example, calculateTax(double, double) is the signature of the method above. Note that the return type is not part of the signature.
Good method names make code self-documenting. Follow these conventions:
calculateTotal, not CalculateTotal or calculate_totalget, set, calculate, validate, is, has, find, create, deletecalculateMonthlyPayment() is better than calc()isEmpty(), hasPermission(), isValid()// Good method names - clear and descriptive
public double calculateMonthlyPayment(double principal, double rate, int months) { ... }
public boolean isEligibleForDiscount(Customer customer) { ... }
public List findOrdersByCustomerId(int customerId) { ... }
public void sendWelcomeEmail(String emailAddress) { ... }
// Bad method names - vague or poorly formatted
public double calc(double a, double b, int c) { ... } // too vague
public boolean check(Customer c) { ... } // check what?
public List data(int id) { ... } // noun, not a verb
public void DoSomething(String s) { ... } // wrong casing
Access modifiers control the visibility of a method — who is allowed to call it. Java provides four levels of access:
| Modifier | Same Class | Same Package | Subclass (different package) | Any Class |
|---|---|---|---|---|
public |
Yes | Yes | Yes | Yes |
protected |
Yes | Yes | Yes | No |
| default (no keyword) | Yes | Yes | No | No |
private |
Yes | No | No | No |
public — The method is part of the class’s public API. Other classes are expected to call it. Example: public double getBalance()private — The method is an internal implementation detail. It is a helper that only this class should call. Example: private boolean validateAccountNumber(String number)protected — The method is meant for use by subclasses (child classes) that extend this class, or by other classes in the same package. Example: protected void onInitialize()void processInternalQueue()Rule of thumb: Start with private and widen the access only when necessary. This is the principle of least privilege — exposing as little as possible keeps your code safe and easy to refactor.
public class BankAccount {
private double balance;
// PUBLIC - anyone can call this to deposit money
public void deposit(double amount) {
if (validateAmount(amount)) {
balance += amount;
logTransaction("DEPOSIT", amount);
}
}
// PUBLIC - anyone can check the balance
public double getBalance() {
return balance;
}
// PRIVATE - only this class needs to validate amounts
private boolean validateAmount(double amount) {
return amount > 0;
}
// PRIVATE - internal logging, not part of the public API
private void logTransaction(String type, double amount) {
System.out.println(type + ": $" + amount + " | Balance: $" + balance);
}
// PROTECTED - subclasses (e.g., SavingsAccount) may override interest calculation
protected double calculateInterest() {
return balance * 0.01;
}
// DEFAULT (package-private) - only classes in the same package can call this
void processEndOfDay() {
balance += calculateInterest();
}
}
The return type declares what kind of value a method sends back to the caller. It can be any data type: a primitive, an object, an array, a collection, or the special keyword void meaning “this method returns nothing.”
A void method performs an action but does not return a value. These are common for methods that print output, modify an object’s state, or trigger side effects.
public void greet(String name) {
System.out.println("Hello, " + name + "!");
// no return statement needed (though you can use 'return;' to exit early)
}
public void setAge(int age) {
if (age < 0) {
System.out.println("Age cannot be negative.");
return; // early exit - still valid in a void method
}
this.age = age;
}
public int add(int a, int b) {
return a + b;
}
public double calculateAverage(double[] scores) {
double sum = 0;
for (double score : scores) {
sum += score;
}
return sum / scores.length;
}
public boolean isPrime(int number) {
if (number <= 1) return false;
if (number <= 3) return true;
if (number % 2 == 0 || number % 3 == 0) return false;
for (int i = 5; i * i <= number; i += 6) {
if (number % i == 0 || number % (i + 2) == 0) return false;
}
return true;
}
public String getFullName(String firstName, String lastName) {
return firstName + " " + lastName;
}
public LocalDate parseDate(String dateString) {
return LocalDate.parse(dateString); // returns a LocalDate object
}
// Returning a custom object
public Student createStudent(String name, int age, double gpa) {
Student student = new Student();
student.setName(name);
student.setAge(age);
student.setGpa(gpa);
return student;
}
// Returning an array
public int[] getFirstNPrimes(int n) {
int[] primes = new int[n];
int count = 0;
int number = 2;
while (count < n) {
if (isPrime(number)) {
primes[count] = number;
count++;
}
number++;
}
return primes;
}
// Returning a List
public List filterLongWords(List words, int minLength) {
List result = new ArrayList<>();
for (String word : words) {
if (word.length() >= minLength) {
result.add(word);
}
}
return result;
}
// Returning a Map
public Map countWordFrequency(String text) {
Map frequency = new HashMap<>();
String[] words = text.toLowerCase().split("\\s+");
for (String word : words) {
frequency.put(word, frequency.getOrDefault(word, 0) + 1);
}
return frequency;
}
Important: Every code path through a non-void method must end with a return statement. The compiler will reject code where a path exists that does not return a value.
// COMPILE ERROR - missing return on the else path
public String getGrade(int score) {
if (score >= 90) {
return "A";
}
// What if score is less than 90? No return statement!
}
// CORRECT - all paths return a value
public String getGrade(int score) {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "F"; // default covers all remaining cases
}
Parameters are the variables listed in a method's declaration. Arguments are the actual values you pass when you call the method. While often used interchangeably in casual conversation, the distinction matters:
// parameters: price and taxRate
// v v
public double calculateTax(double price, double taxRate) {
return price * taxRate;
}
// Calling the method:
// arguments: 99.99 and 0.08
// v v
double tax = calculateTax(99.99, 0.08);
This is one of the most misunderstood topics in Java. Java always passes arguments by value. There is no pass-by-reference in Java. But what "by value" means differs for primitives and objects:
For primitives: Java copies the actual value. Changing the parameter inside the method has zero effect on the original variable.
public class PassByValueDemo {
public static void tryToChange(int number) {
number = 999; // modifies the local copy only
System.out.println("Inside method: " + number);
}
public static void main(String[] args) {
int original = 42;
tryToChange(original);
System.out.println("After method call: " + original);
}
}
// Output:
// Inside method: 999
// After method call: 42 <-- original is unchanged!
For objects: Java copies the reference (the memory address), not the object itself. Both the original variable and the parameter point to the same object in memory. So you can modify the object's contents through the parameter. However, reassigning the parameter to a new object does not affect the original variable.
public class PassByValueObjects {
public static void modifyList(List list) {
list.add("Cherry"); // WORKS - modifies the same object the caller sees
System.out.println("Inside method (after add): " + list);
list = new ArrayList<>(); // reassigns the LOCAL copy of the reference
list.add("Dragonfruit");
System.out.println("Inside method (after reassign): " + list);
}
public static void main(String[] args) {
List fruits = new ArrayList<>(Arrays.asList("Apple", "Banana"));
modifyList(fruits);
System.out.println("After method call: " + fruits);
}
}
// Output:
// Inside method (after add): [Apple, Banana, Cherry]
// Inside method (after reassign): [Dragonfruit]
// After method call: [Apple, Banana, Cherry] <-- Cherry was added, but reassignment had no effect
The takeaway: the method receives a copy of the reference. Through that copy, it can reach into the object and change its state. But it cannot make the caller's variable point to a different object.
Sometimes you do not know in advance how many arguments will be passed. Java supports varargs (variable-length arguments) using the ... syntax. Internally, Java treats varargs as an array.
public class VarargsDemo {
// The 'numbers' parameter accepts zero or more int values
public static int sum(int... numbers) {
int total = 0;
for (int n : numbers) {
total += n;
}
return total;
}
// Varargs can be combined with regular parameters
// but the varargs parameter MUST be the last one
public static String formatMessage(String prefix, String... lines) {
StringBuilder sb = new StringBuilder(prefix).append(":\n");
for (String line : lines) {
sb.append(" - ").append(line).append("\n");
}
return sb.toString();
}
public static void main(String[] args) {
System.out.println(sum()); // 0 arguments -> 0
System.out.println(sum(5)); // 1 argument -> 5
System.out.println(sum(1, 2, 3, 4, 5)); // 5 arguments -> 15
// You can also pass an array directly
int[] values = {10, 20, 30};
System.out.println(sum(values)); // 60
System.out.println(formatMessage("Errors", "Null pointer", "Out of bounds"));
}
}
// Output:
// 0
// 5
// 15
// 60
// Errors:
// - Null pointer
// - Out of bounds
Varargs rules to remember:
Method overloading means defining multiple methods with the same name but different parameter lists in the same class. Java determines which version to call based on the number, types, and order of arguments at compile time.
You use method overloading to provide multiple ways to do the same logical operation with different inputs. The Java standard library uses this pattern extensively — for example, System.out.println() has overloaded versions for int, double, String, Object, and more.
Overloaded methods must differ in at least one of these ways:
Overloaded methods cannot differ only by:
public class AreaCalculator {
// Overloaded method: calculate area of a square
public static double calculateArea(double side) {
return side * side;
}
// Overloaded method: calculate area of a rectangle
public static double calculateArea(double length, double width) {
return length * width;
}
// Overloaded method: calculate area of a triangle
public static double calculateArea(double base, double height, boolean isTriangle) {
return 0.5 * base * height;
}
// Overloaded method: calculate area of a circle (different parameter type)
public static double calculateArea(int radius) {
return Math.PI * radius * radius;
}
public static void main(String[] args) {
System.out.println("Square (5): " + calculateArea(5.0));
System.out.println("Rectangle (4 x 6): " + calculateArea(4.0, 6.0));
System.out.println("Triangle (3, 8): " + calculateArea(3.0, 8.0, true));
System.out.println("Circle (radius 7): " + calculateArea(7));
}
}
// Output:
// Square (5): 25.0
// Rectangle (4 x 6): 24.0
// Triangle (3, 8): 12.0
// Circle (radius 7): 153.93804002589985
Consider this: if you had two methods with the same name and same parameters but different return types, how would the compiler know which one you meant?
// COMPILE ERROR - these two methods have the same signature
public int getResult(int x) {
return x * 2;
}
public double getResult(int x) { // Same name, same parameters
return x * 2.0;
}
// The compiler cannot tell which one you want when you write:
// getResult(5); <-- ambiguous!
public class NotificationService {
// Send to one recipient
public void sendNotification(String recipient, String message) {
System.out.println("Sending to " + recipient + ": " + message);
}
// Send to multiple recipients
public void sendNotification(List recipients, String message) {
for (String recipient : recipients) {
sendNotification(recipient, message); // reuses the single-recipient version
}
}
// Send with a priority level
public void sendNotification(String recipient, String message, int priority) {
String prefix = (priority >= 5) ? "[URGENT] " : "";
sendNotification(recipient, prefix + message);
}
// Send with a delay
public void sendNotification(String recipient, String message, long delayMillis) {
System.out.println("Scheduled in " + delayMillis + "ms to " + recipient + ": " + message);
}
}
This is a critical distinction that every Java developer must understand. A static method belongs to the class itself. An instance method belongs to a specific object (instance) of the class.
| Feature | Static Method | Instance Method |
|---|---|---|
| Belongs to | The class | An object (instance) |
| How to call | ClassName.method() |
object.method() |
| Can access instance variables? | No | Yes |
| Can access static variables? | Yes | Yes |
Can use this keyword? |
No | Yes |
| Common use | Utility/helper methods, factory methods | Operations on object state |
public class Temperature {
private double celsius; // instance variable - each Temperature object has its own
// INSTANCE METHOD - operates on this object's celsius value
public Temperature(double celsius) {
this.celsius = celsius;
}
public double toFahrenheit() {
return (this.celsius * 9.0 / 5.0) + 32;
}
public double toCelsius() {
return this.celsius;
}
// STATIC METHOD - does not need an object, just takes input and returns output
public static double convertCelsiusToFahrenheit(double celsius) {
return (celsius * 9.0 / 5.0) + 32;
}
public static double convertFahrenheitToCelsius(double fahrenheit) {
return (fahrenheit - 32) * 5.0 / 9.0;
}
public static void main(String[] args) {
// Using static methods - no object needed
double f = Temperature.convertCelsiusToFahrenheit(100);
System.out.println("100°C = " + f + "°F");
// Using instance methods - requires an object
Temperature boiling = new Temperature(100);
System.out.println("100°C = " + boiling.toFahrenheit() + "°F");
Temperature body = new Temperature(37);
System.out.println("Body temp: " + body.toFahrenheit() + "°F");
}
}
// Output:
// 100°C = 212.0°F
// 100°C = 212.0°F
// Body temp: 98.60000000000001°F
Static methods are the backbone of utility classes — classes that group related helper methods together. The Java standard library has many examples: Math, Collections, Arrays, Objects. These classes are never instantiated; you just call their static methods directly.
// Standard library utility methods - all static, no object needed int max = Math.max(10, 20); // 20 double sqrt = Math.sqrt(144); // 12.0 int abs = Math.abs(-42); // 42 Collections.sort(myList); Arrays.sort(myArray); String result = Objects.requireNonNull(input, "Input must not be null");
You can create your own utility class:
public final class StringUtils {
// Private constructor prevents instantiation
private StringUtils() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
public static boolean isNullOrEmpty(String str) {
return str == null || str.isEmpty();
}
public static boolean isNullOrBlank(String str) {
return str == null || str.isBlank();
}
public static String capitalize(String str) {
if (isNullOrEmpty(str)) return str;
return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase();
}
public static String truncate(String str, int maxLength) {
if (str == null || str.length() <= maxLength) return str;
return str.substring(0, maxLength - 3) + "...";
}
}
// Usage - called on the class, not on an object
String name = StringUtils.capitalize("john"); // "John"
boolean empty = StringUtils.isNullOrEmpty(""); // true
String preview = StringUtils.truncate("Hello World, this is a long string", 15); // "Hello World,..."
main Static?The main method is the entry point of a Java application. The JVM calls it before any objects exist. Since no object has been created yet, the method must be static so that the JVM can invoke it on the class itself: MyApp.main(args).
Method chaining is a technique where each method returns the object itself (this), allowing multiple method calls to be linked together in a single statement. This produces fluent, readable code.
You have already seen this pattern if you have used StringBuilder:
// Without method chaining - repetitive variable reference
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" ");
sb.append("World");
sb.append("!");
String result = sb.toString();
// With method chaining - fluent and concise
String result = new StringBuilder()
.append("Hello")
.append(" ")
.append("World")
.append("!")
.toString();
The key is to return this from methods that modify the object. This is the foundation of the Builder pattern, one of the most common design patterns in Java.
public class HttpRequest {
private String method;
private String url;
private Map headers = new HashMap<>();
private String body;
private int timeoutSeconds;
// Each setter returns 'this' to enable chaining
public HttpRequest method(String method) {
this.method = method;
return this;
}
public HttpRequest url(String url) {
this.url = url;
return this;
}
public HttpRequest header(String key, String value) {
this.headers.put(key, value);
return this;
}
public HttpRequest body(String body) {
this.body = body;
return this;
}
public HttpRequest timeout(int seconds) {
this.timeoutSeconds = seconds;
return this;
}
public void send() {
System.out.println(method + " " + url);
headers.forEach((k, v) -> System.out.println(" " + k + ": " + v));
if (body != null) System.out.println(" Body: " + body);
System.out.println(" Timeout: " + timeoutSeconds + "s");
}
public static void main(String[] args) {
// Clean, readable method chaining
new HttpRequest()
.method("POST")
.url("https://api.example.com/users")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer abc123")
.body("{\"name\": \"John\", \"email\": \"john@example.com\"}")
.timeout(30)
.send();
}
}
// Output:
// POST https://api.example.com/users
// Content-Type: application/json
// Authorization: Bearer abc123
// Body: {"name": "John", "email": "john@example.com"}
// Timeout: 30s
Method chaining is used extensively in modern Java: Stream API, StringBuilder, Optional, testing libraries like AssertJ and Mockito, and HTTP client libraries. Mastering this pattern will make your code both more readable and more idiomatic.
Recursion is when a method calls itself to solve a problem by breaking it into smaller sub-problems. Every recursive method must have two parts:
StackOverflowError.The factorial of n (written as n!) is the product of all positive integers from 1 to n. For example, 5! = 5 × 4 × 3 × 2 × 1 = 120.
public class RecursionDemo {
// Recursive factorial
public static long factorial(int n) {
if (n < 0) throw new IllegalArgumentException("Negative numbers not allowed");
if (n <= 1) return 1; // BASE CASE: 0! = 1, 1! = 1
return n * factorial(n - 1); // RECURSIVE CASE: n! = n * (n-1)!
}
public static void main(String[] args) {
System.out.println("5! = " + factorial(5)); // 120
System.out.println("10! = " + factorial(10)); // 3628800
System.out.println("0! = " + factorial(0)); // 1
}
}
// How factorial(5) executes:
// factorial(5) = 5 * factorial(4)
// = 5 * 4 * factorial(3)
// = 5 * 4 * 3 * factorial(2)
// = 5 * 4 * 3 * 2 * factorial(1)
// = 5 * 4 * 3 * 2 * 1
// = 120
The Fibonacci sequence is: 0, 1, 1, 2, 3, 5, 8, 13, 21, ... where each number is the sum of the two preceding ones.
public class FibonacciDemo {
// Simple recursive Fibonacci - correct but SLOW for large n
// Time complexity: O(2^n) because it recalculates the same values many times
public static long fibRecursive(int n) {
if (n <= 0) return 0; // base case
if (n == 1) return 1; // base case
return fibRecursive(n - 1) + fibRecursive(n - 2); // recursive case
}
// Iterative Fibonacci - much faster: O(n) time, O(1) space
public static long fibIterative(int n) {
if (n <= 0) return 0;
if (n == 1) return 1;
long prev = 0, current = 1;
for (int i = 2; i <= n; i++) {
long next = prev + current;
prev = current;
current = next;
}
return current;
}
public static void main(String[] args) {
// Both produce the same result
for (int i = 0; i <= 10; i++) {
System.out.println("fib(" + i + ") = " + fibIterative(i));
}
// But try fibRecursive(45) vs fibIterative(45) - huge difference in speed
long start = System.currentTimeMillis();
System.out.println("fibIterative(45) = " + fibIterative(45));
System.out.println("Iterative time: " + (System.currentTimeMillis() - start) + "ms");
start = System.currentTimeMillis();
System.out.println("fibRecursive(45) = " + fibRecursive(45));
System.out.println("Recursive time: " + (System.currentTimeMillis() - start) + "ms");
}
}
// Output:
// fib(0) = 0
// fib(1) = 1
// fib(2) = 1
// fib(3) = 2
// fib(4) = 3
// fib(5) = 5
// fib(6) = 8
// fib(7) = 13
// fib(8) = 21
// fib(9) = 34
// fib(10) = 55
// fibIterative(45) = 1134903170
// Iterative time: 0ms
// fibRecursive(45) = 1134903170
// Recursive time: ~5000ms (varies by machine)
| Use Recursion When | Use Iteration When |
|---|---|
| The problem is naturally recursive (tree traversal, directory walking, divide-and-conquer algorithms) | A simple loop can solve it (summing numbers, iterating a list) |
| The recursive solution is significantly more readable | Performance and memory matter (recursion uses stack frames) |
| Depth is bounded and predictable | Input size could be very large (risk of StackOverflowError) |
Every method call adds a frame to the call stack. Recursive calls pile up frames. If the recursion goes too deep (typically a few thousand calls), the stack runs out of space and Java throws a StackOverflowError.
// DANGER: no base case - infinite recursion
public static void infinite() {
infinite(); // calls itself forever
}
// Calling infinite() will crash with:
// Exception in thread "main" java.lang.StackOverflowError
// DANGER: base case that is never reached
public static int badFactorial(int n) {
return n * badFactorial(n - 1); // never stops because there is no base case for n <= 1
}
Writing methods that work is the minimum bar. Writing methods that are clean, maintainable, and easy to understand is what separates a senior developer from a beginner. Here are the practices that matter most.
A method should do one thing and do it well. If you find yourself writing a method name with "and" in it (validateAndSave, fetchAndProcess), that is a sign it should be two methods.
// BAD - does too many things
public void processOrder(Order order) {
// validate
if (order.getItems().isEmpty()) throw new IllegalArgumentException("No items");
if (order.getCustomer() == null) throw new IllegalArgumentException("No customer");
// calculate total
double total = 0;
for (Item item : order.getItems()) {
total += item.getPrice() * item.getQuantity();
}
order.setTotal(total);
// apply discount
if (order.getCustomer().isVip()) {
order.setTotal(total * 0.9);
}
// save to database
database.save(order);
// send confirmation email
emailService.send(order.getCustomer().getEmail(), "Order confirmed!");
}
// GOOD - each method has a single responsibility
public void processOrder(Order order) {
validateOrder(order);
calculateTotal(order);
applyDiscounts(order);
saveOrder(order);
sendConfirmation(order);
}
private void validateOrder(Order order) {
if (order.getItems().isEmpty()) throw new IllegalArgumentException("No items");
if (order.getCustomer() == null) throw new IllegalArgumentException("No customer");
}
private void calculateTotal(Order order) {
double total = order.getItems().stream()
.mapToDouble(item -> item.getPrice() * item.getQuantity())
.sum();
order.setTotal(total);
}
private void applyDiscounts(Order order) {
if (order.getCustomer().isVip()) {
order.setTotal(order.getTotal() * 0.9);
}
}
private void saveOrder(Order order) {
database.save(order);
}
private void sendConfirmation(Order order) {
emailService.send(order.getCustomer().getEmail(), "Order confirmed!");
}
A good method fits on one screen (roughly 20-30 lines). If a method is longer, look for opportunities to extract sub-steps into separate methods. Short methods are easier to read, test, and debug.
Ideally, a method should have 0 to 3 parameters. More than 4 is a code smell. If you need many parameters, consider grouping them into an object.
// BAD - too many parameters, hard to remember the order
public User createUser(String firstName, String lastName, String email,
String phone, String address, String city,
String state, String zipCode, boolean isActive) {
// ...
}
// GOOD - group related data into an object
public User createUser(UserRegistration registration) {
// ...
}
// The UserRegistration class groups the parameters logically
public class UserRegistration {
private String firstName;
private String lastName;
private String email;
private String phone;
private Address address;
private boolean active;
// constructor, getters, setters...
}
A method named getBalance() should return the balance, not also send an email or write to a log file. If a method does something beyond what its name suggests, it has a side effect. Side effects make code unpredictable and hard to debug.
Use guard clauses (early returns) to handle edge cases at the top of the method. This keeps the "happy path" at the lowest indentation level.
// BAD - deeply nested, hard to follow
public double calculateDiscount(Customer customer, Order order) {
double discount = 0;
if (customer != null) {
if (order != null) {
if (!order.getItems().isEmpty()) {
if (customer.isVip()) {
discount = order.getTotal() * 0.15;
} else if (order.getTotal() > 100) {
discount = order.getTotal() * 0.05;
}
}
}
}
return discount;
}
// GOOD - guard clauses flatten the logic
public double calculateDiscount(Customer customer, Order order) {
if (customer == null || order == null) return 0;
if (order.getItems().isEmpty()) return 0;
if (customer.isVip()) {
return order.getTotal() * 0.15;
}
if (order.getTotal() > 100) {
return order.getTotal() * 0.05;
}
return 0;
}
Use Javadoc comments for public methods, especially those with non-obvious behavior, edge cases, or multiple parameters.
/**
* Calculates the monthly payment for a fixed-rate loan.
*
* @param principal the loan amount in dollars (must be positive)
* @param annualRate the annual interest rate as a decimal (e.g., 0.05 for 5%)
* @param months the number of monthly payments (must be at least 1)
* @return the fixed monthly payment amount, rounded to 2 decimal places
* @throws IllegalArgumentException if principal or months is non-positive, or rate is negative
*/
public static double calculateMonthlyPayment(double principal, double annualRate, int months) {
if (principal <= 0) throw new IllegalArgumentException("Principal must be positive");
if (months < 1) throw new IllegalArgumentException("Months must be at least 1");
if (annualRate < 0) throw new IllegalArgumentException("Rate cannot be negative");
if (annualRate == 0) return principal / months;
double monthlyRate = annualRate / 12;
double payment = principal * (monthlyRate * Math.pow(1 + monthlyRate, months))
/ (Math.pow(1 + monthlyRate, months) - 1);
return Math.round(payment * 100.0) / 100.0;
}
Here are the mistakes that trip up Java developers most often when working with methods. Learning to recognize these patterns will save you hours of debugging.
// COMPILE ERROR - missing return on the 'else' path
public String getStatus(int code) {
if (code == 200) {
return "OK";
} else if (code == 404) {
return "Not Found";
}
// What if code is 500? The compiler sees a path with no return.
}
// FIX - always have a default return
public String getStatus(int code) {
if (code == 200) return "OK";
if (code == 404) return "Not Found";
if (code == 500) return "Internal Server Error";
return "Unknown Status: " + code; // default handles all other cases
}
// MISTAKE - trying to "swap" two variables through a method
public static void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
// This only swaps the LOCAL copies. The caller's variables are unchanged.
}
int x = 10, y = 20;
swap(x, y);
System.out.println(x + ", " + y); // Still prints: 10, 20
// MISTAKE - trying to "reset" a String through a method
public static void reset(String str) {
str = ""; // reassigns the local reference, not the caller's variable
}
String name = "Alice";
reset(name);
System.out.println(name); // Still prints: Alice
public class StaticContextError {
private String name = "Java";
public void printName() {
System.out.println(name);
}
public static void main(String[] args) {
// COMPILE ERROR - cannot call instance method from static context
// printName();
// FIX - create an instance first
StaticContextError obj = new StaticContextError();
obj.printName(); // Now it works
}
}
// MISTAKE - the base case is wrong, never terminates for negative input
public static int countdown(int n) {
if (n == 0) return 0; // only stops at exactly 0
return countdown(n - 1); // what if n starts negative? It goes -1, -2, -3... forever
}
countdown(-1); // StackOverflowError!
// FIX - use <= instead of ==
public static int countdown(int n) {
if (n <= 0) return 0; // handles 0 AND negative numbers
return countdown(n - 1);
}
// MISTAKE - String methods return a NEW string; they do not modify the original String name = " Hello World "; name.trim(); // returns a new trimmed string, but we throw it away name.toUpperCase(); // same problem System.out.println(name); // " Hello World " - unchanged! // FIX - capture the return value String name = " Hello World "; name = name.trim(); name = name.toUpperCase(); System.out.println(name); // "HELLO WORLD"
public class OverloadingTrap {
public static void print(int value) {
System.out.println("int: " + value);
}
public static void print(long value) {
System.out.println("long: " + value);
}
public static void print(Integer value) {
System.out.println("Integer: " + value);
}
public static void main(String[] args) {
print(42); // Calls print(int) - exact match
print(42L); // Calls print(long) - exact match
print(Integer.valueOf(42)); // Calls print(Integer) - exact match
// What about just print(42)?
// Java prefers: exact match > widening > autoboxing > varargs
// So print(42) calls print(int), NOT print(Integer) or print(long)
}
}
Let us put everything together in a complete, runnable program that demonstrates method syntax, access modifiers, return types, parameters, overloading, static vs instance methods, and best practices. This StudentGradeManager processes student grades and generates a report.
import java.util.ArrayList;
import java.util.List;
public class StudentGradeManager {
// ===== Instance Variables =====
private String studentName;
private List grades;
// ===== Constructor =====
public StudentGradeManager(String studentName) {
this.studentName = studentName;
this.grades = new ArrayList<>();
}
// ===== Instance Methods =====
/**
* Adds a single grade to this student's record.
* @param grade a value between 0.0 and 100.0
*/
public void addGrade(double grade) {
if (!isValidGrade(grade)) {
System.out.println("Invalid grade: " + grade + " (must be 0-100)");
return; // early return - guard clause
}
grades.add(grade);
}
/**
* Adds multiple grades at once using varargs.
* Demonstrates method overloading and varargs.
*/
public void addGrade(double... newGrades) {
for (double grade : newGrades) {
addGrade(grade); // reuses the single-grade version for validation
}
}
/**
* Calculates and returns the average of all grades.
* @return the average, or 0.0 if no grades exist
*/
public double calculateAverage() {
if (grades.isEmpty()) return 0.0;
double sum = 0;
for (double grade : grades) {
sum += grade;
}
return sum / grades.size();
}
/**
* Returns the highest grade.
* @return the maximum grade, or 0.0 if no grades exist
*/
public double getHighestGrade() {
if (grades.isEmpty()) return 0.0;
double max = grades.get(0);
for (double grade : grades) {
if (grade > max) max = grade;
}
return max;
}
/**
* Returns the lowest grade.
* @return the minimum grade, or 0.0 if no grades exist
*/
public double getLowestGrade() {
if (grades.isEmpty()) return 0.0;
double min = grades.get(0);
for (double grade : grades) {
if (grade < min) min = grade;
}
return min;
}
/**
* Determines the letter grade for this student based on their average.
* Uses the static helper method internally.
*/
public String getLetterGrade() {
return convertToLetterGrade(calculateAverage());
}
/**
* Checks if the student is passing (average >= 60).
* Demonstrates a boolean-returning method with a descriptive name.
*/
public boolean isPassing() {
return calculateAverage() >= 60.0;
}
/**
* Returns the number of grades recorded.
*/
public int getGradeCount() {
return grades.size();
}
/**
* Generates a formatted report card for this student.
* This is the "orchestrator" method that calls other focused methods.
*/
public String generateReport() {
StringBuilder report = new StringBuilder();
report.append("========================================\n");
report.append(" STUDENT REPORT CARD\n");
report.append("========================================\n");
report.append(" Student: ").append(studentName).append("\n");
report.append(" Grades: ").append(grades).append("\n");
report.append(" Count: ").append(getGradeCount()).append("\n");
report.append(" Average: ").append(formatScore(calculateAverage())).append("\n");
report.append(" Highest: ").append(formatScore(getHighestGrade())).append("\n");
report.append(" Lowest: ").append(formatScore(getLowestGrade())).append("\n");
report.append(" Letter: ").append(getLetterGrade()).append("\n");
report.append(" Status: ").append(isPassing() ? "PASSING" : "FAILING").append("\n");
report.append("========================================\n");
return report.toString();
}
// ===== Private Helper Methods =====
/**
* Validates that a grade is within the acceptable range.
* Private because only this class needs this logic.
*/
private boolean isValidGrade(double grade) {
return grade >= 0.0 && grade <= 100.0;
}
/**
* Formats a score to one decimal place.
*/
private String formatScore(double score) {
return String.format("%.1f", score);
}
// ===== Static Utility Methods =====
/**
* Converts a numeric score to a letter grade.
* Static because it does not depend on any instance state.
*/
public static String convertToLetterGrade(double score) {
if (score >= 93) return "A";
if (score >= 90) return "A-";
if (score >= 87) return "B+";
if (score >= 83) return "B";
if (score >= 80) return "B-";
if (score >= 77) return "C+";
if (score >= 73) return "C";
if (score >= 70) return "C-";
if (score >= 67) return "D+";
if (score >= 63) return "D";
if (score >= 60) return "D-";
return "F";
}
/**
* Compares two students and returns the one with the higher average.
* Static because it operates on two objects, not "this" object.
*/
public static StudentGradeManager getTopStudent(StudentGradeManager s1, StudentGradeManager s2) {
return (s1.calculateAverage() >= s2.calculateAverage()) ? s1 : s2;
}
// Getter for name (used in main for display)
public String getStudentName() {
return studentName;
}
// ===== Main Method: Program Entry Point =====
public static void main(String[] args) {
// Create students and add grades
StudentGradeManager alice = new StudentGradeManager("Alice Johnson");
alice.addGrade(95.5, 88.0, 92.3, 78.0, 96.5); // varargs overload
StudentGradeManager bob = new StudentGradeManager("Bob Smith");
bob.addGrade(72.0); // single grade overload
bob.addGrade(65.5);
bob.addGrade(80.0);
bob.addGrade(55.0);
bob.addGrade(70.0);
StudentGradeManager charlie = new StudentGradeManager("Charlie Davis");
charlie.addGrade(45.0, 52.0, 38.0, 60.0, 55.5);
// Generate reports (instance method)
System.out.println(alice.generateReport());
System.out.println(bob.generateReport());
System.out.println(charlie.generateReport());
// Use static method directly on the class
System.out.println("Letter grade for 85.0: " + StudentGradeManager.convertToLetterGrade(85.0));
// Use static method to compare students
StudentGradeManager top = StudentGradeManager.getTopStudent(alice, bob);
System.out.println("Top student: " + top.getStudentName()
+ " (avg: " + String.format("%.1f", top.calculateAverage()) + ")");
// Test invalid grade (guard clause in action)
alice.addGrade(105.0); // prints error message
alice.addGrade(-5.0); // prints error message
}
}
// Output:
// ========================================
// STUDENT REPORT CARD
// ========================================
// Student: Alice Johnson
// Grades: [95.5, 88.0, 92.3, 78.0, 96.5]
// Count: 5
// Average: 90.1
// Highest: 96.5
// Lowest: 78.0
// Letter: A-
// Status: PASSING
// ========================================
//
// ========================================
// STUDENT REPORT CARD
// ========================================
// Student: Bob Smith
// Grades: [72.0, 65.5, 80.0, 55.0, 70.0]
// Count: 5
// Average: 68.5
// Highest: 80.0
// Lowest: 55.0
// Letter: D+
// Status: PASSING
// ========================================
//
// ========================================
// STUDENT REPORT CARD
// ========================================
// Student: Charlie Davis
// Grades: [45.0, 52.0, 38.0, 60.0, 55.5]
// Count: 5
// Average: 50.1
// Highest: 60.0
// Lowest: 38.0
// Letter: F
// Status: FAILING
// ========================================
//
// Letter grade for 85.0: B
// Top student: Alice Johnson (avg: 90.1)
// Invalid grade: 105.0 (must be 0-100)
// Invalid grade: -5.0 (must be 0-100)
| Concept | Key Rule |
|---|---|
| Method Signature | Name + parameter types. Return type is NOT part of the signature. |
| Access Modifiers | private → default → protected → public (narrowest to widest) |
| void Methods | Perform an action, return nothing. Can use return; to exit early. |
| Pass-by-Value | Java always copies the value. For objects, it copies the reference (so you can modify the object but not reassign the caller's variable). |
| Varargs | Use Type... name. Must be the last parameter. Treated as an array inside the method. |
| Overloading | Same name, different parameter list. Return type alone is not enough. |
| Static Methods | Belong to the class. Cannot use this or access instance variables. |
| Instance Methods | Belong to an object. Can access everything: instance vars, static vars, this. |
| Method Chaining | Return this from setter-like methods to enable fluent API style. |
| Recursion | Always define a base case. Prefer iteration for simple loops. |
| Best Practice | Single responsibility. Max 3-4 parameters. Start private, widen as needed. Return early. |
An array in Java is a fixed-size container that holds a collection of elements of the same data type, stored in contiguous memory locations. Once you create an array with a specific size, that size cannot change for the lifetime of the array.
Think of an array like a row of mailboxes in an apartment building. Each mailbox is numbered starting from 0, every mailbox is the same size (same data type), the total number of mailboxes is fixed when the building is constructed, and you can go directly to any mailbox if you know its number.
Key characteristics of Java arrays:
ArrayList.0, the second at index 1, and the last at index length - 1.int[], double[], String[], Employee[] are all valid array types.// Visualizing an array in memory
// Index: [0] [1] [2] [3] [4]
// Value: 10 20 30 40 50
// Address: 0x100 0x104 0x108 0x10C 0x110 (contiguous 4-byte int blocks)
int[] scores = {10, 20, 30, 40, 50};
System.out.println(scores[0]); // 10 -- first element
System.out.println(scores[4]); // 50 -- last element
System.out.println(scores.length); // 5 -- total number of elements
There are three distinct steps when working with arrays: declaration (telling the compiler what type of array you want), instantiation (allocating memory for the array), and initialization (filling the array with values). These steps can be done separately or combined into a single statement.
Java supports two styles for declaring an array variable. Both are equivalent, but the first style is preferred because it keeps the type information together.
// Preferred style: brackets after the type int[] numbers; String[] names; double[] prices; // Also valid but less common: brackets after the variable name (C-style) int scores[]; String cities[]; // The preferred style makes it clearer that the TYPE is "int array" // rather than the variable being an "array version" of int
newThe new keyword allocates memory for the array on the heap. You must specify the size, and it cannot be changed later.
// Declare and instantiate separately int[] numbers; numbers = new int[5]; // creates an array of 5 ints, all initialized to 0 // Declare and instantiate in one line String[] names = new String[3]; // creates an array of 3 Strings, all initialized to null double[] prices = new double[10]; // creates an array of 10 doubles, all initialized to 0.0 // The size can be a variable or expression int studentCount = 30; int[] grades = new int[studentCount]; boolean[] flags = new boolean[studentCount * 2];
When you know the values at compile time, you can use an array literal (also called an array initializer). The compiler infers the size from the number of values you provide.
// Array literal -- size is inferred (5 elements)
int[] primes = {2, 3, 5, 7, 11};
// Array literal with Strings
String[] weekdays = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday"};
// Array literal with doubles
double[] temperatures = {98.6, 99.1, 97.8, 100.4, 98.2};
// You can also use new with a literal (required when not in a declaration)
int[] evenNumbers = new int[]{2, 4, 6, 8, 10};
// This form is necessary when passing an array inline to a method
printArray(new int[]{1, 2, 3}); // cannot write printArray({1, 2, 3})
When you create an array with new but do not provide initial values, Java fills every element with the default value for that type.
| Array Type | Default Value | Example |
|---|---|---|
int[], short[], byte[], long[] |
0 |
new int[3] → {0, 0, 0} |
float[], double[] |
0.0 |
new double[2] → {0.0, 0.0} |
boolean[] |
false |
new boolean[2] → {false, false} |
char[] |
'\u0000' (null character) |
new char[2] → {'\u0000', '\u0000'} |
String[], any object array |
null |
new String[2] → {null, null} |
import java.util.Arrays; int[] numbers = new int[5]; System.out.println(Arrays.toString(numbers)); // Output: [0, 0, 0, 0, 0] boolean[] flags = new boolean[3]; System.out.println(Arrays.toString(flags)); // Output: [false, false, false] String[] names = new String[4]; System.out.println(Arrays.toString(names)); // Output: [null, null, null, null] double[] prices = new double[3]; System.out.println(Arrays.toString(prices)); // Output: [0.0, 0.0, 0.0]
Array elements are accessed and modified using the index operator []. The index must be an integer between 0 (inclusive) and array.length - 1 (inclusive). Accessing an index outside this range throws an ArrayIndexOutOfBoundsException at runtime.
String[] colors = new String[4];
// Setting values (writing)
colors[0] = "Red";
colors[1] = "Green";
colors[2] = "Blue";
colors[3] = "Yellow";
// Getting values (reading)
System.out.println(colors[0]); // Red
System.out.println(colors[2]); // Blue
// Modifying an existing value
colors[1] = "Purple";
System.out.println(colors[1]); // Purple
// The length property (not a method -- no parentheses)
System.out.println("Array size: " + colors.length); // Array size: 4
// Accessing the last element
System.out.println("Last: " + colors[colors.length - 1]); // Last: Yellow
This is one of the most common runtime errors in Java. It occurs when you try to access an index that does not exist in the array. The compiler cannot catch this — it only happens at runtime.
int[] numbers = {10, 20, 30};
// Valid indices: 0, 1, 2
System.out.println(numbers[0]); // 10
System.out.println(numbers[2]); // 30
// RUNTIME ERROR: index 3 does not exist (valid range is 0-2)
// System.out.println(numbers[3]);
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
// RUNTIME ERROR: negative indices are never valid
// System.out.println(numbers[-1]);
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3
// Common bug: using <= instead of < in a loop
// for (int i = 0; i <= numbers.length; i++) { // BUG: runs one iteration too many
// System.out.println(numbers[i]);
// }
// CORRECT
for (int i = 0; i < numbers.length; i++) {
System.out.println(numbers[i]);
}
// Output:
// 10
// 20
// 30
There are several ways to iterate through an array in Java. Each approach has its strengths, and knowing when to use which one is important for writing clean, efficient code.
for LoopThe traditional for loop gives you full control: you know the index, you can iterate forwards or backwards, you can skip elements, and you can modify elements in place. Use this when you need the index or when you need to change element values.
String[] fruits = {"Apple", "Banana", "Cherry", "Date", "Elderberry"};
// Forward iteration
for (int i = 0; i < fruits.length; i++) {
System.out.println("Index " + i + ": " + fruits[i]);
}
// Output:
// Index 0: Apple
// Index 1: Banana
// Index 2: Cherry
// Index 3: Date
// Index 4: Elderberry
// Reverse iteration
for (int i = fruits.length - 1; i >= 0; i--) {
System.out.println(fruits[i]);
}
// Output:
// Elderberry
// Date
// Cherry
// Banana
// Apple
// Modify elements in place (convert to uppercase)
for (int i = 0; i < fruits.length; i++) {
fruits[i] = fruits[i].toUpperCase();
}
System.out.println(java.util.Arrays.toString(fruits));
// Output: [APPLE, BANANA, CHERRY, DATE, ELDERBERRY]
for-each LoopThe for-each loop (introduced in Java 5) is the cleanest way to iterate when you just need to read each element and do not need the index. It eliminates off-by-one errors entirely.
int[] scores = {95, 87, 76, 92, 88};
// Read each element -- no index variable needed
for (int score : scores) {
System.out.println("Score: " + score);
}
// Output:
// Score: 95
// Score: 87
// Score: 76
// Score: 92
// Score: 88
// Calculate average
int sum = 0;
for (int score : scores) {
sum += score;
}
double average = (double) sum / scores.length;
System.out.println("Average: " + average);
// Output: Average: 87.6
// WARNING: Assigning to the loop variable does NOT modify the array
for (int score : scores) {
score = score + 10; // this changes the local copy only
}
System.out.println(java.util.Arrays.toString(scores));
// Output: [95, 87, 76, 92, 88] -- unchanged!
while LoopA while loop is rarely the first choice for iterating arrays, but it is useful when you want to stop iteration early based on a dynamic condition or when you are processing elements until you find something specific.
String[] tasks = {"Email client", "Fix login bug", "URGENT: Deploy hotfix", "Write tests", "Code review"};
// Find and process the first urgent task
int index = 0;
while (index < tasks.length) {
if (tasks[index].startsWith("URGENT")) {
System.out.println("Found urgent task at index " + index + ": " + tasks[index]);
break;
}
index++;
}
// Output: Found urgent task at index 2: URGENT: Deploy hotfix
Arrays.stream() (Java 8+)The Stream API provides a functional, declarative approach to array processing. It is especially powerful when you need to chain operations like filtering, mapping, and reducing.
import java.util.Arrays;
import java.util.stream.IntStream;
int[] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Print each element
Arrays.stream(numbers).forEach(n -> System.out.print(n + " "));
// Output: 1 2 3 4 5 6 7 8 9 10
System.out.println();
// Filter and transform: get squares of even numbers
int[] evenSquares = Arrays.stream(numbers)
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.toArray();
System.out.println(Arrays.toString(evenSquares));
// Output: [4, 16, 36, 64, 100]
// Sum all elements
int sum = Arrays.stream(numbers).sum();
System.out.println("Sum: " + sum);
// Output: Sum: 55
// Find max
int max = Arrays.stream(numbers).max().orElse(0);
System.out.println("Max: " + max);
// Output: Max: 10
// String array with streams
String[] languages = {"Java", "Python", "JavaScript", "Go", "Rust"};
Arrays.stream(languages)
.filter(lang -> lang.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
// Output:
// JAVA
// PYTHON
// JAVASCRIPT
// RUST
| Approach | Best For | Gives Index? | Can Modify? |
|---|---|---|---|
for (int i = 0; ...) |
Need index, modify in place, iterate backwards | Yes | Yes |
for (Type x : arr) |
Read-only traversal of all elements | No | No (local copy) |
while |
Conditional early termination | Yes (manual) | Yes |
Arrays.stream() |
Functional pipelines: filter, map, reduce | No (unless IntStream.range) |
No (produces new array) |
A multi-dimensional array is an array of arrays. The most common form is the 2D array, which you can think of as a table with rows and columns. Java also supports 3D arrays and beyond, though anything past 2D is rare in practice.
A 2D array is declared with two sets of brackets. The first dimension represents rows and the second represents columns.
// Declare and instantiate a 3x4 matrix (3 rows, 4 columns)
int[][] matrix = new int[3][4];
// Set values
matrix[0][0] = 1;
matrix[0][1] = 2;
matrix[1][2] = 5;
matrix[2][3] = 9;
// Initialize with literal values
int[][] grid = {
{1, 2, 3, 4}, // row 0
{5, 6, 7, 8}, // row 1
{9, 10, 11, 12} // row 2
};
// Access elements
System.out.println(grid[0][0]); // 1 (row 0, col 0)
System.out.println(grid[1][2]); // 7 (row 1, col 2)
System.out.println(grid[2][3]); // 12 (row 2, col 3)
// Dimensions
System.out.println("Rows: " + grid.length); // 3
System.out.println("Columns: " + grid[0].length); // 4
int[][] matrix = {
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
};
// Traditional nested for loop -- gives you row and column indices
System.out.println("Using traditional for loop:");
for (int row = 0; row < matrix.length; row++) {
for (int col = 0; col < matrix[row].length; col++) {
System.out.printf("%4d", matrix[row][col]);
}
System.out.println();
}
// Output:
// 1 2 3
// 4 5 6
// 7 8 9
// Enhanced for-each loop -- cleaner when you don't need indices
System.out.println("\nUsing for-each loop:");
for (int[] row : matrix) {
for (int value : row) {
System.out.printf("%4d", value);
}
System.out.println();
}
// Output:
// 1 2 3
// 4 5 6
// 7 8 9
// Sum all elements
int total = 0;
for (int[] row : matrix) {
for (int value : row) {
total += value;
}
}
System.out.println("Sum of all elements: " + total);
// Output: Sum of all elements: 45
In Java, a 2D array is actually an array of arrays, and each inner array can have a different length. This is called a jagged array (or ragged array). This is different from languages like C where a 2D array must be rectangular.
// Jagged array: each row has a different number of columns
int[][] jagged = new int[3][]; // 3 rows, columns not yet defined
jagged[0] = new int[]{1, 2}; // row 0 has 2 columns
jagged[1] = new int[]{3, 4, 5, 6}; // row 1 has 4 columns
jagged[2] = new int[]{7, 8, 9}; // row 2 has 3 columns
// Iterate safely using each row's own length
for (int i = 0; i < jagged.length; i++) {
System.out.print("Row " + i + " (" + jagged[i].length + " elements): ");
for (int j = 0; j < jagged[i].length; j++) {
System.out.print(jagged[i][j] + " ");
}
System.out.println();
}
// Output:
// Row 0 (2 elements): 1 2
// Row 1 (4 elements): 3 4 5 6
// Row 2 (3 elements): 7 8 9
// Practical example: storing student grades where each student took a different number of exams
int[][] studentGrades = {
{90, 85, 92}, // Student 0: took 3 exams
{78, 88, 95, 82}, // Student 1: took 4 exams
{91, 76}, // Student 2: took 2 exams
{88, 93, 79, 84, 90} // Student 3: took 5 exams
};
for (int s = 0; s < studentGrades.length; s++) {
int sum = 0;
for (int grade : studentGrades[s]) {
sum += grade;
}
double avg = (double) sum / studentGrades[s].length;
System.out.printf("Student %d: %d exams, average = %.1f%n", s, studentGrades[s].length, avg);
}
// Output:
// Student 0: 3 exams, average = 89.0
// Student 1: 4 exams, average = 85.8
// Student 2: 2 exams, average = 83.5
// Student 3: 5 exams, average = 86.8
A 3D array adds another dimension. You can think of it as a collection of 2D tables (like pages in a book, each page being a table). Three-dimensional arrays are uncommon outside of scientific computing and image processing.
// 3D array: 2 "layers", each with 3 rows and 4 columns
int[][][] cube = new int[2][3][4];
cube[0][0][0] = 1;
cube[1][2][3] = 99;
// Initialize with literal
int[][][] data = {
{ // layer 0
{1, 2},
{3, 4}
},
{ // layer 1
{5, 6},
{7, 8}
}
};
// Print all elements
for (int layer = 0; layer < data.length; layer++) {
System.out.println("Layer " + layer + ":");
for (int row = 0; row < data[layer].length; row++) {
for (int col = 0; col < data[layer][row].length; col++) {
System.out.print(data[layer][row][col] + " ");
}
System.out.println();
}
System.out.println();
}
// Output:
// Layer 0:
// 1 2
// 3 4
//
// Layer 1:
// 5 6
// 7 8
The java.util.Arrays class provides a rich set of static methods for working with arrays. Before writing your own array logic, check if Arrays already has a method for it -- it almost certainly does, and the built-in implementations are optimized and well-tested.
Arrays.toString() and Arrays.deepToString()Printing an array directly with System.out.println() produces a useless memory reference like [I@1b6d3586. Use Arrays.toString() for 1D arrays and Arrays.deepToString() for multi-dimensional arrays.
import java.util.Arrays;
int[] numbers = {5, 3, 8, 1, 9};
// Without Arrays.toString() -- NOT useful
System.out.println(numbers);
// Output: [I@1b6d3586 (memory reference, not the contents)
// With Arrays.toString() -- human-readable
System.out.println(Arrays.toString(numbers));
// Output: [5, 3, 8, 1, 9]
// For 2D arrays, use deepToString()
int[][] matrix = {{1, 2, 3}, {4, 5, 6}};
System.out.println(Arrays.toString(matrix));
// Output: [[I@4554617c, [I@74a14482] -- toString only shows inner array references
System.out.println(Arrays.deepToString(matrix));
// Output: [[1, 2, 3], [4, 5, 6]] -- deepToString shows actual values
Arrays.sort()Sorts the array in ascending order. For primitives, it uses a dual-pivot Quicksort (O(n log n) average). For objects, it uses TimSort (stable, O(n log n)). You can sort the entire array or a subrange.
import java.util.Arrays;
import java.util.Collections;
// Sort integers in ascending order
int[] numbers = {5, 2, 8, 1, 9, 3};
Arrays.sort(numbers);
System.out.println(Arrays.toString(numbers));
// Output: [1, 2, 3, 5, 8, 9]
// Sort a subrange: indices 1 (inclusive) to 4 (exclusive)
int[] partial = {50, 30, 10, 40, 20};
Arrays.sort(partial, 1, 4); // sorts indices 1, 2, 3 only
System.out.println(Arrays.toString(partial));
// Output: [50, 10, 30, 40, 20]
// Sort Strings alphabetically
String[] fruits = {"Banana", "Apple", "Cherry", "Date"};
Arrays.sort(fruits);
System.out.println(Arrays.toString(fruits));
// Output: [Apple, Banana, Cherry, Date]
// Sort Strings in descending order (requires wrapper array for Collections.reverseOrder)
String[] cities = {"New York", "London", "Tokyo", "Sydney"};
Arrays.sort(cities, Collections.reverseOrder());
System.out.println(Arrays.toString(cities));
// Output: [Tokyo, Sydney, New York, London]
// Sort Integer wrapper array in descending order
Integer[] values = {5, 2, 8, 1, 9, 3};
Arrays.sort(values, Collections.reverseOrder());
System.out.println(Arrays.toString(values));
// Output: [9, 8, 5, 3, 2, 1]
Arrays.binarySearch()Performs a binary search on a sorted array and returns the index of the target element. If the element is not found, it returns -(insertion point) - 1. The array must be sorted first or the result is undefined.
import java.util.Arrays;
int[] sorted = {10, 20, 30, 40, 50, 60, 70};
// Search for existing element
int index = Arrays.binarySearch(sorted, 40);
System.out.println("40 found at index: " + index);
// Output: 40 found at index: 3
// Search for non-existing element
int notFound = Arrays.binarySearch(sorted, 35);
System.out.println("35 result: " + notFound);
// Output: 35 result: -4
// The value -4 means: not found, and the insertion point would be index 3
// Formula: -(insertion point) - 1 = -(3) - 1 = -4
// Practical use: check if element exists
String[] names = {"Alice", "Bob", "Charlie", "Diana"};
Arrays.sort(names); // MUST sort before binary search
String target = "Charlie";
int result = Arrays.binarySearch(names, target);
if (result >= 0) {
System.out.println(target + " found at index " + result);
} else {
System.out.println(target + " not found");
}
// Output: Charlie found at index 2
Arrays.fill()Sets every element in an array (or a subrange) to the same value. Useful for initializing arrays with a non-default value.
import java.util.Arrays;
// Fill entire array
int[] scores = new int[5];
Arrays.fill(scores, -1);
System.out.println(Arrays.toString(scores));
// Output: [-1, -1, -1, -1, -1]
// Fill a subrange: indices 1 (inclusive) to 4 (exclusive)
boolean[] flags = new boolean[6];
Arrays.fill(flags, 1, 4, true);
System.out.println(Arrays.toString(flags));
// Output: [false, true, true, true, false, false]
// Practical: initialize a grid with dots
char[][] board = new char[3][3];
for (char[] row : board) {
Arrays.fill(row, '.');
}
System.out.println(Arrays.deepToString(board));
// Output: [[., ., .], [., ., .], [., ., .]]
Arrays.copyOf() and Arrays.copyOfRange()Create new arrays by copying all or part of an existing array. These methods handle the new allocation for you.
import java.util.Arrays;
int[] original = {10, 20, 30, 40, 50};
// Copy entire array
int[] copy = Arrays.copyOf(original, original.length);
System.out.println(Arrays.toString(copy));
// Output: [10, 20, 30, 40, 50]
// Copy with a larger size (pads with default value)
int[] expanded = Arrays.copyOf(original, 8);
System.out.println(Arrays.toString(expanded));
// Output: [10, 20, 30, 40, 50, 0, 0, 0]
// Copy with a smaller size (truncates)
int[] truncated = Arrays.copyOf(original, 3);
System.out.println(Arrays.toString(truncated));
// Output: [10, 20, 30]
// Copy a subrange: from index 1 (inclusive) to 4 (exclusive)
int[] sub = Arrays.copyOfRange(original, 1, 4);
System.out.println(Arrays.toString(sub));
// Output: [20, 30, 40]
Arrays.equals() and Arrays.deepEquals()Compare array contents for equality. The == operator only checks if two variables point to the same array object in memory -- it does not compare contents.
import java.util.Arrays;
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
int[] c = a;
// == compares references (memory addresses), NOT contents
System.out.println(a == b); // false -- different objects in memory
System.out.println(a == c); // true -- same object (c points to a)
// Arrays.equals() compares contents element by element
System.out.println(Arrays.equals(a, b)); // true -- same values
System.out.println(Arrays.equals(a, c)); // true -- same values (and same object)
// For 2D arrays, use deepEquals()
int[][] matrix1 = {{1, 2}, {3, 4}};
int[][] matrix2 = {{1, 2}, {3, 4}};
System.out.println(Arrays.equals(matrix1, matrix2)); // false -- compares inner array references
System.out.println(Arrays.deepEquals(matrix1, matrix2)); // true -- compares actual nested values
Arrays.asList()Converts an array to a List. The resulting list is a fixed-size wrapper backed by the original array -- you can modify elements, but you cannot add or remove elements.
import java.util.Arrays;
import java.util.List;
import java.util.ArrayList;
String[] colors = {"Red", "Green", "Blue"};
// Fixed-size list backed by the array
List colorList = Arrays.asList(colors);
System.out.println(colorList);
// Output: [Red, Green, Blue]
// You CAN modify existing elements
colorList.set(0, "Purple");
System.out.println(colorList);
// Output: [Purple, Green, Blue]
// Changes are reflected in the original array (they share the same storage)
System.out.println(colors[0]);
// Output: Purple
// You CANNOT add or remove elements
// colorList.add("Yellow"); // throws UnsupportedOperationException
// colorList.remove(0); // throws UnsupportedOperationException
// To get a fully mutable list, wrap it in a new ArrayList
List mutableList = new ArrayList<>(Arrays.asList(colors));
mutableList.add("Yellow");
mutableList.remove("Green");
System.out.println(mutableList);
// Output: [Purple, Blue, Yellow]
| Method | Purpose | Requires Sorted? |
|---|---|---|
Arrays.toString(arr) |
Readable string representation of 1D array | No |
Arrays.deepToString(arr) |
Readable string representation of multi-dimensional array | No |
Arrays.sort(arr) |
Sort in ascending order | No (it does the sorting) |
Arrays.binarySearch(arr, key) |
Find index of element (O(log n)) | Yes |
Arrays.fill(arr, val) |
Set all elements to the same value | No |
Arrays.copyOf(arr, len) |
Copy array with new length | No |
Arrays.copyOfRange(arr, from, to) |
Copy a subrange of the array | No |
Arrays.equals(a, b) |
Compare 1D array contents | No |
Arrays.deepEquals(a, b) |
Compare multi-dimensional array contents | No |
Arrays.asList(arr) |
Convert array to fixed-size List | No |
Arrays.stream(arr) |
Create a Stream for functional processing | No |
These operations come up frequently in real projects and coding interviews. Some have utility method shortcuts and some require manual implementation.
import java.util.Arrays;
int[] temps = {72, 68, 75, 80, 65, 90, 85};
// Approach 1: Manual loop
int min = temps[0];
int max = temps[0];
for (int i = 1; i < temps.length; i++) {
if (temps[i] < min) min = temps[i];
if (temps[i] > max) max = temps[i];
}
System.out.println("Min: " + min + ", Max: " + max);
// Output: Min: 65, Max: 90
// Approach 2: Sort (modifies the array)
int[] sorted = Arrays.copyOf(temps, temps.length);
Arrays.sort(sorted);
System.out.println("Min: " + sorted[0] + ", Max: " + sorted[sorted.length - 1]);
// Output: Min: 65, Max: 90
// Approach 3: Streams (Java 8+)
int streamMin = Arrays.stream(temps).min().orElse(0);
int streamMax = Arrays.stream(temps).max().orElse(0);
System.out.println("Min: " + streamMin + ", Max: " + streamMax);
// Output: Min: 65, Max: 90
import java.util.Arrays;
int[] numbers = {1, 2, 3, 4, 5};
// Swap elements from both ends toward the middle
int left = 0;
int right = numbers.length - 1;
while (left < right) {
int temp = numbers[left];
numbers[left] = numbers[right];
numbers[right] = temp;
left++;
right--;
}
System.out.println(Arrays.toString(numbers));
// Output: [5, 4, 3, 2, 1]
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
int[] withDuplicates = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
// Using a Set to remove duplicates (preserves insertion order with LinkedHashSet)
Set unique = new LinkedHashSet<>();
for (int num : withDuplicates) {
unique.add(num);
}
// Convert back to array
int[] noDuplicates = unique.stream().mapToInt(Integer::intValue).toArray();
System.out.println(Arrays.toString(noDuplicates));
// Output: [3, 1, 4, 5, 9, 2, 6]
// Alternative: Using streams (Java 8+)
int[] streamResult = Arrays.stream(withDuplicates).distinct().toArray();
System.out.println(Arrays.toString(streamResult));
// Output: [3, 1, 4, 5, 9, 2, 6]
import java.util.Arrays;
int[] first = {1, 2, 3};
int[] second = {4, 5, 6, 7};
// Approach 1: System.arraycopy
int[] merged = new int[first.length + second.length];
System.arraycopy(first, 0, merged, 0, first.length);
System.arraycopy(second, 0, merged, first.length, second.length);
System.out.println(Arrays.toString(merged));
// Output: [1, 2, 3, 4, 5, 6, 7]
// Approach 2: Streams (Java 8+)
int[] streamMerged = java.util.stream.IntStream
.concat(Arrays.stream(first), Arrays.stream(second))
.toArray();
System.out.println(Arrays.toString(streamMerged));
// Output: [1, 2, 3, 4, 5, 6, 7]
import java.util.Arrays;
String[] languages = {"Java", "Python", "JavaScript", "Go", "Rust"};
// Approach 1: Linear search with loop
boolean found = false;
for (String lang : languages) {
if (lang.equals("Python")) {
found = true;
break;
}
}
System.out.println("Found Python (loop): " + found);
// Output: Found Python (loop): true
// Approach 2: Arrays.asList().contains()
boolean exists = Arrays.asList(languages).contains("Python");
System.out.println("Found Python (asList): " + exists);
// Output: Found Python (asList): true
// Approach 3: Stream (Java 8+)
boolean streamFound = Arrays.stream(languages).anyMatch("Python"::equals);
System.out.println("Found Python (stream): " + streamFound);
// Output: Found Python (stream): true
// Approach 4: Binary search (requires sorted array)
Arrays.sort(languages);
boolean binaryFound = Arrays.binarySearch(languages, "Python") >= 0;
System.out.println("Found Python (binarySearch): " + binaryFound);
// Output: Found Python (binarySearch): true
import java.util.Arrays;
double[] prices = {19.99, 35.50, 12.75, 8.25, 44.00};
// Loop approach
double total = 0;
for (double price : prices) {
total += price;
}
System.out.printf("Total (loop): $%.2f%n", total);
// Output: Total (loop): $120.49
// Stream approach (Java 8+)
double streamTotal = Arrays.stream(prices).sum();
System.out.printf("Total (stream): $%.2f%n", streamTotal);
// Output: Total (stream): $120.49
// Average
double average = Arrays.stream(prices).average().orElse(0.0);
System.out.printf("Average price: $%.2f%n", average);
// Output: Average price: $24.10
When you assign one array variable to another, you are not copying the array -- you are copying the reference. Both variables then point to the same array in memory. To create an independent copy, you need to use one of the following techniques.
import java.util.Arrays;
int[] original = {1, 2, 3, 4, 5};
// Reference copy -- NOT an independent copy
int[] reference = original;
reference[0] = 999;
System.out.println(Arrays.toString(original));
// Output: [999, 2, 3, 4, 5] -- original is modified because both variables point to the same array!
// Reset
original[0] = 1;
// Actual copy -- independent array
int[] actualCopy = Arrays.copyOf(original, original.length);
actualCopy[0] = 999;
System.out.println(Arrays.toString(original));
// Output: [1, 2, 3, 4, 5] -- original is safe
System.out.println(Arrays.toString(actualCopy));
// Output: [999, 2, 3, 4, 5]
import java.util.Arrays;
int[] source = {10, 20, 30, 40, 50};
// 1. Arrays.copyOf() -- simplest approach
int[] copy1 = Arrays.copyOf(source, source.length);
System.out.println("copyOf: " + Arrays.toString(copy1));
// Output: copyOf: [10, 20, 30, 40, 50]
// 2. System.arraycopy() -- fastest (native method), most flexible
int[] copy2 = new int[source.length];
System.arraycopy(source, 0, copy2, 0, source.length);
System.out.println("arraycopy: " + Arrays.toString(copy2));
// Output: arraycopy: [10, 20, 30, 40, 50]
// System.arraycopy parameters:
// (sourceArray, sourceStartIndex, destArray, destStartIndex, numberOfElements)
// 3. clone() -- inherited from Object
int[] copy3 = source.clone();
System.out.println("clone: " + Arrays.toString(copy3));
// Output: clone: [10, 20, 30, 40, 50]
// 4. Manual loop -- useful when you need to transform while copying
int[] copy4 = new int[source.length];
for (int i = 0; i < source.length; i++) {
copy4[i] = source[i];
}
System.out.println("manual: " + Arrays.toString(copy4));
// Output: manual: [10, 20, 30, 40, 50]
All four methods above create a shallow copy. For primitive arrays (int[], double[], etc.), a shallow copy is effectively a deep copy because primitives are values, not references. However, for object arrays, a shallow copy only copies the references -- both arrays will point to the same objects.
import java.util.Arrays;
// Shallow copy with object arrays -- dangerous
StringBuilder[] original = {
new StringBuilder("Hello"),
new StringBuilder("World")
};
StringBuilder[] shallowCopy = original.clone();
// Modify an object through the copy
shallowCopy[0].append(" Java");
// The original is also affected because both arrays reference the same StringBuilder objects
System.out.println(original[0]); // Hello Java <-- MODIFIED!
System.out.println(shallowCopy[0]); // Hello Java
// Deep copy: you must create new objects manually
StringBuilder[] deepCopy = new StringBuilder[original.length];
for (int i = 0; i < original.length; i++) {
deepCopy[i] = new StringBuilder(original[i].toString());
}
// Now modifying the deep copy does NOT affect the original
deepCopy[0].append(" is great");
System.out.println(original[0]); // Hello Java <-- unchanged
System.out.println(deepCopy[0]); // Hello Java is great
Arrays and ArrayList are both used to store collections of elements, but they have fundamental differences. Understanding when to use each is an important skill for any Java developer.
| Feature | Array | ArrayList |
|---|---|---|
| Size | Fixed at creation | Grows and shrinks dynamically |
| Primitives | Yes (int[], double[], etc.) |
No (must use wrapper: Integer, Double) |
| Type safety | Checked at runtime (can throw ArrayStoreException) |
Generics provide compile-time type checking |
| Performance | Faster (no boxing, direct memory access) | Slightly slower (autoboxing, object overhead) |
| Memory | More compact (especially for primitives) | Higher overhead (stores object references + internal array) |
| Length | .length (field) |
.size() (method) |
| Multi-dimensional | Built-in (int[][]) |
Nested lists (ArrayList<ArrayList<Integer>>) |
| Built-in methods | None (use Arrays utility class) |
Rich API: add, remove, contains, indexOf, etc. |
| Null elements | Allowed in object arrays | Allowed |
| Thread safety | Not synchronized | Not synchronized (use Collections.synchronizedList) |
int[] is much more efficient than ArrayList<Integer>), or when working with multi-dimensional data.contains(), remove(), or indexOf(), when working with Java Collections Framework methods, or when you need to return a growable collection from a method.import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
// Array to ArrayList
String[] colorsArray = {"Red", "Green", "Blue"};
List colorsList = new ArrayList<>(Arrays.asList(colorsArray));
System.out.println("ArrayList: " + colorsList);
// Output: ArrayList: [Red, Green, Blue]
// ArrayList to Array
List fruitsList = new ArrayList<>();
fruitsList.add("Apple");
fruitsList.add("Banana");
fruitsList.add("Cherry");
String[] fruitsArray = fruitsList.toArray(new String[0]);
System.out.println("Array: " + Arrays.toString(fruitsArray));
// Output: Array: [Apple, Banana, Cherry]
// For primitive arrays, you need special handling
int[] primitiveArray = {1, 2, 3, 4, 5};
// int[] to List
List intList = new ArrayList<>();
for (int num : primitiveArray) {
intList.add(num); // autoboxing: int -> Integer
}
System.out.println("Integer List: " + intList);
// Output: Integer List: [1, 2, 3, 4, 5]
// Java 8+ stream approach
List streamList = Arrays.stream(primitiveArray).boxed().collect(java.util.stream.Collectors.toList());
System.out.println("Stream List: " + streamList);
// Output: Stream List: [1, 2, 3, 4, 5]
// List to int[]
int[] backToArray = intList.stream().mapToInt(Integer::intValue).toArray();
System.out.println("Back to array: " + Arrays.toString(backToArray));
// Output: Back to array: [1, 2, 3, 4, 5]
In Java, when you pass an array to a method, you are passing the reference by value. This means the method receives a copy of the reference that points to the same array object. As a result, any modifications the method makes to the array elements will be visible to the caller.
import java.util.Arrays;
public class ArrayMethodDemo {
// Modifying array elements inside a method DOES affect the original
static void doubleValues(int[] arr) {
for (int i = 0; i < arr.length; i++) {
arr[i] = arr[i] * 2;
}
}
// Reassigning the reference does NOT affect the original
static void tryToReplace(int[] arr) {
arr = new int[]{99, 99, 99}; // this only changes the local copy of the reference
}
// Returning an array from a method
static int[] createRange(int start, int end) {
int[] range = new int[end - start + 1];
for (int i = 0; i < range.length; i++) {
range[i] = start + i;
}
return range;
}
public static void main(String[] args) {
int[] numbers = {1, 2, 3, 4, 5};
// Modifications to elements persist
doubleValues(numbers);
System.out.println(Arrays.toString(numbers));
// Output: [2, 4, 6, 8, 10] -- original array is modified
// Reassignment inside the method does NOT affect the original
tryToReplace(numbers);
System.out.println(Arrays.toString(numbers));
// Output: [2, 4, 6, 8, 10] -- unchanged, the method only changed its local reference
// Getting an array back from a method
int[] range = createRange(5, 10);
System.out.println(Arrays.toString(range));
// Output: [5, 6, 7, 8, 9, 10]
}
}
Java's varargs syntax (Type... name) lets you pass zero or more arguments of a type, and Java automatically packages them into an array. The varargs parameter must be the last parameter in the method signature.
public class VarargsDemo {
// Varargs: accepts zero or more int arguments
static int sum(int... numbers) {
int total = 0;
for (int n : numbers) {
total += n;
}
return total;
}
// Varargs with other parameters (varargs must be last)
static void printScores(String studentName, int... scores) {
System.out.print(studentName + ": ");
for (int score : scores) {
System.out.print(score + " ");
}
System.out.println();
}
public static void main(String[] args) {
// Call with individual arguments
System.out.println(sum(1, 2, 3)); // Output: 6
System.out.println(sum(10, 20)); // Output: 30
System.out.println(sum()); // Output: 0 (zero arguments)
// Call with an array
int[] values = {5, 10, 15, 20};
System.out.println(sum(values)); // Output: 50
// Mixed parameters
printScores("Alice", 95, 87, 92);
// Output: Alice: 95 87 92
printScores("Bob", 78, 88);
// Output: Bob: 78 88
}
}
Arrays are deceptively simple. Here are the most common mistakes that trip up both beginners and experienced developers.
This is the single most common array error. It happens when you access an index that does not exist. Remember: valid indices run from 0 to length - 1.
int[] data = {10, 20, 30};
// BUG: <= causes the loop to access index 3, which does not exist
// for (int i = 0; i <= data.length; i++) {
// System.out.println(data[i]); // crashes on i = 3
// }
// CORRECT: use < (strictly less than)
for (int i = 0; i < data.length; i++) {
System.out.println(data[i]);
}
// SAFEST: use for-each when you don't need the index
for (int value : data) {
System.out.println(value);
}
== Instead of Arrays.equals()The == operator checks if two variables point to the same object in memory. It does not compare contents.
import java.util.Arrays;
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
// BUG: == checks reference equality, not content
if (a == b) {
System.out.println("Equal");
} else {
System.out.println("Not equal"); // This prints, even though contents are identical
}
// Output: Not equal
// CORRECT: use Arrays.equals() for content comparison
if (Arrays.equals(a, b)) {
System.out.println("Equal");
}
// Output: Equal
// For 2D arrays, use Arrays.deepEquals()
int[][] m1 = {{1, 2}, {3, 4}};
int[][] m2 = {{1, 2}, {3, 4}};
System.out.println(Arrays.equals(m1, m2)); // false (compares inner array references)
System.out.println(Arrays.deepEquals(m1, m2)); // true (compares actual values)
length, length(), and size()Java uses different syntax for different types. Getting them mixed up is a compile-time error.
import java.util.List;
import java.util.ArrayList;
int[] numbers = {1, 2, 3, 4, 5};
String text = "Hello";
List list = new ArrayList<>(List.of(1, 2, 3));
// Array: .length (property, no parentheses)
System.out.println(numbers.length); // 5
// String: .length() (method, with parentheses)
System.out.println(text.length()); // 5
// List/Collection: .size() (method, with parentheses)
System.out.println(list.size()); // 3
// Common compile errors:
// numbers.length() -- ERROR: cannot invoke length() on int[]
// numbers.size() -- ERROR: cannot invoke size() on int[]
// text.length -- ERROR: cannot access length field on String
// list.length -- ERROR: cannot access length field on ArrayList
When you create an object array with new, all elements default to null. Calling a method on a null element throws a NullPointerException.
String[] names = new String[5];
names[0] = "Alice";
names[1] = "Bob";
// indices 2, 3, 4 are still null
// BUG: NullPointerException on index 2
// for (String name : names) {
// System.out.println(name.toUpperCase()); // crashes when name is null
// }
// CORRECT: null check before using the element
for (String name : names) {
if (name != null) {
System.out.println(name.toUpperCase());
}
}
// Output:
// ALICE
// BOB
If you store 5 student names, the valid indices are 0 through 4, not 1 through 5. This is especially confusing when the array represents real-world items that people number starting from 1 (rooms, seats, etc.).
// Classroom seats numbered 1 through 30
// Approach 1: Use index 0 as seat 1 (adjust when displaying)
String[] seats = new String[30];
seats[0] = "Alice"; // seat 1
seats[1] = "Bob"; // seat 2
seats[29] = "Zara"; // seat 30
for (int i = 0; i < seats.length; i++) {
if (seats[i] != null) {
System.out.println("Seat " + (i + 1) + ": " + seats[i]);
}
}
// Output:
// Seat 1: Alice
// Seat 2: Bob
// Seat 30: Zara
// Approach 2: Create array with size+1 and ignore index 0
// This way seat numbers directly match indices
String[] directSeats = new String[31]; // indices 0-30, ignore 0
directSeats[1] = "Alice";
directSeats[2] = "Bob";
directSeats[30] = "Zara";
// Access: directSeats[seatNumber] -- no math needed
These guidelines will help you write array code that is clean, safe, and maintainable.
The for-each loop is cleaner, eliminates off-by-one errors, and clearly communicates that you are processing every element.
String[] names = {"Alice", "Bob", "Charlie"};
// Verbose and error-prone
for (int i = 0; i < names.length; i++) {
System.out.println(names[i]);
}
// Cleaner and safer
for (String name : names) {
System.out.println(name);
}
If you do not know the size in advance, or if elements will be added and removed, use ArrayList instead of trying to resize arrays manually.
import java.util.ArrayList; import java.util.List; // BAD: manual resizing is error-prone and ugly int[] data = new int[10]; int count = 0; // ... later when the array is full: // int[] bigger = new int[data.length * 2]; // System.arraycopy(data, 0, bigger, 0, data.length); // data = bigger; // GOOD: let ArrayList handle resizing ListdataList = new ArrayList<>(); dataList.add(42); dataList.add(17); // add as many elements as needed -- ArrayList resizes automatically
The java.util.Arrays class has optimized, well-tested implementations for sorting, searching, copying, filling, and comparing. Always check it first before writing your own.
import java.util.Arrays;
int[] data = {5, 3, 8, 1, 9, 2, 7};
// Use built-in sort instead of writing your own
Arrays.sort(data);
// Use built-in binary search instead of writing a loop
int index = Arrays.binarySearch(data, 7);
// Use built-in toString() instead of a formatting loop
System.out.println(Arrays.toString(data));
// Output: [1, 2, 3, 5, 7, 8, 9]
// Use built-in equals() instead of element-by-element comparison
int[] other = {1, 2, 3, 5, 7, 8, 9};
System.out.println(Arrays.equals(data, other));
// Output: true
When an index comes from user input, a calculation, or another method, always validate it before using it to access the array.
public static String safeGet(String[] array, int index) {
if (array == null) {
return "Array is null";
}
if (index < 0 || index >= array.length) {
return "Index " + index + " is out of bounds (valid: 0 to " + (array.length - 1) + ")";
}
return array[index];
}
// Usage
String[] colors = {"Red", "Green", "Blue"};
System.out.println(safeGet(colors, 1)); // Green
System.out.println(safeGet(colors, 5)); // Index 5 is out of bounds (valid: 0 to 2)
System.out.println(safeGet(null, 0)); // Array is null
When a method returns an array but has no results, return an empty array (new String[0]) instead of null. This lets callers safely iterate without null checks.
// BAD: returning null forces every caller to check for null
public static String[] findMatchesBad(String[] items, String prefix) {
// ... if no matches found:
return null;
}
// Caller must check for null EVERY TIME
// String[] matches = findMatchesBad(items, "X");
// if (matches != null) { // annoying and easy to forget
// for (String match : matches) { ... }
// }
// GOOD: return empty array, callers can iterate safely
public static String[] findMatches(String[] items, String prefix) {
java.util.List results = new java.util.ArrayList<>();
for (String item : items) {
if (item.startsWith(prefix)) {
results.add(item);
}
}
return results.toArray(new String[0]); // returns empty array if no matches
}
// Caller never needs to check for null
String[] items = {"Apple", "Avocado", "Banana", "Cherry"};
String[] matches = findMatches(items, "A");
for (String match : matches) {
System.out.println(match);
}
// Output:
// Apple
// Avocado
String[] noMatches = findMatches(items, "Z");
for (String match : noMatches) {
System.out.println(match); // loop body simply never executes
}
// (no output -- no crash, no null check needed)
Let us tie everything together with a practical program that uses arrays for real-world data management. This StudentGradeTracker demonstrates array creation, iteration, sorting, searching, copying, statistics calculations, and working with 2D arrays.
import java.util.Arrays;
public class StudentGradeTracker {
// Student names
private String[] names;
// 2D array: each row is a student, each column is an assignment grade
private double[][] grades;
// Number of students currently enrolled
private int studentCount;
public StudentGradeTracker(int maxStudents, int assignmentCount) {
this.names = new String[maxStudents];
this.grades = new double[maxStudents][assignmentCount];
this.studentCount = 0;
// Initialize all grades to -1 (ungraded)
for (double[] row : grades) {
Arrays.fill(row, -1.0);
}
}
// Add a student and their grades
public boolean addStudent(String name, double[] studentGrades) {
if (studentCount >= names.length) {
System.out.println("ERROR: Class is full. Cannot add " + name);
return false;
}
if (studentGrades.length != grades[0].length) {
System.out.println("ERROR: Expected " + grades[0].length
+ " grades, got " + studentGrades.length);
return false;
}
names[studentCount] = name;
// Copy grades (not reference) so the caller cannot modify our data
System.arraycopy(studentGrades, 0, grades[studentCount], 0, studentGrades.length);
studentCount++;
return true;
}
// Calculate average grade for a student
public double getStudentAverage(int studentIndex) {
if (studentIndex < 0 || studentIndex >= studentCount) {
return -1;
}
double sum = 0;
int count = 0;
for (double grade : grades[studentIndex]) {
if (grade >= 0) {
sum += grade;
count++;
}
}
return count > 0 ? sum / count : 0;
}
// Calculate average for a specific assignment across all students
public double getAssignmentAverage(int assignmentIndex) {
if (assignmentIndex < 0 || assignmentIndex >= grades[0].length) {
return -1;
}
double sum = 0;
int count = 0;
for (int s = 0; s < studentCount; s++) {
if (grades[s][assignmentIndex] >= 0) {
sum += grades[s][assignmentIndex];
count++;
}
}
return count > 0 ? sum / count : 0;
}
// Find the highest grade across all students and assignments
public double getHighestGrade() {
double highest = Double.MIN_VALUE;
for (int s = 0; s < studentCount; s++) {
for (double grade : grades[s]) {
if (grade > highest) {
highest = grade;
}
}
}
return highest;
}
// Find the lowest grade across all students and assignments
public double getLowestGrade() {
double lowest = Double.MAX_VALUE;
for (int s = 0; s < studentCount; s++) {
for (double grade : grades[s]) {
if (grade >= 0 && grade < lowest) {
lowest = grade;
}
}
}
return lowest;
}
// Get students sorted by average grade (descending) -- returns a ranked copy
public String[] getRankedStudents() {
// Create index-average pairs
double[] averages = new double[studentCount];
Integer[] indices = new Integer[studentCount];
for (int i = 0; i < studentCount; i++) {
averages[i] = getStudentAverage(i);
indices[i] = i;
}
// Sort indices by average grade (descending)
Arrays.sort(indices, (a, b) -> Double.compare(averages[b], averages[a]));
// Build ranked name list
String[] ranked = new String[studentCount];
for (int i = 0; i < studentCount; i++) {
ranked[i] = names[indices[i]];
}
return ranked;
}
// Determine letter grade from numeric score
private static String getLetterGrade(double average) {
if (average >= 90) return "A";
if (average >= 80) return "B";
if (average >= 70) return "C";
if (average >= 60) return "D";
return "F";
}
// Print a formatted report card
public void printReport() {
System.out.println("=".repeat(65));
System.out.println(" STUDENT GRADE REPORT");
System.out.println("=".repeat(65));
// Header row
System.out.printf("%-12s", "Student");
for (int a = 0; a < grades[0].length; a++) {
System.out.printf(" HW%-3d", a + 1);
}
System.out.printf(" %7s %5s%n", "Average", "Grade");
System.out.println("-".repeat(65));
// Student rows
for (int s = 0; s < studentCount; s++) {
System.out.printf("%-12s", names[s]);
for (double grade : grades[s]) {
if (grade >= 0) {
System.out.printf(" %5.1f", grade);
} else {
System.out.printf(" %5s", "N/A");
}
}
double avg = getStudentAverage(s);
System.out.printf(" %7.2f %5s%n", avg, getLetterGrade(avg));
}
System.out.println("-".repeat(65));
// Assignment averages
System.out.printf("%-12s", "Class Avg");
for (int a = 0; a < grades[0].length; a++) {
System.out.printf(" %5.1f", getAssignmentAverage(a));
}
System.out.println();
// Overall stats
System.out.println("-".repeat(65));
System.out.printf("Highest grade: %.1f%n", getHighestGrade());
System.out.printf("Lowest grade: %.1f%n", getLowestGrade());
System.out.println();
// Rankings
System.out.println("Class Rankings (by average):");
String[] ranked = getRankedStudents();
for (int i = 0; i < ranked.length; i++) {
int studentIndex = -1;
for (int s = 0; s < studentCount; s++) {
if (names[s].equals(ranked[i])) {
studentIndex = s;
break;
}
}
double avg = getStudentAverage(studentIndex);
System.out.printf(" %d. %-12s %.2f (%s)%n", i + 1, ranked[i], avg, getLetterGrade(avg));
}
System.out.println("=".repeat(65));
}
// --- Main method: demonstrate the tracker ---
public static void main(String[] args) {
// Create tracker for up to 10 students with 5 assignments
StudentGradeTracker tracker = new StudentGradeTracker(10, 5);
// Add students with their assignment grades
tracker.addStudent("Alice", new double[]{92, 88, 95, 90, 87});
tracker.addStudent("Bob", new double[]{78, 82, 75, 80, 85});
tracker.addStudent("Charlie", new double[]{95, 97, 93, 98, 96});
tracker.addStudent("Diana", new double[]{88, 72, 80, 85, 90});
tracker.addStudent("Edward", new double[]{65, 70, 68, 72, 60});
// Print the full report
tracker.printReport();
}
}
=================================================================
STUDENT GRADE REPORT
=================================================================
Student HW1 HW2 HW3 HW4 HW5 Average Grade
-----------------------------------------------------------------
Alice 92.0 88.0 95.0 90.0 87.0 90.40 A
Bob 78.0 82.0 75.0 80.0 85.0 80.00 B
Charlie 95.0 97.0 93.0 98.0 96.0 95.80 A
Diana 88.0 72.0 80.0 85.0 90.0 83.00 B
Edward 65.0 70.0 68.0 72.0 60.0 67.00 D
-----------------------------------------------------------------
Class Avg 83.6 81.8 82.2 85.0 83.6
-----------------------------------------------------------------
Highest grade: 98.0
Lowest grade: 60.0
Class Rankings (by average):
1. Charlie 95.80 (A)
2. Alice 90.40 (A)
3. Diana 83.00 (B)
4. Bob 80.00 (B)
5. Edward 67.00 (D)
=================================================================
names[] stores student namesgrades[][] stores each student's assignment scores (rows = students, columns = assignments)getRankedStudents() returns a new String arrayaddStudent() accepts a double array parameter
An operator is a special symbol that tells the Java compiler to perform a specific operation on one or more values. The values that an operator acts upon are called operands. Together, an operator and its operands form an expression that evaluates to a result.
int sum = 2 + 5; // 2 and 5 are operands // + is the operator // 2 + 5 is an expression that evaluates to 7 // = is the assignment operator that stores the result in the variable sum
| Operand | Operator | Operand |
|---|---|---|
| 2 | + | 5 |
Operators can be classified by the number of operands they take:
++count, !isValid)a + b, x > y)condition ? value1 : value2)Java provides the following groups of operators:
Arithmetic operators perform mathematical calculations, just like the algebra you learned in school. They work with numeric data types such as int, double, float, and long.
| Operator | Name | Description | Example | Result |
|---|---|---|---|---|
| + | Addition | Adds two values | 5 + 3 | 8 |
| – | Subtraction | Subtracts one value from another | 10 – 4 | 6 |
| * | Multiplication | Multiplies two values | 5 * 4 | 20 |
| / | Division | Divides the left operand by the right operand | 15 / 4 | 3 (integer division) |
| % | Modulus | Returns the remainder of a division | 15 % 4 | 3 |
| ++ | Increment | Increases the value by 1 | x++ or ++x | x + 1 |
| — | Decrement | Decreases the value by 1 | x– or –x | x – 1 |
public class ArithmeticOperators {
public static void main(String[] args) {
int a = 20;
int b = 7;
System.out.println("a + b = " + (a + b)); // Output: a + b = 27
System.out.println("a - b = " + (a - b)); // Output: a - b = 13
System.out.println("a * b = " + (a * b)); // Output: a * b = 140
System.out.println("a / b = " + (a / b)); // Output: a / b = 2 (integer division truncates)
System.out.println("a % b = " + (a % b)); // Output: a % b = 6 (remainder)
// Division with doubles gives the full decimal result
double x = 20.0;
double y = 7.0;
System.out.println("x / y = " + (x / y)); // Output: x / y = 2.857142857142857
}
}
The ++ and -- operators can be placed before (prefix) or after (postfix) a variable. The placement matters when the expression is used inside another statement:
++x) — increments the value first, then uses the new value in the expression.x++) — uses the current value in the expression first, then increments.This is a classic interview question. Understanding the difference will save you from subtle bugs.
public class IncrementDemo {
public static void main(String[] args) {
// Post-increment: uses the value THEN increments
int a = 5;
int b = a++;
System.out.println("Post-increment:");
System.out.println("b = " + b); // Output: b = 5 (a's old value was assigned first)
System.out.println("a = " + a); // Output: a = 6 (a was incremented after)
// Pre-increment: increments THEN uses the value
int x = 5;
int y = ++x;
System.out.println("\nPre-increment:");
System.out.println("y = " + y); // Output: y = 6 (x was incremented first)
System.out.println("x = " + x); // Output: x = 6
// Same rules apply to decrement (-- operator)
int count = 10;
System.out.println("\nPost-decrement: " + count--); // Output: 10 (prints, then decrements)
System.out.println("After post-decrement: " + count); // Output: 9
System.out.println("Pre-decrement: " + --count); // Output: 8 (decrements, then prints)
}
}
Comparison operators compare two values and return a boolean result: either true or false. They are most commonly used in if statements, while loops, and other control flow structures.
| Operator | Name | Description | Example | Result |
|---|---|---|---|---|
| == | Equal to | Returns true if both values are equal | 5 == 5 | true |
| != | Not equal to | Returns true if the values are not equal | 5 != 3 | true |
| > | Greater than | Returns true if the left value is greater than the right | 10 > 5 | true |
| < | Less than | Returns true if the left value is less than the right | 3 < 8 | true |
| >= | Greater than or equal to | Returns true if the left value is greater than or equal to the right | 5 >= 5 | true |
| <= | Less than or equal to | Returns true if the left value is less than or equal to the right | 3 <= 8 | true |
Important: The == operator compares primitive values directly. For objects like String, use the .equals() method instead. Using == on objects compares their memory references, not their content.
public class ComparisonOperators {
public static void main(String[] args) {
int age = 25;
int votingAge = 18;
System.out.println("age == 25: " + (age == 25)); // Output: true
System.out.println("age != votingAge: " + (age != votingAge)); // Output: true
System.out.println("age > votingAge: " + (age > votingAge)); // Output: true
System.out.println("age < 30: " + (age < 30)); // Output: true
System.out.println("age >= 25: " + (age >= 25)); // Output: true
System.out.println("age <= 18: " + (age <= 18)); // Output: false
// Common mistake with Strings
String name1 = new String("Java");
String name2 = new String("Java");
System.out.println("\nname1 == name2: " + (name1 == name2)); // Output: false (different objects)
System.out.println("name1.equals(name2): " + name1.equals(name2)); // Output: true (same content)
}
}
Assignment operators store a value in a variable. The basic assignment operator is =. Java also provides compound assignment operators that combine an arithmetic operation with assignment, making your code shorter and more readable.
| Operator | Name | Equivalent To | Example (if x = 10) | Result |
|---|---|---|---|---|
| = | Assign | x = value | x = 5 | x is 5 |
| += | Add and assign | x = x + value | x += 3 | x is 13 |
| -= | Subtract and assign | x = x - value | x -= 4 | x is 6 |
| *= | Multiply and assign | x = x * value | x *= 2 | x is 20 |
| /= | Divide and assign | x = x / value | x /= 5 | x is 2 |
| %= | Modulus and assign | x = x % value | x %= 3 | x is 1 |
public class AssignmentOperators {
public static void main(String[] args) {
int score = 100;
System.out.println("Initial score: " + score); // Output: 100
score += 10; // score = score + 10
System.out.println("After += 10: " + score); // Output: 110
score -= 25; // score = score - 25
System.out.println("After -= 25: " + score); // Output: 85
score *= 2; // score = score * 2
System.out.println("After *= 2: " + score); // Output: 170
score /= 5; // score = score / 5
System.out.println("After /= 5: " + score); // Output: 34
score %= 10; // score = score % 10
System.out.println("After %= 10: " + score); // Output: 4
}
}
Logical operators are used to combine multiple boolean expressions into a single result. They are essential for building complex conditions in if statements and loops. Every professional Java application uses logical operators extensively.
| Operator | Name | Description | Example | Result |
|---|---|---|---|---|
| && | Logical AND | Returns true only if both conditions are true | true && false | false |
| || | Logical OR | Returns true if at least one condition is true | true || false | true |
| ! | Logical NOT | Reverses the boolean value | !true | false |
Truth tables show every possible outcome for logical operators. Memorizing these will help you write correct conditions on the first try.
AND (&&) Truth Table:
| A | B | A && B |
|---|---|---|
| true | true | true |
| true | false | false |
| false | true | false |
| false | false | false |
OR (||) Truth Table:
| A | B | A || B |
|---|---|---|
| true | true | true |
| true | false | true |
| false | true | true |
| false | false | false |
public class LogicalOperators {
public static void main(String[] args) {
int age = 25;
boolean hasLicense = true;
boolean isInsured = false;
// AND (&&): Both conditions must be true
boolean canDrive = (age >= 16) && hasLicense;
System.out.println("Can drive: " + canDrive); // Output: true (both conditions are true)
// OR (||): At least one condition must be true
boolean hasDocuments = hasLicense || isInsured;
System.out.println("Has documents: " + hasDocuments); // Output: true (hasLicense is true)
// NOT (!): Reverses the boolean value
boolean isNotInsured = !isInsured;
System.out.println("Is NOT insured: " + isNotInsured); // Output: true
// Combining multiple logical operators
boolean canRentCar = (age >= 21) && hasLicense && isInsured;
System.out.println("Can rent car: " + canRentCar); // Output: false (isInsured is false)
// Real-world example: form validation
String username = "folau";
String password = "secret123";
boolean isValidLogin = (username != null && !username.isEmpty())
&& (password != null && password.length() >= 8);
System.out.println("Valid login: " + isValidLogin); // Output: true
}
}
Java's && and || operators use short-circuit evaluation, meaning they stop evaluating as soon as the result is determined:
&& (AND): If the left side is false, the right side is never evaluated because the result will always be false.|| (OR): If the left side is true, the right side is never evaluated because the result will always be true.This is not just a performance optimization — it is a defensive coding technique. You can use it to avoid NullPointerException errors:
public class ShortCircuitDemo {
public static void main(String[] args) {
// Short-circuit prevents NullPointerException
String name = null;
// Without short-circuit, this would crash with NullPointerException:
// boolean result = name.length() > 3; // CRASH! name is null
// With short-circuit AND, the second condition is skipped when name is null
boolean isLongName = (name != null) && (name.length() > 3);
System.out.println("Is long name: " + isLongName); // Output: false (safe!)
// Short-circuit OR: provides a default when first condition is true
boolean hasPermission = true;
boolean isAdmin = false;
// isAdmin is never checked because hasPermission is already true
boolean canAccess = hasPermission || isAdmin;
System.out.println("Can access: " + canAccess); // Output: true
// Practical example: safe method call chain
String email = null;
boolean isValidEmail = (email != null) && email.contains("@") && email.contains(".");
System.out.println("Valid email: " + isValidEmail); // Output: false (safe, no NPE)
}
}
Bitwise operators work on individual bits (0s and 1s) of integer values. While you may not use them daily, they appear in performance-critical code, permission systems, and low-level operations. They are also commonly asked about in interviews.
| Operator | Name | Description | Example (a=5, b=3) | Result |
|---|---|---|---|---|
| & | Bitwise AND | Sets each bit to 1 if both bits are 1 | 5 & 3 (0101 & 0011) | 1 (0001) |
| | | Bitwise OR | Sets each bit to 1 if at least one bit is 1 | 5 | 3 (0101 | 0011) | 7 (0111) |
| ^ | Bitwise XOR | Sets each bit to 1 if only one bit is 1 | 5 ^ 3 (0101 ^ 0011) | 6 (0110) |
| ~ | Bitwise NOT | Flips all bits (inverts) | ~5 (~0101) | -6 |
| << | Left shift | Shifts bits left, fills with 0s (multiplies by 2) | 5 << 1 (0101 << 1) | 10 (1010) |
| >> | Right shift | Shifts bits right, preserves sign (divides by 2) | 5 >> 1 (0101 >> 1) | 2 (0010) |
public class BitwiseOperators {
public static void main(String[] args) {
int a = 5; // binary: 0101
int b = 3; // binary: 0011
System.out.println("a & b = " + (a & b)); // Output: 1 (0001)
System.out.println("a | b = " + (a | b)); // Output: 7 (0111)
System.out.println("a ^ b = " + (a ^ b)); // Output: 6 (0110)
System.out.println("~a = " + (~a)); // Output: -6
System.out.println("a << 1 = " + (a << 1)); // Output: 10 (1010) - multiplied by 2
System.out.println("a >> 1 = " + (a >> 1)); // Output: 2 (0010) - divided by 2
// Practical example: checking if a number is even or odd using bitwise AND
int number = 42;
if ((number & 1) == 0) {
System.out.println(number + " is even"); // Output: 42 is even
} else {
System.out.println(number + " is odd");
}
// Practical example: permission flags
int READ = 0b001; // 1
int WRITE = 0b010; // 2
int EXECUTE = 0b100; // 4
int userPermissions = READ | WRITE; // Combine permissions: 011 (3)
boolean canRead = (userPermissions & READ) != 0;
boolean canWrite = (userPermissions & WRITE) != 0;
boolean canExec = (userPermissions & EXECUTE) != 0;
System.out.println("Can read: " + canRead); // Output: true
System.out.println("Can write: " + canWrite); // Output: true
System.out.println("Can execute: " + canExec); // Output: false
}
}
The ternary operator is Java's only operator that takes three operands. It is a compact alternative to an if-else statement and is useful when you need to assign a value based on a condition.
Syntax:
variable = (condition) ? valueIfTrue : valueIfFalse;
Think of it as asking a yes-or-no question: "Is this condition true? If yes, use the first value. If no, use the second value."
public class TernaryOperator {
public static void main(String[] args) {
// Basic example
int temperature = 35;
String weather = (temperature > 30) ? "Hot" : "Cool";
System.out.println("Weather: " + weather); // Output: Hot
// Equivalent if-else (ternary is more concise)
// String weather;
// if (temperature > 30) {
// weather = "Hot";
// } else {
// weather = "Cool";
// }
// Assigning max value
int a = 15;
int b = 22;
int max = (a > b) ? a : b;
System.out.println("Max: " + max); // Output: 22
// Checking for null safely
String name = null;
String displayName = (name != null) ? name : "Guest";
System.out.println("Hello, " + displayName); // Output: Hello, Guest
// Nested ternary (use sparingly - readability suffers)
int score = 85;
String grade = (score >= 90) ? "A"
: (score >= 80) ? "B"
: (score >= 70) ? "C"
: (score >= 60) ? "D" : "F";
System.out.println("Grade: " + grade); // Output: B
// Practical: formatting output
int itemCount = 1;
String message = "You have " + itemCount + " item" + (itemCount != 1 ? "s" : "") + " in your cart.";
System.out.println(message); // Output: You have 1 item in your cart.
itemCount = 5;
message = "You have " + itemCount + " item" + (itemCount != 1 ? "s" : "") + " in your cart.";
System.out.println(message); // Output: You have 5 items in your cart.
}
}
The instanceof operator checks whether an object is an instance of a specific class, subclass, or interface. It returns true or false. This operator is essential when working with inheritance and polymorphism, where you need to determine an object's actual type at runtime before performing type-specific operations.
public class InstanceofOperator {
public static void main(String[] args) {
// Basic type checking
String name = "Folau";
boolean isString = name instanceof String;
System.out.println("name instanceof String: " + isString); // Output: true
// Checking with inheritance
Object obj = "Hello World";
System.out.println("obj instanceof String: " + (obj instanceof String)); // Output: true
System.out.println("obj instanceof Object: " + (obj instanceof Object)); // Output: true
// Null check - instanceof always returns false for null
String nullStr = null;
System.out.println("null instanceof String: " + (nullStr instanceof String)); // Output: false
// Practical example: processing different types
Object[] items = {"Hello", 42, 3.14, true, new int[]{1, 2, 3}};
for (Object item : items) {
if (item instanceof String) {
System.out.println("String: " + item);
} else if (item instanceof Integer) {
System.out.println("Integer: " + item);
} else if (item instanceof Double) {
System.out.println("Double: " + item);
} else if (item instanceof Boolean) {
System.out.println("Boolean: " + item);
} else {
System.out.println("Other type: " + item.getClass().getSimpleName());
}
}
// Output:
// String: Hello
// Integer: 42
// Double: 3.14
// Boolean: true
// Other type: int[]
}
}
Since Java 16, you can use pattern matching with instanceof to cast and assign in a single step, eliminating the need for a separate cast statement:
public class PatternMatchingInstanceof {
public static void main(String[] args) {
Object value = "Hello, Java!";
// Before Java 16: traditional instanceof + cast
if (value instanceof String) {
String str = (String) value;
System.out.println("Length: " + str.length());
}
// Java 16+: pattern matching instanceof (cleaner)
if (value instanceof String str) {
System.out.println("Length: " + str.length()); // Output: Length: 12
}
// Works with negation too
Object number = 42;
if (!(number instanceof String str)) {
System.out.println("Not a string"); // Output: Not a string
}
}
}
When multiple operators appear in a single expression, Java uses operator precedence to determine the order of evaluation. Operators with higher precedence are evaluated first. When operators have the same precedence, associativity (left-to-right or right-to-left) determines the order.
You do not need to memorize this entire table. The practical rule is: use parentheses () to make your intent clear. Parentheses always have the highest precedence and make code more readable.
| Precedence | Operator | Description | Associativity |
|---|---|---|---|
| 1 (highest) | () [] . | Parentheses, array access, member access | Left to right |
| 2 | ++ -- ! ~ | Unary operators (prefix increment, NOT, bitwise NOT) | Right to left |
| 3 | * / % | Multiplication, division, modulus | Left to right |
| 4 | + - | Addition, subtraction | Left to right |
| 5 | << >> | Bitwise shift | Left to right |
| 6 | < <= > >= instanceof | Relational operators | Left to right |
| 7 | == != | Equality operators | Left to right |
| 8 | & | Bitwise AND | Left to right |
| 9 | ^ | Bitwise XOR | Left to right |
| 10 | | | Bitwise OR | Left to right |
| 11 | && | Logical AND | Left to right |
| 12 | || | Logical OR | Left to right |
| 13 | ? : | Ternary | Right to left |
| 14 (lowest) | = += -= *= /= %= | Assignment operators | Right to left |
public class OperatorPrecedence {
public static void main(String[] args) {
// Multiplication is evaluated before addition (higher precedence)
int result1 = 2 + 3 * 4;
System.out.println("2 + 3 * 4 = " + result1); // Output: 14 (not 20)
// Parentheses override default precedence
int result2 = (2 + 3) * 4;
System.out.println("(2 + 3) * 4 = " + result2); // Output: 20
// Logical AND (&&) has higher precedence than logical OR (||)
boolean result3 = true || false && false;
System.out.println("true || false && false = " + result3); // Output: true
// Evaluated as: true || (false && false) -> true || false -> true
// Use parentheses to change the order
boolean result4 = (true || false) && false;
System.out.println("(true || false) && false = " + result4); // Output: false
// Complex expression - parentheses make intent clear
int x = 10, y = 5, z = 3;
// Unclear without parentheses:
int unclear = x + y * z - x / y;
// Clear with parentheses:
int clear = x + (y * z) - (x / y);
System.out.println("Result: " + unclear); // Output: 23
System.out.println("Result: " + clear); // Output: 23 (same, but readable)
}
}
In real-world applications, you will frequently combine multiple types of operators in a single block of code. The following examples demonstrate how operators work together in scenarios you might encounter on the job.
/**
* Example 1: Grade Calculator
* Combines arithmetic, comparison, logical, and ternary operators.
*/
public class GradeCalculator {
public static void main(String[] args) {
int exam1 = 88;
int exam2 = 76;
int exam3 = 95;
int homework = 82;
// Arithmetic operators: calculate weighted average
double finalGrade = (exam1 + exam2 + exam3) / 3.0 * 0.7 + homework * 0.3;
System.out.println("Final grade: " + finalGrade); // Output: 85.21666...
// Comparison + ternary: determine letter grade
String letterGrade = (finalGrade >= 90) ? "A"
: (finalGrade >= 80) ? "B"
: (finalGrade >= 70) ? "C"
: (finalGrade >= 60) ? "D" : "F";
System.out.println("Letter grade: " + letterGrade); // Output: B
// Logical operators: check honors eligibility
boolean noFailingExams = (exam1 >= 60) && (exam2 >= 60) && (exam3 >= 60);
boolean isHonors = (finalGrade >= 85) && noFailingExams;
System.out.println("Honors: " + isHonors); // Output: true
// Assignment operator: apply bonus
double bonus = isHonors ? 2.0 : 0.0;
finalGrade += bonus;
System.out.println("Final grade with bonus: " + finalGrade); // Output: 87.21666...
}
}
/**
* Example 2: User Registration Validation
* Demonstrates real-world use of logical, comparison, and instanceof operators.
*/
public class RegistrationValidator {
public static void main(String[] args) {
String username = "john_doe";
String password = "Secure@123";
String email = "john@example.com";
int age = 20;
// Validate username: not null, not empty, between 3-20 characters
boolean isValidUsername = (username != null)
&& (!username.isEmpty())
&& (username.length() >= 3)
&& (username.length() <= 20);
// Validate password: at least 8 characters, contains a digit
boolean hasDigit = false;
for (char c : password.toCharArray()) {
if (c >= '0' && c <= '9') { // Comparison operators on char values
hasDigit = true;
break;
}
}
boolean isValidPassword = (password != null) && (password.length() >= 8) && hasDigit;
// Validate email: contains @ and a dot after @
boolean isValidEmail = (email != null)
&& email.contains("@")
&& (email.indexOf('.', email.indexOf('@')) > 0);
// Validate age: must be 18 or older
boolean isValidAge = (age >= 18);
// All fields must be valid for registration to succeed
boolean canRegister = isValidUsername && isValidPassword && isValidEmail && isValidAge;
// Ternary operator for the final message
String status = canRegister ? "Registration successful!" : "Registration failed. Please fix errors.";
System.out.println(status); // Output: Registration successful!
// Detailed feedback using logical NOT
if (!isValidUsername) System.out.println("- Invalid username");
if (!isValidPassword) System.out.println("- Invalid password");
if (!isValidEmail) System.out.println("- Invalid email");
if (!isValidAge) System.out.println("- Must be 18 or older");
}
}
/**
* Example 3: Simple Shopping Cart
* Demonstrates arithmetic, comparison, ternary, and compound assignment operators.
*/
public class ShoppingCart {
public static void main(String[] args) {
double itemPrice = 29.99;
int quantity = 3;
double taxRate = 0.08; // 8% tax
double discountThreshold = 50.0;
double discountRate = 0.10; // 10% discount
// Calculate subtotal using arithmetic operators
double subtotal = itemPrice * quantity;
System.out.println("Subtotal: $" + subtotal); // Output: $89.97
// Apply discount if subtotal exceeds threshold (comparison + ternary)
double discount = (subtotal >= discountThreshold) ? subtotal * discountRate : 0.0;
System.out.println("Discount: $" + discount); // Output: $8.997
// Compound assignment: subtract discount from subtotal
subtotal -= discount;
System.out.println("After discount: $" + subtotal); // Output: $80.973
// Calculate tax
double tax = subtotal * taxRate;
System.out.println("Tax: $" + tax); // Output: $6.47784
// Compound assignment: add tax to subtotal
subtotal += tax;
System.out.printf("Total: $%.2f%n", subtotal); // Output: Total: $87.45
// Check if free shipping applies (logical operator)
boolean isMember = true;
boolean freeShipping = (subtotal > 75.0) || isMember;
System.out.println("Free shipping: " + freeShipping); // Output: true
}
}
Operators are the building blocks of every Java expression. Here is a quick recap of what we covered:
| Operator Category | Operators | Primary Use |
|---|---|---|
| Arithmetic | + - * / % ++ -- | Mathematical calculations |
| Comparison | == != > < >= <= | Comparing values, producing booleans |
| Assignment | = += -= *= /= %= | Storing and updating values in variables |
| Logical | && || ! | Combining boolean conditions |
| Bitwise | & | ^ ~ << >> | Bit-level operations, flags, permissions |
| Ternary | ? : | Inline conditional value assignment |
| Instanceof | instanceof | Runtime type checking |
Key takeaways:
++x) and post-increment (x++) — it is a common interview question.== for primitive comparisons and .equals() for object comparisons.&& and ||) to write null-safe conditions.