🔤 Розділ 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).

Пов’язані питання