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

Как работает метод split()

Метод split() разбивает исходную строку на массив подстрок на основе заданного разделителя.


🟢 Junior Level

Метод split() разбивает исходную строку на массив подстрок на основе заданного разделителя.

Главная особенность метода заключается в том, что аргумент-разделитель воспринимается не как обычный текст, а как регулярное выражение (Regular Expression / Regex).

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

  1. Экранирование метасимволов: Символы, имеющие специальное значение в регулярных выражениях (точка ., вертикальная черта |, плюс +, звездочка *, вопросительный знак ? и др.), необходимо экранировать двойным слешем \\ или методом Pattern.quote(). Например, str.split("\\.").
  2. Удаление пустых строк в конце (Trailing Empty Strings): По умолчанию метод split(regex) отбрасывает пустые строки на конце результата. Чтобы сохранить их, необходимо использовать перегрузку с отрицательным лимитом: split(regex, -1).
// 1. Стандартное разделение
String data = "apple,banana,orange";
String[] fruits = data.split(","); 
// Результат: ["apple", "banana", "orange"]

// 2. Ловушка с точкой:
String ip = "192.168.1.1";
String[] bad = ip.split(".");      // ОШИБКА: вернет пустой массив! (точка = любой символ)
String[] good = ip.split("\\.");   // ПРАВИЛЬНО: ["192", "168", "1", "1"]

// 3. Ловушка с пустыми хвостами:
String csv = "user,123,,,";
String[] defaultSplit = csv.split(",");     // ["user", "123"] (пустые хвосты отсечены!)
String[] fullSplit    = csv.split(",", -1); // ["user", "123", "", "", ""] (все сохранено)

🟡 Middle Level

Перегрузки метода и параметр limit

В классе String объявлены две перегрузки метода:

public String[] split(String regex) {
    return split(regex, 0);
}

public String[] split(String regex, int limit)

Поведение метода полностью определяется параметром limit: | Значение limit | Максимум элементов | Обработка завершающих пустых строк | Пример для "a,b,,,".split(",", limit) | | :— | :— | :— | :— | | limit = 0 (по умолчанию) | Без ограничений | ❌ Удаляются все пустые строки в конце | ["a", "b"] | | limit < 0 (например, -1) | Без ограничений | ✅ Сохраняются все пустые строки в конце | ["a", "b", "", "", ""] | | limit > 0 (например, 2) | Не более limit | ✅ Сохраняются (остаток строки целиком в последнем элементе) | ["a", "b,,,"] |

Быстрый путь (Fast-Path Optimization)

Многие полагают, что вызов split() всегда компилирует тяжелое регулярное выражение через java.util.regex.Pattern. В HotSpot JVM это не так. Если разделитель удовлетворяет критериям Fast-Path, JVM выполняет простой посимвольный поиск без создания regex-движка:

  1. Длина строки-разделителя ровно 1 символ, и этот символ не является метасимволом regex (.$|()[{^?*+\\).
  2. Длина строки-разделителя ровно 2 символа, первый символ — \\, а второй — не является буквой ASCII или цифрой (например, split("\\.") или split("\\|")).
str.split(",");  // FAST-PATH: быстрый поиск indexOf, без regex-движка
str.split("\\."); // FAST-PATH: быстрый поиск точки, без regex-движка
str.split("\\s+"); // SLOW-PATH: компиляция Pattern, обход конечного автомата

Типичные ошибки

  1. split("|") без экранирования: Символ | в regex означает логическое «ИЛИ». В результате строка разобьется на отдельные символы с пустыми элементами между ними. Правильно: str.split("\\|").
  2. Компиляция regex в цикле:
    // ПЛОХО: 1 000 000 раз компилирует один и тот же Pattern
    for (String line : lines) {
        String[] parts = line.split("\\s+");
    }
    
    // ХОРОШО: предкомпилированный Pattern
    private static final Pattern SPACES = Pattern.compile("\\s+");
    for (String line : lines) {
        String[] parts = SPACES.split(line);
    }
    

🔴 Senior Level

Анализ исходного кода OpenJDK HotSpot

Внутри метода java.lang.String.split(String regex, int limit) проверка Fast-Path реализована на битовых масках:

public String[] split(String regex, int limit) {
    char ch = 0;
    if (((regex.length() == 1 &&
         ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
         (regex.length() == 2 &&
          regex.charAt(0) == '\\' &&
          (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
          ((ch-'a')|('z'-ch)) < 0 &&
          ((ch-'A')|('Z'-ch)) < 0)) &&
        (ch < Character.MIN_HIGH_SURROGATE ||
         ch > Character.MAX_LOW_SURROGATE))
    {
        // FAST-PATH
        int off = 0;
        int next = 0;
        boolean limited = limit > 0;
        ArrayList<String> list = new ArrayList<>();
        while ((next = indexOf(ch, off)) != -1) {
            if (!limited || list.size() < limit - 1) {
                list.add(substring(off, next));
                off = next + 1;
            } else {
                // Достигнут предел limit: сохраняем весь хвост строки
                int last = length();
                list.add(substring(off, last));
                off = last;
                break;
            }
        }
        // ... отсечение пустых строк при limit == 0
        return list.toArray(new String[resultSize]);
    }
    // SLOW-PATH: компиляция Pattern
    return Pattern.compile(regex).split(this, limit);
}

Накладные расходы памяти и аллокаций

Даже в режиме Fast-Path стандартный метод split() имеет скрытые издержки:

  1. Создается вспомогательный ArrayList<String>.
  2. На каждом шаге вызывается substring(off, next), создающий новый объект String и массив байтов byte[] в куче.
  3. Финальный вызов list.toArray(new String[...]) аллоцирует массив ссылок. В Slow-Path дополнительно аллоцируются:
    • Экземпляр Pattern с деревом узлов абстрактного синтаксического дерева (AST).
    • Экземпляр Matcher с внутренними массивами групп.

Высокопроизводительные альтернативы (Zero-Allocation / Low-Overhead)

В Highload системах для парсинга текстовых протоколов (CSV, FIX, HTTP заголовки) String.split() не используется:

  1. Pattern.splitAsStream() (Java 8+): Позволяет лениво обрабатывать элементы через Stream<String> без предварительного формирования полного массива в памяти.
  2. Guava Splitter:
    Splitter.on(',').trimResults().omitEmptyStrings().split(line);
    
  3. Ручной парсинг через indexOf с переиспользуемым буфером: Позволяет парсить миллионы строк в секунду с нулевой нагрузкой на сборщик мусора (Zero Allocation).

4 Tricky Questions

1. Что вернут выражения ",".split(",") и ",".split(",", -1)?

Ответ:

  • ",".split(",") вернет пустой массив нулевой длины new String[0]. Объяснение: Запятая делит строку на две пустые подстроки: ["", ""]. Поскольку параметр limit = 0 по умолчанию удаляет все пустые строки на конце, обе пустые строки отсекаются, и массив становится пустым.
  • ",".split(",", -1) вернет массив из двух пустых строк: ["", ""]. Объяснение: Параметр limit = -1 запрещает удаление завершающих пустых строк, поэтому обе пустые части сохраняются.

2. Что вернет вызов "".split(",") на абсолютно пустой строке?

Ответ: Вызов "".split(",") вернет массив из одного элемента, содержащий пустую строку: [""] (длина массива 1). Объяснение: Строка "" не содержит разделителя ,. Согласно контракту метода, если разделитель не найден, исходная строка целиком помещается в качестве единственного элемента результирующего массива.

3. Как с помощью параметра limit безопасно распарсить строку конфигурации "key=value=extra" ровно на ключ и значение?

Ответ: Необходимо передать limit = 2:

String property = "key=value=extra";
String[] parts = property.split("=", 2);
// parts[0] -> "key"
// parts[1] -> "value=extra"

При limit = 2 метод выполнит ровно одно разделение по первому найденному знаку =, а весь оставшийся хвост строки (даже если он содержит знаки =) нетронутым запишет во второй элемент массива.

4. Почему str.split("\\s+") выполняется в десятки раз медленнее, чем str.split(" ") при многократном вызове?

Ответ:

  • str.split(" ") попадает в Fast-Path: пробел является односимвольным разделителем без метасимволов regex. JVM обходит массив байтов простым линейным поиском indexOf(' '), не задействуя движок регулярных выражений.
  • str.split("\\s+") содержит квантификатор + и метасимвол \s, поэтому Fast-Path не срабатывает. При каждом вызове метода вызывается Pattern.compile("\\s+"), который парсит строку регулярного выражения, строит дерево состояний конечного автомата, аллоцирует Matcher и выполняет сопоставление с откатами (backtracking).

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

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

«Метод split(regex, limit) разбивает строку по регулярному выражению. По умолчанию (limit = 0) все пустые строки в конце результата отбрасываются; чтобы их сохранить, передают limit = -1. Если разделитель — один символ (не метасимвол regex) или экранированный символ ("\\."), HotSpot использует Fast-Path на базе быстрого indexOf, не создавая движок Pattern. Сложные regex компилируются через Pattern.compile(), поэтому в циклах их обязательно нужно кэшировать в static final Pattern».

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

  1. Параметр limit: 0 — отсекать пустые хвосты; < 0 — сохранять всё; > 0 — вернуть не более limit элементов (остаток в последнем).
  2. Fast-Path критерии: Строка длиной 1 символ (не meta) или 2 символа (\ + не-буквенно-цифровой ASCII).
  3. Метасимволы: .$|()[{^?*+\\ требуют экранирования \\ или Pattern.quote().
  4. Edge Cases: "".split(",") $\to$ [""]; ",".split(",") $\to$ []; ",".split(",", -1) $\to$ ["", ""].
  5. Highload оптимизация: Замена на предкомпилированный Pattern, Pattern.splitAsStream(), Guava Splitter или ручной indexOf для Zero-Allocation.

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

  • ❌ Утверждать: «split() принимает обычную строку, а не регулярное выражение».
  • ❌ Думать, что split(".") разделит строку по точкам (точка совпадет со всеми символами).
  • ❌ Полагать, что split() по умолчанию сохраняет пустые строки на конце.
  • ❌ Заявлять, что split() всегда компилирует Pattern (забывая про Fast-Path).

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