Thread Safety in OOP: Locks, Sync & Patterns

Updated 2026-03-11

TL;DR

Thread safety ensures that objects behave correctly when accessed by multiple threads simultaneously. This involves designing classes with immutability, proper synchronization, and careful management of shared state. Understanding thread safety is crucial for designing scalable systems and is frequently tested in system design interviews.

Prerequisites: Understanding of basic OOP concepts (classes, objects, encapsulation), familiarity with multi-threading basics (what threads are, concurrent execution), and knowledge of mutable vs immutable data structures.

After this topic: Design thread-safe classes using immutability and synchronization patterns, identify shared state pitfalls in concurrent scenarios, and articulate when and how to address concurrency concerns in low-level design interviews.

Core Concept

What is Thread Safety?

Thread safety means that a class or object functions correctly when accessed by multiple threads concurrently, regardless of scheduling or interleaving of thread execution. A thread-safe class protects its internal state from race conditions, data corruption, and inconsistent reads.

Why Thread Safety Matters

In modern applications, multiple threads often access shared objects simultaneously. Without thread safety:

  • Race conditions occur when threads compete to modify shared data
  • Data corruption happens when partial updates are visible to other threads
  • Inconsistent state emerges when invariants are temporarily violated during updates

Thread safety is critical for servers handling concurrent requests, caching systems, connection pools, and any shared resource in a multi-threaded environment.

Core Strategies for Thread Safety

1. Immutability

The simplest approach: objects whose state cannot change after construction are inherently thread-safe. No synchronization needed because there’s nothing to corrupt.

2. Synchronization

Use locks (mutexes) to ensure only one thread accesses critical sections at a time. This prevents concurrent modifications but can impact performance.

3. Thread-Local Storage

Give each thread its own copy of data, eliminating sharing entirely. Useful for per-thread caches or context objects.

4. Atomic Operations

Use atomic primitives for simple operations like counters. These provide thread safety without explicit locks for specific operations.

Shared State Pitfalls

The root of most concurrency bugs is shared mutable state. When multiple threads can both read and write the same data, you need explicit coordination. Common pitfalls include:

  • Forgetting to synchronize all access paths to shared data
  • Holding locks for too long, causing performance bottlenecks
  • Creating deadlocks through circular lock dependencies
  • Assuming single operations are atomic when they’re not

Visual Guide

Race Condition Example

sequenceDiagram
    participant T1 as Thread 1
    participant Obj as Shared Object<br/>(balance=100)
    participant T2 as Thread 2
    
    T1->>Obj: Read balance (100)
    T2->>Obj: Read balance (100)
    T1->>T1: Calculate: 100 + 50 = 150
    T2->>T2: Calculate: 100 - 30 = 70
    T1->>Obj: Write balance (150)
    T2->>Obj: Write balance (70)
    
    Note over Obj: Final balance: 70<br/>Lost the +50 update!<br/>Should be 120

Without synchronization, concurrent updates can overwrite each other, leading to lost updates and incorrect state.

Thread Safety Strategies

graph TD
    A[Thread Safety Approaches] --> B[Immutability]
    A --> C[Synchronization]
    A --> D[Thread-Local]
    A --> E[Atomic Operations]
    
    B --> B1[No state changes<br/>after construction]
    C --> C1[Locks/Mutexes<br/>Synchronized methods]
    D --> D1[Each thread has<br/>own copy]
    E --> E1[Hardware-level<br/>atomic primitives]
    
    style B fill:#90EE90
    style B1 fill:#90EE90
    style C fill:#FFD700
    style C1 fill:#FFD700

Different strategies for achieving thread safety, with immutability being the safest but not always practical.

Examples

Example 1: Thread-Unsafe Counter (The Problem)

class UnsafeCounter:
    def __init__(self):
        self.count = 0
    
    def increment(self):
        # This looks atomic but it's actually three operations:
        # 1. Read self.count
        # 2. Add 1
        # 3. Write back to self.count
        self.count += 1
    
    def get_count(self):
        return self.count

# Simulating concurrent access
import threading

counter = UnsafeCounter()
threads = []

for _ in range(10):
    t = threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"Expected: 10000, Got: {counter.get_count()}")
# Output: Expected: 10000, Got: 9847 (varies each run)
# Lost updates due to race conditions!

What went wrong? Multiple threads read the same value, increment it, and write back, overwriting each other’s updates.

Example 2: Thread-Safe Counter with Lock

import threading

class SafeCounter:
    def __init__(self):
        self.count = 0
        self._lock = threading.Lock()  # Mutex for synchronization
    
    def increment(self):
        with self._lock:  # Only one thread can execute this block at a time
            self.count += 1
    
    def get_count(self):
        with self._lock:  # Even reads need synchronization!
            return self.count

# Same test as before
counter = SafeCounter()
threads = []

for _ in range(10):
    t = threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"Expected: 10000, Got: {counter.get_count()}")
# Output: Expected: 10000, Got: 10000 ✓

Java equivalent:

public class SafeCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

Try it yourself: Modify SafeCounter to add a decrement() method. Should it use the same lock?

Example 3: Immutable Thread-Safe Class

from typing import List

class ImmutablePoint:
    """A point in 2D space - thread-safe through immutability"""
    
    def __init__(self, x: float, y: float):
        self._x = x  # Convention: _ prefix indicates "don't modify"
        self._y = y
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    def translate(self, dx: float, dy: float) -> 'ImmutablePoint':
        """Returns a NEW point - doesn't modify this one"""
        return ImmutablePoint(self._x + dx, self._y + dy)
    
    def __repr__(self):
        return f"Point({self._x}, {self._y})"

# Usage - completely thread-safe, no locks needed
point = ImmutablePoint(10, 20)
print(point)  # Output: Point(10, 20)

new_point = point.translate(5, 5)
print(point)      # Output: Point(10, 20) - unchanged!
print(new_point)  # Output: Point(15, 25)

# Multiple threads can safely call methods on the same point
# because no method modifies the object's state

Python note: To enforce immutability more strictly, use __slots__ and properties without setters, or use dataclasses with frozen=True:

from dataclasses import dataclass

@dataclass(frozen=True)
class ImmutablePoint:
    x: float
    y: float
    
    def translate(self, dx: float, dy: float) -> 'ImmutablePoint':
        return ImmutablePoint(self.x + dx, self.y + dy)

# Attempting to modify raises an error:
point = ImmutablePoint(10, 20)
# point.x = 30  # FrozenInstanceError!

Try it yourself: Create an immutable Rectangle class with width and height. Add a scale(factor) method that returns a new scaled rectangle.

Example 4: Bank Account with Proper Synchronization

import threading
import time

class BankAccount:
    def __init__(self, initial_balance: float):
        self._balance = initial_balance
        self._lock = threading.Lock()
    
    def deposit(self, amount: float):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        
        with self._lock:
            # Simulate some processing time
            current = self._balance
            time.sleep(0.001)  # Database write delay
            self._balance = current + amount
    
    def withdraw(self, amount: float) -> bool:
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        
        with self._lock:
            if self._balance >= amount:
                current = self._balance
                time.sleep(0.001)  # Database write delay
                self._balance = current - amount
                return True
            return False
    
    def get_balance(self) -> float:
        with self._lock:
            return self._balance
    
    def transfer_to(self, other: 'BankAccount', amount: float) -> bool:
        """Transfer money to another account - needs careful locking!"""
        # WRONG: This can deadlock if two threads transfer in opposite directions
        # We'll show the correct version below
        with self._lock:
            if self._balance >= amount:
                with other._lock:
                    self._balance -= amount
                    other._balance += amount
                    return True
        return False

# Testing
account = BankAccount(1000)

def make_deposits():
    for _ in range(100):
        account.deposit(10)

def make_withdrawals():
    for _ in range(100):
        account.withdraw(5)

threads = [
    threading.Thread(target=make_deposits),
    threading.Thread(target=make_deposits),
    threading.Thread(target=make_withdrawals),
    threading.Thread(target=make_withdrawals),
]

for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"Final balance: {account.get_balance()}")
# Output: Final balance: 2000.0
# Started with 1000, added 2000 (2×100×10), subtracted 1000 (2×100×5)

Deadlock-free transfer (correct version):

class BankAccount:
    # ... (previous methods remain the same)
    
    def transfer_to(self, other: 'BankAccount', amount: float) -> bool:
        """Deadlock-free transfer using lock ordering"""
        # Always acquire locks in the same order (by object id)
        first_lock = self._lock if id(self) < id(other) else other._lock
        second_lock = other._lock if id(self) < id(other) else self._lock
        
        with first_lock:
            with second_lock:
                if self._balance >= amount:
                    self._balance -= amount
                    other._balance += amount
                    return True
        return False

Try it yourself: Add a transfer_between(from_account, to_account, amount) static method that safely transfers between any two accounts.

Common Mistakes

1. Synchronizing Only Writes, Not Reads

Mistake:

class BadCache:
    def __init__(self):
        self._data = {}
        self._lock = threading.Lock()
    
    def put(self, key, value):
        with self._lock:
            self._data[key] = value
    
    def get(self, key):
        # WRONG: No lock on read!
        return self._data.get(key)

Why it’s wrong: Even reads need synchronization when writes are happening. Without it, you might read partially updated data or trigger internal dictionary corruption in Python.

Fix: Synchronize all access to shared mutable state, including reads.

2. Assuming Single Operations Are Atomic

Mistake:

class BadCounter:
    def __init__(self):
        self.count = 0
    
    def increment(self):
        self.count += 1  # Looks atomic, but it's not!

Why it’s wrong: count += 1 is actually three operations: read, add, write. Another thread can interleave between these steps.

Fix: Use explicit synchronization or atomic operations. In Python, use threading.Lock(). In Java, use AtomicInteger. In C++, use std::atomic<int>.

3. Holding Locks While Calling External Code

Mistake:

class BadService:
    def __init__(self):
        self._data = []
        self._lock = threading.Lock()
        self._listeners = []
    
    def add_item(self, item):
        with self._lock:
            self._data.append(item)
            # WRONG: Calling external code while holding lock
            for listener in self._listeners:
                listener.on_item_added(item)  # What if this is slow or blocks?

Why it’s wrong: If a listener callback is slow or tries to acquire another lock, you create performance bottlenecks or deadlocks. You don’t control what external code does.

Fix: Release the lock before calling external code, or copy data needed for callbacks:

def add_item(self, item):
    with self._lock:
        self._data.append(item)
        listeners_copy = list(self._listeners)
    
    # Call listeners outside the lock
    for listener in listeners_copy:
        listener.on_item_added(item)

4. Inconsistent Lock Ordering (Deadlock)

Mistake:

# Thread 1:
with account_a._lock:
    with account_b._lock:
        # transfer from A to B

# Thread 2:
with account_b._lock:  # Different order!
    with account_a._lock:
        # transfer from B to A
# DEADLOCK: Each thread holds one lock and waits for the other

Why it’s wrong: When two threads acquire multiple locks in different orders, they can deadlock (each waiting for the other’s lock).

Fix: Always acquire locks in a consistent global order (e.g., by object ID or a designated ordering).

5. Forgetting to Make Fields volatile or Using Proper Memory Barriers

Mistake (Java/C++):

class BadFlag {
    private boolean flag = false;  // Missing 'volatile'
    
    public void setFlag() {
        flag = true;  // Other threads might never see this!
    }
    
    public boolean isFlag() {
        return flag;
    }
}

Why it’s wrong: Without volatile (Java) or proper memory ordering (C++), compilers and CPUs can cache values in registers. One thread’s write might never be visible to other threads.

Fix: Use volatile for simple flags, or use proper synchronization/atomic operations.

Python note: Python’s GIL (Global Interpreter Lock) provides some protection here, but you still need locks for compound operations. In truly concurrent Python (e.g., with C extensions or multiprocessing), you face the same issues as Java/C++.

Interview Tips

When to Discuss Thread Safety in LLD Interviews

Mention concurrency when:

  • The system explicitly handles multiple concurrent users/requests (web servers, APIs)
  • You’re designing shared resources (connection pools, caches, rate limiters)
  • The interviewer asks about scalability or performance under load
  • You’re designing stateful services that multiple threads will access

Don’t over-engineer: For single-threaded scenarios or when the interviewer hasn’t mentioned concurrency, focus on core design first. You can say: “In a production system, we’d need to make this thread-safe using [specific approach], but I’ll focus on the core logic first.”

How to Articulate Thread Safety Decisions

Use this framework:

  1. Identify shared state: “The cache will be accessed by multiple request-handling threads simultaneously.”
  2. Choose a strategy: “I’ll use a read-write lock here because reads are much more frequent than writes.”
  3. Explain trade-offs: “This adds some overhead, but prevents race conditions. For higher performance, we could use a concurrent hash map with lock striping.”

Specific Interview Scenarios

Scenario 1: Designing a Cache

  • Good answer: “I’ll use a ConcurrentHashMap (Java) or threading.Lock per cache entry (Python) to allow concurrent reads while protecting writes. For eviction, I’ll use a separate lock to avoid holding the main lock during cleanup.”
  • Red flag: Not mentioning thread safety at all when designing a shared cache.

Scenario 2: Singleton Pattern

  • Good answer: “I’ll use double-checked locking or a class-level initialization to ensure thread-safe lazy initialization.”
  • Show depth: Mention that Python’s module-level initialization is inherently thread-safe, while Java requires explicit synchronization.

Scenario 3: Rate Limiter

  • Good answer: “Each user’s token bucket needs atomic operations for decrementing tokens. I’ll use AtomicInteger or a lock per user bucket, not a global lock, to maximize concurrency.”

Code Review Red Flags to Mention

Show expertise by identifying these issues:

  • “This counter should use atomic operations or synchronization.”
  • “We need to ensure this read-modify-write sequence is atomic.”
  • “This could deadlock if two threads transfer between the same accounts in opposite directions.”
  • “Holding this lock while doing I/O will create a bottleneck.”

Language-Specific Points to Know

Python:

  • Mention the GIL and its implications (protects some operations but not compound ones)
  • Know when to use threading.Lock, threading.RLock, and queue.Queue
  • Be aware that multiprocessing bypasses the GIL for CPU-bound tasks

Java:

  • Know synchronized, volatile, java.util.concurrent package
  • Understand AtomicInteger, ConcurrentHashMap, ReentrantLock
  • Be able to discuss happens-before relationships

C++:

  • Know std::mutex, std::atomic, std::lock_guard
  • Understand memory ordering (acquire/release semantics)
  • Be aware of std::shared_mutex for reader-writer scenarios

Practice Phrases

  • “To make this thread-safe, I would…”
  • “The critical section here is… so I’ll protect it with…”
  • “This is inherently thread-safe because it’s immutable.”
  • “We need to synchronize here to prevent race conditions on…”
  • “For better performance, we could use optimistic locking / lock-free algorithms, but that adds complexity.”

Key Takeaways

  • Thread safety ensures correct behavior when multiple threads access shared objects concurrently — without it, you get race conditions, data corruption, and inconsistent state.

  • Immutability is the simplest path to thread safety — objects that can’t change after construction need no synchronization, making them inherently safe and easy to reason about.

  • Synchronize all access to shared mutable state, not just writes — reads can also see inconsistent data if not properly synchronized, and compound operations like count++ are never atomic.

  • Avoid holding locks while calling external code or doing I/O — this creates performance bottlenecks and potential deadlocks; release locks before callbacks or copy data needed outside critical sections.

  • In interviews, mention thread safety for shared resources and concurrent systems — articulate your strategy (immutability, locks, atomics), explain trade-offs, and show awareness of language-specific tools without over-engineering simple scenarios.