Java Performance Optimization Simple Tips and Tricks
Java is a versatile and widely used programming language known for its portability and robustness. However, as with any language, Java applications can face performance challenges, especially when dealing with resource-intensive tasks or large-scale systems. To ensure your Java applications run efficiently, optimizing their performance is essential. This article explores several simple Java performance optimization tips and tricks to help you build faster and more efficient applications.
For some examples, we will use Java Microbenchmark Harness (JMH) to measure method execution time.
Use StringBuilder for String Concatenation
In Java, concatenating strings using the “+” operator can be inefficient, especially within loops. Each concatenation creates a new String object, which can lead to unnecessary memory allocations and overhead. To optimize string concatenation, use StringBuilder, which is mutable and performs better for repeated string manipulations.
The String concatenation with “+”
public String createStringUsingString() {
String str = "";
for (int i=0; i<1000; i++) {
str += "some_text";
}
return str;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.createStringUsingString avgt 5 0.444 ± 0.053 ms/op
The String concatenation with StringBuilder
public String createStringUsingStringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i=0; i<1000; i++) {
sb.append("some_text");
}
return sb.toString();
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.createStringUsingStringBuilder avgt 5 0.008 ± 0.001 ms/op
Result
From the above two examples, we can see that concatenation with the “+” operator takes 0.444 milliseconds, and with StringBuilder, on the other hand, takes only 0.008 milliseconds. So it is evident that, in this case, StringBuilder is much faster.
Use Primitive Types
Whenever possible, prefer using primitive data types (e.g.,?int,?double,?float) instead of their wrapper classes (e.g.,?Integer,?Double,?Float). Primitive types have lower memory overhead and avoid unnecessary autoboxing and unboxing operations.
The key reasons why using?primitives?can be faster than using?wrapper classes?in Java are:
It’s important to note that while using primitives can provide performance benefits, there are cases where wrapper classes are necessary or more convenient. For example, wrapper classes is often unavoidable when working with data structures requiring objects (like collections). Additionally, wrapper classes provide proper utility methods and nullability support that primitive types lack.
In modern versions of Java, the JVM performs a certain level of optimization, and the performance gap between primitives and wrapper classes has been reduced in some scenarios due to the introduction of techniques like escape analysis. However, it’s generally a good practice for performance-critical code to be aware of the potential performance implications and consider using primitives where appropriate.
Wrapper Class usage
public Integer sumWithWrapperClass(Integer a, Integer b) {
Integer sum = a + b;
return sum;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark (a) (b) Mode Cnt Score Error Units
MyBenchmark.sumWithWrapperClass 5 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass 5 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass 5 5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass -5 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass -5 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass -5 5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass 0 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass 0 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithWrapperClass 0 5 avgt 3 ≈ 10?? ms/op
Primitives usage
public int sumWithPrimitives(int a, int b) {
int sum = a + b;
return sum;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark (a) (b) Mode Cnt Score Error Units
MyBenchmark.sumWithPrimitives 5 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives 5 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives 5 5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives -5 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives -5 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives -5 5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives 0 0 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives 0 -5 avgt 3 ≈ 10?? ms/op
MyBenchmark.sumWithPrimitives 0 5 avgt 3 ≈ 10?? ms/op
Result
From the above two examples, we can see that summing 2 Integers takes ≈ 10?? (≈ 0.00001) milliseconds, and the same operation with 2 primitives, on the other hand, takes ≈ 10?? (≈ 0.000001) milliseconds. So it is evident that, in this case, primitives are much faster.
Use Efficient Data Structures
Data structures are containers for storing and organizing data in memory. They provide various methods for accessing, inserting, and deleting data elements. The choice of data structure directly influences the efficiency of these operations. Different data structures have different strengths and weaknesses, making them suitable for specific use cases. You can optimize your application’s performance by understanding the characteristics of other data structures. Selecting data structures best suited for the specific operations you need to perform is crucial.
Example 1: Insert elements in the middle of the List (ArrayList vs LinkedList)
ArrayList
The inserting element in the middle of the ArrayList is a pretty slow procedure.
When inserting an element in the middle of an ArrayList, you must shift the existing features to accommodate the new element. Here’s a step-by-step process to insert an element in the middle of an ArrayList:
public void insertInTheMiddleArrayList() {
List<Integer> arrayList = new ArrayList<>();
for (int i=0; i<1000000; i++) {
arrayList.add(i);
}
arrayList.add(500000, -1);
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.insertInTheMiddleArrayList avgt 3 42.363 ± 9.906 ms/op
LinkedList
Inserting an element in the middle of a LinkedList in Java is generally more efficient than an ArrayList because LinkedList is implemented as a doubly linked list, making it easier to insert elements in the middle without shifting the entire list. Here’s a step-by-step process to insert an element in the middle of a LinkedList:
public void insertInTheMiddleLinkedList() {
List<Integer> linkedList = new LinkedList<>();
for (int i=0; i<1000000; i++) {
linkedList.add(i);
}
linkedList.add(500000, -1);
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.insertInTheMiddleLinkedList avgt 3 13.547 ± 1.300 ms/op
Result
From the above two examples, we can see that adding an element in the middle of an ArrayList takes 42.363 milliseconds, and adding an element in the middle of a LinkedList takes 13.547 milliseconds. So it is evident that, in this case, adding an element in the middle of a LinkedList is much faster. Remember that this process involves shifting elements, which can be inefficient for large ArrayLists. In scenarios where you need frequent middle insertions, and performance is critical, you might consider using a different data structure like a linked list.
Example 2: Remove the element from the middle of the List (ArrayList vs LinkedList)
ArrayList
Deleting an element from the middle of the ArrayList is also a pretty slow procedure.
The steps are similar to the previous example when it determines the index at which you want to remove an element, removes the element at the specified index, and shifts the remaining elements to fill the gap.
public void removeFromTheMiddleOfArrayList() {
List<Integer> arrayList = new ArrayList<>();
for (int i=0; i<1000000; i++) {
arrayList.add(i);
}
arrayList.remove(500000);
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.removeFromTheMiddleOfArrayList avgt 3 42.117 ± 86.527 ms/op
LinkedList
The steps are similar to the previous example when it determines the index at which you want to remove an element and removes it at the specified index by adjusting pointers.
public void removeFromTheMiddleOfLinkedList() {
List<Integer> linkedList = new LinkedList<>();
for (int i=0; i<1000000; i++) {
linkedList.add(i);
}
linkedList.remove(500000);
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.removeFromTheMiddleOfLinkedList avgt 3 7.272 ± 6.501 ms/op
Result
From the above two examples, we can see that removing an element from the middle of an ArrayList takes 42.117 milliseconds, and removing an element from the middle of a LinkedList takes 7.272 milliseconds. So it is evident that, in this case, removing an element from the middle of a LinkedList is much faster.
Example 3: Access by index (ArrayList vs LinkedList)
LinkedList
A LinkedList is implemented using a doubly-linked list. Each element in the list (node) references the previous and next elements. This data structure allows for efficient insertion and deletion operations at any position, but when accessing elements by index, LinkedList operations can be slower than ArrayList. Accessing elements by index in a LinkedList has a linear time complexity on average, O(n), where n is the index you’re trying to access. This is because the list needs to traverse from the beginning or end of the list, depending on which index is closer, until it reaches the desired position.
public int accessByIndexLinkedList() {
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 1500000; i++) {
linkedList.add(i);
}
int number = linkedList.get(1400000);
return number;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.accessByIndexLinkedList avgt 5 178.962 ± 75.787 ms/op
ArrayList
An ArrayList is implemented using a dynamic array. It’s essentially an array that automatically resizes itself when necessary as elements are added or removed. Retrieving an element by index in an ArrayList is relatively fast because you directly access an element in a contiguous memory location. Accessing elements by index in an ArrayList has a constant time complexity on average, O(1), assuming the underlying dynamic array resizing doesn’t happen frequently.
public int accessByIndexArrayList() {
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 1500000; i++) {
arrayList.add(i);
}
int number = arrayList.get(1400000);
return number;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.accessByIndexArrayList avgt 5 63.011 ± 3.797 ms/op
Result
From the above two examples, we can see that getting an element by index from the LinkedList takes 178.962 milliseconds and only 63.011 milliseconds in the ArrayList case. Obviously that getting an element by an index is much faster for ArrayList.
Example 4: Array vs ArrayList
One typical decision developers face is choosing between arrays and the ArrayList class. Arrays are a fundamental data structure in Java, while ArrayList is a part of the Java Collections Framework. Arrays have a fixed size, while ArrayLists can dynamically resize as needed. Default ArrayList capacity equals 10. The formula for increasing the size of ArrayList is?(OLD_CAPACITY * 3) / 2 + 1.
ArrayList
public List createArrayList() {
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < 1500000; i++) {
arrayList.add(i);
}
return arrayList;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
领英推荐
Benchmark Mode Cnt Score Error Units
MyBenchmark.createArrayList avgt 5 71.240 ± 6.042 ms/op
Array
public int[] createArray() {
int array[] = new int[1500000];
for (int i = 0; i < 1500000; i++) {
array[i] = i;
}
return array;
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.createArray avgt 5 0.788 ± 0.028 ms/op
Result
The above two examples show that creating ArrayList takes 71.240 milliseconds and only 0.788 milliseconds to create Array. If the number of elements is known ahead of time, it is much faster to create an Array than ArrayList.
Example 5: Insert into HashMap vs TreeMap
Another critical choice is between the HashMap and TreeMap classes for storing key-value pairs. HashMap uses hash-based indexing for quick access, while TreeMap maintains elements in sorted order based on their keys. If you prioritize fast retrieval times and don’t need a specific order, HashMap is generally more efficient due to its constant-time lookups. However, if you require elements to be sorted by keys, TreeMap can provide better performance for tasks like range queries.
HashMap performs better for insertion and lookup due to its average O(1) time complexity. On the other hand, TreeMap has an average O(log n) time complexity for insertion and lookup, making it slower as the number of elements increases.
TreeMap
public void insertTreeMap() {
Map<Integer, String> treeMap = new TreeMap<>();
for (int i = 0; i < 1000000; i++) {
treeMap.put(i, "Value " + i);
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.insertTreeMap avgt 5 205.077 ± 94.451 ms/op
HashMap
public void insertHashMap() {
Map<Integer, String> hashMap = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
hashMap.put(i, "Value " + i);
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.insertHashMap avgt 5 103.690 ± 9.183 ms/op
Result
The above two examples show that inserting into TreeMap takes 205.077 milliseconds and 103.690 milliseconds in the HashMap case. If you don’t need a specific order, the HashMap is much faster than the TreeMap.
Example 6: Sort and iterate HashMap vs TreeMap
In this example, TreeMap outperforms HashMap when iterating through the entries in sorted order. The TreeMap maintains the keys in sorted order, simplifying sorted iteration. However, remember that this advantage comes at the cost of slower insertion and lookup times compared to the HashMap.
HashMap
public void sortAndPrintResultsHashMap() {
Map<Integer, String> hashMap = new HashMap<>();
for (int i = 1000000 - 1; i >= 0; i--) {
hashMap.put(i, "Value " + i);
}
List<Integer> sortedKeys = new ArrayList<>(hashMap.keySet());
Collections.sort(sortedKeys);
for (Integer key : sortedKeys) {
System.out.println(key + " : " + hashMap.get(key));
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.sortAndPrintResultsHashMap avgt 5 13698.239 ± 21647.247 ms/op
TreeMap
public void sortAndPrintResultsTreeMap() {
Map<Integer, String> treeMap = new TreeMap<>();
for (int i = 1000000 - 1; i >= 0; i--) {
treeMap.put(i, "Value " + i);
}
for (Map.Entry<Integer, String> entry : treeMap.entrySet()) {
System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.sortAndPrintResultsTreeMap avgt 5 7849.875 ± 1979.701 ms/op
Result
The above two examples show that sorting elements and iterating through them in TreeMap takes 205.077 milliseconds and 103.690 milliseconds in the HashMap case. In this case, TreeMap is much faster than HashMap when a specific order is needed.
Optimize Loops
Loops are an integral part of Java programs; optimizing them can substantially impact performance. Here are some key points to consider when optimizing loops:
for (int i=0; i<1000000; i++) {
sourceArray[i] = "Value " + i;
}
String[] destinationArray = new String[1000000];
System.arraycopy(sourceArray, 0, destinationArray, 0, sourceArray.length);
}
public void copyWithManualLoop() {
String[] sourceArray = new String[1000000];
for (int i=0; i<1000000; i++) {
sourceArray[i] = "Value " + i;
}
String[] destinationArray = new String[1000000];
for (int i = 0; i < sourceArray.length; i++) {
destinationArray[i] = sourceArray[i];
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.copyWithManualLoop avgt 5 62.734 ± 2.847 ms/op
MyBenchmark.copyWithSystemArrayCopy avgt 5 52.476 ± 0.998 ms/op
Loop
public void processWithForLoop() {
String[] data = new String[1000000];
for (int i = 0; i < data.length; i++) {
data[i] = "Value " + i;
}
for (String value : data) {
System.out.println("New value: " + value.toUpperCase());
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.processWithForLoop avgt 5 14946.115 ± 812.357 ms/op
Parallel Stream
public void processWithParallelStream() {
String[] data = new String[1000000];
for (int i = 0; i < data.length; i++) {
data[i] = "Value " + i;
}
Arrays.stream(data)
.parallel()
.forEach(this::processValue);
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.processWithParallelStream avgt 5 7588.523 ± 2797.955 ms/op
Threads
public void processWithThreads() throws InterruptedException {
String[] data = new String[1000000];
for (int i = 0; i < data.length; i++) {
data[i] = "Value " + i;
}
int numThreads = Runtime.getRuntime().availableProcessors();
Thread[] threads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++) {
int startIndex = i * (data.length / numThreads);
int endIndex = (i == numThreads - 1) ? data.length : (i + 1) * (data.length / numThreads);
threads[i] = new Thread(new DataProcessor(data, startIndex, endIndex));
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
}
class DataProcessor implements Runnable {
private String[] data;
private int startIndex;
private int endIndex;
public DataProcessor(String[] data, int startIndex, int endIndex) {
this.data = data;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
@Override
public void run() {
for (int i = startIndex; i < endIndex; i++) {
processValue(data[i]);
}
}
private void processValue(String value) {
System.out.println("New value: " + value.toUpperCase());
}
}
Execution time (in milliseconds) with Java Microbenchmark Harness (JMH)
Benchmark Mode Cnt Score Error Units
MyBenchmark.processWithThreads avgt 5 6946.115 ± 2467.378 ms/op
Employ the ‘final’ Keyword
Using the final keyword can indeed impact optimization and performance in certain cases, although it might not always result in significant improvements. Here are a few scenarios where using the final keyword can potentially optimize and increase performance in a Java application:
It’s important to note that while using the final keyword can offer potential performance benefits, the impact might not always be substantial. Modern JVMs are equipped with powerful optimization techniques, and the JVM can often optimize code without explicitly using the final. It’s generally recommended to focus on writing clean, maintainable, and readable code first and then consider using final for performance optimization when appropriate.
Optimize Exception Handling
Exception handling is a critical aspect of Java programming, as it ensures the robustness and reliability of your applications by gracefully managing unexpected errors and failures. However, how you handle exceptions can significantly impact the performance of your Java applications.
Leverage Java Concurrency Utilities
Java, a widely-used programming language, offers a comprehensive set of Concurrency Utilities that allow developers to optimize the performance of their applications by efficiently utilizing available hardware resources.
Concurrency in Java refers to the ability of a program to execute multiple tasks simultaneously, enabling more efficient resource utilization and improved responsiveness. However, managing concurrency manually can be complex and error-prone, leading to issues like deadlocks, race conditions, and inconsistent behavior. Java’s Concurrency Utilities provide a higher-level, safer, and more manageable approach to handling concurrent tasks.
Key Concurrency Utilities in Java
While Concurrency Utilities offer powerful tools to optimize performance, using them incorrectly can lead to bugs and degraded performance. Here are some best practices to keep in mind:
Reduce object creation wherever possible
One common area that often contributes to performance bottlenecks is excessive object creation. Creating and disposing of objects can result in increased memory usage, garbage collection overhead, and slower execution times.
Object Pooling
Object Pooling is a technique that involves creating a pool of reusable objects upfront and reusing them whenever needed rather than creating new objects each time. This approach can be particularly practical for frequently used objects like database connections, thread instances, or network sockets.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class ConnectionPool {
private static final int MAX_POOL_SIZE = 10;
private List<Connection> connections = new ArrayList<>(MAX_POOL_SIZE);
public ConnectionPool() throws SQLException {
for (int i = 0; i < MAX_POOL_SIZE; i++) {
Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/db", "username", "password");
connections.add(connection);
}
}
public Connection getConnection() {
if (connections.isEmpty()) {
throw new IllegalStateException("Connection pool exhausted");
}
return connections.remove(0);
}
public void releaseConnection(Connection connection) {
if (connections.size() < MAX_POOL_SIZE) {
connections.add(connection);
} else {
try {
connection.close();
} catch (SQLException e) {
// Handle exception
}
}
}
}
Immutable Objects
Immutable objects are instances whose state cannot be changed after creation. Since their state remains constant, these objects can be safely shared across threads without synchronization, leading to better performance.
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
Reusing Objects
In scenarios where objects can be reused without compromising code readability or maintainability, consider modifying objects in place instead of creating new ones.
public class ReuseExample {
public static void main(String[] args) {
StringBuilder reusableBuilder = new StringBuilder();
for (int i = 0; i < 10000; i++) {
reusableBuilder.setLength(0); // Clear the builder
reusableBuilder.append(i).append(", ");
String result = reusableBuilder.toString();
// Use the result
}
}
}
Conclusion
Optimizing your Java performance requires a deep understanding of your application and its architecture. By following these tips and tricks, you should be well on the way to improving your Java application’s performance. Remember that performance optimization is an ongoing process, and you should always be looking for ways to improve your code. With a little effort and discipline, you can create Java applications that are fast, efficient, and capable of handling even the most demanding workloads.
Operador Logistico
11 个月To all the #Node #Developers out there, this one's for you! Looking for… ?? ? Great income & better career growth? ?? ? Silicon-valley clients? ?? ? Flexible working hours in all time zones? ? ? Cutting-edge tech stack projects? ?? Then, stop hustling & become our partner today at https://app.workfall.com/partner/signup