Avatar ·

Почему вывод "B" в Java работает намного медленнее, чем вывод "#"?

Всем привет! Я новичок в Java и пишу простой скрипт для построения гистограммы распределения случайных чисел. В цикле я вывожу символы в консоль через System.out.print(). Заметил странную вещь: когда я вывожу символ "#" (решетка), программа выполняется очень быстро. Но если я меняю символ на "B" (латинская заглавная буква), выполнение становится заметно медленнее — разница в 2-3 раза! Вот упрощенный код, который демонстрирует проблему: ```java public class TestPrint { public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 100000; i++) { System.out.print("#"); // или "B" } long end = System.currentTimeMillis(); System.out.println("\nВремя: " + (end - start) + " ms"); } } ``` С "#" я получаю около 200-300 мс, а с "B" — около 600-800 мс. Тестировал на разных версиях Java (8, 11, 17) — разница стабильно воспроизводится. Почему так происходит? Ведь оба символа — это просто строки длиной в один символ. Может быть, дело в кодировке? Или это связано с тем, как System.out обрабатывает разные символы? Буду благодарен за объяснение и способ исправить ситуацию. <<>>

Причина: разница в ширине символов при рендеринге в терминале

Проблема не в Java, а в том, как ваш терминал (консоль) отображает символы. Символ "#" (U+0023) — это моноширинный ASCII-символ, который занимает ровно одну колонку. Символ "B" (U+0042) — тоже ASCII, но во многих шрифтах и терминалах он может быть полужирным или двухширинным в зависимости от настроек локали и кодировки. Однако истинная причина глубже.

На самом деле, ключевой фактор — это поддержка Unicode и механизм перемотки строки. Когда Java выводит символ через System.out.print(), он передается в поток вывода. Терминал получает байты и должен отобразить их. Для символа "#" терминал почти всегда использует простое растровое изображение. Для "B" многие терминалы (особенно Windows cmd, старые эмуляторы или IDE) пытаются применить лигатуры или двойную ширину из-за особенностей шрифта.

Но самый важный нюанс — автоматический перенос строки. В некоторых средах (например, IntelliJ IDEA или Eclipse) консоль поддерживает мягкий перенос. Если символ "B" оказывается в конце строки, терминал может попытаться проверить, не является ли он частью слова (для переноса). Это вызывает дополнительные вычисления. Символ "#" никогда не участвует в переносе, поэтому его вывод оптимизирован.

Докажем это экспериментом. Измените код, чтобы выводить символы в буфер и затем выводить одной строкой:

public class TestPrint {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            sb.append("#");  // или "B"
        }
        System.out.print(sb.toString());
        long end = System.currentTimeMillis();
        System.out.println("\nВремя: " + (end - start) + " ms");
    }
}

Теперь разница между "#" и "B" исчезнет! Оба будут выполняться за ~10-20 мс. Это доказывает, что проблема именно в частых вызовах System.out.print() и в том, как терминал обрабатывает каждый отдельный символ.

Почему разница только для "B"?

  • Ширина символа: В некоторых шрифтах (например, Consolas, Courier New) буква "B" может быть чуть шире "#", что заставляет терминал пересчитывать позицию курсора.
  • Антиалиасинг и сглаживание: Для букв с кривыми линиями (B, D, O) применяется более сложный рендеринг, чем для прямых символов (#, +, -).
  • Историческая причина: На старых терминалах (VT100) символ "#" был одним из самых простых для отрисовки — просто пересечение линий. Буквы требовали больше времени на генерацию символа.

Как исправить?

Лучший способ — не выводить каждый символ отдельно. Используйте буферизацию:

try (PrintWriter writer = new PrintWriter(System.out, true)) {
    for (int i = 0; i < 100000; i++) {
        writer.print("#"); // или "B" — разницы нет
    }
}

Или просто собирайте строку в StringBuilder и выводите один раз. Если вам нужно построчно выводить гистограмму, используйте System.out.printf() или форматирование с String.format().

<<>>

Альтернативное объяснение: проблема с кэшированием и JIT-компиляцией

Другая возможная причина — особенности работы JIT-компилятора (Just-In-Time) в Java. Когда вы выводите один и тот же символ в цикле, JVM может оптимизировать вызов System.out.print() для константных строк. Символ "#" — это простой байт 0x23, который легко кэшируется. Символ "B" — 0x42, и в некоторых реализациях JVM может решить, что это "более сложный" символ, и не применять к нему оптимизацию встраивания (inlining).

Проверьте это, используя флаг -XX:+PrintCompilation при запуске. Вы увидите, что для "#" компилятор генерирует более агрессивный код. Также попробуйте выводить символ как System.out.print((int)'B') — если разница исчезнет, значит дело в преобразовании char в строку.

На практике рекомендую использовать System.out.write('B') — это метод для вывода одного байта, он работает быстрее, чем print(String). Но помните, что write() не добавляет перевод строки.

for (int i = 0; i < 100000; i++) {
    System.out.write('B'); // быстрее, чем print("B")
}

Итог: проблема комплексная, но всегда решается буферизацией вывода. Не выводите символы по одному в цикле — это антипаттерн для Java.