Код Java с ошибками: 10 самых распространенных ошибок, которые допускают разработчики Java
Опубликовано: 2022-03-11Java — это язык программирования, изначально разработанный для интерактивного телевидения, но со временем он получил широкое распространение везде, где можно использовать программное обеспечение. Разработанная с учетом концепции объектно-ориентированного программирования, упраздняющая сложности других языков, таких как C или C++, сборка мусора и архитектурно-независимая виртуальная машина, Java создала новый способ программирования. Более того, у него несложная кривая обучения, и, похоже, он успешно придерживается собственного девиза — «Напиши один раз, работай везде», что почти всегда верно; но проблемы с Java все еще присутствуют. Я рассмотрю десять проблем с Java, которые, по моему мнению, являются наиболее распространенными ошибками.
Распространенная ошибка №1: пренебрежение существующими библиотеками
Игнорирование бесчисленного количества библиотек, написанных на Java, определенно является ошибкой со стороны Java-разработчиков. Прежде чем изобретать велосипед, попробуйте поискать доступные библиотеки — многие из них отшлифованы за годы своего существования и бесплатны для использования. Это могут быть библиотеки ведения журналов, такие как logback и Log4j, или сетевые библиотеки, такие как Netty или Akka. Некоторые из библиотек, такие как Joda-Time, стали стандартом де-факто.
Ниже приводится личный опыт одного из моих предыдущих проектов. Часть кода, отвечающая за экранирование HTML, была написана с нуля. Он работал хорошо в течение многих лет, но в конце концов столкнулся с пользовательским вводом, из-за которого он закрутился в бесконечный цикл. Пользователь, обнаружив, что служба не отвечает, попытался повторить попытку с тем же вводом. В конце концов, все процессоры на сервере, выделенные для этого приложения, были заняты этим бесконечным циклом. Если бы автор этого наивного инструмента для экранирования HTML решил использовать одну из известных библиотек, доступных для экранирования HTML, например HtmlEscapers из Google Guava, этого, вероятно, не произошло бы. По крайней мере, для большинства популярных библиотек, за которыми стоит сообщество, ошибка была бы найдена и исправлена ранее сообществом для этой библиотеки.
Распространенная ошибка №2: Отсутствие ключевого слова break в блоке Switch-Case
Эти проблемы с Java могут быть очень неприятными и иногда остаются незамеченными до тех пор, пока не будут запущены в производство. Поведение с ошибками в операторах switch часто полезно; однако отсутствие ключевого слова «break», когда такое поведение нежелательно, может привести к катастрофическим результатам. Если вы забыли поставить «разрыв» в «случай 0» в приведенном ниже примере кода, программа напишет «Ноль», а затем «Единицу», поскольку поток управления внутри здесь будет проходить через весь оператор «переключатель» до тех пор, пока он достигает «разрыва». Например:
public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }
В большинстве случаев более чистым решением было бы использование полиморфизма и перенос кода с определенным поведением в отдельные классы. Ошибки Java, подобные этой, можно обнаружить с помощью статических анализаторов кода, например, FindBugs и PMD.
Распространенная ошибка № 3: Забыть освободить ресурсы
Каждый раз, когда программа открывает файл или сетевое соединение, для новичков в Java важно освобождать ресурс после того, как вы закончите его использовать. Аналогичную осторожность следует соблюдать, если во время операций с такими ресурсами должно быть выдано какое-либо исключение. Можно возразить, что FileInputStream имеет финализатор, который вызывает метод close() для события сборки мусора; однако, поскольку мы не можем быть уверены, когда начнется цикл сборки мусора, входной поток может потреблять ресурсы компьютера в течение неопределенного периода времени. На самом деле, в Java 7 специально для этого случая появился очень полезный и аккуратный оператор, который называется try-with-resources:
private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }
Этот оператор можно использовать с любым объектом, который реализует интерфейс AutoClosable. Это гарантирует, что каждый ресурс будет закрыт к концу инструкции.
Распространенная ошибка № 4: Утечки памяти
Java использует автоматическое управление памятью, и хотя забыть о выделении и освобождении памяти вручную — это облегчение, это не означает, что начинающий разработчик Java не должен знать, как память используется в приложении. Проблемы с выделением памяти все еще возможны. Пока программа создает ссылки на объекты, которые больше не нужны, она не будет освобождена. В некотором смысле мы все еще можем назвать это утечкой памяти. Утечки памяти в Java могут происходить по-разному, но наиболее распространенной причиной являются вечные ссылки на объекты, потому что сборщик мусора не может удалить объекты из кучи, пока на них еще есть ссылки. Можно создать такую ссылку, определив класс со статическим полем, содержащим некоторую коллекцию объектов, и забыв установить для этого статического поля значение null после того, как коллекция больше не нужна. Статические поля считаются корнями GC и никогда не собираются.
Другой потенциальной причиной таких утечек памяти является группа объектов, ссылающихся друг на друга, вызывающих циклические зависимости, так что сборщик мусора не может решить, нужны ли эти объекты с перекрестными ссылками зависимостей или нет. Другая проблема — утечки в памяти без кучи при использовании JNI.
Пример примитивной утечки может выглядеть следующим образом:
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }
В этом примере создаются две запланированные задачи. Первая задача берет последнее число из очереди под названием «numbers» и печатает число и размер очереди в случае, если число делится на 51. Вторая задача помещает числа в очередь. Обе задачи планируются с фиксированной частотой и запускаются каждые 10 мс. Если код выполняется, вы увидите, что размер двухсторонней очереди постоянно увеличивается. Это в конечном итоге приведет к тому, что очередь будет заполнена объектами, потребляющими всю доступную память кучи. Чтобы предотвратить это, сохранив семантику этой программы, мы можем использовать другой метод для взятия чисел из дека: «pollLast». В отличие от метода «peekLast», «pollLast» возвращает элемент и удаляет его из дека, а «peekLast» возвращает только последний элемент.
Чтобы узнать больше об утечках памяти в Java, обратитесь к нашей статье, которая демистифицирует эту проблему.
Распространенная ошибка № 5: чрезмерное выделение мусора
Чрезмерное выделение мусора может произойти, когда программа создает много недолговечных объектов. Сборщик мусора работает постоянно, удаляя ненужные объекты из памяти, что негативно сказывается на производительности приложений. Один простой пример:
String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));
В Java-разработке строки неизменяемы. Итак, на каждой итерации создается новая строка. Чтобы решить эту проблему, мы должны использовать изменяемый StringBuilder:
StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));
В то время как первая версия требует довольно много времени для выполнения, версия, использующая StringBuilder, дает результат за значительно меньшее количество времени.
Распространенная ошибка № 6: Использование пустых ссылок без необходимости
Рекомендуется избегать чрезмерного использования null. Например, предпочтительнее возвращать из методов пустые массивы или коллекции вместо нулей, поскольку это может помочь предотвратить исключение NullPointerException.
Рассмотрим следующий метод, который обходит коллекцию, полученную из другого метода, как показано ниже:
List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }
Если getAccountIds() возвращает значение null, когда у человека нет учетной записи, будет возбуждено исключение NullPointerException. Чтобы исправить это, потребуется нулевая проверка. Однако, если вместо null он возвращает пустой список, то NullPointerException больше не является проблемой. Более того, код стал чище, так как нам не нужно проверять значение null для переменной accountIds.
Чтобы иметь дело с другими случаями, когда нужно избежать нулей, можно использовать разные стратегии. Одна из этих стратегий — использовать необязательный тип, который может быть либо пустым объектом, либо переносом некоторого значения:
Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }
На самом деле Java 8 предлагает более лаконичное решение:
Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);
Необязательный тип был частью Java начиная с версии 8, но он уже давно хорошо известен в мире функционального программирования. До этого он был доступен в Google Guava для более ранних версий Java.
Распространенная ошибка № 7: игнорирование исключений
Часто возникает соблазн оставить исключения необработанными. Тем не менее, как для начинающих, так и для опытных Java-разработчиков лучше всего работать с ними. Исключения создаются намеренно, поэтому в большинстве случаев нам необходимо решить проблемы, вызывающие эти исключения. Не упускайте из виду эти события. При необходимости вы можете либо перекинуть его, либо показать пользователю диалог об ошибке, либо добавить сообщение в лог. По крайней мере, следует объяснить, почему исключение осталось необработанным, чтобы другие разработчики знали причину.
selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }
Более четкий способ подчеркнуть незначительность исключения — закодировать это сообщение в имя переменной исключения, например:
try { selfie.delete(); } catch (NullPointerException unimportant) { }
Распространенная ошибка № 8: исключение параллельной модификации
Это исключение возникает, когда коллекция изменяется во время итерации по ней с использованием методов, отличных от тех, которые предоставляются объектом итератора. Например, у нас есть список головных уборов, и мы хотим удалить все те, у которых есть уши:
List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }
Если мы запустим этот код, будет возбуждено «ConcurrentModificationException», поскольку код изменяет коллекцию во время ее итерации. Такое же исключение может возникнуть, если один из нескольких потоков, работающих с одним и тем же списком, пытается изменить коллекцию, в то время как другие перебирают ее. Параллельная модификация коллекций в нескольких потоках — это естественная вещь, но ее следует обрабатывать обычными инструментами из набора инструментов параллельного программирования, такими как блокировки синхронизации, специальные коллекции, адаптированные для параллельной модификации и т. д. Есть небольшие различия в том, как можно решить эту проблему Java. в однопоточных случаях и многопоточных случаях. Ниже приводится краткое обсуждение некоторых способов решения этой проблемы в однопоточном сценарии:

Собирайте объекты и удаляйте их в другом цикле
Собирать шапки-ушанки в список для последующего удаления из другого цикла — очевидное решение, но требует дополнительной коллекции для хранения удаляемых шапок:
List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }
Используйте метод Iterator.remove
Этот подход более лаконичен и не требует создания дополнительной коллекции:
Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }
Используйте методы ListIterator
Использование итератора списка уместно, когда измененная коллекция реализует интерфейс List. Итераторы, реализующие интерфейс ListIterator, поддерживают не только операции удаления, но также операции добавления и установки. ListIterator реализует интерфейс Iterator, поэтому пример будет выглядеть почти так же, как метод удаления Iterator. Единственная разница заключается в типе итератора шляпы и в том, как мы получаем этот итератор с помощью метода «listIterator()». Фрагмент ниже показывает, как заменить каждую шапку-ушанку на сомбреро с помощью методов «ListIterator.remove» и «ListIterator.add»:
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }
С помощью ListIterator вызовы методов удаления и добавления можно заменить одним вызовом set:
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }
Использование потоковых методов, представленных в Java 8. В Java 8 программисты могут преобразовывать коллекцию в поток и фильтровать этот поток в соответствии с некоторыми критериями. Вот пример того, как потоковое API может помочь нам отфильтровать шляпы и избежать «ConcurrentModificationException».
hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));
Метод «Collectors.toCollection» создаст новый ArrayList с отфильтрованными шляпами. Это может быть проблемой, если условию фильтрации должно удовлетворять большое количество элементов, что приводит к большому ArrayList; таким образом, его следует использовать с осторожностью. Используйте метод List.removeIf, представленный в Java 8 Другим решением, доступным в Java 8 и, безусловно, наиболее кратким, является использование метода removeIf:
hats.removeIf(IHat::hasEarFlaps);
Вот и все. Под капотом он использует «Iterator.remove» для реализации поведения.
Используйте специализированные коллекции
Если бы в самом начале мы решили использовать «CopyOnWriteArrayList» вместо «ArrayList», то вообще не было бы проблем, так как «CopyOnWriteArrayList» предоставляет методы модификации (такие как установка, добавление и удаление), которые не меняются резервный массив коллекции, а создайте его новую модифицированную версию. Это позволяет перебирать исходную версию коллекции и вносить в нее изменения одновременно, без риска «ConcurrentModificationException». Недостаток этой коллекции очевиден - генерация новой коллекции при каждой модификации.
Есть и другие коллекции, настроенные для разных случаев, например, «CopyOnWriteSet» и «ConcurrentHashMap».
Другая возможная ошибка при одновременном изменении коллекции — создание потока из коллекции и во время итерации потока изменение резервной коллекции. Общее правило для потоков — избегать изменения базовой коллекции во время запроса потока. Следующий пример покажет неправильный способ обработки потока:
List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));
Метод peek собирает все элементы и выполняет указанное действие над каждым из них. Здесь действие пытается удалить элементы из базового списка, что является ошибочным. Чтобы этого избежать, попробуйте некоторые из способов, описанных выше.
Распространенная ошибка № 9: Разрыв контрактов
Иногда код, предоставляемый стандартной библиотекой или сторонним поставщиком, зависит от правил, которым необходимо следовать, чтобы все работало. Например, это может быть контракт hashCode и equals, выполнение которого гарантирует работу набора коллекций из среды коллекций Java и других классов, использующих методы hashCode и equals. Несоблюдение контрактов — это не та ошибка, которая всегда приводит к исключениям или прерыванию компиляции кода; это более сложно, потому что иногда это меняет поведение приложения без каких-либо признаков опасности. Ошибочный код может попасть в производственную версию и вызвать целую кучу нежелательных эффектов. Это может включать в себя плохое поведение пользовательского интерфейса, неверные отчеты о данных, низкую производительность приложений, потерю данных и многое другое. К счастью, эти катастрофические ошибки случаются не очень часто. Я уже упоминал контракт hashCode и equals. Он используется в коллекциях, основанных на хэшировании и сравнении объектов, таких как HashMap и HashSet. Проще говоря, договор содержит два правила:
- Если два объекта равны, то их хеш-коды должны быть равны.
- Если два объекта имеют одинаковый хеш-код, то они могут быть равными, а могут и не быть.
Нарушение первого правила контракта приводит к проблемам при попытке извлечения объектов из хэш-карты. Второе правило означает, что объекты с одинаковым хеш-кодом не обязательно равны. Давайте рассмотрим последствия нарушения первого правила:
public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }
Как видите, класс Boat имеет переопределенные методы equals и hashCode. Однако он нарушил контракт, потому что hashCode каждый раз возвращает случайные значения для одного и того же объекта. Следующий код, скорее всего, не найдет бот с именем «Enterprise» в хеш-наборе, несмотря на то, что мы добавили такой бот ранее:
public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }
Другой пример контракта включает метод finalize. Вот цитата из официальной документации java, описывающая его функцию:
Общий контракт finalize заключается в том, что он вызывается, если и когда виртуальная машина JavaTM определила, что больше нет никаких средств, с помощью которых этот объект может быть доступен для любого потока (который еще не умер), кроме как в результате действие, предпринятое при завершении какого-либо другого объекта или класса, который готов к завершению. Метод finalize может выполнять любые действия, в том числе снова делать этот объект доступным для других потоков; обычная цель finalize, однако, состоит в том, чтобы выполнить действия по очистке перед тем, как объект будет безвозвратно отброшен. Например, метод finalize для объекта, представляющего соединение ввода-вывода, может выполнять явные транзакции ввода-вывода, чтобы разорвать соединение до того, как объект будет окончательно удален.
Можно решить использовать метод finalize для освобождения ресурсов, таких как обработчики файлов, но это плохая идея. Это связано с тем, что нет никаких гарантий времени, когда будет вызван finalize, поскольку он вызывается во время сборки мусора, а время GC неопределенно.
Распространенная ошибка № 10: использование необработанного типа вместо параметризованного
Необработанные типы, согласно спецификациям Java, — это типы, которые либо не параметризованы, либо являются нестатическими членами класса R, которые не унаследованы от суперкласса или суперинтерфейса R. Не было альтернатив необработанным типам, пока в Java не были введены универсальные типы. . Он поддерживает универсальное программирование, начиная с версии 1.5, и универсальные методы, несомненно, стали значительным улучшением. Однако из соображений обратной совместимости осталась ловушка, которая потенциально может сломать систему типов. Давайте посмотрим на следующий пример:
List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Здесь у нас есть список чисел, определенный как необработанный ArrayList. Поскольку его тип не указан параметром типа, мы можем добавить в него любой объект. Но в последней строке мы приводим элементы к типу int, удваиваем его и печатаем удвоенное число в стандартный вывод. Этот код скомпилируется без ошибок, но после его запуска возникнет исключение времени выполнения, поскольку мы попытались преобразовать строку в целое число. Очевидно, что система типов не сможет помочь нам написать безопасный код, если мы скроем от нее нужную информацию. Чтобы решить эту проблему, нам нужно указать тип объектов, которые мы собираемся хранить в коллекции:
List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Единственным отличием от оригинала является строка, определяющая коллекцию:
List<Integer> listOfNumbers = new ArrayList<>();
Исправленный код не будет компилироваться, потому что мы пытаемся добавить строку в коллекцию, которая должна хранить только целые числа. Компилятор выдаст ошибку и укажет на строку, где мы пытаемся добавить в список строку «Twenty». Всегда полезно параметризовать универсальные типы. Таким образом, компилятор может выполнить все возможные проверки типов, а вероятность возникновения исключений во время выполнения, вызванных несоответствиями системы типов, сведена к минимуму.
Заключение
Java как платформа упрощает разработку программного обеспечения, полагаясь как на сложную JVM, так и на сам язык. Однако его функции, такие как удаление ручного управления памятью или достойные инструменты ООП, не устраняют всех проблем и проблем, с которыми сталкивается обычный разработчик Java. Как всегда, знание, практика и учебные пособия по Java, подобные этому, являются лучшими средствами для предотвращения и устранения ошибок приложений, поэтому знайте свои библиотеки, читайте java, читайте документацию JVM и пишите программы. Не забывайте и о статических анализаторах кода, так как они могут указывать на существующие ошибки и подсвечивать потенциальные ошибки.