Яка оптимальна ємність та коефіцієнт навантаження для HashMap фіксованого розміру?


85

Я намагаюся з'ясувати оптимальну потужність та коефіцієнт навантаження для конкретного випадку. Я думаю, що я зрозумів суть цього, але я все одно буду вдячний за підтвердження від когось більш знаного, ніж я. :)

Якщо я знаю, що моя HashMap заповниться, щоб містити, скажімо, 100 об’єктів, і я витрачу більшу частину часу, маючи 100 об’єктів, я здогадуюсь, що оптимальними значеннями є початкова ємність 100 та коефіцієнт завантаження 1? Або мені потрібна ємність 101, або є якісь інші проблеми?

EDIT: Добре, я виділив кілька годин і провів тестування. Ось результати:

  • Цікаво, що ємність, ємність + 1, ємність + 2, ємність-1 і навіть ємність-10 дають абсолютно однакові результати. Я би очікував, що принаймні ємність-1 та ємність-10 дадуть гірші результати.
  • Використання початкової ємності (на відміну від значення за замовчуванням 16) дає помітне покращення put () - на 30% швидше.
  • Використання коефіцієнта навантаження 1 дає однакову продуктивність для невеликої кількості об’єктів і кращу продуктивність для більшої кількості об’єктів (> 100000). Однак це не покращується пропорційно кількості об’єктів; Я підозрюю, що є додатковий фактор, який впливає на результати.
  • Продуктивність get () дещо відрізняється для різної кількості об’єктів / місткості, але хоча вона може дещо відрізнятися від випадку до випадку, як правило, це не залежить від початкової ємності чи коефіцієнта навантаження.

EDIT2: Додавання деяких діаграм і з мого боку. Ось один, що ілюструє різницю між коефіцієнтом навантаження 0,75 та 1, у випадках, коли я ініціалізую HashMap і заповнюю його на повну потужність. На масштабі y - час у мс (нижчий - краще), а на масштабі x - розмір (кількість об’єктів). Оскільки розмір змінюється лінійно, необхідний час також зростає лінійно.

Отже, давайте подивимось, що я отримав. Наступні дві діаграми показують різницю у коефіцієнтах навантаження. Перша діаграма показує, що відбувається, коли HashMap заповнюється до кінця; коефіцієнт навантаження 0,75 працює гірше через зміну розміру. Однак це не постійно гірше, і тут є всілякі нерівності та стрибки - я думаю, що GC має важливу роль у цьому. Коефіцієнт навантаження 1,25 працює так само, як 1, тому він не включений у діаграму.

повністю заповнені

Ця діаграма доводить, що 0,75 було гірше через зміну розміру; якщо ми заповнимо HashMap до половини ємності, 0,75 не гірше, просто ... по-різному (і він повинен використовувати менше пам'яті і мати непомітно кращу продуктивність ітерацій).

наполовину заповнений

Ще одне, що я хочу показати. Це отримання продуктивності для всіх трьох факторів навантаження та різних розмірів HashMap. Постійно постійний з невеликими варіаціями, за винятком одного стрибка для коефіцієнта навантаження 1. Я б дуже хотів знати, що це (можливо, GC, але хто знає).

піти колос

І ось код для зацікавлених:

import java.util.HashMap;
import java.util.Map;

public class HashMapTest {

  // capacity - numbers high as 10000000 require -mx1536m -ms1536m JVM parameters
  public static final int CAPACITY = 10000000;
  public static final int ITERATIONS = 10000;

  // set to false to print put performance, or to true to print get performance
  boolean doIterations = false;

  private Map<Integer, String> cache;

  public void fillCache(int capacity) {
    long t = System.currentTimeMillis();
    for (int i = 0; i <= capacity; i++)
      cache.put(i, "Value number " + i);

    if (!doIterations) {
      System.out.print(System.currentTimeMillis() - t);
      System.out.print("\t");
    }
  }

  public void iterate(int capacity) {
    long t = System.currentTimeMillis();

    for (int i = 0; i <= ITERATIONS; i++) {
      long x = Math.round(Math.random() * capacity);
      String result = cache.get((int) x);
    }

    if (doIterations) {
      System.out.print(System.currentTimeMillis() - t);
      System.out.print("\t");
    }
  }

  public void test(float loadFactor, int divider) {
    for (int i = 10000; i <= CAPACITY; i+= 10000) {
      cache = new HashMap<Integer, String>(i, loadFactor);
      fillCache(i / divider);
      if (doIterations)
        iterate(i / divider);
    }
    System.out.println();
  }

  public static void main(String[] args) {
    HashMapTest test = new HashMapTest();

    // fill to capacity
    test.test(0.75f, 1);
    test.test(1, 1);
    test.test(1.25f, 1);

    // fill to half capacity
    test.test(0.75f, 2);
    test.test(1, 2);
    test.test(1.25f, 2);
  }

}

1
Оптимальне в тому сенсі, що зміна за замовчуванням забезпечує кращу продуктивність (швидше виконання () виконання) для цього випадку.
Domchi

2
@Peter GC = збір сміття.
Домчі

2
Ці діаграми акуратні ... Що ви використовували для їх створення / відтворення?
G_H

1
@G_H Нічого химерного - вихід вищезазначеної програми та Excel. :)
Домчі,

2
Наступного разу використовуйте точки, а не лінії. Це полегшить візуальне порівняння.
Пол Дрейпер

Відповіді:


74

Добре, щоб заспокоїти цю справу, я створив тестовий додаток для запуску декількох сценаріїв та отримання візуалізації результатів. Ось як проводяться тести:

  • Випробувано низку різних розмірів колекції: сто, тисяча і сто тисяч записів.
  • Використовувані ключі - це екземпляри класу, які однозначно ідентифікуються ідентифікатором. У кожному тесті використовуються унікальні ключі із збільшенням цілих чисел як ідентифікаторів. 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 {

        //First entry of each array is the sample collection size, subsequent entries
        //are the hash limits
        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};

        //Doing a warmup run to eliminate JIT influence
        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();

        //Now for the real thing...
        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();
        }

//      ResultVisualizer.visualizeResults(results);

    }

    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 + "%");

        //Generating our sample key collection.
        final List<Key> keys = generateSamples(hashLimit, sampleSize);

        //Generating our value collection
        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);

        //Haha, what kind of noob explicitly calls for garbage collection?
        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) {

            //Equals implies same hashCode if limit is the same
            //Same hashCode doesn't necessarily implies equals

            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) {

        //[x][y] => x is mapped to initial capacity, y is mapped to load factor
        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. Це означає, що кожен хеш-код повинен зустрічатися двічі, а кожен інший ключ стикається на хеш-карті.

size_100_hlimit_50_puts

Ну, це починається не дуже добре. Ми бачимо, що є велика гаряча точка для початкової ємності на 25% вище розміру колекції, з коефіцієнтом завантаження 1. Нижній лівий кут працює не дуже добре.


Розмір колекції: 100. Обмеження хешу: 90. Кожен десятий ключ має повторюваний хеш-код.

size_100_hlimit_90_puts

Це трохи більш реалістичний сценарій, не маючи ідеальної хеш-функції, але все одно перевантаження 10%. Точки доступу немає, але поєднання низької початкової ємності з низьким коефіцієнтом навантаження, очевидно, не працює.


Розмір колекції: 100. Обмеження хешу: 100. Кожен ключ як власний унікальний хеш-код. Якщо достатньо сегментів, не очікується зіткнень.

size_100_hlimit_100_puts

Початкова потужність 100 з коефіцієнтом навантаження 1 здається чудовою. Дивно, але більша початкова потужність з меншим коефіцієнтом навантаження не обов'язково хороша.


Розмір колекції: 1000. Обмеження хешу: 500. Тут стає все серйозніше, з 1000 записів. Як і в першому тесті, є перевантаження хешу від 2 до 1.

size_1000_hlimit_500_puts

У нижньому лівому куті все ще не все добре. Але, схоже, існує симетрія між комбінацією нижчого початкового рахунку / високого коефіцієнта навантаження та вищого початкового числа / низького коефіцієнта навантаження.


Розмір колекції: 1000. Обмеження хешу: 900. Це означає, що кожен десятий хеш-код повторюється двічі. Розумний сценарій щодо зіткнень.

size_1000_hlimit_900_puts

Щось дуже смішне відбувається з малоймовірним комбінованим початковим потенціалом, який є занадто низьким з коефіцієнтом навантаження вище 1, що є досить інтуїтивним. В іншому випадку все-таки досить симетрично.


Розмір колекції: 1000. Обмеження хешу: 990. Деякі зіткнення, але лише деякі. Цілком реалістично в цьому відношенні.

size_1000_hlimit_990_puts

У нас тут приємна симетрія. Нижній лівий кут все ще є неоптимальним, але комбіновані 1000 init ємність / 1,0 коефіцієнт навантаження проти 1250 init ємність / 0,75 коефіцієнт навантаження на тому ж рівні.


Розмір колекції: 1000. Обмеження хешу: 1000. Немає повторюваних хеш-кодів, але тепер із розміром вибірки 1000.

size_1000_hlimit_1000_puts

Тут можна сказати не так багато. Поєднання більш високої початкової потужності з коефіцієнтом навантаження 0,75, здається, трохи перевершує комбінацію 1000 початкової потужності з коефіцієнтом навантаження 1.


Розмір колекції: 100_000. Обмеження хешу: 10_000. Добре, зараз це стає серйозним, з розміром вибірки сто тисяч і 100 дублікатів хеш-коду на ключ.

size_100000_hlimit_10000_puts

Так! Я думаю, ми знайшли наш нижчий спектр. Ініційна ємність з точністю до колекції з коефіцієнтом навантаження 1 тут справляється дуже добре, але крім того, що це по всьому магазину.


Розмір колекції: 100_000. Обмеження хешу: 90_000. Трохи реалістичніше, ніж у попередньому тесті, тут ми маємо 10% перевантаження хеш-кодів.

size_100000_hlimit_90000_puts

Нижній лівий кут все ще небажаний. Найвищі початкові можливості працюють найкраще.


Розмір колекції: 100_000. Обмеження хешу: 99_000. Хороший сценарій, це. Велика колекція з перевантаженням хеш-коду на 1%.

size_100000_hlimit_99000_puts

Тут виграє точний розмір колекції як потужність ініціативи з коефіцієнтом завантаження 1! Однак трохи більші потужності ініціалізації працюють досить добре.


Розмір колекції: 100_000. Обмеження хешу: 100_000. Великий. Найбільша колекція з досконалою хеш-функцією.

size_100000_hlimit_100000_puts

Тут є щось дивовижне. Виграє початкова ємність із додатковим приміщенням на 50% при коефіцієнті завантаження 1.


Добре, це все для путів. Зараз ми перевіримо отримання. Пам'ятайте, що наведені нижче карти відносяться до найкращого / найгіршого часу отримання, встановлений час більше не враховується.

Отримати результати


Розмір колекції: 100. Обмеження хешу: 50. Це означає, що кожен хеш-код повинен зустрічатися двічі, і кожен другий ключ повинен був зіткнутися на хеш-карті.

size_100_hlimit_50_gets

Е ... Що?


Розмір колекції: 100. Обмеження хешу: 90. Кожен десятий ключ має повторюваний хеш-код.

size_100_hlimit_90_gets

Ой, Неллі! Це найбільш вірогідний сценарій, який може співвідноситися з питанням запитувача, і, мабуть, початкова потужність 100 з коефіцієнтом навантаження 1 - одна з найгірших речей тут! Клянусь, я не підробив цього.


Розмір колекції: 100. Обмеження хешу: 100. Кожен ключ як власний унікальний хеш-код. Жодних зіткнень не очікується.

size_100_hlimit_100_gets

Це виглядає дещо спокійніше. Переважно однакові результати.


Розмір колекції: 1000. Обмеження хешу: 500. Так само, як і в першому тесті, є перевантаження хешем від 2 до 1, але тепер із набагато більше записів.

size_1000_hlimit_500_gets

Схоже, будь-яке налаштування дасть тут гідний результат.


Розмір колекції: 1000. Обмеження хешу: 900. Це означає, що кожен десятий хеш-код повторюється двічі. Розумний сценарій щодо зіткнень.

size_1000_hlimit_900_gets

І так само, як і з путами для цієї установки, ми отримуємо аномалію в дивному місці.


Розмір колекції: 1000. Обмеження хешу: 990. Деякі зіткнення, але лише деякі. Цілком реалістично в цьому відношенні.

size_1000_hlimit_990_gets

Гідна продуктивність скрізь, за винятком поєднання високої початкової потужності з низьким коефіцієнтом навантаження. Я би очікував цього для путів, оскільки можна очікувати дві зміни розміру хеш-карти. Але чому на отримує?


Розмір колекції: 1000. Обмеження хешу: 1000. Немає повторюваних хеш-кодів, але тепер із розміром вибірки 1000.

size_1000_hlimit_1000_gets

Повністю не вражаюча візуалізація. Здається, це працює незалежно від того, що.


Розмір колекції: 100_000. Обмеження хешу: 10_000. Знову переходимо до 100K з великою кількістю хеш-кодів, що перекриваються.

size_100000_hlimit_10000_gets

Це не виглядає красиво, хоча погані місця дуже локалізовані. Ефективність тут, здається, багато в чому залежить від певної синергії між налаштуваннями.


Розмір колекції: 100_000. Обмеження хешу: 90_000. Трохи реалістичніше, ніж у попередньому тесті, тут ми маємо 10% перевантаження хеш-кодів.

size_100000_hlimit_90000_gets

Значна дисперсія, хоча, якщо ви примружили очі, ви побачите стрілку, спрямовану в правий верхній кут.


Розмір колекції: 100_000. Обмеження хешу: 99_000. Хороший сценарій, це. Велика колекція з перевантаженням хеш-коду на 1%.

size_100000_hlimit_99000_gets

Дуже хаотично. Тут важко знайти велику структуру.


Розмір колекції: 100_000. Обмеження хешу: 100_000. Великий. Найбільша колекція з досконалою хеш-функцією.

size_100000_hlimit_100000_gets

Хтось ще думає, що це починає виглядати як графіка Atari? Здається, це сприяє початковій потужності саме розміру колекції, -25% або + 50%.


Гаразд, зараз час робити висновки ...

  • Щодо часу накладання: ви хочете уникнути початкової ємності, яка нижча за очікувану кількість записів на карті. Якщо точне число відомо заздалегідь, це число або щось трохи вище, здається, працює найкраще. Високі коефіцієнти навантаження можуть компенсувати нижчі початкові потужності завдяки попереднім змінам розміру хеш-карти. Для більш високих початкових можливостей вони, здається, не так важливі.
  • Щодо часу отримання: результати тут трохи хаотичні. Існує не так багато висновків. Здається, він дуже покладається на тонкі співвідношення між перекриттям хеш-коду, початковою ємністю та коефіцієнтом навантаження, причому деякі нібито погані установки добре працюють, а хороші - жахливо.
  • Я, мабуть, сповнений глупоти, коли справа доходить до припущень про продуктивність Java. Правда в тому, що якщо ви не ідеально налаштуєте свої налаштування на реалізацію HashMap, результати будуть скрізь. Якщо у цього є щось одне, це те, що початковий розмір за замовчуванням 16 трохи німий для будь-чого, крім найменших карт, тому використовуйте конструктор, який встановлює початковий розмір, якщо у вас є уявлення про порядок розміру це буде.
  • Тут ми вимірюємо в наносекундах. Найкращий середній час на 10 путів - 1179 нс, а найгірший - 5105 нс на моїй машині. Найкращий середній час за 10 прийомів був 547 нс, а найгірший 3484 нс. Це може бути фактором 6, але ми говоримо менше мілісекунди. На колекціях, які значно більші за те, що мав на увазі оригінальний плакат.

Ну ось і все. Сподіваюся, мій код не має жахливого недогляду, який робить недійсним усе, що я тут розмістив. Це було весело, і я дізнався, що врешті-решт ви можете так само покладатися на Java, щоб робити свою роботу, ніж очікувати великої різниці від крихітних оптимізацій. Це не означає, що деяких речей не слід уникати, але тоді ми в основному говоримо про побудову довгих рядків для циклів for, використання неправильних структур даних та створення алгоритмів O (n ^ 3).


1
Дякую за зусилля, виглядає чудово! Щоб не лінуватися, я також додав кілька гарних графіків до своїх результатів. Мої тести трохи грубіші, ніж ваші, але я виявив, що відмінності помітніші при використанні більших карт. З маленькими картами, що б ви не робили, ви не можете пропустити. Ефективність, як правило, хаотична через оптимізацію JVM та GC, і я маю теорію, згідно з якою всі важкі висновки, з’їдені цим хаосом для деяких ваших менших наборів даних.
Domchi

Ще один коментар щодо отримання продуктивності. Це здається хаотичним, але я виявив, що воно дуже сильно варіюється у дуже вузькому діапазоні, але загалом це постійно та нудно, як біс. Я випадково отримував дивні стрибки, такі як ви на 100/90. Я не можу це пояснити, але на практиці це, мабуть, непомітно.
Domchi

G_H, будь ласка, погляньте на мою відповідь, я знаю, що це дуже стара тема, але, можливо, ваші тести слід переробити з урахуванням цього.
durron597

Гей, ви повинні опублікувати це в ACM як доповідь на конференції :) Які зусилля!
yerlilbilgin

12

Це досить чудова тема, за винятком того, що вам не вистачає однієї важливої ​​речі. Ти сказав:

Цікаво, що ємність, ємність + 1, ємність + 2, ємність-1 і навіть ємність-10 дають абсолютно однакові результати. Я би очікував, що принаймні ємність-1 та ємність-10 дадуть гірші результати.

Вихідний код внутрішньо перетворює початкову потужність на наступну найвищу потужність двох. Це означає, що, наприклад, початкові потужності 513, 600, 700, 800, 900, 1000 і 1024 будуть використовувати однакову початкову потужність (1024). Це не робить недійсним тестування, проведене @G_H, однак, перед аналізом його результатів слід усвідомити, що це робиться. І це пояснює дивну поведінку деяких тестів.

Це право конструктора для джерела JDK:

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and load factor.
 *
 * @param  initialCapacity the initial capacity
 * @param  loadFactor      the load factor
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);

    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;

    this.loadFactor = loadFactor;
    threshold = (int)(capacity * loadFactor);
    table = new Entry[capacity];
    init();
}

Це дуже цікаво! Я не мав уявлення про це. Справді пояснює те, що я бачив у тестах. І знову ж таки, це підтверджує, що передчасна оптимізація часто буває корисною, оскільки ви просто не знаєте (а то й справді повинні знати), що компілятор або код може робити за вашою спиною. І тоді, звичайно, це може відрізнятися залежно від версії / реалізації. Дякуємо, що прояснили це!
G_H

@G_H Я хотів би побачити, щоб ваші тести запускались знову, вибираючи цифри, більш відповідні з урахуванням цієї інформації. Наприклад, якщо у мене 1200 елементів, чи слід використовувати карту 1024, карту 2048 чи карту 4096? Я не знаю відповіді на вихідне питання, тому для початку я знайшов цю тему. Хоча, я знаю, що Гуава помножує ваш час expectedSizeна 1.33час, коли ви це зробитеMaps.newHashMap(int expectedSize)
durron597

Якби HashMap не округлювався до значення степеня-два для capacity, деякі сегменти ніколи не використовувались. Індекс сегмента, куди слід помістити картографічні дані, визначається bucketIndex = hashCode(key) & (capacity-1). Отже, якби capacityбуло щось інше, ніж ступінь двійки, двійкове представлення (capacity-1)буде мати в собі деякі нулі, а це означає, що &(двійкові та) операції завжди обнуляють певні нижчі біти hashCode. Приклад: (capacity-1)є 111110(62) замість 111111(63). У цьому випадку можна використовувати лише відра з парними індексами.
Michael Geier

2

Просто піти з 101. Я насправді не впевнений, що це потрібно, але це не могло б коштувати зусиль, щоб коли-небудь турбуватися про те, щоб дізнатись напевно.

... просто додайте 1.


EDIT: Деяке обґрунтування моєї відповіді.

По-перше, я припускаю, що ви HashMapне зростете далі 100; якщо це сталося, слід залишити коефіцієнт навантаження таким, яким він є. Подібним чином, якщо ваша проблема стосується продуктивності, залиште коефіцієнт навантаження як є . Якщо ваша проблема - пам’ять, ви можете заощадити трохи, встановивши статичний розмір. Це може можливо бути варто робити , якщо ви зубріння багато матеріалу в пам'яті; тобто зберігають безліч карт або створюють карти розміром купі простору.

По-друге, я вибираю значення, 101тому що воно забезпечує кращу читабельність ... якщо я потім переглядаю ваш код і бачу, що ви встановили початкову ємність 100і завантажуєте його 100елементами, мені доведеться прочитайте Javadoc, щоб переконатися, що він не змінить розмір, коли досягне точно 100. Звичайно, я не знайду там відповіді, тому доведеться подивитися на джерело. Це не варто ... просто залиште це, 101і всі задоволені, і ніхто не дивиться, хоча вихідний код java.util.HashMap. Ура.

По-третє, твердження, що встановлення HashMapточної потужності того, що ви очікуєте, з коефіцієнтом навантаження 1 " вб'є вашу ефективність пошуку та вставки " , просто не відповідає дійсності, навіть якщо це зроблено жирним шрифтом.

... якщо у вас є nвідра, і ви випадковим чином розподіляєте nпредмети у nвідра, так, ви, в кінцевому підсумку, отримаєте предмети в тому ж відрі, звичайно ... але це не кінець світу ... на практиці, це лише ще пара рівних порівнянь. Насправді є особливо. невелика різниця, якщо врахувати, що альтернативою є призначення nелементів у n/0.75відра.

Не потрібно вірити мені на слово ...


Код швидкого тестування:

static Random r = new Random();

public static void main(String[] args){
    int[] tests = {100, 1000, 10000};
    int runs = 5000;

    float lf_sta = 1f;
    float lf_dyn = 0.75f;

    for(int t:tests){
        System.err.println("=======Test Put "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        long norm_put = testInserts(map, t, runs);
        System.err.print("Norm put:"+norm_put+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        long sta_put = testInserts(map, t, runs);
        System.err.print("Static put:"+sta_put+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        long dyn_put = testInserts(map, t, runs);
        System.err.println("Dynamic put:"+dyn_put+" ms. ");
    }

    for(int t:tests){
        System.err.println("=======Test Get (hits) "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        fill(map, t);
        long norm_get_hits = testGetHits(map, t, runs);
        System.err.print("Norm get (hits):"+norm_get_hits+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        fill(map, t);
        long sta_get_hits = testGetHits(map, t, runs);
        System.err.print("Static get (hits):"+sta_get_hits+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        fill(map, t);
        long dyn_get_hits = testGetHits(map, t, runs);
        System.err.println("Dynamic get (hits):"+dyn_get_hits+" ms. ");
    }

    for(int t:tests){
        System.err.println("=======Test Get (Rand) "+t+"");
        HashMap<Integer,Integer> map = new HashMap<Integer,Integer>();
        fill(map, t);
        long norm_get_rand = testGetRand(map, t, runs);
        System.err.print("Norm get (rand):"+norm_get_rand+" ms. ");

        int cap_sta = t;
        map = new HashMap<Integer,Integer>(cap_sta, lf_sta);
        fill(map, t);
        long sta_get_rand = testGetRand(map, t, runs);
        System.err.print("Static get (rand):"+sta_get_rand+" ms. ");

        int cap_dyn = (int)Math.ceil((float)t/lf_dyn);
        map = new HashMap<Integer,Integer>(cap_dyn, lf_dyn);
        fill(map, t);
        long dyn_get_rand = testGetRand(map, t, runs);
        System.err.println("Dynamic get (rand):"+dyn_get_rand+" ms. ");
    }
}

public static long testInserts(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    for(int i=0; i<runs; i++){
        fill(map, test);
        map.clear();
    }
    return System.currentTimeMillis()-b4;
}

public static void fill(HashMap<Integer,Integer> map, int test){
    for(int j=0; j<test; j++){
        if(map.put(r.nextInt(), j)!=null){
            j--;
        }
    }
}

public static long testGetHits(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    ArrayList<Integer> keys = new ArrayList<Integer>();
    keys.addAll(map.keySet());

    for(int i=0; i<runs; i++){
        for(int j=0; j<test; j++){
            keys.get(r.nextInt(keys.size()));
        }
    }
    return System.currentTimeMillis()-b4;
}

public static long testGetRand(HashMap<Integer,Integer> map, int test, int runs){
    long b4 = System.currentTimeMillis();

    for(int i=0; i<runs; i++){
        for(int j=0; j<test; j++){
            map.get(r.nextInt());
        }
    }
    return System.currentTimeMillis()-b4;
}

Результати тесту:

=======Test Put 100
Norm put:78 ms. Static put:78 ms. Dynamic put:62 ms. 
=======Test Put 1000
Norm put:764 ms. Static put:763 ms. Dynamic put:748 ms. 
=======Test Put 10000
Norm put:12921 ms. Static put:12889 ms. Dynamic put:12873 ms. 
=======Test Get (hits) 100
Norm get (hits):47 ms. Static get (hits):31 ms. Dynamic get (hits):32 ms. 
=======Test Get (hits) 1000
Norm get (hits):327 ms. Static get (hits):328 ms. Dynamic get (hits):343 ms. 
=======Test Get (hits) 10000
Norm get (hits):3304 ms. Static get (hits):3366 ms. Dynamic get (hits):3413 ms. 
=======Test Get (Rand) 100
Norm get (rand):63 ms. Static get (rand):46 ms. Dynamic get (rand):47 ms. 
=======Test Get (Rand) 1000
Norm get (rand):483 ms. Static get (rand):499 ms. Dynamic get (rand):483 ms. 
=======Test Get (Rand) 10000
Norm get (rand):5190 ms. Static get (rand):5362 ms. Dynamic get (rand):5236 ms. 

re: ↑ - там про це → || ← велика різниця між різними налаштуваннями .


Що стосується мого початкового відповіді (біт вище першої горизонтальної лінії), він навмисно GLIB , тому що в більшості випадків , цей тип мікро-оптимізації недобре .


@EJP, мої здогадки не є неправильними. Див. Редагування вище. Ваші здогадки неправильні щодо того, чиї здогадки правильні, а чиї - неправильні.
badroit

(... можливо, я трохи хитрий ... Хоча мене трохи дратує: P)
badroit

3
Вас може по праву дратувати EJP, однак зараз моя черга; P - хоча я погоджуюся, що передчасна оптимізація багато в чому нагадує передчасну еякуляцію, будь ласка, не думайте, що щось, що зазвичай не вартує зусиль, не варте зусиль у моєму випадку . У моєму випадку це досить важливо, щоб я не хотів здогадуватися, тому я його розглянув - +1 у моєму випадку не потрібен (але може бути там, де ваша початкова / фактична ємність не однакова, а loadFactor не 1, перегляньте це приведення до int у HashMap: порог = (int) (ємність * loadFactor)).
Домчі

@badroit Ви прямо сказали, що я насправді не впевнений, що це потрібно '. Тому це були здогадки. Тепер, коли ви зробили і розмістили дослідження, це вже не здогадки, і оскільки ви, очевидно, не робили цього раніше, це було явно здогадка, інакше ви були б у цьому впевнені. Що стосується "неправильної", Javadoc прямо передбачає коефіцієнт навантаження 0,75, як це робить кілька десятиліть досліджень, і відповідь G_H. Врешті-решт, щодо того, що "це не могло б коштувати зусиль", дивіться коментар Домчі тут. Не залишає багато чого правильного, хоча загалом я погоджуюся з вами щодо мікрооптимізації.
Маркіз Лорнський

Розслабтесь усі. Так, моя відповідь перебільшила речі. Якщо у вас є 100 об'єктів, які не мають надзвичайно важкої equalsфункції, ви, мабуть, уникнете, помістивши їх у список і просто використовуючи `contains´. За такого невеликого набору ніколи не буде великих відмінностей у продуктивності. Це насправді важливо лише в тому випадку, якщо проблеми зі швидкістю або пам’яттю перевищують усі інші, або якщо рівні та хеш дуже конкретні. Пізніше я проведу тест з великими колекціями та різними коефіцієнтами навантаження та початковою потужністю, щоб перевірити, повно я чи не лайно.
G_H


1

З HashMapJavaDoc:

Як правило, коефіцієнт навантаження за замовчуванням (.75) пропонує хороший компроміс між часовими та космічними витратами. Більші значення зменшують накладні витрати на простір, але збільшують вартість пошуку (що відображається у більшості операцій класу HashMap, включаючи get and put). Очікувана кількість записів на карті та коефіцієнт її завантаження слід враховувати при встановленні її початкової ємності, щоб мінімізувати кількість операцій повторної обробки. Якщо початкова ємність перевищує максимальну кількість записів, поділену на коефіцієнт навантаження, жодних операцій переобробки не буде.

Отже, якщо ви очікуєте 100 входів, можливо, коефіцієнт навантаження 0,75 та початкова потужність стелі (100 / 0,75) буде найкращим. Це зводиться до 134.

Треба визнати, я не впевнений, чому вартість пошуку була б більшою за більш високого коефіцієнта навантаження. Те, що HashMap є більш "переповненим", не означає, що більше об'єктів буде розміщено в одному сегменті, так? Це залежить лише від їхнього хеш-коду, якщо я не помиляюся. Отже, припускаючи пристойне розповсюдження хеш-коду, чи не повинно більшість випадків все одно бути O (1) незалежно від коефіцієнта навантаження?

EDIT: Мені слід прочитати більше перед публікацією ... Звичайно, хеш-код не може безпосередньо зіставитись з якимсь внутрішнім індексом. Його потрібно зменшити до значення, яке відповідає поточній потужності. Це означає, що чим більша ваша початкова ємність, тим меншою ви можете очікувати кількість зіткнень хешу. Вибір початкової ємності саме за розміром (або +1) вашого об'єкта з коефіцієнтом навантаження 1 дійсно забезпечить, щоб ваша карта ніколи не змінювалася. Однак це призведе до знищення ефективності пошуку та вставки. Змінення розміру все ще є відносно швидким і може відбуватися лише один раз, тоді як пошук виконується майже на будь-якій відповідній роботі з картою. Як результат, оптимізація для швидкого пошуку - це те, що ви справді хочете тут. Ви можете поєднати це з тим, що вам ніколи не доведеться змінювати розмір, роблячи те, що каже JavaDoc: візьміть необхідну ємність, розділіть на оптимальний коефіцієнт навантаження (наприклад, 0,75) і використовуйте її як початкову ємність із цим коефіцієнтом навантаження. Додайте 1, щоб переконатися, що округлення не допоможе.


1
msgstr " це призведе до знищення ефективності пошуку та вставки ". Це надмірно перебільшує / просто-неправильно.
badroit

1
Мої тести показують, що на ефективність пошуку не впливає встановлення коефіцієнта навантаження 1. Ефективність вставки насправді покращується; оскільки розмірів немає, це швидше. Отже, ваше твердження є правильним для загального випадку (пошук HashMap з невеликою кількістю елементів буде швидшим на 0,75, ніж з 1), але неправильний для мого конкретного випадку, коли HashMap завжди заповнений до максимальної ємності, яка ніколи не змінюється. Ваша пропозиція встановити початковий розмір вище є цікавою, але неактуальною для мого випадку, оскільки моя таблиця не росте, тому коефіцієнт навантаження важливий лише у світлі зміни розміру.
Домчі,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.