Resource Tracking with Counters and Semaphores in Java
Table of Contents
Resource Tracking #
It is sometimes required to restrict access to a particular resource, such as databases, network connections or files, to a limited number of threads. Uncontrolled access to these resources can lead to problems such as resource exhaustion, deadlocks, and poor performance or even in the case of files, data corruption. Many common resources already have libraries that handle these issues, such as connection pools, datasources, but sometimes it is necessary to implement resource tracking manually for custom resources.
For single threaded applications, resource tracking is relatively simple as only one thread will using the resource at a time, so that only one connection is required, or only one file is open at a time, etc.
However, in multi-threaded applications, multiple threads may need access to the same resource at the same time. Tracking the number of threads that are using a resource is more difficult in this case. One solution is to use counters to keep track of the number of threads that are using a resource, incrementing and decrementing the counter as threads acquire and release the resource.
Tracking Resources with Counters #
Using Synchronized Blocks #
A simple approach to a counter is to use a synchronized block to increment and decrement the counter. The synchronized block ensures that only one thread can access the counter at a time, so incrementing and decrementing the counter is thread-safe.
public class ResourceCounter {
private int count = 0;
private static int MAX = 10;
public synchronized SomeResource acquireResource() {
if (count < MAX) {
count++;
return new SomeResource();
} else {
return null;
}
}
public synchronized void releaseResource() {
count--;
}
public static void main(String[] args) {
ResourceCounter counter = new ResourceCounter();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
SomeResource resource = counter.acquireResource();
if (resource != null) {
System.out.println("Resource acquired");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
counter.releaseResource();
System.out.println("Resource released");
} else {
System.out.println("Resource not available");
}
}).start();
}
}
}
Although, this approach is simple and easy to understand, it has the disadvantage that the synchronized block can only be accessed by one thread at a time.
This can lead to contention and poor performance if there are many threads that are blocked waiting to access the resource.
Note that even if there is sufficient resources available, the threads are still blocked waiting for the synchronized lock to be released.
Using Atomic Counters #
A more efficient approach is to use an atomic counter, such as AtomicInteger. Atomic counters are faster than synchronized blocks because they use low-level atomic operations provided by the hardware to increment and decrement the counter. This means that multiple threads can access the counter at the same time without blocking each other.
public class ResourceAtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
private static int MAX = 10;
public SomeResource acquireResource() {
int current = count.incrementAndGet();
if (current < MAX) {
return new SomeResource();
} else {
count.decrementAndGet();
return null;
}
}
public void releaseResource() {
count.decrementAndGet();
}
}
To explain the above code, consider two simultaneous threads that call acquireResource(), and the count value is 0. The first thread increments the counter from 0 to 1 and returns the resource. The second thread also increments the counter from 1 to 2 and also returns the resource.
If the counter is currently at 9, and two more threads call acquireResource(), the first thread increments the counter from 9 to 10 and returns the resource. The second thread increments the counter from 10 to 11, but since the counter is now at the maximum value of 10, the second thread decrements the counter back to 10 and returns null.
There is no thread blocking in this approach, as the incrementAndGet() returns almost immediately, allowing the caller to do another action before trying to aquire the resource again. This is useful in case where multiple resources are required to process a task.
Atomic numbers use CAS #
The incrementAndGet() and decrementAndGet() methods are atomic operations that increment and decrement the counter respectively, and return the new value of the counter. These operations are guaranteed to be atomic, which means that they will not be interrupted by other threads. So the counter will always be in a consistent state, even when multiple threads are accessing it at the same time.
Java implements atomic operations using low-level hardware instructions, such as compare-and-swap (CAS), which are provided by the hardware. These instructions are much faster than using synchronized blocks, which require acquiring and releasing locks, and are more efficient for high-performance applications.
CAS works by comparing the current value of the counter with the expected value, and if they are the same, it updates the counter with a new value. If the current value of the counter has changed since the comparison, the operation is retried until it succeeds.
Acquiring Resources with Semaphores #
Another way to track resources is to use semaphores. Consider a semaphore as a counter that can be incremented and decremented, but with the added feature that if the counter is zero, the thread will block until the counter is greater than zero. The boilerplate code of keeping track of the counter is handled by the semaphore, which makes it easier to implement resource tracking.
A semaphore has two operations: acquire() and release(). The acquire() operation decrements the semaphore, and if the semaphore is zero, the thread blocks until the semaphore is greater
public SomeResource acquireResource() {
try {
semaphore.acquire();
return new SomeResource();
} catch (InterruptedException e) {
return null;
}
}
In the above code, the acquire() method decrements the semaphore, and if the semaphore is zero, the thread blocks until the semaphore is greater than zero. The release() method increments the semaphore, allowing other threads to acquire the resource.
As per the synchronized block example, threads that call acquireResource() will be blocked if the semaphore is at its maximum value of 10. However, the code is more efficient than using synchronized blocks because the semaphore is implemented using low-level atomic operations, similar to the atomic counter example.
However, the Semaphore class also has the tryAcquire() method, which allows the thread to return immediately if the semaphore is zero, rather than blocking as per AtomicInteger.
public class ResourceSemaphore {
private Semaphore semaphore = new Semaphore(10);
public SomeResource acquireResource() {
if (semaphore.tryAcquire()) {
return new SomeResource();
} else {
return null;
}
}
public void releaseResource() {
semaphore.release();
}
}
Conclusion #
The following table summarizes the different approaches to tracking resources in Java:
| Approach | Description | Blocking |
|---|---|---|
| Synchronized Blocks | Simple and easy to understand, but blocks threads | Yes |
| Atomic Counters | Efficient and non-blocking, but requires more complex code | No |
| Semaphores | Efficient and non-blocking, with built-in blocking support | Yes with aquire(), No with tryAcquire() |