Mastering Unbounded and Bounded Queues in Java: When to Use Each

This article explains the concepts, characteristics, and ideal scenarios for unbounded and bounded queues in Java, provides step‑by‑step Maven setup and complete code examples for asynchronous task scheduling, event‑driven processing, and API rate‑limiting, and highlights practical considerations such as resource management and performance.

Java Architecture Stack
Java Architecture Stack
Java Architecture Stack
Mastering Unbounded and Bounded Queues in Java: When to Use Each

Unbounded Queue (Unbounded Queue)

An unbounded queue has no logical limit on the number of elements it can hold, so producers can keep adding items without blocking or throwing exceptions.

Key Characteristics

Dynamic Expansion : Grows memory as needed to accommodate more elements.

High Concurrency Friendly : Handles large volumes of requests without blocking.

Memory Consumption : Unlimited growth can lead to high memory usage or out‑of‑memory errors if not monitored.

Typical Use Cases

Task Scheduling : Producers enqueue tasks that consumers process asynchronously.

Event Handling : Event‑driven systems receive a flood of events and process them in separate consumer threads.

Sample Implementation – Asynchronous Task Scheduler

The following Maven dependency is required for logging (SLF4J):

<dependencies>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.32</version>
    </dependency>
</dependencies>

Java code (using LinkedBlockingQueue as an unbounded queue):

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class Task {
    private final String name;
    public Task(String name) { this.name = name; }
    public String getName() { return name; }
    @Override public String toString() { return "Task{name='" + name + "'}"; }
}

class TaskProducer implements Runnable {
    private final BlockingQueue<Task> taskQueue;
    public TaskProducer(BlockingQueue<Task> taskQueue) { this.taskQueue = taskQueue; }
    @Override public void run() {
        int count = 0;
        while (true) {
            try {
                Task task = new Task("Task-" + count++);
                System.out.println("Producing " + task);
                taskQueue.put(task);
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

class TaskConsumer implements Runnable {
    private final BlockingQueue<Task> taskQueue;
    public TaskConsumer(BlockingQueue<Task> taskQueue) { this.taskQueue = taskQueue; }
    @Override public void run() {
        while (true) {
            try {
                Task task = taskQueue.take();
                System.out.println("Consuming " + task);
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

public class AsyncTaskScheduler {
    public static void main(String[] args) {
        BlockingQueue<Task> taskQueue = new LinkedBlockingQueue<>();
        Thread producer = new Thread(new TaskProducer(taskQueue));
        Thread consumer = new Thread(new TaskConsumer(taskQueue));
        producer.start();
        consumer.start();
        try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        finally { producer.interrupt(); consumer.interrupt(); }
    }
}

Explanation of the Code

Task class : Represents a unit of work with a name and a readable toString() method.

TaskProducer : Implements Runnable, continuously creates new Task objects, prints a log, and puts them into the queue, pausing 100 ms between creations.

TaskConsumer : Also implements Runnable, repeatedly takes tasks from the queue, logs consumption, and simulates processing with a 200 ms sleep.

AsyncTaskScheduler : Sets up the shared LinkedBlockingQueue, starts producer and consumer threads, lets them run for 5 seconds, then interrupts both to shut down gracefully.

Event‑Driven Example Using an Unbounded Queue

The same pattern applies to event processing: events are produced, placed in a LinkedBlockingQueue, and consumed asynchronously.

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class Event {
    private final String message;
    public Event(String message) { this.message = message; }
    public String getMessage() { return message; }
    @Override public String toString() { return "Event{message='" + message + "'}"; }
}

class EventProducer implements Runnable {
    private final BlockingQueue<Event> eventQueue;
    public EventProducer(BlockingQueue<Event> eventQueue) { this.eventQueue = eventQueue; }
    @Override public void run() {
        int count = 0;
        while (true) {
            try {
                Event ev = new Event("Event-" + count++);
                System.out.println("Producing " + ev);
                eventQueue.put(ev);
                Thread.sleep(50);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

class EventConsumer implements Runnable {
    private final BlockingQueue<Event> eventQueue;
    public EventConsumer(BlockingQueue<Event> eventQueue) { this.eventQueue = eventQueue; }
    @Override public void run() {
        while (true) {
            try {
                Event ev = eventQueue.take();
                System.out.println("Consuming " + ev);
                Thread.sleep(100);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

public class EventDrivenSystem {
    public static void main(String[] args) {
        BlockingQueue<Event> eventQueue = new LinkedBlockingQueue<>();
        Thread producer = new Thread(new EventProducer(eventQueue));
        Thread consumer = new Thread(new EventConsumer(eventQueue));
        producer.start();
        consumer.start();
        try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        finally { producer.interrupt(); consumer.interrupt(); }
    }
}

Bounded Queue (Bounded Queue)

A bounded queue imposes a fixed maximum capacity. When the limit is reached, further insertions block or fail, providing natural flow‑control.

Key Characteristics

Fixed Capacity : Defined at creation; exceeding it blocks producers.

Flow Control : Prevents system overload by throttling incoming requests.

Memory Management : Caps memory usage, avoiding unbounded growth.

Typical Use Cases

Producer‑Consumer Model : Guarantees that producers cannot outpace consumers, protecting memory.

Rate Limiting : Controls request throughput in high‑traffic APIs.

Producer‑Consumer Example Using ArrayBlockingQueue

Maven dependency (same SLF4J as above) is required.

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

class Task {
    private final String name;
    public Task(String name) { this.name = name; }
    @Override public String toString() { return "Task{name='" + name + "'}"; }
}

class TaskProducer implements Runnable {
    private final BlockingQueue<Task> taskQueue;
    public TaskProducer(BlockingQueue<Task> taskQueue) { this.taskQueue = taskQueue; }
    @Override public void run() {
        int count = 0;
        while (true) {
            try {
                Task task = new Task("Task-" + count++);
                System.out.println("Producing " + task);
                taskQueue.put(task); // blocks if queue is full
                Thread.sleep(100);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

class TaskConsumer implements Runnable {
    private final BlockingQueue<Task> taskQueue;
    public TaskConsumer(BlockingQueue<Task> taskQueue) { this.taskQueue = taskQueue; }
    @Override public void run() {
        while (true) {
            try {
                Task task = taskQueue.take();
                System.out.println("Consuming " + task);
                Thread.sleep(200);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

public class ProducerConsumerProblem {
    public static void main(String[] args) {
        BlockingQueue<Task> taskQueue = new ArrayBlockingQueue<>(5); // capacity 5
        Thread producer = new Thread(new TaskProducer(taskQueue));
        Thread consumer = new Thread(new TaskConsumer(taskQueue));
        producer.start();
        consumer.start();
        try { Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        finally { producer.interrupt(); consumer.interrupt(); }
    }
}

Rate‑Limiting Example Using a Bounded Queue

This demonstrates how an ArrayBlockingQueue can throttle API requests.

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

class Request {
    private final String clientId;
    public Request(String clientId) { this.clientId = clientId; }
    @Override public String toString() { return "Request{clientId='" + clientId + "'}"; }
}

class RequestProducer implements Runnable {
    private final BlockingQueue<Request> requestQueue;
    public RequestProducer(BlockingQueue<Request> requestQueue) { this.requestQueue = requestQueue; }
    @Override public void run() {
        int count = 0;
        while (true) {
            try {
                Request req = new Request("Client-" + count++);
                System.out.println("Producing " + req);
                requestQueue.put(req); // blocks when full
                Thread.sleep(50);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

class RequestConsumer implements Runnable {
    private final BlockingQueue<Request> requestQueue;
    public RequestConsumer(BlockingQueue<Request> requestQueue) { this.requestQueue = requestQueue; }
    @Override public void run() {
        while (true) {
            try {
                Request req = requestQueue.take();
                System.out.println("Consuming " + req);
                Thread.sleep(100);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
        }
    }
}

public class RateLimitingControl {
    public static void main(String[] args) {
        BlockingQueue<Request> requestQueue = new ArrayBlockingQueue<>(5);
        Thread producer = new Thread(new RequestProducer(requestQueue));
        Thread consumer = new Thread(new RequestConsumer(requestQueue));
        producer.start();
        consumer.start();
        try { Thread.sleep(10000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        finally { producer.interrupt(); consumer.interrupt(); }
    }
}

Key Takeaways

Use an unbounded queue when you need to handle large, unpredictable workloads and can tolerate higher memory usage.

Choose a bounded queue when memory is limited or you must enforce strict flow‑control to prevent overload.

Both patterns rely on the producer‑consumer model, where producers add work items and consumers process them concurrently.

Proper thread management, graceful shutdown, and thoughtful exception handling are essential for robust implementations.

JavaconcurrencyRate LimitingProducer ConsumerqueueBounded QueueUnbounded Queue
Java Architecture Stack
Written by

Java Architecture Stack

Dedicated to original, practical tech insights—from skill advancement to architecture, front‑end to back‑end, the full‑stack path, with Wei Ge guiding you.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.