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

Что произойдет с потоком при выполнении I/O операции?

2.0 Middle🔥 201 комментариев
#Другое

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

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

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

Поведение потока при I/O операции

Когда поток в Java выполняет I/O операцию (чтение из файла, сокета, БД и т.д.), происходит ряд важных событий, которые существенно влияют на производительность приложения. Это одна из критических концепций для понимания многопоточности.

1. Что происходит с потоком при I/O операции

Основной момент: Поток БЛОКИРУЕТСЯ и переходит в состояние WAITING (ожидания).

public class IOThreadExample {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        
        Thread thread = new Thread(() -> {
            try {
                System.out.println("[" + Thread.currentThread().getName() + "] Начало I/O");
                
                // I/O операция: чтение из файла
                FileInputStream fis = new FileInputStream("large_file.txt");
                byte[] buffer = new byte[1024];
                fis.read(buffer);  // БЛОКИРУЕТ поток здесь!
                // Поток ЖДЁТ, пока данные придут с диска
                
                System.out.println("[" + Thread.currentThread().getName() + "] I/O завершён");
                fis.close();
                
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        
        thread.start();
        
        // Главный поток продолжает работать
        System.out.println("[" + Thread.currentThread().getName() + "] Продолжает работу");
        
        try {
            thread.join();  // Ждём завершения рабочего потока
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        long endTime = System.currentTimeMillis();
        System.out.println("Прошло времени: " + (endTime - startTime) + "ms");
    }
}

Вывод программы:

[main] Продолжает работу
[Thread-0] Начало I/O
[Thread-0] I/O завершён
Прошло времени: ~500ms (или больше, в зависимости от скорости диска)

2. Состояния потока при I/O

Поток проходит через несколько состояний:

┌──────────────┐
│   RUNNABLE   │ ← Поток готов к выполнению
└──────┬───────┘
       │
       ├─ Вызов I/O операции (read(), write())
       ↓
┌──────────────┐
│   WAITING    │ ← БЛОКИРУЕТ на I/O (нет CPU, просто ждёт)
└──────┬───────┘
       │
       ├─ I/O завершена, данные получены
       ↓
┌──────────────┐
│   RUNNABLE   │ ← Вернулся в очередь для CPU
└──────────────┘

Состояние потока можно проверить:

Thread thread = new Thread(() -> {
    try {
        System.out.println("Статус до I/O: " + Thread.currentThread().getState());  // RUNNABLE
        Thread.sleep(1000);  // Имитирует I/O
        System.out.println("Статус после I/O: " + Thread.currentThread().getState());  // RUNNABLE
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});

3. Блокирующие vs. Неблокирующие I/O

Блокирующие I/O (Blocking I/O) — стандартный случай:

@Service
public class UserService {
    private final UserRepository userRepository;
    
    public User getUser(Long id) {
        // БЛОКИРУЕТ этот поток на время запроса к БД
        return userRepository.findById(id).orElseThrow();
    }
}

@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;
    
    @GetMapping("/{id}")
    public UserDto getUser(@PathVariable Long id) {
        // Этот поток блокируется до получения ответа из БД
        User user = userService.getUser(id);
        return toDto(user);
    }
}

// Если 100 клиентов запрашивают одновременно:
// → Нужно 100 потоков
// → Каждый ждёт I/O (например, 200ms)
// → CPU бездействует во время ожидания
// → Потребление памяти: 100 потоков * ~1MB = 100MB

Неблокирующие I/O (Non-Blocking I/O) — асинхронный подход:

@RestController
@RequestMapping("/api/users")
public class UserControllerAsync {
    private final UserService userService;
    
    @GetMapping("/{id}")
    public Mono<UserDto> getUser(@PathVariable Long id) {
        // Поток НЕ блокируется! Используется callback или Future
        return userService.findById(id)
            .map(this::toDto)
            .switchIfEmpty(Mono.error(new UserNotFoundException()));
    }
}

@Service
public class AsyncUserService {
    private final UserRepository userRepository;
    
    public Mono<User> findById(Long id) {
        // Реактивный репозиторий — не блокирует
        return userRepository.findById(id);
    }
}

// Если 10,000 клиентов запрашивают одновременно:
// → Можно обойтись 10-20 потоками (event loop)
// → Потребление памяти: ~10MB
// → Гораздо выше пропускная способность (throughput)

4. Проблема "Context Switch" при блокировке

Когда поток блокируется на I/O, операционная система переходит на другой поток. Это дорого:

public class ContextSwitchDemo {
    private static int taskCount = 0;
    private static final Object lock = new Object();
    
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        
        // Много потоков, блокирующихся на I/O
        ExecutorService executor = Executors.newFixedThreadPool(100);
        
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                try {
                    // I/O операция (например, 100ms задержка)
                    simulateNetworkCall();
                    
                    synchronized (lock) {
                        taskCount++;
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        
        executor.shutdown();
        try {
            executor.awaitTermination(10, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        long endTime = System.currentTimeMillis();
        System.out.println("Время: " + (endTime - startTime) + "ms");
        System.out.println("Задач выполнено: " + taskCount);
    }
    
    private static void simulateNetworkCall() throws InterruptedException {
        Thread.sleep(100);  // Имитирует I/O операцию
    }
}

// Вывод: ~1000ms (100 потоков могут работать параллельно)
// Context switch-ей: много!
// Потребление памяти: 100 потоков * 1MB = 100MB

5. Thread Pool и управление ресурсами

Для блокирующих I/O операций используются потоки из пула:

@Configuration
public class ThreadPoolConfig {
    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);      // Минимум потоков
        executor.setMaxPoolSize(100);      // Максимум потоков
        executor.setQueueCapacity(500);    // Очередь ожидающих задач
        executor.setThreadNamePrefix("async-");
        executor.initialize();
        return executor;
    }
}

@Service
public class UserService {
    private final UserRepository userRepository;
    private final ThreadPoolTaskExecutor executor;
    
    public Future<User> getUserAsync(Long id) {
        // Отправляем задачу в пул потоков
        return executor.submit(() -> {
            // Блокирующая I/O операция выполнится в отдельном потоке
            return userRepository.findById(id).orElseThrow();
        });
    }
}

// Контроллер может использовать Future или обернуть в Mono
@RestController
public class UserController {
    private final UserService userService;
    
    @GetMapping("/{id}")
    public Mono<UserDto> getUser(@PathVariable Long id) {
        return Mono.fromFuture(userService.getUserAsync(id))
            .map(this::toDto);
    }
}

6. Типичный сценарий: Web Request с I/O

1. HTTP запрос приходит
   ↓
2. Spring выделяет поток из пула (например, из Tomcat pool)
   ↓
3. Контроллер вызывает userService.getUser(id)
   ↓
4. Сервис обращается к БД: statement.executeQuery()
   ↓
5. *** ПОТОК БЛОКИРУЕТСЯ ЗДЕСЬ *** 
   → Состояние: WAITING
   → CPU не используется
   → Операционная система переключается на другой поток
   ↓
6. БД обрабатывает запрос (например, 100ms)
   ↓
7. БД отправляет результат по сокету
   ↓
8. Драйвер JDBC пробуждает поток
   → Состояние: RUNNABLE
   → Поток возвращается в очередь для CPU
   ↓
9. Поток продолжает выполнение (тем же местом, где блокировался)
   ↓
10. Контроллер преобразует результат в JSON
   ↓
11. HTTP ответ отправляется клиенту
   ↓
12. Поток возвращается в пул и становится доступным для новых запросов

7. Проблемы при блокировке потоков

// ПРОБЛЕМА: Thread Starvation (голодание потоков)
public class ThreadStarvationExample {
    private final ExecutorService executor = Executors.newFixedThreadPool(2);
    
    public void badExample() {
        // Задача 1: блокируется на I/O
        executor.submit(() -> {
            try {
                FileInputStream fis = new FileInputStream("file.txt");
                fis.read();  // Блокирует на 1 секунду
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        
        // Задача 2: тоже блокируется на I/O
        executor.submit(() -> {
            try {
                FileInputStream fis = new FileInputStream("file2.txt");
                fis.read();  // Блокирует на 1 секунду
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        
        // Задача 3: НЕ ВЫПОЛНИТСЯ длительное время!
        // Оба потока заняты блокировкой на I/O
        executor.submit(() -> {
            System.out.println("Эта задача ждёт...");
        });
    }
}

8. Java Virtual Threads (Java 19+) — решение!

// Старый способ (Project Loom не требуется)
ExecutorService executor = Executors.newFixedThreadPool(100);

// Новый способ с Virtual Threads
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

public class VirtualThreadExample {
    public static void main(String[] args) {
        // Virtual Threads дешевые, можно создавать миллионы!
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        
        for (int i = 0; i < 1_000_000; i++) {
            final int taskId = i;
            executor.submit(() -> {
                try {
                    // Блокирующая I/O
                    Thread.sleep(100);
                    System.out.println("Задача " + taskId + " завершена");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        
        executor.shutdown();
    }
}

// С Virtual Threads:
// → 1,000,000 потоков занимают ~10MB памяти (вместо 1GB!)
// → Блокировка I/O не проблема, JVM переключается на другие virtual threads
// → Максимальная утилизация CPU

Итоговая таблица: что происходит

ЭтапСтатус потокаCPU используетсяПамятьПримечание
До I/ORUNNABLE+Поток выполняет код
Во время I/OWAITING+Поток блокирован, CPU не используется
После I/ORUNNABLE✓ (в очереди)+Поток ждёт в очереди CPU
ЗавершеноTERMINATED-Поток удален

Ключевые выводы

  1. Поток БЛОКИРУЕТСЯ на I/O операции — переходит в состояние WAITING
  2. CPU не используется во время блокировки (впустую тратится контекст)
  3. Память удерживается — поток остаётся в памяти, ждёт завершения I/O
  4. Context switch — операционная система переключается на другой поток (дорого)
  5. Thread starvation — если все потоки блокированы, новые задачи не выполняются
  6. Решения:
    • Async/NIO — неблокирующие операции
    • Reactive (Mono, Flux) — обработка асинхронно
    • Virtual Threads (Java 19+) — дешевые потоки, блокировка не проблема

Понимание этого механизма критично для проектирования высокопроизводительных приложений!