avatarNiket Agrawal

Summary

The article presents a custom approach to detect and prevent OutOfMemoryError in Spring Boot applications by using a combination of Java's Runtime class, Spring AOP, and a custom CheckMemory annotation.

Abstract

In the context of critical business operations that cannot tolerate failures, such as long-running batch jobs and applications handling large datasets, the article addresses the issue of OutOfMemoryError in Spring Boot applications. It provides a background on the Java heap and the role of the Garbage Collector in memory management. The solution involves creating a CheckMemory annotation to mark memory-intensive methods and an Aspect class to check for available memory before execution. If the available memory is insufficient, a LowMemoryException is thrown, and the operation is suspended to prevent crashes. The article also discusses alternative strategies for handling low memory situations, such as manually triggering garbage collection, adjusting JVM memory allocations, implementing retry logic, and delaying retries. The author emphasizes the importance of proactive memory management and provides a GitHub repository with code examples, encouraging readers to clap and subscribe for more content.

Opinions

  • The author believes that proactive memory management is crucial for preventing application crashes and ensuring smooth operation during memory-intensive tasks.
  • The approach taken by the author is a fail-fast strategy, where the system halts operation if memory thresholds are not met, thereby avoiding potential data corruption or system instability.
  • The author acknowledges that manually triggering garbage collection is not recommended but suggests it as a possible intervention in critical scenarios.
  • The author advocates for custom solutions tailored to specific application needs, as opposed to generic memory management strategies.
  • By providing a GitHub repository with the article, the author shows a commitment to practical, hands-on learning and encourages community engagement and support through platforms like Medium and BuyMeACoffee.

Prevent OutOfMemoryError in Spring Boot

There are many business instances where we cannot afford failures (for example, long-running batch jobs, crucial computations, and applications dealing with massive amounts of data). Such intensive memory operations frequently result in heap memory errors, and we may need to reprocess them after restarting the program. Such huge operation failures not only cause problems, but they are also more difficult to recover from and take longer to complete after re-attempts.

This article will look at a custom approach to detect and prevent OutOfMemoryError in a Spring Boot application. This approach can help us detect probable memory issues in advance.

Background:

The Java heap is the area of memory used to store objects instantiated by applications running on the JVM. The memory management of Heap is handled by the Garbage Collector in Java. Java objects and JRE classes are allocated heap memory by the Java Runtime. When the heap memory space is exhausted, the program will throw java.lang.OutOfMemoryError

java.lang.OutOfMemoryError: Java heap space

Prerequisites:

Basic knowledge of JVM, Garbage Collector, Spring AOP, Spring Boot

Also, I am assuming that one would know which operations in the application are memory-heavy and might run out of memory when there is a memory crunch. Such methods should become our target method.

Java Runtime:

Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the Runtime.getRuntime() method.

Generally, memory configuration for Java Virtual Machine is done via JVM options like -Xmx and -Xms. JVM will start initially from the memory defined with -Xms and can obtain memory for heap up to the value defined against -Xmx

Runtime.getRuntime().maxMemory() returns the maximum amount of memory that the Java virtual machine will attempt to use

Runtime.getRuntime().freeMemory() returns the currently occupied memory by JVM

Runtime.getRuntime().totalMemory() returns the total amount of memory currently occupied by JVM. It can grow up to the defined maximum memory.

Here is how we can calculate the maximum free memory for JVM

Used Memory = Runtime.getRuntime().totalMemory() — Runtime.getRuntime().freeMemory()

Maximum Free Memory = Runtime.getRuntime().maxMemory() — Used Memory

Our Solution:

  1. Create an Annotation named CheckMemory. It can take two parameters, minRequiredMemory, and JVM Memory Unit.
  2. Create an Aspect class that checks the max free heap memory and throws an exception if it's lower than the defined minRequiredMemory.
  3. Identify memory-heavy method and annotate it with CheckMemory
  4. Handle exception with ExceptionHandler in ControllerAdvice class

Let’s check out the code references. We will first create an annotation with the name of CheckMemory.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckMemory {

  long minRequiredMemory() default 0;

  MemoryUnit jvmMemoryUnit() default MemoryUnit.MB;

  enum MemoryUnit {
    KB, MB
  }
}

Now we’ll create the Aspect class and create a method with @Before advice that works for CheckMemory annotation.

This method first obtains the maximum available memory to JVM by deducting used memory from the total max JVM memory. Later, it checks if the maximum available memory is lower than the minimum required memory passed via annotation. If that is the case, then it will throw LowMemoryException.

@Aspect
@Component
public class CheckMemoryAspect {

  static final Logger logger = LoggerFactory.getLogger(CheckMemoryAspect.class);

  @Before("@annotation(checkMemory)")
  public void executionTimeLogger(JoinPoint joinPoint, CheckMemory checkMemory) {
    long maxAvailableMemory = getMaxAvailableMemory(checkMemory.jvmMemoryUnit());
    long minRequiredMemory = checkMemory.minRequiredMemory();
    if (minRequiredMemory != 0 && minRequiredMemory > maxAvailableMemory) {
      logger.warn("Checking JVM memory before calling method: {}", joinPoint.getSignature());
      logger.warn(
          "Max free memory in JVM is {}{} and it is lower than defined threshold memory: {}{} ",
          maxAvailableMemory, checkMemory.jvmMemoryUnit(), minRequiredMemory,
          checkMemory.jvmMemoryUnit());
      // Raise Email Alert
      // Trigger GC and Re-attemp after some delay
      // Raise Exception
      throw new LowMemoryException("JVM Memory is Low hence suspending operation");
    }
  }
}

Below are the methods that convert memory in bytes to defined memory units. The getMaxAvailableMemory method calculates the maximum free memory JVM can obtain at runtime.

  private long getMaxAvailableMemory(MemoryUnit memoryUnit) {
    long maxFreeMemory = getMemory(Runtime.getRuntime().maxMemory(), memoryUnit);
    long usedMemory = getMemory(Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(), memoryUnit);
    logger.info("Max JVM Memory: {} and Current Used JVM Memory: {}", maxFreeMemory, usedMemory);
    return (maxFreeMemory - usedMemory);
  }

  private long getMemory(long memorySize, MemoryUnit memoryUnit) {
    int byteToMb = 1024 * 1024;
    int byteToKb = 1024;

    switch (memoryUnit) {
      case KB:
        return memorySize / byteToKb;
      case MB:
        return memorySize / byteToMb;
      default:
        return memorySize;
    }
  }

Assuming that the processMillionRecords method in our service class is the one that performs heavy memory operations. Hence, it is crucial to check if this method will not run out of heap space.

To achieve this, we’ll annotate this method with CheckMemory annotation and define that minRequiredMemory to allow the running of this method should be 512 MB.

@Service
public class HeavyMemoryOperationServiceImpl implements HeavyMemoryOperationService {

  @Override
  @CheckMemory(jvmMemoryUnit = MemoryUnit.MB, minRequiredMemory = 512)
  public void processMillionRecords() {
    // Big Memory Operations
  }
}

This is the code snippet of our custom exception class

public class LowMemoryException extends RuntimeException {

  private static final long serialVersionUID = 285565931479778831L;

  public LowMemoryException(String message) {
    super(message);
  }
}

We’ll handle this exception via the ExceptionHandler method and return the error

@ControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(LowMemoryException.class)
  ResponseEntity<String> handleException(LowMemoryException ex) {
    return new ResponseEntity<String>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
  }
}

Let’s see how the controller looks.

@RestController
public class HeavyMemoryOperationController {

  HeavyMemoryOperationService service;

  @Autowired
  public HeavyMemoryOperationController(HeavyMemoryOperationService service) {
    this.service = service;
  }

  @PostMapping("processMillionRecords")
  public void processtMillionRecords() {
    // triggering a method to occupy some memory
    List<String> dummyData = IntStream.range(0, 1000000).boxed()
        .map(num -> RandomStringUtils.random(50)).collect(Collectors.toList());
    service.processMillionRecords();
  }
}

Output:

As we can see, the logger prints the Maximum Memory allocated to JVM and currently used memory. It also shows the maximum memory available to claim, and it is lower than the minimum required memory by our method.

We can also see the exception thrown as our REST response.

Other Alternatives:

We took a fail-fast approach in our solution, where we failed the request in case of low memory detection. However, we can handle this in many other ways. Let's check out some of them.

  • Trigger GC manually. Though this is not recommended, and it doesn't guarantee that GC will be triggered, however, it expedites the efforts of JVM to reclaim some space from memory.
  • Try changing JVM and MetaSpace memory allocations
  • Try re-try logic. We can put a recursive logic to make re-attempts instead of failing the requests
  • Delay the retries. Write up a logic to retry after some delay, it can give JVM time to reclaim space.

A lot depends on what type of application it is and what scenarios lead to heap space issues. My purpose was simply to offer you an idea of how we may deal with them in a proactive manner. I hope this was useful and that it will assist you with your commercial applications.

Thanks for reading

  • 👉 Check out the GitHub code reference of this article
  • 👏 Please clap for the story and subscribe 👉(you can give up to 50 likes)
  • 📰 Read more content on my Medium
  • 🔔 Follow me: LinkedIn | Twitter | GitHub

I keep all my content free for readers so that everyone can make most of it. 🧡You can support my work on BuyMeACoffee 🧡

Spring Boot
Programming
Technology
Java
Software Engineering
Recommended from ReadMedium