🔤 Раздел 12 · Вопрос #16

В чём особенность метода replace() vs replaceAll()

Оба метода — и replace(), и replaceAll() — заменяют все найденные вхождения в строке на новое значение.


🟢 Junior Level

Оба метода — и replace(), и replaceAll() — заменяют все найденные вхождения в строке на новое значение.

Главное отличие между ними заключается в том, как именно интерпретируется искомый шаблон:

  • replace() выполняет литеральную (текстовую) замену. Он ищет точное совпадение символов или подстроки. Спецсимволы (точка, звездочка, скобки) воспринимаются как обычный текст.
  • replaceAll() выполняет замену на основе регулярного выражения (Regex). Искомый аргумент компилируется как шаблон регулярного выражения, а в строке замены символы $ и \ имеют специальное значение (ссылки на группы).

Суть в 30 секундах

Самое коварное заблуждение на собеседованиях — думать, что replace() меняет только первое вхождение, а replaceAll() — все. Оба метода меняют ВСЕ вхождения! Если вам требуется заменить только первое вхождение, для этого существует отдельный метод replaceFirst().

String text = "a.b.c.d";

// 1. Литеральная замена: точка — это просто символ '.'
System.out.println(text.replace(".", "/")); 
// Выведет: "a/b/c/d"

// 2. Замена по регулярному выражению: точка — это "любой символ" в Regex!
System.out.println(text.replaceAll(".", "/")); 
// Выведет: "///////" (каждый символ заменен на '/')

// 3. Замена только первого вхождения:
System.out.println(text.replaceFirst("\\.", "/")); 
// Выведет: "a/b.c.d"

🟡 Middle Level

Сигнатуры и различия методов

В классе java.lang.String представлены 4 метода для замены:

Метод Что принимает на поиск Что принимает на замену Что заменяет Производительность
replace(char, char) Символ char Символ char Все вхождения ⚡ Самый быстрый (прямой скан массива байт)
replace(CharSequence, CharSequence) Литеральная строка Литеральная строка Все вхождения 🚀 Очень быстрый (без regex-движка в Java 9+)
replaceAll(String, String) Регулярное выражение Строка подстановки (с группами $) Все вхождения 🐢 Медленный (компиляция Pattern/Matcher)
replaceFirst(String, String) Регулярное выражение Строка подстановки (с группами $) Первое вхождение 🐢 Медленный (компиляция Pattern/Matcher)

Ловушки метода replaceAll()

  1. Неэкранированные спецсимволы в regex: Вызов str.replaceAll("100$", "200$") не найдет строку "100$", потому что в регулярных выражениях знак $ означает конец строки. Для литерального поиска требуется экранирование: Pattern.quote("100$").
  2. Падение при символе $ в строке подстановки (replacement):
    String price = "Cost: PRICE";
    // ОШИБКА: выбросит IllegalArgumentException: No group with name 100!
    String result = price.replaceAll("PRICE", "$100"); 
    

    В replaceAll символ $ используется для подстановки захваченных групп ($1, $2). Если за знаком $ идет число, движок ищет соответствующую regex-группу. Решение: Для безопасной вставки произвольного текста в replaceAll строку замены оборачивают в Matcher.quoteReplacement("$100").


🔴 Senior Level

Эволюция реализации: как устроен replace() в Java 8 против Java 9+

До Java 9 метод replace(CharSequence, CharSequence) имел скрытый overhead:

  • Внутри он вызывал: Pattern.compile(target.toString(), Pattern.LITERAL).matcher(this).replaceAll(Matcher.quoteReplacement(replacement.toString())) Хотя поиск был литеральным (Pattern.LITERAL), JVM создавала объекты Pattern и Matcher, тратя память и время на прогон через абстрактный конечный автомат.

Начиная с Java 9 (включая Java 17 и 21): Метод replace(CharSequence, CharSequence) был полностью переписан без использования java.util.regex:

  1. Проверяет первое вхождение через быстрый поиск indexOf(target).
  2. Оптимизация отсутствия совпадений: Если подстрока не найдена, метод немедленно возвращает this без единой аллокации в куче!
  3. Точный расчет размера: Если совпадения есть, метод заранее вычисляет точную длину итогового массива байтов: \(\text{newLength} = \text{oldLength} + \text{matchCount} \times (\text{replacementLength} - \text{targetLength})\)
  4. Выделяет ровно один массив byte[] нужного размера с учетом coder (Latin-1 / UTF-16) и напрямую копирует нетронутые фрагменты и строки подстановки. В результате replace() в современных версиях Java работает в 5–10 раз быстрее, чем replaceAll().
// OpenJDK String.java (упрощенно)
public String replace(CharSequence target, CharSequence replacement) {
    String trgStr = target.toString();
    String replStr = replacement.toString();
    int i = indexOf(trgStr);
    if (i < 0) {
        return this; // Быстрый возврат без аллокаций!
    }
    // Прямой расчет и сборка массива байт без Pattern/Matcher
    return isLatin1()
        ? StringLatin1.replace(value, trgStr.value, replStr.value)
        : StringUTF16.replace(value, trgStr.value, replStr.value);
}

Безопасность: уязвимость ReDoS (Regular Expression Denial of Service)

Использование replaceAll() с динамическим пользовательским вводом в качестве шаблона поиска несет критическую уязвимость ReDoS:

  • Если злоумышленник передаст специально сформированное регулярное выражение с катастрофическим откатом (Catastrophic Backtracking), например (a+)+$, поток сервера зависнет со 100% утилизацией ядра CPU на несколько минут или часов.
  • Метод replace() принципиально защищен от ReDoS, так как выполняет детерминированный линейный поиск за время $O(N)$.

4 Tricky Questions

1. Почему вызов str.replaceAll("PRICE", "$100") выбрасывает IllegalArgumentException и как это исправить?

Ответ: В методе replaceAll второй аргумент (replacement) не является простым текстом. Символ $ зарезервирован для механизма обратных ссылок на группы захвата (Capturing Groups). Когда движок регулярных выражений встречает $100, он пытается найти в regex захваченную круглыми скобками группу с номером 100. Поскольку в регулярном выражении "PRICE" скобок и групп нет, JVM выбрасывает java.lang.IllegalArgumentException: No group with name 100 или IndexOutOfBoundsException. Способы решения:

  1. Если не требуется regex — использовать str.replace("PRICE", "$100") (безопасная литеральная замена).
  2. Если regex необходим — экранировать замену через str.replaceAll("PRICE", Matcher.quoteReplacement("$100")).

2. Создает ли str.replace("target", "replacement") новый объект в куче, если подстрока "target" не была найдена в исходной строке?

Ответ: В современных версиях Java (Java 9, 11, 17, 21) — нет, не создает. Метод replace() первым делом запускает поиск через indexOf(target). Если индекс первого совпадения < 0, метод возвращает ссылку this (исходный объект). Проверка str == str.replace("absent", "new") вернет true, а потребление памяти кучи равно 0 байт. Примечание: Метод replaceAll(), напротив, создает объекты Pattern и Matcher даже в том случае, если ни одного совпадения не будет найдено.

3. Как заменить ТОЛЬКО ПЕРВОЕ вхождение обычной текстовой строки без интерпретации ее как регулярного выражения?

Ответ: В классе String нет перегрузки replaceFirst(CharSequence, CharSequence) — метод replaceFirst(String regex, String replacement) принимает только регулярное выражение. Есть два надежных пути:

  1. Экранирование обоих аргументов:
    String result = str.replaceFirst(
        Pattern.quote(target), 
        Matcher.quoteReplacement(replacement)
    );
    
  2. Ручная замена через indexOf (наиболее производительный вариант без аллокаций regex):
    public static String replaceFirstLiteral(String source, String target, String replacement) {
        int index = source.indexOf(target);
        if (index == -1) return source;
        return source.substring(0, index) + replacement + source.substring(index + target.length());
    }
    

4. Что произойдет при вызове text.replaceAll("(?i)apple", "orange")?

Ответ: Произойдет регистронезависимая замена слова apple во всех вариантах написания (Apple, APPLE, aPple и т.д.) на слово orange. Префикс (?i) в синтаксисе Java Regex включает флаг Pattern.CASE_INSENSITIVE (регистронезависимый режим) для всего последующего шаблона. Метод replace() реализовать подобное одной строкой не способен, так как работает строго с литералами чувствительно к регистру.


🎯 Шпаргалка для интервью

30-секундный ответ

«И replace(), и replaceAll() заменяют все найденные совпадения в строке. Разница в типе поиска: replace() выполняет быстрый литеральный поиск точного текста без использования регулярных выражений, а replaceAll() компилирует первый аргумент в регулярное выражение (Pattern). Кроме того, во втором аргументе replaceAll() символы $ и \ служат для обратных ссылок на regex-группы и требуют экранирования через Matcher.quoteReplacement(). Для замены одного вхождения используется replaceFirst()».

Ключевые тезисы для Senior-интервью

  1. Оба меняют ВСЁ: replace меняет не первое вхождение, а абсолютно все вхождения.
  2. Java 9+ переписан: В Java 9+ replace(CharSequence, CharSequence) работает без Pattern.LITERAL, используя прямой скан массива byte[], и возвращает this при 0 совпадений.
  3. Спецсимволы в replacement: В replaceAll символ $ означает ссылку на группу; передача сырых строк (например, цен $50) без Matcher.quoteReplacement() приводит к падению в рантайме.
  4. Защита от ReDoS: replace() детерминирован и неуязвим к атакам на отказ в обслуживании через регулярные выражения.
  5. Fast Char Replacement: Метод replace(char, char) — самый быстрый способ замены одиночных символов (однопроходной цикл по массиву байт).

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Утверждать: «replace() меняет только первое вхождение, а replaceAll() — все» (главный индикатор некомпетентности).
  • ❌ Использовать replaceAll() для замены простых фиксированных строк (например, точек или слэшей), забывая об оверхеде компиляции regex и экранировании.
  • ❌ Передавать непроверенный пользовательский ввод напрямую в replaceAll() (риск ReDoS-атаки).
  • ❌ Полагать, что replace() медленнее replaceAll().

Связанные вопросы