Любопытное поведение хеш-кода Java с дублирующимися положительными и отрицательными числами с плавающей запятой

Вопрос или проблема

У меня было немного любопытное поведение с негативными и позитивными двойными значениями хэш-кодов, которого я не ожидал. По сути, если одно и то же значение Double встречается дважды в функции хэш-кода, и если знак этого Double меняется (положительное <-> отрицательное), хэш-код остается прежним. Например:

public final int hashCode() {
    int result = ...
    result = 31 * result + netAmount.hashCode();
    result = 31 * result + grossAmount.hashCode();
    ...
    return result;
}

Используя jshell и смотря на двоичный код для хэш-кода, становится понятно, что при умножении на 2 вы получаете то же самое значение:

Double.valueOf("460.4").hashCode() = 639279104
Double.valueOf("-460.4").hashCode() = -1508204544

Double.valueOf("460.4").hashCode() * 2 = 1278558208
Double.valueOf("-460.4").hashCode() * 2 = 1278558208

Хорошо, это имеет смысл. Но в моей функции хэш-кода я умножаю на 31 после каждого шага, так что это должно предотвратить это? Возможно, нет. Превращая свою функцию хэш-кода в однострочник:

31 * (123 + Double.valueOf("460.4").hashCode()) + Double.valueOf("460.4").hashCode() = -1017901339
31 * (123 + Double.valueOf("-460.4").hashCode()) + Double.valueOf("-460.4").hashCode() = -1017901339

А если я проведу математику над этим, то мы увидим, что это становится:

31*123 + 32*Double.valueOf("460.4").hashCode() = -1017901339
31*123 + 32*Double.valueOf("-460.4").hashCode() = -1017901339

Таким образом, 32 здесь имеет тот же эффект, что и побитовый сдвиг, что означает, что положительное значение двойного типа оказывается тем же, что и отрицательное. Удивительно!

Что мне следует делать, чтобы предотвратить эти конфликты? Просто использовать 37 или какое-то другое число? Добавить хэш-код знака двойного числа к результату?

редактировать: Для дополнительного разъяснения, “однострочник” – это моя попытка объединить все строки в функции хэш-кода. По сути:

public final int hashCode() {
    int result = 123
    result = 31 * result + Double.valueOf("460.4").hashCode();
    result = 31 * result + Double.valueOf("460.4").hashCode();
    return result;
}

Сделайте это для положительного или отрицательного значения двойного типа 460.4, и у вас будет тот же результат.

Итак, вы имеете дело с переполнением целого числа. hashCode возвращает int:

public int hashCode()

Возвращает значение хэш-кода для объекта. Этот метод поддерживается для удобства хэш-таблиц, таких как те, которые предоставляются HashMap.

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Object.html#hashCode()

Тип возвращаемого значения, int, имеет 32 бита:
https://docs.oracle.com/en/java/javase/17/docs/api/constant-values.html#java.lang.Integer.SIZE

Даже простое умножение вашего hashCode на 2 вызывает переполнение (потеря самого левого бита, то есть бита знака), в результате чего два полученных значения становятся равными:

Double.valueOf("460.4").hashCode()     ->     00100110 00011010 10100000 00000000
Double.valueOf("-460.4").hashCode()     ->     10100110 00011010 10100000 00000000
Double.valueOf("460.4").hashCode() * 2 -> |0| 01001100 00110101 01000000 00000000
Double.valueOf("-460.4").hashCode() * 2 -> |1| 01001100 00110101 01000000 00000000

При этом |0| и |1| указывают, какой бит был потерян в результате умножения на 2 (что по существу является одиночным сдвигом влево, так как это степень 2).

Умножение на 32 (опять же, сдвиг влево на 5, так как 32 – это степень 2) приводит к еще большему количеству потерянных битов:

...("460.4").hashCode() * 32 -> |00100| 11000011 01010100 00000000 00000000
...("-460.4").hashCode() * 32 -> |10100| 11000011 01010100 00000000 00000000

Снова, с битами в ||, которые были потеряны.

Я не уверен, откуда берутся 123 или 31 в ваших примерах, но вот некоторые предложения:

  • возможно, выполняйте свои математические операции, используя подходящие типы данных (например, Double или Long) перед применением hashCode
  • прочитайте и полностью поймите контракт hashCode по предоставленной ссылке (ниже дублируется). Третий пункт здесь очень актуален.

Каждый раз, когда метод вызывается более одного раза на одном и том же объекте во время выполнения Java-приложения, метод hashCode должен последовательно возвращать одно и то же целое число, при условии, что никакая информация, используемая в сравнении по equals, не была изменена. Это целое число не обязательно должно оставаться согласованным от одного выполнения приложения к другому выполнению того же приложения.

Если два объекта равны по методу equals, тогда вызов метода hashCode для каждого из этих двух объектов должен возвращать одно и то же целое число.

Не требуется, чтобы если два объекта не равны по методу equals, то вызов метода hashCode для каждого из этих двух объектов должен возвращать различные целые числа. Тем не менее, программист должен быть в курсе того, что получение различных целых чисел для неравных объектов может улучшить производительность хэш-таблиц.

Вы ошибочно сделали “однострочники”, добавив хэш-код дважды:

31 * (123 + Double.valueOf("460.4").hashCode()) + Double.valueOf("460.4").hashCode()

Они должны выглядеть просто так:

31 * 123 + Double.valueOf("460.4").hashCode()

Тогда вы не получите одинаковый хэш-код для как положительных, так и отрицательных значений полей вашего объекта. Вот демонстрация:

public class App {
    Double netAmount;

    public final int hashCode() {
        int result = 123; // хэш-код какого-то другого поля
        result = 31 * result + netAmount.hashCode();
        return result;
    }

    public static void main(String[] args) {
        App app = new App();
        app.netAmount = 460.4;
        System.out.println(app.hashCode());
        app.netAmount = -460.4;
        System.out.println(app.hashCode());
    }
}

Это выведет разные значения:

639282917
-1508200731

Ответ или решение

Давайте разберёмся с поведением хэш-кодов в Java и проблемами, связанными с положительными и отрицательными значениями типа Double. В результате вы столкнулись с неожиданным поведением: несмотря на то, что знак значения изменяется, хэш-код может оставаться одинаковым. Давайте разберём это более детально.

Объяснение проблемы

Причиной этого поведения является переполнение целочисленного типа. Метод hashCode() возвращает 32-битное целое число (int). При умножении значений хэш-кодов, как вы верно заметили, происходит сдвиг бит и возможное переполнение, что приводит к тому, что два разных значения могут давать одинаковый хэш-код.

Например:

Double.valueOf("460.4").hashCode() = 639279104
Double.valueOf("-460.4").hashCode() = -1508204544

При умножении на 2 или 32 значения могут потерять номер знака и привести к одинаковому результату:

Double.valueOf("460.4").hashCode() * 2 = 1278558208
Double.valueOf("-460.4").hashCode() * 2 = 1278558208

Исправление проблемы

Чтобы избежать столкновения хэш-кодов для положительных и отрицательных значений, необходимо в вашем методе hashCode() правильно использовать конструкцию, которая предотвратит переполнение.

  1. Используйте корректные вычисления с использованием подходящего типа данных, например, long или double, перед применением метода hashCode.
  2. Не дублируйте хэш-коды в вычислениях. Ваша "однострочная" реализация содержит ошибку, когда вы добавляете хэш-код дважды. Правильный код будет выглядеть так:
public final int hashCode() {
    int result = 123; // начальное значение
    result = 31 * result + netAmount.hashCode();
    return result;
}

Пример с использованием класса:

public class App {
    Double netAmount;

    public final int hashCode() {
        int result = 123; // начальное значение, может быть любое
        result = 31 * result + netAmount.hashCode();
        return result;
    }

    public static void main(String[] args) {
        App app = new App();
        app.netAmount = 460.4;
        System.out.println(app.hashCode()); // разный хэш-код
        app.netAmount = -460.4;
        System.out.println(app.hashCode()); // разный хэш-код
    }
}

При выполнении этого кода вы получите различные значения хэш-кодов для положительного и отрицательного значений netAmount.

Заключение

Чтобы предотвратить столкновения, важно понимать, как работает метод hashCode() и следовать его контракту. Также обратите внимание на правильность математических операций с целыми числами, чтобы не допустить переполнений. Добавление дополнительной логики, например, индикации знака, может помочь ценить уникальность значений и избежать коллизий в хэш-таблицах.

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

Если у вас есть дополнительные вопросы по этому вопросу или другие темы, не стесняйтесь обращаться!

Оцените материал
Добавить комментарий

Капча загружается...