Кофе-брейк #146. 5 ошибок, которые совершают 99% Java-разработчиков. Строки в Java — вид изнутри

Статья из группы Random

5 ошибок, которые совершают 99% Java-разработчиков

Источник: Medium В этой публикации вы узнаете о наиболее распространенных ошибках, которые допускают многие Java-разработчики. Кофе-брейк #146. 5 ошибок, которые совершают 99% Java-разработчиков. Строки в Java — вид изнутри - 1Как программист, работающий на Java, я знаю, как плохо тратить кучу времени на устранение ошибок в коде. Иногда на это уходит несколько часов. При этом многие ошибки появляются из-за того, что разработчик игнорирует базовые правила, — то есть это ошибки очень низкого уровня. Сегодня мы рассмотрим некоторые распространенные ошибки кодирования, а затем объясним, как их устранить. Надеюсь, это поможет вам избежать проблем в повседневной работе.

Сравнение объектов с использованием Objects.equals

Я полагаю, что вы знакомы с этим методом. Многие разработчики часто его используют. Это метод, появившийся в JDK 7, помогает быстро сравнивать объекты и эффективно избегать надоедливую проверку нулевого указателя. Но этот метод иногда используется неправильно. Вот что я имею в виду:

 Long longValue = 123L;
 System.out.println(longValue==123); //true
 System.out.println(Objects.equals(longValue,123)); //false
Почему замена == на Objects.equals() приведет к неверному результату? Это связано с тем, что с помощью компилятора == будет получен базовый тип данных, соответствующий типу упаковки longValue, а затем происходит сравнение его с этим базовым типом данных. Это эквивалентно тому, что компилятор автоматически преобразует константы в базовый тип данных сравнения. После использования метода Objects.equals() базовым типом данных константы компилятора по умолчанию является int. Ниже приведен исходный код Objects.equals(), в котором a.equals(b) использует Long.equals() и определяет тип объекта. Это происходит потому, что компилятор посчитал, что константа имеет тип int, поэтому результат сравнения должен быть ложным.

public static boolean equals(Object a, Object b) {
        return (a == b) || (a != null && a.equals(b));
    }
    
  public boolean equals(Object obj) {
        if (obj instanceof Long) {
            return value == ((Long)obj).longValue();
        }
        return false;
    }
Зная причину, исправить ошибку очень просто. Просто объявляйте тип данных констант, например, Objects.equals(longValue,123L). Вышеперечисленные проблемы не возникнут, если логика будет строгой. Что нам нужно сделать, так это следовать четким правилам программирования.

Неправильный формат даты

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

Instant instant = Instant.parse("2021-12-31T00:00:00.00Z");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("YYYY-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault());
System.out.println(formatter.format(instant));//2022-12-31 08:00:00
Здесь используется формат YYYY-MM-dd для изменения даты с 2021 по 2022 год. Так делать не стоит. Почему? Это связано с тем, что шаблон Java DateTimeFormatter “YYYY” основан на стандарте ISO-8601, в котором год определяется по четвергу каждой недели. Но 31 декабря 2021 года пришлось на пятницу, поэтому программа ошибочно указывает 2022 год. Чтобы избежать подобного, для форматирования даты необходимо использовать формат yyyy-MM-dd. Эта ошибка встречается нечасто, только с приходом нового года. Но в моей компании это стало причиной сбоя в производстве.

Использование ThreadLocal в ThreadPool

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

private ThreadLocal<User> currentUser = ThreadLocal.withInitial(() -> null);

    private ExecutorService executorService = Executors.newFixedThreadPool(4);

    public void executor() {
        executorService.submit(()->{
            User user = currentUser.get();
            Integer userId = user.getId();
            sendEmail(userId);
        });
    }
Если мы используем ThreadLocal для сохранения информации о пользователе, появится скрытая ошибка. Поскольку используется пул потоков, а потоки можно использовать повторно, то при использовании ThreadLocal для получения информации о пользователе может ошибочно отображаться чужая информация. Для решения этой проблемы стоит использовать сессии.

Используйте HashSet для удаления повторяющихся данных

При кодировании у нас часто возникает потребность в дедупликации. При упоминании дедупликации многие первым делом думают использовать HashSet. Однако неосторожное использование HashSet может привести к сбою дедупликации.

User user1 = new User();
user1.setUsername("test");

User user2 = new User();
user2.setUsername("test");

List<User> users = Arrays.asList(user1, user2);
HashSet<User> sets = new HashSet<>(users);
System.out.println(sets.size());// the size is 2
Некоторые внимательные читатели должны догадаться о причине сбоя. HashSet использует хэш-код для обращения к хэш-таблице и использует метод equals, чтобы определить, равны ли объекты. Если определяемый пользователем объект не переопределяет метод хэш-кода и метод equals, то по умолчанию будут использоваться метод хэш-кода и метод equals родительского объекта. Таким образом HashSet предположит, что это два разных объекта, что приведет к сбою дедупликации.

Исключение "съеденного" потока пула


ExecutorService executorService = Executors.newFixedThreadPool(1);
        executorService.submit(()->{
            //do something
            double result = 10/0;
        });
Приведенный выше код имитирует сценарий, где в пуле потоков выдается исключение. Бизнес-код должен предполагать различные ситуации, поэтому очень вероятно, что по некоторым причинам он вызовет RuntimeException. Но если здесь нет специальной обработки, то это исключение будет “съедено” пулом потоков. И у вас даже не будет способа проверить причину исключения. Поэтому лучше всего перехватывать исключения в пуле процессов.

Строки в Java — вид изнутри

Источник: Medium Автор этой статьи решил подробно рассмотреть создание, функционал и особенности работы строк в Java. Кофе-брейк #146. 5 ошибок, которые совершают 99% Java-разработчиков. Строки в Java — вид изнутри - 2

Создание

Строка в Java может быть создана двумя разными способами: неявно, как строковый литерал, и явно, с использованием ключевого слова new. Строковые литералы (String literals) — это символы, заключенные в двойные кавычки.

String literal   = "Michael Jordan";
String object    = new String("Michael Jordan");
Хотя обе декларации создают строковый объект, есть разница в том, как оба этих объекта находятся в куче памяти.

Внутреннее представление

Раньше строки сохранялись в форме char[], то есть каждый символ представлял собой отдельный элемент в массиве символов. Поскольку они были представлены в формате кодировки символов UTF-16, это означало, что каждый символ занимал два байта памяти. Это не очень правильно, поскольку статистика использования показывает, что большинство строковых объектов состоят только из символов Latin-1. Символы Latin-1 могут быть представлены с использованием одного байта памяти, что может значительно сократить использование памяти — на целых 50%. Новая внутренняя функция строк была реализована как часть выпуска JDK 9 на основе JEP 254 под названием Compact Strings. В этом релизе char[] изменили на byte[] и было добавлено поле флага кодировщика для представления используемой кодировки (Latin-1 или UTF-16). После этого кодирование происходит на основе содержимого строки. Если значение содержит только символы Latin-1, то используется кодировка Latin-1 (класс StringLatin1) или применяется кодировка UTF-16 (класс StringUTF16).

Выделение памяти

Как указывалось ранее, существует разница в способе выделения памяти для этих объектов в куче. Использование явного ключевого слова new довольно прямолинейно, поскольку JVM создает и выделяет память для переменной в куче. Поэтому использование строкового литерала следует за процессом, называемым интернированием. Интернирование строк — это процесс помещения строк в пул. Он использует метод хранения только одной копии каждого отдельного строкового значения, которое должно быть неизменным. Отдельные значения хранятся во внутреннем пуле (String Intern pool). Этот пул представляет собой хранилище Hashtable, в котором хранится ссылка на каждый строковый объект, созданный с помощью литералов, и его хэш. Хотя строковое значение находится в куче, его ссылку можно найти во внутреннем пуле. В этом легко убедиться с помощью приведенного ниже эксперимента. Здесь у нас есть две переменные с одинаковым значением:

String firstName1   = "Michael";
String firstName2   = "Michael";
System.out.println(firstName1 == firstName2);             //true
Во время выполнения кода, когда JVM сталкивается с firstName1, он ищет строковое значение во внутреннем пуле строк Michael. Если он не может найти его, тогда для объекта создается новая запись во внутреннем пуле. Когда выполнение достигает firstName2, процесс снова повторяется, и на этот раз значение может быть найдено в пуле на основе переменной firstName1. Таким образом, вместо дублирования и создания новой записи возвращается та же ссылка. Поэтому условие равенства выполняется. С другой стороны, если переменная со значением Michael создается с помощью ключевого слова new, интернирование не происходит и условие равенства не выполняется.

String firstName3 = new String("Michael");
System.out.println(firstName3 == firstName2);           //false
Интернирование может применяться с firstName3 метода intern(), хотя обычно это не предпочтительно.

firstName3 = firstName3.intern();                      //Interning
System.out.println(firstName3 == firstName2);          //true
Интернирование также может происходить при объединении двух строковых литералов с помощью оператора +.

String fullName = "Michael Jordan";
System.out.println(fullName == "Michael " + "Jordan");     //true
Здесь мы видим, что во время компиляции компилятор добавляет оба литерала и удаляет оператор + из выражения, чтобы сформировать одну строку, как показано ниже. Во время выполнения оба значения, fullName и “добавленный литерал”, интернируются, и выполняется условие равенства.

//After Compilation
System.out.println(fullName == "Michael Jordan");

Равенство

Из приведенных выше экспериментов видно, что по умолчанию интернируются только строковые литералы. Однако Java-приложение наверняка не будет иметь только строковые литералы, поскольку оно может получать строки из разных источников. Поэтому использование оператора равенства не рекомендуется и может привести к нежелательным результатам. Проверка равенства должна выполняться только методом equals. Он выполняет равенство на основе значения строки, а не адреса памяти, в котором она хранится.

System.out.println(firstName1.equals(firstName2));       //true
System.out.println(firstName3.equals(firstName2));       //true
Существует также немного измененная версия вызываемого метода equals под названием equalsIgnoreCase. Она может быть полезна для целей, нечувствительных к регистру.

String firstName4 = "miCHAEL";
System.out.println(firstName4.equalsIgnoreCase(firstName1));  //true

Неизменность

Строки неизменяемы, то есть их внутреннее состояние невозможно изменить после создания. Можно изменить значение переменной, но не само значение строки. Каждый метод класса String, связанный с управлением объектом (например, concat, substring), возвращает новую копию значения, а не обновляет существующее значение.

String firstName  = "Michael";
String lastName   = "Jordan";
firstName.concat(lastName);
                                            
System.out.println(firstName);                       //Michael
System.out.println(lastName);                        //Jordan
Как видите, никаких изменений не происходит ни с одной из переменных: ни firstName, ни lastName. Методы класса String не изменяют внутреннее состояние, они создают новую копию результата и возвращают результат, как показано ниже.

firstName = firstName.concat(lastName);       
                          
System.out.println(firstName);                      //MichaelJordan
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ