[Перевод] 10 самых распространённых ошибок, которые делают новички в Java
Здравствуйте, меня зовут Александр Акбашев, я Lead QA Engineer в проекте Skyforge. А также по совместительству ассистент tully в Технопарке на курсе «Углубленное программирование на Java». Наш курс идет во втором семестре Технопарка, и мы получаем студентов, прошедших курсы по C++ и Python. Поэтому я давно хотел подготовить материал, посвященный самым распространенным ошибкам новичков в Java. К сожалению, написать такую статью я так и не собрался. К счастью, такую статью написал наш соотечественник — Михаил Селиванов, правда, на английском. Ниже представлен перевод данной статьи с небольшими комментариями. По всем замечаниям, связанным с переводом, прошу писать в личные сообщения.Изначально язык Java создавался для интерактивного телевидения, однако со временем стал использоваться везде, где только можно. Его разработчики руководствовались принципами объектно-ориентированного программирования, отказавшись от излишней сложности, свойственной тем же С и С++. Платформонезависимость виртуальной машины Java сформировала в своё время новый подход к программированию. Добавьте к этому плавную кривую обучения и лозунг «Напиши однажды, запускай везде», что почти всегда соответствует истине. Но всё-таки ошибки до сих пор встречаются, и здесь я хотел бы разобрать наиболее распространённые из них.
Для Java написано несметное количество библиотек, но новички зачастую не пользуются всем этим богатством. Прежде чем изобретать велосипед, лучше сначала изучите имеющиеся наработки по интересующему вопросу. Многие библиотеки годами доводились разработчиками до совершенства, и вы можете пользоваться ими совершенно бесплатно. В качестве примеров можно привести библиотеки для логирования logback и Log4j, сетевые библиотеки Netty и Akka. А некоторые разработки, вроде Joda-Time, среди программистов стали стандартом де-факто.На эту тему хочу рассказать о своём опыте работы над одним из проектов. Та часть кода, которая отвечала за экранирование HTML-символов, была написана мной с нуля. Несколько лет всё работало без сбоев. Но однажды пользовательский запрос спровоцировал бесконечный цикл. Сервис перестал отвечать, и пользователь попытался снова ввести те же данные. В конце концов, все процессорные ресурсы сервера, выделенные для этого приложения, оказались заняты этим бесконечным циклом. И если бы автор этого наивного инструмента для замены символов воспользовался одной из хорошо известных библиотек, HtmlEscapers или Google Guava, вероятно, этого досадного происшествия не произошло. Даже если бы в библиотеке была какая-то скрытая ошибка, то наверняка она была бы обнаружена и исправлена сообществом разработчиков раньше, чем проявилась бы на моём проекте. Это характерно для большинства наиболее популярных библиотек.
Подобные ошибки могут сильно сбивать с толку. Бывает, что их даже не обнаруживают, и код попадает в продакшен. С одной стороны, неудачное выполнение операторов switch часто бывает полезным. Но если так не было задумано изначально, отсутствие ключевого слова break может привести к катастрофическим результатам. Если в нижеприведённом примере опустить break в case 0, то программа после One выведет Zero, поскольку поток выполнения команд пройдёт через все switch, пока не встретит break. 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»); } } Чаще всего целесообразно использовать полиморфизм для выделения частей кода со специфическим поведением в отдельные классы. А подобные ошибки можно искать с помощью статических анализаторов кода, например, FindBugs или PMD. Каждый раз после того, как программа открывает файл или устанавливает сетевое соединение, нужно освобождать использовавшиеся ресурсы. То же самое относится и к ситуациям, когда при оперировании ресурсами возникали какие-либо исключения. Кто-то может возразить, что у 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. Тогда вам не придётся беспокоиться об освобождении ресурсов, это будет происходить автоматически после выполнения оператора. В Java применяется автоматическое управление памятью, позволяющее не заниматься ручным выделением и освобождением. Но это вовсе не означает, что разработчикам можно вообще не интересоваться, как приложения используют память. Увы, но всё же здесь могут возникать проблемы. До тех пор, пока программа удерживает ссылки на объекты, которые больше не нужны, память не освобождается. Таким образом, это можно назвать утечкой памяти. Причины бывают разные, и наиболее частой из них является как раз наличие большого количества ссылок на объекты. Ведь пока есть ссылка, сборщик мусора не может удалить этот объект из кучи. Например, вы описали класс со статическим полем, содержащим коллекцию объектов, при этом создалась ссылка. Если вы забыли обнулить это поле после того, как коллекция стала не нужна, то и ссылка никуда не делась. Такие статические поля считаются корнями для сборщика мусора и не собираются им.Другой частой причиной возникновения утечек является наличие циклических ссылок. В этом случае сборщик просто не может решить, нужны ли ещё объекты, перекрёстно ссылающиеся друг на друга. Утечки также могут возникать в стеке при использовании JNI (Java Native Interface). Например:
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool (1);
final Deque
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 только возвращает.Если хотите узнать побольше об утечках памяти, то можете изучить посвящённую этому статью.
Примечание переводчика: на самом деле, в Java решена проблема циклических ссылок, т.к. современные алгоритмы сборки мусора учитывают достижимость ссылок из корневых узлов. Если объекты, содержащие ссылки друг на друга, не достижимы от корня, они будут считаться мусором. Об алгоритмах работы сборщика мусора можно почитать в Java Platform Performance: Strategies and Tactics.
Такое случается, когда программа создаёт большое количество объектов, использующихся в течение очень непродолжительного времени. При этом сборщик мусора безостановочно убирает из памяти ненужные объекты, что приводит к сильному падению производительности. Простой пример:
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));
Если в первом варианте на выполнение кода уходит немало времени, то во втором производительность уже гораздо выше.
Старайтесь избегать применения null. Например, возвращать пустые массивы или коллекции лучше методами, чем null, поскольку это позволит предотвратить появление NullPointerException. Ниже представлен пример метода, обрабатывающего коллекцию, полученную из другого метода:
List
Optional
В случае с одним тредом эта проблема решается немного иначе.
Собрать объекты и удалить их в другом цикле
На ум сразу приходит решение собрать ушанки и удалить их во время следующего цикла. Но тогда придётся создать новую коллекцию для хранения шапок, приготовленных для удаления.
List
Iterator
IHat sombrero = new Sombrero ();
ListIterator
hats.removeIf (IHat: hasEarFlaps); И всё. На внутреннем уровне этот метод задействуется Iterator.remove.Использовать специализированные коллекции
Если бы в самом начале мы решили использовать CopyOnWriteArrayList вместо ArrayList, то проблем бы вообще не было, потому что CopyOnWriteArrayList использует методы модифицирования (присвоения, добавления и удаления), которые не меняют базовый массив (backing array) коллекции. Вместо этого создаётся новая, модифицированная версия. Благодаря этому можно одновременно итерировать и модифицировать исходную версию коллекции без опасения получить ConcurrentModificationException. Недостаток у этого способа очевиден — приходится генерировать новую коллекцию для каждой модификации.
Существуют коллекции, настроенные для разных случаев, например, CopyOnWriteSet и ConcurrentHashMap.
Другой возможной ошибкой, связанной с ConcurrentModificationException, является создание потока из коллекции, а потом модифицирование базовой коллекции (backing collection) во время итерирования потока. Избегайте этого. Ниже приведён пример неправильного обращения с потоком:
List
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 содержит переопределённые методы hashCode и equals. Но контракт всё равно был нарушен, потому что hashCode возвращает каждый раз случайные значения для одного и того же объекта. Скорее всего, лодка под названием Enterprise так и не будет найдена в массиве хэшей, несмотря на то, что она была ранее добавлена:
public static void main (String[] args) {
Set
System.out.printf («We have a boat named 'Enterprise' : %b\n», boats.contains (new Boat («Enterprise»))); } Другой пример относится к методу finalize. Вот что говорится о его функционале в официальной документации Java: Основной контракт finalize заключается в том, что он вызывается тогда и если, когда виртуальная машина определяет, что больше нет никаких причин, по которым данный объект должен быть доступен какому-либо треду (ещё не умершему). Исключением может быть результат завершения какого-то другого объекта или класса, который готов быть завершённым. Метод finalize может осуществлять любое действие, в том числе снова делать объект доступным для других тредов. Но обычно finalize используется для действий по очистке до того, как объект необратимо удаляется. Например, этот метод для объекта, представляющего собой соединение input/output, может явным образом осуществить I/O транзакции для разрыва соединения до того, как объект будет необратимо удалён.
Не надо использовать метод finalize для освобождения ресурсов наподобие обработчиков файлов, потому что неизвестно, когда он может быть вызван. Это может произойти во время работы сборщика мусора. В результате продолжительность его работы непредсказуемо затянется.
Согласно спецификации 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 забрасываются элементы, удваиваются и выводятся. Этот код откомпилируется без ошибок, но если его запустить, то выскочит исключение на этапе выполнения (runtime exception), потому что мы пытаемся записать строчную переменную в числовую. Очевидно, что если мы скроем от системы типов необходимую информацию, то она не убережёт нас от написания ошибочного кода. Поэтому старайтесь определять типы объектов, которые собираетесь хранить в коллекции:
List
listOfNumbers.add (10); listOfNumbers.add («Twenty»);
listOfNumbers.forEach (n → System.out.println ((int) n * 2));
От первоначального варианта этот пример отличается строкой, в которой задаётся коллекция:
List