BlockingQueue and Semaphore in Java: Solving the Producer-Consumer Problem

Contents
Introduction: BlockingQueue and Semaphore in Java
The Producer-Consumer problem is a classic synchronization scenario in multithreaded programming. In Java, two advanced techniques for solving this problem include using BlockingQueue and Semaphore. Both approaches offer elegant solutions, but their internals, performance implications, and use cases differ significantly.
While our previous guide covered synchronized
blocks, real-world systems often require more robust and scalable approaches.
Why synchronized
may not be enough?
- In high-traffic systems (e.g., thread pools handling 10K+ tasks/second), the overhead of
synchronized
blocks becomes a bottleneck. Example: A financial trading platform processing market data feeds. - Naive synchronisation can lead to thread starvation, where some threads wait indefinitely. Example: A priority task queue where high-value transactions must jump ahead.
- Modern architectures (Kafka, RabbitMQ) extend this pattern across networks. Example: Microservices communicating via message queues.
In this tutorial, we’ll take a deep dive into these two techniques, walk through working examples, and compare their advantages and disadvantages.
Approach 1: Producer-Consumer with BlockingQueue
In Java, the BlockingQueue
interface from the java.util.concurrent
package provides a high-level, thread-safe abstraction for coordinating data exchange between multiple threads. It is especially useful in producer-consumer scenarios where we need to safely share a bounded buffer between producer and consumer threads.
Unlike traditional collections, a BlockingQueue
includes built-in blocking behaviour, removing the need for manual synchronisation:
- A producer thread calling
put()
will block if the queue is full, until space becomes available. - A consumer thread calling
take()
will block if the queue is empty, until an element is available.
This behaviour prevents common multithreading issues such as race conditions, busy-waiting, and manual synchronisation errors.
Java Producer-Consumer with ArrayBlockingQueue
Let’s explore an example using ArrayBlockingQueue
, a fixed-size thread-safe queue implementation:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
/**
* Java BlockingQueue Example: Producer-Consumer Pattern Implementation
*
* This tutorial demonstrates thread-safe communication between producer and consumer threads
* using Java's BlockingQueue from java.util.concurrent package.
*
* Key Features:
* - Thread-safe queue operations
* - Automatic blocking when queue is full/empty
* - Efficient producer-consumer workflow
* - Graceful thread interruption handling
*/
public class BlockingQueueExample {
// Optimal queue capacity for demonstration
private static final int QUEUE_CAPACITY = 5;
// Control thread execution with volatile flag
private static volatile boolean running = true;
public static void main(String[] args) {
// Create a thread-safe blocking queue with fixed capacity
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
// Producer Thread: Generates sequential numbers
Thread producer = new Thread(() -> {
int value = 0;
try {
while (running && !Thread.currentThread().isInterrupted()) {
// Put value into queue (blocks if queue is full)
queue.put(value);
System.out.println("[Producer] Created item: " + value);
value++;
// Simulate production time
Thread.sleep(500);
}
} catch (InterruptedException e) {
System.out.println("Producer thread interrupted");
Thread.currentThread().interrupt();
} finally {
System.out.println("Producer thread terminating");
}
}, "Producer-Thread");
// Consumer Thread: Processes items from queue
Thread consumer = new Thread(() -> {
try {
while (running && !Thread.currentThread().isInterrupted()) {
// Take value from queue (blocks if queue is empty)
int data = queue.take();
System.out.println("[Consumer] Processed item: " + data);
// Simulate consumption time (longer than production)
Thread.sleep(1000);
}
} catch (InterruptedException e) {
System.out.println("Consumer thread interrupted");
Thread.currentThread().interrupt();
} finally {
System.out.println("Consumer thread terminating");
}
}, "Consumer-Thread");
// Start both threads
producer.start();
consumer.start();
// Add shutdown hook for clean termination
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
running = false;
producer.interrupt();
consumer.interrupt();
System.out.println("\nShutting down producer-consumer example...");
}));
}
}
The following behavior can be observed when running the code:

How BlockingQueue Solves the Producer-Consumer Problem
The producer-consumer pattern has a simple concept but tricky implementation:
- One thread creates data (producer)
- Another thread uses that data (consumer)
- They need to share data safely without stepping on each other’s toes
The Problems BlockingQueue Fixes:
- Different Speed Problem
In real apps, producers and consumers work at different speeds. Here, the producer makes items faster (every 500ms) than the consumer can use them (every 1000ms). WithoutBlockingQueue
:- The fast producer would overfill memory
- The slow consumer would miss items
- Thread Collision Problem
Regular queues aren’t thread-safe. If both threads access the queue at the same time:- Data can get corrupted
- Some items might disappear
- The program might crash randomly
How BlockingQueue
Works:
- When the queue is full
If the producer tries to add an item when the queue is full:
→ The producer thread automatically pauses (blocks)
→ It wakes up only when space becomes available
→ No lost items, no crashes - When the queue is empty
If the consumer tries to take an item when empty:
→ The consumer thread automatically pauses
→ It wakes up when new items arrive
→ No busy waiting that wastes CPU - Built-in Safety
All operations are automatically synchronized:
→ No manual locks needed
→ No race conditions
→ No deadlocks from improper locking
Approach 2: Producer-Consumer with Semaphores
In Java, the Semaphore
class from the java.util.concurrent
package provides a flexible way to control access to shared resources using “permits“. While BlockingQueue
abstracts away the low-level synchronisation, using Semaphores gives us full control over the coordination logic. For our producer-consumer problem.
We’ll use two semaphores:
- One to track empty slots (where producers can put items)
- One to track filled slots (where consumers can take items)
Key Differences from BlockingQueue
:
- More Control: Semaphores give us finer control over thread coordination
- Manual Management: We need to handle the buffer synchronisation ourselves
- Flexibility: Can implement more complex patterns beyond simple queues
Key Semaphores Used
Semaphore | Purpose | Initial Value |
---|---|---|
emptySlots | Counts available empty slots (for producer) | Size of buffer |
filledSlots | Counts available filled slots (for consumer) | 0 |
bufferLock | Ensures only one thread modifies the buffer | 1 |
Java Producer-Consumer with Semaphores
and Synchronized blocks
We’ll use Semaphore
for this example:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.Semaphore;
/**
* Producer-Consumer Solution Using Semaphores and Synchronized Blocks
*
* Demonstrates thread coordination using semaphores instead of BlockingQueue.
*
* Key Features:
* - Uses counting semaphores for empty/full control
* - Mutual exclusion on shared buffer via synchronized block (acts as a mutex)
* - Same thread safety guarantees as BlockingQueue
* - More flexible for complex scenarios
*/
public class SemaphoresExample {
private static final int BUFFER_SIZE = 5;
private static volatile boolean running = true;
// Shared buffer and its lock
private static final Queue<Integer> buffer = new LinkedList<>();
private static final Object bufferLock = new Object();
// Semaphores to track empty and full slots
private static final Semaphore emptySlots = new Semaphore(BUFFER_SIZE);
private static final Semaphore filledSlots = new Semaphore(0);
public static void main(String[] args) {
// Producer Thread
Thread producer = new Thread(() -> {
int value = 0;
try {
while (running && !Thread.currentThread().isInterrupted()) {
emptySlots.acquire(); // Wait for empty slot
synchronized (bufferLock) {
buffer.add(value);
System.out.println("[" + Thread.currentThread().getName() + "] Created item: " + value);
value++;
}
filledSlots.release(); // Signal new item available
Thread.sleep(500);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("Producer thread terminating");
}
}, "Producer-Thread");
// Consumer Thread
Thread consumer = new Thread(() -> {
try {
while (running && !Thread.currentThread().isInterrupted()) {
filledSlots.acquire(); // Wait for available item
int data;
synchronized (bufferLock) {
data = buffer.remove();
}
System.out.println("[Consumer] Processed item: " + data);
emptySlots.release(); // Signal slot freed up
Thread.sleep(1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println("Consumer thread terminating");
}
}, "Consumer-Thread");
producer.start();
consumer.start();
// Shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
running = false;
producer.interrupt();
consumer.interrupt();
System.out.println("\nShutting down semaphore example...");
}));
}
}
When the code is executed, we observe the following:

How Semaphores Solve the Producer-Consumer Problem
- Empty Slots Semaphore
- Starts with count = buffer size (5)
- A producer must “acquire” before adding an item
- Consumer “releases” after taking an item
- Filled Slots Semaphore
- Starts with count = 0
- Consumer must “acquire” before taking an item
- Producer “releases” after adding an item
Step-by-Step Flow:
Producer Side:
- Waits for an empty slot (
emptySlots.acquire()
) - Locks the buffer and adds the item
- Signals a new item is available (
filledSlots.release()
)
Consumer Side:
- Waits for the available item (
filledSlots.acquire()
) - Locks buffer and removes the item
- Signals empty slot available (
emptySlots.release()
)
BlockingQueue and Semaphore: Key Differences
Feature | BlockingQueue | Semaphore Approach |
---|---|---|
Ease of Use | Simple, built-in API | More complex, needs manual control |
Synchronization | Automatic, thread-safe | Manual (using synchronized + semaphores) |
Buffer Management | Built-in (no manual lock or queue needed) | Custom queue + semaphores + locking needed |
Flexibility | Limited to queue semantics | Very flexible; can model other patterns |
Performance | Highly optimized internally | Slightly slower due to manual coordination |
Learning Curve | Low (ideal for beginners) | Moderate (needs understanding of concurrency concepts) |
Common BlockingQueue and Semaphore Interview Questions
Here are some frequently asked interview questions and concepts related to both:
1. How does BlockingQueue
handle thread synchronisation internally?
- It uses a
ReentrantLock
to provide mutual exclusion. - Two
Condition
variables are used:notFull
(for producers) andnotEmpty
(for consumers). put()
waits onnotFull
if the queue is full.take()
waits onnotEmpty
if the queue is empty.- Producers signal
notEmpty
after adding an element. - Consumers signal
notFull
after removing an element.
2. What happens if multiple producers and consumers use the same BlockingQueue
?
- The queue remains thread-safe due to locks and conditions.
- Multiple producers block when the queue is full.
- Multiple consumers block when the queue is empty.
- Execution order depends on JVM thread scheduling.
3. Can BlockingQueue
cause a deadlock? If yes, how?
- Normally, no, because it uses proper
ReentrantLock
andCondition
waiting mechanisms. - Deadlocks can occur if threads are not careful, e.g., holding other locks while calling blocking methods on the queue.
4. What’s the difference between put()
/take()
and offer()
/poll()
?
Method | Behavior |
---|---|
put() | Blocks if the queue is full |
take() | Blocks if the queue is empty |
offer() | Returns false immediately if the queue is full (non-blocking) |
poll() | Returns null immediately if the queue is empty (non-blocking) |
5. How would you implement a BlockingQueue
from scratch?
- Use a
Queue
(e.g.,LinkedList
). - Use a
ReentrantLock
to provide mutual exclusion. - Use two
Condition
variables:notFull
andnotEmpty
. put()
waits onnotFull
, adds the item, then signalsnotEmpty
.take()
waits onnotEmpty
, removes the item, then signalsnotFull
.
6. Can a Semaphore
cause a deadlock? How?
Yes, if threads acquire semaphores in an inconsistent order.
Example:
- Thread 1: acquire semaphore A, then semaphore B.
- Thread 2: acquire semaphore B, then semaphore A.
If both threads hold one semaphore and wait for the other, a deadlock occurs.
7. How would you implement a producer-consumer problem using Semaphore
?
- Use two counting semaphores:
emptySlots
(initialised to buffer size)filledSlots
(initialised to0
)
- Producer: acquire
emptySlots
, add item to buffer, releasefilledSlots
. - Consumer: acquire
filledSlots
, remove item from buffer, releaseemptySlots
.
8. Can you use a single Semaphore
instead of two for producer-consumer?
No, because:
- A single semaphore cannot track both empty and filled slots.
- You need two separate conditions (like in
BlockingQueue
).
Conclusion: BlockingQueue and Semaphore in Java
For most producer-consumer scenarios, BlockingQueue
is the simplest, safest choice. Use Semaphores when we need fine-grained control over custom synchronisation.
In the next post, we’ll explore a more advanced version of the Producer-Consumer pattern using:
ReentrantLock
(an explicit and flexible version ofsynchronized
)Condition
(a more powerful version ofwait()
/notify()
)
Share this content:
Leave a Comment