Core Java

Reduce Memory Footprint in Java

Memory optimization in Java applications is critical for improving performance and reducing costs, especially in cloud environments. By carefully managing resources, minimizing unused memory, and leveraging efficient data structures, developers can build applications that run faster and use fewer resources. Let’s explore various techniques to reduce memory footprint in Java.

1. Overview

Java applications often allocate more memory than necessary due to suboptimal coding practices, improper configuration, or an insufficient understanding of how memory management works in the JVM. Left unchecked, these issues can lead to performance bottlenecks, excessive garbage collection, and even application crashes under high load. Memory optimization techniques are essential for several reasons:

  • Reducing garbage collection overhead: The JVM’s garbage collector (GC) is responsible for reclaiming unused memory, but frequent or large-scale GC operations can pause application execution, reducing throughput. By minimizing memory waste, you reduce the frequency and duration of GC cycles, leading to smoother performance.
  • Improving application scalability and responsiveness: Applications with optimized memory usage can handle larger user loads without significant degradation in performance. Efficient memory usage ensures faster response times and better scalability across distributed systems.
  • Lowering operational costs in cloud-based systems: Cloud providers often charge based on memory usage. By optimizing memory, you can scale down the size of instances, reduce infrastructure costs, and make your application more sustainable.

Understanding memory optimization also requires knowing the internal workings of the JVM, such as:

  • Heap and Non-Heap Memory: The JVM heap is where objects are stored, while non-heap memory includes areas like the Metaspace for class metadata. Mismanaging these regions can lead to OutOfMemoryError exceptions.
  • Memory Allocation Strategies: Java collections, such as ArrayList and HashMap, dynamically resize themselves, often over-allocating memory. Understanding how to pre-size these collections can reduce waste.

By combining an understanding of these principles with coding best practices, you can design applications that are both performant and cost-efficient.

2. Understanding jol-core library

To measure and analyze memory usage, we can use the JOL Core Library, which helps us understand object memory layouts.

Add the following dependency to your pom.xml:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>your_jar_version</version>
</dependency>

Let us explore an example to estimate the size of an object using jol-core library.

import org.openjdk.jol.info.ClassLayout;

public class ObjectSizeExample {
    static class MyClass {
        int id;
        String name;
    }

    public static void main(String[] args) {
        MyClass myObject = new MyClass();
        System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
    }
}

This Java program demonstrates the use of the Java Object Layout (JOL) library to analyze the memory layout of a Java object at runtime. The program defines a static inner class, MyClass, which contains two fields: an integer id and a string name. In the main method, an instance of MyClass is created. The ClassLayout.parseInstance(myObject).toPrintable() method from the JOL library is used to print the memory layout of the created object, including header details, field offsets, and memory alignment information.

The memory layout analysis helps developers understand how Java objects are organized in memory, which can be useful for optimizing memory usage and performance.

The code produces the following output; however, the exact result may vary depending on the specific JVM implementation.

org.openjdk.jol.info.ClassLayout:
INSTANCE size: 16 bytes
HEADER:
  0: (8 bytes) mark word
  8: (4 bytes) class pointer
FIELDS:
 12: (4 bytes) int id
 16: (4 bytes) java.lang.String name (reference)
ALIGNMENT:
  Total instance size: 24 bytes (aligned to 8 bytes)

3. Size of Java Primitives and Objects

The memory usage of Java primitives and objects can have a substantial effect on overall memory consumption. Below is an overview of the usage of various Java primitives and objects:

TypeSizeDescriptionExample
byte1 byteUsed for storing small integer values, range: -128 to 127.byte b = 100;
short2 bytesUsed for storing medium-sized integer values, range: -32,768 to 32,767.short s = 30000;
int4 bytesMost commonly used integer type, range: -2,147,483,648 to 2,147,483,647.int i = 1000000;
long8 bytesUsed for storing large integer values, range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807.long l = 10000000000L;
float4 bytesUsed for storing single-precision floating-point numbers, range: approximately ±3.40282347E+38F.float f = 3.14f;
double8 bytesUsed for storing double-precision floating-point numbers, range: approximately ±1.79769313486231570E+308.double d = 3.14159265359;
char2 bytesUsed for storing single Unicode characters.char c = 'A';
boolean1 byteUsed for storing true/false values. Actual memory may depend on JVM implementation.boolean flag = true;

In Java, every object has some inherent overhead that contributes to its memory footprint. This overhead is due to the internal structure of objects managed by the JVM. Understanding these components is essential for optimizing memory usage.

  • Object Header:
    • Typically 12 bytes on a 64-bit JVM (with compressed oops enabled).
    • Composed of:
      • Mark Word (8 bytes): Stores metadata about the object, including hash code, GC state, and synchronization information.
      • Class Pointer (4 bytes): Points to the object’s class in the Metaspace, which contains type information.
    • The size may increase on a 32-bit JVM or if compressed oops (ordinary object pointers) is disabled.
  • Padding:
    • Ensures memory alignment for faster access, aligning objects to an 8-byte boundary.
    • Padding adds “filler” bytes to maintain this alignment, which can result in additional memory usage depending on the object’s fields.

Besides headers and padding, the following also contribute to an object’s memory footprint:

  • Instance Fields:
    • The memory consumed by fields (both primitive and reference types) declared in the class.
    • Fields are stored in contiguous memory slots after the object header.
  • Alignment Gap: If the fields of an object do not fill up the available memory aligned to the nearest 8-byte boundary, the remaining space is left empty.

4. Example: Impact of Inefficient memory allocation

Let’s demonstrate the impact of inefficient memory allocation using a simple example:

import java.util.ArrayList;
import java.util.List;

public class MemoryExample {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            numbers.add(i);
        }
        System.out.println("List size: " + numbers.size());
    }
}

4.1 Code Explanation

The code demonstrates a simple example of creating and populating a large ArrayList in Java. It begins by declaring a new ArrayList of type Integer, which will hold a collection of integers. The type Integer is a wrapper class for the primitive int, meaning it provides additional features but comes with the overhead of object creation and memory consumption.

In the for loop, the code iterates 1,000,000 times, adding each value of i to the list using the add method. The loop starts at 0 and increments i by 1 on each iteration until it reaches 999,999. As each value is added, the ArrayList dynamically resizes itself to accommodate the new elements. By default, ArrayList starts with an initial capacity (typically 10) and increases its size by 50% whenever the existing capacity is exhausted. This resizing process involves allocating a new array, copying the old elements to the new array, and then adding the new element, which can be memory-intensive for large collections.

After the loop completes, the size() method is called on the list to retrieve the total number of elements it contains, which should equal 1,000,000. The result is then printed to the console using System.out.println.

This code highlights an important consideration when working with collections in Java: pre-sizing collections. Since the ArrayList undergoes multiple resizing during the addition of elements, significant memory and processing overhead can occur.

To optimize this, you can specify the expected size of the list during initialization, as shown below:

// Optimized memory allocation
List<String> items = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
	items.add("Item " + i);
}

By specifying an initial capacity of 1,000,000, the list avoids unnecessary resizing operations, improving both performance and memory efficiency. Let’s validate this using jol-core.

import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;

import java.util.ArrayList;
import java.util.List;

public class MemoryExample {
    public static void main(String[] args) {
        // Non-initialized ArrayList
        List<Integer> nonInitializedList = new ArrayList<>();
        for (int i = 0; i < 1000000; i++) {
            nonInitializedList.add(i);
        }

        // Pre-initialized ArrayList
        List<Integer> preInitializedList = new ArrayList<>(1000000);
        for (int i = 0; i < 1000000; i++) {
            preInitializedList.add(i);
        }

        // Validate memory usage with jol-core
        System.out.println("Non-initialized List Memory Layout:");
        System.out.println(GraphLayout.parseInstance(nonInitializedList).toFootprint());

        System.out.println("Pre-initialized List Memory Layout:");
        System.out.println(GraphLayout.parseInstance(preInitializedList).toFootprint());

        System.out.println("Non-initialized List Size: " + nonInitializedList.size());
        System.out.println("Pre-initialized List Size: " + preInitializedList.size());
    }
}

When you execute this code, the output will appear in the IDE console.

Non-initialized List Memory Layout:
java.util.ArrayList@6d311334 footprint:
 COUNT       AVG       SUM   DESCRIPTION
    1      32        32    java.util.ArrayList
    23   1048576  24117248  int[]

Pre-initialized List Memory Layout:
java.util.ArrayList@4e25154f footprint:
 COUNT       AVG       SUM   DESCRIPTION
    1      32        32    java.util.ArrayList
    1   4000000  4000000   int[]

Non-initialized List Size: 1000000
Pre-initialized List Size: 1000000

When a list is not pre-initialized, multiple internal arrays are created during resizing as elements are added, leading to increased memory consumption and a significantly larger total memory footprint. In contrast, a pre-initialized list creates only one internal array with the specified capacity, avoiding resizing operations and reducing memory overhead. This makes pre-initialization a more efficient approach for handling large datasets.

5. Java Collections

Java collections are powerful and versatile, making them the go-to choice for handling dynamic data structures. However, this versatility comes at a cost: collections often consume more memory than necessary due to internal overhead. For example, they maintain additional structures such as capacity buffers, hash tables, or tree nodes, which can increase memory usage significantly. To address this, it is important to understand how to use collections efficiently and minimize unnecessary overhead.

Below are some optimization tips:

  • Using Arrays: When the size of the data structure is fixed, arrays are a more memory-efficient alternative to collections like ArrayList or LinkedList. Arrays do not have additional metadata or resizing overhead. However, arrays lack the flexibility of dynamic resizing and built-in methods for operations like adding or removing elements.
    int[] numbers = new int[1000000];
    for (int i = 0; i < numbers.length; i++) {
        numbers[i] = i;
    }
    

    By using arrays, you save memory that would otherwise be consumed by collection-related overhead. However, this approach is best suited for scenarios where the size of the dataset is known beforehand and does not need to change dynamically.

  • Choosing the Right Java Map: Java provides multiple types of map implementations, each designed for specific use cases. Choosing the right map can help optimize both memory usage and performance:
    • HashMap: A general-purpose map implementation that offers average constant-time complexity for insertions, deletions, and lookups. However, it can consume significant memory due to its internal hash table, which stores buckets for resolving collisions.
      Map hashMap = new HashMap();
      hashMap.put("Apple", 10);
      hashMap.put("Banana", 20);
      
    • TreeMap: Maintains sorted keys and is implemented as a Red-Black tree. While it is memory-intensive compared to HashMap, it is suitable when you need ordered traversal or range queries. The trade-off is slower performance due to tree traversal (O(log n) complexity for most operations).
      Map treeMap = new TreeMap();
      treeMap.put("Apple", 10);
      treeMap.put("Banana", 20);
      
    • EnumMap: A highly memory-efficient map designed specifically for enum keys. It uses an array internally to store values, which makes it faster and more lightweight than a HashMap. This is ideal for scenarios where you need to map enum constants to values.
      import java.util.EnumMap;
      
      enum Day { MONDAY, TUESDAY, WEDNESDAY }
      
      public class EnumMapExample {
          public static void main(String[] args) {
              EnumMap<Day, String> schedule = new EnumMap<>(Day.class);
              schedule.put(Day.MONDAY, "Work");
              schedule.put(Day.TUESDAY, "Gym");
              schedule.put(Day.WEDNESDAY, "Rest");
      
              System.out.println(schedule);
          }
      }
      

By carefully selecting and optimizing the use of collections, you can significantly reduce memory usage and improve the overall performance of your Java applications.

6. Avoid Object Duplication

Object duplication can significantly increase memory usage, especially when dealing with large datasets or frequently repeated values. This is particularly problematic with strings, as they are immutable and often used extensively in Java applications. Each time a new string object is created, it consumes additional memory, even if an identical string already exists. By reusing string instances, you can avoid this unnecessary overhead.

6.1 Using String.intern() to Reuse String Instances

Java provides the String.intern() method to manage string duplication effectively. The intern() method ensures that all identical string values share the same memory reference by storing them in the JVM’s string pool. Here’s an example:

String str1 = "Hello"; // Stored in the string pool
String str2 = new String("Hello").intern(); // Explicitly added to the string pool

System.out.println(str1 == str2); // true

In this example:

  • str1 is a string literal, which is automatically added to the string pool.
  • str2 is a new string object, but calling intern() adds it to the string pool if an equivalent string does not already exist.
  • The == operator checks if both str1 and str2 refer to the same memory location, which they do after intern() is called.

6.2 Why Use intern()?

Using String.intern() has the following benefits:

  • Memory Efficiency: Identical strings share a single instance in the string pool, reducing memory consumption.
  • Improved Performance: Comparing strings using == is faster than using equals(), as it only compares references.

The intern() method is particularly useful in the following scenarios:

  • Applications with repetitive string values, such as keys in configuration files, log messages, or data processing pipelines.
  • When processing large datasets where the same string values appear multiple times.
  • To optimize memory usage in string-heavy applications, such as parsers or serializers.

6.3 Example: Optimizing Strings in a Loop

The following example shows how intern() can significantly reduce memory usage when working with repetitive string values:

import java.util.ArrayList;
import java.util.List;

public class StringInternExample {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            String value = new String("Duplicate").intern();
            strings.add(value);
        }
        System.out.println("String list size: " + strings.size());
    }
}

In this example:

  • The string "Duplicate" is reused by interning it, ensuring only one instance exists in the string pool.
  • Without interning, each iteration would create a new string object, consuming much more memory.

While intern() is a powerful tool for optimizing memory usage, excessive use of the method can lead to increased CPU overhead due to string pool lookups. Additionally, the size of the string pool is limited, so overusing it may lead to memory constraints. It is essential to use intern() judiciously and profile your application to ensure it benefits from this optimization. Let’s validate this using jol-core.

import org.openjdk.jol.info.GraphLayout;

import java.util.ArrayList;
import java.util.List;

public class StringInternExample {
    public static void main(String[] args) {
        // Without interning
        List<String> withoutInterning = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            String value = new String("Duplicate");
            withoutInterning.add(value);
        }

        // With interning
        List<String> withInterning = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            String value = new String("Duplicate").intern();
            withInterning.add(value);
        }

        // Print memory footprint using jol-core
        System.out.println("Memory footprint without interning:");
        System.out.println(GraphLayout.parseInstance(withoutInterning).toFootprint());

        System.out.println("Memory footprint with interning:");
        System.out.println(GraphLayout.parseInstance(withInterning).toFootprint());

        System.out.println("List size without interning: " + withoutInterning.size());
        System.out.println("List size with interning: " + withInterning.size());
    }
}

When you execute this code, the output will appear in the IDE console.

Memory footprint without interning:
java.util.ArrayList@4b67cf4d footprint:
 COUNT       AVG       SUM   DESCRIPTION
    1        40        40   java.util.ArrayList
100000       24   2400000   java.lang.String

Memory footprint with interning:
java.util.ArrayList@7ea987ac footprint:
 COUNT       AVG       SUM   DESCRIPTION
    1        40        40   java.util.ArrayList
    1        24        24   java.lang.String

List size without interning: 100000
List size with interning: 100000

When interning is not used, the code creates 100,000 separate String objects, each occupying its own memory space, leading to significant memory consumption due to the duplication of identical string values. In contrast, with interning, only a single String instance is stored in the string pool and reused for all occurrences, resulting in dramatically reduced memory usage by avoiding the creation of duplicate string objects.

7. Leveraging Java 8 Features

Java 8 introduced several features and enhancements that not only improved programming flexibility but also contributed to better memory efficiency. By leveraging these features, developers can write cleaner, more concise code while minimizing memory overhead. Below are two key Java 8 features that significantly impact memory usage:

  • Streams: Java Streams provide a high-level abstraction for performing aggregate operations on collections or other data sources. Unlike traditional collections-based processing, Streams are designed to operate on large datasets without creating intermediate storage, thereby reducing memory consumption. Streams support lazy evaluation, meaning operations are not executed until the terminal operation (like forEach) is invoked. This feature allows for optimized processing pipelines, particularly with large datasets.
    import java.util.stream.IntStream;
    
    public class StreamExample {
        public static void main(String[] args) {
            IntStream.range(0, 1000000).forEach(System.out::println);
        }
    }
    

    Key benefits of this approach:

    • Lazy Processing: Only processes the data needed at any given time, reducing memory usage.
    • Parallel Processing: Supports parallel streams, leveraging multi-core processors to process large datasets efficiently.
    • Immutability: Operations do not modify the original data source, preventing unintended side effects and reducing memory-related bugs.
  • Optional: The Optional class provides a robust way to handle the absence of a value, avoiding the need for null checks and reducing the chances of NullPointerException. With Optional, developers can eliminate unnecessary object creation and improve code readability.
    import java.util.Optional;
    
    public class OptionalExample {
        public static void main(String[] args) {
            Optional optionalValue = Optional.ofNullable(getValue());
            String result = optionalValue.orElse("Default Value");
            System.out.println(result);
        }
    
        private static String getValue() {
            return null; // Simulating a value that may or may not be present
        }
    }
    

    Key benefits of this approach:

    • Null Safety: Eliminates the need for extensive null checks and improves code clarity.
    • Memory Efficiency: Prevents the creation of temporary objects or exceptions to handle null values.
    • Functional Style: Provides methods like map, filter, and ifPresent to operate on the encapsulated value directly.

8. Conclusion

Memory optimization in Java requires an understanding of object sizes, collection overheads, and best practices. By pre-sizing collections, avoiding unnecessary object creation, and leveraging Java features, you can significantly reduce your application’s memory footprint. Start small, measure your application’s memory usage, and iteratively optimize for the best results.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button