Mastering Multithreading in Java: Part 2 - Creation, Join, and Daemon

Mastering Multithreading in Java: Part 2 - Creation, Join, and Daemon

Recap of Multithreading Fundamentals

In the previous article, we started discussing multithreading. We mentioned that creating a new thread means creating a new stack within a single process, resulting in multiple stacks within one process that share all program components except the stack itself. We also discussed that multithreading and multiprocessing are two forms of multitasking, each with its own advantages and disadvantages.

If different threads work with completely different data, it may be best to treat them as separate processes to prevent interference. This distinction is the basis for deciding whether to use different threads or different processes. When considering multithreading, threads should be viewed as integral parts of one system. For instance, consider the human body—breathing, blood circulation, thinking, and other physiological processes occur simultaneously and share common resources (internal organs)—a perfect example of multithreading. We also touched on the need for synchronization when threads access shared resources, to prevent them from interfering with each other. Returning to our analogy, if I breathe and drink simultaneously, I will likely choke.

On the other hand, in multiprocessing, all data is different, and from a physical standpoint, it’s located in different places. To exchange data, we need communication channels, such as sockets or shared memory, without which processes cannot communicate. This communication challenge is eliminated for threads, as they share most of the memory within a process, except for individual stacks. However, synchronization in multiprocessing is generally simpler, as the operating system often provides intuitive mechanisms or automatic synchronization for communication devices. In contrast, inter-thread communication frees us from the need for additional devices but requires us to handle synchronization ourselves. Over the next few articles, we’ll explore the main methods of synchronization and the tools that help solve this challenge.

Now, let’s discuss how threads are represented in Java, specifically through the Thread class. The unique aspect of this class is that its objects are not just virtual entities in memory but also represent a real stack within the operating system, making a Thread object a fully-fledged living entity.


Understanding Thread class

Threads are fascinating entities. In programming, we typically work with abstractions. For example, what is a List? It’s a collection of sections of RAM in our process that contains information about objects. Sometimes, however, we encounter objects that represent something very specific and tangible in the real world, acting as a bridge that allows us to interact with that object. For instance, an OutputStream is not just an abstract entity; it’s a real communication channel that exists between the JVM and our operating system. Similarly, no matter how many OutputStream objects we create, that many real channels will exist. The same logic applies to Thread. Unlike an object of the Person class, which is just a piece of data in memory, a Thread object corresponds to a real stack inside our process. This makes it vital to understand that a Thread object has specific, sequential states.

From a high-level view (which we’ll explore in detail in future articles), the Thread life cycle has three main states:

  1. New - This is when a Thread object is created. At this point, it’s just a piece of data in memory, no different from an object of the Person class.
  2. Runnable - After creation, we can launch the thread using its start() method. At this stage, specific actions occur: a physical stack is created within our process, and the Thread object describes this stack. The thread is now in the Runnable state, meaning it’s ready for execution. The thread asks the JVM for access to the processor, entering the “ready” substate. When it’s granted processor time, it performs its task, entering the “running” substate. Since threads are not given constant access to the processor, the OS rapidly switches between processes and threads, so these two substates can change many times while the thread remains runnable.
  3. Terminated - Once the thread completes its task, the physical stack disappears, leaving only the Thread object, which is now just a piece of memory. This state is called Terminated.


Now that we’ve examined the Thread object from a bird’s eye view, understanding that it represents a separate stack within a single process, let’s move on to how to interact with such an object.


Creation

When creating a thread, two important methods should be understood:

  • run() - This is an empty method that you must override to define the thread’s task, which will be placed on its execution stack.
  • start() - This method is called on your thread to create a separate stack and start execution on it.

These methods are crucial because creating a separate stack in a process involves a direct call to the operating system, which the JVM handles internally. We can’t create a stack directly from the code, which is why the start() method is pre-written for us. Its job is to create a stack in the process and then start executing everything written in the run() method on that stack.

A common question arises: can you call run() yourself? Yes, you can, but you need to understand that if you create a Thread and call run() on it instead of start(), a new stack won’t be created, and everything will execute on the calling stack.

Imagine you create thread1 .... thread10 from your main(String[] args) method, expecting 11 threads (10 new ones and the main thread running main()). Instead, all 10 created threads will execute sequentially in the main thread. This is why the start() method exists—remember, run() is only for defining the task, not starting it.

To create your own thread, you can either extend the Thread class:

class MyThread extends Thread {

    @Override
    public void run() {
        // thread task
    }
}

Thread thread = new MyThread();
thread.start();        

Or implement the Runnable interface:

class MyRunnable implements Runnable {

    @Override
    public void run() {
        // thread task
    }
}

Thread thread = new Thread(new MyRunnable());
thread.start();        

You might wonder where Runnable comes from and why we have a second method. Runnable is a functional interface, and in line with OOD principles, Java developers designed Thread to implement Runnable while also containing a Runnable data member that can be passed through the constructor. This gives you a choice: either create a separate class/anonymous class and override the run() method or pass a small lambda to make the code more readable.

Moreover, the ability to create functionality (Runnable) and execute it (Thread) in different parts of the program enhances code reusability. This capability underpins advanced multithreading tools, which we’ll cover in later articles.

So how does it work? It’s simple. Each Thread contains a Runnable variable that can be passed through the constructor. Since Thread itself implements Runnable, it also has a run() method that looks something like this:

@Override
public void run() {

    if (runnable != null) {
        runnable.run();
    }
}        

Therefore, if you create a thread using Runnable, its run() method will be called. But if you extend Thread, you simply override its run() method with your logic.


Join

Imagine your program has two different threads, and the first must wait for the second to complete. This is common when one thread needs data calculated by another. However, since we can’t predict the execution speed or order, there’s no guarantee the data will be ready when thread1 needs it. What can you do?

For this, there’s the join() method. This method is straightforward: join() puts the calling thread to sleep until the thread passed to it completes its work. Let’s break this down:

  1. You have two threads, thread1 and thread2.
  2. While designing your program, you realize that thread1 needs data from the variable sum, but sum is empty. Thread2 is responsible for calculating this data. Since you can’t be sure whether thread2 will have finished by the time thread1 needs sum, you need a way to ensure that sum is calculated before thread1 proceeds.
  3. The simplest solution is to make thread1 wait until thread2 finishes. To do this, you call thread2.join() from within thread1. This tells the scheduler to put thread1 to sleep until thread2 completes its task.

The syntax of thread2.join() can be unintuitive for beginners. It might seem that thread2.join() would put thread2 to sleep, but it doesn’t. Instead, join() acts on the stack of the thread that calls it. So, in the object.join() construct, object is the thread we wait for, while the thread that calls join() goes to sleep. If you write this in thread1, it means thread1 will sleep. If you write it in your main(String[] args) method, it means the main thread will sleep.

Now, here’s a quick test: what happens if a thread calls join() on itself? In other words, this.join()? It puts itself to sleep until it finishes its work. But since a sleeping thread doesn’t work, it enters eternal hibernation—a phenomenon called self-deadlock. We’ll discuss deadlocks in more detail in later articles.


Daemon Threads

The last topic I’d like to cover in this article is the life cycle of a process in Java. How long does a process live? The most intuitive answer is: until all threads finish executing. This answer is generally correct, with one exception. Threads are divided into two types:

  1. User threads
  2. Daemon threads

A user thread is a classic thread, as we discussed earlier. It performs its task, and the process will exist as long as this task continues. It doesn’t matter how many of these threads there are; whether it’s the main thread (the thread in which main(String[] args) runs, conceptually no different from other threads), or threads created from it, the process persists until all user threads have completed.


What is daemon?

There is also a category of tasks that must be executed in the background, but only as long as user threads are running. Once the main functionality is completed, these background tasks are no longer needed.

Consider an example: we have a thread that writes the running time of our program to the console every 5 seconds, just for informational purposes. This is useful as long as the program is running and the process is alive. However, once the program ends, we don’t need it to continue logging the time. Such threads in Java are known as daemon threads or detached threads.

The term “daemon” comes from Unix systems, where it refers to processes that automatically launch when the operating system starts and run continuously in the background. In Windows, such processes are called services. Typically, these are background tasks that are invisible to the user, performing useful work quietly as long as the program is running.

In Java, if you create a thread and set it as a daemon (using the .setDaemon(true) method), it means this thread won’t prevent the program from terminating. One crucial point to remember is that you can set a thread as a daemon or regular thread only before starting it. Once a thread has been started, its type cannot be changed for the duration of its life. Attempting to do so will result in an IllegalThreadStateException.


Conclusion: Harnessing the Power of Java Multithreading

In this exploration of Java’s multithreading capabilities, we’ve uncovered the intricacies of thread creation, the utility of the join() method, and the distinct role of daemon threads. Understanding these concepts is crucial for developing robust, efficient Java applications that make the most of concurrent processing.

Threads are integral to multitasking within a program, representing real, executable entities that interact with the operating system. By properly utilizing the start() and run() methods, developers can effectively manage these threads, ensuring that tasks are executed independently and in parallel. The join() method adds another layer of control, allowing one thread to wait for another to complete, which is vital for tasks that depend on the output of others.

Daemon threads, with their ability to run background tasks without hindering the termination of the application, offer a flexible approach to managing non-essential processes. These threads, once set as daemons, gracefully conclude their operations as soon as all user threads have finished, ensuring that resources are not wasted on unnecessary tasks.

As we continue our journey through the world of multithreading in Java, remember that mastering these tools and techniques will empower you to create applications that are not only efficient but also reliable and scalable. Proper thread management, synchronization, and a deep understanding of how threads interact with each other and the operating system are key to avoiding common pitfalls and unlocking the full potential of Java’s multithreading capabilities. In the upcoming articles, we’ll delve further into these topics, exploring advanced synchronization techniques and the challenges of concurrent programming.


Previously Covered Topics in This Series:

Netanel Stern

CEO and security engineer

2 个月

???? ??? ?? ?? ?????? ??????? ??? ???? ???? ????? ???? ?????? ???: https://chat.whatsapp.com/HWWA9nLQYhW9DH97x227hJ

回复

要查看或添加评论,请登录

社区洞察

其他会员也浏览了