Хорошо, чтобы положить этому конец, я создал тестовое приложение, чтобы запустить несколько сценариев и получить визуализацию результатов. Вот как проходят тесты:
- Было опробовано несколько различных размеров коллекций: сто, одна тысяча и сто тысяч записей.
- Используемые ключи - это экземпляры класса, которые однозначно идентифицируются идентификатором. Каждый тест использует уникальные ключи с увеличивающимися целыми числами в качестве идентификаторов. В
equals
Метод использует только идентификатор, поэтому ни одна клавиша отображения не перезаписывает другой.
- Ключи получают хэш-код, который состоит из остатка модуля от их идентификатора против некоторого предустановленного числа. Мы назовем этот номер пределом хеширования . Это позволило мне контролировать количество ожидаемых хеш-коллизий. Например, если размер нашей коллекции равен 100, у нас будут ключи с идентификаторами от 0 до 99. Если предел хеширования равен 100, каждый ключ будет иметь уникальный хеш-код. Если предел хеширования равен 50, ключ 0 будет иметь тот же хэш-код, что и ключ 50, 1 будет иметь тот же хэш-код, что и 51 и т. Д. Другими словами, ожидаемое количество хеш-коллизий на ключ - это размер коллекции, деленный на хэш предел.
- Для каждой комбинации размера коллекции и ограничения хеширования я провел тест с использованием хэш-карт, инициализированных с разными настройками. Эти настройки представляют собой коэффициент загрузки и начальную емкость, которая выражается как коэффициент настройки сбора. Например, тест с размером коллекции 100 и начальным коэффициентом емкости 1,25 инициализирует хэш-карту с начальной емкостью 125.
- Значение для каждого ключа просто новое
Object
.
- Каждый результат теста инкапсулируется в экземпляр класса Result. В конце всех тестов результаты отсортированы от худшей общей производительности к лучшей.
- Среднее время для пут-получения рассчитывается на каждые 10 пут-приемов.
- Все комбинации тестов запускаются один раз, чтобы исключить влияние JIT-компиляции. После этого запускаются тесты для получения реальных результатов.
Вот класс:
package hashmaptest;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
public class HashMapTest {
private static final List<Result> results = new ArrayList<Result>();
public static void main(String[] args) throws IOException {
final int[][] sampleSizesAndHashLimits = new int[][] {
{100, 50, 90, 100},
{1000, 500, 900, 990, 1000},
{100000, 10000, 90000, 99000, 100000}
};
final double[] initialCapacityFactors = new double[] {0.5, 0.75, 1.0, 1.25, 1.5, 2.0};
final float[] loadFactors = new float[] {0.5f, 0.75f, 1.0f, 1.25f};
for(int[] sizeAndLimits : sampleSizesAndHashLimits) {
int size = sizeAndLimits[0];
for(int i = 1; i < sizeAndLimits.length; ++i) {
int limit = sizeAndLimits[i];
for(double initCapacityFactor : initialCapacityFactors) {
for(float loadFactor : loadFactors) {
runTest(limit, size, initCapacityFactor, loadFactor);
}
}
}
}
results.clear();
for(int[] sizeAndLimits : sampleSizesAndHashLimits) {
int size = sizeAndLimits[0];
for(int i = 1; i < sizeAndLimits.length; ++i) {
int limit = sizeAndLimits[i];
for(double initCapacityFactor : initialCapacityFactors) {
for(float loadFactor : loadFactors) {
runTest(limit, size, initCapacityFactor, loadFactor);
}
}
}
}
Collections.sort(results);
for(final Result result : results) {
result.printSummary();
}
}
private static void runTest(final int hashLimit, final int sampleSize,
final double initCapacityFactor, final float loadFactor) {
final int initialCapacity = (int)(sampleSize * initCapacityFactor);
System.out.println("Running test for a sample collection of size " + sampleSize
+ ", an initial capacity of " + initialCapacity + ", a load factor of "
+ loadFactor + " and keys with a hash code limited to " + hashLimit);
System.out.println("====================");
double hashOverload = (((double)sampleSize/hashLimit) - 1.0) * 100.0;
System.out.println("Hash code overload: " + hashOverload + "%");
final List<Key> keys = generateSamples(hashLimit, sampleSize);
final List<Object> values = generateValues(sampleSize);
final HashMap<Key, Object> map = new HashMap<Key, Object>(initialCapacity, loadFactor);
final long startPut = System.nanoTime();
for(int i = 0; i < sampleSize; ++i) {
map.put(keys.get(i), values.get(i));
}
final long endPut = System.nanoTime();
final long putTime = endPut - startPut;
final long averagePutTime = putTime/(sampleSize/10);
System.out.println("Time to map all keys to their values: " + putTime + " ns");
System.out.println("Average put time per 10 entries: " + averagePutTime + " ns");
final long startGet = System.nanoTime();
for(int i = 0; i < sampleSize; ++i) {
map.get(keys.get(i));
}
final long endGet = System.nanoTime();
final long getTime = endGet - startGet;
final long averageGetTime = getTime/(sampleSize/10);
System.out.println("Time to get the value for every key: " + getTime + " ns");
System.out.println("Average get time per 10 entries: " + averageGetTime + " ns");
System.out.println("");
final Result result =
new Result(sampleSize, initialCapacity, loadFactor, hashOverload, averagePutTime, averageGetTime, hashLimit);
results.add(result);
System.gc();
try {
Thread.sleep(200);
} catch(final InterruptedException e) {}
}
private static List<Key> generateSamples(final int hashLimit, final int sampleSize) {
final ArrayList<Key> result = new ArrayList<Key>(sampleSize);
for(int i = 0; i < sampleSize; ++i) {
result.add(new Key(i, hashLimit));
}
return result;
}
private static List<Object> generateValues(final int sampleSize) {
final ArrayList<Object> result = new ArrayList<Object>(sampleSize);
for(int i = 0; i < sampleSize; ++i) {
result.add(new Object());
}
return result;
}
private static class Key {
private final int hashCode;
private final int id;
Key(final int id, final int hashLimit) {
this.id = id;
this.hashCode = id % hashLimit;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(final Object o) {
return ((Key)o).id == this.id;
}
}
static class Result implements Comparable<Result> {
final int sampleSize;
final int initialCapacity;
final float loadFactor;
final double hashOverloadPercentage;
final long averagePutTime;
final long averageGetTime;
final int hashLimit;
Result(final int sampleSize, final int initialCapacity, final float loadFactor,
final double hashOverloadPercentage, final long averagePutTime,
final long averageGetTime, final int hashLimit) {
this.sampleSize = sampleSize;
this.initialCapacity = initialCapacity;
this.loadFactor = loadFactor;
this.hashOverloadPercentage = hashOverloadPercentage;
this.averagePutTime = averagePutTime;
this.averageGetTime = averageGetTime;
this.hashLimit = hashLimit;
}
@Override
public int compareTo(final Result o) {
final long putDiff = o.averagePutTime - this.averagePutTime;
final long getDiff = o.averageGetTime - this.averageGetTime;
return (int)(putDiff + getDiff);
}
void printSummary() {
System.out.println("" + averagePutTime + " ns per 10 puts, "
+ averageGetTime + " ns per 10 gets, for a load factor of "
+ loadFactor + ", initial capacity of " + initialCapacity
+ " for " + sampleSize + " mappings and " + hashOverloadPercentage
+ "% hash code overload.");
}
}
}
Это может занять некоторое время. Результаты распечатываются на стандартном носителе. Вы могли заметить, что я закомментировал строку. Эта строка вызывает визуализатор, который выводит визуальные представления результатов в файлы png. Класс для этого приведен ниже. Если вы хотите запустить его, раскомментируйте соответствующую строку в приведенном выше коде. Имейте в виду: класс визуализатора предполагает, что вы работаете в Windows, и создаст папки и файлы в C: \ temp. При работе на другой платформе отрегулируйте это.
package hashmaptest;
import hashmaptest.HashMapTest.Result;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.imageio.ImageIO;
public class ResultVisualizer {
private static final Map<Integer, Map<Integer, Set<Result>>> sampleSizeToHashLimit =
new HashMap<Integer, Map<Integer, Set<Result>>>();
private static final DecimalFormat df = new DecimalFormat("0.00");
static void visualizeResults(final List<Result> results) throws IOException {
final File tempFolder = new File("C:\\temp");
final File baseFolder = makeFolder(tempFolder, "hashmap_tests");
long bestPutTime = -1L;
long worstPutTime = 0L;
long bestGetTime = -1L;
long worstGetTime = 0L;
for(final Result result : results) {
final Integer sampleSize = result.sampleSize;
final Integer hashLimit = result.hashLimit;
final long putTime = result.averagePutTime;
final long getTime = result.averageGetTime;
if(bestPutTime == -1L || putTime < bestPutTime)
bestPutTime = putTime;
if(bestGetTime <= -1.0f || getTime < bestGetTime)
bestGetTime = getTime;
if(putTime > worstPutTime)
worstPutTime = putTime;
if(getTime > worstGetTime)
worstGetTime = getTime;
Map<Integer, Set<Result>> hashLimitToResults =
sampleSizeToHashLimit.get(sampleSize);
if(hashLimitToResults == null) {
hashLimitToResults = new HashMap<Integer, Set<Result>>();
sampleSizeToHashLimit.put(sampleSize, hashLimitToResults);
}
Set<Result> resultSet = hashLimitToResults.get(hashLimit);
if(resultSet == null) {
resultSet = new HashSet<Result>();
hashLimitToResults.put(hashLimit, resultSet);
}
resultSet.add(result);
}
System.out.println("Best average put time: " + bestPutTime + " ns");
System.out.println("Best average get time: " + bestGetTime + " ns");
System.out.println("Worst average put time: " + worstPutTime + " ns");
System.out.println("Worst average get time: " + worstGetTime + " ns");
for(final Integer sampleSize : sampleSizeToHashLimit.keySet()) {
final File sizeFolder = makeFolder(baseFolder, "sample_size_" + sampleSize);
final Map<Integer, Set<Result>> hashLimitToResults =
sampleSizeToHashLimit.get(sampleSize);
for(final Integer hashLimit : hashLimitToResults.keySet()) {
final File limitFolder = makeFolder(sizeFolder, "hash_limit_" + hashLimit);
final Set<Result> resultSet = hashLimitToResults.get(hashLimit);
final Set<Float> loadFactorSet = new HashSet<Float>();
final Set<Integer> initialCapacitySet = new HashSet<Integer>();
for(final Result result : resultSet) {
loadFactorSet.add(result.loadFactor);
initialCapacitySet.add(result.initialCapacity);
}
final List<Float> loadFactors = new ArrayList<Float>(loadFactorSet);
final List<Integer> initialCapacities = new ArrayList<Integer>(initialCapacitySet);
Collections.sort(loadFactors);
Collections.sort(initialCapacities);
final BufferedImage putImage =
renderMap(resultSet, loadFactors, initialCapacities, worstPutTime, bestPutTime, false);
final BufferedImage getImage =
renderMap(resultSet, loadFactors, initialCapacities, worstGetTime, bestGetTime, true);
final String putFileName = "size_" + sampleSize + "_hlimit_" + hashLimit + "_puts.png";
final String getFileName = "size_" + sampleSize + "_hlimit_" + hashLimit + "_gets.png";
writeImage(putImage, limitFolder, putFileName);
writeImage(getImage, limitFolder, getFileName);
}
}
}
private static File makeFolder(final File parent, final String folder) throws IOException {
final File child = new File(parent, folder);
if(!child.exists())
child.mkdir();
return child;
}
private static BufferedImage renderMap(final Set<Result> results, final List<Float> loadFactors,
final List<Integer> initialCapacities, final float worst, final float best,
final boolean get) {
final Color[][] map = new Color[initialCapacities.size()][loadFactors.size()];
for(final Result result : results) {
final int x = initialCapacities.indexOf(result.initialCapacity);
final int y = loadFactors.indexOf(result.loadFactor);
final float time = get ? result.averageGetTime : result.averagePutTime;
final float score = (time - best)/(worst - best);
final Color c = new Color(score, 1.0f - score, 0.0f);
map[x][y] = c;
}
final int imageWidth = initialCapacities.size() * 40 + 50;
final int imageHeight = loadFactors.size() * 40 + 50;
final BufferedImage image =
new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_3BYTE_BGR);
final Graphics2D g = image.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, imageWidth, imageHeight);
for(int x = 0; x < map.length; ++x) {
for(int y = 0; y < map[x].length; ++y) {
g.setColor(map[x][y]);
g.fillRect(50 + x*40, imageHeight - 50 - (y+1)*40, 40, 40);
g.setColor(Color.BLACK);
g.drawLine(25, imageHeight - 50 - (y+1)*40, 50, imageHeight - 50 - (y+1)*40);
final Float loadFactor = loadFactors.get(y);
g.drawString(df.format(loadFactor), 10, imageHeight - 65 - (y)*40);
}
g.setColor(Color.BLACK);
g.drawLine(50 + (x+1)*40, imageHeight - 50, 50 + (x+1)*40, imageHeight - 15);
final int initialCapacity = initialCapacities.get(x);
g.drawString(((initialCapacity%1000 == 0) ? "" + (initialCapacity/1000) + "K" : "" + initialCapacity), 15 + (x+1)*40, imageHeight - 25);
}
g.drawLine(25, imageHeight - 50, imageWidth, imageHeight - 50);
g.drawLine(50, 0, 50, imageHeight - 25);
g.dispose();
return image;
}
private static void writeImage(final BufferedImage image, final File folder,
final String filename) throws IOException {
final File imageFile = new File(folder, filename);
ImageIO.write(image, "png", imageFile);
}
}
Визуализированный результат выглядит следующим образом:
- Тесты делятся сначала по размеру коллекции, а затем по хеш-пределу.
- Для каждого теста есть выходное изображение, показывающее среднее время размещения (на 10 операций ввода) и среднее время получения (на 10 операций ввода). Изображения представляют собой двухмерные «тепловые карты», которые показывают цвет для каждой комбинации начальной емкости и коэффициента нагрузки.
- Цвета на изображениях основаны на среднем времени по нормализованной шкале от лучшего до худшего результата, в диапазоне от насыщенного зеленого до насыщенного красного. Другими словами, лучшее время будет полностью зеленым, а худшее - полностью красным. Два разных измерения времени никогда не должны иметь одинаковый цвет.
- Цветовые карты рассчитываются отдельно для положительных и отрицательных результатов, но охватывают все тесты для соответствующих категорий.
- Визуализации показывают начальную грузоподъемность по оси x и коэффициент нагрузки по оси y.
Без лишних слов, давайте посмотрим на результаты. Начну с результатов по путам.
Положите результаты
Размер коллекции: 100. Предел хеширования: 50. Это означает, что каждый хеш-код должен повторяться дважды, а все остальные ключи конфликтуют в хэш-карте.
Ну, это начинается не очень хорошо. Мы видим, что есть большая точка доступа для начальной емкости, на 25% превышающей размер коллекции, с коэффициентом загрузки 1. Левый нижний угол работает не очень хорошо.
Размер коллекции: 100. Предел хеширования: 90. Один из десяти ключей имеет повторяющийся хеш-код.
Это немного более реалистичный сценарий, не имеющий идеальной хеш-функции, но все же 10% перегрузки. Точка доступа исчезла, но сочетание низкой начальной емкости с низким коэффициентом загрузки явно не работает.
Размер коллекции: 100. Предел хеширования: 100. Каждый ключ как собственный уникальный хеш-код. Коллизий не ожидается, если ведра достаточно.
Начальная емкость 100 с коэффициентом загрузки 1 кажется прекрасной. Удивительно, но более высокая начальная емкость при более низком коэффициенте нагрузки не всегда хорошо.
Размер коллекции: 1000. Предел хеширования: 500. Здесь становится все серьезнее, с 1000 записей. Как и в первом тесте, есть перегрузка хеша от 2 до 1.
В нижнем левом углу все еще не все в порядке. Но, похоже, существует симметрия между комбинацией меньшего начального количества / высокого коэффициента загрузки и более высокого начального количества / низкого коэффициента загрузки.
Размер коллекции: 1000. Предел хеширования: 900. Это означает, что каждый десятый хэш-код встречается дважды. Разумный сценарий столкновений.
Что-то очень забавное происходит с маловероятной комбинацией начальной емкости, которая слишком мала, с коэффициентом загрузки выше 1, что довольно нелогично. В остальном все еще довольно симметрично.
Размер коллекции: 1000. Ограничение по хешу: 990. Некоторые коллизии, но только некоторые. Вполне реально в этом плане.
У нас здесь хорошая симметрия. Нижний левый угол по-прежнему неоптимален, но комбинация 1000 инициализаций / коэффициент загрузки 1.0 и 1250 инициализаций / коэффициент загрузки 0,75 находятся на том же уровне.
Размер коллекции: 1000. Предел хеширования: 1000. Нет повторяющихся хеш-кодов, но теперь размер выборки составляет 1000.
Здесь особо нечего сказать. Комбинация более высокой начальной емкости с коэффициентом нагрузки 0,75, кажется, немного превосходит комбинацию начальной емкости 1000 с коэффициентом нагрузки 1.
Размер коллекции: 100_000. Предел хеширования: 10_000. Хорошо, сейчас все становится серьезно, с размером выборки сто тысяч и 100 дубликатов хэш-кода на ключ.
Ой! Думаю, мы нашли наш нижний спектр. Емкость инициализации, равная точно размеру коллекции с коэффициентом загрузки 1, здесь очень хорошо работает, но в остальном это повсюду.
Размер коллекции: 100_000. Предел хеширования: 90_000. Немного более реалистично, чем предыдущий тест, здесь у нас 10% перегрузка хэш-кодов.
Левый нижний угол по-прежнему нежелателен. Лучше всего работают более высокие начальные мощности.
Размер коллекции: 100_000. Предел хеширования: 99_000. Хороший сценарий. Большая коллекция с перегрузкой хэш-кода 1%.
Здесь лучше использовать точный размер коллекции в качестве емкости инициализации с коэффициентом загрузки 1! Тем не менее, немного большие возможности инициализации работают достаточно хорошо.
Размер коллекции: 100_000. Предел хеширования: 100_000. Большая. Самая большая коллекция с идеальной хеш-функцией.
Здесь есть кое-что удивительное. Первоначальная мощность с 50% дополнительной комнаты при коэффициенте загрузки 1 побеждает.
Хорошо, это все, что касается пут. А теперь проверим. Помните, что все приведенные ниже карты относятся к лучшему / худшему времени получения, время размещения больше не учитывается.
Получите результат
Размер коллекции: 100. Предел хеширования: 50. Это означает, что каждый хеш-код должен встречаться дважды, и ожидалось, что все остальные ключи будут конфликтовать в хэш-карте.
Эх ... Что?
Размер коллекции: 100. Предел хеширования: 90. Один из десяти ключей имеет повторяющийся хеш-код.
Эй, Нелли! Это наиболее вероятный сценарий, который коррелирует с вопросом автора, и очевидно, что начальная емкость 100 с коэффициентом загрузки 1 - одна из худших вещей здесь! Клянусь, я не притворялся.
Размер коллекции: 100. Предел хеширования: 100. Каждый ключ как собственный уникальный хеш-код. Столкновения не ожидается.
Это выглядит немного более мирным. В основном одинаковые результаты по всем направлениям.
Размер коллекции: 1000. Предел хеширования: 500. Как и в первом тесте, имеется перегрузка хеш-функции от 2 до 1, но теперь с гораздо большим количеством записей.
Похоже, любая настройка здесь даст достойный результат.
Размер коллекции: 1000. Предел хеширования: 900. Это означает, что каждый десятый хэш-код встречается дважды. Разумный сценарий столкновений.
И точно так же, как с путами для этой установки, мы получаем аномалию в странном месте.
Размер коллекции: 1000. Ограничение по хешу: 990. Некоторые коллизии, но только некоторые. Вполне реально в этом плане.
Достойная производительность везде, за исключением комбинации высокой начальной емкости с низким коэффициентом загрузки. Я бы ожидал этого для put, поскольку можно было бы ожидать двух изменений размеров хэш-карты. Но почему на приеме?
Размер коллекции: 1000. Предел хеширования: 1000. Нет повторяющихся хеш-кодов, но теперь размер выборки составляет 1000.
Совершенно не впечатляющая визуализация. Кажется, это работает, несмотря ни на что.
Размер коллекции: 100_000. Предел хеширования: 10_000. Снова возвращаемся к 100К, с большим количеством перекрывающихся хэш-кодов.
Выглядит не очень красиво, хотя плохие места очень локализованы. Производительность здесь, похоже, во многом зависит от определенного взаимодействия между настройками.
Размер коллекции: 100_000. Предел хеширования: 90_000. Немного более реалистично, чем предыдущий тест, здесь у нас 10% перегрузка хэш-кодов.
Большая разница, хотя, если прищуриться, можно увидеть стрелку, указывающую в правый верхний угол.
Размер коллекции: 100_000. Предел хеширования: 99_000. Хороший сценарий. Большая коллекция с перегрузкой хэш-кода 1%.
Очень хаотично. Здесь сложно найти много структуры.
Размер коллекции: 100_000. Предел хеширования: 100_000. Большая. Самая большая коллекция с идеальной хеш-функцией.
Кто-нибудь еще думает, что это начинает выглядеть как графика Atari? Похоже, что это в пользу начальной емкости точно такого же размера коллекции, -25% или + 50%.
Хорошо, теперь пора делать выводы ...
- Что касается времени размещения: вам нужно избегать начальной емкости, которая меньше ожидаемого количества записей на карте. Если заранее известно точное число, лучше всего подходит это число или что-то немного большее. Высокие коэффициенты загрузки могут компенсировать более низкие начальные емкости из-за более раннего изменения размеров хэш-карты. Для более высоких начальных мощностей они, похоже, не имеют большого значения.
- Что касается времени получения: здесь результаты немного хаотичны. Выводить особо нечего. Кажется, что он очень сильно зависит от тонких соотношений между перекрытием хэш-кода, начальной емкостью и коэффициентом загрузки, при этом некоторые предположительно плохие настройки работают хорошо, а хорошие настройки работают ужасно.
- Я, по-видимому, полон чуши, когда дело касается предположений о производительности Java. По правде говоря, если вы не настроите идеально свои настройки для реализации
HashMap
, результаты будут повсюду. Если есть что-то, что можно вынести из этого, так это то, что начальный размер по умолчанию, равный 16, немного глуп для чего-либо, кроме самых маленьких карт, поэтому используйте конструктор, который устанавливает начальный размер, если у вас есть какое-либо представление о том, какой порядок размера это будет.
- Здесь мы измеряем в наносекундах. Лучшее среднее время на 10 пут было 1179 нс, а худшее - 5105 нс на моей машине. Лучшее среднее время на 10 попыток составило 547 нс, а худшее - 3484 нс. Это может быть разница в шесть раз, но мы говорим меньше миллисекунды. О коллекциях, которые намного больше, чем предполагалось в оригинальном плакате.
Ну вот и все. Я надеюсь, что в моем коде нет какого-то ужасного упущения, которое сводит на нет все, что я здесь опубликовал. Это было весело, и я узнал, что в конце концов вы можете с таким же успехом полагаться на Java в выполнении своей работы, чем ожидать большой разницы от крошечных оптимизаций. Это не означает, что некоторых вещей нельзя избегать, но тогда мы в основном говорим о построении длинных строк в циклах for, использовании неправильных структур данных и создании алгоритма O (n ^ 3).