← Назад к вопросам

Какая проблема возникнет при использовании volatile счётчика, если несколько потоков параллельно увеличивают его значение?

2.0 Middle🔥 111 комментариев
#Многопоточность

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Какая проблема возникнет при использовании volatile счётчика, если несколько потоков параллельно увеличивают его значение?

Это классическая проблема в многопоточном Java. volatile не гарантирует атомарность операций, и результат будет неправильный. Подробно разбираю проблему и решения.

Чего ожидает начинающий разработчик

public class VolatileCounterProblem {
    private volatile int counter = 0; // Кажется, что это достаточно!
    
    public void incrementCounter() {
        counter++; // Проблема здесь!
    }
    
    public static void main(String[] args) throws InterruptedException {
        VolatileCounterProblem app = new VolatileCounterProblem();
        
        // 10 потоков, каждый увеличивает счётчик на 1000
        // Ожидаем результат: 10000
        // Получим: случайное число < 10000 (например, 9856)
        
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    app.incrementCounter();
                }
            }).start();
        }
        
        Thread.sleep(1000);
        System.out.println("Counter: " + app.counter); // Неправильный результат!
    }
}

Почему volatile недостаточно?

volatile гарантирует только:

  • Видимость (visibility) — все потоки видят последнее значение
  • НЕ гарантирует атомарность (atomicity)
// counter++ это ТРИ операции:
// 1. Прочитать текущее значение: temp = counter (READ)
// 2. Увеличить: temp = temp + 1 (COMPUTE)
// 3. Записать обратно: counter = temp (WRITE)

// Временная шкала:
// Thread 1 (T1):  READ(counter=0) → COMPUTE(temp=1) → WRITE(counter=1)
// Thread 2 (T2):  READ(counter=0) → COMPUTE(temp=1) → WRITE(counter=1)
//
// Результат: counter=1 (вместо ожидаемого 2!)
// Операция T2 затёрла результат T1

Визуализация проблемы

Время ▼
      T1                T2               T3
      |                 |                |
      READ(0)           |                |
      |                 READ(0)          |
      |                 |                READ(0)
      |                 |                |
      COMPUTE(0+1)      COMPUTE(0+1)     COMPUTE(0+1)
      |                 |                |
      |                 WRITE(1)         |
      |                 |                |
      WRITE(1)          |                |
      |                 |                WRITE(1)
      |                 |                |

Результат: counter = 1
Ожидание: counter = 3
Потеря данных: 2 увеличения потеряны!

Проблема на практике

public class VolatileVsAtomic {
    private volatile int volatileCounter = 0;
    private AtomicInteger atomicCounter = new AtomicInteger(0);
    
    public void testPerformance() throws InterruptedException {
        // Test 1: volatile счётчик
        long startTime = System.currentTimeMillis();
        
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100000; j++) {
                    volatileCounter++; // Race condition!
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        long volatileTime = System.currentTimeMillis() - startTime;
        System.out.println("Volatile counter: " + volatileCounter + " (ожидаем 1000000)");
        System.out.println("Время: " + volatileTime + "ms");
        
        // Test 2: AtomicInteger
        startTime = System.currentTimeMillis();
        
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100000; j++) {
                    atomicCounter.incrementAndGet(); // Атомарно!
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        long atomicTime = System.currentTimeMillis() - startTime;
        System.out.println("\nAtomic counter: " + atomicCounter.get()); // Всегда 1000000
        System.out.println("Время: " + atomicTime + "ms");
    }
}

// Output примерный:
// Volatile counter: 897654 (ожидаем 1000000) ← НЕПРАВИЛЬНО!
// Время: 150ms
//
// Atomic counter: 1000000 ← ПРАВИЛЬНО!
// Время: 450ms (медленнее, но правильно)

Решение 1: synchronized

public class SynchronizedCounter {
    private int counter = 0;
    
    // Синхронизируем метод
    public synchronized void increment() {
        counter++; // Теперь атомарно!
    }
    
    public synchronized int getCounter() {
        return counter;
    }
}

// Или с блоком:
public class SynchronizedBlockCounter {
    private int counter = 0;
    private final Object lock = new Object();
    
    public void increment() {
        synchronized (lock) {
            counter++; // Атомарно
        }
    }
    
    public int getCounter() {
        synchronized (lock) {
            return counter;
        }
    }
}

Решение 2: AtomicInteger (рекомендуется)

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger counter = new AtomicInteger(0);
    
    public void increment() {
        counter.incrementAndGet(); // Атомарная операция
    }
    
    public int getCounter() {
        return counter.get();
    }
    
    public static void main(String[] args) throws InterruptedException {
        AtomicCounter app = new AtomicCounter();
        
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    app.increment();
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("Counter: " + app.getCounter()); // 10000 ✓
    }
}

Решение 3: Lock (более гибко)

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockCounter {
    private int counter = 0;
    private final Lock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            counter++; // Атомарно
        } finally {
            lock.unlock();
        }
    }
    
    public int getCounter() {
        lock.lock();
        try {
            return counter;
        } finally {
            lock.unlock();
        }
    }
    
    // С timeout'ом
    public boolean incrementWithTimeout() {
        try {
            if (lock.tryLock(5, TimeUnit.SECONDS)) {
                try {
                    counter++;
                    return true;
                } finally {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}

Решение 4: LongAdder (для high-contention сценариев)

import java.util.concurrent.atomic.LongAdder;

public class LongAdderCounter {
    private LongAdder counter = new LongAdder();
    
    public void increment() {
        counter.increment(); // Очень быстро при высокой контенции!
    }
    
    public long getCounter() {
        return counter.sum();
    }
    
    public static void main(String[] args) throws InterruptedException {
        LongAdderCounter app = new LongAdderCounter();
        
        long startTime = System.currentTimeMillis();
        
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000000; j++) {
                    app.increment();
                }
            });
            threads[i].start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("Counter: " + app.getCounter()); // 10000000
        System.out.println("Time: " + (System.currentTimeMillis() - startTime) + "ms");
        // LongAdder ~100ms (почти как volatile без потери данных!)
        // AtomicInteger ~500ms
    }
}

Сравнение подходов

Подход              | Атомарность | Производительность | Простота | Когда использовать
─────────────────────────────────────────────────────────────────────────────────────
volatile            | НЕТ ❌      | Очень быстро       | Простой  | Только для flag'ов, НЕ для счётчиков
synchronized        | ДА ✓        | Медленно            | Средний  | Не высоконагруженные счётчики
AtomicInteger       | ДА ✓        | Быстро             | Средний  | Стандартный выбор
LongAdder           | ДА ✓        | Очень быстро       | Средний  | High-contention счётчики (100+ потоков)
ReentrantLock       | ДА ✓        | Быстро             | Сложный  | Когда нужна гибкость (tryLock, timeout)

Best Practices

// ❌ ПЛОХО: volatile для счётчика
private volatile int counter = 0;
public void increment() { counter++; }

// ✅ ХОРОШО: AtomicInteger
private AtomicInteger counter = new AtomicInteger(0);
public void increment() { counter.incrementAndGet(); }

// ✅ ХОРОШО: Для высоконагруженных систем
private LongAdder counter = new LongAdder();
public void increment() { counter.increment(); }

// ✅ ХОРОШО: Если нужна гибкость
private final Lock lock = new ReentrantLock();
private int counter = 0;
public void increment() {
    lock.lock();
    try {
        counter++;
    } finally {
        lock.unlock();
    }
}

Когда volatile работает

// ✅ ПРАВИЛЬНО: volatile для boolean флага (one-way operation)
private volatile boolean shutdown = false;

public void shutdownRequested() {
    shutdown = true; // Один поток пишет
}

public boolean isShutdown() {
    return shutdown; // Другие потоки читают
}

// ✅ ПРАВИЛЬНО: volatile для reference'ов
private volatile CachedData cache; // Один поток пишет, другие читают

Итоговые выводы

  1. volatile НЕ гарантирует атомарность — это частая ошибка
  2. counter++ это три операции, не одна
  3. Race condition приводит к потере данных
  4. Используй AtomicInteger или LongAdder для счётчиков
  5. synchronized подходит для простых случаев, но медленнее
  6. LongAdder быстрее при high-contention
  7. Всегда проверяй многопоточный код под нагрузкой

В production коде я всегда использую AtomicInteger для счётчиков и LongAdder для высоконагруженных систем с сотнями потоков, обновляющих один счётчик.

Какая проблема возникнет при использовании volatile счётчика, если несколько потоков параллельно увеличивают его значение? | PrepBro