Однажды я потратил три часа на поиск бага. Три чертовых часа! Программа вроде работала, но результаты расчетов постоянно плавали. И только случайно я заметил, что 0.3 + 0.4 у меня не равно 0.7. Вот тогда-то я и понял, что сравнение в Java это не такая простая штука, как кажется. Это продолжение первой статьи про сравнение, где мы разбирали теорию. А сейчас давай копнем глубже и посмотрим, где Java может тебя неприятно удивить. Поговорим про строки, вещественные числа и всякие неочевидные моменты, о которых на собеседованиях обожают спрашивать. Если нужен короткий ответ прямо сейчас: оператор == у объектов сравнивает ссылки, а не содержимое, поэтому строки и обертки сравнивают через equals(), вещественные числа с погрешностью, а enum можно спокойно и через ==.Две руки держат красное и зеленое яблоки: наглядная метафора сравнения объектов

Кратко

  • == у ссылочных типов сравнивает адреса объектов, а не значения, и только у примитивов сравнивает сами значения.
  • Со строками == иногда «работает» из-за пула литералов, но ломается на конкатенации и на new String(). Правильный способ это equals().
  • Вещественные числа не сравнивают напрямую: 0.3f + 0.4f не равно 0.7f. Сравнивают с погрешностью, а для денег берут BigDecimal.
  • NaN не равен даже самому себе, а +0.0 и -0.0 оператор == считает равными, хотя деление на них дает бесконечности разного знака.
  • Обертки Integer, Long, Short, Byte, Character кешируют маленькие значения, поэтому == для 127 даст true, а для 128 уже false.
  • Элементы enum существуют в единственном экземпляре, так что тут == честен и безопасен.

Строки и оператор ==: история одного предательства

Знаешь, строки в Java это как хороший друг, который иногда ведет себя странно. Вроде все нормально, но потом бац, и сюрприз. Смотри, что происходит:
String str1 = "JavaRush";
String str2 = "JavaRush";
System.out.println(str1 == str2 ? "одна и та же" : "разные");
Запусти этот код. Результат будет "одна и та же". Погоди, разве мы не учили, что строки надо сравнивать через equals? Да, но тут есть нюанс. Компилятор Java тот еще хитрец. Он видит два одинаковых литерала и думает: "Зачем создавать два объекта? Создам один, а переменным дам ссылки на него". Экономия памяти, все дела. Но попробуй вот так:
String str1 = "JavaRush";
String str2 = "Java";
String str3 = "Rush";
System.out.println(str1 == (str2 + str3) ? "одна и та же" : "разные");
Теперь уже "разные"! Потому что конкатенация создает новый объект. И ссылки получаются разные. А можно и вообще так:
String str1 = "JavaRush";
String str2 = new String("JavaRush");
System.out.println(str1 == str2 ? "одна и та же" : "разные");
Опять "разные". Ключевое слово new говорит Java: "Я хочу НОВЫЙ объект, даже если такая строка уже есть". Вот так и попадаются джуны на собеседованиях. Используют == для строк, код вроде работает, а потом бац, и баг в продакшене. На курсе JavaRush мы этому посвящаем целый модуль, потому что штука реально важная. И еще одна ловушка, про которую вспоминают уже в проде: equals вызывается у объекта. Если слева окажется null, программа упадет с NullPointerException. С Java 7 для таких случаев есть Objects.equals(a, b) из пакета java.util: он сам проверит обе ссылки и вернет true, только если оба аргумента равны или оба равны null.
String s1 = null;
String s2 = "JavaRush";

// System.out.println(s1.equals(s2));        // NullPointerException
System.out.println(Objects.equals(s1, s2));  // false
System.out.println(Objects.equals(null, null)); // true
Разница простая: equals требует, чтобы слева был живой объект, а Objects.equals() сначала проверяет ссылки и только потом сравнивает значения.

String.intern(): магия пула строк

А теперь покажу тебе трюк, который знают далеко не все. У класса String есть метод intern(), который работает с пулом строк. Представь себе такой склад, где Java хранит все строковые литералы. Когда ты вызываешь intern(), Java идет на этот склад и проверяет: есть там такая строка или нет? Если есть, возвращает ссылку на нее. Если нет, добавляет твою строку в пул и возвращает ссылку.
String str1 = "JavaRush";
String str2 = new String("JavaRush");
System.out.println(str1 == str2); // false
System.out.println(str1.intern() == str2.intern()); // true
Теперь они указывают на один объект! Но честно говоря, я за семь лет работы использовал intern() от силы раза три. Это такая оптимизация, которая нужна только в специфических случаях.

Вещественные числа: добро пожаловать в ад

Окей, держись. Сейчас я покажу тебе что-то, что взорвет твой мозг. Готов? Чему равно 0.3f + 0.4f? Не спеши отвечать "0.7". Проверь:
float f1 = 0.7f;
float f2 = 0.3f + 0.4f;
System.out.println("f1 == f2: " + (f1 == f2));
Результат? false. Я серьезно. Запусти и убедись сам. Первый раз, когда я это увидел, я минут пятнадцать пялился в монитор и думал, что у меня с головой что-то не так. Давай разберемся, что творится. Выведем эти числа с большей точностью:
float f1 = 0.3f;
float f2 = 0.4f;
float f3 = f1 + f2;
float f4 = 0.7f;

System.out.println("f1 = " + (double)f1);
System.out.println("f2 = " + (double)f2);
System.out.println("f3 = " + (double)f3);
System.out.println("f4 = " + (double)f4);
Вывод:
f1 = 0.30000001192092896
f2 = 0.4000000059604645
f3 = 0.7000000476837158
f4 = 0.699999988079071
Видишь? Числа разные! И это не баг, а особенность представления дробных чисел в памяти компьютера. Представь, что ты пытаешься нарезать пиццу на идеально ровные кусочки, но у тебя только обычный нож (не лазерный). Каждый раз будет небольшая погрешность. Вот и компьютер хранит дроби с погрешностью, потому что память конечна. У float на мантиссу отведено 24 бита, это примерно 7 значащих цифр. У double получше: 53 бита и около 15-16 значащих цифр. Но погрешность есть всегда.

Как сравнивать вещественные числа правильно

Раз прямое сравнение не работает, надо сравнивать с погрешностью. Вот так:
float f1 = 0.3f;
float f2 = 0.4f;
float f3 = f1 + f2;
float f4 = 0.7f;

System.out.println("|f3 - f4| < 1e-6: " + (Math.abs(f3 - f4) < 1e-6));
Результат: true. Вот теперь работает! Мы проверяем, что разница между числами меньше одной миллионной. Для float достаточно 1e-6, для double можно взять 1e-15. Кстати, в библиотеке JUnit методы сравнения чисел так и работают: они принимают три параметра: два числа и точность сравнения. Библиотека знает, о чем говорит. И еще один момент. Когда пишешь числа в научной нотации, будь внимателен. Хочешь написать одну миллионную? Правильно так: 1e-6. А вот 10e-6 это уже 10 умножить на 10 в минус шестой, то есть 0.00001. Я на этом спалился на одном проекте, искали баг полдня. Не повторяй моих ошибок!

Плюс ноль и минус ноль: да, они разные

Думаешь, ноль это просто ноль? Как бы не так. В представлении вещественных чисел есть знаковый бит. И если все остальные биты нули, а знаковый бит единица, получается -0.0. Звучит безумно, правда? Но посмотри:
float f1 = 0.0f / 1.0f;
float f2 = 0.0f / -1.0f;

System.out.println("f1 = " + f1);
System.out.println("f2 = " + f2);
System.out.println("f1 == f2: " + (f1 == f2));

float f3 = 1.0f / f1;
float f4 = 1.0f / f2;

System.out.println("f3 = " + f3);
System.out.println("f4 = " + f4);
Вывод:
f1 = 0.0
f2 = -0.0
f1 == f2: true
f3 = Infinity
f4 = -Infinity
Видишь? Java считает их равными, но деление на них дает разные результаты! Один дает положительную бесконечность, другой отрицательную. Если тебе нужно различать +0.0 и -0.0, сравнивай битовые представления:
int i1 = Float.floatToIntBits(f1);
int i2 = Float.floatToIntBits(f2);

System.out.println("i1 (+0.0): " + Integer.toBinaryString(i1));
System.out.println("i2 (-0.0): " + Integer.toBinaryString(i2));
System.out.println("i1 == i2: " + (i1 == i2));
Результат:
i1 (+0.0): 0
i2 (-0.0): 10000000000000000000000000000000
i1 == i2: false
Теперь Java их различает.

NaN: число, которое не равно само себе

NaN расшифровывается как Not-a-Number, "не число". Получается при каких-то некорректных операциях типа деления нуля на ноль. И у него есть одна дикая особенность: оно не равно само себе!
float x = 0.0f / 0.0f;
System.out.println("x = " + x);
System.out.println("x == x: " + (x == x));
Результат:
x = NaN
x == x: false
Представляешь? Переменная не равна сама себе! Это единственное значение в Java с таким поведением. Если в твоем объекте поле стало NaN, а equals сравнивает поля оператором ==, результат всегда будет false, даже если все остальные поля идентичны. А вот обертка Float ведет себя иначе: ее equals() сравнивает битовые представления через floatToIntBits(), поэтому два NaN она считает равными, а +0.0 и -0.0, наоборот, разными. Проверить, является ли число NaN, можно методом Float.isNaN():
float x = 0.0f / 0.0f;
System.out.println(Float.isNaN(x)); // true
А если хочется закрыть обе ловушки сразу, и NaN, и минус ноль, есть метод Float.compare() (для double соответственно Double.compare()). Он сравнивает числа так же, как equals() у обертки: два NaN для него равны, а +0.0 считается больше -0.0.
System.out.println(Float.compare(Float.NaN, Float.NaN)); // 0, числа равны
System.out.println(Float.compare(0.0f, -0.0f));           // больше нуля: +0.0 больше -0.0
System.out.println(Float.NaN == Float.NaN);               // false
System.out.println(0.0f == -0.0f);                        // true
Видишь? Там, где оператор == ошибается, Float.compare() дает ожидаемый ответ. Именно поэтому его используют в equals() и в компараторах.

Autoboxing и кеширование: неожиданные сюрпризы

С Java 5 появился autoboxing, автоматическое преобразование примитивов в объекты и обратно. Казалось бы, удобная штука. Но есть подвох. Классы-обертки Integer, Long, Short, Byte, Character кешируют некоторые значения. Например, Integer кеширует числа от -128 до 127. Это значит, что при создании объектов с такими значениями Java возвращает ссылку на один и тот же объект из кеша.
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true

Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false
Видишь разницу? Для 127 сработал кеш, и a и b указывают на один объект. А для 128 создались два разных объекта. Это оптимизация от создателей Java. Они решили, что маленькие числа используются часто, поэтому кешировать их выгодно. Но это может привести к странным багам, если ты не в курсе. На курсе JavaRush мы разбираем эту тему в разделе про коллекции и обертки: там много практических задач на понимание autoboxing.

Enum: сравнивай спокойно

А вот с перечислениями (enum) все просто и хорошо. Каждый элемент enum существует в единственном экземпляре, это гарантирует виртуальная машина. Поэтому их можно спокойно сравнивать через ==:
enum Season {
    WINTER, SPRING, SUMMER, AUTUMN
}

Season s1 = Season.SUMMER;
Season s2 = Season.SUMMER;
System.out.println(s1 == s2); // true
Никаких подвохов. Enum это один из немногих типов, где == работает предсказуемо и надежно.

Что запомнить

Давай подведем итоги. Сравнение в Java штука коварная:
Что сравниваем Что не работает Как правильно
Строки Оператор ==: он сравнивает ссылки, а не текст equals(), а intern() только в особых случаях
Вещественные числа Прямое сравнение: 0.3f + 0.4f не равно 0.7f Сравнение с погрешностью Math.abs(a - b) < epsilon, а для денег BigDecimal
NaN и -0.0 NaN не равен сам себе, а +0.0 == -0.0 дает true Float.isNaN() и сравнение битов через Float.floatToIntBits()
Обертки примитивов Оператор ==: он работает только внутри кеша от -128 до 127 equals() или сравнение распакованных примитивов
enum Ничего, тут все честно Оператор ==, это самый надежный случай
Строки сравнивай через equals, не надейся на ==: даже если сейчас работает, завтра может сломаться. Для финансовых расчетов бери BigDecimal, а с autoboxing будь осторожен: кеш оберток дает неожиданное поведение ровно там, где его не ждешь. Удачи с багами и помни: если что-то работает странно, возможно, дело в сравнении!

Вопросы и ответы

Почему нельзя сравнивать строки через ==?

Потому что == сравнивает ссылки, а не текст. Иногда он все же дает true: одинаковые литералы компилятор складывает в пул строк и переиспользует один объект. Но стоит собрать строку конкатенацией во время работы программы или создать ее через new String(), как объект будет новый и сравнение сломается. Правильный способ один: equals().

Почему 0.3f + 0.4f не равно 0.7f?

Потому что дробные числа хранятся в двоичном виде с округлением. В переменную 0.3f на самом деле попадает 0.30000001192092896, в 0.4f попадает 0.4000000059604645, их сумма дает 0.7000000476837158, а литерал 0.7f это 0.699999988079071. Числа разные, поэтому == честно возвращает false.

Как тогда сравнивать вещественные числа?

Через погрешность: проверять методом Math.abs(), что разница по модулю меньше выбранного порога. Для float обычно берут одну миллионную, то есть 1e-6, для double достаточно 1e-15. Так устроены и методы сравнения в JUnit, которые принимают третьим параметром допустимую погрешность. Если речь про деньги, вещественные типы лучше не использовать вовсе, для них есть BigDecimal.

Правда ли, что NaN ломает equals у объекта?

Смотря как написан equals. Если он сравнивает поля оператором ==, то да: NaN не равен сам себе, и объекты никогда не совпадут. А вот обертка Float ведет себя иначе: ее equals() сравнивает битовые представления через floatToIntBits(), поэтому два NaN она считает равными, а +0.0 и -0.0, наоборот, разными.

Почему Integer со значением 127 сравнивается через == как true, а 128 уже нет?

Из-за кеша оберток. При автоупаковке значения от -128 до 127 берутся из готового кеша, и обе переменные указывают на один объект. Для 128 кеша уже нет, создаются два разных объекта, и == дает false. Верхнюю границу кеша можно поднять настройкой виртуальной машины, поэтому полагаться на такое поведение нельзя: сравнивайте обертки через equals().

Когда == все-таки безопасен?

Для примитивов, для сравнения с null и для элементов enum. Каждый элемент перечисления существует в единственном экземпляре, это гарантирует виртуальная машина, поэтому == тут не только работает, но и считается хорошим тоном: он быстрее и не упадет, если ссылка окажется null. Читайте также: