Оптимизация производительности Java HashMap / альтернатива - PullRequest
98 голосов
/ 18 ноября 2009

Я хочу создать большой HashMap, но производительность put() недостаточно хороша. Есть идеи?

Другие предложения по структуре данных приветствуются, но мне нужна функция поиска Java Map:

map.get(key)

В моем случае я хочу создать карту с 26 миллионами записей. При использовании стандартного Java HashMap скорость размещения становится невыносимо низкой после 2-3 миллионов вставок.

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

Мой метод хеш-кода:

byte[] a = new byte[2];
byte[] b = new byte[3];
...

public int hashCode() {
    int hash = 503;
    hash = hash * 5381 + (a[0] + a[1]);
    hash = hash * 5381 + (b[0] + b[1] + b[2]);
    return hash;
}

Я использую ассоциативное свойство сложения, чтобы равные объекты имели одинаковый хэш-код. Массивы представляют собой байты со значениями в диапазоне от 0 до 51. Значения используются только один раз в любом массиве. Объекты равны, если массивы a содержат одинаковые значения (в любом порядке) и то же самое относится к массиву b. Таким образом, a = {0,1} b = {45,12,33} и a = {1,0} b = {33,45,12} равны.

РЕДАКТИРОВАТЬ, некоторые примечания:

  • Несколько человек критиковали использование хеш-карты или другой структуры данных для хранения 26 миллионов записей. Я не понимаю, почему это может показаться странным. Это выглядит как классическая проблема структур данных и алгоритмов для меня. У меня 26 миллионов элементов, и я хочу иметь возможность быстро вставлять их и искать их в структуре данных: предоставьте мне структуру данных и алгоритмы.

  • Установка начальной емкости Java HashMap по умолчанию на 26 миллионов снижает производительность.

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

Ответы [ 25 ]

54 голосов
/ 19 ноября 2009

Как отмечали многие, виноват hashCode() метод. Он генерировал только около 20 000 кодов для 26 миллионов различных объектов. Это в среднем 1300 объектов на хэш-корзину = очень и очень плохо. Однако, если я превращу два массива в число в базе 52, я гарантированно получу уникальный хэш-код для каждого объекта:

public int hashCode() {       
    // assume that both a and b are sorted       
    return a[0] + powerOf52(a[1], 1) + powerOf52(b[0], 2) + powerOf52(b[1], 3) + powerOf52(b[2], 4);
}

public static int powerOf52(byte b, int power) {
    int result = b;
    for (int i = 0; i < power; i++) {
        result *= 52;
    }
    return result;
}

Массивы сортируются, чтобы обеспечить выполнение этим методом hashCode() контракта о том, что равные объекты имеют одинаковый хэш-код. Используя старый метод, среднее число пут в секунду по блокам в 100 000 пут, от 100 000 до 2 000 000 было:

168350.17
109409.195
81344.91
64319.023
53780.79
45931.258
39680.29
34972.676
31354.514
28343.062
25562.371
23850.695
22299.22
20998.006
19797.799
18702.951
17702.434
16832.182
16084.52
15353.083

Использование нового метода дает:

337837.84
337268.12
337078.66
336983.97
313873.2
317460.3
317748.5
320000.0
309704.06
310752.03
312944.5
265780.75
275540.5
264350.44
273522.97
270910.94
279008.7
276285.5
283455.16
289603.25

Гораздо лучше. Старый метод очень быстро исчез, а новый сохраняет хорошую пропускную способность.

17 голосов
/ 18 ноября 2009

Одна вещь, которую я заметил в вашем hashCode() методе, это то, что порядок элементов в массивах a[] и b[] не имеет значения. Таким образом, (a[]={1,2,3}, b[]={99,100}) будет хэшироваться до того же значения, что и (a[]={3,1,2}, b[]={100,99}). На самом деле все ключи k1 и k2, где sum(k1.a)==sum(k2.a) и sum(k1.b)=sum(k2.b) приведут к коллизиям. Я предлагаю назначить вес каждой позиции массива:

hash = hash * 5381 + (c0*a[0] + c1*a[1]);
hash = hash * 5381 + (c0*b[0] + c1*b[1] + c3*b[2]);

где, c0, c1 и c3 являются различными константами (вы можете использовать различные константы для b при необходимости). Это должно выровнять вещи немного больше.

16 голосов
/ 18 ноября 2009

Разобраться в Паскале: понимаете ли вы, как работает HashMap? У вас есть некоторое количество слотов в вашей хэш-таблице. Хеш-значение для каждого ключа найдено, а затем сопоставлено с записью в таблице. Если два значения хеша отображаются на одну и ту же запись - «коллизия хешей» - HashMap создает связанный список.

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

Итак, если вы видите проблемы с производительностью, первое, что я проверю, это: получаю ли я случайное распределение хеш-кодов? Если нет, вам нужна лучшая хэш-функция. Что ж, «лучше» в данном случае может означать «лучше для моего конкретного набора данных». Например, предположим, что вы работали со строками и взяли длину строки за значение хеша. (Не то, как работает Java String.hashCode, но я просто делаю простой пример.) Если ваши строки имеют разную длину, от 1 до 10 000, и довольно равномерно распределены по этому диапазону, это может быть очень хорошим хэш-функция Но если все ваши строки состоят из 1 или 2 символов, это будет очень плохая хеш-функция.

Редактировать: я должен добавить: Каждый раз, когда вы добавляете новую запись, HashMap проверяет, является ли она дубликатом. Когда происходит коллизия хэшей, он должен сравнивать входящий ключ с каждым ключом, сопоставленным с этим слотом. Таким образом, в худшем случае, когда все хэшируется в один слот, второй ключ сравнивается с первым ключом, третий ключ сравнивается с № 1 и № 2, четвертый ключ сравнивается с № 1, № 2 и № 3. и т. д. К тому времени, как вы наберете 1 миллион, вы сделаете более триллиона сравнений.

@ Оскар: Хмм, я не понимаю, как это "не совсем". Это больше похоже на «позвольте мне уточнить». Но да, это правда, что если вы делаете новую запись с тем же ключом, что и существующая запись, это перезаписывает первую запись. Вот что я имел в виду, когда говорил о поиске дубликатов в последнем абзаце: всякий раз, когда ключ хэшируется в одном и том же слоте, HashMap должен проверить, является ли он дубликатом существующего ключа, или же они находятся в одном и том же слоте по совпадению хэш-функция Я не знаю, в чем заключается «суть» HashMap: я бы сказал, что «весь смысл» в том, что вы можете быстро извлекать элементы по ключу.

Но, во всяком случае, это не влияет на «весь смысл», который я пытался сформулировать: когда у вас есть два ключа - да, разные ключи, а не один и тот же ключ, появляющийся снова - эта карта отображается в одном слоте в таблице HashMap создает связанный список. Затем, поскольку он должен проверять каждый новый ключ, чтобы увидеть, является ли он на самом деле дубликатом существующего ключа, каждая попытка добавить новую запись, которая отображается в этот же слот, должна преследовать связанный список, исследующий каждую существующую запись, чтобы увидеть, является дубликатом ранее увиденного ключа или, если это новый ключ.

Обновление задолго до исходного поста

Я только что получил голосование по этому ответу через 6 лет после публикации, что заставило меня перечитать вопрос.

Приведенная в вопросе хэш-функция не подходит для 26 миллионов записей.

Он складывает вместе a [0] + a [1] и b [0] + b [1] + b [2]. Он говорит, что значения каждого байта находятся в диапазоне от 0 до 51, так что дает только (51 * 2 + 1) * (51 * 3 + 1) = 15 862 возможных значений хеш-функции. С 26 миллионами записей это означает в среднем около 1639 записей на хеш-значение. Это много-много коллизий, требующих много-много последовательных поисков по связанным спискам.

ОП говорит, что разные порядки в массиве a и массиве b следует считать равными, то есть [[1,2], [3,4,5]]. Equals ([[2,1], [5,3 , 4]]), и поэтому для выполнения контракта они должны иметь одинаковые хеш-коды. Хорошо. Тем не менее, существует более 15 000 возможных значений. Его вторая предложенная хеш-функция намного лучше, предоставляя более широкий диапазон.

Хотя, как прокомментировал кто-то еще, для хэш-функции кажется неуместным изменять другие данные. Было бы более разумно «нормализовать» объект при его создании или заставить хэш-функцию работать с копиями массивов. Кроме того, использование цикла для вычисления констант каждый раз через функцию неэффективно. Поскольку здесь есть только четыре значения, я бы написал

return a[0]+a[1]*52+b[0]*52*52+b[1]*52*52*52+b[2]*52*52*52*52;

, что заставит компилятор выполнить вычисление один раз во время компиляции; или иметь 4 статических константы, определенных в классе.

Кроме того, первый черновик в хэш-функции имеет несколько вычислений, которые ничего не добавляют к диапазону выходных данных. Обратите внимание, что он сначала устанавливает хэш = 503, а затем умножает на 5381, прежде чем даже рассматривать значения из класса. Итак ... фактически он добавляет 503 * 5381 к каждому значению. Что это делает? Добавление константы к каждому хэш-значению просто сжигает циклы процессора, не выполняя ничего полезного. Урок здесь: добавление сложности к хеш-функции не является целью. Цель состоит в том, чтобы получить широкий диапазон различных значений, а не просто добавить сложность ради сложности.

7 голосов
/ 18 ноября 2009

Моя первая идея - убедиться, что вы правильно инициализируете свою HashMap. Из JavaDocs для HashMap :

Экземпляр HashMap имеет два параметра, которые влияют на его производительность: начальная емкость и коэффициент загрузки. Емкость - это количество сегментов в хэш-таблице, а начальная емкость - это просто емкость на момент создания хеш-таблицы. Коэффициент загрузки - это мера того, насколько полной хеш-таблице разрешено получать до того, как ее емкость будет автоматически увеличена. Когда число записей в хеш-таблице превышает произведение коэффициента загрузки и текущей емкости, хеш-таблица перефразируется (то есть внутренние структуры данных перестраиваются), так что хеш-таблица имеет примерно вдвое больше сегментов.

Итак, если вы начинаете с слишком маленького HashMap, то каждый раз, когда ему нужно изменить размер, все хэши пересчитываются ... что может быть тем, что вы чувствуете, когда получаете до точки вставки 2-3 миллиона.

7 голосов
/ 19 ноября 2009

Попадание в серую область "вкл / выкл темы", но необходимо, чтобы устранить путаницу в отношении предположения Оскара Рейеса о том, что большее число коллизий хешей - хорошая вещь, потому что это уменьшает количество элементов в HashMap. Я могу неправильно понять, что говорит Оскар, но я, кажется, не единственный: kdgregory, delfuego, Nash0, и я, кажется, все разделяю то же (неправильное) понимание.

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

Пример Java pastebin на http://pastebin.com/f20af40b9, кажется, указывает на то, что вышеизложенное правильно суммирует то, что предлагает Оскар.

Независимо от какого-либо понимания или недопонимания, что происходит, если различные экземпляры одного и того же класса не вставляются только один раз в HashMap, если они имеют один и тот же хэш-код - только до тех пор, пока не будет установлено, равны ли ключи или нет. Контракт хеш-кода требует, чтобы равные объекты имели одинаковый хеш-код; однако не требуется, чтобы неравные объекты имели разные хеш-коды (хотя это может быть желательно по другим причинам) [1].

Ниже приведен пример pastebin.com/f20af40b9 (на который Оскар ссылается как минимум дважды), но слегка измененный для использования утверждений JUnit, а не линий печати. Этот пример используется для поддержки предложения о том, что одни и те же хеш-коды вызывают коллизии, и когда классы одинаковы, создается только одна запись (например, только одна строка в данном конкретном случае):

@Test
public void shouldOverwriteWhenEqualAndHashcodeSame() {
    String s = new String("ese");
    String ese = new String("ese");
    // same hash right?
    assertEquals(s.hashCode(), ese.hashCode());
    // same class
    assertEquals(s.getClass(), ese.getClass());
    // AND equal
    assertTrue(s.equals(ese));

    Map map = new HashMap();
    map.put(s, 1);
    map.put(ese, 2);
    SomeClass some = new SomeClass();
    // still  same hash right?
    assertEquals(s.hashCode(), ese.hashCode());
    assertEquals(s.hashCode(), some.hashCode());

    map.put(some, 3);
    // what would we get?
    assertEquals(2, map.size());

    assertEquals(2, map.get("ese"));
    assertEquals(3, map.get(some));

    assertTrue(s.equals(ese) && s.equals("ese"));
}

class SomeClass {
    public int hashCode() {
        return 100727;
    }
}

Однако, хэш-код не полная история. То, что игнорирует пример pastebin, это то, что оба s и ese равны: они оба являются строкой "ese". Таким образом, вставка или получение содержимого карты с использованием s или ese или "ese" в качестве ключа эквивалентны, поскольку s.equals(ese) && s.equals("ese").

Второй тест показывает, что ошибочно заключать, что идентичные хэш-коды в одном и том же классе являются причиной того, что ключ -> значение s -> 1 перезаписывается на ese -> 2, когда map.put(ese, 2) вызывается в первом тесте. Во втором тесте s и ese по-прежнему имеют один и тот же хэш-код (что подтверждено assertEquals(s.hashCode(), ese.hashCode());) И они того же класса. Однако s и ese являются экземплярами MyString в этом тесте, а не экземплярами Java String - единственная разница, относящаяся к этому тесту, равна: String s equals String ese в тесте выше, тогда как MyStrings s does not equal MyString ese в Тест два:

@Test
public void shouldInsertWhenNotEqualAndHashcodeSame() {
    MyString s = new MyString("ese");
    MyString ese = new MyString("ese");
    // same hash right?
    assertEquals(s.hashCode(), ese.hashCode());
    // same class
    assertEquals(s.getClass(), ese.getClass());
    // BUT not equal
    assertFalse(s.equals(ese));

    Map map = new HashMap();
    map.put(s, 1);
    map.put(ese, 2);
    SomeClass some = new SomeClass();
    // still  same hash right?
    assertEquals(s.hashCode(), ese.hashCode());
    assertEquals(s.hashCode(), some.hashCode());

    map.put(some, 3);
    // what would we get?
    assertEquals(3, map.size());

    assertEquals(1, map.get(s));
    assertEquals(2, map.get(ese));
    assertEquals(3, map.get(some));
}

/**
 * NOTE: equals is not overridden so the default implementation is used
 * which means objects are only equal if they're the same instance, whereas
 * the actual Java String class compares the value of its contents.
 */
class MyString {
    String i;

    MyString(String i) {
        this.i = i;
    }

    @Override
    public int hashCode() {
        return 100727;
    }
}

Основываясь на последующем комментарии, Оскар, похоже, полностью меняет сказанное ранее и признает важность равенства. Тем не менее, все еще кажется, что понятие «равно» - это то, что важно, а не «тот же класс», неясно (выделено мной):

"Не совсем. Список создается только в том случае, если хеш такой же, но ключ другой. Например, если строка дает хеш-код 2345, а Integer дает тот же хэш-код 2345, то вставляется целое число в список, потому что String.equals (Integer) имеет значение false. Но если у вас тот же класс (или хотя бы .equals возвращает true) , тогда используется та же запись. Например, new String ("one") ) и `new String (" one "), используемые в качестве ключей, будут использовать одну и ту же запись. На самом деле это ВСЕ точка HashMap на первом месте! Смотрите сами: pastebin.com/f20af40b9 - Оскар Рейес"

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

"@ delfuego: убедитесь сами: pastebin.com/f20af40b9 Итак, в этом вопросе используется один и тот же класс (подождите минуту, тот же класс используется правильно?) Что означает, что когда тот же самый используется хэш, используется та же самая запись, и нет «списка» записей. - Оскар Рейес "

или

"На самом деле это увеличит производительность. Чем больше коллизий, тем меньше записей в хеш-таблице, тем меньше работы. Это не хеш (который выглядит хорошо), ни хеш-таблица (который работает отлично), я бы поспорил это на создании объекта, где производительность ухудшается. - Оскар Рейес "

или

"@ kdgregory: Да, но только если столкновение происходит с разными классами, для одного и того же класса (что имеет место) используется одна и та же запись. - Оскар Рейес"

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


[1] - Из Effective Java, Второе издание , Джошуа Блох:

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

  • Если два объекта равны в соответствии с методом равным s (Obj ect), то вызов метода hashCode для каждого из двух объектов должен привести к одинаковому результату. целочисленный результат.

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

7 голосов
/ 18 ноября 2009

Я бы предложил трехсторонний подход:

  1. Запустите Java с большим объемом памяти: java -Xmx256M, например, для работы с 256 мегабайтами. Используйте больше при необходимости, и у вас много оперативной памяти.

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

  3. Используйте лучший алгоритм хеширования. Тот, который вы разместили, вернет тот же хеш, где a = {0, 1}, как и где a = {1, 0}, при прочих равных.

Используйте то, что Java дает вам бесплатно.

public int hashCode() {
    return 31 * Arrays.hashCode(a) + Arrays.hashCode(b);
}

Я почти уверен, что вероятность такого столкновения гораздо меньше, чем у существующего метода hashCode, хотя это зависит от точного характера ваших данных.

5 голосов
/ 18 ноября 2009

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

a [0] + a [1] всегда будет между 0 и 512. добавление b всегда приведет к числу от 0 до 768. умножьте их, и вы получите верхний предел 400 000 уникальных комбинаций, при условии, что ваши данные идеально распределены среди каждого возможного значения каждого байта. Если ваши данные вообще регулярны, у вас, вероятно, гораздо меньше уникальных результатов этого метода.

4 голосов
/ 18 ноября 2009

HashMap имеет начальную емкость, и производительность HashMap очень сильно зависит от hashCode, который создает базовые объекты.

Попробуйте настроить оба.

4 голосов
/ 19 ноября 2009

Если два байтовых массива, которые вы упомянули, представляют собой весь ваш ключ, значения находятся в диапазоне 0-51, уникальны, а порядок в массивах a и b незначителен, моя математика говорит мне, что их всего около 26 миллионов. возможные перестановки и то, что вы, вероятно, пытаетесь заполнить карту значениями для всех возможных ключей.

В этом случае заполнение и получение значений из вашего хранилища данных, конечно, будет намного быстрее, если вы будете использовать массив вместо HashMap и индексировать его от 0 до 25989599.

4 голосов
/ 18 ноября 2009

Если ключи имеют какой-либо шаблон, вы можете разбить карту на карты меньшего размера и получить индексную карту.

Пример: Ключи: 1,2,3, .... n 28 карт по 1 миллиону каждая. Карта индекса: 1-1 000 000 -> Map1 1 000 000 - 2 000 000 -> Карта 2

Итак, вы будете выполнять два поиска, но набор ключей будет 1 000 000 против 28 000 000. Вы также можете легко сделать это с помощью шаблонов жала.

Если ключи абсолютно случайны, это не будет работать

...