Get list of clusters within ECS
aws ecs list-clusters --profile {profile-name}
// output
{
"clusterArns": [
"arn:aws:ecs:us-east-1:123456789:cluster/server-api",
"arn:aws:ecs:us-east-1:123456789:cluster/server-web"
]
}
Get list of services within a cluster
aws ecs list-services --cluster server-api --profile {profile-name}
//output
{
"serviceArns": [
"arn:aws:ecs:us-east-1:12345679:service/server-api-service"
]
}
Build docker and push image to ECR
# authenticate to ECR
aws ecr get-login-password --region us-east-1 --profile folau | docker login --username AWS --password-stdin {account-num}.dkr.ecr.us-east-1.amazonaws.com
# build image
docker build -t backend-api .
# tag
docker tag backend-api:latest {account-num}.dkr.ecr.us-east-1.amazonaws.com/backend-api:latest
# push imge to ECR
docker push {account-num}.dkr.ecr.us-east-1.amazonaws.com/backend-api:latest
Build docker, push image to ECR, and deploy image to ECS Fargate
#!/bin/sh
# us-east-1
aws ecr get-login-password --region us-east-1 --profile {aws-profile} | docker login --username AWS --password-stdin {aws-account-number}.dkr.ecr.{aws-region}.amazonaws.com
docker build -t {project-name} .
docker tag {project-name}:latest {aws-account-number}.dkr.ecr.{aws-region}.amazonaws.com/{ecr-repository}:latest
docker push {aws-account-number}.dkr.ecr.{aws-region}.amazonaws.com/{ecr-repository}:latest
# task-definition with no version will use the lastest version
aws ecs update-service \
--cluster {cluster-name} \
--service {service-name} \
--task-definition {task-def-name} \
--force-new-deployment \
--profile {profileName}
# Example
#!/bin/sh
aws ecr get-login-password --region us-east-1 --profile folau | docker login --username AWS --password-stdin 1231231231.dkr.ecr.us-east-1.amazonaws.com
docker build -t backend-api .
docker tag backend-api:latest 1231231231.dkr.ecr.us-east-1.amazonaws.com/backend-api:latest
docker push 1231231231.dkr.ecr.us-east-1.amazonaws.com/backend-api:latest
aws ecs update-service \
--cluster backend-api \
--service backend-api \
--task-definition backend-api \
--force-new-deployment \
--profile folau
Update desired count
aws ecs update-service --cluster backend-api --service backend-api --task-definition backend-api \ --desired-count 0 \ --profile folau
Stack is a simple data structure that allows adding and removing elements in a particular order. Every time an element is added, it goes on the top of the stack and the only element that can be removed is the element that is at the top of the stack, just like a pile of objects.
A stack, under the hood, is an ordered list in which insertion and deletion are done only at the top. The last element inserted is the first one to be deleted. Hence, it is called the Last in First out (LIFO) or First in Last out (FILO) list.
A pile of plates in a cafeteria is a good example of a stack. The plates are added to the stack as they are cleaned and they are placed on the top. When a plate, is required it is taken from the top of the stack. The first plate placed on the stack is the last one to be used.
Special names are given to the two changes that can be made to a stack. When an element is inserted in a stack, the concept is called push, and when an element is removed from the stack, the concept is called pop. Trying to pop out an empty stack is called underflow and trying to push an element in a full stack is called overflow.

push() function is used to insert new elements into the Stack and pop() function is used to remove an element from the stack. Both insertion and removal are allowed at only one end of Stack called Top.





@Data
@NoArgsConstructor
public class MyStack<E> {
private LinkedList<E> list = new LinkedList<>();
public E push(E item) {
list.addFirst(item);
return item;
}
public E pop() {
if (list.size() <= 0) {
throw new EmptyStackException();
}
return list.remove();
}
public int getSize() {
return list.size();
}
public E peek() {
if (list.size() <= 0) {
throw new EmptyStackException();
}
return list.getFirst();
}
public void print() {
int size = getSize();
int count = 1;
if (count >= size) {
return;
}
E item = list.getFirst();
while (item != null) {
System.out.println(item.toString());
if (count >= size) {
break;
}
item = list.get(count);
count++;
}
}
}
JavaScript programs start off pretty small almost every time. Not until your project grows and grows so large that your scripts may start to look like spaghetti code. At this point, your code has to be broken down into separate modules that can be imported where needed.
The good news is that modern browsers have started to support module functionality natively, and this is what this article is all about. This can only be a good thing — browsers can optimize loading of modules, making it more efficient than having to use a library and do all of that extra client-side processing and extra round trips.
Export
The first thing you do to get access to module features is export them. This is done using the export statement. The easiest way to use it is to place it in front of any items you want exported out of the module. A more convenient way of exporting all the items you want to export is to use a single export statement at the end of your module file.
You can export functions, var, let, const, and classes. They need to be top-level items; you can’t use export inside a function
class User{
//private fields are declared with #
#name;
constructor(name) {
this.name = name;
}
getName(){
return this.name;
}
}
function sayHi(name) {
return `Hello, ${name}!`;
}
let folau = {
name: "Folau",
getName(){
return this.name;
}
}
/**
* export class User {...} -> import {User} from ..
* export default class User {...} -> import User from ...
*/
// export has to be in the same order as import
// Named exports are explicit. They exactly name what they import, so we have that information from them; that’s a good thing.
export {User, sayHi, folau};
Import
Once you’ve exported some features out of your module, you need to import them into your script to be able to use them.
You use the import statement, followed by a comma-separated list of the features you want to import wrapped in curly braces, followed by the keyword from, followed by the path to the module file — a path relative to the site root. we are using the dot (.) syntax to mean “the current location”, followed by the path beyond that to the file we are trying to find. This is much better than writing out the entire relative path each time, as it is shorter, and it makes the URL portable — the example will still work if you move it to a different location in the site hierarchy.
<script type="module">
// import has to be in the same order as export
// Named exports are explicit. They exactly name what they import, so we have that information from them; that’s a good thing.
import {User, sayHi, folau} from './module.js';
console.log("Module");
let greeting = sayHi("Folau");
console.log(greeting);// Hello, Folau
console.log("name: ", folau.getName());// Folau
let lisa = new User("Lisa");
console.log("name: ", lisa.getName());// Lisa
</script>
Linked List is a very commonly used linear data structure which consists of group of nodes in a sequence. Each node holds its own data and the address of the next node hence forming a chain like structure.
Linked Lists are used to create trees and graphs.

There are 3 different implementations of Linked List available, they are:
Singly linked lists contain nodes which have a data part as well as an address part i.e. next, which points to the next node in the sequence of nodes.
The operations we can perform on singly linked lists are insertion, deletion and traversal.

@ToString
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Customer {
private String firstName;
private String lastName;
private String email;
}
@ToString
@Data
public class SingleNode {
private Customer data;
private SingleNode next;
public SingleNode(Customer data) {
this(data, null);
}
public SingleNode(Customer data, SingleNode next) {
this.data = data;
this.next = next;
}
}
@NoArgsConstructor
@Data
public class MySingleLinkedList {
int size = 0;
private SingleNode head;
private SingleNode tail;
public MySingleLinkedList(SingleNode head) {
append(head);
}
/**
* add to end of list<br>
* 1. If the Linked List is empty then we simply, add the new Node as the Head of the Linked List.<br>
* 2. If the Linked List is not empty then we find the last node, and make it' next to the new Node, hence making
* the new node the last Node.
*
* Time complexity of append is O(n) where n is the number of nodes in the linked list. Since there is a loop from
* head to end, the function does O(n) work. This method can also be optimized to work in O(1) by keeping an extra
* pointer to the tail of the linked list.
*/
public void append(SingleNode node) {
if (node == null) {
return;
}
if (this.head == null) {
this.head = node;
this.tail = node;
} else {
this.tail.setNext(node);
this.tail = node;
}
size++;
}
/**
* append to front of list<br>
* 1. The first Node is the Head for any Linked List.<br>
* 2. When a new Linked List is instantiated, it just has the Head, which is Null.<br>
* 3. Else, the Head holds the pointer to the first Node of the List.<br>
* 4. When we want to add any Node at the front, we must make the head point to it.<br>
* 5. And the Next pointer of the newly added Node, must point to the previous Head, whether it be NULL(in case of
* new List) or the pointer to the first Node of the List.<br>
* 6. The previous Head Node is now the second Node of Linked List, because the new Node is added at the front.<br>
*/
public void prepend(SingleNode node) {
if (node == null) {
return;
}
if (this.head == null) {
this.head = node;
} else {
node.setNext(this.head);
this.head = node;
}
size++;
}
/**
* add at position of list
*/
public void add(int index, SingleNode node) {
if (node == null) {
return;
}
int count = 0;
SingleNode currentNode = this.head;
if (this.head.equals(currentNode)) {
/*
* adding to the head
*/
node.setNext(currentNode);
this.head = node;
} else if (this.tail.equals(currentNode)) {
this.tail.setNext(node);
this.tail = node;
}
SingleNode previous = null;
while (currentNode.getNext() != null) {
if (index == count) {
break;
}
previous = currentNode;
currentNode = currentNode.getNext();
count++;
}
node.setNext(currentNode);
previous.setNext(node);
size++;
}
/**
* is empty?
*/
public boolean isEmpty() {
return size <= 0 || this.head == null;
}
/**
* exist? search by email
*/
public boolean existByEmail(String email) {
if (email == null || email.length() <= 0) {
return false;
}
if (this.head == null) {
return false;
} else {
SingleNode currentNode = this.head;
/**
* if email not found, keep checking the next node<br>
* when email is found, break out of while loop
*/
while (!currentNode.getData().getEmail().equalsIgnoreCase(email.toLowerCase())) {
/**
* next is null, so email is not found
*/
if (currentNode.getNext() == null) {
return false;
}
currentNode = currentNode.getNext();
}
return true;
}
}
/**
* get node on index
*/
public SingleNode get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index out of bounds");
}
if (index == 0) {
return this.head;
} else if (index == (size - 1)) {
return this.tail;
} else {
int count = 0;
SingleNode currentNode = this.head;
while (currentNode.getNext() != null) {
currentNode = currentNode.getNext();
count++;
if (index == count) {
break;
}
}
return currentNode;
}
}
/**
* remove node base on index<br>
* 1. If the Node to be deleted is the first node, then simply set the Next pointer of the Head to point to the next
* element from the Node to be deleted.<br>
* 2. If the Node is in the middle somewhere, then find the Node before it, and make the Node before it point to the
* Node next to it.<br>
*/
public void removeAt(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index out of bounds");
}
if (index==0) {
this.head = this.head.getNext();
return;
}
int count = 1;
SingleNode previous = this.head;
SingleNode currentNode = this.head.getNext();
SingleNode next = null;
while (count < index) {
previous = currentNode;
currentNode = currentNode.getNext();
next = currentNode.getNext();
count++;
}
if (currentNode.equals(this.tail)) {
this.tail = previous;
this.tail.setNext(null);
} else {
// drop currentNode
previous.setNext(next);
}
}
/**
* remove all<br>
* set head to null
*/
public void removeAll() {
this.head = null;
this.tail = null;
}
/**
* print out list
*/
public void printList() {
if (this.head == null) {
System.out.println("list is empty");
}
int count = 0;
SingleNode node = this.head;
while (node != null) {
System.out.println("index: " + count);
System.out.println("data: " + node.getData().toString());
node = node.getNext();
if (node == null) {
System.out.println("tail: " + tail.getData().toString());
System.out.println("end of list\n");
}
System.out.println("\n");
count++;
}
}
}
In a doubly linked list, each node contains a data part and two addresses, one for the previous node and one for the next node.

@AllArgsConstructor
@ToString
@Data
public class DoubleNode {
private DoubleNode prev;
private Customer data;
private DoubleNode next;
public DoubleNode(Customer data) {
this(null, data, null);
}
}
@NoArgsConstructor
@Data
public class MyDoubleLinkedList {
int size = 0;
private DoubleNode head;
private DoubleNode tail;
public MyDoubleLinkedList(DoubleNode head) {
append(head);
}
/**
* add to end of list<br>
* 1. If the Linked List is empty then we simply, add the new Node as the Head of the Linked List.<br>
* 2. If the Linked List is not empty then we find the last node, and make it' next to the new Node, hence making
* the new node the last Node.
*/
public void append(DoubleNode node) {
if (node == null) {
return;
}
if (this.head == null) {
this.head = node;
this.tail = node;
} else {
DoubleNode currentNode = this.head;
while (currentNode.getNext() != null) {
currentNode = currentNode.getNext();
}
// order doesn't matter, as long as the last operation is tail=node
this.tail.setNext(node);
node.setPrev(this.tail);
this.tail = node;
}
size++;
}
/**
* append to front of list<br>
* 1. The first Node is the Head for any Linked List.<br>
* 2. When a new Linked List is instantiated, it just has the Head, which is Null.<br>
* 3. Else, the Head holds the pointer to the first Node of the List.<br>
* 4. When we want to add any Node at the front, we must make the head point to it.<br>
* 5. And the Next pointer of the newly added Node, must point to the previous Head, whether it be NULL(in case of
* new List) or the pointer to the first Node of the List.<br>
* 6. The previous Head Node is now the second Node of Linked List, because the new Node is added at the front.<br>
*/
public void prepend(DoubleNode node) {
if (node == null) {
return;
}
if (this.head == null) {
this.head = node;
} else {
// order doesn't matter, as long as the last operation is head=node
this.head.setPrev(node);
node.setNext(this.head);
this.head = node;
}
size++;
}
/**
* add at position of list
*/
public void add(int index, DoubleNode node) {
if (node == null) {
return;
}
int count = 0;
DoubleNode currentNode = this.head;
if (index == 0) {
node.setNext(currentNode);
this.head = node;
} else if (index == (size - 1)) {
this.tail.setNext(node);
this.tail = node;
} else {
DoubleNode previous = null;
while (currentNode.getNext() != null) {
if (index == count) {
break;
}
previous = currentNode;
currentNode = currentNode.getNext();
count++;
}
node.setNext(currentNode);
previous.setNext(node);
}
size++;
}
/**
* is empty?
*/
public boolean isEmpty() {
return size <= 0 || this.head == null;
}
/**
* exist? search by email
*/
public boolean existByEmail(String email) {
if (email == null || email.length() <= 0) {
return false;
}
if (this.head == null) {
return false;
} else {
DoubleNode currentNode = this.head;
/**
* if email not found, keep checking the next node<br>
* when email is found, break out of while loop
*/
while (!currentNode.getData().getEmail().equalsIgnoreCase(email.toLowerCase())) {
/**
* next is null, so email is not found
*/
if (currentNode.getNext() == null) {
return false;
}
currentNode = currentNode.getNext();
}
return true;
}
}
/**
* get node on index
*/
public DoubleNode get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index out of bounds");
}
if (index == 0) {
return this.head;
}
if (index == 0) {
return this.head;
} else if (index == (size - 1)) {
return this.tail;
} else {
int count = 0;
DoubleNode currentNode = this.head;
while (currentNode.getNext() != null) {
currentNode = currentNode.getNext();
count++;
if (index == count) {
break;
}
}
return currentNode;
}
}
/**
* remove node base on index<br>
* 1. If the Node to be deleted is the first node, then simply set the Next pointer of the Head to point to the next
* element from the Node to be deleted.<br>
* 2. If the Node is in the middle somewhere, then find the Node before it, and make the Node before it point to the
* Node next to it.<br>
*/
public void remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index out of bounds");
}
int count = 0;
if (index == 0) {
this.head = this.head.getNext();
this.head.setPrev(null);
} else if (index == (size - 1)) {
this.tail = this.tail.getPrev();
this.tail.setNext(null);
} else {
DoubleNode previous = null;
DoubleNode currentNode = this.head;
DoubleNode next = null;
while (currentNode.getNext() != null) {
if (index == count) {
break;
}
previous = currentNode;
currentNode = currentNode.getNext();
next = currentNode.getNext();
count++;
}
previous.setNext(next);
next.setPrev(previous);
}
}
/**
* remove all<br>
* set head to null
*/
public void removeAll() {
this.head = null;
this.tail = null;
}
/**
* print out list
*/
public void printList() {
if (this.head == null) {
System.out.println("list is empty");
}
int count = 0;
DoubleNode node = this.head;
while (node != null) {
System.out.println("index: " + count);
System.out.println("prev: " + ((node.getPrev() != null) ? node.getPrev().getData().toString() : ""));
System.out.println("current: " + node.getData().toString());
System.out.println("next: " + ((node.getNext() != null) ? node.getNext().getData().toString() : ""));
node = node.getNext();
if (node == null) {
System.out.println("tail: " + tail.getData().toString());
System.out.println("end of list\n");
}
System.out.println("\n");
count++;
}
}
}
In circular linked list the last node of the list holds the address of the first node hence forming a circular chain.

| Data Structure | Time Complexity | Space Complexity | |||||||
|---|---|---|---|---|---|---|---|---|---|
| Average | Worst | Worst | |||||||
| Access | Search | Insertion | Deletion | Access | Search | Insertion | Deletion | ||
| Singly-Linked List | Θ(n) |
Θ(n) |
Θ(1) |
Θ(1) |
O(n) |
O(n) |
O(1) |
O(1) |
O(n) |
| Doubly-Linked List | Θ(n) |
Θ(n) |
Θ(1) |
Θ(1) |
O(n) |
O(n) |
O(1) |
O(1) |
O(n) |
Imagine a restaurant with a single waiter. One waiter takes an order, walks to the kitchen, waits for the food, brings it back, then moves to the next table. During peak hours, customers wait forever. Now imagine the same restaurant with five waiters. While one waiter is waiting for food at the kitchen, another is taking an order, a third is serving a table, and a fourth is processing a payment. The restaurant handles far more customers in the same amount of time — not because each waiter is faster, but because work happens concurrently.
Multithreading is the restaurant-with-multiple-waiters approach applied to software. A thread is an independent path of execution within a program. Multithreading means running multiple threads simultaneously within a single process, allowing your program to do several things at once.
These two terms are often confused, but they are fundamentally different:
| Feature | Process | Thread |
|---|---|---|
| Definition | An independent program in execution with its own memory space | A lightweight unit of execution within a process |
| Memory | Each process has its own heap, stack, and data segments | Threads within a process share the heap but have separate stacks |
| Communication | Inter-process communication (IPC) — sockets, pipes, shared memory (expensive) | Direct access to shared variables (fast but requires synchronization) |
| Creation cost | Expensive — OS must allocate memory, file descriptors, etc. | Cheap — only needs a new stack and program counter |
| Isolation | One process crashing does not affect others | One thread crashing can kill the entire process |
| Example | Running Chrome and IntelliJ side by side | Chrome loading 10 tabs simultaneously within one Chrome process |
Multithreading is powerful but dangerous. Shared mutable state, race conditions, deadlocks, and visibility problems make concurrent code the most difficult category of bugs to write, debug, and fix. As Brian Goetz wrote in Java Concurrency in Practice: “Writing correct concurrent programs is hard; writing correct concurrent programs that are also performant is harder still.” This tutorial will teach you both the tools and the discipline to get it right.
Java provides two fundamental ways to create a thread: extending the Thread class and implementing the Runnable interface. Both achieve the same result — a new thread of execution — but they have important design trade-offs.
The simplest approach: create a subclass of java.lang.Thread and override the run() method. The code inside run() is what the new thread will execute.
public class MyThread extends Thread {
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + " - Count: " + i);
try {
Thread.sleep(500); // Pause 500ms between iterations
} catch (InterruptedException e) {
System.out.println("Thread was interrupted");
return;
}
}
}
public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
thread1.setName("Worker-A");
thread2.setName("Worker-B");
thread1.start(); // Starts a NEW thread that calls run()
thread2.start(); // Starts another NEW thread
System.out.println("Main thread continues immediately");
}
}
// Output (order may vary -- that's the nature of concurrency):
// Main thread continues immediately
// Worker-A - Count: 1
// Worker-B - Count: 1
// Worker-A - Count: 2
// Worker-B - Count: 2
// Worker-A - Count: 3
// Worker-B - Count: 3
// Worker-A - Count: 4
// Worker-B - Count: 4
// Worker-A - Count: 5
// Worker-B - Count: 5
The preferred approach. Instead of subclassing Thread, you implement Runnable -- a functional interface with a single run() method -- and pass it to a Thread object.
public class MyRunnable implements Runnable {
private final String taskName;
public MyRunnable(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(taskName + " - Step " + i + " [" + Thread.currentThread().getName() + "]");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupt flag
return;
}
}
}
public static void main(String[] args) {
Thread thread1 = new Thread(new MyRunnable("Download"), "IO-Thread-1");
Thread thread2 = new Thread(new MyRunnable("Parse"), "IO-Thread-2");
thread1.start();
thread2.start();
}
}
// Output (order varies):
// Download - Step 1 [IO-Thread-1]
// Parse - Step 1 [IO-Thread-2]
// Download - Step 2 [IO-Thread-1]
// Parse - Step 2 [IO-Thread-2]
// ...
Since Runnable is a functional interface (one abstract method), you can express it as a lambda. This is the most concise and modern approach for simple tasks.
public class LambdaThreadDemo {
public static void main(String[] args) {
// Inline lambda -- no need for a separate class
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("Lambda thread: " + i);
}
});
thread1.start();
// Even shorter for one-liners
new Thread(() -> System.out.println("Quick task on " + Thread.currentThread().getName())).start();
// Method reference (if you have an existing method)
new Thread(LambdaThreadDemo::doWork).start();
}
private static void doWork() {
System.out.println("Doing work on " + Thread.currentThread().getName());
}
}
// Output (order varies):
// Lambda thread: 0
// Lambda thread: 1
// Lambda thread: 2
// Quick task on Thread-1
// Doing work on Thread-2
Every thread in Java goes through a well-defined set of states. Understanding these states is essential for debugging threading issues.
new Thread() start()
| |
v v
[ NEW ] ----------> [ RUNNABLE ] <---------+
/ \ |
/ \ |
sleep()/wait() / \ I/O done / |
lock wait / \ notify / |
v v | |
[ TIMED_WAITING ] [ WAITING ] --+
[ BLOCKED ] (waiting for lock)
\ /
\ /
v v
[ TERMINATED ]
(run() completes or
exception thrown)
| State | Description | How to Enter |
|---|---|---|
NEW |
Thread object created but start() not yet called |
new Thread() |
RUNNABLE |
Thread is eligible to run. May be actually running or waiting for CPU time | start(), or returning from a blocked/waiting state |
BLOCKED |
Thread is waiting to acquire a monitor lock (synchronized block/method) | Attempting to enter a synchronized block held by another thread |
WAITING |
Thread is waiting indefinitely for another thread to perform an action | Object.wait(), Thread.join(), LockSupport.park() |
TIMED_WAITING |
Thread is waiting for a specified amount of time | Thread.sleep(ms), Object.wait(ms), Thread.join(ms) |
TERMINATED |
Thread has completed execution (run() returned or exception thrown) | run() completes normally or throws an uncaught exception |
| Criteria | extends Thread | implements Runnable |
|---|---|---|
| Inheritance | Uses up your single inheritance slot | Your class can still extend another class |
| Separation of concerns | Mixes task logic with threading infrastructure | Separates the task (what to do) from the mechanism (how to run it) |
| Reusability | Task is tied to Thread -- cannot submit to a thread pool | Same Runnable can be passed to Thread, ExecutorService, etc. |
| Lambda support | Not possible (abstract class, not interface) | Supported (Runnable is a functional interface) |
| Resource sharing | Each Thread instance has its own fields | Multiple threads can share a single Runnable instance |
| When to use | Almost never -- only if you need to override other Thread methods | Almost always -- this is the standard approach |
Rule of thumb: Always prefer Runnable (or even better, use an ExecutorService as shown in Section 7). Extending Thread is a legacy pattern that you will see in older codebases but should avoid in new code.
The Thread class provides several methods for controlling thread behavior. Understanding these methods -- especially the difference between start() and run() -- is critical for writing correct concurrent programs.
This is the single most common mistake beginners make with threads. Calling run() does NOT create a new thread. It executes the method on the current thread, just like any other method call. Only start() creates a new thread and invokes run() on that new thread.
public class StartVsRun {
public static void main(String[] args) {
Runnable task = () -> System.out.println("Running on: " + Thread.currentThread().getName());
// WRONG: Calling run() directly -- executes on main thread
Thread t1 = new Thread(task, "Worker-1");
t1.run(); // Output: Running on: main <-- NOT on Worker-1!
// CORRECT: Calling start() -- executes on new thread
Thread t2 = new Thread(task, "Worker-2");
t2.start(); // Output: Running on: Worker-2
}
}
// Output:
// Running on: main
// Running on: Worker-2
Thread.sleep(milliseconds) pauses the currently executing thread for at least the specified duration. It does not release any locks the thread holds. It throws InterruptedException if another thread interrupts the sleeping thread.
public class SleepDemo {
public static void main(String[] args) {
System.out.println("Start: " + System.currentTimeMillis());
try {
Thread.sleep(2000); // Pause for 2 seconds
} catch (InterruptedException e) {
System.out.println("Sleep was interrupted!");
}
System.out.println("End: " + System.currentTimeMillis());
// The difference will be approximately 2000ms
}
}
join() makes the calling thread wait until the target thread completes. This is how you enforce ordering -- "don't continue until this thread is done." You can also pass a timeout: join(5000) waits at most 5 seconds.
public class JoinDemo {
public static void main(String[] args) throws InterruptedException {
Thread downloader = new Thread(() -> {
System.out.println("Downloading file...");
try { Thread.sleep(3000); } catch (InterruptedException e) { return; }
System.out.println("Download complete!");
});
Thread processor = new Thread(() -> {
System.out.println("Processing file...");
try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
System.out.println("Processing complete!");
});
downloader.start();
downloader.join(); // Main thread WAITS until downloader finishes
// Only after download is complete do we start processing
processor.start();
processor.join(); // Main thread WAITS until processor finishes
System.out.println("All work done!");
}
}
// Output (always in this order due to join()):
// Downloading file...
// Download complete!
// Processing file...
// Processing complete!
// All work done!
| Method | Description | Example |
|---|---|---|
Thread.currentThread() |
Returns a reference to the currently executing thread | String name = Thread.currentThread().getName(); |
setName(String) / getName() |
Set or get the thread's name (extremely useful for debugging and logging) | thread.setName("OrderProcessor-1"); |
setPriority(int) / getPriority() |
Set priority from 1 (MIN) to 10 (MAX). Default is 5. Note: priority is a hint to the OS scheduler -- not a guarantee | thread.setPriority(Thread.MAX_PRIORITY); |
isAlive() |
Returns true if the thread has been started and has not yet terminated |
if (thread.isAlive()) { ... } |
yield() |
Suggests to the scheduler that the current thread is willing to yield its current CPU time. Rarely used and behavior is platform-dependent | Thread.yield(); |
interrupt() |
Sets the thread's interrupt flag. If the thread is sleeping or waiting, it throws InterruptedException. Otherwise, the thread must check Thread.interrupted() or isInterrupted() |
thread.interrupt(); |
isInterrupted() |
Checks the thread's interrupt flag without clearing it | while (!Thread.currentThread().isInterrupted()) { ... } |
Thread.interrupted() |
Checks AND clears the current thread's interrupt flag (static method) | if (Thread.interrupted()) { cleanup(); } |
setDaemon(boolean) |
Mark thread as a daemon thread. Daemon threads are killed when all non-daemon threads finish. Must be called before start() |
thread.setDaemon(true); |
Never use the deprecated stop() method. The correct way to signal a thread to stop is with interrupt(). The thread itself must cooperate by checking for interruption.
public class InterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Working...");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
// sleep() clears the interrupt flag, so we re-set it
System.out.println("Interrupted during sleep -- shutting down gracefully");
Thread.currentThread().interrupt(); // Restore the flag
break; // Exit the loop
}
}
System.out.println("Worker finished cleanup and exiting");
});
worker.start();
Thread.sleep(2000); // Let it work for 2 seconds
worker.interrupt(); // Signal the thread to stop
worker.join(); // Wait for it to actually finish
System.out.println("Main thread done");
}
}
// Output:
// Working...
// Working...
// Working...
// Working...
// Interrupted during sleep -- shutting down gracefully
// Worker finished cleanup and exiting
// Main thread done
When multiple threads read and write shared data simultaneously, you get race conditions -- one of the most insidious bugs in software development. Race conditions are non-deterministic: the code might work correctly 99 times and fail on the 100th, or pass all tests in development and fail in production under load.
Let us demonstrate the problem with a simple shared counter. Two threads each increment it 100,000 times. The expected result is 200,000. But without synchronization, the result is unpredictable.
public class RaceConditionDemo {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 100_000; i++) {
counter++; // NOT atomic! Read -> Increment -> Write (3 steps)
}
};
Thread t1 = new Thread(increment);
Thread t2 = new Thread(increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Expected: 200000");
System.out.println("Actual: " + counter);
}
}
// Output (varies on each run):
// Expected: 200000
// Actual: 156823 <-- WRONG! Lost updates due to race condition
Why does this happen? The expression counter++ looks atomic but is actually three steps: (1) read the current value of counter, (2) add 1, (3) write the new value back. If Thread A reads counter = 50, then Thread B also reads counter = 50 before A writes back, both threads write 51 -- and one increment is lost.
Java's built-in solution is the synchronized keyword. It ensures that only one thread at a time can execute a synchronized block or method on the same object. Every Java object has an intrinsic lock (also called a monitor). When a thread enters a synchronized block, it acquires the lock. When it exits, it releases the lock. Any other thread trying to enter a synchronized block on the same object will be blocked until the lock is released.
public class SynchronizedCounter {
private int count = 0;
// Synchronized method -- locks on 'this' object
public synchronized void increment() {
count++; // Only one thread at a time can execute this
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedCounter counter = new SynchronizedCounter();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + counter.getCount());
}
}
// Output (always correct now):
// Count: 200000
Synchronized blocks are more flexible than synchronized methods because you can synchronize on any object and limit the scope of the lock to only the code that actually needs protection.
public class SynchronizedBlockDemo {
private int count = 0;
private final Object lock = new Object(); // Dedicated lock object
public void increment() {
// Only the critical section is synchronized -- not the whole method
synchronized (lock) {
count++;
}
}
// You can also synchronize on 'this'
public void decrement() {
synchronized (this) {
count--;
}
}
// Static synchronized method locks on the Class object
private static int globalCount = 0;
public static synchronized void incrementGlobal() {
globalCount++; // Locks on SynchronizedBlockDemo.class
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlockDemo demo = new SynchronizedBlockDemo();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) demo.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100_000; i++) demo.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Count: " + demo.count); // Always 200000
}
}
A deadlock occurs when two or more threads are each waiting for a lock held by another, creating a circular dependency that can never be resolved. The program hangs forever.
Classic scenario: Thread A holds Lock 1 and waits for Lock 2. Thread B holds Lock 2 and waits for Lock 1. Neither can proceed.
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread 1: Holding Lock A...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for Lock B...");
synchronized (lockB) { // BLOCKED -- Thread 2 holds lockB
System.out.println("Thread 1: Holding both locks!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread 2: Holding Lock B...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for Lock A...");
synchronized (lockA) { // BLOCKED -- Thread 1 holds lockA
System.out.println("Thread 2: Holding both locks!");
}
}
});
thread1.start();
thread2.start();
// DEADLOCK! Both threads are stuck forever.
}
}
// Output:
// Thread 1: Holding Lock A...
// Thread 2: Holding Lock B...
// Thread 1: Waiting for Lock B...
// Thread 2: Waiting for Lock A...
// (program hangs -- deadlock)
tryLock(timeout) from java.util.concurrent.locks.ReentrantLock instead of synchronized. If the lock is not available within the timeout, back off.// FIX: Consistent lock ordering prevents deadlock
public class DeadlockFixed {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
// Both threads acquire locks in the SAME order: A then B
Runnable task = () -> {
synchronized (lockA) { // Always lock A first
System.out.println(Thread.currentThread().getName() + ": Holding Lock A");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lockB) { // Then lock B
System.out.println(Thread.currentThread().getName() + ": Holding both locks!");
}
}
};
new Thread(task, "Thread-1").start();
new Thread(task, "Thread-2").start();
}
}
// Output (no deadlock):
// Thread-1: Holding Lock A
// Thread-1: Holding both locks!
// Thread-2: Holding Lock A
// Thread-2: Holding both locks!
The volatile keyword solves a different problem than synchronized. While synchronized provides mutual exclusion (only one thread at a time), volatile provides visibility -- it guarantees that when one thread writes a value, all other threads see the updated value immediately.
Modern CPUs have caches. When a thread reads a variable, it may read a cached copy rather than the actual value in main memory. Without volatile, one thread's writes may never be visible to another thread.
public class VisibilityProblem {
private static boolean running = true; // NOT volatile -- may be cached
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
int count = 0;
while (running) { // May read cached value of 'running' forever!
count++;
}
System.out.println("Worker stopped after " + count + " iterations");
});
worker.start();
Thread.sleep(1000);
running = false; // Main thread sets running to false
System.out.println("Main thread set running = false");
// BUG: The worker thread might NEVER see running = false
// because it keeps reading its cached copy.
// The program might hang forever!
}
}
public class VisibilityFixed {
private static volatile boolean running = true; // volatile guarantees visibility
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
int count = 0;
while (running) { // Always reads from main memory
count++;
}
System.out.println("Worker stopped after " + count + " iterations");
});
worker.start();
Thread.sleep(1000);
running = false; // This write is immediately visible to the worker
System.out.println("Main thread set running = false");
worker.join();
}
}
// Output:
// Main thread set running = false
// Worker stopped after 248573921 iterations
| Scenario | volatile | synchronized |
|---|---|---|
| One thread writes, other threads only read (flag pattern) | Sufficient | Not needed |
| Multiple threads do read-modify-write (counter++) | NOT sufficient | Required (or use AtomicInteger) |
| Writing one variable that depends on another | NOT sufficient | Required |
| Single write/read of a primitive (boolean, int, etc.) | Sufficient | Works but overkill |
Rule of thumb: Use volatile for simple flags and status indicators where only one thread writes. Use synchronized (or atomic classes) for anything that involves compound operations like check-then-act or read-modify-write.
Sometimes threads need to communicate with each other -- not just prevent simultaneous access, but actually coordinate: "I've produced data, you can consume it now" or "The buffer is full, stop producing until I make room." This is inter-thread communication, and Java provides wait(), notify(), and notifyAll() for exactly this purpose.
notify() or notifyAll() on the same object. It must be called from inside a synchronized block -- otherwise you get an IllegalMonitorStateException.Critical rule: Always call wait() in a while loop, not an if statement. A thread can be woken for reasons other than notify() (spurious wakeup), so it must re-check the condition.
This is the classic use case for wait()/notify(). One or more threads produce data, and one or more threads consume it. They coordinate through a shared buffer.
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumer {
private static final int MAX_SIZE = 5;
private static final Queue buffer = new LinkedList<>();
public static void main(String[] args) {
Thread producer = new Thread(() -> {
int value = 0;
while (true) {
synchronized (buffer) {
// Wait while buffer is full
while (buffer.size() == MAX_SIZE) {
try {
System.out.println("Buffer full -- producer waiting...");
buffer.wait(); // Release lock and wait
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
buffer.add(value);
System.out.println("Produced: " + value);
value++;
buffer.notifyAll(); // Wake up consumer(s)
}
try { Thread.sleep(200); } catch (InterruptedException e) { return; }
}
}, "Producer");
Thread consumer = new Thread(() -> {
while (true) {
synchronized (buffer) {
// Wait while buffer is empty
while (buffer.isEmpty()) {
try {
System.out.println("Buffer empty -- consumer waiting...");
buffer.wait(); // Release lock and wait
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
int value = buffer.poll();
System.out.println("Consumed: " + value);
buffer.notifyAll(); // Wake up producer(s)
}
try { Thread.sleep(500); } catch (InterruptedException e) { return; }
}
}, "Consumer");
producer.setDaemon(true);
consumer.setDaemon(true);
producer.start();
consumer.start();
// Let them run for a few seconds
try { Thread.sleep(5000); } catch (InterruptedException e) {}
System.out.println("Main thread done -- daemon threads will stop");
}
}
// Output (excerpt):
// Produced: 0
// Produced: 1
// Consumed: 0
// Produced: 2
// Consumed: 1
// Produced: 3
// Produced: 4
// Produced: 5
// Buffer full -- producer waiting...
// Consumed: 2
// Produced: 6
// ...
The wait() and notify() mechanism is built on top of the object's intrinsic lock. When you call wait(), the thread releases the lock and goes to sleep atomically -- meaning no other thread can slip in between the "check condition" and "go to sleep" steps. Without synchronization, you would have a race condition: the producer could call notify() after the consumer checks the condition but before it calls wait(), and the signal would be lost forever.
In modern Java, prefer BlockingQueue (Section 9) over manual wait()/notify() for producer-consumer patterns. It handles all the synchronization internally and is much less error-prone.
Creating a new Thread object for every task is expensive. Each thread allocates its own stack (typically 512KB-1MB), and the OS must schedule it. If your application creates thousands of threads, you will run out of memory or suffer from excessive context switching.
Thread pools solve this by maintaining a fixed set of reusable threads. Tasks are submitted to a queue, and the pool's threads pick them up as they become available. When a thread finishes a task, it does not die -- it goes back to the pool and waits for the next task.
ExecutorService is the primary interface for thread pool management in Java. The Executors factory class provides convenient methods to create common pool configurations.
| Factory Method | Pool Behavior | Best For |
|---|---|---|
Executors.newFixedThreadPool(n) |
Exactly n threads. Tasks queue up when all threads are busy |
CPU-bound work where you want to control parallelism |
Executors.newCachedThreadPool() |
Creates new threads as needed, reuses idle threads. Idle threads expire after 60 seconds | Many short-lived I/O-bound tasks |
Executors.newSingleThreadExecutor() |
One thread. Tasks execute sequentially in submission order | Tasks that must not overlap (logging, database writes) |
Executors.newScheduledThreadPool(n) |
n threads that can run tasks after a delay or periodically |
Scheduled tasks, heartbeats, periodic cleanup |
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
// Create a pool with 3 threads
ExecutorService pool = Executors.newFixedThreadPool(3);
// Submit 10 tasks -- only 3 run at a time, others queue up
for (int i = 1; i <= 10; i++) {
final int taskId = i;
pool.submit(() -> {
System.out.println("Task " + taskId + " running on " +
Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskId + " complete");
});
}
// Graceful shutdown
pool.shutdown(); // No new tasks accepted
boolean finished = pool.awaitTermination(30, TimeUnit.SECONDS); // Wait up to 30s
System.out.println("All tasks finished: " + finished);
}
}
// Output (task grouping shows 3 concurrent threads):
// Task 1 running on pool-1-thread-1
// Task 2 running on pool-1-thread-2
// Task 3 running on pool-1-thread-3
// Task 1 complete
// Task 4 running on pool-1-thread-1
// Task 2 complete
// Task 5 running on pool-1-thread-2
// Task 3 complete
// Task 6 running on pool-1-thread-3
// ...
// All tasks finished: true
| Method | Return Type | Exception Handling | Defined In |
|---|---|---|---|
execute(Runnable) |
void |
Uncaught exceptions crash the thread (logged by default UncaughtExceptionHandler) | Executor |
submit(Runnable) |
Future<?> |
Exceptions are captured inside the Future. Call future.get() to retrieve them |
ExecutorService |
submit(Callable<T>) |
Future<T> |
Same as above, plus you get a return value | ExecutorService |
Best practice: Prefer submit() over execute(). With execute(), exceptions in tasks silently kill the thread. With submit(), exceptions are captured and you can retrieve them via future.get().
Failing to shut down an ExecutorService keeps the JVM alive because the pool's threads are non-daemon by default. Always use this pattern:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ProperShutdown {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(4);
// Submit work...
for (int i = 0; i < 20; i++) {
final int taskId = i;
pool.submit(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task " + taskId + " done");
});
}
// Step 1: Stop accepting new tasks
pool.shutdown();
try {
// Step 2: Wait for existing tasks to finish
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
// Step 3: Force shutdown if tasks take too long
System.out.println("Forcing shutdown...");
pool.shutdownNow(); // Interrupts running tasks
// Step 4: Wait again briefly
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("Pool did not terminate!");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
System.out.println("Pool shut down cleanly");
}
}
For tasks that need to run after a delay or periodically (like heartbeat checks, cache expiration, or scheduled reports), use ScheduledExecutorService.
import java.util.concurrent.*;
import java.time.LocalTime;
public class ScheduledTaskDemo {
public static void main(String[] args) throws InterruptedException {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Run once after a 2-second delay
scheduler.schedule(() -> {
System.out.println("Delayed task executed at " + LocalTime.now());
}, 2, TimeUnit.SECONDS);
// Run repeatedly: initial delay 1s, then every 3s
scheduler.scheduleAtFixedRate(() -> {
System.out.println("Heartbeat at " + LocalTime.now());
}, 1, 3, TimeUnit.SECONDS);
// Run with fixed delay between end of one execution and start of next
scheduler.scheduleWithFixedDelay(() -> {
System.out.println("Cleanup at " + LocalTime.now());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}, 0, 2, TimeUnit.SECONDS);
// Let it run for 15 seconds
Thread.sleep(15000);
scheduler.shutdown();
}
}
// Output (timestamps vary):
// Cleanup at 14:30:00.001
// Heartbeat at 14:30:01.002
// Cleanup at 14:30:03.005
// Delayed task executed at 14:30:02.001
// Heartbeat at 14:30:04.002
// Cleanup at 14:30:06.008
// Heartbeat at 14:30:07.002
// ...
Runnable has two fundamental limitations: it cannot return a value and it cannot throw checked exceptions. When you need both, use Callable.
| Feature | Runnable | Callable<V> |
|---|---|---|
| Method | void run() |
V call() throws Exception |
| Return value | No | Yes -- returns V |
| Checked exceptions | Cannot throw (must catch internally) | Can throw any exception |
| Submit to pool | submit(runnable) returns Future<?> |
submit(callable) returns Future<V> |
| Lambda compatible | Yes | Yes |
When you submit a Callable to an ExecutorService, you get back a Future. This Future is a placeholder for the result that will be available when the computation completes.
import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;
public class CallableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
// Submit Callable tasks that return results
List> futures = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
final int taskId = i;
Future future = pool.submit(() -> {
Thread.sleep(1000 + (int)(Math.random() * 2000));
return "Result from task " + taskId;
});
futures.add(future);
}
// Collect results as they complete
for (Future future : futures) {
// get() blocks until the result is available
String result = future.get(); // Can also use get(timeout, unit)
System.out.println(result);
}
pool.shutdown();
}
}
// Output:
// Result from task 1
// Result from task 2
// Result from task 3
// Result from task 4
// Result from task 5
| Method | Description |
|---|---|
get() |
Blocks until the result is available. Throws ExecutionException if the task threw an exception |
get(timeout, unit) |
Blocks for at most the specified time. Throws TimeoutException if the result is not ready in time |
isDone() |
Returns true if the task completed (normally, with exception, or cancelled) |
cancel(mayInterruptIfRunning) |
Attempts to cancel the task. If true, interrupts the thread. Returns true if cancellation was successful |
isCancelled() |
Returns true if the task was cancelled before completion |
When a Callable throws an exception, the exception does not propagate to the calling thread immediately. It is wrapped in an ExecutionException and thrown when you call future.get(). You must unwrap it to get the original exception.
import java.util.concurrent.*;
public class FutureExceptionHandling {
public static void main(String[] args) {
ExecutorService pool = Executors.newSingleThreadExecutor();
Future future = pool.submit(() -> {
// This task throws an exception
if (true) throw new IllegalArgumentException("Invalid input!");
return 42;
});
try {
Integer result = future.get();
System.out.println("Result: " + result);
} catch (ExecutionException e) {
// The original exception is wrapped inside ExecutionException
Throwable cause = e.getCause();
System.out.println("Task failed with: " + cause.getClass().getSimpleName()
+ " - " + cause.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// With timeout
Future slowTask = pool.submit(() -> {
Thread.sleep(10_000);
return "Done";
});
try {
String result = slowTask.get(2, TimeUnit.SECONDS); // Wait max 2 seconds
} catch (TimeoutException e) {
System.out.println("Task timed out! Cancelling...");
slowTask.cancel(true); // Interrupt the running task
} catch (ExecutionException | InterruptedException e) {
System.out.println("Error: " + e.getMessage());
}
pool.shutdown();
}
}
// Output:
// Task failed with: IllegalArgumentException - Invalid input!
// Task timed out! Cancelling...
The java.util.concurrent package (introduced in Java 5) contains a rich set of high-level concurrency utilities that replace error-prone manual synchronization. These are the tools professional Java developers use daily.
A CountDownLatch allows one or more threads to wait until a set of operations in other threads completes. You initialize it with a count, threads call countDown() when they finish, and waiting threads call await() which blocks until the count reaches zero.
Use case: "Start processing only after all services have initialized" or "Wait for all worker threads to finish before aggregating results."
import java.util.concurrent.*;
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
int serviceCount = 3;
CountDownLatch latch = new CountDownLatch(serviceCount);
// Simulate 3 services starting up
String[] services = {"Database", "Cache", "MessageQueue"};
ExecutorService pool = Executors.newFixedThreadPool(3);
for (String service : services) {
pool.submit(() -> {
try {
int startupTime = (int)(Math.random() * 3000);
Thread.sleep(startupTime);
System.out.println(service + " started in " + startupTime + "ms");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown(); // Signal this service is ready
}
});
}
System.out.println("Waiting for all services to start...");
latch.await(); // Block until count reaches 0
System.out.println("All services ready! Application is starting...");
pool.shutdown();
}
}
// Output:
// Waiting for all services to start...
// Cache started in 892ms
// Database started in 1543ms
// MessageQueue started in 2701ms
// All services ready! Application is starting...
Similar to CountDownLatch, but reusable. All threads wait at the barrier until everyone arrives, then they all proceed simultaneously. Useful for iterative algorithms where threads must synchronize between phases.
import java.util.concurrent.*;
public class CyclicBarrierDemo {
public static void main(String[] args) {
int numWorkers = 3;
// The optional Runnable runs when all threads arrive at the barrier
CyclicBarrier barrier = new CyclicBarrier(numWorkers, () -> {
System.out.println("--- All workers reached the barrier! Proceeding to next phase ---");
});
ExecutorService pool = Executors.newFixedThreadPool(numWorkers);
for (int i = 1; i <= numWorkers; i++) {
final int workerId = i;
pool.submit(() -> {
try {
// Phase 1
System.out.println("Worker " + workerId + ": Phase 1 complete");
barrier.await(); // Wait for all workers
// Phase 2
Thread.sleep((long)(Math.random() * 1000));
System.out.println("Worker " + workerId + ": Phase 2 complete");
barrier.await(); // Barrier resets automatically!
System.out.println("Worker " + workerId + ": All done!");
} catch (InterruptedException | BrokenBarrierException e) {
Thread.currentThread().interrupt();
}
});
}
pool.shutdown();
}
}
// Output:
// Worker 1: Phase 1 complete
// Worker 3: Phase 1 complete
// Worker 2: Phase 1 complete
// --- All workers reached the barrier! Proceeding to next phase ---
// Worker 2: Phase 2 complete
// Worker 1: Phase 2 complete
// Worker 3: Phase 2 complete
// --- All workers reached the barrier! Proceeding to next phase ---
// Worker 2: All done!
// Worker 1: All done!
// Worker 3: All done!
A Semaphore controls access to a shared resource by maintaining a set of permits. Threads call acquire() to get a permit (blocking if none are available) and release() to return one. Unlike a lock, a semaphore allows multiple threads to access the resource simultaneously -- up to the permit count.
Use case: Rate limiting, connection pools, bounded resource access.
import java.util.concurrent.*;
public class SemaphoreDemo {
public static void main(String[] args) {
// Only 3 threads can access the database simultaneously
Semaphore dbConnectionPool = new Semaphore(3);
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 1; i <= 10; i++) {
final int userId = i;
pool.submit(() -> {
try {
System.out.println("User " + userId + " waiting for connection...");
dbConnectionPool.acquire(); // Block if all 3 permits are taken
System.out.println("User " + userId + " got connection! " +
"Available: " + dbConnectionPool.availablePermits());
Thread.sleep(2000); // Simulate database query
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
dbConnectionPool.release(); // Return the permit
System.out.println("User " + userId + " released connection");
}
});
}
pool.shutdown();
}
}
// Output (excerpt -- only 3 users have connections at any time):
// User 1 waiting for connection...
// User 1 got connection! Available: 2
// User 2 waiting for connection...
// User 2 got connection! Available: 1
// User 3 waiting for connection...
// User 3 got connection! Available: 0
// User 4 waiting for connection...
// (User 4 blocks until someone releases)
// User 1 released connection
// User 4 got connection! Available: 2
// ...
The java.util.concurrent.atomic package provides thread-safe classes for common operations without locks. They use CPU-level compare-and-swap (CAS) instructions, which are much faster than synchronized for simple operations like incrementing a counter.
import java.util.concurrent.atomic.*;
import java.util.concurrent.*;
public class AtomicDemo {
// AtomicInteger -- thread-safe counter without synchronized
private static final AtomicInteger counter = new AtomicInteger(0);
// AtomicLong -- same but for long values
private static final AtomicLong totalBytes = new AtomicLong(0);
// AtomicBoolean -- thread-safe flag
private static final AtomicBoolean isRunning = new AtomicBoolean(true);
// AtomicReference -- thread-safe reference to any object
private static final AtomicReference currentUser = new AtomicReference<>("none");
public static void main(String[] args) throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(4);
// 4 threads incrementing the counter 100,000 times each
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
for (int j = 0; j < 100_000; j++) {
counter.incrementAndGet(); // Atomic increment
}
});
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("Counter: " + counter.get()); // Always 400000
// Other useful AtomicInteger methods
AtomicInteger ai = new AtomicInteger(10);
System.out.println("Get and add 5: " + ai.getAndAdd(5)); // 10 (returns old value)
System.out.println("Current value: " + ai.get()); // 15
System.out.println("Compare and set: " + ai.compareAndSet(15, 20)); // true
System.out.println("Current value: " + ai.get()); // 20
System.out.println("Update atomically: " + ai.updateAndGet(x -> x * 2)); // 40
System.out.println("Accumulate: " + ai.accumulateAndGet(3, Integer::sum)); // 43
}
}
// Output:
// Counter: 400000
// Get and add 5: 10
// Current value: 15
// Compare and set: true
// Current value: 20
// Update atomically: 40
// Accumulate: 43
Standard collections like HashMap and ArrayList are not thread-safe. Using them from multiple threads without synchronization causes data corruption. Java provides concurrent alternatives that handle synchronization internally.
| Standard Collection | Concurrent Alternative | Key Characteristics |
|---|---|---|
HashMap |
ConcurrentHashMap |
Segment-based locking. Multiple threads can read/write different segments simultaneously. No null keys or values |
ArrayList |
CopyOnWriteArrayList |
Creates a new copy of the array on every write. Excellent for read-heavy, write-rare scenarios (e.g., listener lists) |
HashSet |
CopyOnWriteArraySet |
Backed by CopyOnWriteArrayList. Same read-heavy trade-off |
| N/A | BlockingQueue |
Queue that blocks on take() when empty and on put() when full. Perfect for producer-consumer |
import java.util.concurrent.*;
import java.util.Map;
public class ConcurrentCollectionsDemo {
public static void main(String[] args) throws InterruptedException {
// === ConcurrentHashMap ===
ConcurrentHashMap wordCounts = new ConcurrentHashMap<>();
ExecutorService pool = Executors.newFixedThreadPool(4);
String[] words = {"java", "thread", "java", "pool", "thread", "java", "sync", "pool"};
for (String word : words) {
pool.submit(() -> {
// merge() is atomic -- no external synchronization needed
wordCounts.merge(word, 1, Integer::sum);
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Word counts: " + wordCounts);
// Output: Word counts: {java=3, thread=2, pool=2, sync=1}
// === CopyOnWriteArrayList ===
CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>();
listeners.add("Listener-A");
listeners.add("Listener-B");
// Safe to iterate even while another thread modifies the list
// Iterator sees a snapshot -- no ConcurrentModificationException
for (String listener : listeners) {
System.out.println("Notifying: " + listener);
}
// === BlockingQueue (producer-consumer) ===
BlockingQueue queue = new LinkedBlockingQueue<>(5);
// Producer
new Thread(() -> {
try {
for (int i = 1; i <= 8; i++) {
queue.put("Item-" + i); // Blocks if queue is full
System.out.println("Produced: Item-" + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// Consumer
new Thread(() -> {
try {
for (int i = 1; i <= 8; i++) {
String item = queue.take(); // Blocks if queue is empty
System.out.println("Consumed: " + item);
Thread.sleep(500);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
Thread.sleep(6000); // Let producer-consumer run
}
}
Virtual threads are the biggest change to Java concurrency since java.util.concurrent was introduced in Java 5. They fundamentally change how we think about threads.
Traditional Java threads (now called platform threads) are thin wrappers around OS-level threads. Each one consumes about 1MB of stack space and carries significant OS overhead. This means a server can typically handle only a few thousand concurrent threads before running out of memory or overwhelming the OS scheduler. For I/O-bound applications (web servers, microservices) that spend most of their time waiting for database queries, HTTP calls, or file reads, this is a severe bottleneck.
A virtual thread is a lightweight thread managed by the JVM, not the OS. It runs on top of a small pool of platform threads (called carrier threads). When a virtual thread blocks (e.g., waiting for I/O), the JVM unmounts it from the carrier thread, freeing that carrier to run another virtual thread. When the I/O completes, the virtual thread is remounted on any available carrier and resumes.
| Feature | Platform Thread | Virtual Thread |
|---|---|---|
| Managed by | OS kernel | JVM |
| Stack size | ~1MB (fixed) | ~few KB (grows as needed) |
| Creation cost | Expensive (~1ms) | Cheap (~1us) |
| Max concurrent | Thousands | Millions |
| Blocking behavior | Blocks the OS thread | JVM unmounts; carrier thread is freed |
| Best for | CPU-bound work | I/O-bound work (HTTP calls, DB queries, file I/O) |
| Thread pooling needed? | Yes (expensive to create) | No (so cheap you create one per task) |
import java.util.concurrent.*;
import java.time.Duration;
import java.time.Instant;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// === Creating a single virtual thread ===
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("Running on: " + Thread.currentThread());
System.out.println("Is virtual: " + Thread.currentThread().isVirtual());
});
vThread.join();
// Output:
// Running on: VirtualThread[#21,my-virtual-thread]/runnable@ForkJoinPool-1-worker-1
// Is virtual: true
// === Launching 100,000 virtual threads ===
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
executor.submit(() -> {
// Simulate an I/O operation
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return taskId;
});
}
} // AutoCloseable -- waits for all tasks to complete
Duration elapsed = Duration.between(start, Instant.now());
System.out.println("100,000 tasks completed in " + elapsed.toMillis() + "ms");
// Output: 100,000 tasks completed in ~1100ms
// (Not 100,000 seconds! Virtual threads let all sleep() calls overlap)
}
}
import java.util.concurrent.*;
public class VirtualThreadFactoryDemo {
public static void main(String[] args) throws InterruptedException {
// Thread.ofVirtual() builder
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
// Creates: worker-0, worker-1, worker-2, ...
Thread t1 = builder.start(() -> System.out.println(Thread.currentThread().getName()));
Thread t2 = builder.start(() -> System.out.println(Thread.currentThread().getName()));
t1.join();
t2.join();
// Output:
// worker-0
// worker-1
// Factory for use with ExecutorService
ThreadFactory factory = Thread.ofVirtual().name("http-handler-", 0).factory();
ExecutorService pool = Executors.newThreadPerTaskExecutor(factory);
for (int i = 0; i < 5; i++) {
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " handling request");
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
// Output:
// http-handler-0 handling request
// http-handler-1 handling request
// http-handler-2 handling request
// http-handler-3 handling request
// http-handler-4 handling request
}
}
Important caveats:
synchronized blocks that perform I/O inside them -- this "pins" the virtual thread to its carrier, negating the benefit. Use ReentrantLock instead.Thread.NORM_PRIORITY and are always daemon threads.Concurrency bugs are notoriously difficult to find, reproduce, and fix. They may only appear under specific timing conditions, making them invisible in unit tests but catastrophic in production. Here are the four most common problems and how to handle each.
A race condition occurs when the correctness of a program depends on the relative timing of thread execution. The most common form is check-then-act: a thread checks a condition and acts on it, but another thread changes the condition between the check and the action.
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
import java.util.HashMap;
public class CheckThenActRace {
// BAD: Race condition -- check and act are not atomic
private static final Map unsafeMap = new HashMap<>();
static void unsafeAddIfAbsent(String key, int value) {
if (!unsafeMap.containsKey(key)) { // CHECK
// Another thread could insert the key RIGHT HERE
unsafeMap.put(key, value); // ACT
}
}
// GOOD: Use ConcurrentHashMap's atomic putIfAbsent
private static final ConcurrentHashMap safeMap = new ConcurrentHashMap<>();
static void safeAddIfAbsent(String key, int value) {
safeMap.putIfAbsent(key, value); // Atomic check-and-act
}
// GOOD: Use computeIfAbsent for lazy initialization
static int safeGetOrCompute(String key) {
return safeMap.computeIfAbsent(key, k -> {
System.out.println("Computing value for: " + k);
return k.length(); // Expensive computation
});
}
}
Covered in Section 4.3. Four conditions must ALL hold for a deadlock to occur (Coffman conditions):
Prevention: Break any one of these conditions. The most practical approach is to break "circular wait" by enforcing a global lock ordering.
A livelock is similar to a deadlock, but the threads are not blocked -- they are actively running and responding to each other, but making no progress. Think of two people meeting in a hallway: one steps left, the other steps left; then one steps right, the other steps right. They are moving, but neither gets past.
Prevention: Introduce randomness into retry delays. Instead of immediately retrying, wait a random amount of time before trying again. This is the same principle used in Ethernet collision avoidance (exponential backoff).
A thread suffers from starvation when it cannot access a shared resource because other threads are perpetually monopolizing it. For example, if high-priority threads constantly acquire a lock, a low-priority thread may never get a chance.
Prevention:
new ReentrantLock(true) grants access in FIFO order| Tool | What It Detects | How to Use |
|---|---|---|
jstack |
Deadlocks, thread dumps | jstack <pid> from the command line |
jconsole |
Deadlocks, thread states | Launch jconsole and connect to your JVM process |
| VisualVM | Thread activity, deadlocks, CPU usage | Download from visualvm.github.io |
| IDE debugger | Thread states, variable inspection | IntelliJ: Run > Threads tab; Eclipse: Debug perspective |
ThreadMXBean |
Deadlock detection programmatically | ManagementFactory.getThreadMXBean().findDeadlockedThreads() |
Concurrency is one of the hardest areas in software development. Following these best practices will help you write correct, maintainable, and performant concurrent code.
Never create threads manually in production code (except for the simplest cases). Thread pools manage thread lifecycle, limit resource consumption, and improve performance through thread reuse.
import java.util.concurrent.*;
public class PoolVsRawThread {
// BAD: Creating a new thread for every task
static void handleRequestBad(String request) {
new Thread(() -> processRequest(request)).start();
// 10,000 requests = 10,000 threads = ~10GB of stack memory = OutOfMemoryError
}
// GOOD: Submit to a thread pool
private static final ExecutorService pool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() // Size pool to CPU cores
);
static void handleRequestGood(String request) {
pool.submit(() -> processRequest(request));
// 10,000 requests queued, processed by a fixed number of threads
}
// BEST (Java 21+): Virtual threads for I/O-bound work
static void handleRequestBest(String request) {
Thread.ofVirtual().start(() -> processRequest(request));
// 10,000 virtual threads = barely any memory overhead
}
private static void processRequest(String request) {
// ... process the request
}
}
Only synchronize the minimum amount of code necessary. Larger synchronized blocks reduce parallelism and increase contention.
import java.util.ArrayList;
import java.util.List;
public class MinimizeSyncBlocks {
private final List data = new ArrayList<>();
private final Object lock = new Object();
// BAD: Entire method is synchronized, including non-critical work
public synchronized void processAndStoreBad(String input) {
String processed = input.trim().toUpperCase(); // No shared state -- doesn't need sync
String validated = validate(processed); // No shared state -- doesn't need sync
data.add(validated); // Shared state -- NEEDS sync
}
// GOOD: Only the critical section is synchronized
public void processAndStoreGood(String input) {
String processed = input.trim().toUpperCase(); // Done outside the lock
String validated = validate(processed); // Done outside the lock
synchronized (lock) {
data.add(validated); // Only this needs the lock
}
}
private String validate(String input) {
return input.isEmpty() ? "DEFAULT" : input;
}
}
| # | Practice | Do | Don't |
|---|---|---|---|
| 1 | Thread management | Use ExecutorService or virtual threads |
Create raw new Thread() for every task |
| 2 | Synchronization scope | Synchronize only the critical section | Synchronize entire methods unnecessarily |
| 3 | Collections | Use ConcurrentHashMap, CopyOnWriteArrayList |
Use HashMap/ArrayList from multiple threads |
| 4 | Simple counters | Use AtomicInteger, AtomicLong |
Use synchronized for a single counter |
| 5 | Shared state | Minimize it. Use immutable objects and local variables | Share mutable fields across threads without protection |
| 6 | Immutability | Make fields final. Use record. Return defensive copies |
Expose mutable objects to multiple threads |
| 7 | Concurrency utilities | Use CountDownLatch, Semaphore, BlockingQueue |
Roll your own with wait()/notify() |
| 8 | Thread stopping | Use interrupt() and check isInterrupted() |
Use deprecated stop() or destroy() |
| 9 | Exception handling | Use submit() and check future.get() for exceptions |
Use execute() and lose exceptions silently |
| 10 | Thread naming | Always name your threads for debugging: thread.setName("OrderProcessor-1") |
Leave default names like Thread-0 |
| 11 | Pool shutdown | shutdown() then awaitTermination() with timeout |
Forget to shutdown (JVM never exits) |
| 12 | Pool sizing | CPU-bound: cores. I/O-bound: cores * (1 + wait/compute ratio) | Use arbitrary pool sizes or newCachedThreadPool() for everything |
Let us tie everything together with a real-world example: a parallel web scraper that fetches multiple URLs concurrently, collects results into a thread-safe map, uses a CountDownLatch for completion tracking, and shuts down cleanly. This example demonstrates thread pools, Callable/Future, ConcurrentHashMap, CountDownLatch, proper exception handling, and graceful shutdown.
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
/**
* Parallel Web Scraper
*
* Demonstrates:
* 1. ExecutorService (thread pool management)
* 2. Callable + Future (tasks that return results)
* 3. ConcurrentHashMap (thread-safe result collection)
* 4. CountDownLatch (waiting for all tasks to complete)
* 5. AtomicInteger (thread-safe counters)
* 6. Proper exception handling in concurrent code
* 7. Graceful shutdown with timeout
* 8. Thread naming for debugging
*/
public class ParallelWebScraper {
// Thread-safe storage for results
private final ConcurrentHashMap results = new ConcurrentHashMap<>();
// Thread-safe counters for statistics
private final AtomicInteger successCount = new AtomicInteger(0);
private final AtomicInteger failureCount = new AtomicInteger(0);
// HTTP client (thread-safe, reusable)
private final HttpClient httpClient;
// Thread pool
private final ExecutorService executor;
public ParallelWebScraper(int maxConcurrency) {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.executor = Executors.newFixedThreadPool(maxConcurrency, r -> {
Thread t = new Thread(r);
t.setName("Scraper-" + t.getId());
t.setDaemon(true); // Don't prevent JVM shutdown
return t;
});
}
/**
* Scrape multiple URLs concurrently and return all results.
*/
public Map scrape(List urls) {
CountDownLatch latch = new CountDownLatch(urls.size());
Instant startTime = Instant.now();
System.out.println("Starting scrape of " + urls.size() + " URLs with thread pool...\n");
// Submit all scrape tasks
List> futures = new ArrayList<>();
for (String url : urls) {
Future future = executor.submit(() -> {
try {
return fetchUrl(url);
} finally {
latch.countDown(); // Always count down, even on failure
}
});
futures.add(future);
}
// Wait for all tasks to complete (with timeout)
try {
boolean completed = latch.await(30, TimeUnit.SECONDS);
if (!completed) {
System.out.println("WARNING: Timed out waiting for all URLs to complete!");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Scrape was interrupted!");
}
// Collect any results we haven't gotten yet (handles exceptions)
for (int i = 0; i < urls.size(); i++) {
Future future = futures.get(i);
String url = urls.get(i);
try {
if (future.isDone()) {
ScrapeResult result = future.get();
results.put(url, result);
}
} catch (ExecutionException e) {
results.put(url, new ScrapeResult(url, -1, "",
e.getCause().getMessage(), 0));
failureCount.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
Duration totalTime = Duration.between(startTime, Instant.now());
printSummary(totalTime);
return new HashMap<>(results); // Return a snapshot
}
/**
* Fetch a single URL and return the result.
*/
private ScrapeResult fetchUrl(String url) {
Instant start = Instant.now();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(15))
.build();
HttpResponse response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
long durationMs = Duration.between(start, Instant.now()).toMillis();
String title = extractTitle(response.body());
System.out.printf("[%s] %d %s (%dms) - \"%s\"%n",
Thread.currentThread().getName(),
response.statusCode(), url, durationMs, title);
ScrapeResult result = new ScrapeResult(url, response.statusCode(),
title, null, durationMs);
results.put(url, result);
successCount.incrementAndGet();
return result;
} catch (Exception e) {
long durationMs = Duration.between(start, Instant.now()).toMillis();
System.out.printf("[%s] FAILED %s (%dms) - %s%n",
Thread.currentThread().getName(), url, durationMs, e.getMessage());
ScrapeResult result = new ScrapeResult(url, -1, "",
e.getMessage(), durationMs);
results.put(url, result);
failureCount.incrementAndGet();
return result;
}
}
/**
* Extract the tag from HTML content.
*/
private String extractTitle(String html) {
int start = html.indexOf("");
int end = html.indexOf(" ");
if (start != -1 && end != -1) {
return html.substring(start + 7, end).trim();
}
return "(no title)";
}
/**
* Print a summary of the scrape results.
*/
private void printSummary(Duration totalTime) {
System.out.println("\n========== SCRAPE SUMMARY ==========");
System.out.println("Total URLs: " + results.size());
System.out.println("Successful: " + successCount.get());
System.out.println("Failed: " + failureCount.get());
System.out.println("Total time: " + totalTime.toMillis() + "ms");
// Calculate average response time for successful requests
long avgTime = results.values().stream()
.filter(r -> r.statusCode() > 0)
.mapToLong(ScrapeResult::durationMs)
.average()
.stream().mapToLong(d -> (long) d)
.findFirst().orElse(0);
System.out.println("Avg response: " + avgTime + "ms");
System.out.println("====================================\n");
// Print individual results
results.forEach((url, result) -> {
String status = result.error() != null ? "FAILED" : String.valueOf(result.statusCode());
System.out.printf(" [%s] %s - %s%n", status, result.title(), url);
});
}
/**
* Gracefully shut down the thread pool.
*/
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("Thread pool did not terminate!");
}
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
System.out.println("Scraper shut down cleanly.");
}
// Immutable result record (Java 16+)
record ScrapeResult(String url, int statusCode, String title,
String error, long durationMs) {}
// === Main: Run the scraper ===
public static void main(String[] args) {
List urls = List.of(
"https://www.google.com",
"https://www.github.com",
"https://www.stackoverflow.com",
"https://www.oracle.com/java/",
"https://docs.oracle.com/en/java/",
"https://www.baeldung.com",
"https://invalid.url.that.does.not.exist.example.com",
"https://httpstat.us/500"
);
ParallelWebScraper scraper = new ParallelWebScraper(4); // 4 concurrent threads
Map results = scraper.scrape(urls);
scraper.shutdown();
}
}
// Output (times vary):
// Starting scrape of 8 URLs with thread pool...
//
// [Scraper-21] 200 https://www.google.com (245ms) - "Google"
// [Scraper-22] 200 https://www.github.com (892ms) - "GitHub"
// [Scraper-23] 200 https://www.stackoverflow.com (567ms) - "Stack Overflow"
// [Scraper-24] 200 https://www.oracle.com/java/ (1203ms) - "Java | Oracle"
// [Scraper-21] 200 https://docs.oracle.com/en/java/ (431ms) - "Java Documentation"
// [Scraper-22] 200 https://www.baeldung.com (678ms) - "Baeldung"
// [Scraper-23] FAILED https://invalid.url.that.does.not.exist.example.com (3012ms) - UnknownHostException
// [Scraper-24] 500 https://httpstat.us/500 (189ms) - "500 Internal Server Error"
//
// ========== SCRAPE SUMMARY ==========
// Total URLs: 8
// Successful: 6
// Failed: 2
// Total time: 3450ms
// Avg response: 743ms
// ====================================
//
// Scraper shut down cleanly.
| Concept | Where It's Used |
|---|---|
| ExecutorService (fixed thread pool) | Executors.newFixedThreadPool(maxConcurrency) -- limits concurrency |
| Custom ThreadFactory | Names threads as "Scraper-N" for debugging; sets daemon flag |
| Callable + Future | executor.submit(() -> fetchUrl(url)) returns Future<ScrapeResult> |
| ConcurrentHashMap | results map -- multiple threads write simultaneously without corruption |
| AtomicInteger | successCount and failureCount -- thread-safe counters without locks |
| CountDownLatch | Main thread waits for all scrape tasks to complete with a 30-second timeout |
| Exception handling | ExecutionException unwrapped from future.get(); individual task failures don't crash the pool |
| Graceful shutdown | shutdown() -> awaitTermination() -> shutdownNow() pattern |
| Thread naming | Custom ThreadFactory sets descriptive names for log output |
| Interrupt handling | Thread.currentThread().interrupt() restores the flag after catching InterruptedException |
| Immutable result (Record) | ScrapeResult record ensures results cannot be mutated after creation |
| HttpClient (thread-safe) | Single HttpClient instance shared across all threads |
| Category | Item | Purpose |
|---|---|---|
| Creating Threads | extends Thread |
Legacy approach -- override run() |
| Creating Threads | implements Runnable |
Preferred -- separates task from thread |
| Creating Threads | () -> { ... } (lambda) |
Concise Runnable for simple tasks |
| Thread Control | start() |
Creates a new thread and calls run() on it |
| Thread Control | join() |
Wait for a thread to finish |
| Thread Control | sleep(ms) |
Pause the current thread |
| Thread Control | interrupt() |
Signal a thread to stop (cooperative) |
| Synchronization | synchronized |
Mutual exclusion -- one thread at a time |
| Synchronization | volatile |
Visibility -- writes visible to all threads immediately |
| Communication | wait() / notify() |
Inter-thread signaling (use inside synchronized) |
| Thread Pools | ExecutorService |
Manage a pool of reusable threads |
| Thread Pools | submit(Callable) |
Submit a task that returns a Future |
| Thread Pools | shutdown() + awaitTermination() |
Graceful pool shutdown |
| Results | Callable<V> |
Task with a return value and checked exceptions |
| Results | Future<V> |
Handle to an async result -- get() to retrieve |
| Coordination | CountDownLatch |
Wait for N events to occur (one-shot) |
| Coordination | CyclicBarrier |
All threads wait at a barrier, then proceed together (reusable) |
| Coordination | Semaphore |
Limit concurrent access to N permits |
| Atomic Operations | AtomicInteger / AtomicLong |
Lock-free thread-safe counters |
| Atomic Operations | AtomicReference<V> |
Lock-free thread-safe reference |
| Concurrent Collections | ConcurrentHashMap |
Thread-safe map with high concurrency |
| Concurrent Collections | CopyOnWriteArrayList |
Thread-safe list for read-heavy workloads |
| Concurrent Collections | BlockingQueue |
Thread-safe queue for producer-consumer |
| Virtual Threads | Thread.ofVirtual().start(...) |
Lightweight thread for I/O-bound tasks (Java 21+) |
| Virtual Threads | Executors.newVirtualThreadPerTaskExecutor() |
Pool that creates a virtual thread per task |