Chapter 44 - Multithreading in Java (Part 4)

Chapter 44 - Multithreading in Java (Part 4)

Table of contents

In this chapter, we dive deeper into the advanced concepts of multithreading in Java, focusing on critical synchronization techniques and thread management. As multithreading allows concurrent execution of multiple tasks, ensuring proper coordination and consistency between threads becomes essential to avoid issues like race conditions or data inconsistencies.

We will explore the following key topics in detail:

  1. Interrupting Threads
    Understand how threads can be interrupted, how the interrupt() method works, and how to handle InterruptedException.

  2. Synchronization in Java
    Discover the importance of synchronization to ensure thread safety when multiple threads access shared resources. We'll cover:

    • Synchronized Methods: Locking entire methods to prevent thread interference.

    • Synchronized Blocks: Enhancing performance by locking only critical sections of code instead of entire methods.

  3. Levels of Synchronization Locks
    Learn about different lock mechanisms and their use cases, including:

    • Object-Level Locks (this lock)

    • Class-Level Locks (ClassName.class lock)

    • Specific Object Locks (custom objects as locks)

  4. Performance Optimization with Synchronized Blocks
    Explore how synchronized blocks improve application performance by locking only problematic code regions rather than entire methods.

  5. Common Pitfalls with Synchronization
    We'll discuss common errors like trying to use primitive types for synchronization and provide correct approaches using wrapper classes.

By the end of this chapter, you will have a strong understanding of how to effectively use synchronization and thread management to build efficient, thread-safe Java applications. You’ll also gain clarity on where to use synchronized methods vs. synchronized blocks and how to handle multi-threaded scenarios efficiently.

Let’s get started! 🚀

1. Interrupting a Thread

1.1 Introduction

In multithreading, the interrupt() method plays a crucial role in managing the lifecycle of threads, especially when one thread needs to signal another to stop its current activity or wake up from a waiting or sleeping state.

Key Points:

  • Method Definition: The public void interrupt() method is used to interrupt a thread.

  • When to Use:

    • To interrupt a thread in a sleeping or waiting state.

    • To notify a thread to stop its execution or respond to an external event.


1.2 What Happens When a Thread is Interrupted?

When a thread is interrupted, the InterruptedException is thrown if the thread is in a sleeping or waiting state.

Characteristics of InterruptedException:

  • Definition: It is a checked exception that must be explicitly handled using a try-catch block.

  • Scenario:
    If a thread is in the waiting state, and another thread interrupts it using the interrupt() method, the InterruptedException is thrown.


1.3 How Does One Thread Interrupt Another?

One thread can interrupt another by invoking the interrupt() method. Let's explore this using an example where the main thread (parent) interrupts the child thread.


1.3.1 Case: Parent Thread Interrupting Child Thread

Scenario Description:

  • The main thread creates and starts a child thread.

  • The child thread performs a task and then enters a sleeping state for 3 seconds after each task iteration.

  • The main thread interrupts the child thread using the interrupt() method.

  • When the child thread detects the interrupt signal, it throws an InterruptedException.


1.3.2 Visualization:

Refer to the picture at timestamp 20:49 (Image-1 in GitHub). Below is the textual representation of the thread states:

                 Thread Scheduler
                    _______|________         
                   |                |
          main thread        thread-0 (child thread)
          |                |    -> (Sleeping / Waiting state)
 t.interrupt()  |                |  
          |                |    InterruptedException is thrown

1.3.3 Key Observations:

  1. Both threads (main and child) have the same priority, so the thread execution order is unpredictable.

  2. Main Thread:

    • Executes and calls t.interrupt() to interrupt the child thread.
  3. Child Thread:

    • Sleeps for 3 seconds after each task execution.

    • When interrupted, it catches the InterruptedException.

  4. The interrupt call will only be executed when the interrupted thread is in the sleeping/waiting state.


1.4 Real-World Analogy:

Imagine a scene from a movie where a snake wants to take revenge on a person. If the person falls asleep, the snake bites immediately. Similarly, if a thread is in a sleeping state, the interrupt call wakes it up by throwing an exception.


1.5 Code Example

Here’s a practical example demonstrating how one thread can interrupt another:

// Child thread class
class MyThread extends Thread {
    @Override
    public void run() {
        try {
            for (int i = 1; i <= 5; i++) {
                System.out.println("I am sleeping thread " + i);
                Thread.sleep(3000); // Sleep for 3 seconds
            }
        } catch (InterruptedException e) {
            System.out.println("I got interrupted");
        }
    }
}

// Main class
public class MultithreadingExample {
    public static void main(String[] args) {
        MyThread t = new MyThread(); // Create a child thread
        t.start(); // Start the child thread

        // Interrupt the child thread
        t.interrupt();

        System.out.println("End of main method");
    }
}

1.5.1 Output:

End of main method
I am sleeping thread 1
I got interrupted

1.5.2 Explanation of Code Behavior:

  1. Main Thread:

    • Calls t.interrupt() immediately after starting the child thread.

    • Ends its execution after printing "End of main method".

  2. Child Thread:

    • Executes the loop and enters a sleeping state using Thread.sleep(3000).

    • When interrupted by the main thread, it catches the InterruptedException and prints "I got interrupted".


1.5.3 Key Takeaways:

  • The interrupt mechanism allows threads to communicate with each other efficiently.

  • Proper handling of InterruptedException is crucial for maintaining thread stability.


1.6 Case-2: Main Thread Interrupting Child Thread Without Sleeping or Waiting State

1.6.1 Scenario Description

In this case, the main thread (parent) interrupts the child thread using the interrupt() method. However, unlike Case-1, the child thread never enters a sleeping or waiting state.

Key Points:

  1. The interrupt() method is invoked, but since the child thread is not in a state where it can respond (e.g., sleeping/waiting), the interrupt call has no effect.

  2. Once the child thread completes its execution, it transitions to the dead state.

  3. The interrupt() call is effectively wasted, as it cannot influence the child thread's execution in this scenario.


1.6.2 Real-World Analogy

Imagine a movie where a snake is waiting to attack a person, but the person dies naturally before the snake can act. The snake leaves without doing anything. Similarly, if a thread finishes its execution naturally without sleeping or waiting, the interrupt call has no effect.


1.6.3 Visualization

Refer to the diagram below to understand the thread states in this scenario:

                 Thread Scheduler
                    _______|________         
                   |                |
          main thread        thread-0 (child thread)
          |           (Not in Sleeping/Waiting state)
 t.interrupt()  |                |   
          |                |   
          |                | Running state  
                          |

1.6.4 Code Example

Here’s a practical example demonstrating this scenario:

// Child thread class
class MyThread extends Thread {
    @Override
    public void run() {
        try {
            for (int i = 1; i <= 5; i++) {
                System.out.println("I am running thread " + i);
            }
        } catch (Exception e) {
            System.out.println("I got interrupted");
        }
    }
}

// Main class
public class MultithreadingExample2 {
    public static void main(String[] args) {
        MyThread t = new MyThread(); // Create a child thread
        t.start(); // Start the child thread

        // Interrupt the child thread
        t.interrupt();

        System.out.println("End of main method");
    }
}

1.6.5 Output

End of main method
I am running thread 1
I am running thread 2
I am running thread 3
I am running thread 4
I am running thread 5

1.6.6 Explanation of Code Behavior

  1. Main Thread:

    • Calls t.interrupt() immediately after starting the child thread.

    • Prints "End of main method" and finishes execution.

  2. Child Thread:

    • Executes the loop and completes its task without entering a sleeping or waiting state.

    • Ignores the interrupt() call since it is irrelevant to its current state.

    • Prints messages for all iterations and transitions to the dead state after completing execution.


1.6.7 Key Takeaways

  1. Interrupt Call Usage:

    • The interrupt() method only has an effect if the thread is in a sleeping or waiting state.
  2. No Effect in Running State:

    • If a thread is actively running and completes its execution without sleeping or waiting, the interrupt() call is wasted.
  3. Error Handling:

    • Even though the try-catch block is present, the InterruptedException is not thrown because the thread never enters a state where the interrupt call is meaningful.

1.7 Case-3: Main Thread Interrupting Child Thread After Entering Sleeping State

1.7.1 Scenario Description

In this case, the main thread (parent) interrupts the child thread using the interrupt() method after the child thread has started executing and entered a sleeping state. This results in an InterruptedException, as the interrupt() method disrupts the sleeping state of the child thread.


1.7.2 Key Points

  1. The interrupt() method effectively interrupts the child thread only when it enters a sleeping or waiting state.

  2. An InterruptedException is generated because the child thread’s sleep state is interrupted by the main thread.

  3. The child thread handles the exception within a try-catch block.


1.7.3 Real-World Analogy

Imagine a scenario where a worker is doing their job and then takes a nap. The manager interrupts the nap midway, forcing the worker to wake up. This is analogous to a thread in a sleeping state being interrupted by another thread.


1.7.4 Visualization

Refer to the diagram below to understand the thread states in this scenario:

                 Thread Scheduler
                    _______|________         
                   |                |
          main thread        thread-0 (child thread)
          |                  (Enters Sleeping after Task) 
 t.interrupt()  |                | 
          |                | 
                          Sleeping State

1.7.5 Code Example

Here’s an example to demonstrate this scenario:

// Child thread class
class MyThread extends Thread {
    @Override
    public void run() {
        // Perform some task
        for (int i = 1; i <= 5; i++) {
            System.out.println("I am a lazy thread " + i);
        }

        // Enter sleeping state
        System.out.println("Entering into sleeping state");
        try {
            Thread.sleep(3000); // Child thread sleeps for 3 seconds
        } catch (InterruptedException e) {
            System.out.println("I got interrupted"); // Handle interruption
        }
    }
}

// Main class
public class MultithreadingCase3 {
    public static void main(String[] args) {
        MyThread t = new MyThread(); // Create child thread
        t.start(); // Start child thread

        // Interrupt the child thread
        t.interrupt();

        // Main thread finishes execution
        System.out.println("End of main method");
    }
}

1.7.6 Output

I am a lazy thread 1  
I am a lazy thread 2  
I am a lazy thread 3  
I am a lazy thread 4  
I am a lazy thread 5  
Entering into sleeping state  
I got interrupted  
End of main method

1.7.7 Explanation of Code Behavior

  1. Main Thread:

    • Calls t.interrupt() while the child thread is running.

    • Continues to execute and prints "End of main method."

  2. Child Thread:

    • Executes its loop and completes the task of printing five iterations.

    • After completing its task, the thread enters a sleeping state using Thread.sleep(3000).

    • When the main thread interrupts, the sleeping state is disrupted, triggering an InterruptedException.

    • The child thread catches the exception and prints "I got interrupted."


1.7.8 Key Takeaways

  1. Interrupt Call is Effective:

    • The interrupt() method successfully interrupts a thread if it is in a sleeping or waiting state.
  2. Handling InterruptedException:

    • Always handle InterruptedException in a try-catch block to ensure the program doesn’t crash.
  3. Thread Lifecycle:

    • After handling the exception, the child thread can either terminate or continue with its execution, depending on the implementation.
  4. Thread Coordination:

    • Thread interruption is a mechanism for coordinating thread behavior in multithreading scenarios.

Notes on Thread Methods and Behavior

General Notes:

  1. If a thread interrupts another thread, but the target thread is not in a waiting/sleeping state, no exception will be thrown.

  2. The interrupt() call will remain valid until the target thread enters a waiting/sleeping state, ensuring that the call is not wasted.

  3. Once the target thread enters a waiting/sleeping state, the interrupt() method will interrupt the thread and cause an exception (InterruptedException).

  4. The interrupt() call is wasted only if the target thread never enters a waiting/sleeping state.


Comparison of Thread Methods: yield(), join(), sleep()

1) Purpose

  • yield(): Pauses the current executing thread to give a chance to other threads of the same priority.

  • join(): A thread waits for another thread to complete its execution and join.

  • sleep(): A thread pauses its execution for a specified amount of time.


2) Is it Static?

  • yield(): Yes

  • join(): No

  • sleep(): Yes


3) Is it Final?

  • yield(): No

  • join(): Yes

  • sleep(): No


4) Is it Overloaded?

  • yield(): No

  • join(): Yes

  • sleep(): Yes


5) Does it Throw InterruptedException?

  • yield(): No

  • join(): Yes

  • sleep(): Yes


6) Is it a Native Method?

  • yield(): Yes

  • join(): No

  • sleep():

    • sleep(long ms): Yes (Native)

    • sleep(long ms, int ns): No (Non-Native)


Additional Notes

  • More .class files = Longer loading time:
    Having a higher number of .class files in your project increases the time required for class loading, which might affect application startup time.

3. Creating a Thread Using the Runnable Interface

Key Points:

  1. Runnable Interface:

    • The Runnable interface is a functional interface defined in Java with a single abstract method, run().

    • Any class that implements Runnable must provide an implementation for the run() method, which contains the task to be executed by the thread.

    @FunctionalInterface
    interface Runnable {
        void run(); // Abstract method
    }
  1. Advantages of Using Runnable:

    • Java supports single inheritance, meaning a class cannot extend more than one class. Using Runnable avoids this limitation since the implementing class does not need to extend Thread.

    • Encourages better separation of task logic and thread management by decoupling the task (defined in Runnable) from the thread itself (handled by the Thread class).

  2. Impact of More .class Files:

    • The number of .class files affects the application startup time.

    • More .class files result in increased class-loading time during application initialization.


Example Code:

// Step 1: Define a class that implements Runnable
class MyRunnable implements Runnable {
    public void run() {
        // Child thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Child Thread");
        }
    }
}

public class Multithreading47 {
    public static void main(String[] args) {
        // Step 2: Create an instance of MyRunnable
        MyRunnable r = new MyRunnable();

        // Step 3: Create a Thread and pass the Runnable instance
        Thread t = new Thread(r);

        // Step 4: Start the thread
        t.start();

        // Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

Explanation:

  1. Child Thread Creation:

    • An instance of the MyRunnable class (which implements Runnable) is passed to the Thread constructor.

    • The start() method is invoked on the Thread object to initiate a new thread of execution.

  2. Execution Flow:

    • Once the thread is started, the JVM scheduler determines the execution order between the main thread and the child thread.

Output (Order may vary):

Main Thread
Main Thread
Child Thread
Child Thread
Child Thread
Main Thread
Main Thread
Main Thread
Main Thread
Child Thread
Child Thread
Child Thread

Key Observations:

  1. Interleaved Output:

    • Threads run independently, so their execution alternates depending on the thread scheduler.

    • The sequence of "Main Thread" and "Child Thread" messages may vary across different executions.

  2. Thread Decoupling:

    • By using Runnable, the logic of the thread (in run()) is decoupled from thread creation (Thread class).

4. Creating a Thread Using Lambda Expressions

Key Points:

  1. Runnable Interface and Lambda Expressions:

    • The Runnable interface is a functional interface, meaning it has a single abstract method:

        @FunctionalInterface
        interface Runnable {
            void run();
        }
      
    • Since Java 8, functional interfaces can be implemented using lambda expressions, providing a more concise and readable syntax.

  2. Benefits of Using Lambda Expressions:

    • Reduces boilerplate code for creating Runnable objects.

    • Makes the code more compact and easier to read.

    • Avoids the need for separate class definitions when implementing Runnable.

  3. Execution Context:

    • The run() method defined using a lambda expression is executed in the child thread.

    • The main thread executes its task concurrently with the child thread.


Example Code (with Lambda Expression):

public class Multithreading48 {
    public static void main(String[] args) {
        // Step 1: Define a Runnable using a Lambda Expression
        Runnable r = () -> {
            for (int i = 0; i <= 5; i++) {
                System.out.println("Child Thread");
            }
        };

        // Step 2: Create a Thread object with the Runnable instance
        Thread t = new Thread(r);

        // Step 3: Start the child thread
        t.start();

        // Step 4: Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

Explanation:

  1. Defining the Task:

    • A Runnable object is created using a lambda expression. The run() method inside the lambda defines the task for the child thread.
  2. Creating and Starting the Thread:

    • The Runnable object is passed to the Thread constructor, creating a new thread (t).

    • The start() method is invoked to begin execution of the child thread.

  3. Concurrent Execution:

    • The run() method executes in the child thread.

    • The main thread continues executing its own task concurrently.


Output (Order may vary):

Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Child Thread
Child Thread
Child Thread
Child Thread
Child Thread
Child Thread

Key Observations:

  1. Interleaved Output:

    • The order of execution between the main thread and the child thread is determined by the JVM's thread scheduler.

    • Hence, the exact sequence of "Main Thread" and "Child Thread" messages may vary.

  2. Lambda Simplification:

    • The lambda expression eliminates the need for creating a separate class to implement the Runnable interface.

    • The task is directly defined inline, making the code more concise and focused.


Comparison Between Runnable and Lambda Expressions:

AspectTraditional RunnableLambda Expression
Syntax ComplexityRequires a separate class or anonymous inner classConcise and inline definition
Code LengthLongerShorter
ReadabilityModerateHigh
Java Version SupportAvailable in all Java versionsRequires Java 8 or higher

5. Creating Threads Using Anonymous Inner Classes

Key Points:

  1. Anonymous Inner Classes:

    • Anonymous inner classes allow you to create a one-time implementation of an interface or class without explicitly declaring a separate class.

    • This is useful when you need a quick implementation of the Runnable interface.

  2. Advantages:

    • Reduces code clutter by avoiding separate class definitions.

    • Allows compact inline implementation.

  3. Execution Context:

    • The run() method defined inside the anonymous inner class executes in the child thread.

    • The main thread runs its task concurrently.


Example Code (Using Anonymous Inner Class with Runnable)

public class Multithreading49 {

    public static void main(String[] args) {
        // Step 1: Create an anonymous inner class for Runnable
        Runnable r = new Runnable() {
            @Override
            public void run() {
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Child Thread");
                }
            }
        };

        // Step 2: Pass the Runnable object to a Thread
        Thread t = new Thread(r);

        // Step 3: Start the thread
        t.start();

        // Step 4: Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

Alternative Approaches:

1. Inline Anonymous Class Definition:

Instead of creating a separate Runnable object, you can directly pass the anonymous inner class as an argument to the Thread constructor.

public class Multithreading49 {

    public static void main(String[] args) {
        // Step 1: Directly pass the anonymous inner class
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Child Thread");
                }
            }
        });

        // Step 2: Start the thread
        t.start();

        // Step 3: Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

2. Using Method Chaining:

You can eliminate variable declarations entirely by chaining the new operator and the start() method call.

public class Multithreading49 {

    public static void main(String[] args) {
        // Step 1: Combine Thread creation and start in one statement
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 1; i <= 5; i++) {
                    System.out.println("Child Thread");
                }
            }
        }).start();

        // Step 2: Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

Explanation of the Code:

  1. Creating an Anonymous Inner Class:

    • The Runnable interface is implemented in an anonymous inner class where the run() method is overridden.

    • The task of the child thread is defined inside this run() method.

  2. Passing to the Thread Constructor:

    • The anonymous Runnable object is passed to the Thread constructor.

    • This associates the task with the thread.

  3. Starting the Thread:

    • The start() method invokes the run() method of the Runnable object in a new thread.
  4. Concurrent Execution:

    • The child thread and the main thread execute their tasks concurrently, and the output order may vary.

Output (Order may vary):

Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Child Thread
Child Thread
Child Thread
Child Thread
Child Thread

Comparison of Approaches:

ApproachAdvantagesDisadvantages
Separate Runnable ObjectReusable code, clearer structureSlightly longer code
Inline Anonymous ClassCompact, reduces boilerplateSlightly less readable if overused
Method ChainingMost concise, eliminates variable declarationHarder to debug or modify in complex scenarios

Key Observations:

  1. Multiple .class Files:

    • When an anonymous inner class is created, an additional .class file is generated, which can increase loading time for many anonymous classes.
  2. Thread Scheduling:

    • The interleaved output of "Main Thread" and "Child Thread" is controlled by the JVM's thread scheduler, and the order is not guaranteed.
  3. Use Cases:

    • Use anonymous inner classes for quick and non-repetitive tasks.

    • For reusable tasks, consider creating separate classes or use lambda expressions if Java 8 or above is available.


Using Lambda Expressions for Multithreading

Lambda expressions in Java, introduced in Java 8, simplify the implementation of functional interfaces. Since Runnable is a functional interface (it has only one abstract method, run()), we can use a lambda expression to replace the anonymous inner class.


Code Explanation

  1. Lambda Expression:

    • The Runnable interface has only one abstract method: void run().

    • A lambda expression can directly implement this method, reducing boilerplate code.

  2. Single .class File:

    • Unlike anonymous inner classes, lambda expressions do not generate a separate .class file for the implementation.

    • The compiled lambda expression is treated as a method reference in the enclosing class.

  3. Code Structure:

    • A new Thread object is created, and the lambda expression implementing Runnable is passed as an argument.

    • The start() method of the Thread object initiates the child thread.


Code Implementation

public class Multithreading50 {

    public static void main(String[] args) {
        // Creating and starting the thread using a lambda expression
        new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println("Child Thread");
            }
        }).start();

        // Main thread task
        for (int i = 0; i <= 5; i++) {
            System.out.println("Main Thread");
        }
    }
}

Output (Order may vary due to thread scheduling)

Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Main Thread
Child Thread
Child Thread
Child Thread
Child Thread
Child Thread

Advantages of Using Lambda Expressions

FeatureDetails
Compact CodeEliminates the need for an anonymous inner class, resulting in cleaner code.
Improved ReadabilityReduces boilerplate code while clearly conveying the task's intent.
No Additional .class FilesUnlike anonymous inner classes, lambda expressions do not generate separate .class files.
EfficiencyDirectly translates to a method reference in the enclosing class.

Comparison: Anonymous Class vs Lambda Expression

AspectAnonymous Inner ClassLambda Expression
SyntaxVerboseConcise
File GenerationGenerates an extra .class fileNo extra .class file
Code ReadabilityLess readable for small tasksMore readable
Java VersionSupported in all versionsRequires Java 8 or later

Use Cases

  • Anonymous Inner Class: Use if the task involves more than one abstract method (non-functional interfaces).

  • Lambda Expression: Prefer for functional interfaces like Runnable for simplicity and clarity.


Explanation of the Multithreading Example with Display and MyThread

This code demonstrates how threads can interact with a shared resource (Display class) in a Has-A Relationship context, where one class (MyThread) contains a reference to another (Display).

Code Structure

  1. Display Class:

    • Contains the wish(String name) method.

    • The method prints a greeting ("Good Evening:") followed by the name of the user, five times.

    • A Thread.sleep(2000) call introduces a 2-second delay between each iteration.

  2. MyThread Class:

    • Extends the Thread class to represent a thread of execution.

    • Has-A Relationship with Display (contains a reference Display d).

    • The constructor initializes the Display object and the String name.

    • Overrides the run() method to execute the wish() method of Display on a specific name.

  3. Main Method (Multithreading51):

    • Creates an object of Display (shared resource).

    • Creates an object of MyThread, passing the Display object and the user's name ("sachin").

    • Starts the thread using t1.start(), which internally calls the run() method.


Code Walkthrough

  1. Initialization:

    • At Line-1, a Display object d is created. This object acts as the shared resource for the thread.

    • At Line-2, a MyThread object t1 is created, passing the Display object d and the name "sachin".

  2. Thread Execution:

    • When t1.start() is called, the thread's run() method executes.

    • Inside run(), the wish(String name) method of the Display class is called using d.wish(name).

    • The wish() method:

      • Iterates 5 times.

      • Prints "Good Evening:" followed by the provided name (sachin).

      • Pauses for 2 seconds between iterations using Thread.sleep(2000).


Key Concepts Illustrated

ConceptDetails
Has-A RelationshipMyThread contains a reference to Display, allowing reuse and modularity.
Thread SynchronizationWhile no explicit synchronization is used here, threads can interact with shared objects.
Thread.sleep()Demonstrates pausing a thread for a fixed duration.
Thread Execution OrderThreads run concurrently, and the exact order depends on the thread scheduler.

Code Output

Good Evening:sachin
Good Evening:sachin
Good Evening:sachin
Good Evening:sachin
Good Evening:sachin
  • The output consists of the greeting repeated five times, with a 2-second pause between each line.

Potential Enhancements

  • Synchronization: If multiple threads access the Display object, synchronization should be added to avoid race conditions. For example:

      public synchronized void wish(String name) {
          // Method implementation
      }
    
  • Thread Safety: The current implementation is safe since only one thread (t1) accesses the Display object. However, in multi-threaded scenarios, thread safety mechanisms like synchronized or ReentrantLock may be needed.

  • Multiple Threads: Adding another thread (e.g., t2) to pass a different name (e.g., "rohit") would show the need for synchronization:

      MyThread t2 = new MyThread(d, "rohit");
      t2.start();
    

Multithreading Example Explanation

Code

class Display {
    public void wish(String name) {
        for (int i = 1; i <= 5; i++) {
            System.out.print("Good Evening:");
            try {
                Thread.sleep(2000); // Pause for 2 seconds
            } catch (InterruptedException e) {
            }
            System.out.println(name);
        }
    }
}

class MyThread extends Thread {
    Display d; // Has-A Relationship
    String name;

    // MyThread Constructor
    MyThread(Display d, String name) {
        this.d = d; // Assign shared Display object
        this.name = name; // Assign user name
    }

    @Override
    public void run() {
        d.wish(name); // Call wish method of Display
    }
}

public class Multithreading51 {
    public static void main(String[] args) {
        // 1. Create an object of Display (shared resource)
        Display d = new Display();

        // 2. Create a thread with the shared Display object and "sachin"
        MyThread t1 = new MyThread(d, "sachin");

        // 3. Start the thread
        t1.start();
    }
}

Detailed Explanation

1. Display Class

  • Purpose: Acts as a shared resource to perform a specific task (wishing the user in this case).

  • Method:

    • wish(String name) prints a greeting message ("Good Evening:") followed by the name.

    • Introduces a 2-second delay between iterations using Thread.sleep(2000).

    • The method executes its logic five times in a loop.

2. MyThread Class

  • Relationship:

    • Demonstrates a Has-A Relationship, where MyThread contains a reference to the Display class.

    • This allows MyThread to call methods of Display and reuse its logic.

  • Constructor:

    • Accepts a Display object (d) and a String (name).

    • Initializes these fields so the run() method can access them.

  • run() Method:

    • Overrides Thread.run() to define the thread's task.

    • Calls the wish() method of the Display class, passing the user's name.

3. Main Method

  1. Line-1: Create a Display object:

    • Acts as a shared resource for threads.
  2. Line-2: Create a MyThread object:

    • Pass the Display object (d) and a name ("sachin") to the constructor.
  3. Start the Thread:

    • t1.start() starts the thread, internally calling its run() method.

    • The run() method calls d.wish("sachin"), executing the logic in the Display class.


Execution Flow

  1. The main method creates a Display object (d).

  2. A MyThread object (t1) is created, passing the Display object and "sachin".

  3. t1.start() triggers the thread execution, calling t1.run().

  4. Inside run(), d.wish("sachin") is executed:

    • Prints "Good Evening:" followed by "sachin".

    • Waits for 2 seconds before repeating the message.

  5. The loop in wish() runs five times, producing the output.


Output

Good Evening:sachin
Good Evening:sachin
Good Evening:sachin
Good Evening:sachin
Good Evening:sachin

Key Concepts

ConceptExplanation
Has-A RelationshipDemonstrates reuse of the Display class logic by having its reference in MyThread.
Thread.sleep()Pauses the thread for a fixed duration (2000 ms or 2 seconds in this case).
Thread ExecutionThe run() method defines the thread's behavior when start() is called.
Shared ResourceThe Display object can be shared between multiple threads (not shown here, but possible).

Enhancements

  1. Using Multiple Threads:

    • Create another thread with a different name to demonstrate concurrent execution:

        MyThread t2 = new MyThread(d, "rohit");
        t2.start();
      
  2. Synchronization:

    • Add synchronization to the wish() method to prevent race conditions if multiple threads access the Display object:

        public synchronized void wish(String name) {
            // Method logic
        }
      

Case-1: Multiple Threads Operating on the Same Java Object

(11) Thread Scheduler:
The Thread Scheduler in Java determines the execution of threads.

  • All threads (main, t1, t2) are created with the same priority because child threads inherit the priority of the parent thread (main).

  • The scheduler employs context switching to allocate CPU time among threads, ensuring that the allocated time to the Java program is fully utilized.

(12) Main Thread:

  • The main thread initializes the Display object and creates t1 and t2 threads using the MyThread class.

  • Once the main thread completes its tasks (like calling start() for t1 and t2), it enters the dead state because no further instructions remain in the main method.

(13) Threads Operating on the Same Object:

  • The Display object (d) is shared by both t1 and t2.

  • Each thread invokes the wish() method on d. Since the method is not synchronized, both threads may access the method simultaneously, leading to unpredictable behavior.


Code Explanation

(14) Thread Creation and Initialization:

  • Line-1: The Display object d is created.

  • Line-2: MyThread objects t1 and t2 are initialized with the Display object and their respective names (sachin and Dhoni).

  • Line-3 and Line-4: Both threads (t1 and t2) are started using the start() method. This creates two child threads in addition to the main thread.

(15) Thread States:

  • After t1.start(), two threads exist:

    • Main Thread

    • Child Thread-1 (t1)

  • After t2.start(), three threads exist:

    • Main Thread

    • Child Thread-1 (t1)

    • Child Thread-2 (t2)

(16) Execution Flow:

  • The wish() method in the Display class contains a for loop that iterates 10 times.

  • Each iteration:

    • Prints "Good Morning: "

    • Sleeps for 2 seconds using Thread.sleep(2000).

(17) Context Switching:

  • While one thread (e.g., t1) is in the sleep state, the thread scheduler switches context to another thread (e.g., t2).

  • This leads to interleaved output where t1 and t2 execute the wish() method alternately.


Output Analysis

(18) Irregular Output:
Since both threads act simultaneously on the same object without synchronization, the output appears unpredictable.
For example:

Good Morning: sachin  
Good Morning: Dhoni  
Good Morning: Dhoni  
Good Morning: sachin
  • Both threads print "Good Morning: " followed by their respective names (sachin or Dhoni).

  • The sequence depends on how the thread scheduler switches between t1 and t2.

(19) Data Inconsistency:

  • Multiple threads accessing the same resource (wish() method of Display) can lead to data inconsistency or conflicts.

  • Synchronization is required to ensure only one thread executes the critical section at a time.


Code Walkthrough

class Display {
    public void wish(String name) {
        for (int i = 1; i <= 10; i++) {
            System.out.print("Good Morning: ");
            try {
                Thread.sleep(2000); // Simulates some processing
            } catch (InterruptedException e) {
                // Handle exception
            }
            System.out.println(name);
        }
    }
}

class MyThread extends Thread {
    Display d;  // Has-A Relationship
    String name;

    MyThread(Display d, String name) {
        this.d = d;
        this.name = name;
    }

    @Override
    public void run() {
        d.wish(name);
    }
}

public class Multithreading52 {
    public static void main(String[] args) {
        Display d = new Display();            // Line-1: Create Display object
        MyThread t1 = new MyThread(d, "sachin"); // Line-2: Create Thread t1
        MyThread t2 = new MyThread(d, "Dhoni");  // Line-2: Create Thread t2

        t1.start(); // Line-3: Start t1
        t2.start(); // Line-4: Start t2
    }
}

Need for Synchronization

(20) Problem Without Synchronization:

  • Threads t1 and t2 can access the wish() method simultaneously, resulting in interleaved output.

  • Synchronization ensures that only one thread can access the critical section (i.e., wish() method) at a time.

(21) Adding Synchronization:
Modify the wish() method in the Display class to include the synchronized keyword:

class Display {
    public synchronized void wish(String name) { // Add synchronized keyword
        for (int i = 1; i <= 10; i++) {
            System.out.print("Good Morning: ");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                // Handle exception
            }
            System.out.println(name);
        }
    }
}

Synchronized Output

(22) Expected Behavior with Synchronization:

  • Thread t1 will complete all 10 iterations of the wish() method before thread t2 starts executing.

  • Output:

Good Morning: sachin  
Good Morning: sachin  
Good Morning: sachin  
...  
Good Morning: Dhoni  
Good Morning: Dhoni  
Good Morning: Dhoni  
...

This ensures consistent and predictable output.

Case 2: Thread Operations with Locks

This section elaborates on how locking mechanisms help manage thread operations on shared resources, ensuring consistency and correctness.


2.1 Printer Example Visualization

  • Imagine a Printer (Display) shared by two departments:

    • Thread t1 (Math Department): Responsible for printing math papers.

    • Thread t2 (Science Department): Responsible for printing science books.

  • Problem Scenario:

    • Both threads (t1 and t2) operate on the same printer simultaneously, causing output overlap and irregular results.

    • For instance, if t1 begins printing "Math Paper: Page 1" while t2 starts printing "Science Book: Chapter 1," the output might look like:

        Math Paper: ScienChapter 1ce Book: Page 1
      
    • Such data inconsistency makes the final output unreliable.


2.2 Problem of Data Inconsistency

  • Definition: Data inconsistency occurs when multiple threads simultaneously access or modify a shared resource without proper synchronization, leading to conflicts.

  • Layman's Analogy:

    • Imagine a biryani plate placed on a road.

    • Two dogs approach from opposite directions and start pulling at the plate simultaneously.

    • The biryani gets messed up because both dogs are acting without coordination.

    • The biryani plate represents the shared resource (e.g., Display), and the two dogs represent t1 and t2.

  • This issue is termed the Biryani Inconsistency Problem in multithreading, where uncoordinated access to shared resources leads to a mess.


2.3 Solution to the Problem

  • The solution is to ensure only one thread operates on the shared object at a time.

  • Analogy: Allow only one dog at a time to eat from the plate. Once it finishes, the next dog can proceed.

  • In programming, this is achieved using locks to coordinate thread access to shared resources.


2.4 Lock Mechanism in Multithreading

Locks ensure thread safety by allowing only one thread to execute a critical section of code at any given time.


2.4.1 How Locks Work
  1. Thread Execution with Locks:

    • Thread t1 Execution:

      • When thread t1 starts, it acquires a lock on the shared resource (e.g., wish() method).

      • This prevents any other thread (t2) from accessing the same resource.

      • While t1 executes, t2 remains in a waiting state.

    • Thread t1 Completes Execution:

      • Once t1 finishes its task, it releases the lock, allowing t2 to proceed.
  2. Thread t2 Execution:

    • After t1 releases the lock, t2 acquires it and begins its task.

    • This process ensures mutual exclusion, preventing both threads from executing simultaneously on the same resource.


2.4.2 Locking in Java

To implement locking in Java, use the synchronized keyword.

  • Lock acquisition:

    • A thread that enters a synchronized method/block automatically acquires the lock.
  • Lock release:

    • The lock is released when the thread exits the synchronized method/block or its execution completes.

2.5 Implementation of Locks

Example: Synchronized Method

class Display {
    public synchronized void wish(String name) { // Lock applied
        for (int i = 1; i <= 5; i++) {
            System.out.print("Good Evening: ");
            try {
                Thread.sleep(2000); // Simulate processing
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted: " + e.getMessage());
            }
            System.out.println(name);
        }
    }
}
  • Explanation:

    • The synchronized keyword ensures only one thread at a time can access the wish() method.

    • Threads t1 and t2 operate sequentially, avoiding data inconsistency.


2.6 Advantages of Locking

  1. Prevents Data Inconsistency:

    • By ensuring mutual exclusion, locks eliminate conflicts and irregularities in thread output.
  2. Thread-Safe Operations:

    • Locks guarantee that threads operate safely on shared resources.

2.7 Disadvantages of Locking

  1. Increased Waiting Time:

    • While one thread holds the lock, others must wait, increasing the overall execution time.
  2. Reduced Performance:

    • Excessive waiting time can lead to poor utilization of CPU resources, degrading performance.

2.8 StringBuffer (Synchronized) vs. StringBuilder (1.5v)

  1. StringBuffer:

    • It is synchronized, meaning at most one thread can operate on it at a time.

    • Ensures thread safety but increases waiting time, leading to reduced performance.

  2. StringBuilder:

    • Introduced in Java 1.5 as a non-synchronized alternative.

    • Suitable when only one thread operates on the resource.

    • Offers better performance due to reduced overhead from synchronization.


2.9 Summary

  • Locks are crucial for managing shared resources in multithreaded environments to avoid data inconsistency.

  • While locks ensure thread safety, they may also lead to performance trade-offs.

  • Use alternatives like StringBuilder when thread safety is not a concern to enhance efficiency.

Explanation of Synchronization in Java


1. What is Synchronization?

  • Synchronization in Java refers to the mechanism that allows control over thread access to shared resources.

  • It ensures that only one thread at a time can access critical sections of code (methods or blocks), which helps prevent issues such as data inconsistency and thread interference.


2. Key Characteristics of Synchronization

  1. synchronized keyword: Applicable to methods and blocks in Java.

    • It locks the shared resource (object) so that only one thread can execute the synchronized block or method at a time.
  2. Prevents Data Inconsistency: Ensures thread-safe operations on shared resources.

  3. Performance Impact:

    • Synchronization increases waiting time for threads, potentially reducing overall system performance.

    • Recommendation: Avoid synchronization unless necessary.


3. How Synchronization Works Internally

  • The synchronization mechanism uses the lock concept internally.

  • Every object in Java has a lock (monitor) associated with it.

    • When a thread enters a synchronized method/block, it acquires the lock on the object.

    • Other threads must wait until the lock is released.

    • Once the thread completes execution, it releases the lock, allowing the next thread to proceed.


4. Why Use Synchronization?

Synchronization is crucial in multithreading for the following reasons:

  1. Prevent Thread Interference: Ensures threads do not interfere with each other's operations on shared resources.

  2. Prevent Consistency Problems: Maintains consistency of data by ensuring controlled access.


Example Program: Demonstrating the Need for Synchronization

Code

// Shared resource class
class Display {
    // Synchronized method to prevent thread interference
    public synchronized void wish(String name) {
        for (int i = 1; i <= 5; i++) {
            System.out.print("Good Morning: ");
            try {
                Thread.sleep(2000); // Simulate a delay
            } catch (InterruptedException e) {
                System.out.println("Thread interrupted: " + e.getMessage());
            }
            System.out.println(name);
        }
    }
}

// Thread class
class MyThread extends Thread {
    Display d; // Has-A Relationship
    String name;

    // Constructor for MyThread
    MyThread(Display d, String name) {
        this.d = d;
        this.name = name;
    }

    @Override
    public void run() {
        d.wish(name); // Access the shared resource
    }
}

// Main class to demonstrate synchronization
public class Multithreading54 {
    public static void main(String[] args) {
        // Create a single shared Display object
        Display d = new Display();

        // Create threads sharing the same Display object
        MyThread t1 = new MyThread(d, "sachin");
        MyThread t2 = new MyThread(d, "Dhoni");

        // Start threads
        t1.start();
        t2.start();
    }
}

Output

Good Morning: sachin
Good Morning: sachin
Good Morning: sachin
Good Morning: sachin
Good Morning: sachin
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni

Explanation of the Program

  1. Shared Resource: The Display class represents the shared resource.

    • The wish(String name) method is declared as synchronized, ensuring that only one thread can execute it at a time.
  2. Thread Creation:

    • t1 and t2 are two threads, both operating on the same Display object (d), demonstrating a Has-A relationship.
  3. Synchronization in Action:

    • When t1 calls d.wish("sachin"), it acquires the lock on the Display object.

    • t2 must wait until t1 completes its execution and releases the lock.

  4. Output Order:

    • Thread t1 completes all iterations before t2 begins execution, ensuring consistent output:

        Good Morning: sachin (x5)
        Good Morning: Dhoni (x5)
      

Important Points to Note

  1. Without Synchronization:

    • If the wish() method is not synchronized, threads t1 and t2 may execute simultaneously, leading to mixed output:

        Good Morning: sachin
        Good Morning: Dhoni
        Good Morning: sachin
        Good Morning: Dhoni
        ...
      
  2. Synchronization and Performance:

    • Synchronization resolves data inconsistency but can increase waiting time, especially in systems with high thread concurrency.
  3. Best Practices:

    • Use synchronization only when necessary to avoid performance bottlenecks.

    • For single-threaded operations, prefer non-synchronized alternatives like StringBuilder over StringBuffer.


Summary

  • Synchronization ensures thread-safe operations on shared resources.

  • The synchronized keyword is crucial for preventing data inconsistency but comes with performance trade-offs.

  • The example demonstrates how synchronization effectively manages concurrent threads to produce consistent results.

Synchronization in Multi-Threaded Scenarios

Case 1: Multiple Threads Operating on a Single Object

  • When multiple threads operate on the same object, synchronization is necessary to prevent:

    1. Thread interference: Where threads overwrite each other’s work.

    2. Data inconsistency: When shared data is modified by different threads simultaneously.

Code Example:

class Display {
    public synchronized void wish(String name) {
        for (int i = 1; i <= 5; i++) {
            System.out.print("Good Morning: ");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name);
        }
    }
}

class MyThread extends Thread {
    Display d; 
    String name;
    MyThread(Display d, String name) {
        this.d = d;
        this.name = name;
    }

    @Override
    public void run() {
        d.wish(name);
    }
}

public class MultithreadingExample {
    public static void main(String[] args) {
        Display d = new Display(); // Single Display object
        MyThread t1 = new MyThread(d, "Sachin");
        MyThread t2 = new MyThread(d, "Dhoni");

        t1.start();
        t2.start();
    }
}

Output (Synchronized):

Good Morning: Sachin
Good Morning: Sachin
Good Morning: Sachin
Good Morning: Sachin
Good Morning: Sachin
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Dhoni

Case 2: Multiple Threads Operating on Multiple Objects

  • When multiple threads operate on multiple objects, synchronization has no impact because each thread works independently on its respective object.

Code Example:

class Display {
    public synchronized void wish(String name) {
        for (int i = 1; i <= 5; i++) {
            System.out.print("Good Morning: ");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name);
        }
    }
}

class MyThread extends Thread {
    Display d; 
    String name;
    MyThread(Display d, String name) {
        this.d = d;
        this.name = name;
    }

    @Override
    public void run() {
        d.wish(name);
    }
}

public class MultithreadingExample {
    public static void main(String[] args) {
        Display d1 = new Display(); // First Display object
        Display d2 = new Display(); // Second Display object

        MyThread t1 = new MyThread(d1, "Sachin");
        MyThread t2 = new MyThread(d2, "Dhoni");

        t1.start();
        t2.start();
    }
}

Output:

Good Morning: Sachin
Good Morning: Dhoni
Good Morning: Sachin
Good Morning: Dhoni
Good Morning: Sachin
Good Morning: Dhoni
Good Morning: Dhoni
Good Morning: Sachin
Good Morning: Sachin
Good Morning: Dhoni

Key Takeaways

  1. Single Object:

    • Synchronization is required to avoid thread interference and data inconsistency.
  2. Multiple Objects:

    • Synchronization isn't required as threads work on different instances, ensuring no shared resource conflict.
  3. Synchronization Overhead:

    • While it resolves data inconsistency, it may impact performance by increasing the waiting time for threads.
  4. Best Practices:

    • Use synchronization only when necessary to maintain a balance between thread safety and performance.

Expanded Explanation on Locks and Synchronization in Java

Synchronization and locks in Java are mechanisms to ensure thread safety when multiple threads access shared resources. This is critical in multithreaded applications to avoid inconsistent or unpredictable outcomes.


1. Locks and Intrinsic Locks (Object Monitor)

In Java, every object has a monitor lock, which is tied to the object's lifecycle. This lock is used to regulate thread access to synchronized methods or blocks:

  • A thread acquires the lock before entering a synchronized method or block.

  • While a thread holds the lock, no other thread can enter any synchronized method on the same object.

  • Once the thread exits the synchronized method, the lock is released, allowing other threads to acquire it.


2. Object-Level Lock vs Method-Level Lock

Locks are tied to objects and not methods. This means:

  • The lock is at the object level.

  • If multiple threads operate on multiple objects, they can access synchronized methods simultaneously since each object has its lock.

Example:

class SharedResource {
    synchronized void methodA() {
        // Critical Section
    }

    synchronized void methodB() {
        // Critical Section
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource obj1 = new SharedResource();
        SharedResource obj2 = new SharedResource();

        Thread t1 = new Thread(() -> obj1.methodA());
        Thread t2 = new Thread(() -> obj2.methodB());

        t1.start();
        t2.start();
    }
}
  • Here, t1 operates on obj1 and t2 operates on obj2. Since these are two different objects, there is no impact of synchronization.

3. Synchronized vs Non-Synchronized Methods

  • Synchronized Methods:

    • These methods require the thread to acquire the object's lock before execution.

    • Only one thread at a time can execute any synchronized method on the same object.

    • Example:

        synchronized void updateResource() {
            // Critical Section
        }
      
  • Non-Synchronized Methods:

    • These methods do not require the lock.

    • Multiple threads can execute them simultaneously without interference.

    • Example:

        void readResource() {
            // Non-Critical Section
        }
      

Scenario with Mixed Synchronization: If a thread holds the lock for a synchronized method, other threads can still execute non-synchronized methods of the same object concurrently.


4. Thread Behavior in Synchronization

Case 1: Multiple Threads, Single Object

  • When multiple threads operate on the same object:

    • If a thread is executing a synchronized method, other threads must wait for the lock to be released.

    • Non-synchronized methods can be executed by any thread without waiting.

Example:

class SharedResource {
    synchronized void synchronizedMethod() {
        System.out.println(Thread.currentThread().getName() + " has acquired the lock.");
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + " has released the lock.");
    }

    void nonSynchronizedMethod() {
        System.out.println(Thread.currentThread().getName() + " is executing non-synchronized method.");
    }
}

public class Main {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread t1 = new Thread(() -> resource.synchronizedMethod());
        Thread t2 = new Thread(() -> resource.synchronizedMethod());
        Thread t3 = new Thread(() -> resource.nonSynchronizedMethod());

        t1.start();
        t2.start();
        t3.start();
    }
}

Case 2: Multiple Threads, Multiple Objects

  • When multiple threads operate on different objects:

    • Synchronization has no effect since each object has its own lock.

Example:

SharedResource obj1 = new SharedResource();
SharedResource obj2 = new SharedResource();

Thread t1 = new Thread(() -> obj1.synchronizedMethod());
Thread t2 = new Thread(() -> obj2.synchronizedMethod());

t1.start();
t2.start();

In this case, t1 and t2 can execute simultaneously because they are working with different locks.


5. Lock Concept Applied to Methods

Synchronized Area:

  • Critical sections where threads must update shared resources (e.g., updating a database, modifying shared variables).

  • Threads must acquire the object's lock to execute these sections.

Non-Synchronized Area:

  • Non-critical sections where threads only read shared resources (e.g., retrieving data, checking availability).

  • Threads do not require the lock, allowing parallel execution.

Example of a Reservation System:

class ReservationApp {
    void checkAvailability() {
        // Non-Synchronized Area: Multiple threads can check availability simultaneously.
    }

    synchronized void bookTicket() {
        // Synchronized Area: Only one thread can book a ticket at a time.
    }
}

6. Rules for Lock and Synchronization

  1. Object Lock:

    • A thread must acquire an object's lock to execute any synchronized method on that object.

    • Other threads must wait if they try to access any synchronized method on the same object.

  2. Releasing the Lock:

    • The lock is released when the synchronized method completes execution or if an exception occurs.
  3. Non-Synchronized Methods:

    • These methods can be accessed by multiple threads even if another thread holds the object's lock.
  4. Lock Scope:

    • The lock applies to the object, not individual methods. All synchronized methods of an object share the same lock.

7. Purpose of Synchronization

  • Synchronization ensures thread safety when threads share and modify common resources.

  • It prevents race conditions, where the outcome of the program depends on the order of thread execution.


8. Lock Limitations

  • Locks cannot be applied to primitive data types.

      int x = 10;
      synchronized(x) {  // Compile-Time Error: Unexpected type
          // Code
      }
    
  • Locks can only be applied to objects and class types.


9. Summary

  • Synchronization is a vital concept to ensure data consistency in multithreaded applications.

  • Use synchronized methods or blocks only for critical sections where shared resources are updated.

  • Non-synchronized methods allow for better performance by enabling concurrent thread execution.

Understanding the Behavior in Detail

In this example, two threads (t1 and t2) are operating on the same object of the Display class without any synchronization. This results in unpredictable and interleaved output.


Concept Breakdown

  1. Threads and Concurrent Execution:

    • In multithreading, multiple threads can execute methods concurrently.

    • If no synchronization is applied, the CPU schedules thread execution independently, based on its internal time-slicing mechanism.

    • This allows different threads to interleave their outputs, leading to irregular behavior.

  2. Methods in the Display Class:

    • displayChar(): Prints characters from 'A' to 'K' with a delay of 2 seconds for each character.

    • displayNumber(): Prints numbers from 1 to 5 with a delay of 2 seconds for each number.

  3. Thread Setup:

    • Thread t1 calls the displayNumber() method.

    • Thread t2 calls the displayChar() method.

    • Both threads share the same Display object and run concurrently.

  4. Lack of Synchronization:

    • Because the synchronized keyword is not used, the methods are not protected from simultaneous execution.

    • This means both t1 and t2 can access their respective methods at the same time.

    • As a result, the output from displayChar() and displayNumber() interleaves.


Code Analysis

Here’s the critical portion of the code:

Display Class

class Display {
    public void displayChar() {
        for (int i = 65; i <= 75; i++) {
            System.out.print((char) i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }

    public void displayNumber() {
        for (int i = 1; i <= 5; i++) {
            System.out.print(i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }
}

Thread-1 (MyThread1)

class MyThread1 extends Thread {
    Display d;

    MyThread1(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayNumber(); // Thread t1 executes this method
    }
}

Thread-2 (MyThread2)

class MyThread2 extends Thread {
    Display d;

    MyThread2(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayChar(); // Thread t2 executes this method
    }
}

Main Class

public class Multithreading58 {
    public static void main(String[] args) {
        Display d1 = new Display(); // Shared display object

        // Two threads operating on the same object
        MyThread1 t1 = new MyThread1(d1);
        MyThread2 t2 = new MyThread2(d1);

        t1.start(); // Start Thread-1
        t2.start(); // Start Thread-2
    }
}

How the Threads Behave

  1. Thread-1 (t1):

    • Calls displayNumber() and starts printing numbers 1, 2, 3, 4, 5 with a 2-second delay between each.
  2. Thread-2 (t2):

    • Calls displayChar() and starts printing characters A, B, C, ... K with a 2-second delay between each.
  3. Concurrent Execution:

    • Since the methods are not synchronized, both threads are allowed to run concurrently.

    • For example:

      • t1 may print 1.

      • Then t2 prints A.

      • t1 continues with 2, and t2 may print B.

    • This interleaved execution causes the output to appear irregular.


Output Example

The output is not deterministic because it depends on the CPU’s thread scheduling. However, a sample output could look like this:

A1B2C3D4E5FGHIJK

Explanation:

  • t1 (printing numbers) and t2 (printing characters) take turns running.

  • There is no fixed order in which the threads execute, leading to the output being mixed up.


Why the Output is Irregular?

  1. Thread Scheduling:

    • The JVM’s thread scheduler determines when each thread gets CPU time.

    • It uses a mechanism like time-slicing or preemptive scheduling to allocate execution time for threads.

  2. No Synchronization:

    • Since the methods are not synchronized, both threads can access their respective methods simultaneously.
  3. Sleep Method:

    • The Thread.sleep(2000) call introduces a 2-second delay after printing each character or number.

    • However, this delay does not block the other thread, which continues execution.


How to Fix Irregular Output?

To ensure that the output from displayNumber() and displayChar() does not interleave, we need to use the synchronized keyword.


Solution: Synchronize Methods

By adding the synchronized keyword to the methods, only one thread will be allowed to access the methods of the Display class at a time.

Modified Display Class:

class Display {
    public synchronized void displayChar() {
        for (int i = 65; i <= 75; i++) {
            System.out.print((char) i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }

    public synchronized void displayNumber() {
        for (int i = 1; i <= 5; i++) {
            System.out.print(i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }
}

Result with Synchronization

With synchronization:

  • t1 will complete printing all numbers 1, 2, 3, 4, 5 first.

  • Only after t1 completes, t2 will print the characters A, B, C, ... K.

Output:

12345ABCDEFGHIJK

Conclusion

  • Without synchronization, methods can be accessed concurrently, leading to mixed and irregular output.

  • By adding synchronized, we ensure that one thread completes its task before another thread begins execution.
    This guarantees a sequential and predictable output.

Understanding the Behavior with Synchronized Methods: Object Level Lock

In this example, the methods displayNum() and displayChar() in the Display class are declared as synchronized, which ensures that only one thread can access these methods at a time. Here, an object-level lock is used, meaning that threads must acquire a lock on the shared object Display to execute its synchronized methods.


Concept Breakdown

  1. Synchronized Methods:

    • Declaring a method as synchronized ensures that only one thread can execute that method at a time.

    • The thread that invokes the method acquires a lock on the object (this), and no other thread can access any other synchronized method of the same object until the lock is released.

  2. Object-Level Lock:

    • The lock is associated with the object (Display instance here).

    • When a thread acquires the lock:

      • It executes the entire synchronized method without interruption from other threads.

      • Other threads attempting to execute synchronized methods on the same object must wait until the lock is released.

  3. Thread Execution:

    • Thread t1 invokes displayNum() and acquires the lock on the shared Display object (d1).

    • Thread t2 invokes displayChar() but cannot execute it immediately because the lock is already held by t1.

    • Once t1 completes the displayNum() method, it releases the lock, allowing t2 to acquire the lock and execute displayChar().


Code Analysis

Display Class (with synchronized methods):

class Display {
    public synchronized void displayChar() {
        for (int i = 65; i <= 75; i++) {
            System.out.print((char) i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }

    public synchronized void displayNum() {
        for (int i = 1; i <= 5; i++) {
            System.out.print(i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }
}
  • The methods displayChar() and displayNum() are synchronized, ensuring that only one thread can execute either method at a time.

Thread Classes:

class MyThread1 extends Thread {
    Display d;

    MyThread1(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayNum(); // Thread-1 calls displayNum()
    }
}

class MyThread2 extends Thread {
    Display d;

    MyThread2(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayChar(); // Thread-2 calls displayChar()
    }
}
  • MyThread1: Runs the displayNum() method.

  • MyThread2: Runs the displayChar() method.

Both threads operate on the same Display object (d1).


Main Method:

public class Multithreading59 {
    public static void main(String[] args) {
        Display d1 = new Display(); // Shared Display object

        // Two threads operating on the same object
        MyThread1 t1 = new MyThread1(d1);
        MyThread2 t2 = new MyThread2(d1);

        t1.start(); // Start Thread-1
        t2.start(); // Start Thread-2
    }
}
  • Both t1 and t2 share the same Display object d1.

  • Since the methods are synchronized, only one thread can execute a method at a time.


Execution Flow

  1. Thread-1 (t1) starts and acquires the lock on d1 to execute displayNum():

    • Prints numbers 1, 2, 3, 4, 5 with a 2-second delay between each.
  2. Thread-2 (t2) tries to acquire the lock to execute displayChar():

    • t2 is blocked because t1 holds the lock.
  3. Once t1 completes the displayNum() method:

    • t1 releases the lock.
  4. Thread-2 (t2) now acquires the lock and executes displayChar():

    • Prints characters A, B, C, ... K with a 2-second delay between each.

Output

The output will be sequential because of the synchronization:

12345ABCDEFGHIJK

Explanation:

  • 12345 is printed first because t1 acquires the lock and executes displayNum() completely.

  • After t1 completes, ABCDEFGHIJK is printed because t2 acquires the lock and executes displayChar() completely.


Key Observations

  1. Synchronized Methods:

    • Ensure that only one thread can execute synchronized methods of a shared object at a time.
  2. Object-Level Lock:

    • Synchronization works at the object level (d1), meaning all synchronized methods of that object share the same lock.
  3. Thread Blocking:

    • If one thread holds the lock, other threads trying to execute synchronized methods on the same object are blocked until the lock is released.

How Object-Level Lock Works Internally

  1. When a thread invokes a synchronized method:

    • It acquires the object lock on the instance of the class.
  2. While the lock is held:

    • Other threads trying to call any synchronized method on the same object must wait.
  3. After completing the method:

    • The thread releases the lock, allowing other threads to acquire it.

Conclusion

In this example:

  • Synchronization ensures that displayNum() and displayChar() are executed sequentially, one after the other.

  • This eliminates the irregular, interleaved output seen in Example-1 where no synchronization was used.

By using synchronized methods, the output is predictable and orderly:

12345ABCDEFGHIJK

Static Synchronized Methods: Class Level Lock

In this example, the methods displayNum() and displayChar() are declared as static synchronized. Unlike instance-level locks (object-level locks), static synchronized methods apply class-level locks. This ensures that only one thread can execute any static synchronized method across all instances of the class.


Key Concepts

  1. Static Synchronized Methods:

    • When a method is declared as static synchronized, the lock is not acquired on an instance (object) but on the class object (Class level).

    • This means the lock is shared across all instances of the class.

  2. Class-Level Lock:

    • The lock is acquired on the Class object of the class (Display.class in this case).

    • If one thread acquires the class-level lock, all other threads trying to access any static synchronized method of that class are blocked until the lock is released.

  3. Thread Behavior:

    • Since the displayNum() and displayChar() methods are static synchronized:

      • Only one thread can execute either method at any time.

      • If one thread executes displayNum(), other threads trying to execute displayChar() or displayNum() must wait.


Code Analysis

Display Class (with static synchronized methods):

class Display {
    public static synchronized void displayChar() {
        for (int i = 65; i <= 75; i++) {
            System.out.print((char) i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }

    public static synchronized void displayNum() {
        for (int i = 1; i <= 5; i++) {
            System.out.print(i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }
}
  • Both displayNum() and displayChar() are static synchronized.

  • They acquire a class-level lock (Display.class).


Thread Classes:

class MyThread1 extends Thread {
    Display d;

    MyThread1(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayNum(); // Thread-1 calls static synchronized displayNum()
    }
}

class MyThread2 extends Thread {
    Display d;

    MyThread2(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayChar(); // Thread-2 calls static synchronized displayChar()
    }
}
  • Thread-1 (MyThread1): Calls displayNum().

  • Thread-2 (MyThread2): Calls displayChar().


Main Method:

public class Multithreading60 {
    public static void main(String[] args) {
        // Create a single Display object
        Display d1 = new Display();

        // Create two threads using the same Display object
        MyThread1 t1 = new MyThread1(d1);
        MyThread2 t2 = new MyThread2(d1);

        // Start threads
        t1.start();
        t2.start();
    }
}
  • Shared Object (d1): Both t1 and t2 operate on the same Display object.

  • However, since the methods are static synchronized, the lock is acquired on the class (Display.class), not on the object (d1).


Execution Flow

  1. Thread-1 (t1) starts and calls the displayNum() method:

    • Acquires the class-level lock (Display.class).

    • Prints numbers 1, 2, 3, 4, 5 with a 2-second delay between each.

  2. Thread-2 (t2) calls the displayChar() method:

    • It cannot execute immediately because the class-level lock is held by t1.
  3. Once Thread-1 (t1) completes the displayNum() method:

    • Releases the class-level lock.
  4. Thread-2 (t2) acquires the class-level lock and executes displayChar():

    • Prints characters A, B, C, ... K with a 2-second delay between each.

Output

The output will be sequential due to the class-level lock:

12345ABCDEFGHIJK

Explanation:

  • Thread-1 acquires the class-level lock first and completes displayNum() → Prints 12345.

  • Thread-2 acquires the lock next and completes displayChar() → Prints ABCDEFGHIJK.


Key Observations

  1. Static Synchronized Methods:

    • Use a class-level lock (Class object) instead of an instance-level lock.

    • The lock is shared across all instances of the class.

  2. Class-Level Lock:

    • Ensures that only one thread can execute any static synchronized method of the class at a time, regardless of the instance.
  3. Thread Blocking:

    • While one thread holds the class-level lock, other threads trying to access any static synchronized method must wait.

Difference Between Object-Level and Class-Level Locks

AspectObject-Level LockClass-Level Lock
Lock TypeLock on an instance (object).Lock on the Class object.
ScopeOne object at a time.Shared across all objects of a class.
Synchronized MethodNon-static synchronized methods.Static synchronized methods.
ConcurrencyAllows multiple threads to execute synchronized methods on different objects.Ensures only one thread executes static synchronized methods across all objects.

Conclusion

In this example:

  • Static synchronized methods use a class-level lock, ensuring sequential execution of the displayNum() and displayChar() methods.

  • The output is predictable and orderly:

12345ABCDEFGHIJK

Static Synchronized vs Synchronized Methods

In this example, one method requires an object-level lock (synchronized) and the other method requires a class-level lock (static synchronized). Since these two locks are independent of each other, threads operating on these methods do not block each other.


Key Points

  1. Synchronized Method:

    • Acquires an object-level lock.

    • Only one thread can execute this method on a given object at a time.

    • Other threads can execute the same method on different objects simultaneously.

  2. Static Synchronized Method:

    • Acquires a class-level lock.

    • This lock is shared across all instances of the class.

    • If one thread holds this lock, no other thread can execute any static synchronized method of the same class.

  3. Independence of Locks:

    • The object-level lock and class-level lock are completely independent.

    • A thread executing a static synchronized method does not block another thread executing a non-static synchronized method.

  4. Irregular Output:

    • Since the two locks are independent, both threads can run their methods simultaneously.

    • This leads to irregular interleaving of output, depending on the thread execution order.


Code Walkthrough

Display Class:

class Display {
    public static synchronized void displayChar() { // Class-level lock
        for (int i = 65; i <= 75; i++) { // ASCII 'A' to 'K'
            System.out.print((char) i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }

    public synchronized void displayNum() { // Object-level lock
        for (int i = 1; i <= 5; i++) {
            System.out.print(i);
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                System.out.println(e);
            }
        }
    }
}
  • displayChar():

    • Declared as static synchronizedClass-level lock.

    • Acquires lock on Display.class.

  • displayNum():

    • Declared as synchronizedObject-level lock.

    • Acquires lock on the instance (d1).


Thread Classes:

class MyThread1 extends Thread {
    Display d;

    MyThread1(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayNum(); // Calls object-level synchronized method
    }
}

class MyThread2 extends Thread {
    Display d;

    MyThread2(Display d) {
        this.d = d;
    }

    @Override
    public void run() {
        d.displayChar(); // Calls static synchronized method
    }
}
  • Thread-1 (MyThread1) calls displayNum() → acquires object-level lock.

  • Thread-2 (MyThread2) calls displayChar() → acquires class-level lock.

Since the locks are independent:

  • Both threads can execute their respective methods simultaneously.

Main Method:

public class Multithreading61 {
    public static void main(String[] args) {
        Display d1 = new Display(); // Shared object

        // Two threads
        MyThread1 t1 = new MyThread1(d1); // Calls displayNum()
        MyThread2 t2 = new MyThread2(d1); // Calls displayChar()

        t1.start();
        t2.start();
    }
}

Execution Flow

  1. Thread-1 (t1) starts and acquires the object-level lock on d1.

    • Executes displayNum() → prints numbers (1, 2, 3, ...).
  2. Thread-2 (t2) starts and acquires the class-level lock.

    • Executes displayChar() → prints characters (A, B, C, ...).
  3. Since the two locks are independent:

    • Both methods can execute simultaneously, leading to an irregular output.

    • Example:

        A1B23C4DE5FGHIJK
      

Output Analysis

Irregular Output:

A1B23C4DE5FGHIJK
  • Explanation:

    • A is printed by Thread-2 (displayChar()).

    • 1 is printed by Thread-1 (displayNum()).

    • B and 2 are printed in quick succession as both threads continue execution independently.

    • The interleaving happens unpredictably because both threads run concurrently without blocking each other.


Key Takeaways

  1. Static Synchronized vs Synchronized:

    • static synchronizedclass-level lock.

    • synchronizedobject-level lock.

  2. Independent Locks:

    • A thread holding a class-level lock does not block threads trying to acquire an object-level lock and vice versa.
  3. Concurrency:

    • Since the locks are independent, both methods can execute simultaneously, causing irregular outputs.
  4. Output Behavior:

    • The order of output depends on the CPU scheduler and thread execution timing.

Difference Between Object-Level and Class-Level Locks

AspectObject-Level LockClass-Level Lock
ScopeLock on an instance (object).Lock on the Class object.
Method TypeNon-static synchronized methods.Static synchronized methods.
ConcurrencyAllows multiple threads to execute methods on different objects.Only one thread can execute static synchronized methods at a time.

Conclusion

This example demonstrates:

  • Independent nature of object-level and class-level locks.

  • How two threads can execute different methods simultaneously.

  • The resulting irregular interleaving of output.

This explanation dives deep into synchronized blocks in Java. Here's a concise and clean version of the points with examples and explanations:


Why Use Synchronized Blocks?

  1. Problem with Entire Method Synchronization:
    If a method is declared synchronized, the entire method becomes a critical section. This causes a performance bottleneck, especially if only a few lines of code (a specific region) are problematic.

     synchronized void m1() { 
         // entire method locked
         region1();
         region2(); // only this needs to be synchronized
         region3();
     }
    
  2. Solution with Synchronized Block:
    A synchronized block allows us to lock only a specific section of the code. This improves performance because:

    • Only the critical section (problematic region) is locked.

    • Other parts of the code can be executed by other threads simultaneously.


Levels of Locks in Synchronized Block

  1. Current Object Lock (this)
    Locks the current object.

     synchronized (this) {
         // Critical section
     }
    
  2. Class-Level Lock (ClassName.class)
    Locks the entire class. This is useful for static members or methods.

     synchronized (Display.class) {
         // Critical section for class-level lock
     }
    
  3. Specific Object Lock
    Locks a particular object passed as a reference.

     synchronized (objectReference) {
         // Critical section
     }
    

Code Example: Demonstrating Synchronized Block

Example-1: Locking Specific Object

class Display {
    void displayNumbers() {
        for (int i = 1; i <= 5; i++) {
            synchronized (this) { // Locking only this region
                System.out.print(i + " ");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

class MyThread extends Thread {
    Display d;
    MyThread(Display d) {
        this.d = d;
    }

    public void run() {
        d.displayNumbers();
    }
}

public class MultithreadingExample {
    public static void main(String[] args) {
        Display d1 = new Display();
        Display d2 = new Display();

        MyThread t1 = new MyThread(d1);
        MyThread t2 = new MyThread(d2);

        t1.start();
        t2.start();
    }
}

Output:
Threads will execute displayNumbers() independently on their objects because the lock is on this (current object).


Example-2: Class-Level Lock

class Display {
    static void displayCharacters() {
        synchronized (Display.class) { // Class-level lock
            for (char c = 'A'; c <= 'E'; c++) {
                System.out.print(c + " ");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

class MyThread1 extends Thread {
    public void run() {
        Display.displayCharacters();
    }
}

class MyThread2 extends Thread {
    public void run() {
        Display.displayCharacters();
    }
}

public class ClassLevelLockExample {
    public static void main(String[] args) {
        MyThread1 t1 = new MyThread1();
        MyThread2 t2 = new MyThread2();

        t1.start();
        t2.start();
    }
}

Output:
Characters will print one at a time because both threads compete for the class-level lock.


Example-3: Error with Primitive Types

Synchronized blocks require an object reference, not a primitive type.

public class MultithreadingExample {
    public static void main(String[] args) {
        int x = 48;
        synchronized (x) { // Error: x is a primitive
            System.out.println(x);
        }
    }
}

Output:

Exception in thread "main" java.lang.Error: Unresolved compilation problem:
   int is not a valid type's argument for the synchronized statement

Corrected Code:

public class MultithreadingExample {
    public static void main(String[] args) {
        Integer x = 48; // Wrapper class
        synchronized (x) {
            System.out.println(x);
        }
    }
}

Output:

48

Summary

  • Use synchronized blocks for performance optimization when only specific regions of code need synchronization.

  • Choose the correct lock:

    • Current object (this) for instance-level locking.

    • Class-level lock (ClassName.class) for static regions.

    • Specific object (objectReference) for fine-grained locking.

  • Primitive types cannot be used as locks. Use wrapper classes instead.

Conclusion:

In this chapter, we explored some of the most essential aspects of multithreading synchronization in Java. Understanding how to manage thread execution and shared resources is vital for building robust, scalable applications. From interrupting threads to using synchronized methods and blocks, you now have a solid foundation in ensuring thread safety and data consistency in your programs.

Here’s a recap of the key points covered:

  • Interrupting Threads: We saw how the interrupt() method can be used to gracefully stop threads, allowing your program to respond to external events without causing resource leaks or undefined behavior.

  • Static Synchronized Methods: By locking methods at the class level, we ensured that only one thread at a time can access a shared resource, which is crucial for preventing concurrency issues.

  • Combining Static and Instance Synchronized Methods: Understanding the interplay between class-level and object-level locks helped us see how they can work independently or create issues when not managed carefully.

  • Synchronized Blocks: We learned that locking just the critical sections of code using synchronized blocks instead of entire methods can significantly improve performance, allowing for more efficient thread execution.

  • Types of Synchronization Locks: Whether locking an object, a class, or a specific region of code, we explored various ways of controlling access to shared resources, ensuring that threads don’t collide and lead to data inconsistency.

  • Wrapper Classes in Synchronization: We also covered the need for using wrapper classes like Integer instead of primitive types for synchronization to avoid compilation errors, ensuring that our locks are applied correctly.

Through these topics, we’ve addressed the core challenges that arise in multithreading environments and provided solutions to manage them effectively. By applying these techniques, you can write code that not only performs well but also handles concurrency issues safely.

As you continue your journey with Java multithreading, keep these concepts in mind to enhance both the functionality and efficiency of your applications. In future chapters, we will explore more advanced topics and patterns in multithreading, but for now, you should have a strong understanding of how synchronization works and how to leverage it in your projects.


Other Series:


Connect with Me
Stay updated with my latest posts and projects by following me on social media:

  • LinkedIn: Connect with me for professional updates and insights.

  • GitHub: Explore my repository and contributions to various projects.

  • LeetCode: Check out my coding practice and challenges.

Your feedback and engagement are invaluable. Feel free to reach out with questions, comments, or suggestions. Happy coding!


Rohit Gawande
Full Stack Java Developer | Blogger | Coding Enthusiast