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 some 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");
}
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());
}
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;
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();
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);
}
}
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);
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;
}
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);
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;
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);
}
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 by 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 Medium for more information on this topic and my other posts. Don’t forget to clap my posts to help them reach more people!
Thank you! 👨💻🚀
To follow me on LinkedIn: https://www.linkedin.com/in/tamerardal/
Resources: