Как работают генераторы случайных чисел в Java

Случайные числа нужны не только в играх. Они участвуют в перемешивании данных, имитационном моделировании, тестировании, распределении запросов и создании одноразовых токенов. Но слово «случайный» в программировании может обозначать разные свойства. Генератор, удобный для учебной симуляции, способен оказаться непригодным для пароля, а безопасный криптографический алгоритм — избыточным для выбора случайного элемента массива.

В Java есть несколько способов получить случайное значение. Чтобы выбрать подходящий, важно понимать, откуда берётся последовательность, можно ли её воспроизвести и что произойдёт при одновременной работе множества потоков.

Схема преобразования начального состояния в последовательность числовых значений

Почему компьютерная случайность обычно псевдослучайна

Обычный алгоритм не извлекает числа «из ниоткуда». Он начинает с внутреннего состояния, вычисляет следующее значение, обновляет состояние и повторяет операцию. Начальное состояние называют seed. Если алгоритм и seed одинаковы, последовательность тоже будет одинаковой.

Это свойство полезно при тестировании. Разработчик может сохранить seed, повторно запустить тест и получить тот же набор входных данных. Но предсказуемость становится проблемой, когда по случайным значениям создаются ссылки для сброса пароля, ключи доступа или идентификаторы с требованиями к секретности.

При оценке генератора обычно смотрят на несколько характеристик:

  • качество распределения и длину периода;
  • возможность воспроизвести результат по seed;
  • скорость генерации значений;
  • поведение при работе из нескольких потоков;
  • устойчивость к предсказанию предыдущих и следующих значений.

Этот перечень показывает, что требования к случайности могут противоречить друг другу: воспроизводимость полезна в тестах, но недопустима при создании секретных значений. Поэтому универсального «самого лучшего» генератора не существует — его выбирают под конкретную задачу.

Random: простой вариант для прикладных задач

Класс java.util.Random остаётся понятной отправной точкой. Он позволяет получать целые и вещественные значения, логические значения и массивы случайных байтов. Для выбора индекса из диапазона достаточно передать верхнюю границу:

Random random = new Random();
int index = random.nextInt(items.size());
String selected = items.get(index);

Метод nextInt(bound) возвращает число от нуля включительно до bound исключительно. Передавать ноль или отрицательное значение нельзя. Если нужен диапазон от min до max включительно, его можно рассчитать так:

int value = min + random.nextInt(max - min + 1);

Здесь важно контролировать корректность границ и возможное переполнение при работе с большими значениями. Для новых проектов также доступен интерфейс RandomGenerator, который задаёт общий API для разных алгоритмов. Конкретную реализацию можно выбрать через фабричный метод, не привязывая остальной код к одному классу.

RandomGenerator generator = RandomGenerator.of("L64X128MixRandom");
int value = generator.nextInt(10, 21); // от 10 до 20

ThreadLocalRandom для многопоточного кода

Один общий экземпляр Random можно использовать из нескольких потоков, но при высокой конкуренции он способен стать лишней точкой соперничества. ThreadLocalRandom решает эту проблему: каждый поток обращается к собственному генератору через current().

int delay = ThreadLocalRandom.current().nextInt(100, 501);

Такой подход подходит для случайных задержек, распределения некритичных задач и моделирования нагрузки в многопоточных приложениях. Экземпляр не следует сохранять в общем поле и передавать между потоками. У класса также нельзя вручную установить seed через обычный публичный вызов, поэтому он не подходит для тестов, где необходимо точно воспроизвести последовательность.

Где применяются случайные значения

Требования к генератору определяет не название проекта, а последствия предсказуемого результата. В симуляции очереди важнее скорость и повторяемость эксперимента. В механике случайной выдачи предметов — стабильное распределение. В системе восстановления доступа на первом месте стоит невозможность угадать токен.

Случайность встречается в следующих задачах:

  • генерация тестовых наборов и property-based testing;
  • моделирование процессов и метод Монте-Карло;
  • перемешивание коллекций и случайная выборка;
  • балансировка и распределение некритичных операций;
  • игровая логика, процедурная генерация и таблицы наград;
  • создание токенов, nonce, ключей и других секретных значений.

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

Рабочий экран разработчика с различными сценариями генерации данных в Java

SecureRandom для задач, связанных с безопасностью

java.security.SecureRandom предназначен для криптографически стойких значений. Его используют там, где знание части последовательности не должно помогать предсказывать остальные результаты. Типичные примеры — временные токены, соль для хеширования паролей, nonce и материал для ключей.

SecureRandom secureRandom = new SecureRandom();
byte[] token = new byte[32];
secureRandom.nextBytes(token);

String encoded = Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(token);

Для токена лучше генерировать массив байтов нужной энтропии, а затем кодировать его, чем собирать строку из небольшого набора символов случайными вызовами. Не следует задавать предсказуемый seed временем запуска, идентификатором пользователя или последовательным номером. Реализация SecureRandom должна получать исходные данные от доступных системе источников энтропии.

Типичные ошибки разработчиков

Проблемы со случайностью чаще появляются не внутри библиотечного алгоритма, а в коде вокруг него. Даже корректный генератор можно использовать так, что распределение станет смещённым или секретное значение окажется предсказуемым.

При проверке кода стоит обратить внимание на типичные ошибки:

  1. Создание генератора при каждом вызове. Обычно лучше переиспользовать экземпляр или применять предусмотренный для потока генератор.
  2. Получение диапазона через остаток от деления. Выражение random.nextInt() % n даёт отрицательные значения и может искажать распределение.
  3. Использование Math.random() в защитном коде. Удобный вызов не превращает обычную псевдослучайность в криптографически стойкую.
  4. Предсказуемый seed. Текущее время легко перебрать, если атакующему известен примерный момент запуска.
  5. Отсутствие сохранённого seed в тестах. Ошибка, найденная на случайных данных, становится трудной для повторения.
  6. Путаница между случайностью и уникальностью. Генератор не отменяет проверку коллизий, если уникальность обязательна.

Все перечисленные ошибки сводятся к одному: разработчик должен контролировать не только сам генератор, но и весь путь полученного значения — от выбора источника случайности до преобразования диапазона и применения результата. Когда эти риски проверены, можно переходить к выбору конкретного API.

Как выбрать генератор

Выбор генератора начинается не с названия класса, а с требований к результату. Нужно заранее определить, допустима ли воспроизводимость последовательности, будет ли код выполняться в нескольких потоках и может ли предсказуемое значение создать угрозу безопасности. С учётом этих критериев основные сценарии Java и подходящие для них генераторы распределяются следующим образом:

Задача Подходящий инструмент Почему
Учебный пример или простая симуляция Random или RandomGenerator Простой API, можно воспроизвести последовательность
Генерация в нескольких потоках ThreadLocalRandom Не требует общего экземпляра между потоками
Большие симуляции со специальными требованиями Реализация RandomGenerator Можно выбрать алгоритм по свойствам и производительности
Токены, ключи и секретные значения SecureRandom Результат рассчитан на устойчивость к предсказанию

Практическое правило простое: сначала сформулировать требования, затем выбирать API. Если результат защищает деньги, учётную запись или персональные данные, нужен SecureRandom. Если важны скорость, параллельная работа либо воспроизводимость эксперимента, следует выбрать соответствующий некриптографический генератор и явно зафиксировать seed там, где он нужен для повторного запуска.