In this course, we will learn the concepts of Java EE 7 with a focus on Concurrency Utilities with Threads, Semaphore, Phaser and other methods, and Transactions.
Learning Objectives
- Concurrency Utilities
Intended Audience
- Anyone looking to get Oracle Java Certification
- Those who want to improve Java 7 EE knowledge
- Java developers
Prerequisites
- Have at least 2 years of Java development experience
Hello there. In this lesson, we'll talk about threads and multi-threads in Java. So, let's start. Thread can be expressed as a thread that can work concurrently with other threads. There are usually two simple ways to create threads. The first method is to create a class that extends the thread class and implement the run method and encode the work we want the thread to do in this run method. Let's move on to the Eclipse. We'll create a Java class in this project. Right click to show the context menu and choose the menu item 'New and class'. We must specify the class name. And now we can add another class. Now, let's create a class with the main method to test the operation of our thread. Let's create a runner instance variable and run the thread with the start method. The output will be like this. And attention here, we use the start method instead of the run method to run the thread. If we called the run method, the process is expressed by the runner would run in the current thread, that is, in the main thread. running the main method instead of running in a separate thread.
When we call the start method, the process is expressed by the runner are run in its own thread. No surprises so far. Even if we wrote that loop that we wrote in run into the main method, we would get the same output. Let's do a multi-thread example to see the difference. We created two threads in the main method and started them one after the other. This time the output will be similar to the following. Because of the concurrency, you may find that the output changes slightly each time you run it. The output will be like this. Let's see how the threads work independently of each other by editing the for loop in the run method to print the name of the thread and working simultaneously without waiting for each other. The output will be like this. As can be seen from the output, thread-1 i.e. runner2, did not wait for thread-0, that is, runner1 to run. This is what makes concurrent programming important. Namely, processor cores ran the processes aligned to them independently of each other simultaneously.
Thus, the work that could be normally completed in 2T with a single core was completed in T time. Returning to our previous warning, if we use the run method instead of the start, the jobs contained in the two runner instance variables would always be run sequentially since the process would be run in the current thread instead of in separate threads. In other words, runner1 would run first, then runner2. So, for a main method like this, the output would be like this. The second method of creating and starting a thread is to parse an instance of a class that implements the runnable interface to the thread constructor. Let's move on to the Eclipse. The runnable interface contains a single method signature. In this case, our main method will change too. You have noticed that we parse an example of a class that implements the runnable interface to the appropriate constructor of the thread class to create new threads.
When the main method above is executed, since the threads will run simultaneously, our output will still be irregular like this. If we are going to use the runnable implementation once, we can also use the anonymous class. The arrangement of data access by more than one thread detailing with the same data is called synchronization of threads. The first of the difficulties we encounter when synchronizing threads is in some cases the automatic caching of data for performance reasons. Let us explain this situation that we have to overcome with an example. Let our processor class which we created by extending the thread class be as follows. If we examine the content of the run method, we can see that when we create an instance of this class and call the start method, the thread to be started will print "Hello forever" because the running variable is always true. Let's add a new method, and when it's called let's end the while loop by setting the value of the running variable to false so the thread is also terminated. Let our main methods start as a processor type thread. And after this thread starts, let its own thread wait 10 seconds and then call the shutdown method and want to terminate the processor type thread it started. The danger here is that on some systems, the processor thread automatically caches its value.
Caching based on the assumption that the running variable will not be changed by any outside intervention in some Java implementations and it always acts as if its value is true. For this situation, English terms such as caching thread locally are used. If this caching situation will cause inconsistencies for our program, for example, if the thread is not terminated as above, the remedy is to use the keyword volatile for the data in question. In the above case, this data is the running variable. The word volatile means volatile, volatile. Another meaning is consistent. It is consistent, so every time we reference it, it returns the current actual value of the variable not its cached value. Thus, we have guaranteed that our code will work on all systems, in all Java implementations. Now let's talk about the second challenge we will face and how to overcome it. First, let's try to explain what the difficulty problem is with an example. The application class has an instance variable of integer type named count, counter with an initial value of zero. doCount() is called by creating an application object within the main method.
Within the doCount() method, two threads are created and started, each containing a loop that will increase the count variable 10,000 times with the run() method. At the end of the method, the last value of the count variable is printed. So far, everything is clear. However, when we run this program, a surprise awaits us. When the program ends, there will be those who expect to see 20,000 on the Console. Yes, sometimes we can see 20,000. However, when you see absurd numbers like 511 and 10,987, you are as surprised as I am. The reason is this. Since thread1 and thread2 are separate threads, after starting with the start() methods, the main thread, i.e., the current thread that started these two threads, continues its operation and prints the instant value of the count variable to the Console. At this time, the last value of the counter may not be 20,000 in some cases as thread1 and thread2 may not be terminated. To solve this situation, we can call the join() method of these two threads and tell them to wait for the current thread until their work is finished. When we run the program, the second surprise appears. Every time we run the program, we expect to see 20,000 but sometimes we see 20,000, but also see each results, such as 14,180, 12,460, 19,422. But this time, you won't see small numbers like 511.
Suspending the current thread with a join() call is not such a bland solution, but it solves only one of our problems. To understand the problem, we need to look a little further up inside the run() method. The problem stems from the simultaneous operation of threads, but how come? Wasn't our aim to run threads simultaneously and thus to increase performance? Why did this strong side of threads appear as a problem now? When we look at the run method, the body of the for loop, we see the following line. The process here is two step. One, read the value of count and add ones. Two, assign the result to the variable count. This process happens very, very quickly. However, this speed may be insufficient in the case of concurrent threads. For example, thread1 reads the value of count as 100 and adds 1 to it. So, it results in 101. At the same time, thread2 reads the value of count as 100 and adds 1 to it. So, it also reaches the result, 101. thread1 writes the result namely, 101, to the variable, count. thread2 writes the result namely, 101, to the variable, count.
As a result, the last value of the variable, count, whose value should increase by two, becomes 101, i.e., it will only increase by 1. For such reasons, increasing the value of the count variable should also be done in a way that only one thread can do it at the same time. The keyword of Java that comes to our rescue is synchronized this time. As a first step, let's start by expressing the increase of the value of the count variable as a method of the Application class and arrange our count++ line in our anonymous Runnable implementations to call this method. Then let's take it a step further and ensure, with the help of the synchronize keyword, that the increment method can only be called one thread at a time. Thus, we have performed the process of increasing the value of the count variable with the constraint that only one thread can perform it at a time. When a thread calls the increment method, all other threads that want to call the increment method wait until the process for that thread is finished. This is called intrinsic lock, i.e., the built-in lock mechanism. Now, every time we run the program without exception, we see the value, 20,000. Also, since the method that operates on the count variable is synchronized, we did not specify the count variable with the volatile keyword.
This is because the synchronize keyword guarantees the functionality of the volatile keyword for the count variable. Let's say, we have x tasks and for example, we want to perform these tasks in y threads. How would we do this in Java? Java's response to this need is thread pools. For example, let's say, we have a processor class. The run() method refers to the task to be performed by the thread that will run it, as we learned earlier. The run() method of the Processor class simply tells us it has started, does its job. As you can see, it only waits for five seconds and finally tells us, it's finished. Let's say, we had to do the task that the Processor class expresses five times, and we have two processor cores and hardware. So, we can run two threads at the same time. We can continue by creating two threads manually, waiting for these threads to terminate, and creating two more threads. However, instead of doing this, we can create a thread pool with the size of two threads with the help of the helper classes, util classes provided by Java and do what we want to do, much easier, and as it should be. To create a 2D thread pool, we'll use the static method, new fixed thread pool of the java.util.concurrent.ExecutorClass.
Calling Executors.newFixedThreadPool(2) returns us a java.util.concurrent.ExecutorService object that will create a two-thread pool and accept and run our tasks. We add our processor type to the task operator, executorService. With the executorService.submit() method, as long as there is space in the executorService pool, it creates a new thread with Runnable objects added to it and starts it. An executorService, like our example, works like this. It immediately starts executing the first task added to it. As soon as the task is added, it immediately starts operating it. However, when the third mission arrives, it waits for space and its pool to be made, i.e., one of the first two missions is finished. In other words, other tasks wait in the queue until one of the tasks in the pool is completed and it continues this way until all missions are finished. The call to executerService.shutdown() is important. It means, accept new task to the task handler, executorService, and terminate the task handler when all existing tasks are finished. If this call is not made, the above program will not terminate, new tasks will continue to wait. In our example, after the shutdown call, we added all tasks to the Console. To indicate this, we print a notification message. If you try to add a new task with the executorService.submit() call after the shutdown call, you can see that a java.util.concurrent.RejectedExecutionException type exception is thrown. What if we want to do something after all the tasks are completed?
For this, the executorService.awaitTermination() method is used. A time limit is specified with the awaitTermination() method. The time specified in the method's call specifies the maximum time to wait, not the time to wait under all conditions. In our example, we specified this time as one day. This means, wait until all missions have been completed. If all tasks are not completed in one day, terminate the task handler. Now, it's time to run the program and see the output. Since we wait five seconds in the run() method, we can also visually observe the working logic of the thread pools. Since the order of the completion of the first and second task, the third and fourth task may vary within themselves, the output may also change. However, the starting order of the threads will always be the same. That's it for now. In our next lesson, we'll continue to thread. Hope to see you in our next lesson. Have a nice day.
OAK Academy is made up of tech experts who have been in the sector for years and years and are deeply rooted in the tech world. They specialize in critical areas like cybersecurity, coding, IT, game development, app monetization, and mobile development.