What is a Memory Leak in Java?
A memory leak happens when objects are no longer needed but still referenced, preventing the garbage collector from reclaiming their memory.
In Java, this usually doesn’t crash your program immediately but gradually consumes heap space → OutOfMemoryError.
In Spring Boot, memory leaks often occur due to:
- Long-lived objects holding references
- Caches, maps, lists growing indefinitely
- Static references
- Improperly scoped beans
- Threads/executors not shut down
Symptoms of Memory Leaks
- High heap usage in monitoring tools.
- Frequent Full GC with little memory reclaimed.
- Application slows down over time.
OutOfMemoryErroreventually occurs.
How to Track Memory Leaks in Java/Spring Boot
A. JVM Monitoring Tools
- VisualVM
- Comes with JDK (
bin/jvisualvm) - Connect to running JVM → Monitor heap usage → Take heap dumps
- Comes with JDK (
- JConsole
- For basic monitoring of memory and threads
- Java Flight Recorder / Mission Control
- Built-in in JDK 11+
- Records detailed memory, thread, and CPU usage
B. Profilers
- YourKit, JProfiler, Eclipse MAT
- They help identify:
- Objects consuming most memory
- Which objects are still strongly referenced
- Growth patterns over time
C. Heap Dumps and Analysis
- Trigger a heap dump when memory is high:
jmap -dump:format=b,file=heapdump.hprof <PID> - Analyze with Eclipse Memory Analyzer Tool (MAT)
- Look for “Leak Suspects”
- Identify objects that shouldn’t be retained
- Trace back which code is holding references
D. Spring Boot-Specific Tips
- Actuator Metrics
- Add
spring-boot-starter-actuator - Use
/actuator/metrics/jvm.memory.usedto monitor memory usage
- Add
Heap Dumps on OOM
server: tomcat: max-threads: 200 spring: jvm: heap-dump-on-out-of-memory-error: true- Check for common leak patterns:
- Singleton beans holding large collections
- Unbounded caches (
Map,List) ThreadLocalnot cleaned up- Database connections not closed
@Scheduledtasks creating objects indefinitely
How to Fix Memory Leaks
A. Code-Level Fixes
- Avoid Static References
- Don’t keep
static MaporListif it grows over time.
- Don’t keep
- Proper Bean Scoping
- Use
@Scope("prototype")for beans that should not live indefinitely
- Use
Clean Up ThreadLocals
try { ThreadLocal<MyObject> local = ...; } finally { local.remove(); }- Close Resources
- Always close
ResultSet,PreparedStatement,Connection - Use try-with-resources
- Always close
- Limit Caches
- Use
Guava CacheorCaffeinewith eviction policies
- Use
- Avoid Unnecessary Object Retention
- Example: don’t store old DTOs in lists unless needed
B. Spring Boot & JVM Tuning
GC Tuning
- Use G1GC or ZGC for large heaps
- Example JVM option:
-XX:+UseG1GC -Xmx2G -Xms1G- Monitor and Adjust Thread Pools
- Executors creating too many threads can retain memory
- Profiling in Production
- Use JFR or VisualVM snapshots
- Detect memory leak patterns over time
C. Example: Common Leak in Spring Boot
@Service public class UserService { private static final List<User> cachedUsers = new ArrayList <>(); public void addUser (User user) { cachedUsers.add(user); // leak if list grows unbounded } } Fix:
@Service public class UserService { private final Cache<Long, User> userCache = Caffeine.newBuilder() .maximumSize( 1000 ) .expireAfterWrite(Duration.ofMinutes( 30 )) .build(); public void addUser (User user) { userCache.put(user.getId(), user); } }
Practical Steps in Spring Boot App
- Enable Actuator → Monitor
/metricsfor memory usage - Use heap dumps at regular intervals or on OOM
- Analyze with Eclipse MAT → Find objects that are not garbage collected
- Fix code → Limit caches, close resources, remove unused references
- Retest and monitor memory over time
Using Abstract Classes and Interfaces in Real-World Software Design: A Payment Processing Use Case
In enterprise software development, one of the most common architectural debates is when to use abstract classes and when to use interfaces. While interfaces define a contract that classes must follow, abstract classes allow developers to share common code across related implementations. To understand this better, let’s explore a real-world product-level use case: building a Payment Processing Engine for a Payroll & Business Finance Management System.
Problem Statement
Our system needs to handle multiple payment modes: Cash, UPI, Bank Transfer, Credit Card, and future expansion like Crypto or PayPal. Each payment type must validate transaction details, execute the payment, rollback on failure, and optionally support refund and reconciliation. This is a perfect scenario for combining interfaces + abstract classes.
Step 1: Defining Interfaces (Contracts)
public interface Payment { boolean validate () ; boolean execute ( double amount); boolean rollback (String transactionId) ; } public interface Refundable { boolean refund (String transactionId, double amount); } public interface Reconcile { void reconcile (String transactionId) ; } Step 2: Abstract Class for Common Logic
public abstract class AbstractPayment implements Payment { protected String transactionId; protected String createdBy; protected double amount; public AbstractPayment (String createdBy, double amount) { this .createdBy = createdBy; this .amount = amount; } @Override public boolean validate () { return amount > 0 ; // Common validation } public abstract double calculateCharges () ; public abstract String getPaymentMode () ; } Step 3: Concrete Implementations
UPI Payment
public class UpiPayment extends AbstractPayment implements Refundable , Reconcile { private String upiId; public UpiPayment (String createdBy, double amount, String upiId) { super (createdBy, amount); this .upiId = upiId; } @Override public boolean execute ( double amount) { System.out.println( "Executing UPI Payment for: " + upiId); return true ; } @Override public boolean rollback (String transactionId) { System.out.println( "Rolling back UPI transaction: " + transactionId); return true ; } @Override public double calculateCharges () { return amount * 0.005 ; // 0.5% UPI charge } @Override public String getPaymentMode () { return "UPI" ; } @Override public boolean refund (String transactionId, double amount) { System.out.println( "Refund via UPI: " + transactionId); return true ; } @Override public void reconcile (String transactionId) { System.out.println( "Reconcile UPI transaction: " + transactionId); } } Bank Transfer Payment
public class BankTransferPayment extends AbstractPayment { private String bankAccount; public BankTransferPayment (String createdBy, double amount, String bankAccount) { super (createdBy, amount); this .bankAccount = bankAccount; } @Override public boolean execute ( double amount) { System.out.println( "Executing Bank Transfer to: " + bankAccount); return true ; } @Override public boolean rollback (String transactionId) { System.out.println( "Rollback Bank Transfer: " + transactionId); return true ; } @Override public double calculateCharges () { return 50 ; // Flat fee } @Override public String getPaymentMode () { return "BANK_TRANSFER" ; } } Step 4: Payment Service in Action
public class PaymentService { public void processPayment (Payment payment, double amount) { if (payment.validate()) { if (payment.execute(amount)) { System.out.println( "Payment executed successfully." ); } else { payment.rollback( "TXN123" ); } } else { System.out.println( "Payment validation failed." ); } } public static void main (String[] args) { Payment upiPayment = new UpiPayment ( "User" , 5000 , "user@upi" ); PaymentService service = new PaymentService (); service.processPayment(upiPayment, 5000 ); Payment bankPayment = new BankTransferPayment ( "Admin" , 20000 , "1234567890" ); service.processPayment(bankPayment, 20000 ); } } Why Use Both Abstract Class and Interface?
- Interfaces → Define rules that all payment types must follow.
- Abstract Class → Provides shared logic (validation, base fields).
- Concrete Classes → Implement specific rules for each payment type.
This combination ensures scalability (easy to add new payment methods), maintainability (common code stays in one place), and compliance with SOLID principles (especially Open-Closed and Liskov Substitution).