Special for Коротко о сути. Если читать файл по одному байту через FileInputStream.read(), гигабайтный файл обрабатывается часами. Если тот же файл читать кусками по 64 000 байт в массив byte[], а результаты складывать в обычный long[256] вместо коллекции, время падает до долей секунды. Ниже пять вариантов одной и той же программы, от самого наивного до быстрого, с реальными замерами на файлах в 46 Мб, 1 Гб и 32 Гб.

Кратко

  • Задача такая: посчитать, какие байты встречаются в файле, и вывести их коды по убыванию без повторов.
  • ArrayList<Integer> на миллиард элементов не переживет ни один компьютер: каждое число превращается в объект и тянет за собой заголовок и ссылку.
  • TreeSet снимает проблему памяти (в множестве не больше 256 элементов) и сортирует сам, но само чтение остается медленным.
  • Обычный long[256] со счетчиками быстрее любой коллекции и не требует сортировки вовсе: индекс массива это и есть байт-код.
  • Главный тормоз не в структуре данных, а в том, что байты читаются по одному: каждое обращение к диску стоит дорого.
  • Чтение блоками по 64 000 байт ускоряет обработку гигабайтного файла с трех часов до долей секунды.

Начнем-с

В 18 уровне начались первые задачи побайтного чтения файлов: прочитать файл, далее найти минимальные/максимальные байты или вывести в упорядоченном виде и т.п.
Абстрактный фон: наложенные друг на друга листы серой бумаги
Народ тут весьма ушлый. Знают про коллекции и про то, что они могут сортировать, вставлять. Коллекции это мощный механизм. И многие не применяли их вообще до JavaRush-а. Оно, конечно, похвально изучать их и пытаться приткнуть куда не попадя. И так. Возьмем задачу, которой нет в заданиях (чтобы не было спойлеров при решении), но есть сильно похожие:
  • Ввести с консоли имя файла
  • Считать все байты из файла.
  • Не учитывая повторений - отсортировать их по байт-коду в убывающем порядке.
  • Вывести на экран
  • Закрыть поток ввода-вывода
Пример байт входного файла 44 83 44 Пример вывода 83 44 Мы дополнительно завели переменные startTime и finishTime, чтобы засечь время выполнения программы. Для вычисления использовал i3-3GHz/8Gb RAM/HDD WD Blue-1Tb/Win7-64/jdk-8u73-windows-x64 (примеры программ в вариантах 1-2 взяты из форума info.javarush, они чуть модифицированы только для сортировки в возрастающем порядке - то есть они РЕАЛЬНЫЕ!!)

Решаем в лоб:


// Вариант 1. Загоняем в коллекцию и сортируем используя ее метод Collections.sort 
public class Solution {
    public static void main(String[] args) throws Exception {
        FileInputStream inputStream = new FileInputStream(new BufferedReader(new InputStreamReader(System.in)).readLine());
        long startTime = System.currentTimeMillis();
        
        ArrayList<Integer> listData = new ArrayList<Integer>();
        while (inputStream.available() > 0) listData.add(inputStream.read());
        inputStream.close();
        ArrayList<Integer> result = new ArrayList<Integer>(new HashSet<Integer>(listData));
        Collections.sort(result);

        while (!result.isEmpty()) {
            System.out.print(result.get(result.size()-1) + " ");
            result.remove(result.get(result.size()-1));
        }

        long finishTime = System.currentTimeMillis();
        System.out.println("\nвремя работы=" + (finishTime-startTime) + "ms.");
    }
}
Решает все замечательно! Тест (если бы был, прошелся бы на ура). Но в жизни мало файлов содержащих только строчку "Мама мыла раму". Давайте скормим нашей программе файл в 46Мб (по нынешним меркам вроде и не особо много). Что такое, программа выполняется 220 секунд. Попытка скормить с вечера 1Gb файл (размер MPEG4 фильма не в самом лучшем качестве) не увенчалась успехом. Программа утром все еще читала - а мне идти на работу уже. В чем проблема? Наверное, в использовании ArrayList<Integer>, у которого внутри 1 миллиард элементов. Каждый элемент занимает минимум 16 байт (заголовок объекта 12 байт плюс само поле int 4 байта), и это не считая ссылки на объект в массиве внутри списка, а она добавляет еще 4 байта. Часть значений, от -128 до 127, Java берет из кэша Integer и заново не создает, но даже с этой поправкой на миллиарде элементов в память уезжает больше десяти гигабайт при размере оперативы в 8. Будем делать лучше. Нырнем в коллекции глубже. И ура, нашлось то, что нам нужно.

Встречаем TreeSet

Это множество:
  • не допускает хранение двух одинаковых элементов (а значит мы будем хранить в памяти все 255 элементов, вместо миллиарда!)
  • при манипуляциях со своими элементами автоматом упорядочивает (само сортирует - вот он, верх совершенства!)
Получаем:

// Вариант 2. Загоняем в ТreeSet который сам сортирует (лютый win!)
public class Solution {
    public static void main(String[] args) throws Exception {
        FileInputStream inputStream = new FileInputStream(new BufferedReader(new InputStreamReader(System.in)).readLine());

        byte[] arrBytes = new byte[256];
        long startTime = System.currentTimeMillis();

        SortedSet<Integer> list = new TreeSet<Integer>();
        while(inputStream.available()>0) list.add(inputStream.read());
        inputStream.close();

        while (!list.isEmpty())        {
            System.out.print(list.last() + " ");
            list.remove(list.last());
        }

		long finishTime = System.currentTimeMillis();
        System.out.println("\nвремя работы=" + (finishTime-startTime) + "ms.");
    }
}
Имеем на выходе: 46Мб файл 176 секунд. 1Gb файл - 3 часа 5 минут. Прогресс налицо. Мы смогли "дождаться" результатов, да и 46Мб файл заметно быстрее обрабатывается. Идем дальше. Давайте попытаемся отказаться от коллекций (это будет для некоторых мучительно больно). Будем использовать простые массивы (это так примитивно). Заметим одну важную вещь. Кол-во встречающихся байт можно загнать в массив длиной 256. Так просто будем увеличивать на единицу соответствующий считанному байту элемент массива.

Массив: побайтно


// Вариант 3. Считываем массив побайтно.
public class Solution {
    public static void main(String[] args) throws Exception {
        FileInputStream inputStream = new FileInputStream(new BufferedReader(new InputStreamReader(System.in)).readLine());

        long[] arrBytes = new long[256];
        long startTime = System.currentTimeMillis();
        
        while (inputStream.available() > 0) arrBytes[inputStream.read()]++;

		inputStream.close();
        // Выводим отсортированный по байт-коду в обратном порядке
        for (long i = 255; i >= 0 ; i--)
            if (arrBytes[(int) i] > 0) System.out.print(i + " ");

			long finishTime = System.currentTimeMillis();
        System.out.println("\nвремя работы=" + (finishTime-startTime) + "ms.");
    }
}
Имеем на выходе: 46Мб файл 158 секунд. 1Gb файл - 2 часа 55 минут. Опять улучшение, но небольшое. И мы сделали все простыми инструментами. Не использовали микроскоп для забивания гвоздей. Теперь лирическое отступление. Вспомним устройство компьютера. Память ОЗУ (DRAM) где обычно выполняется программа и хранятся переменные имеет высокую скорость доступа, но небольшой размер. Память на жестком/flash диске (HDD или Flash-накопители) где обычно хранятся файлы, наоборот имеет низкую скорость доступа, но большой размер. Так что когда мы побайтно читаем 1Gb файл (то есть миллиард раз обращаемся к HDD) - мы тратим много времени на работу с низкоскоростным устройством (по песчинке перекладываем песок с кузова КамАЗа в песочницу). Попробуем еще улучшить.

Вывалим сразу ВЕСЬ КамАЗ с песком за один раз!


// Вариант 4. Считываем массив сразу целиком за раз в память.
public class Solution {
    public static void main(String[] args) throws Exception {
        FileInputStream inputStream = new FileInputStream(new BufferedReader(new InputStreamReader(System.in)).readLine());

        long[] arrBytes = new long[256];
        long startTime = System.currentTimeMillis();
        
        byte fileImage[]=new byte[inputStream.available()];
        long fileSize=fileImage.length;
        inputStream.read(fileImage);
        for (int i = 0; i = 0 ; i--)
            if (arrBytes[(int) i] > 0) System.out.print(i + " ");

		long finishTime = System.currentTimeMillis();
        System.out.println("\nвремя работы=" + (finishTime-startTime) + "ms.");
    }
}
небольшое, но опять таки важное отступление Заметим:
  1. индекс у arrBytes определен в пределах 0..255,
  2. fileImage - массив байт, элементы которого имеют значение -128..127
Поэтому для подсчета байт будем использовать конструкцию arrBytes[fileImage[i] & 0b11111111]++; которая банально сбросит бит знака и вернет нам значение в диапазоне 0..255 И так, результаты: 46Мб файл 0.13 секунды (меньше секунды). 1Gb файл - 9 секунд. Мы сделали это! Мы невероятно круты! Ускорились с 3 часов до 9 секунд. Все, можно откинуться в кресле и попить чайку. А теперь еще один эксперимент - попробуем файл в 32 Gb (например, HD фильм). Получим в результате треск работающего HDD с вываливанием программы в Windows. КамАЗ вывалив кузов с песком сломал песочницу! Что будем делать? Вспомним еще один факт. Файлы в ОС хранятся обычно порциями (кластерами) по 2-64Кб (зависит от типа файловой системы, настроек и т.п.). Будем считывать порциями, для примера в 64000 байт. Попытаемся разгрузить КамАЗ экскаватором достаточно большими порциями:

Используем буфер


// Вариант 5. Считываем массив кусками.
public class Solution {
    public static void main(String[] args) throws Exception {
        FileInputStream inputStream = new FileInputStream(new BufferedReader(new InputStreamReader(System.in)).readLine());

        long[] arrBytes = new long[256];
        long startTime = System.currentTimeMillis();
        
        int  bufferSize = 64000;
        byte buffer[]   = new byte[64000];

        while (inputStream.available() > 0) {
            if (inputStream.available() < 64000) bufferSize = inputStream.available();
            inputStream.read(buffer, 0, bufferSize );
            for (int i = 0; i = 0 ; i--)
            if (arrBytes[(int) i] > 0) System.out.print(i + " ");

		long finishTime = System.currentTimeMillis();
        System.out.println("\nвремя работы=" + (finishTime-startTime) + "ms.");
    }
}
В итоге получили: 46Мб файл 0.08 секунды (меньше секунды). 1Gb файл - 0.9 секунд(меньше секунды). 32Gb файл - 31 секунда. Заметим для 1 Gb файла мы улучшили производительность с нескольких часов до долей секунд!!!

Сравним все пять вариантов

Чтобы цифры не были разбросаны по всей статье, сведем их в одну таблицу. Замеры сделаны на одной и той же машине, ее конфигурация указана в начале:
Вариант Что внутри 46 Мб 1 Гб 32 Гб
1. ArrayList + Collections.sort Все байты в список, потом HashSet и сортировка 220 с не дождался не проверялся
2. TreeSet Множество само хранит только уникальные значения и сортирует 176 с 3 ч 5 мин не проверялся
3. long[256], побайтно Счетчики в массиве, индекс это байт-код 158 с 2 ч 55 мин не проверялся
4. Файл целиком в память Весь файл в один byte[], потом счетчики 0.13 с 9 с падает
5. Чтение блоками по 64 000 байт Тот же массив счетчиков, но файл читается кусками 0.08 с 0.9 с 31 с
На этом скромном факте закончим эксперимент и улучшение начального кода. Мы достигли прогресса во многом - нас радуют новые показатели расхода памяти и времени работы. Также мы не подтягиваем в данном случае бесполезные коллекции из стандартной библиотеки.

Что здесь не так с точки зрения продакшена

Все пять вариантов выше решают учебную задачу и решают ее верно. Но если такой код поедет в реальный проект, у него найдутся два слабых места. Первое. В варианте 4 размер файла определяется как new byte[inputStream.available()]. Документация Oracle предупреждает об этом дословно: возвращаемое значение available() никогда не следует использовать для выделения буфера под весь поток. С FileInputStream на локальном файле это случайно работает, но для потока из сети или из конвейера метод вернет лишь то, что успело прийти в буфер прямо сейчас. Второе. Конструкция while (inputStream.available() > 0) ненадежна по той же причине: у сетевого потока данные могут просто задержаться в пути, метод вернет ноль, и цикл завершится на середине файла. Плюс во всех вариантах игнорируется то, что возвращает read(), а он имеет полное право прочитать меньше байт, чем у него попросили. Канонический цикл чтения обеих проблем не имеет и выглядит так:

int count;
while ((count = inputStream.read(buffer)) != -1) {
    for (int i = 0; i < count; i++) arrBytes[buffer[i] & 0b11111111]++;
}
Признаком конца файла тут служит -1, который возвращает сам read(), а переменная count говорит, сколько байт реально прочитано за этот заход.

Как это пишут сегодня

Статье почти десять лет, и с тех пор часть работы взял на себя стандартный пакет. Во-первых, ручной буфер из варианта 5 давно реализован в классе BufferedInputStream: он делает ровно то же самое, читает с диска большими кусками и отдает вам хоть по одному байту. Во-вторых, поток стоит открывать в try-with-resources: тогда он закроется сам даже при исключении, а не только при удачном завершении, как сейчас. Тот же подсчет байт на современных средствах:

public class Solution {
    public static void main(String[] args) throws Exception {
        Path path = Paths.get(new BufferedReader(new InputStreamReader(System.in)).readLine());
        long[] arrBytes = new long[256];

        try (InputStream in = new BufferedInputStream(Files.newInputStream(path))) {
            byte[] buffer = new byte[64000];
            int count;
            while ((count = in.read(buffer)) != -1) {
                for (int i = 0; i < count; i++) arrBytes[buffer[i] & 0b11111111]++;
            }
        }

        // Выводим отсортированный по байт-коду в обратном порядке
        for (int i = 255; i >= 0; i--)
            if (arrBytes[i] > 0) System.out.print(i + " ");
    }
}
Для файлов, которые заведомо помещаются в память, есть путь еще короче: Files.readAllBytes(path) вернет сразу весь массив байт. Но разбираться, что происходит под капотом, все равно полезнее, чем сразу звать готовый метод, ради этого статья и написана. P.S. Кто-то скажет пример надуманный и т.п. Но полно похожих задач: проанализировать огромный объем элементов, имеющих конечное число состояний. Например изображения (RGB обычно хранится в 24 битах, в нашем случае long[] arrRGB = new long[256*256*256] занял бы в памяти 128Мб), музыка (амплитуда обычно оцифровывается в 16 или 24 бита) или дискретные показатели датчиков и т.п.

Вопросы и ответы

Почему побайтное чтение файла работает так медленно?

Потому что каждый вызов read() без буфера доходит до операционной системы, а обращение к диску стоит на порядки дороже операции в памяти. Миллиард байт это миллиард таких обращений. В примерах выше картину усугубляет и available(), который тоже вызывается на каждой итерации цикла. Лечится это чтением блоками: один вызов забирает сразу десятки тысяч байт.

Зачем в коде побитовая маска 0b11111111?

Потому что byte в Java знаковый и хранит значения от -128 до 127, а индекс массива счетчиков нужен в диапазоне от 0 до 255. Маска & 0b11111111 (то же самое, что & 0xFF) обнуляет разряды выше восьмого, которые появляются при расширении byte до int, и превращает -1 в 255. Без нее на первом же байте со старшим битом программа упала бы с ArrayIndexOutOfBoundsException.

Можно ли просто прочитать файл целиком в массив?

Для небольших файлов да, но ограничений два. Первое: массив в Java адресуется int, поэтому больше примерно двух гигабайт в него физически не поместится. Второе: файл целиком должен влезть в оперативную память, и вариант 4 выше именно поэтому падает на 32 Гб. Отдельно стоит знать, что определять размер файла через available() неправильно: документация Oracle прямо предупреждает, что возвращаемое значение этого метода нельзя использовать для выделения буфера под весь поток.

Какой размер буфера выбрать?

Разумный диапазон это от 8 до 64 килобайт. Смысл в том, чтобы за одно обращение к диску забирать сразу несколько кластеров файловой системы: дальнейшее увеличение почти не дает выигрыша, зато расходует память. Для ориентира у BufferedInputStream размер буфера по умолчанию 8192 байта. В примере выше взято 64 000 байт, и этого хватает с запасом.

Можно ли обойтись без ручного буфера?

Да. Обернув поток в BufferedInputStream, вы получите буферизацию бесплатно и с тем же эффектом, а читать сможете хоть по одному байту. Есть и более короткие пути из пакета java.nio.file: Files.newInputStream() вместо new FileInputStream() и Files.readAllBytes(), если файл заведомо небольшой. Ручной буфер в статье полезен тем, что показывает, что именно происходит внутри.

Почему ArrayList из Integer занимает так много памяти?

Потому что в коллекцию нельзя положить примитив: каждое число превращается в объект Integer. А объект это не только четыре байта самого значения, но и служебный заголовок, выравнивание до кратности восьми и ссылка на него в массиве внутри списка. В сумме выходит около двадцати байт на одно число вместо одного байта, который вы прочитали из файла. На миллиарде элементов эта разница и становится решающей.

Читайте также