Java Language
Ошибки Java - проблемы с производительностью
Поиск…
Вступление
В этом разделе описывается ряд «ошибок» (т. Е. Ошибок, создаваемых новичками java-программистов), которые относятся к производительности Java-приложений.
замечания
В этом разделе описываются некоторые «микро» методы кодирования Java, которые неэффективны. В большинстве случаев неэффективность относительно невелика, но по-прежнему стоит избегать их.
Pitfall - накладные расходы на создание сообщений журнала
Уровни журналов TRACE
и DEBUG
позволяют передавать подробные сведения о работе данного кода во время выполнения. Обычно рекомендуется устанавливать уровень журнала выше этих, однако необходимо соблюдать осторожность, чтобы эти утверждения не влияли на производительность даже при кажущемся «выключенном».
Рассмотрим этот оператор журнала:
// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString()
+ " parameters: " + Arrays.toString(veryLongParamArray));
Даже если для уровня журнала задано значение INFO
, аргументы, переданные в debug()
будут оцениваться при каждом выполнении строки. Это делает его излишне потребляющим по нескольким причинам:
-
String
конкатенация: несколькоString
экземпляров будут созданы -
InetAddress
может даже выполнять поиск DNS. -
veryLongParamArray
может быть очень длинным - создание String из него потребляет память, требует времени
Решение
Большинство систем ведения журналов предоставляют средства для создания журнальных сообщений с использованием строк исправлений и ссылок на объекты. Сообщение журнала будет оцениваться только в том случае, если сообщение действительно зарегистрировано. Пример:
// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));
Это работает очень хорошо, если все параметры могут быть преобразованы в строки с помощью String.valueOf (Object) . Если перевод сообщений журнала более сложный, уровень регистрации можно проверить перед протоколированием:
if (LOG.isDebugEnabled()) {
// Argument expression evaluated only when DEBUG is enabled
LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
Arrays.toString(veryLongParamArray);
}
Здесь LOG.debug()
с дорогостоящим Arrays.toString(Obect[])
обрабатывается только тогда, когда DEBUG
фактически включен.
Pitfall - Конкатенация строк в цикле не масштабируется
В качестве иллюстрации рассмотрим следующий код:
public String joinWords(List<String> words) {
String message = "";
for (String word : words) {
message = message + " " + word;
}
return message;
}
Несчастливо этот код неэффективен, если список words
длинный. Корень проблемы заключается в следующем:
message = message + " " + word;
Для каждой итерации цикла этот оператор создает новую строку message
содержащую копию всех символов в исходной строке message
с добавленными к ней дополнительными символами. Это создает много временных строк и много копирует.
Когда мы анализируем joinWords
, предполагая, что существует N слов со средней длиной M, мы обнаруживаем, что создаются временные строки O (N), и в процессе будут скопированы символы O (MN 2 ). Компонент N 2 вызывает особую тревогу.
Рекомендуемый подход для этой проблемы 1 заключается в использовании StringBuilder
вместо конкатенации строк следующим образом:
public String joinWords2(List<String> words) {
StringBuilder message = new StringBuilder();
for (String word : words) {
message.append(" ").append(word);
}
return message.toString();
}
Анализ joinWords2
необходимо учитывать накладные расходы «взросление» в StringBuilder
массива подложки , который содержит символы Строителя. Однако выясняется, что количество новых объектов, созданных, равно O (logN), а количество копируемых символов - O (MN). Последний включает символы, скопированные в окончательный вызов toString()
.
(Возможно, это можно будет настроить дальше, создав StringBuilder
с правильной способностью для начала. Однако общая сложность остается неизменной.)
Возвращаясь к исходному методу joinWords
, выясняется, что критический оператор будет оптимизирован типичным компилятором Java примерно так:
StringBuilder tmp = new StringBuilder();
tmp.append(message).append(" ").append(word);
message = tmp.toString();
Однако компилятор Java не будет «вытаскивать» StringBuilder
из цикла, как это joinWords2
вручную в коде для joinWords2
.
Ссылка:
1 - В Java 8 и более поздних версиях класс Joiner
может использоваться для решения этой конкретной проблемы. Тем не менее, это не то, о чем этот пример действительно должен быть .
Pitfall - использование «нового» для создания примитивных экземпляров оболочки неэффективно
Язык Java позволяет использовать new
для создания экземпляров Integer
, Boolean
и т. Д., Но, как правило, это плохая идея. Лучше использовать autoboxing (Java 5 и более поздние версии) или метод valueOf
.
Integer i1 = new Integer(1); // BAD
Integer i2 = 2; // BEST (autoboxing)
Integer i3 = Integer.valueOf(3); // OK
Причина, по которой использование new Integer(int)
явно является плохой идеей, заключается в том, что он создает новый объект (если только не оптимизирован JIT-компилятором). В отличие от этого, когда используется автобоксинг или явный вызов valueOf
, среда выполнения Java будет пытаться повторно использовать объект Integer
из кэша ранее существовавших объектов. Каждый раз, когда время выполнения имеет «попадание в кеш», оно позволяет избежать создания объекта. Это также экономит память кучи и уменьшает накладные расходы GC, вызванные оттоком объектов.
Заметки:
- В последних реализациях Java автобоксинг реализуется путем вызова
valueOf
, и есть кеши дляBoolean
,Byte
,Short
,Integer
,Long
иCharacter
. - Поведение кэширования для интегральных типов обеспечивается спецификацией Java Language Specification.
Pitfall - вызов новой строки (String) неэффективен
Использование new String(String)
для дублирования строки неэффективно и почти всегда не нужно.
- Строковые объекты неизменяемы, поэтому нет необходимости копировать их для защиты от изменений.
- В некоторых более старых версиях Java объекты
String
могут совместно использовать массивы поддержки с другими объектамиString
. В этих версиях возможно утечка памяти путем создания (малой) подстроки (большой) строки и ее сохранения. Однако, начиная с Java 7, массивы поддержкиString
не разделяются.
В отсутствие каких-либо ощутимых преимуществ вызов new String(String)
просто расточительно:
- Выполнение копии занимает процессорное время.
- Копия использует больше памяти, что увеличивает заметную память приложения и / или увеличивает накладные расходы GC.
- Операции типа
equals(Object)
иhashCode()
могут быть медленнее при копировании объектов String.
Pitfall - Calling System.gc () неэффективен
Это (почти всегда) плохая идея вызвать System.gc()
.
Javadoc для метода gc()
указывает следующее:
«Вызов метода
gc
предполагает, что виртуальная машина Java тратит усилия на повторное использование неиспользуемых объектов, чтобы сделать память, которую они в настоящее время занимают, для быстрого повторного использования. Когда управление возвращается из вызова метода, виртуальная машина Java приложила все усилия для восстановления пространство от всех отброшенных объектов ».
Из этого можно выделить пару важных моментов:
Использование слова «предлагает», а не (скажем) «говорит» означает, что JVM может игнорировать это предложение. Поведение JVM по умолчанию (последние выпуски) заключается в том, чтобы следовать этому предложению, но это можно переопределить, установив
-XX:+DisableExplicitGC
при запуске JVM.Фраза «лучшее усилие для освобождения пространства от всех отброшенных объектов» подразумевает, что вызов
gc
вызовет «полную» сборку мусора.
Итак, почему вызывает System.gc()
плохую идею?
Во-первых, полная сборка мусора дорогая. Полный GC включает посещение и «маркировку» каждого объекта, который все еще доступен; т.е. каждый объект, который не является мусором. Если вы инициируете это, когда не нужно собирать мусор, GC делает большую работу за относительно небольшую выгоду.
Во-вторых, полная сборка мусора может нарушить свойства «локальности» объектов, которые не собираются. Объекты, которые распределены одним и тем же потоком примерно в одно и то же время, как правило, распределяются близко друг к другу в памяти. Это хорошо. Объекты, которые распределены одновременно, скорее всего, будут связаны; т.е. ссылаться друг на друга. Если ваше приложение использует эти ссылки, то вероятность того, что доступ к памяти будет быстрее из-за различных эффектов кэширования памяти и страницы. К сожалению, полная сборка мусора, как правило, перемещает объекты вокруг, так что объекты, которые когда-то были близки, теперь раздвинуты.
В-третьих, запуск полной сборки мусора может привести к приостановке вашего приложения до тех пор, пока сбор не будет завершен. Пока это происходит, ваше приложение будет невосприимчивым.
На самом деле, лучшая стратегия - позволить JVM решить, когда запускать GC и какую коллекцию запускать. Если вы не вмешиваетесь, JVM выберет время и тип сбора, который оптимизирует пропускную способность или минимизирует время паузы GC.
Сначала мы говорили «... (почти всегда) плохая идея ...». На самом деле есть несколько сценариев, где это может быть хорошей идеей:
Если вы выполняете единичный тест для некоторого кода, который чувствителен к сборке мусора (например, что-то включает финализаторы или слабые / мягкие / фантомные ссылки
System.gc()
может потребоваться вызовSystem.gc()
.В некоторых интерактивных приложениях могут быть определенные моменты времени, когда пользователю не будет делаться пауза в сборе мусора. Одним из примеров является игра, в которой есть естественные паузы в «игре»; например, при загрузке нового уровня.
Pitfall - чрезмерное использование примитивных типов обертки неэффективно
Рассмотрим эти два фрагмента кода:
int a = 1000;
int b = a + 1;
а также
Integer a = 1000;
Integer b = a + 1;
Вопрос: Какая версия более эффективна?
Ответ: Две версии выглядят почти одинаково, но первая версия намного эффективнее второй.
Вторая версия использует представление для чисел, которые используют больше пространства, и опирается на автоматическое боксирование и автоматическое распаковывание за кулисами. Фактически вторая версия прямо эквивалентна следующему коду:
Integer a = Integer.valueOf(1000); // box 1000
Integer b = Integer.valueOf(a.intValue() + 1); // unbox 1000, add 1, box 1001
Сравнивая это с другой версией, использующей int
, есть четыре дополнительных вызова метода, когда используется Integer
. В случае valueOf
каждый вызов будет создавать и инициализировать новый объект Integer
. Вся эта дополнительная работа по боксу и распаковке, вероятно, сделает вторую версию на порядок медленнее, чем первая.
В дополнение к этому, вторая версия выделяет объекты в куче в каждом вызове valueOf
. Хотя использование пространства зависит от платформы, оно, вероятно, будет находиться в области 16 байт для каждого объекта Integer
. Напротив, версия int
нуждается в нулевом избытке кучи, предполагая, что a
и b
являются локальными переменными.
Еще одна важная причина, по которой примитивы быстрее, чем их эквивалент в коробке, - это то, как их соответствующие типы массивов выкладываются в памяти.
Если вы берете int[]
и Integer[]
в качестве примера, то в случае int[]
значения int
смежно располагаются в памяти. Но в случае Integer[]
это не значения, которые выложены, а ссылки (указатели) на объекты Integer
, которые, в свою очередь, содержат фактические значения int
.
Помимо того, что это дополнительный уровень косвенности, это может быть большой танк, когда дело доходит до местоположения кеша при повторении значений. В случае int[]
процессор может извлекать все значения в массиве в его кеш-память сразу, потому что они смежны в памяти. Но в случае Integer[]
процессор потенциально должен выполнить дополнительную выборку памяти для каждого элемента, так как массив содержит только ссылки на фактические значения.
Короче говоря, использование примитивных типов обертки относительно дорого как в ресурсах ЦП, так и в памяти. Использование их излишне эффективно.
Pitfall - Итерация ключей карты может быть неэффективной
Следующий примерный код медленнее, чем нужно:
Map<String, String> map = new HashMap<>();
for (String key : map.keySet()) {
String value = map.get(key);
// Do something with key and value
}
Это связано с тем, что для каждого ключа на карте требуется поиск по карте (метод get()
). Этот поиск может быть неэффективным (в HashMap он влечет за собой вызов hashCode
на ключ, а затем поиск правильного ведра во внутренних структурах данных, а иногда даже вызов equals
). На большой карте это не может быть тривиальным накладным.
Правильный способ избежать этого - перебирать записи карты, которые подробно описаны в разделе « Коллекции»
Pitfall - использование параметра size () для проверки, является ли коллекция пустой, неэффективно.
Рамка коллекций Java предоставляет два связанных метода для всех объектов Collection
:
-
size()
возвращает количество записей вCollection
и - Метод
isEmpty()
возвращает true, если (и только если)Collection
пуста.
Оба метода можно использовать для проверки пустоты коллекции. Например:
Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty(); // Best
Хотя эти подходы выглядят одинаково, некоторые реализации коллекции не сохраняют размер. Для такой коллекции реализация size()
должна вычислять размер каждый раз, когда он вызывается. Например:
- Простой класс связанного списка (но не
java.util.LinkedList
), возможно, потребуется пройти через список для подсчета элементов. - Класс
ConcurrentHashMap
должен суммировать записи во всех «сегментах» карты. - Для ленивой реализации коллекции может потребоваться реализовать всю коллекцию в памяти, чтобы подсчитать элементы.
Напротив, метод isEmpty()
должен только проверять, есть ли хотя бы один элемент в коллекции. Это не связано с подсчетом элементов.
Хотя size() == 0
не всегда менее эффективен, чем isEmpty()
, для правильно реализованного isEmpty()
немыслимо менее эффективно, чем size() == 0
. Следовательно, isEmpty()
является предпочтительным.
Pitfall - Эффективность связана с регулярными выражениями
Регулярное выражение является мощным инструментом (в Java и в других контекстах), но имеет некоторые недостатки. Одно из них, что регулярные выражения имеют тенденцию быть довольно дорогими.
Образцы шаблонов и совпадений следует использовать повторно
Рассмотрим следующий пример:
/**
* Test if all strings in a list consist of English letters and numbers.
* @param strings the list to be checked
* @return 'true' if an only if all strings satisfy the criteria
* @throws NullPointerException if 'strings' is 'null' or a 'null' element.
*/
public boolean allAlphanumeric(List<String> strings) {
for (String s : strings) {
if (!s.matches("[A-Za-z0-9]*")) {
return false;
}
}
return true;
}
Этот код правильный, но он неэффективен. Проблема заключается в matches(...)
вызова. Под капотом s.matches("[A-Za-z0-9]*")
эквивалентно этому:
Pattern.matches(s, "[A-Za-z0-9]*")
что в свою очередь эквивалентно
Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()
Pattern.compile("[A-Za-z0-9]*")
анализирует регулярное выражение, анализирует его и создает объект Pattern
который содержит структуру данных, которая будет использоваться движком regex. Это нетривиальное вычисление. Затем объект Matcher
создается, чтобы обернуть аргумент s
. Наконец, мы вызываем match()
чтобы выполнить фактическое сопоставление шаблонов.
Проблема в том, что эта работа повторяется для каждой итерации цикла. Решение состоит в том, чтобы реструктурировать код следующим образом:
private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");
public boolean allAlphanumeric(List<String> strings) {
Matcher matcher = ALPHA_NUMERIC.matcher("");
for (String s : strings) {
matcher.reset(s);
if (!matcher.matches()) {
return false;
}
}
return true;
}
Обратите внимание, что javadoc для Pattern
указывает:
Экземпляры этого класса являются неизменными и безопасны для использования несколькими параллельными потоками. Экземпляры класса
Matcher
небезопасны для такого использования.
Не используйте match (), когда вы должны использовать find ()
Предположим, вы хотите проверить, содержит ли строка s
три или более цифр подряд. Вы можете выразить это различными способами, в том числе:
if (s.matches(".*[0-9]{3}.*")) {
System.out.println("matches");
}
или же
if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
System.out.println("matches");
}
Первый из них более краткий, но он также, вероятно, будет менее эффективным. На первый взгляд первая версия попытается сопоставить всю строку с шаблоном. Кроме того, поскольку «. *» Является «жадным» шаблоном, совпадение шаблонов, вероятно, будет «нетерпеливо» пытаться до конца строки и возвращаться, пока не найдет совпадение.
Напротив, вторая версия будет искать слева направо и прекратит поиск, как только он найдет 3 цифры подряд.
Используйте более эффективные альтернативы регулярным выражениям
Регулярные выражения - это мощный инструмент, но они не должны быть вашим единственным инструментом. Многие задачи могут быть выполнены более эффективно другими способами. Например:
Pattern.compile("ABC").matcher(s).find()
делает то же самое, что:
s.contains("ABC")
за исключением того, что последний намного эффективнее. (Даже если вы можете амортизировать затраты на компиляцию регулярного выражения.)
Часто не-регулярное выражение является более сложным. Например, тест, выполняемый allAlplanumeric
matches()
вызывает более ранний allAlplanumeric
метод, можно переписать как:
public boolean matches(String s) {
for (char c : s) {
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9')) {
return false;
}
}
return true;
}
Теперь это больше кода, чем использование Matcher
, но он также будет значительно быстрее.
Катастрофический отход
(Это потенциально проблема со всеми реализациями регулярных выражений, но мы упомянем это здесь, потому что это ловушка для использования Pattern
.)
Рассмотрим этот (надуманный) пример:
Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());
Первый вызов println
быстро напечатает true
. Второй будет печатать false
. В конце концов. Действительно, если вы экспериментируете с приведенным выше кодом, вы увидите, что каждый раз, когда вы добавляете A
перед C
, время будет удвоенным.
Это поведение является примером катастрофического отступления . Механизм соответствия шаблонов, который реализует соответствие регулярному выражению, бесплодно пытается использовать все возможные способы, которыми может соответствовать шаблон.
Давайте посмотрим, что означает (A+)+B
По-видимому, кажется, что «один или несколько символов A
за которым следует значение B
», но на самом деле он говорит одну или несколько групп, каждая из которых состоит из одного или нескольких символов A
Так, например:
- «AB» соответствует только одному способу: '(A) B'
- «AAB» соответствует двум путям: «(AA) B» или «(A) (A) B`
- «AAAB» соответствует четырем путям: «(AAA) B» или «(AA) (A) B
or '(A)(AA)B
или «(A) (A) (A) B` - и так далее
Другими словами, количество возможных совпадений равно 2 N, где N - количество символов A
Вышеприведенный пример явно надуман, но шаблоны, демонстрирующие такие характеристики (например, O(2^N)
или O(N^K)
для большого K
) возникают часто, когда используются неосмотрительные регулярные выражения. Существуют некоторые стандартные средства защиты:
- Избегайте встраивания повторяющихся шаблонов в другие повторяющиеся шаблоны.
- Избегайте использования слишком много повторяющихся шаблонов.
- Повторно используйте повторное повторное следование.
- Не используйте регулярные выражения для сложных задач синтаксического анализа. (Вместо этого напишите правильный парсер.)
Наконец, остерегайтесь ситуаций, когда пользователь или клиент API могут снабжать строку регулярных выражений патологическими характеристиками. Это может привести к случайному или преднамеренному «отказу в обслуживании».
Рекомендации:
- Тег Regular Expressions , особенно http://www.riptutorial.com/regex/topic/259/getting-started-with-regular-expressions/977/backtracking#t=201610010339131361163 и http://www.riptutorial.com/ регулярное выражение / тема / 259 / получение стартер-с-регулярные выражениями / 4527 / , когда вы-должны-не-потребительный регулярные выражения # т = 201610010339593564913
- «Эффект регулярного выражения» Джеффа Этвуда.
- «Как убить Java с помощью регулярного выражения» Андреаса Хауфлера.
Pitfall - встроенные строки, чтобы вы могли использовать ==, - это плохая идея
Когда некоторые программисты видят этот совет:
«Тестирование строк с использованием
==
неверно (если строки не интернированы)»
их первоначальная реакция заключается в интерновных строках, чтобы они могли использовать ==
. (В конце концов ==
быстрее, чем вызов String.equals(...)
, не так ли.)
Это неправильный подход, с нескольких точек зрения:
хрупкость
Прежде всего, вы можете только безопасно использовать ==
если знаете, что все объекты String
вы тестируете, были интернированы. JLS гарантирует, что строковые литералы в вашем исходном коде будут интернированы. Однако ни один из стандартных API Java SE не гарантирует возврата интернированных строк, кроме String.intern(String)
. Если вы пропустили только один источник объектов String
, которые не были интернированы, ваше приложение будет ненадежным. Эта ненадежность проявится как ложные негативы, а не исключения, которые могут затруднить обнаружение.
Затраты на использование «intern ()»
Под капотом интернирование выполняется путем сохранения хеш-таблицы, содержащей ранее интернированные объекты String
. Используется какой-то слабый механизм ссылок, так что хеш-таблица интернирования не становится утечкой хранилища. Хотя хэш-таблица реализована в собственном коде (в отличие от HashMap
, HashTable
и т. Д.), intern
вызовы по-прежнему относительно дорогостоящие с точки зрения используемого процессора и памяти.
Эта стоимость должна сравниваться с сохранением, которую мы собираемся получить, используя ==
вместо equals
. На самом деле, мы не собираемся ломаться, даже если каждая интернированная строка сравнивается с другими строками «несколько раз».
(Помимо этого: несколько ситуаций, в которых интернирование стоит, как правило, сводятся к уменьшению печати в ноге памяти приложения, когда одни и те же строки повторяются много раз, и эти строки имеют долгий срок службы.)
Влияние на сбор мусора
Помимо прямых затрат на процессор и память, описанных выше, интернированные строки влияют на производительность сборщика мусора.
Для версий Java до Java 7 интерполяционные строки хранятся в пространстве «PermGen», который собирается нечасто. Если PermGen необходимо собрать, это (обычно) запускает полную сборку мусора. Если пространство PermGen заполняется полностью, JVM аварийно завершает работу, даже если в обычных пространствах кучи было свободное пространство.
В Java 7 пул строк был перемещен из «PermGen» в обычную кучу. Однако хеш-таблица по-прежнему будет долговечной структурой данных, которая заставит любые интернированные строки быть долговечными. (Даже если объекты интернированных строк были выделены в пространстве Эдена, они, скорее всего, будут продвигаться до их сбора.)
Таким образом, во всех случаях интернирование строки увеличивает продолжительность жизни по сравнению с обычной строкой. Это увеличит накладные расходы на сборку мусора на протяжении всей жизни JVM.
Вторая проблема заключается в том, что хеш-таблица должна использовать слабый механизм ссылок, чтобы предотвратить прерывание строки с утечкой памяти. Но такой механизм больше подходит для сборщика мусора.
Эти накладные расходы на сбор мусора трудно определить количественно, но нет сомнений в том, что они существуют. Если вы широко используете intern
, они могут быть значительными.
Размер хэш-таблицы пула строк
Согласно этому источнику , начиная с Java 6, пул строк реализуется как хэш-таблица фиксированного размера с цепочками для обработки строк, которые хешируются в одном и том же ведре. В ранних выпусках Java 6 хэш-таблица имела (жесткий) постоянный размер. Параметр настройки ( -XX:StringTableSize
) был добавлен в качестве обновления в середине жизни на Java 6. Затем, в среднем обновлении до Java 7, размер пула по умолчанию был изменен с 1009
до 60013
.
Суть в том, что если вы намерены интенсивно использовать intern
в своем коде, рекомендуется выбрать версию Java, где размер хеш-таблицы настраивается, и убедитесь, что вы правильно настроили его размер. В противном случае производительность intern
может ухудшиться по мере увеличения пула.
Интернирование как потенциальный отказ в обслуживании вектора
Алгоритм hashcode для строк хорошо известен. Если вы ставите строки, поставленные вредоносными пользователями или приложениями, это может быть использовано как часть атаки на отказ в обслуживании (DoS). Если агент злой агент устанавливает, что все строки, которые он предоставляет, имеют один и тот же хэш-код, это может привести к неуравновешенной хеш-таблице и производительности O(N)
для intern
... где N
- количество столкнутых строк.
(Есть более простые / более эффективные способы запуска DoS-атаки на службу. Однако этот вектор можно использовать, если целью DoS-атаки является нарушение безопасности или уклонение от DoS-защиты первой линии.)
Pitfall - Малые чтения / записи на небуферизованных потоках неэффективны
Рассмотрим следующий код для копирования одного файла в другой:
import java.io.*;
public class FileCopy {
public static void main(String[] args) throws Exception {
try (InputStream is = new FileInputStream(args[0]);
OutputStream os = new FileOutputStream(args[1])) {
int octet;
while ((octet = is.read()) != -1) {
os.write(octet);
}
}
}
}
(Мы рассмотрели пропущенную проверку нормальных аргументов, сообщение об ошибках и т. Д., Поскольку они не имеют отношения к точке этого примера.)
Если вы скомпилируете вышеуказанный код и используете его для копирования огромного файла, вы заметите, что он очень медленный. Фактически, это будет, по крайней мере, на пару порядков медленнее, чем стандартные утилиты копирования файлов ОС.
( Добавьте фактические измерения производительности здесь! )
Основная причина того, что приведенный выше пример медленный (в случае большого файла) заключается в том, что он выполняет однобайтные чтения и однобайтные записи в небуферизованных байтовых потоках. Простым способом повышения производительности является обтекание потоков буферизованными потоками. Например:
import java.io.*;
public class FileCopy {
public static void main(String[] args) throws Exception {
try (InputStream is = new BufferedInputStream(
new FileInputStream(args[0]));
OutputStream os = new BufferedOutputStream(
new FileOutputStream(args[1]))) {
int octet;
while ((octet = is.read()) != -1) {
os.write(octet);
}
}
}
}
Эти небольшие изменения улучшат скорость копирования данных, по крайней мере, на пару порядков, в зависимости от различных факторов, связанных с платформой. Буферизованные обертки потока заставляют данные считываться и записываться в больших кусках. В экземплярах оба буфера реализованы как массивы байтов.
С
is
, данные считываются из файла в буфер за несколько килобайт за раз. Когда вызываетсяread()
, реализация, как правило, возвращает байт из буфера. Он будет считываться только из основного входного потока, если буфер опустел.Поведение для
os
аналогично. Вызовыos.write(int)
записывают одиночные байты в буфер. Данные записываются только в выходной поток при заполнении буфера или при сбросе или закрытииos
.
Как насчет потоков, основанных на символах?
Как вам следует знать, Java I / O предоставляет различные API для чтения и записи двоичных и текстовых данных.
-
InputStream
иOutputStream
являются базовыми API для потокового двоичного ввода-вывода -
Reader
иWriter
являются базовыми API-интерфейсами для потокового ввода-вывода.
Для ввода / вывода текста BufferedReader
и BufferedWriter
являются эквивалентами для BufferedInputStream
и BufferedOutputStream
.
Почему буферизованные потоки имеют большое значение?
Настоящая причина, по которой буферизованные потоки помогают повысить производительность, связана с тем, как приложение обращается к операционной системе:
Java-метод в Java-приложении или вызовы собственных процедур в собственных библиотеках времени выполнения JVM бывают быстрыми. Обычно они выполняют несколько инструкций машины и имеют минимальное влияние на производительность.
Напротив, вызовы во время выполнения JVM для операционной системы выполняются не быстро. Они включают нечто вроде «syscall». Типичный шаблон для системного вызова выглядит следующим образом:
- Поместите аргументы syscall в регистры.
- Выполните команду ловушки SYSENTER.
- Обработчик ловушки переключается в привилегированное состояние и изменяет отображение виртуальной памяти. Затем он отправляет код для обработки конкретного системного вызова.
- Обработчик syscall проверяет аргументы, опасаясь, что ему не сообщается о доступе к памяти, которую пользовательский процесс не должен видеть.
- Выполняется конкретная работа в режиме syscall. В случае
read
syscall это может включать:- проверяя, что есть данные для чтения в текущей позиции дескриптора файла
- вызывая обработчик файловой системы для извлечения требуемых данных с диска (или там, где он хранится) в буферный кеш,
- копирование данных из буферного кеша в JVM-адрес
- корректировка позиции дескриптора файловой позиции thstream
- Вернитесь из syscall. Это влечет за собой повторное изменение сопоставлений виртуальных машин и выключение привилегированного состояния.
Как вы можете себе представить, выполнение одного системного вызова может привести к тысячам машинных инструкций. Консервативно, по крайней мере на два порядка больше обычного вызова метода. (Вероятно, три или более.)
Учитывая это, причина, по которой буферизованные потоки имеет большое значение, заключается в том, что они резко сокращают количество системных вызовов. Вместо того, чтобы выполнять syscall для каждого вызова read()
, буферизованный входной поток считывает большой объем данных в буфер по мере необходимости. Большинство вызовов read()
для буферизованного потока выполняют некоторые простые проверки и возвращают byte
который был прочитан ранее. Аналогичные рассуждения применяются в случае выходного потока, а также в случаях потока символов.
(Некоторые считают, что производительность буферизованного ввода-вывода происходит из-за несоответствия между размером запроса на чтение и размером блока диска, временной задержкой на диске и такими вещами. Фактически, современная ОС использует ряд стратегий для обеспечения того, чтобы обычно не нужно ждать диска. Это не настоящее объяснение.)
Буферизованные потоки всегда выигрывают?
Не всегда. Буферизованные потоки, безусловно, выигрывают, если ваше приложение собирается делать «маленькие» чтения или записи. Однако, если вашему приложению нужно выполнять большие чтения или записи в / из большого byte[]
или char[]
, то буферизованные потоки не дадут вам реальных преимуществ. Действительно, может быть даже небольшое (незначительное) исполнение.
Это самый быстрый способ скопировать файл на Java?
Нет, это не так. Когда вы используете API-интерфейсы, основанные на потоке Java, для копирования файла, вы берете на себя стоимость, по крайней мере, одной дополнительной копии данных, хранящейся в памяти. Этого можно избежать, если вы используете NIO ByteBuffer
и API Channel
. ( Добавьте ссылку на отдельный пример здесь. )