Hello and welcome back.
In the previous lesson, we designed the TokenBucket class, establishing its core components: capacity, refillRate, availableTokens, and lastRefillTimestamp. We also outlined the "lazy refill" logic, where tokens are replenished on-demand when a request arrives. A key takeaway was the need for atomicity to prevent race conditions in a multithreaded environment.
Today, we will turn that design into a concrete, thread-safe implementation. We will explore two fundamental Java mechanisms for managing concurrent access: the synchronized keyword and ReentrantLock. By the end of this lesson, you will have a working, lock-based token bucket implementation ready to be integrated into our rate limiter.
Your learning outcome for this lesson:
You will implement a lock-based token bucket using synchronized blocks or ReentrantLock.
1. The Need for Locking in Concurrent Systems
In our token bucket design, the availableTokens and lastRefillTimestamp fields represent shared, mutable state. Imagine two threads executing the tryConsume() method at nearly the same time:
- Thread A reads
availableTokens(e.g., 1.2 tokens). - Thread B reads
availableTokens(also 1.2 tokens). - Thread A determines it can consume a token, calculates
newTokens = 0.2, and prepares to write this value back. - Before Thread A can write, Thread B also determines it can consume a token, calculates
newTokens = 0.2, and writes this value back. - Thread A finally writes its calculated value of
0.2back.
The result is incorrect. Two requests were granted, but the token count was only decremented once. This is a classic race condition. To prevent this, we must ensure that the sequence of "read-modify-write" operations on our shared state is atomic—that is, it happens as a single, indivisible unit. Locking is the primary mechanism to achieve this.

2. Approach 1: The synchronized Keyword
The synchronized keyword is Java's built-in mechanism for ensuring that only one thread can execute a block of code at a time. It uses an intrinsic lock (also called a monitor lock) associated with every Java object. When a thread enters a synchronized block, it acquires the lock; when it exits, it releases the lock.
To understand how this works and why it's essential, let's watch a short video.
The Synchronized Keyword in Java Multithreading - Java Programming
The video 'The Synchronized Keyword in Java Multithreading' by Will Tollefson clearly demonstrates the problem of race conditions and how synchronized solves it.
Please watch the following segments: 00:00 - 03:36: This part explains the race condition with a practical example, showing how concurrent increments lead to an incorrect final count. 03:36 - 06:00: This covers both synchronized methods and synchronized blocks. Focus on the synchronized (this) block, as it gives us more granular control. 06:48 - 08:46: This segment explains the underlying concept of a monitor lock and why you must synchronize on a shared object.
Implementing the Token Bucket with synchronized
Based on the principles from the video, we can protect our tryConsume() logic by wrapping the critical section in a synchronized block. We use this as the lock object, ensuring that all threads attempting to consume a token from the same bucket instance will synchronize with each other.
Here is a simple implementation using this approach. Notice the allowRequest() method is marked synchronized, which is equivalent to wrapping the entire method body in synchronized(this).
The article 'Token Bucket - Rate Limiter' provides a very clear, minimal implementation that uses a synchronized method for thread safety. This is a great example of our first approach.
Please read the sections 'Core Components of the Token Bucket Algorithm in Code' and examine the TokenBucketRateLimiter.java code snippet. Pay close attention to the synchronized boolean allowRequest() method and the refill() logic it calls.
This implementation is simple and effective. The synchronized keyword handles all the complexities of acquiring and releasing the lock, even if an exception occurs.
3. Approach 2: ReentrantLock
While synchronized is convenient, the java.util.concurrent.locks package provides a more powerful and flexible locking mechanism: ReentrantLock. It offers the same basic mutual exclusion guarantees but with additional capabilities (which we will explore in later lessons).
Key characteristics of ReentrantLock:
- Explicit Locking: You must explicitly call
lock()to acquire the lock andunlock()to release it. - The
try-finallyIdiom: It is absolutely critical to place theunlock()call inside afinallyblock. This guarantees that the lock is released even if an exception is thrown within thetryblock, preventing a deadlock where other threads can never acquire the lock.
This next video explains the basics of ReentrantLock.
Java ReentrantLock - fairness, tryLock and more
The video 'Java ReentrantLock' by Defog Tech provides a great introduction to this locking mechanism.
Please watch the following segments: 00:00 - 04:15: This explains the fundamental concept of a lock and shows the basic lock() and unlock() pattern. 05:57 - 06:51: This is the most important part. It explains why using a try-finally block is mandatory to avoid deadlocks.
Implementing the Token Bucket with ReentrantLock
Now let's see how to apply ReentrantLock to our token bucket. This implementation will be slightly more verbose but also more explicit about the lock management.
Java Multithreading for Senior Engineering Interviews (Part II)
The article 'Java Multithreading for Senior Engineering Interviews (Part II)' provides an excellent, thread-safe TokenBucket implementation using ReentrantLock.
Please read the first section of the article, which begins with an introduction to the Token Bucket algorithm. Study the TokenBucket class implementation carefully. Note how lock.lock() is called at the beginning of the try block and lock.unlock() is called in the finally block within the getToken() method. (Note: The use of Condition is for blocking when tokens are empty; for our non-blocking tryConsume, we would simply return false.)
The code in the article demonstrates the correct and robust pattern for using ReentrantLock. For our rate limiter's tryConsume() method, which should return immediately, we would replace the while loop and awaitNanos call with a simple if condition, but the locking structure remains the same.
4. Comparing synchronized vs. ReentrantLock
Both synchronized and ReentrantLock can solve our immediate problem. So which one should you use? Here's a quick comparison.

Key takeaways from the comparison:
- Simplicity:
synchronizedis less verbose and easier to use for basic locking. - Explicitness:
ReentrantLockmakes lock acquisition and release explicit, which some developers find clearer. - Features:
ReentrantLockprovides advanced features like timed waits, interruptible lock acquisition, and fairness policies, which are essential for more complex concurrency scenarios.
For the purpose of our rate limiter, either would be a valid choice. However, using ReentrantLock demonstrates familiarity with the more modern java.util.concurrent library, which is a valuable skill.
5. Your Implementation Task
Now it's time to put this into practice. Let's create our lock-based TokenBucket class. We will use ReentrantLock for this implementation to practice the try-finally pattern.
The class should have the fields we designed in the last lesson and a method to attempt to consume a token. For now, we will create a single bucket instance. We will later adapt this to work with our RateLimiter strategy interface that you designed previously.
Here is the class structure. Your task is to complete the tryConsume() method.
import java.util.concurrent.locks.ReentrantLock;
public class TokenBucket {
private final long capacity;
private final double refillRate; // tokens per second
private double availableTokens;
private long lastRefillTimestamp;
private final ReentrantLock lock = new ReentrantLock();
public TokenBucket(long capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.availableTokens = capacity;
this.lastRefillTimestamp = System.nanoTime();
}
/**
* Attempts to consume one token from the bucket.
* This method is thread-safe.
*
* @return {@code true} if a token was consumed, {@code false} otherwise.
*/
public boolean tryConsume() {
lock.lock();
try {
// 1. Refill tokens based on elapsed time since lastRefillTimestamp.
// - Get the current time with System.nanoTime().
// - Calculate tokens to add.
// - Update availableTokens, ensuring it doesn't exceed capacity.
// - Update lastRefillTimestamp to the current time.
// 2. Check if a token is available (availableTokens >= 1).
// - If yes, decrement availableTokens by 1 and return true.
// - If no, return false.
// --- YOUR IMPLEMENTATION GOES HERE ---
return false; // Placeholder
} finally {
lock.unlock();
}
}
}
Instructions:
- Copy the code above into your IDE.
- Implement the logic inside the
tryblock of thetryConsume()method. - The logic is identical to what we reviewed in the articles: calculate elapsed time, compute new tokens, cap the total at
capacity, update the timestamp, and then check if a token can be consumed.
Take your time to implement this. This is a foundational piece of our rate limiter.
Conclusion
In this lesson, you've implemented a thread-safe, lock-based token bucket, a critical step toward our final goal.
Key Takeaways:
- Protecting shared, mutable state (like
availableTokens) is essential to prevent race conditions in concurrent applications. - Java provides two primary lock-based mechanisms for this: the
synchronizedkeyword andReentrantLock. synchronizedis simpler and relies on intrinsic object monitors.ReentrantLockis more explicit and flexible, requiring the developer to manage the lock's lifecycle, always using atry-finallyblock to guarantee the lock is released.
Next Steps
Locking is effective, but it can have performance implications. When a thread is waiting for a lock, it is blocked and cannot do any work. For high-throughput systems, an alternative is often preferred: lock-free programming.
In our next lesson, we will refactor our token bucket to a lock-free implementation using AtomicLong and compare-and-set (CAS) loops. We will then be able to explain the performance trade-offs between these two important concurrency models.