Top 10 Common Mistakes Made by Java Developers: Buggy Code

Java is a programming language that was initially developed for interactive television, but it has become ubiquitous in the software world. By embracing object-oriented programming, simplifying complexities found in languages like C or C++, offering garbage collection, and using an architecture-neutral virtual machine, Java revolutionized programming. Its gentle learning curve and adherence to its “Write once, run everywhere” philosophy have contributed to its success, though challenges remain. Let’s delve into ten common Java pitfalls.

Common Mistake #1: Neglecting Existing Libraries

Java developers should leverage the plethora of existing libraries. Before reinventing the wheel, explore readily available, refined, and often free libraries. These range from logging libraries like logback and Log4j to network-focused ones like Netty or Akka, with some like Joda-Time becoming industry standards.

A personal anecdote illustrates this. In a previous project, custom-written HTML escaping code, functional for years, encountered an input triggering an infinite loop, rendering the service unresponsive. Had the developer opted for established libraries like HtmlEscapers from Google Guava, this could have been avoided. Popular libraries benefit from community support, often leading to earlier detection and resolution of such issues.

Common Mistake #2: Missing the ‘break’ Keyword in a Switch-Case Block

This oversight can be embarrassing and sometimes goes unnoticed until production. While fallthrough behavior in switch statements is occasionally useful, omitting the “break” keyword when unintended can have detrimental consequences. For instance, forgetting “break” in “case 0” below would output both “Zero” and “One” due to uncontrolled flow within the “switch”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void switchCasePrimer() {
    	int caseIndex = 0;
    	switch (caseIndex) {
        	case 0:
            	System.out.println("Zero");
        	case 1:
            	System.out.println("One");
            	break;
        	case 2:
            	System.out.println("Two");
            	break;
        	default:
            	System.out.println("Default");
    	}
}

A cleaner approach often involves polymorphism, delegating specific behaviors to separate classes. Static code analyzers like FindBugs and PMD can help detect such Java mistakes.

Common Mistake #3: Forgetting to Free Resources

When a program accesses files or establishes network connections, it’s crucial to release these resources after use. This holds true even when exceptions occur. While FileInputStream’s finalizer invokes the close() method during garbage collection, its timing is unpredictable, potentially leading to prolonged resource consumption. Java 7 introduced a handy solution for this - try-with-resources:

1
2
3
4
5
6
7
8
9
private static void printFileJava7() throws IOException {
    try(FileInputStream input = new FileInputStream("file.txt")) {
        int data = input.read();
        while(data != -1){
            System.out.print((char) data);
            data = input.read();
        }
    }
}

Applicable to classes implementing AutoClosable, this ensures resource closure at the end of the statement.

Common Mistake #4: Memory Leaks

Java’s automatic memory management, while convenient, doesn’t make developers immune to memory usage concerns. Memory allocation problems persist. Holding references to unused objects prevents their garbage collection, effectively causing memory leaks. This commonly occurs with everlasting object references, often due to static fields holding collections without being nullified after use. Static fields, being GC roots, are never collected.

Circular dependencies between objects also lead to leaks, as the garbage collector struggles to determine their necessity. JNI usage can cause non-heap memory leaks as well.

Here’s a simple leak example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>();
final BigDecimal divisor = new BigDecimal(51);

scheduledExecutorService.scheduleAtFixedRate(() -> {
	BigDecimal number = numbers.peekLast();
   	if (number != null && number.remainder(divisor).byteValue() == 0) {
     	System.out.println("Number: " + number);
		System.out.println("Deque size: " + numbers.size());
	}
}, 10, 10, TimeUnit.MILLISECONDS);

	scheduledExecutorService.scheduleAtFixedRate(() -> {
		numbers.add(new BigDecimal(System.currentTimeMillis()));
	}, 10, 10, TimeUnit.MILLISECONDS);

try {
	scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
	e.printStackTrace();
}

This creates two scheduled tasks, one printing numbers divisible by 51 from a deque and the other populating it. Both run every 10ms, leading to an ever-growing deque, eventually consuming all heap memory. Using “pollLast”, which removes the element unlike “peekLast”, would prevent this while maintaining program logic.

To learn more about memory leaks in Java, please refer to our comprehensive article on the subject.

Common Mistake #5: Excessive Garbage Allocation

Creating numerous short-lived objects leads to excessive garbage allocation, forcing the garbage collector into overdrive and impacting performance. Consider this:

1
2
3
4
5
String oneMillionHello = "";
for (int i = 0; i < 1000000; i++) {
    oneMillionHello = oneMillionHello + "Hello!";
}
System.out.println(oneMillionHello.substring(0, 6));

Strings in Java are immutable, resulting in new string creation with each iteration. Using a mutable StringBuilder addresses this:

1
2
3
4
5
StringBuilder oneMillionHelloSB = new StringBuilder();
    for (int i = 0; i < 1000000; i++) {
        oneMillionHelloSB.append("Hello!");
    }
System.out.println(oneMillionHelloSB.toString().substring(0, 6));

The StringBuilder version executes significantly faster compared to the first.

Common Mistake #6: Using Null References without Need

Minimizing null usage is recommended. For instance, returning empty arrays or collections instead of null from methods can prevent NullPointerExceptions.

Consider:

1
2
3
4
List<String> accountIds = person.getAccountIds();
for (String accountId : accountIds) {
    processAccount(accountId);
}

If getAccountIds() returns null for no accounts, a NullPointerException occurs. A null-check becomes necessary. However, returning an empty list eliminates this need, resulting in cleaner code.

In other null-avoidance scenarios, strategies like using the Optional type, either empty or wrapping a value, can be employed:

1
2
3
4
Optional<String> optionalString = Optional.ofNullable(nullableString);
if(optionalString.isPresent()) {
    System.out.println(optionalString.get());
}

Java 8 offers a more concise way:

1
2
Optional<String> optionalString = Optional.ofNullable(nullableString);
optionalString.ifPresent(System.out::println);

While present in Java since version 8, Optional has long been a staple in functional programming, available via Google Guava for earlier versions.

Common Mistake #7: Ignoring Exceptions

Ignoring exceptions might be tempting, but handling them is crucial. Exceptions are thrown for a reason, demanding attention. Rethrow them if needed, notify the user, or log them. At the very least, explain why an exception is unhandled to inform other developers.

1
2
3
4
5
6
selfie = person.shootASelfie();
try {
    selfie.show();
} catch (NullPointerException e) {
    // Maybe, invisible man. Who cares, anyway?
}

A clearer way to indicate an exception’s insignificance is through a descriptive variable name:

1
try { selfie.delete(); } catch (NullPointerException unimportant) {  }

Common Mistake #8: Concurrent Modification Exception

This exception arises from modifying a collection during iteration using methods outside the iterator. Take, for example, removing hats with ear flaps:

1
2
3
4
5
6
7
8
9
List<IHat> hats = new ArrayList<>();
hats.add(new Ushanka()); // that one has ear flaps
hats.add(new Fedora());
hats.add(new Sombrero());
for (IHat hat : hats) {
    if (hat.hasEarFlaps()) {
        hats.remove(hat);
    }
}

This throws a “ConcurrentModificationException” as the collection is modified during iteration. The same can happen with multi-threaded access. While concurrent modification is common, it necessitates synchronization mechanisms like locks, specialized concurrent collections, etc. Single-threaded and multi-threaded scenarios have different solutions. Here are some single-threaded approaches:

Collect objects and remove them in another loop

This involves gathering hats with ear flaps in a separate list for later removal, requiring an additional collection:

1
2
3
4
5
6
7
8
9
List<IHat> hatsToRemove = new LinkedList<>();
for (IHat hat : hats) {
    if (hat.hasEarFlaps()) {
        hatsToRemove.add(hat);
    }
}
for (IHat hat : hatsToRemove) {
    hats.remove(hat);
}

Use Iterator.remove method

This concise method avoids an extra collection:

1
2
3
4
5
6
7
Iterator<IHat> hatIterator = hats.iterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.remove();
    }
}

Use ListIterator’s methods

Suitable for List implementations, ListIterator supports removal, addition, and modification. It resembles the Iterator example but utilizes the “listIterator()” method. Here’s how to replace hats with sombreros:

1
2
3
4
5
6
7
8
9
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.remove();
        hatIterator.add(sombrero);
    }
}

“ListIterator.set” can further simplify this:

1
2
3
4
5
6
7
8
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.set(sombrero); // set instead of remove and add
    }
}

Use stream methods introduced in Java 8

Java 8 lets us filter collections as streams:

1
2
hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))
        .collect(Collectors.toCollection(ArrayList::new));

While concise, “Collectors.toCollection” creates a new ArrayList, which can be problematic for large datasets.

Use List.removeIf method presented in Java 8

This is the most concise solution in Java 8:

1
hats.removeIf(IHat::hasEarFlaps);

It internally uses “Iterator.remove” for its functionality.

Use specialized collections

Using “CopyOnWriteArrayList” from the outset would have avoided the issue altogether. It creates modified copies instead of altering the original, allowing concurrent iteration and modification without “ConcurrentModificationException”. However, this comes at the cost of creating new collections with each modification.

Other specialized collections like “CopyOnWriteSet” and “ConcurrentHashMap” exist for different use cases.

Another pitfall is modifying the backing collection while iterating its stream. The rule of thumb is to avoid this. This example demonstrates incorrect handling:

1
2
3
4
5
List<IHat> filteredHats = hats.stream().peek(hat -> {
    if (hat.hasEarFlaps()) {
        hats.remove(hat);
    }
}).collect(Collectors.toCollection(ArrayList::new));

Here, the “peek” operation attempts to remove elements from the underlying list, leading to errors. Employ the previously discussed methods to avoid this.

Common Mistake #9: Breaking Contracts

Code often relies on rules, or contracts, for proper functioning. For instance, the hashCode and equals contract ensures proper behavior for Java collections like HashMap and HashSet. Violating these contracts might not always result in immediate errors but can lead to unpredictable application behavior, manifesting as UI issues, inaccurate reports, performance degradation, or even data loss.

The hashCode and equals contract states:

  • Equal objects must have equal hash codes.
  • Objects with the same hash code might not be equal.

Breaking the first rule creates problems when retrieving objects from hash-based collections. The second rule indicates that hash collisions don’t guarantee equality.

Consider this example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static class Boat {
    private String name;

    Boat(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Boat boat = (Boat) o;

        return !(name != null ? !name.equals(boat.name) : boat.name != null);
    }

    @Override
    public int hashCode() {
        return (int) (Math.random() * 5000);
    }
}

“Boat” overrides equals and hashCode but violates the contract by returning random hash codes. This makes finding the “Enterprise” boat unlikely:

1
2
3
4
5
6
public static void main(String[] args) {
    Set<Boat> boats = new HashSet<>();
    boats.add(new Boat("Enterprise"));

    System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise")));
}

Another example involves the finalize method. The Java documentation states:

The general contract of finalize is that it is invoked if and when the JavaTM virtual machine has determined that there is no longer any means by which this object can be accessed by any thread (that has not yet died), except as a result of an action taken by the finalization of some other object or class which is ready to be finalized. The finalize method may take any action, including making this object available again to other threads; the usual purpose of finalize, however, is to perform cleanup actions before the object is irrevocably discarded. For example, the finalize method for an object that represents an input/output connection might perform explicit I/O transactions to break the connection before the object is permanently discarded.

Using finalize for resource cleanup is ill-advised due to the lack of timing guarantees, as its execution is tied to the unpredictable garbage collection cycle.

Common Mistake #10: Using Raw Type Instead of a Parameterized One

Raw types, in essence, are non-parametrized types or non-static members not inherited from superclasses or interfaces. Prevalent before generics, their continued existence for backward compatibility introduces potential risks.

Consider:

1
2
3
4
List listOfNumbers = new ArrayList();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));

This raw ArrayList, lacking type specification, allows adding any object. However, casting to an integer at runtime causes an exception when encountering a string. The type system fails to ensure safety due to missing information.

Parametrizing the collection resolves this:

1
2
3
4
5
6
List<Integer> listOfNumbers = new ArrayList<>();

listOfNumbers.add(10);
listOfNumbers.add("Twenty");

listOfNumbers.forEach(n -> System.out.println((int) n * 2));

The key difference lies in:

1
List<Integer> listOfNumbers = new ArrayList<>();

Now, attempting to add a string to an integer list results in a compile-time error, preventing runtime issues. Parametrizing generic types enables thorough type checking by the compiler, minimizing inconsistencies.

Conclusion

While Java simplifies software development through its sophisticated JVM and language features, challenges remain for developers despite automatic memory management and OOP support. Knowledge, practice, and resources like this guide are crucial for avoiding and tackling errors. Explore libraries, delve into Java and JVM documentation, write code, and leverage static analyzers for bug detection and prevention.

Licensed under CC BY-NC-SA 4.0