Quick Answer

Prefer implementing Runnable or Callable over extending Thread, and hand tasks to an ExecutorService instead of calling new Thread yourself. Calling t.run() executes on the current thread; only start() creates a new one. count++ is a read, an add and a write, so two threads lose updates unless you use AtomicInteger or a lock. volatile fixes visibility, never atomicity. synchronized locks an object, so two instances do not exclude each other and a static field needs a static lock.

Thread, Runnable, and why start() matters

There are two ways to give the JVM some work: extend Thread and override run, or implement Runnable and hand it to something that runs it.

class Downloader extends Thread {
    @Override public void run() { fetch(); }
}

Runnable job = () -> fetch();
new Thread(job).start();

Prefer Runnable. Your class stays free to extend something else, and more importantly the task is separated from the machinery that runs it, so the same Runnable can go to a thread, to an ExecutorService, or to a test that runs it inline.

Now the trap that appears in more interviews than any other concurrency question.

Thread t = new Thread(() -> System.out.println(Thread.currentThread().getName()));

t.run();     // prints "main" - an ordinary method call, no new thread
t.start();   // prints "Thread-0" - the JVM actually creates a thread

run() is just a method. Only start() asks the operating system for a thread and then calls run on it. Code that calls run() by mistake is fully sequential and passes every test, right up to the day someone wonders why the "parallel" import takes an hour.

A Runnable cannot return a value and cannot throw a checked exception. Callable<V> can do both, and pairs with Future<V> to give you the result later.

Callable<Integer> task = () -> countStudents("Pune");   // may throw

Three more facts that save time later. t.join() blocks until t finishes, which is how you wait for results without a sleep. t.setDaemon(true) marks a thread as not worth keeping the JVM alive for, and it must be called before start(). And Thread.sleep does not release any lock the thread holds, which surprises people who expect a sleep inside a synchronized block to let another thread in. Only Object.wait() releases the monitor.

count++ is three operations

Here is the smallest program that demonstrates the whole problem.

public class Counter {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable job = () -> {
            for (int i = 0; i < 100_000; i++) count++;
        };

        Thread a = new Thread(job);
        Thread b = new Thread(job);
        a.start(); b.start();
        a.join();  b.join();

        System.out.println(count);   // usually less than 200000
    }
}

count++ is not one operation. It is read the field, add one, write it back. Two threads can both read 41, both compute 42, and both write 42. One increment has disappeared with no error anywhere. The result differs between runs and between machines, which is exactly why this class of bug survives your testing and shows up in production under load.

There are two fixes and they solve different problems. For a single variable, use an atomic class, which performs the read-modify-write as one indivisible hardware operation.

static final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();

When several fields must change together, you need a lock, because atomicity of each field separately does not give you consistency across the set.

private int credits;
private int debits;

synchronized void record(int amount) {
    if (amount >= 0) credits += amount;
    else             debits -= amount;
}

The second kind matters more than the first, and it appears in a shape you will write without thinking: check something, then act on it. Between the check and the act, another thread can change the answer.

if (!map.containsKey(roll)) {      // both threads see false
    map.put(roll, newRecord());    // one record silently overwrites the other
}

map.putIfAbsent(roll, newRecord());   // one call, one decision

The other symptom of a race is not a lost update but a corrupted object. An ArrayList grown by two threads at once can end up with a wrong size, nulls in the middle, or an ArrayIndexOutOfBoundsException thrown from inside the library's own code. If you ever see a stack trace pointing into java.util with no obvious cause, suspect an unsynchronised collection shared between threads.

What synchronized actually locks

synchronized locks an object. It does not lock a method, and it does not lock a block of code. Two threads can run the same synchronized method at the same moment, provided they are calling it on different instances.

class Registry {
    private static int total;

    synchronized void add() {              // locks 'this'
        total++;                           // does NOT protect a static field
    }

    static synchronized void addSafely() { // locks Registry.class
        total++;
    }
}

An instance method locks this; a static method locks the Class object. They are two different locks, so a static synchronized method and an instance synchronized method do not exclude each other at all. A shared static field guarded only by instance methods is unprotected.

A private final lock object is clearer and safer, because no outside code can lock on your instance and deadlock you by accident.

private final Object lock = new Object();
private final List<String> pending = new ArrayList<>();

void add(String roll) {
    synchronized (lock) {
        pending.add(roll);
    }
}

Note the final. Synchronising on a field you later reassign means different threads acquire different objects and the block protects nothing. Equally bad: never synchronise on a String literal, a Boolean, or a boxed small Integer. Those are cached and shared across the whole JVM, so unrelated code in a completely different library can block you.

synchronized ("lock") { ... }               // shared with any other code using "lock"
synchronized (Integer.valueOf(1)) { ... }   // small Integer values are cached

Deadlock is two threads taking the same two locks in opposite orders: thread A holds the account lock and wants the ledger lock, thread B holds the ledger lock and wants the account lock, and neither ever moves again. The standard cure is to define a global ordering for locks and always acquire them in that order. ReentrantLock gives you an escape hatch as well.

private final ReentrantLock accountLock = new ReentrantLock();

// tryLock with a timeout throws InterruptedException, so declare or catch it
if (accountLock.tryLock(200, TimeUnit.MILLISECONDS)) {
    try {
        transfer();
    } finally {
        accountLock.unlock();   // must be in finally
    }
} else {
    // give up and retry later, rather than hanging forever
}

That finally is not optional. Releasing the monitor automatically, even when the body throws, is the one thing synchronized gives you for free and an explicit lock does not.

volatile fixes visibility, not atomicity

Every core has its own caches, and the JIT compiler is allowed to keep a field in a register. Without a memory barrier there is no guarantee that a write performed by one thread is ever observed by another.

static boolean running = true;   // deliberately not volatile

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (running) {
            // busy work
        }
        System.out.println("stopped");
    }).start();

    Thread.sleep(500);
    running = false;      // the worker thread may never see this
}

This can loop forever, and whether it does depends on the JVM, the optimisation level and the machine. The compiler is entitled to hoist the read out of the loop, because within that thread nothing writes running. Declaring the field volatile forces a fresh read each time and makes the write visible to other threads.

What volatile does not do is make compound operations atomic. volatile int count; count++; is still read, add, write, and still loses updates. This is the single most common misunderstanding in Java concurrency interviews, and stating it clearly is usually worth more than reciting the memory model.

volatile is the right tool for a one-way flag, for a reference published once and read many times, and for the double-checked locking singleton where it prevents another thread from seeing a partially constructed object.

private static volatile Config instance;

static Config get() {
    if (instance == null) {
        synchronized (Config.class) {
            if (instance == null) {
                instance = new Config();
            }
        }
    }
    return instance;
}

Without volatile on that field, the assignment and the constructor's field writes can become visible in either order to a second thread, so it can obtain a non-null reference to an object whose fields are still default values. That is not a theoretical concern; it is why the naive version of this pattern was declared broken.

A simple decision rule. If a field is written once and then only read, volatile or final is enough. If threads read a value and then write a new value based on what they read, you need an Atomic class or a lock.

ExecutorService and thread-safe collections

Creating a thread per task is expensive and unbounded. Each platform thread costs memory for its stack and asks the operating system for a real scheduling entity, so ten thousand incoming jobs means ten thousand threads and eventually an OutOfMemoryError. A pool fixes the cost and, just as importantly, caps the concurrency.

ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();

for (String city : List.of("Pune", "Kochi", "Surat")) {
    futures.add(pool.submit(() -> countStudents(city)));
}

int total = 0;
for (Future<Integer> f : futures) {
    total += f.get();     // blocks; a task failure arrives as ExecutionException
}

pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();
}

Two things bite people here. First, submit swallows exceptions. If the task throws and you never call get(), the failure is stored in the Future and disappears completely, with no stack trace printed anywhere. execute, which takes a Runnable and returns nothing, sends the exception to the thread's uncaught exception handler instead, so it is at least visible. If you use submit, you must consume the Future.

Second, forgetting shutdown(). A fixed pool's threads are non-daemon by default, so main finishes, the process stays alive with nothing to do, and your build hangs. shutdown() stops accepting work and lets running tasks finish; shutdownNow() also interrupts them, which only helps if your tasks respect interruption.

For shared state, use the concurrent collections rather than hand-rolled locking.

Map<String, Integer> hits = new ConcurrentHashMap<>();

hits.merge(city, 1, Integer::sum);              // atomic increment
hits.computeIfAbsent(city, k -> loadCity(k));   // computed once, not per thread

Notice what we did not write: hits.put(city, hits.get(city) + 1). Every individual method on a ConcurrentHashMap is atomic, but two calls in sequence are not. merge, compute, computeIfAbsent and putIfAbsent exist precisely so the check and the act happen inside one call.

Know what the alternatives are for. Collections.synchronizedList wraps every method in a lock but does not cover iteration, so you must synchronise on the wrapper yourself around the whole loop. CopyOnWriteArrayList copies the backing array on every write, which is ideal for a listener list read constantly and modified rarely, and terrible for a write-heavy queue. For producer and consumer work, use an ArrayBlockingQueue or LinkedBlockingQueue and let put and take handle the waiting instead of writing your own wait and notify.

Recent JDKs add virtual threads, which make blocking calls far cheaper and change how you size pools. They change none of the above: a race condition is still a race condition, and volatile still does not make ++ atomic.

Frequently Asked Questions

Why does my counter print a different number on every run? Because count++ is a read, an add and a write, and the operating system can switch threads between any two of them. Two threads read the same old value, both add one, and both write the same new value, so one increment is lost. How many are lost depends on timing, which is why the number changes each run. Use AtomicInteger, or guard the update with a lock.
What is the difference between t.start() and t.run()? start() asks the JVM to create a new thread and then invokes run() on that thread, so your code executes concurrently. run() is an ordinary method call that executes on the thread that called it, with no concurrency at all. A program that mistakenly calls run() behaves correctly but sequentially, which makes the bug easy to miss until performance matters.
Is volatile enough to make a counter thread-safe? No. volatile guarantees that a write by one thread becomes visible to others and prevents certain reorderings, but it does nothing about atomicity. A volatile counter still performs read, add and write as separate steps, so two threads can still lose an update. volatile is correct for a flag written once or a reference published once, not for anything that reads a value and then writes based on it.
Why did my ExecutorService task fail without any error message? Because you used submit() and never called get() on the returned Future. submit captures any exception inside the Future and rethrows it as an ExecutionException only when you ask for the result, so a task that fails and is never awaited disappears silently. Either consume every Future, or use execute() for fire-and-forget work so the exception reaches the uncaught exception handler.
Is using ConcurrentHashMap enough to make my code thread-safe? Only for single operations. Each method call on it is atomic, but a sequence of calls is not, so 'if absent then put' written as two statements is still a race. Use the compound methods designed for it: putIfAbsent, merge, compute and computeIfAbsent. And remember the map only protects itself, not the objects stored inside it, which still need their own thread safety if they are mutated.