Concurrency · LLD
Immutability as a concurrency tool
Immutability means once a value object is created, nobody can change its fields. Example: both gates print a fee of twenty rupees an hour. Gate A does not lock Money. Gate B does not lock TicketId. They pass those values around safely because nobody can change a Money amount that another thread already holds.
The shared board of free spots still needs a concurrency story. Money and TicketId do not — they are immutable values. Make the easy parts immutable so the remaining critical section is small enough to name.
The real question
What can I make unable to change, so I do not have to lock it?
Share freely
Coins
should be values. You do not occupy a Money. You with another Money if the fee changes. Every thread may hold the same instance.
Still needs a story
The board
Occupancy, ticket status, the cache recency list. These change. Immutability shrinks the lock; it does not delete occupy. You still need an atomic arrow on the spot.
A value you can share
Reason first: if nothing can change after construction, there is no check-then-act on this object. Then the Java is a final class with final fields and operations that return new instances.
final class Money {
private final long paise;
private final String currency;
Money(long paise, String currency) {
this.paise = paise;
this.currency = currency;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException();
return new Money(paise + other.paise, currency);
}
}
// No lock. Both gates may hold the same Money.How the lock shrinks
Before: every park() reads the hourly rate under the lot lock “because shared.” After: the rate is a Money published once (or swapped as a whole under a tiny CAS). The lock, if any, covers only pick-and-occupy. Saturday’s queue is shorter because the receipt math does not wait on occupy, and occupy does not wait on math.
Freeze values
Fee, id, type, timestamps as Instant you pass in — not a live clock field you mutate.
Publish once
Construct, then share. final fields plus a concurrent queue is safe publication.
Name the leftover
Only occupancy and status arrows remain. Those get CAS or a small lock.
final is not a freeze spell
on a class stops subclassing. final on a field stops reassignment. A final List<Floor> that you still add to is shared mutable state. A getter that returns the internal list of floors lets Gate B clear() your lot. Freeze the fields. Freeze what they point to, or copy on the way out. Prefer new objects over setters.
final class ParkingLot {
private final List<Floor> floors; // reference frozen
List<Floor> getFloors() {
return floors; // live guts — Gate B can add()
}
List<Floor> getFloorsSafe() {
return List.copyOf(floors); // snapshot
}
}Even copyOf is a snapshot of mutable Floor objects if Floor is not itself frozen. Either make Floor immutable, or do not expose it. park() as a command is usually enough.
Hand-off is easier too
Put an immutable ParkedEvent on a queue. Workers may read it without locking. That is of a finished picture. If the event carried a live Ticket you might still mutate, you just moved the race into the logger. at the boundary.
final class ParkedEvent {
private final TicketId id;
private final SpotId spot;
private final Instant at;
ParkedEvent(TicketId id, SpotId spot, Instant at) {
this.id = id;
this.spot = spot;
this.at = at;
}
}
queue.put(new ParkedEvent(ticket.id(), spot.id(), clock.instant()));
// workers only read. Gate must not keep a setter on this event.Date, arrays, and other fake values
java.util.Date looks like a timestamp and mutates. Two gates sharing a Date and calling setTime is a race with extra legacy. Instant, LocalDate, Money, TicketId — pass them in, never mutate. A byte[] feeTable field is a live array even if the field is final. Copy it in the constructor, or wrap it so no setter exists. String is already a value; intern() is not a concurrency strategy.
final class RateCard {
private final Instant effectiveFrom;
private final Money hourly;
private final int[] brackets; // still mutable guts
RateCard(Instant from, Money hourly, int[] brackets) {
this.effectiveFrom = from;
this.hourly = hourly;
this.brackets = brackets.clone();
}
}Records in modern Java are a shorthand for this shape: final fields, no setters, equals by value. They still copy arrays shallowly. If a component is a List you built as ArrayList, the record is not a freeze. Put List.copyOf in the constructor, or do not accept a List.
Where immutability stops
You cannot make the parking board immutable and still park. You can make each ParkedEvent immutable. The leftover mutable bits should be few enough to list on a whiteboard: occupancy per spot, ticket status, maybe a ConcurrentHashMap of tickets. That list is the thread-safety page. This page earned its keep when the fee, the id, and the event stopped appearing on that list.
Common mistakes
final class, mutable guts
final List<Floor> floors that you still add to is shared mutable state. final means the reference cannot change. The list still can.
Returning the live collection
getSpots() that returns the internal ArrayList lets a gate occupy by mutating your list. Return a copy, or an unmodifiable view, or do not expose the list.
Mutable Money to save an allocation
addInPlace() on a shared fee is a race with extra cleverness. Allocate the new Money. Occupancy is the expensive story, not the coin.
Clock as a value, not a field you poke
parkedAt as Instant passed into park() is a value. A mutable clock field you set and later overwrite is shared state. Tests inject a Clock. Production uses Clock.systemUTC(). Neither is LocalDateTime.now() scattered in the domain. The timestamp on a ParkedEvent is frozen at construction. Two workers may read it. Nobody calls setParkedAt. That is immutability doing concurrency work without a lock.
Fee calculation returns a new Money. Tax is plus, not addInPlace. The receipt is a new object. The only leftover writes are occupancy and status. You can point at them. That is the point of this page.
Exercises
- Circle every field on Ticket. Which can be values? Which arrows still need CAS or a lock?
- A Date field you set at park and update at exit. What goes wrong if two threads read it while you setTime? What Instant design avoids that?
- You return Collections.unmodifiableList(spots) but ParkingSpot is mutable. Did you freeze occupancy?
- A record RateCard(Instant from, Money hourly, List<Integer> brackets) built with a live ArrayList. What still mutates?
Circle every type on Ticket. Values get no lock. Arrows get CAS. Collections you expose get a copy or no getter. That circling is the whole page, applied.
Prefer Instant passed in over a Date you poke. Prefer plus() over addInPlace(). Prefer a ParkedEvent that cannot change over a live Ticket on the logger’s queue.
Read next
Where freeze still lies
A record of List<Spot> is not frozen. An unmodifiable wrapper over a list you still add to from another field is not frozen. A Money with a mutable Currency enum holder you invented is not frozen. Cloning a Date still gives a Date; Instant was the fix. Publishing an immutable event then mutating the Ticket it pointed at by id is a race on the entity, not on the event — the event was honest; the index was not.
Identity-sensitive caches of supposedly immutable objects that you then intern and mutate are a classic lie. If two gates share a Money instance, they may, because it cannot change. If you later add a setter for “debug,” you owe a new story to every holder.
Hash codes of mutable keys in a ConcurrentHashMap are a different disaster. TicketId as a value object belongs as a key. A mutable Ticket as a key does not.
Takeaway
Make value objects immutable so you do not need to lock them. final fields are not enough if nested collections still mutate. Publish a finished object, then stop writing it. Occupancy still needs an atomic update. Immutability just made that update the only thing left to protect.
If a type has a setter, it is not a coin. If a getter returns live guts, the freeze was a costume. Two gates may share Money. They may not share a Date you still poke, a List you still add to, or a Ticket you still markClosed after the queue has it.
Instant in, Clock injected, plus() not addInPlace(), copyOf at the boundary. The leftover list of mutable facts should fit on one sticky note. If it does not, you froze the wrong things.
HashMap keys must be values too. A mutable Ticket as a key is a different race. TicketId belongs there. The entity does not.
Instant in, Clock injected, plus() not addInPlace(), copyOf at the boundary. The leftover list of mutable facts should fit on one sticky note. If it does not, you froze the wrong things.