Muy bien, para dejar esto en reposo, he creado una aplicación de prueba para ejecutar un par de escenarios y obtener algunas visualizaciones de los resultados. Así es como se hacen las pruebas:
- Se han probado varios tamaños de colección diferentes: cien, mil y cien mil entradas.
- Las claves utilizadas son instancias de una clase que se identifican de forma exclusiva mediante un ID. Cada prueba utiliza claves únicas, con números enteros crecientes como ID. El
equals
método solo usa el ID, por lo que ninguna asignación de teclas sobrescribe a otra.
- Las claves obtienen un código hash que consiste en el resto del módulo de su ID contra algún número preestablecido. Llamaremos a ese número el límite de hash . Esto me permitió controlar la cantidad de colisiones hash que se esperarían. Por ejemplo, si el tamaño de nuestra colección es 100, tendremos claves con ID de 0 a 99. Si el límite de hash es 100, cada clave tendrá un código hash único. Si el límite de hash es 50, la clave 0 tendrá el mismo código hash que la clave 50, 1 tendrá el mismo código hash que 51, etc. En otras palabras, el número esperado de colisiones hash por clave es el tamaño de la colección dividido por el hash límite.
- Para cada combinación de tamaño de colección y límite de hash, ejecuté la prueba usando mapas hash inicializados con diferentes configuraciones. Estas configuraciones son el factor de carga y una capacidad inicial que se expresa como un factor de la configuración de recolección. Por ejemplo, una prueba con un tamaño de colección de 100 y un factor de capacidad inicial de 1,25 inicializará un mapa hash con una capacidad inicial de 125.
- El valor de cada clave es simplemente nuevo
Object
.
- Cada resultado de la prueba se encapsula en una instancia de una clase Result. Al final de todas las pruebas, los resultados se ordenan del peor rendimiento general al mejor.
- El tiempo medio de las opciones de compra y venta se calcula por 10 opciones de compra.
- Todas las combinaciones de prueba se ejecutan una vez para eliminar la influencia de la compilación JIT. Después de eso, las pruebas se ejecutan para obtener resultados reales.
Aquí está la clase:
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.");
}
}
}
Ejecutar esto puede llevar un tiempo. Los resultados se imprimen en formato estándar. Puede que notes que he comentado una línea. Esa línea llama a un visualizador que genera representaciones visuales de los resultados en archivos png. La clase para esto se da a continuación. Si desea ejecutarlo, elimine el comentario de la línea correspondiente en el código anterior. Tenga cuidado: la clase de visualizador asume que está ejecutando Windows y creará carpetas y archivos en C: \ temp. Cuando corra en otra plataforma, ajuste esto.
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);
}
}
La salida visualizada es la siguiente:
- Las pruebas se dividen primero por tamaño de colección y luego por límite de hash.
- Para cada prueba, hay una imagen de salida con respecto al tiempo de colocación promedio (por cada 10 colocaciones) y el tiempo de obtención promedio (por cada 10 obtenciones). Las imágenes son "mapas de calor" bidimensionales que muestran un color por combinación de capacidad inicial y factor de carga.
- Los colores de las imágenes se basan en el tiempo promedio en una escala normalizada del mejor al peor resultado, que va desde el verde saturado al rojo saturado. En otras palabras, el mejor momento será completamente verde, mientras que el peor momento será completamente rojo. Dos medidas de tiempo diferentes nunca deben tener el mismo color.
- Los mapas de color se calculan por separado para las opciones de compra y venta, pero abarcan todas las pruebas para sus respectivas categorías.
- Las visualizaciones muestran la capacidad inicial en su eje x y el factor de carga en el eje y.
Sin más preámbulos, echemos un vistazo a los resultados. Comenzaré con los resultados de las opciones de venta.
Poner resultados
Tamaño de la colección: 100. Límite de hash: 50. Esto significa que cada código hash debe aparecer dos veces y todas las demás claves chocan en el mapa hash.
Bueno, eso no empieza muy bien. Vemos que hay un gran punto de acceso para una capacidad inicial del 25% por encima del tamaño de la colección, con un factor de carga de 1. La esquina inferior izquierda no funciona muy bien.
Tamaño de la colección: 100. Límite de hash: 90. Una de cada diez claves tiene un código hash duplicado.
Este es un escenario un poco más realista, que no tiene una función hash perfecta, pero sigue teniendo una sobrecarga del 10%. El punto de acceso se ha ido, pero la combinación de una capacidad inicial baja con un factor de carga bajo obviamente no funciona.
Tamaño de la colección: 100. Límite de hash: 100. Cada clave tiene su propio código hash único. No se esperan colisiones si hay suficientes cubos.
Una capacidad inicial de 100 con un factor de carga de 1 parece estar bien. Sorprendentemente, una capacidad inicial más alta con un factor de carga más bajo no es necesariamente buena.
Tamaño de la colección: 1000. Límite de hash: 500. Aquí se está poniendo más serio, con 1000 entradas. Al igual que en la primera prueba, hay una sobrecarga de hash de 2 a 1.
La esquina inferior izquierda todavía no funciona bien. Pero parece haber una simetría entre la combinación de conteo inicial más bajo / factor de carga alto y conteo inicial más alto / factor de carga bajo.
Tamaño de la colección: 1000. Límite de hash: 900. Esto significa que uno de cada diez códigos hash se producirá dos veces. Escenario razonable de colisiones.
Algo muy divertido está sucediendo con la combinación poco probable de una capacidad inicial que es demasiado baja con un factor de carga superior a 1, lo cual es bastante contrario a la intuición. De lo contrario, sigue siendo bastante simétrico.
Tamaño de la colección: 1000. Límite de hash: 990. Algunas colisiones, pero solo algunas. Muy realista a este respecto.
Tenemos una bonita simetría aquí. La esquina inferior izquierda todavía es subóptima, pero los combos 1000 init capacidad / 1.0 factor de carga versus 1250 init capacidad / 0.75 factor de carga están en el mismo nivel.
Tamaño de la colección: 1000. Límite de hash: 1000. Sin códigos hash duplicados, pero ahora con un tamaño de muestra de 1000.
No hay mucho que decir aquí. La combinación de una capacidad inicial más alta con un factor de carga de 0,75 parece superar ligeramente la combinación de una capacidad inicial de 1000 con un factor de carga de 1.
Tamaño de la colección: 100_000. Límite de hash: 10_000. Muy bien, se está poniendo serio ahora, con un tamaño de muestra de cien mil 100 duplicados de código hash por clave.
¡Ay! Creo que encontramos nuestro espectro más bajo. Una capacidad de inicio del tamaño de la colección exactamente con un factor de carga de 1 está funcionando muy bien aquí, pero aparte de eso, está en toda la tienda.
Tamaño de la colección: 100_000. Límite de hash: 90_000. Un poco más realista que la prueba anterior, aquí tenemos una sobrecarga del 10% en códigos hash.
La esquina inferior izquierda sigue siendo indeseable. Las capacidades iniciales más altas funcionan mejor.
Tamaño de la colección: 100_000. Límite de hash: 99_000. Buen escenario, este. Una colección grande con una sobrecarga de código hash del 1%.
¡Usar el tamaño exacto de la colección como capacidad de inicio con un factor de carga de 1 gana aquí! Sin embargo, las capacidades de inicio un poco más grandes funcionan bastante bien.
Tamaño de la colección: 100_000. Límite de hash: 100_000. El Grande. La colección más grande con una función hash perfecta.
Algunas cosas sorprendentes aquí. Una capacidad inicial con un 50% de espacio adicional con un factor de carga de 1 gana.
Muy bien, eso es todo para los put. Ahora, comprobaremos los get. Recuerde, los mapas a continuación son relativos a los mejores / peores tiempos de obtención, los tiempos de colocación ya no se tienen en cuenta.
Tener resultados
Tamaño de la colección: 100. Límite de hash: 50. Esto significa que cada código hash debería aparecer dos veces y se esperaba que todas las demás claves colisionen en el mapa hash.
Eh ... ¿Qué?
Tamaño de la colección: 100. Límite de hash: 90. Una de cada diez claves tiene un código hash duplicado.
¡Vaya, Nelly! Este es el escenario más probable que se correlacione con la pregunta del autor de la pregunta, y aparentemente una capacidad inicial de 100 con un factor de carga de 1 es una de las peores cosas aquí. Te juro que no fingí esto.
Tamaño de la colección: 100. Límite de hash: 100. Cada clave tiene su propio código hash único. No se esperan colisiones.
Esto parece un poco más pacífico. En su mayoría, los mismos resultados en todos los ámbitos.
Tamaño de la colección: 1000. Límite de hash: 500. Al igual que en la primera prueba, hay una sobrecarga de hash de 2 a 1, pero ahora con muchas más entradas.
Parece que cualquier configuración producirá un resultado decente aquí.
Tamaño de la colección: 1000. Límite de hash: 900. Esto significa que uno de cada diez códigos hash se producirá dos veces. Escenario razonable de colisiones.
Y al igual que con los put para esta configuración, obtenemos una anomalía en un lugar extraño.
Tamaño de la colección: 1000. Límite de hash: 990. Algunas colisiones, pero solo algunas. Muy realista a este respecto.
Rendimiento decente en todas partes, salvo por la combinación de una alta capacidad inicial con un factor de carga bajo. Esperaría esto para las put, ya que se pueden esperar dos cambios de tamaño de mapa hash. Pero, ¿por qué?
Tamaño de la colección: 1000. Límite de hash: 1000. Sin códigos hash duplicados, pero ahora con un tamaño de muestra de 1000.
Una visualización totalmente espectacular. Esto parece funcionar pase lo que pase.
Tamaño de la colección: 100_000. Límite de hash: 10_000. Volviendo a los 100K nuevamente, con una gran cantidad de superposición de códigos hash.
No se ve bonito, aunque los puntos malos están muy localizados. El rendimiento aquí parece depender en gran medida de una cierta sinergia entre entornos.
Tamaño de la colección: 100_000. Límite de hash: 90_000. Un poco más realista que la prueba anterior, aquí tenemos una sobrecarga del 10% en códigos hash.
Mucha variación, aunque si entrecierras los ojos puedes ver una flecha apuntando a la esquina superior derecha.
Tamaño de la colección: 100_000. Límite de hash: 99_000. Buen escenario, este. Una colección grande con una sobrecarga de código hash del 1%.
Muy caótico. Es difícil encontrar mucha estructura aquí.
Tamaño de la colección: 100_000. Límite de hash: 100_000. El Grande. La colección más grande con una función hash perfecta.
¿Alguien más piensa que esto está empezando a parecerse a los gráficos de Atari? Esto parece favorecer una capacidad inicial exactamente del tamaño de la colección, -25% o + 50%.
Muy bien, es hora de sacar conclusiones ahora ...
- Con respecto a los tiempos de colocación: querrá evitar capacidades iniciales que sean más bajas que el número esperado de entradas de mapas. Si se conoce un número exacto de antemano, ese número o algo ligeramente superior parece funcionar mejor. Los factores de carga altos pueden compensar las capacidades iniciales más bajas debido a cambios de tamaño anteriores del mapa hash. Para capacidades iniciales más altas, no parecen importar mucho.
- Con respecto a los tiempos de obtención: los resultados son un poco caóticos aquí. No hay mucho que concluir. Parece depender mucho de las relaciones sutiles entre la superposición del código hash, la capacidad inicial y el factor de carga, con algunas configuraciones supuestamente malas que funcionan bien y unas buenas que funcionan mal.
- Aparentemente estoy lleno de tonterías cuando se trata de suposiciones sobre el rendimiento de Java. La verdad es que, a menos que esté ajustando perfectamente su configuración a la implementación de
HashMap
, los resultados van a estar por todas partes. Si hay algo que se puede sacar de esto, es que el tamaño inicial predeterminado de 16 es un poco tonto para cualquier cosa que no sean los mapas más pequeños, así que use un constructor que establezca el tamaño inicial si tiene alguna idea sobre qué orden de tamaño Va a ser.
- Estamos midiendo en nanosegundos aquí. El mejor tiempo promedio por cada 10 put fue 1179 ns y el peor 5105 ns en mi máquina. El mejor tiempo promedio por cada 10 veces fue de 547 ns y el peor de 3484 ns. Esa puede ser una diferencia de factor 6, pero estamos hablando de menos de un milisegundo. En colecciones que son mucho más grandes de lo que tenía en mente el póster original.
Bueno, eso es todo. Espero que mi código no tenga un descuido terrible que invalide todo lo que he publicado aquí. Esto ha sido divertido y he aprendido que, al final, es mejor confiar en Java para hacer su trabajo que esperar mucha diferencia de pequeñas optimizaciones. Eso no quiere decir que no se deban evitar algunas cosas, pero luego estamos hablando principalmente de construir cadenas largas en bucles for, usar estructuras de datos incorrectas y hacer algoritmos O (n ^ 3).