Concurrent Priority Queue (Skiplist-Based)
Lock-free priority queue is hard, heap-based requires global rebalance. Skiplist-based works: probabilistic levels, ordered by priority, CAS-based insert/delete. Java's ConcurrentSkipListMap is the production-grade impl. Use it; don't roll a custom one.
When a concurrent priority queue is needed
A priority queue lets a producer hand work to a consumer with the consumer always picking the highest-priority item next. The cases that need it tend to be a small set:
- A job scheduler that runs higher-priority jobs first.
- An event-driven simulator that processes events in timestamp order.
- A network packet scheduler that drains high-priority queues first.
- A database query optimiser that processes its lowest-cost candidate plan first.
For most of these, Java's PriorityBlockingQueue is enough. It is a binary heap behind a single lock, well-tested and predictable. Only reach for a lock-free skiplist-backed design after measurement shows the heap's lock is the actual bottleneck. Lock-free priority queues are not free; the skiplist uses several times more memory than a heap and has worse cache locality.
The skiplist, in a picture
A skiplist is a sorted linked list with express lanes stacked on top of it. The bottom level (level 0) contains every item in sorted order. Each higher level contains a random half of the level below it. The result is a structure that has the same O(log n) search cost as a balanced tree, but with much simpler concurrent updates.
H is the head sentinel. The same physical key (for example 50) is referenced once per level it appears in; the diagram shows one node per occurrence to keep the picture readable.
Searching for 35 takes the express lanes first and drops to the local roads at the end:
| Level | Walk | Result |
|---|---|---|
| 3 | from H, next is 50, too big | drop down |
| 2 | from H, walk right past 20 to 50, too big | drop down to level 1 from the 20 node |
| 1 | from 20, next is 35 | found |
The total number of comparisons is roughly log₂(N), the same as a balanced tree, because every level halves the search space.
Inserting picks a random level for the new node (50% chance of just level 1, 25% chance of level 2, 12.5% of level 3, and so on), then splices the new node into every level it appears in. Pop-min reads the first real node at level 0.
The reason this works as a lock-free design: inserts are local. The only pointers that change are the ones in the new node and its predecessors at each level it occupies. There is no global rebalance like a red-black tree would need. Each level's splice is a single CAS (compare the predecessor's next pointer, swap it to the new node), which is exactly the operation a lock-free retry loop is built around.
Java's ConcurrentSkipListMap
Java's ConcurrentSkipListMap is the production-grade implementation of this idea. The properties that matter:
- Reads are lock-free. Traversal walks the levels with plain atomic loads. Concurrent inserts do not block readers.
- Inserts and deletes are CAS-based at each level the node touches. No lock acquisition.
- Average cost is
O(log n)forget,put, andremove, the same as a balanced tree. - Iterators are weakly consistent. They never throw
ConcurrentModificationException, but they may or may not reflect concurrent updates that happen during the iteration. - Memory overhead is real. Around four times the memory of a
TreeMapfor the same number of entries, because every node needs a pointer per level and the multi-level structure has empty slots.
For a priority queue, the idiomatic shape is a ConcurrentSkipListMap<Integer, ConcurrentLinkedQueue<Task>>. The outer map is sorted by priority; the inner queue holds all tasks at that priority in FIFO order. firstEntry() reads the highest-priority bucket, poll() on its queue takes the oldest task at that priority, and an empty bucket gets cleaned up with a race-tolerant remove.
When to roll a custom one
Almost never. ConcurrentSkipListMap, PriorityBlockingQueue, JCTools' lock-free queues, and the Disruptor between them cover almost every production need. A hand-rolled lock-free priority queue is the right answer only when both of these are true: (a) measurement has shown that the standard option is the actual bottleneck, and (b) there is a specialised requirement (per-priority memory limits, custom eviction policy, fixed-size pre-allocated buckets) that the standard option cannot satisfy.
Implementations
Java's PriorityBlockingQueue is a thread-safe binary heap with a single lock. Not lock-free, but production-tested. For most use cases, this is the right answer. Lock-free alternatives are exotic.
1 import java.util.concurrent.PriorityBlockingQueue;
2
3 record Task(int priority, String name) implements Comparable<Task> {
4 @Override public int compareTo(Task o) {
5 return Integer.compare(this.priority, o.priority);
6 }
7 }
8
9 PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
10 queue.offer(new Task(3, "low"));
11 queue.offer(new Task(1, "high"));
12 queue.offer(new Task(2, "med"));
13
14 Task next = queue.take(); // returns Task(1, "high"), blocks if emptySkiplist-backed; CAS-based insert and delete; lock-free reads. Use as a sorted map keyed by priority; values are tasks. Better than PBQ for read-heavy mixed workloads.
1 import java.util.concurrent.ConcurrentSkipListMap;
2
3 // Multi-level priority, could have many tasks per priority
4 ConcurrentSkipListMap<Integer, ConcurrentLinkedQueue<Task>> queue =
5 new ConcurrentSkipListMap<>();
6
7 void offer(Task task) {
8 queue.computeIfAbsent(task.priority(), k -> new ConcurrentLinkedQueue<>())
9 .offer(task);
10 }
11
12 Task poll() {
13 while (!queue.isEmpty()) {
14 var entry = queue.firstEntry();
15 if (entry == null) return null;
16 Task t = entry.getValue().poll();
17 if (t != null) return t;
18 // Bucket emptied, clean up (with race tolerance)
19 queue.remove(entry.getKey(), entry.getValue());
20 }
21 return null;
22 }Key points
- •Heap-based PQ: hard to make lock-free (global rebalancing on every op)
- •Skiplist: probabilistic balanced structure, naturally lock-free
- •Insert: choose random level, CAS pointers in at each level
- •Java's ConcurrentSkipListMap is the canonical implementation
- •PriorityBlockingQueue (Java) is heap-based with a single lock, fine for moderate scale
Follow-up questions
▸Why is heap-based PQ hard to make lock-free?
▸Skiplist vs B-tree for ordered concurrent map?
▸When does PriorityBlockingQueue bottleneck?
▸How does this compare to Disruptor?
Gotchas
- !PriorityBlockingQueue.iterator() is NOT in priority order, only poll() is
- !ConcurrentSkipListMap.firstEntry() is a snapshot, value may have been polled by another thread by the time it's used
- !Skiplist memory overhead: ~4x of equivalent array (multi-level pointers per node)
- !size() on ConcurrentSkipListMap is O(n); don't call in hot paths