Pull to refresh
VK
Building the Internet

10 самых распространённых ошибок, которые делают новички в Java

Reading time 14 min
Views 106K
Original author: Mikhail Selivanov
Здравствуйте, меня зовут Александр Акбашев, я Lead QA Engineer в проекте Skyforge. А также по совместительству ассистент tully в Технопарке на курсе «Углубленное программирование на Java». Наш курс идет во втором семестре Технопарка, и мы получаем студентов, прошедших курсы по C++ и Python. Поэтому я давно хотел подготовить материал, посвященный самым распространенным ошибкам новичков в Java. К сожалению, написать такую статью я так и не собрался. К счастью, такую статью написал наш соотечественник — Михаил Селиванов, правда, на английском. Ниже представлен перевод данной статьи с небольшими комментариями. По всем замечаниям, связанным с переводом, прошу писать в личные сообщения.



Изначально язык Java создавался для интерактивного телевидения, однако со временем стал использоваться везде, где только можно. Его разработчики руководствовались принципами объектно-ориентированного программирования, отказавшись от излишней сложности, свойственной тем же С и С++. Платформонезависимость виртуальной машины Java сформировала в своё время новый подход к программированию. Добавьте к этому плавную кривую обучения и лозунг «Напиши однажды, запускай везде», что почти всегда соответствует истине. Но всё-таки ошибки до сих пор встречаются, и здесь я хотел бы разобрать наиболее распространённые из них.

Ошибка первая: пренебрегают существующими библиотеками


Для Java написано несметное количество библиотек, но новички зачастую не пользуются всем этим богатством. Прежде чем изобретать велосипед, лучше сначала изучите имеющиеся наработки по интересующему вопросу. Многие библиотеки годами доводились разработчиками до совершенства, и вы можете пользоваться ими совершенно бесплатно. В качестве примеров можно привести библиотеки для логирования logback и Log4j, сетевые библиотеки Netty и Akka. А некоторые разработки, вроде Joda-Time, среди программистов стали стандартом де-факто.

На эту тему хочу рассказать о своём опыте работы над одним из проектов. Та часть кода, которая отвечала за экранирование HTML-символов, была написана мной с нуля. Несколько лет всё работало без сбоев. Но однажды пользовательский запрос спровоцировал бесконечный цикл. Сервис перестал отвечать, и пользователь попытался снова ввести те же данные. В конце концов, все процессорные ресурсы сервера, выделенные для этого приложения, оказались заняты этим бесконечным циклом. И если бы автор этого наивного инструмента для замены символов воспользовался одной из хорошо известных библиотек, HtmlEscapers или Google Guava, вероятно, этого досадного происшествия не произошло. Даже если бы в библиотеке была какая-то скрытая ошибка, то наверняка она была бы обнаружена и исправлена сообществом разработчиков раньше, чем проявилась бы на моём проекте. Это характерно для большинства наиболее популярных библиотек.

Ошибка вторая: не используют ключевое слово break в конструкции Switch-Case


Подобные ошибки могут сильно сбивать с толку. Бывает, что их даже не обнаруживают, и код попадает в продакшен. С одной стороны, неудачное выполнение операторов switch часто бывает полезным. Но если так не было задумано изначально, отсутствие ключевого слова break может привести к катастрофическим результатам. Если в нижеприведённом примере опустить break в case 0, то программа после Zero выведет One, поскольку поток выполнения команд пройдёт через все 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<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 только возвращает.

Если хотите узнать побольше об утечках памяти, то можете изучить посвящённую этому статью.

Примечание переводчика: на самом деле, в 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<String> accountIds = person.getAccountIds();
for (String accountId : accountIds) {
    processAccount(accountId);
}

Если getAccountIds() возвращает null, когда у person нет account, то возникнет NullPointerException. Чтобы этого не произошло, необходимо делать проверку на null. А если вместо null возвращается пустой список, то проблема с NullPointerException не возникает. К тому же код без null-проверок получается чище.

В разных ситуациях можно по-разному избегать использования null. Например, использовать класс Optional, который может быть как пустым объектом, так и обёрткой (wrap) для какого-либо значения:

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);

Optional появился в восьмой версии Java, но в функциональном программировании он использовался ещё задолго до этого. Например, в Google Guava для ранних версий Java.

Ошибка седьмая: игнорирование исключений


Зачастую начинающие разработчики никак не обрабатывают исключения. Однако не стоит пренебрегать этой работой. Исключения бросаются не просто так, и в большинстве случаев нужно разбираться с причинами. Не игнорируйте подобные события. Если нужно, бросьте их заново, чтобы посмотреть сообщение об ошибке, или залогируйте. В крайнем случае, нужно хотя бы обосновать для других разработчиков причину, по которой вы не разбирались с исключением.

selfie = person.shootASelfie();
try {
    selfie.show();
} catch (NullPointerException e) {
    // Может, человек-невидимка. Да какая разница?
}

Лучше всего обозначить незначительность исключения с помощью сообщения в переменной:

try { selfie.delete(); } catch (NullPointerException unimportant) {  }

Примечание переводчика: Практика не устаёт доказывать, что не бывает неважных исключений. Если исключение хочется проигнорировать, то нужно добавлять какие-то дополнительные проверки, чтобы либо не вызывать исключение в принципе, либо игнорировать исключение сверхточечно. Иначе вас ожидают долгие часы дебага в поисках ошибки, которую так легко было написать в лог. Также нужно помнить, что создание исключения — операция не бесплатная. Как минимум, нужно собрать коллстэк, а для этого нужно приостановиться на safepoint. И это всё занимает время...

Ошибка восьмая: ConcurrentModificationException


Это исключение возникает, когда коллекция модифицируется во время итерирования любыми методами, за исключением средств самого итератора. Например, у нас есть список головных уборов, и мы хотим убрать из него все шапки-ушанки:

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, поскольку код модифицирует коллекцию во время итерирования. То же самое исключение возникнет, если один из нескольких тредов, работающих с одним списком, попытается модифицировать коллекцию, пока другие треды итерируют её. Одновременное модифицирование коллекции является частым явлением при многопоточности, но в этом случае нужно применять соответствующие инструменты, вроде блокировок синхронизации (synchronization lock), специальных коллекций, адаптированных для одновременной модификации и т.д.

В случае с одним тредом эта проблема решается немного иначе.

Собрать объекты и удалить их в другом цикле

На ум сразу приходит решение собрать ушанки и удалить их во время следующего цикла. Но тогда придётся создать новую коллекцию для хранения шапок, приготовленных для удаления.

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, целесообразно использовать итератор списка (list iterator). Итераторы, реализующие интерфейс 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 вызовы методов удаления и добавления могут быть заменены одним вызовом:

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, можно трансформировать коллекцию в поток, а потом отфильтровать его по каким-либо критериям. Вот пример того, как поточный API может помочь в фильтрации шапок без появления ConcurrentModificationException:

hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))
        .collect(Collectors.toCollection(ArrayList::new));

Метод Collectors.toCollection создаёт новый ArrayList с отфильтрованными шапками. Если критериям удовлетворяет большое количество объектов, то это может быть проблемой, поскольку ArrayList получается довольно большим. Так что пользуйтесь этим способом с осторожностью.

Можно поступить другим образом — использовать метод List.removeIf, представленный в Java 8. Это самый короткий вариант:

hats.removeIf(IHat::hasEarFlaps);

И всё. На внутреннем уровне этот метод задействуется Iterator.remove.

Использовать специализированные коллекции

Если бы в самом начале мы решили использовать CopyOnWriteArrayList вместо ArrayList, то проблем бы вообще не было, потому что CopyOnWriteArrayList использует методы модифицирования (присвоения, добавления и удаления), которые не меняют базовый массив (backing array) коллекции. Вместо этого создаётся новая, модифицированная версия. Благодаря этому можно одновременно итерировать и модифицировать исходную версию коллекции без опасения получить ConcurrentModificationException. Недостаток у этого способа очевиден — приходится генерировать новую коллекцию для каждой модификации.

Существуют коллекции, настроенные для разных случаев, например, CopyOnWriteSet и ConcurrentHashMap.

Другой возможной ошибкой, связанной с ConcurrentModificationException, является создание потока из коллекции, а потом модифицирование базовой коллекции (backing collection) во время итерирования потока. Избегайте этого. Ниже приведён пример неправильного обращения с потоком:

List<IHat> filteredHats = hats.stream().peek(hat -> {
    if (hat.hasEarFlaps()) {
        hats.remove(hat);
    }
}).collect(Collectors.toCollection(ArrayList::new));

Метод peek собирает все элементы и применяет к каждому определённое действие. В данном случае, пытается удалить элемент из базового списка, что не есть правильно. Старайтесь применять другие методы, описанные выше.

Ошибка девятая: нарушение контрактов


Бывает, что для правильной работы кода из стандартной библиотеки или от какого-то вендора нужно соблюдать определённые правила. Например, контракт hashCode и equals гарантирует работу набора коллекций из фреймворка коллекций Java, а также других классов, использующих методы hashCode и equals. Несоблюдение контракта не всегда приводит к исключениям или прерыванию компиляции. Тут всё несколько сложнее, иногда это может повлиять на работу приложения так, что вы не заметите ничего подозрительного. Ошибочный код может попасть в продакшен и привести к неприятным последствиям. Например, стать причиной глючности UI, неправильных отчётов данных, низкой производительности, потери данных и т.д. К счастью, такое случается редко. Тот же вышеупомянутый контракт hashCode и equals используется в коллекциях, основанных на хэшинге и сравнении объектов, вроде HashMap и HashSet. Проще говоря, контракт содержит два условия:
  • Если два объекта эквивалентны, то их коды тоже должны быть эквивалентны.
  • Если даже два объекта имеют одинаковые хэш-коды, то они могут и не быть эквивалентны.

Нарушение первого правила приводит к проблемам при попытке извлечения объектов из hashmap.

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 содержит переопределённые методы hashCode и equals. Но контракт всё равно был нарушен, потому что 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 заключается в том, что он вызывается тогда и если, когда виртуальная машина определяет, что больше нет никаких причин, по которым данный объект должен быть доступен какому-либо треду (ещё не умершему). Исключением может быть результат завершения какого-то другого объекта или класса, который готов быть завершённым. Метод finalize может осуществлять любое действие, в том числе снова делать объект доступным для других тредов. Но обычно finalize используется для действий по очистке до того, как объект необратимо удаляется. Например, этот метод для объекта, представляющего собой соединение input/output, может явным образом осуществить I/O транзакции для разрыва соединения до того, как объект будет необратимо удалён.

Не надо использовать метод finalize для освобождения ресурсов наподобие обработчиков файлов, потому что неизвестно, когда он может быть вызван. Это может произойти во время работы сборщика мусора. В результате продолжительность его работы непредсказуемо затянется.

Ошибка десятая: использование сырых типов (raw type) вместо параметризованных


Согласно спецификации 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<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. Так что всегда старайтесь параметризировать обобщённые типы. В этом случае компилятор сможет всё проверить, и шансы появления runtime exception из-за противоречий в системе типов будут сведены к минимуму.

Заключение


Многие моменты в разработке ПО на платформе Java упрощены, благодаря разделению на сложную Java Virtual Machine и сам язык. Однако широкие возможности, вроде автоматического управления памятью или приличных OOP-инструментов, не исключают вероятности возникновения проблем. Советы здесь универсальны: регулярно практикуйтесь, изучайте библиотеки, читайте документацию. И не забывайте о статических анализаторах кода, они могут указать на имеющиеся баги и подсказать, на что стоит обратить внимание.
Tags:
Hubs:
+37
Comments 85
Comments Comments 85

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен