Edit: Changed bottom sentinel, fixed a bug
First, don't iterate the entire map to find an available ID. You should only need constant time to do it.
What you could do to make it fast is to do this:
Create an int index = 1;
for your counter. This is technically the number of IDs generated + 1, and is always > 0
.
Create a ArrayDeque<Integer> free = new ArrayDeque<>();
to house the free IDs. Guaranteed constant access.
When you allocate an ID, if the free ID queue is empty, you can just return the counter and increment it (i.e. return index++;
). Otherwise, grab its head and return that.
When you release an ID, push the previously used ID to the free
deque.
Remember to synchronize
your methods.
This guarantees O(1)
allocation and release, and it also keeps allocation quite low (literally once per free
). Although it's synchronized, it's fast enough that it shouldn't be a problem.
An implementation might look like this:
import java.util.ArrayDeque;
public class IDPool {
int index = 1;
ArrayDeque<Integer> free = new ArrayDeque<>();
public synchronized int acquire() {
if (free.isEmpty()) return index++;
else return free.pop();
}
public synchronized void release(id) {
free.push(id);
}
}
Additionally, if you want to ensure the free ID list is unique (as you should for anything important) as well as persistent, you can do the following:
Use an HashMap<Integer id, Integer prev>
to hold all generated IDs. Remember it doesn't need to be ordered or even iterated.
- This is technically going to be a stack encoded inside a hash map.
- Highly efficient implementations of this exist.
- In reality, any unordered
int
-> int
map will do here.
Track the top
ID for the free ID set. Remember that 1 can represent nothing and zero used, so you don't have to box it. (IDs are always positive.) Initially, this would just be int top = 1;
When allocating an ID, if there are free IDs (i.e. top >= 2
), do the following:
- Set the new
top
to the old head
's value in the free
map.
- Set the old
top
's value in the map to 0, marking it used.
- Return the old
top
.
When releasing an old ID, do this instead:
- If the old ID is already in the pool, return early, so we don't corrupt it.
- Set the ID's value in the map to the old
top
.
- Set the new
top
to the ID, since it's always the last one to use.
The optimized implementation would end up looking like this:
import java.util.HashMap;
public class IDPool {
int index = 2;
int top = 1;
HashMap<Integer, Integer> pool = new HashMap<>();
public synchronized int acquire() {
int id = top;
if (id == 1) return index++;
top = pool.replace(id, 0);
return id;
}
public synchronized void release(id) {
if (pool.getOrDefault(id, 1) == 0) return;
pool.put(id, top);
top = id;
}
}
If need be, you could use a growable integer array instead of the hash map (it's always contiguous), and realize significant performance gains. Matter of fact, that is how I'd likely implement it. It'd just require a minor amount of bit twiddling to do so, because I'd maintain the array's size to be rounded up to the next power of 2.
Yeah...I had to actually write a similar pool in JavaScript because I actually needed moderately fast IDs in Node.js for potentially high-frequency, long-lived IPC communication.
The good thing about this is that it generally avoids allocations (worst case being once per acquired ID when none are released), and it's very amenable to later optimization where necessary.