Expert Core Java design questions — securing third-party plugins now that the Security Manager is gone, default methods and backward compatibility, dynamic proxies and java.lang.reflect.Proxy, implementing deep copies, debugging NullPointerExceptions (helpful NPE messages), equals/hashCode in high-traffic caches, the Reflection API, a reflection-based DI container, designing unextendable classes, final as a design tool, how records and sealed classes change OOP, a payment-method design, and choosing functional interfaces.
Published September 25, 2026
These are design and judgement questions. The expected answer isn't a definition: it's a decision with trade-offs, ideally tied to how frameworks you use (Spring, Hibernate, Jackson) rely on the same mechanisms.
Short answer: Treat plugins as untrusted code, and remember that in-process sandboxing is no longer available. The Security Manager was deprecated for removal in Java 17, and permanently disabled in Java 24 (JEP 486), so you can't restrict what code does once it's inside your JVM. Use defence in depth:
Common trap: answering "use the SecurityManager and a policy file". That mechanism no longer works on current Java.
Short answer: They let you add methods to published interfaces without breaking implementers. Existing classes inherit the default body, so they still compile and link. That's how Java 8 added stream(), forEach() and removeIf() to collections. The limits:
removeIf on a custom concurrent collection).Use defaults for genuinely generic behaviour built from the abstract methods, and document them. Don't use them to smuggle state or complex logic into interfaces.
java.lang.reflect.Proxy for?Short answer: java.lang.reflect.Proxy generates, at runtime, a class implementing one or more interfaces. Every call on it is routed to an InvocationHandler, which can:
It's the mechanism behind:
@SuppressWarnings("unchecked")
static <T> T retrying(T target, Class<T> api, int maxAttempts) {
return (T) Proxy.newProxyInstance(api.getClassLoader(), new Class<?>[]{api}, (proxy, method, args) -> {
for (int attempt = 1; ; attempt++) {
try { return method.invoke(target, args); }
catch (InvocationTargetException e) {
if (attempt >= maxAttempts || !(e.getCause() instanceof IOException)) throw e.getCause();
}
}
});
}
Key points to cover:
final classes or methods.equals, hashCode and toString also go through the handler.Learn it in depth → Composite & Proxy
Short answer: Options, from best to worst:
List.copyOf), which can be shared safely.copy() methods that copy each mutable field recursively (and use List.copyOf/new ArrayList<>(...) for collections). This is explicit, fast, and type-safe. Handle cycles with an identity map if the graph has them.public Order deepCopy() {
Order copy = new Order(this.id, this.customer); // Customer is immutable: sharing is fine
this.lines.forEach(l -> copy.lines.add(new OrderLine(l))); // mutable children copied
copy.metadata = new HashMap<>(this.metadata); // values immutable (Strings)
return copy;
}
Common trap: clone() on an object with a List field copies only the reference. Both "copies" share the same list.
NullPointerException. How do you diagnose it, and make such problems easier to debug?Short answer:
Cannot invoke "Customer.getAddress()" because "order.customer" is null. Make sure your logs keep full stack traces, with correlation or trace IDs.Optional or a default) or invalid (fail fast with Objects.requireNonNull and a clear message at the boundary).Preventing NPEs, as a practice:
@Nullable/@NullMarked, with NullAway or Error Prone. Spring Framework 7 adopts JSpecify.null.Optional for optional return values.Common trap: sprinkling if (x != null) checks everywhere hides bugs. The page silently shows nothing, instead of failing loudly where the real mistake is.
equals() and hashCode() correctly affect a cache in a high-traffic application?Short answer:
equals (ignoring a relevant field) causes wrong data to be served, for example user A seeing user B's cached result. That's a security incident.hashCode (a constant, or only a low-entropy field) makes buckets collide. Lookups degrade towards O(log n) (treeified) or O(n), CPU spikes, and throughput collapses under load.hashCode (hashing big collections on every lookup) costs CPU. Cache the hash in immutable keys.Best practice: use small, immutable key types (records: record PriceKey(String sku, String region, Currency ccy)) that include every dimension the value depends on (tenant, locale, version). Test the contract (EqualsVerifier). For distributed caches (Redis), the serialised key string plays the role of equals: build it deterministically.
Short answer: Reflection (java.lang.reflect plus Class) lets code inspect and use types at runtime:
setAccessible(true), where modules allow it.Use cases:
@Test methods), mocking (Mockito), validation, and plugin loading by class name.The costs:
--add-opens, and your own modules must opens packages.Prefer MethodHandles, annotation processing or code generation where performance or native images matter.
Short answer:
@Component (or register them explicitly).@Inject).constructor.newInstance(args).@Inject fields, and call @PostConstruct methods.public final class MiniContainer {
private final Map<Class<?>, Object> singletons = new HashMap<>();
private final Set<Class<?>> creating = new HashSet<>();
private final Map<Class<?>, Class<?>> bindings; // interface -> implementation
public MiniContainer(Map<Class<?>, Class<?>> bindings) { this.bindings = bindings; }
@SuppressWarnings("unchecked")
public synchronized <T> T get(Class<T> type) {
Class<?> impl = bindings.getOrDefault(type, type);
Object existing = singletons.get(impl);
if (existing != null) return (T) existing;
if (!creating.add(impl)) throw new IllegalStateException("Circular dependency on " + impl.getName());
try {
Constructor<?> ctor = Arrays.stream(impl.getDeclaredConstructors())
.filter(c -> c.isAnnotationPresent(Inject.class)).findFirst()
.orElseGet(() -> impl.getDeclaredConstructors()[0]);
Object[] args = Arrays.stream(ctor.getParameterTypes()).map(this::get).toArray();
ctor.setAccessible(true);
Object instance = ctor.newInstance(args);
singletons.put(impl, instance);
return (T) instance;
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Cannot create " + impl.getName(), e);
} finally {
creating.remove(impl);
}
}
}
Key points to cover:
Short answer: Make it immutable and non-extendable:
final (or give it only private constructors, with static factories).private final.withX(...)).List.copyOf, and copies of Date or arrays).this during construction.Records give you most of this for free: a final class, private final fields, and accessors. You still need to copy mutable components in the compact constructor.
public record Allocation(String orderId, List<String> skus) {
public Allocation {
Objects.requireNonNull(orderId);
skus = List.copyOf(skus); // defensive, immutable copy
}
public Allocation withSku(String sku) {
var copy = new ArrayList<>(skus); copy.add(sku);
return new Allocation(orderId, copy);
}
}
Common trap: final alone doesn't make a class unmodifiable. A final class with a public setter, or an exposed mutable list, can still be changed. And "final utility class" is a different goal: a private constructor, and static methods.
final significantly affect the design of a Java program?Short answer:
final fields give safe publication guarantees (JMM §17.5). Immutable objects can be shared across threads without locks: value objects, configuration, and messages.final unless they're deliberately designed and documented for extension. This avoids fragile base classes, and keeps invariants safe (String, Integer and LocalDate are final).static final) are inlined at compile time. Remember the recompilation trap.final leaves in a closed type family.Key points to cover:
final classes can't be proxied by CGLIB. Spring and Hibernate need non-final classes and methods for class-based proxies (JPA entities and @Transactional beans). Kotlin's all-open plugin exists for exactly this reason.Short answer: They push Java towards data-oriented programming, alongside classic OOP:
equals, hashCode, toString and accessors are generated, and the state is the API. They make value objects (Money, IDs, DTOs, events) cheap to write, and encourage immutability. They're final, and can't extend classes, which favours composition.switch with record patterns, Java 21), you get exhaustive, compiler-checked handling of all variants. These are algebraic data types.sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String authId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(Duration retryAfter) implements PaymentResult {}
String message(PaymentResult r) {
return switch (r) { // exhaustive: no default needed
case Approved a -> "Paid (" + a.authId() + ")";
case Declined(String reason) -> "Declined: " + reason;
case Pending p -> "Retry in " + p.retryAfter().toSeconds() + "s";
};
}
Learn it in depth → Sealed Classes
Short answer:
PaymentMethod/PaymentProvider, with authorize, capture, refund and supports(method). Callers depend only on this interface (Strategy + DIP).AbstractPaymentProvider implements the interface with a template method: validate → idempotency check → call the provider (abstract) → map the result → audit and metrics. It contains no provider-specific logic.CardProvider, PayPalProvider, CryptoProvider) implement only their API calls and error mapping (the Adapter role for each SDK).Money value objects (never double), idempotency keys, asynchronous confirmations for crypto (pending states and webhooks), a sealed PaymentResult, retries and circuit breakers per provider, and PCI scope reduction (tokenisation).public interface PaymentProvider {
PaymentMethodType type();
PaymentResult authorize(PaymentRequest request);
RefundResult refund(RefundRequest request);
}
public abstract class AbstractPaymentProvider implements PaymentProvider {
@Override public final PaymentResult authorize(PaymentRequest req) {
validate(req);
return idempotency.getOrCompute(req.idempotencyKey(), () -> {
PaymentResult r = doAuthorize(req); // provider-specific
audit.record(req, r);
return r;
});
}
protected abstract PaymentResult doAuthorize(PaymentRequest req);
protected void validate(PaymentRequest req) { if (req.amount().isNegativeOrZero()) throw new InvalidPaymentException(); }
}
Learn it in depth → Strategy Pattern
Short answer: Whenever you need to pass behaviour consisting of one operation, without a class hierarchy. Real examples:
Predicate<Order> isEligibleForFreeShipping, composed with and/or/negate, and configured per region.retry(() -> client.call(), policy) takes a Supplier<T>.Consumer<OrderPlaced>, and UI listeners (ActionListener is a functional interface).Map<CustomerTier, UnaryOperator<Money>>.jdbcTemplate.query(sql, rowMapper), where RowMapper is functional.Map<CustomerTier, UnaryOperator<Money>> discount = Map.of(
CustomerTier.GOLD, m -> m.times(0.90),
CustomerTier.SILVER, m -> m.times(0.95),
CustomerTier.BASIC, UnaryOperator.identity());
Money finalPrice = discount.get(customer.tier()).apply(basePrice);
Key points to cover:
FraudRule), or you need checked exceptions.Q: What replaced the Security Manager for restricting code? A: Nothing inside the JVM. Isolation now comes from the OS and the platform: containers, separate processes, seccomp or AppArmor, VMs, or sandboxed runtimes (WASM). JPMS and class loaders provide modularity, not security.
Q: How do Spring's JDK proxies and CGLIB proxies differ in behaviour?
A: A JDK proxy implements only the bean's interfaces, so you must inject by interface. CGLIB subclasses the class, so you can inject by class, but final methods aren't intercepted, and the constructor may run twice in older setups. Spring Boot defaults to CGLIB (proxyTargetClass=true).
Q: What is a MethodHandle, and why is it faster than reflection?
A: A typed, directly executable reference to a method, constructor or field (java.lang.invoke). Access is checked once, at lookup, and the JIT can inline calls through constant method handles. It's the foundation of lambdas (invokedynamic), and since Java 18, of core reflection itself.
Q: Can records be JPA entities?
A: Not as managed entities. JPA needs mutable, non-final classes with no-arg constructors, for proxies and dirty checking. Records are ideal as DTOs, projections (SELECT new ... or interface-less projections), embeddable-like value objects in some providers, and query results.