Java System, OOP, and Design Interview Questions
These questions focus on senior Java backend interviews for enterprise systems: platform fundamentals, object-oriented design, API contracts, build tooling, and maintainable service design.
1. What is the difference between the JDK, JRE, JVM, and Java SE?
The JVM executes bytecode and manages runtime concerns like class loading, memory, and garbage collection. The JRE is the runtime environment needed to run Java applications. The JDK includes the tools needed to develop Java code, such as javac, jar, and debugging tools. Java SE is the standard platform specification and API set. In modern Java, interviewers usually expect you to say: develop with a JDK, ship against a target Java runtime/platform, and understand that the JVM is the execution engine rather than the language itself.
2. What happens from .java source code to a running Java process?
Source files are compiled by javac into .class bytecode. At runtime, the class loader loads classes, bytecode verification checks safety constraints, and the JVM interprets or JIT-compiles hot paths into machine code. Objects are allocated on the heap, method frames live on thread stacks, and garbage collectors reclaim unreachable heap objects. A strong senior answer connects compile time, class loading, runtime optimization, and memory management.
3. How would you explain Java packages and modules?
Packages organize related classes and avoid naming collisions. Modules add stronger boundaries by declaring what a module requires and what packages it exports. Packages are mostly a naming and access-control structure; modules are a dependency and encapsulation structure. In large systems, modules can make dependencies explicit and reduce accidental coupling, but many enterprise applications still rely on classpath-based dependency management.
4. What is the difference between classpath and module path?
The classpath is a flat search path for classes and JARs. The module path is used by the Java Platform Module System to resolve modules and their declared dependencies. Classpath code often lives in the unnamed module and can hide dependency problems until runtime. Module path code has stronger readability rules and better encapsulation, but it requires libraries to be modularized or at least compatible with automatic modules.
5. What is encapsulation, and why does it matter in system design?
Encapsulation means hiding internal state and exposing behavior through a controlled API. In Java, that usually means private fields, constructors or factories that maintain invariants, and methods that express domain operations. Encapsulation matters because it lets you change internal representation without breaking callers. In service design, the same idea applies at the module or API boundary: expose stable behavior, not internal tables or implementation details.
class BankAccount {
private long balanceInCents;
BankAccount(long openingBalanceInCents) {
if (openingBalanceInCents < 0) {
throw new IllegalArgumentException("opening balance cannot be negative");
}
this.balanceInCents = openingBalanceInCents;
}
void withdraw(long amountInCents) {
if (amountInCents <= 0 || amountInCents > balanceInCents) {
throw new IllegalArgumentException("invalid withdrawal");
}
balanceInCents -= amountInCents;
}
long balanceInCents() {
return balanceInCents;
}
} 6. How do abstraction and interface design differ?
Abstraction is the design idea of exposing essential behavior while hiding irrelevant details. An interface is one Java tool for expressing an abstraction. A good interface describes what clients need, not what the current implementation happens to do. In senior interviews, emphasize small cohesive interfaces, clear ownership of behavior, and avoiding “god interfaces” that force implementations to depend on methods they do not need.
interface PaymentProcessor {
PaymentReceipt charge(Money amount, PaymentMethod method);
}
class CheckoutService {
private final PaymentProcessor paymentProcessor;
CheckoutService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
PaymentReceipt checkout(Order order, PaymentMethod method) {
return paymentProcessor.charge(order.total(), method);
}
} 7. When should you use an interface instead of an abstract class?
Use an interface when you want to define a capability or contract that can be implemented by unrelated classes. Use an abstract class when you need shared state, shared protected behavior, or a partial base implementation. Java interfaces can have default and static methods, but they still should not become dumping grounds for shared mutable state. Prefer interfaces for public service contracts and abstract classes for narrow internal reuse.
interface Auditable {
String auditId();
}
abstract class BaseReport implements Auditable {
private final String auditId;
protected BaseReport(String auditId) {
this.auditId = auditId;
}
@Override
public String auditId() {
return auditId;
}
abstract String render();
} 8. What is polymorphism in Java?
Polymorphism means code can work with values through a common type while runtime dispatch chooses the actual implementation. In Java, overriding instance methods gives dynamic dispatch, while overloading is compile-time method selection based on parameter types. The senior-level point is that polymorphism reduces branching and makes extension easier, but only when the abstraction is stable and meaningful.
interface NotificationSender {
void send(String userId, String message);
}
class EmailSender implements NotificationSender {
public void send(String userId, String message) {
System.out.println("email: " + message);
}
}
class SmsSender implements NotificationSender {
public void send(String userId, String message) {
System.out.println("sms: " + message);
}
}
void notifyUser(List<NotificationSender> senders, String userId, String message) {
for (NotificationSender sender : senders) {
sender.send(userId, message);
}
} 9. What is the difference between overloading, overriding, and hiding?
Overloading means multiple methods share a name but have different parameter lists; selection happens at compile time. Overriding means a subclass provides a new implementation for an inherited instance method; selection happens at runtime. Hiding applies to static methods and fields, where the referenced type controls selection rather than the runtime object. A clean answer should explicitly say that static methods are not polymorphic in the same way instance methods are.
Hiding is easiest to see with static methods. If a subclass declares a static method with the same signature as a static method in the parent class, the subclass method hides the parent method. Because static methods are resolved by the reference type, not the actual runtime object. The selected method depends on the reference type used at compile time, not the object created at runtime.
class Parent {
static void sayHi() {
System.out.println("Parent static method");
}
void describe() {
System.out.println("Parent instance method");
}
}
class Child extends Parent {
static void sayHi() {
System.out.println("Child static method");
}
@Override
void describe() {
System.out.println("Child instance method");
}
}
public class HidingExample {
public static void main(String[] args) {
Parent value = new Child();
value.sayHi(); // Parent static method
value.describe(); // Child instance method
Parent.sayHi(); // Parent static method
Child.sayHi(); // Child static method
}
} The important contrast is that describe() is overridden, so Java dispatches to Child based on the runtime object. sayHi() is hidden, so Java resolves it from the reference type, Parent. For readability, avoid calling static methods through instances; prefer Parent.sayHi() or Child.sayHi() so the selected method is explicit.
10. What is the equals and hashCode contract?
If two objects are equal according to equals, they must return the same hashCode. If equals changes because mutable fields change, hash-based collections like HashMap and HashSet can break because the object may be stored in the wrong bucket. Implement both methods from the same stable identity fields, and avoid using mutable fields for equality when instances will be keys in maps or members of sets.
record UserId(String value) { }
Set<UserId> users = new HashSet<>();
users.add(new UserId("u-123"));
System.out.println(users.contains(new UserId("u-123"))); // true
// Records implement equals and hashCode from their components,
// so two UserId values with the same value compare as equal. 11. When should you use composition over inheritance?
Prefer composition when you want to reuse behavior without creating a rigid type hierarchy. Inheritance is best for true “is-a” relationships where substitutability holds. Composition is better for “has-a” relationships and for behavior that may vary independently. For example, an OrderService can compose a PricingStrategy rather than subclassing many service variants. This reduces coupling and makes testing easier.
interface PricingStrategy {
Money price(Order order);
}
class StandardPricing implements PricingStrategy {
public Money price(Order order) {
return order.subtotal();
}
}
class OrderService {
private final PricingStrategy pricingStrategy;
OrderService(PricingStrategy pricingStrategy) {
this.pricingStrategy = pricingStrategy;
}
Money total(Order order) {
return pricingStrategy.price(order);
}
} 12. How do you design immutable Java objects?
Make the class final or carefully control inheritance, make fields private and final, validate all constructor inputs, avoid exposing mutable internals, and make defensive copies of mutable arguments and return values. Immutability makes objects easier to reason about, safe to share across threads, and useful as map keys. Records are convenient for shallow immutable data carriers, but you still need defensive copies if a component is mutable.
final class OrderSnapshot {
private final List<String> itemIds;
OrderSnapshot(List<String> itemIds) {
this.itemIds = List.copyOf(itemIds);
}
List<String> itemIds() {
return itemIds;
}
} 13. What are Java records, and when should you avoid them?
Records are concise transparent carriers for immutable data. They work well for DTOs, value-like return types, event payloads, and composite keys. Avoid records when the type needs rich lifecycle behavior, complex invariants across multiple construction paths, identity-based mutability, lazy-loaded state, or framework requirements that conflict with record construction. A record should still represent a real domain concept, not just “a bag of fields”.
record EmailAddress(String value) {
EmailAddress {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("invalid email address");
}
value = value.trim().toLowerCase();
}
} 14. How does Java generics type erasure affect design?
Generics provide compile-time type safety, but most generic type information is erased at runtime. That means List<String> and List<Integer> are both just List at runtime, and you cannot directly create new T() or check instanceof List<String>. In API design, pass Class<T> or a type token when runtime type information is needed, and avoid APIs that depend on unavailable generic metadata.
class JsonReader {
<T> T read(String json, Class<T> type) {
// Runtime code needs Class<T> because T is erased.
return deserialize(json, type);
}
private <T> T deserialize(String json, Class<T> type) {
throw new UnsupportedOperationException("example only");
}
}
// This is not legal:
// if (value instanceof List<String>) { ... } 15. What does PECS mean in Java generics?
PECS means “producer extends, consumer super.” Use ? extends T when a structure produces T values for you to read. Use ? super T when a structure consumes T values you want to write. For example, List<? extends Number> is good for reading numbers, while List<? super Integer> is good for adding integers. This is about variance and keeping generic APIs flexible without losing type safety.
double sum(List<? extends Number> numbers) {
double total = 0;
for (Number number : numbers) {
total += number.doubleValue();
}
return total;
}
void addDefaults(List<? super Integer> output) {
output.add(1);
output.add(2);
output.add(3);
} 16. How do checked and unchecked exceptions affect API design?
Checked exceptions force callers to acknowledge recoverable failure paths, but too many checked exceptions can make APIs noisy and brittle. Unchecked exceptions are better for programming errors, invariant violations, or failures the immediate caller cannot reasonably handle. In service code, convert low-level exceptions into domain-specific errors at boundaries, preserve causes, and do not leak infrastructure-specific exceptions across public APIs.
class PaymentGatewayException extends RuntimeException {
PaymentGatewayException(String message, Throwable cause) {
super(message, cause);
}
}
class PaymentClient {
PaymentReceipt charge(PaymentRequest request) {
try {
return callGateway(request);
} catch (IOException exception) {
throw new PaymentGatewayException("payment gateway unavailable", exception);
}
}
} 17. How do annotations and reflection affect Java application design?
Annotations let code attach structured metadata to classes, methods, fields, and parameters. Frameworks can then use reflection to inspect that metadata and wire behavior such as dependency injection, validation, serialization, routing, or persistence. The design trade-off is that annotations make code declarative and concise, but too much reflection-driven behavior can hide control flow and move errors from compile time to runtime. In senior design discussions, explain what the annotation means, who consumes it, and how failures are detected.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Audit {
String value();
}
class AccountService {
@Audit("close-account")
void closeAccount(String accountId) {
// business logic
}
}
for (Method method : AccountService.class.getDeclaredMethods()) {
Audit audit = method.getAnnotation(Audit.class);
if (audit != null) {
System.out.println(method.getName() + " -> " + audit.value());
}
} 18. How would you explain garbage collection to a backend engineer?
Garbage collection automatically reclaims heap memory for objects no longer reachable by the application. The main engineering trade-off is not “GC or no GC”; it is throughput, pause time, allocation rate, heap size, and object lifetime profile. For a backend service, watch allocation hotspots, avoid unnecessary short-lived garbage in hot paths, and tune only after measuring with logs and profiling. Premature GC tuning often hides simpler design issues.
19. How do Maven or Gradle dependencies affect system reliability?
Build tools resolve dependency versions, transitive dependencies, scopes/configurations, repositories, and packaging. Dependency drift can cause runtime incompatibilities, security exposure, or classpath conflicts. A senior answer should mention lockfiles or dependency verification where available, explicit version management, avoiding dynamic versions in production, minimizing dependency surface area, and understanding which dependencies are compile-only, runtime-only, or exposed as API.
20. How would you design a maintainable Java service layer?
Start with domain boundaries and use cases, not framework annotations. Keep controllers thin, put business behavior in cohesive services or domain objects, isolate persistence behind repositories, define external integrations behind clients/adapters, and make side effects explicit. Use dependency injection to invert control, but avoid turning every small helper into a service. The goal is code that is testable, observable, and change-friendly under real business pressure.
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
OrderResponse placeOrder(PlaceOrderRequest request) {
Order order = orderService.placeOrder(request.customerId(), request.items());
return OrderResponse.from(order);
}
}
class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
Order placeOrder(String customerId, List<OrderItem> items) {
Order order = Order.create(customerId, items);
paymentClient.authorize(order.paymentRequest());
return orderRepository.save(order);
}
}