Unraveling Java’s Hidden Nuances: The Key to Writing Smarter Code

Didem AĞDOĞAN
8 min readDec 22, 2024

--

Part 1 — A Little Similar, But Not Quite the Same!

What are these topics that seem similar but are not quite the same? Today, we’re exploring them. Names that look alike or minor differences in functionality can sometimes be confusing. However, in this article, we’ll dive into these nuances to clear up any confusion.

Comparable vs. Comparator

In Java, when we want to sort an array or a list, we rely on the java.lang.Comparable and java.util.Comparator interfaces. These two interfaces provide solutions for sorting operations based on different needs.

What is Comparable?

The Comparable interface allows us to embed the sorting logic directly into the object itself. A class implementing this interface defines a default sorting order, which is implemented in the compareTo(Object o) method. However, this sorting order is fixed and cannot be changed dynamically.

class Movie implements Comparable<Movie> {
private String name;
private int year;

@Override
public int compareTo(Movie m) {
return this.year - m.year; // Sorts movies by release year
}
}

In this class, movies are sorted by default based on their release year. If we wanted to sort by another criterion (e.g., movie name), we’d have to change the compareTo method.

What is Comparator?

The Comparator interface allows us to define the sorting logic externally. This is especially advantageous in the following cases:

  1. If we want to define sorting logic in multiple ways, we can use a Comparator to define different sorting strategies for a class.
  2. When we cannot modify the source code of the class (e.g., a third-party library).

Sorting by salary:

public static Comparator<Employee> SalaryComparator = new Comparator<Employee>() {
@Override
public int compare(Employee e1, Employee e2) {
return (int) (e1.getSalary() - e2.getSalary());
}
};
Arrays.sort(empArr, Employee.SalaryComparator);

Sorting by name:

public static Comparator<Employee> NameComparator = new Comparator<Employee>() {
@Override
public int compare(Employee e1, Employee e2) {
return e1.getName().compareTo(e2.getName());
}
};
Arrays.sort(empArr, Employee.NameComparator);

Key Differences

  • Sometimes, we may not be able to modify the source code of a class. In such cases, Comparable is not an option. However, Comparator provides the flexibility to handle such scenarios.
  • For the same class, we can define multiple Comparator objects to sort a list by salary, name, or any other attribute.

Serializable vs. Externalizable

In Java, the Serializable and Externalizable interfaces are used for serializing objects to store them or send them over a network. However, there are significant differences in how they operate and their features.

Serializable Interface

  • The Serializable interface does not define any methods, and the serialization process is managed automatically by the JVM. The JVM processes all serializable states (non-transient and non-static fields) using reflection.
  • It provides a simpler structure, allowing us to serialize class instances without writing custom methods.
  • Due to the use of reflection and metadata, Serializable is slower compared to Externalizable.
  • If custom serialization is needed, fields can be marked with the transient keyword to exclude them from the process. Transient fields are stored with their default values.
import java.io.Serializable;

class Employee implements Serializable {
private static final long serialVersionUID = 1L; // For serialization compatibility
private String name;
private int age;
private transient double salary; // Will not be serialized

// Constructor, Getter, and Setter methods
}

Externalizable Interface

  • Classes implementing the Externalizable interface must override the writeExternal() and readExternal() methods. These methods give full control over the serialization and deserialization processes.
  • Externalizable is better suited for scenarios where we need custom serialization logic, such as specifying which fields to serialize or determining the format.
  • It provides better performance since reflection is not used, and only specified fields are processed.
  • The serialized fields must be read back in the same order as they were written. Otherwise, exceptions like java.io.EOFException may occur.
import java.io.Externalizable;
import java.io.ObjectInput;
import java.io.ObjectOutput;
import java.io.IOException;

class Country implements Externalizable {
private String code;
private String name;

public Country() {} // Default constructor is mandatory

@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(code);
out.writeUTF(name);
}

@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
code = in.readUTF();
name = in.readUTF();
}
}

When to Use?

  • Use Serializable: When the entire object needs to be serialized without customization.
  • Use Externalizable: When the serialization process requires customization (e.g. serializing only specific fields).
  • Use Externalizable: When performance is critical and full control over serialization is required.

Checked vs. Unchecked Exceptions

In Java, exceptions are used to handle errors and ensure that a program can continue running in the face of unexpected conditions. Exceptions are divided into two main groups: Checked Exceptions and Unchecked Exceptions. Their primary differences lie in compile-time checks and handling mechanisms.

What is a Checked Exception?

Checked Exceptions are exceptions that are checked at compile time. These exceptions can be anticipated and handled within the program’s logic, often occurring in operations dependent on external resources (e.g., file I/O or database operations).

Features:

  • The compiler enforces handling of checked exceptions. If not handled (using a try-catch block or the throws keyword), the program will not compile.
  • Represent problems arising from external resources (e.g., missing files or failed database connections).

Examples: ClassNotFoundException, IOException, SQLException, FileNotFoundException

import java.io.*;
public class CheckedExceptionExample {
public static void main(String[] args) {
try {
FileReader file = new FileReader("nonexistentfile.txt");
} catch (FileNotFoundException e) {
System.out.println("File not found: " + e.getMessage());
}
}
}

FileNotFoundException is a checked exception. Since we need to ensure the file exists, we must handle this exception with a try-catch block or the throws keyword.

What is an Unchecked Exception?

Unchecked Exceptions occur at runtime and are not checked by the compiler. These exceptions are usually programming errors resulting from incorrect logic or invalid data.

Features:

  • No compile-time checks are performed, so handling them is optional.
  • Typically associated with programming mistakes (e.g., accessing a null object or going out of array bounds).
  • Can be fatal and cause the program to crash.

Examples: ArithmeticException, ArrayIndexOutOfBoundsException, NullPointerException, NumberFormatException

public class UncheckedExceptionExample {
public static void main(String[] args) {
int[] numbers = {1, 2, 3};
System.out.println(numbers[5]); // ArrayIndexOutOfBoundsException
}
}

In this example, an ArrayIndexOutOfBoundsException occurs when attempting to access an index outside the array bounds.

Failsafe vs Failfast

In Java, iterators exhibit two different approaches when working with collections: Fail-Fast and Fail-Safe. These mechanisms offer distinct strategies for handling errors and ensuring data consistency.

Fail-Fast

Fail-Fast iterators stop the process as quickly as possible when an error is detected. This makes errors immediately visible and halts the entire operation. While iterating over a collection, if a structural modification (such as adding or removing an element) occurs, a ConcurrentModificationException is thrown.

Features:

  • Consumes less memory as it does not create a copy of the collection.
  • Detects errors quickly.

Examples: Default collections in the java.util package, such as ArrayList and HashMap.

Fail-Safe

Fail-Safe iterators do not stop the operation in case of an error; instead, they aim to prevent errors as much as possible. While iterating over a collection, the iterator operates on a copy of the collection. As a result, structural modifications do not affect the original collection.

Features:

  • Consumes more memory as it works on a copy of the collection.
  • Provides safer iteration but does not reflect changes made to the original collection.

Examples: Collections like ConcurrentHashMap and CopyOnWriteArrayList.

Message-Driven vs Event-Driven

Asynchronous Communication with Message-Driven Architecture

In a message-driven architecture, communication between services is facilitated through a messaging infrastructure (e.g., message queues or message buses) instead of direct interaction. This approach is based on the Publish/Subscribe principle.

  • Decoupled Services: Services are not directly connected to one another. A service publishes a message, and other services can subscribe to process that message.
  • No Awareness of Other Services: Services do not need to know about each other’s existence. This simplifies maintenance and supports independent development processes.
  • Message Queues: Messages are placed in a queue and consumed by other services. The publishing service does not need to know whether or when the message will be processed.
  • Fault Tolerance: If a service is down, messages are queued and processed once the service resumes.

Messaging Tools: RabbitMQ, Apache Kafka, ActiveMQ

Protocols: AMQP (Advanced Message Queuing Protocol), MQTT

Example: In an e-commerce system, when a user places an order, the order service publishes an “order creation” message to a queue. Different services (e.g., inventory update service, payment service) subscribe to this message and process it to perform the necessary actions.

Asynchronous Communication with Event-Driven Architecture

In an event-driven architecture, communication between system components occurs through events. Each component executes its business logic independently and transmits events via a message bus.

  • Event Broadcasting: When an event occurs, it is broadcast across the system. Components that listen for this event (event listeners) can process it. Each component only reacts to events it is interested in.
  • Message Bus or Event Broker: A message bus or event broker (e.g., Kafka) facilitates the communication of events between components.

Messaging Tools: Apache Kafka, AWS SNS (Simple Notification Service), Google Pub/Sub

Protocols: Webhooks, MQTT

Example: In an e-commerce system, when a product is purchased, the order service publishes an “OrderPlaced” event. This event is sent to other services via a message queue (e.g., Kafka, RabbitMQ) or an event publishing system (e.g., AWS SNS, Azure Event Grid). The payment service listens for the “OrderPlaced” event and initiates the payment process.

Both approaches are critical for asynchronous communication in modern microservice architectures and should be chosen based on the use case.

  • Message-Driven Architecture: Preferred when high decoupling and fault tolerance are required.
  • Event-Driven Architecture: More suitable for real-time operations or event-based process management.

ReentrantLock vs ReadWriteLock

ReentrantLock and ReadWriteLock are important synchronization tools used in Java to ensure data consistency in multi-threaded scenarios. ReentrantLock allows a thread to acquire the same lock multiple times, while ReadWriteLock offers a more efficient sharing mechanism between read and write operations. Both structures are designed to improve performance and ensure proper resource management in different use cases.

ReentrantLock

ReentrantLock is a mutual exclusion mechanism that allows a thread to acquire the same lock multiple times. It protects a critical section that should be accessed by only one thread at a time.

Features:

  • Fairness: The lock can be assigned to threads in a fair, sequential order or without any specific order.
  • Exclusive Access: Only one thread can hold the lock at a time.
  • Reentrancy: The same thread can acquire the same lock multiple times (recursive locking).
  • Try-Lock Support: The tryLock() method allows a thread to attempt to acquire the lock without waiting, returning immediately if the lock is unavailable.
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();

public void criticalSection() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " is executing critical section");
// Critical code
} finally {
lock.unlock();
}
}
}

ReadWriteLock

ReadWriteLock is used in scenarios involving reading and writing operations. Multiple threads can perform read operations simultaneously, but only one thread can perform a write operation at a time.

  • Read Operations: While reading, no changes are made to the data, allowing multiple threads to read data concurrently.
  • Write Operations: During write operations, all access to the data is locked to ensure exclusive write access, preventing any other read or write operations during that time.

Features:

  • Exclusive Write Access: Only one thread can perform a write operation at a time.
  • Read and Write Locks:
  • The readLock() method is used for read operations, allowing multiple threads to read simultaneously.
  • The writeLock() method is used for write operations, ensuring exclusive access for a single thread.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private int sharedResource = 0;

public void read() {
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " is reading: " + sharedResource);
} finally {
lock.readLock().unlock();
}
}

public void write(int value) {
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " is writing: " + value);
sharedResource = value;
} finally {
lock.writeLock().unlock();
}
}
}

When to Use:

  • ReentrantLock: Used in scenarios where only one thread should have access to a resource at a time (e.g., bank account transactions).
  • ReadWriteLock: Preferred in scenarios with frequent read operations but fewer write operations (e.g., a cache application).

Stay tuned for the next part of this series, where we’ll continue to delve into Java’s intricacies and uncover more comparisons!

--

--

Didem AĞDOĞAN
Didem AĞDOĞAN

Written by Didem AĞDOĞAN

Software Developer, Traveller, Curious

No responses yet