Introduction
Welcome back to our series on "Effective Java, 3rd Edition"! In our last post, we explored the first five items of Chapter 2, covering the basics of object creation. This time, we're diving deeper into some of the more nuanced aspects of managing an object's lifecycle. We'll look at how to prevent memory leaks and handle resource management.
Chapter 2: Creating and Destroying Objects (Items 6-9)
- Item 6: Avoid Creating Unnecessary Objects : Avoid creating unnecessary objects to improve performance and code cleanliness.
- Item 7: Eliminate Obsolete Object References: Prevent memory leaks by nulling out obsolete references.
- Item 8: Avoid Finalizers and Cleaners: Use deterministic resource management instead of unreliable finalizers or cleaners.
- Item 9: Prefer try-with-resources to try-finally: Ensure reliable resource cleanup in Java with try-with-resources.
Item 6: Avoid Creating Unnecessary Objects
Creating unnecessary objects can negatively impact performance and lead to code that is less clean. The most common pitfall is creating a new String object when a literal would suffice. Java's object creation is a relatively expensive operation, so it's a practice worth avoiding when possible.
Java
A classic anti-pattern is using the String constructor for a string literal.
// DON'T DO THIS! Creating unnecessary String objects.
String s = new String("Hello"); // This creates two String objects: one literal and one object instance.
// The correct, more efficient way.
String s = "Hello"; // This uses a single String literal from the string pool.
The same principle applies to using factory methods for objects that can be reused. Instead of creating a new object every time, a static factory method can return an existing instance. This is especially true for immutable objects.
// A less obvious example of unnecessary object creation in a loop.
Long sum = 0L; // Autoboxing is creating a new Long object in each iteration!
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
// The corrected version using a primitive long.
long sum = 0L; // No unnecessary objects are created here.
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
Kotlin
Kotlin's approach with primitives and immutability helps mitigate some of these issues. Kotlin's string literals are handled the same way as Java's, but its type system often guides you towards more efficient choices. For instance, the Long vs long issue from Java doesn't exist in the same way, as Kotlin's Long is compiled to a primitive long where possible.
// Kotlin code that avoids unnecessary object creation.
val s = "Hello" // No unnecessary object creation.
var sum = 0L // The L denotes a Long, but Kotlin optimizes this to a primitive long.
for (i in 0..Int.MAX_VALUE) {
sum += i
}
Summary
Strive to reuse objects whenever possible. Avoid creating unnecessary new instances, especially in performance-critical code. This makes your applications faster and more memory-efficient.
Item 7: Eliminate Obsolete Object References
This item is a critical one for any developer working with a garbage-collected language. The core idea is to prevent memory leaks by ensuring that objects you are no longer using are truly eligible for garbage collection. A memory leak occurs when an object is "logically" no longer needed but is still "physically" referenced by your code, preventing the garbage collector from reclaiming its memory.
A classic example is implementing a custom stack or queue. When you pop an element from the stack, the array that backed it still holds a reference to the popped object. While your size variable correctly indicates the new top of the stack, the old reference remains. If the popped object holds a large amount of memory, or if you repeat this process many times, it can lead to a significant memory leak.
Java
In Java, you must explicitly null out the reference once you are done with it.
// A simple Java Stack with a memory leak.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
// ... (resize logic)
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
// Memory leak here! elements[size] still holds a reference
// to the popped object.
return result;
}
}
The fix is straightforward: just null the reference.
// The corrected Java Stack.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Corrected: explicitly null the obsolete reference
return result;
}
Kotlin
The same problem can exist in Kotlin, especially when using mutable arrays or collections. The principle for the solution remains identical.
// A simple Kotlin Stack with a potential memory leak.
class Stack<E> {
private var elements = arrayOfNulls<Any>(16)
private var size = 0
fun push(e: E) {
// ... (resize logic)
elements[size++] = e
}
fun pop(): E {
if (size == 0)
throw EmptyStackException()
@Suppress("UNCHECKED_CAST")
val result = elements[--size] as E
// Same issue here!
return result
}
}
And the fix is just as simple.
// The corrected Kotlin Stack.
fun pop(): E {
if (size == 0)
throw EmptyStackException();
@Suppress("UNCHECKED_CAST")
val result = elements[--size] as E
elements[size] = null // Corrected: null out the obsolete reference
return result
}
Summary
The key takeaway is to be mindful of your object references. When an object is no longer part of your data structure's logical state, be sure to set its reference to null to allow the garbage collector to do its job.
Item 8: Avoid Finalizers and Cleaners
The finalize() method was a feature in older versions of Java intended for resource cleanup, but it is deeply flawed. Finalizers are unpredictable, non-deterministic, and can introduce performance overhead and even security vulnerabilities. You can't guarantee when or even if a finalizer will run, making them completely unreliable for critical resource cleanup.
Java 9 introduced Cleaner as a more robust (but still not guaranteed) alternative to finalizers. While it's a step up, the principle remains the same: it's not the right tool for the job.
Java
The correct way to handle resource cleanup in Java is to use try-with-resources (see Item 8). This ensures that a resource is closed deterministically and reliably.
// A problematic use of a finalizer in Java.
// This is not a reliable way to close resources.
public class ResourceHandler {
private final FileInputStream fileInputStream;
public ResourceHandler(String filePath) throws FileNotFoundException {
this.fileInputStream = new FileInputStream(filePath);
}
@Override
protected void finalize() throws IOException {
fileInputStream.close();
}
}
Kotlin
Kotlin has taken an even stronger stance on this. The finalize() method does not exist in Kotlin, and you cannot override it. This is a deliberate design decision that guides developers away from this problematic pattern and toward a more reliable solution.
The idiomatic Kotlin approach is to use the use
function, which we'll discuss in the next two items.
Summary
Don't use finalizers or cleaners. They are unreliable and dangerous. Instead, use a deterministic resource management approach.
Item 9: Prefer try-with-resources to try-finally
This item is a direct solution to the problems highlighted in Item 7. Before Java 7, resource management was often handled with try-finally blocks, which were verbose and error-prone. A key issue with try-finally is that an exception in the finally block can "swallow" the original exception from the try block, making debugging a nightmare.
try-with-resources
, introduced in Java 7, is a significant improvement. It automatically handles the closing of any resource that implements the java.lang.AutoCloseable
interface. It is concise, safe, and correctly handles suppressed exceptions, ensuring you don't lose the original stack trace.
Java
The difference in code complexity is stark.
// Legacy Java with try-finally. Verbose and error-prone.
try {
InputStream is = new FileInputStream("file.txt");
try {
// Do something with the stream...
} finally {
is.close();
}
} catch (IOException e) {
// ...
}
And with try-with-resources:
// Modern Java with try-with-resources. Clean and reliable.
try (InputStream is = new FileInputStream("file.txt")) {
// Do something with the stream...
} catch (IOException e) {
// ...
}
Kotlin
Kotlin's approach to this problem is the use
extension function, which achieves the same goals as try-with-resources but with a more functional and idiomatic syntax.
// The Kotlin equivalent of try-with-resources using `use`.
import java.io.File
fun readFile(path: String) {
File(path).inputStream().use { inputStream ->
// This block executes, and `inputStream` is automatically closed
// when the block exits.
}
}
Summary
try-with-resources
in Java and the use
function in Kotlin are the definitive ways to handle resources that require closing. They prevent resource leaks and make your code significantly more robust and readable.
Summary
For any resource management task in Kotlin, the use
function should be your first and only choice. It's a prime example of how Kotlin's language design helps you write more effective, safe, and concise code.
Next Up
In our next post, we'll dive into Chapter 3: Methods Common to All Objects, exploring the critical importance of correctly overriding methods like equals()
, hashCode()
, and toString()
. Stay tuned!
Top comments (0)