DEV Community

Cover image for Boost Your Java Back-End Performance: Essential Optimization Tips!
Tamer Ardal
Tamer Ardal

Posted on

Boost Your Java Back-End Performance: Essential Optimization Tips!

Performance plays a critical role in the success of a software project. Optimizations applied during Java back-end development ensure efficient use of system resources and increase the scalability of your application.

In this article, I will share with you some optimization techniques that I consider important to avoid common mistakes.

Choose the Right Data Structure for Performance

Choosing an efficient data structure can significantly improve the performance of your application, especially when dealing with large datasets or time-critical operations. Using the correct data structure minimizes access time, optimizes memory usage, and reduces processing time.

For instance, when you need to search frequently in a list, using a HashSet instead of an ArrayList can yield faster results:

// Inefficient - O(n) complexity for contains() check
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
// Checking if "Alice" exists - time complexity O(n)
if (names.contains("Alice")) {
    System.out.println("Found Alice");
}

// Efficient - O(1) complexity for contains() check
Set<String> namesSet = new HashSet<>();
namesSet.add("Alice");
namesSet.add("Bob");
// Checking if "Alice" exists - time complexity O(1)
if (namesSet.contains("Alice")) {
    System.out.println("Found Alice");
}
Enter fullscreen mode Exit fullscreen mode

In this example, HashSet provides an average time complexity of O(1) for contains() operations, while ArrayList requires O(n) as it must iterate through the list. Therefore, for frequent lookups, a HashSet is more efficient than an ArrayList.

By the way, if you want to know what time complexity is: Time Complexity refers to how the running time of an algorithm varies with the input size. This helps us understand how fast an algorithm runs and usually shows how it behaves in the worst case. Time complexity is commonly denoted by Big O Notation.

Check Exception Conditions at the Beginning

You can avoid unnecessary processing overhead by checking the fields to be used in the method that should not be null at the beginning of the method. It is more effective in terms of performance to check them at the beginning, instead of checking for null checks or illegal conditions in later steps in the method.

public void processOrder(Order order) {
    if (Objects.isNull(order))
        throw new IllegalArgumentException("Order cannot be null");
    if (order.getItems().isEmpty())
        throw new IllegalStateException("Order must contain items");

    ...

    // Process starts here.
    processItems(order.getItems());
}
Enter fullscreen mode Exit fullscreen mode

As this example shows, the method may contain other processes before getting to the processItems method. In any case, the Items list in the Order object is needed for the processItems method to work. You can avoid unnecessary processing by checking conditions at the beginning of the process.

Avoid Creating Unnecessary Objects

Creating unnecessary objects in Java applications can negatively affect performance by increasing Garbage Collection time. The most important example of this is String usage.

This is because the String class in Java is immutable. This means that each new String modification creates a new object in memory. This can cause a serious performance loss, especially in loops or when multiple concatenations are performed.

The best way to solve this problem is to use StringBuilder. StringBuilder can modify the String it is working on and perform operations on the same object without creating a new object each time, resulting in a more efficient result.

For example, in the following code fragment, a new String object is created for each concatenation operation:

String result = "";
for (int i = 0; i < 1000; i++) 
    result += "number " + i;
Enter fullscreen mode Exit fullscreen mode

In the loop above, a new String object is created with each result += operation. This increases both memory consumption and processing time.

We can avoid unnecessary object creation by doing the same with StringBuilder. StringBuilder improves performance by modifying the existing object:

StringBuilder result = new StringBuilder();

for (int i = 0; i < 1000; i++) 
    result.append("number ").append(i);

String finalResult = result.toString();
Enter fullscreen mode Exit fullscreen mode

In this example, only one object is created using StringBuilder, and operations are performed on this object throughout the loop. As a result, the string manipulation is completed without creating new objects in memory.

Say Goodbye to Nested Loops: Use Flatmap

The flatMap function included with the Java Stream API are powerful tool for optimizing operations on collections. Nested loops can lead to performance loss and more complex code. By using this method, you can make your code more readable and gain performance.

  • flatMap Usage

map: Operates on each element and returns another element as a result.
flatMap: Operates on each element, converts the results into a flat structure, and provides a simpler data structure.

In the following example, operations are performed with lists using a nested loop. As the list expands, the operation will become more inefficient.

List<List<String>> listOfLists = new ArrayList<>();
for (List<String> list : listOfLists) {
    for (String item : list) {
        System.out.println(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

By using flatMap, we can get rid of nested loops and get a cleaner and more performant structure.

List<List<String>> listOfLists = new ArrayList<>();
listOfLists.stream()
           .flatMap(Collection::stream)
           .forEach(System.out::println);
Enter fullscreen mode Exit fullscreen mode

In this example, we convert each list into a flat stream with flatMap and then process the elements with forEach. This method makes the code both shorter and more performance-efficient.

Prefer Lightweight DTOs Instead of Entities

Returning data pulled from the database directly as Entity classes can lead to unnecessary data transfer. This is a very faulty method both in terms of security and performance. Instead, using DTO to return only the data needed improves API performance and prevents unnecessary large data transfers.

@Getter
@Setter
public class UserDTO {
    private String name;
    private String email;
}
Enter fullscreen mode Exit fullscreen mode

Speaking of databases; Use EntityGraph, Indexing, and Lazy Fetch Feature

Database performance directly affects the speed of the application. Performance optimization is especially important when retrieving data between related tables. At this point, you can avoid unnecessary data loading by using EntityGraph and Lazy Fetching. At the same time, proper indexing in database queries dramatically improves query performance.

  • Optimized Data Extraction with EntityGraph

EntityGraph allows you to control the associated data in database queries. You pull only the data you need, avoiding the costs of eager fetching.

Eager Fetching is when the data in the related table automatically comes with the query.

@EntityGraph(attributePaths = {"addresses"})
User findUserByUserId(@Param("userId") Long userId);
Enter fullscreen mode Exit fullscreen mode

In this example, address-related data and user information are retrieved in the same query. Unnecessary additional queries are avoided.

  • Avoid Unnecessary Data with Lazy Fetch

Unlike Eager Fetch, Lazy Fetch fetches data from related tables only when needed.

@OneToMany(fetch = FetchType.LAZY)
private List<Address> addresses;
Enter fullscreen mode Exit fullscreen mode
  • Improve Performance with Indexing

Indexing is one of the most effective methods to improve the performance of database queries. A table in a database consists of rows and columns, and when a query is made, it is often necessary to scan all rows. Indexing speeds up this process, allowing the database to search faster over a specific field.

Lighten the Query Load by Using Cache

Caching is the process of temporarily storing frequently accessed data or computational results in a fast storage area such as memory. Caching aims to provide this information more quickly when the data or computation result is needed again. Especially in database queries and transactions with high computational costs, the use of cache can significantly improve performance.

Spring Boot’s @Cacheable annotation makes cache usage very easy.

@Cacheable("users")
public User findUserById(Long userId) {
    return userRepository.findById(userId);
}
Enter fullscreen mode Exit fullscreen mode

In this example, when the findUserById method is called for the first time, the user information is retrieved from the database and stored in the cache. When the same user information is needed again, it is retrieved from the cache without going to the database.

You can also use a top-rated caching solution such as Redis for the needs of your project.

Conclusion

You can develop faster, more efficient, and scalable applications using these optimization techniques, especially in your back-end projects developed with Java.

...

Thank you for reading my article! If you have any questions, feedback or thoughts you would like to share, I would love to hear them in the comments.

You can follow me on Dev.to for more information on this topic and my other posts. Don’t forget to like my posts to help them reach more people!

Thank you!👨‍💻🚀

To follow me on LinkedIn: tamerardal
Read and Follow me on Medium: Boost Your Java Back-End Performance: Essential Optimization Tips!

Resources:

  1. Baeldung, JPA Entity Graph
  2. Baeldung, A Guide To Caching in Spring
  3. Baeldung, Eager/Lazy Loading in Hibernate

Top comments (0)