Iterator versus Stream of Java 8
java stream map (2)
Para aprovechar la amplia gama de métodos de consulta incluidos en java.util.stream
de Jdk 8, intento diseñar modelos de dominio en los que los buscadores de relación con *
multiplicidad (con cero o más instancias) devuelvan un Stream<T>
, en lugar de un Iterable<T>
o un Iterator<T>
.
Mi duda es si hay alguna sobrecarga adicional incurrida por el Stream<T>
en comparación con el Iterator<T>
?
Entonces, ¿hay alguna desventaja de comprometer mi modelo de dominio con un Stream<T>
?
O, en su lugar, ¿siempre debo devolver un Iterator<T>
o Iterable<T>
, y dejarle al usuario final la decisión de elegir si usar una transmisión, o no, convirtiendo ese iterador con StreamUtils
?
Tenga en cuenta que devolver una Collection
no es una opción válida porque en este caso la mayoría de las relaciones son flojas y de tamaño desconocido.
Comparemos la operación común de iterar sobre todos los elementos, suponiendo que la fuente es una ArrayList
. Entonces, hay tres formas estándar para lograr esto:
-
final E[] elementData = (E[]) this.elementData; final int size = this.size; for (int i=0; modCount == expectedModCount && i < size; i++) { action.accept(elementData[i]); }
-
final Object[] elementData = ArrayList.this.elementData; if (i >= elementData.length) { throw new ConcurrentModificationException(); } while (i != size && modCount == expectedModCount) { consumer.accept((E) elementData[i++]); }
Stream.forEach
que terminará llamando aSpliterator.forEachRemaining
if ((i = index) >= 0 && (index = hi) <= a.length) { for (; i < hi; ++i) { @SuppressWarnings("unchecked") E e = (E) a[i]; action.accept(e); } if (lst.modCount == mc) return; }
Como puede ver, el bucle interno del código de implementación, donde terminan estas operaciones, es básicamente el mismo, iterando sobre índices y leyendo directamente la matriz y pasando el elemento al Consumer
.
Cosas similares se aplican a todas las colecciones estándar de JRE, todas ellas tienen implementaciones adaptadas para todas las formas de hacerlo, incluso si está utilizando un contenedor de solo lectura. En este último caso, la Stream
API incluso ganaría ligeramente, Collection.forEach
debe ser llamada en la vista de solo lectura para delegar en la colección original para cada forEach
. Del mismo modo, el iterador debe estar envuelto para proteger contra intentos de invocar el método remove()
. Por el contrario, spliterator()
puede devolver directamente el Spliterator
la colección original ya que no admite modificaciones. Por lo tanto, la secuencia de una vista de solo lectura es exactamente la misma que la secuencia de la colección original.
Aunque todas estas diferencias apenas se notan al medir el rendimiento de la vida real como, como se dijo, el ciclo interno , que es lo más relevante para el rendimiento, es el mismo en todos los casos.
La pregunta es qué conclusión extraer de eso. Aún puede devolver una vista de contenedor de solo lectura a la colección original, ya que la persona que llama aún puede invocar a stream().forEach(…)
para iterar directamente en el contexto de la colección original.
Dado que el rendimiento no es realmente diferente, debería centrarse en el diseño de nivel superior, como se explica en "¿Debo devolver una Colección o un Stream?"
Hay muchos consejos de rendimiento aquí, pero lamentablemente gran parte de esto es conjeturas, y poco de eso apunta a las consideraciones de rendimiento real.
@Holger lo hace bien al señalar que debemos resistir la tendencia aparentemente abrumadora de dejar que la cola de rendimiento agite al perro de diseño API.
Si bien hay un trillón de consideraciones que pueden hacer que una transmisión sea más lenta, igual o más rápida que cualquier otra forma de cruce en cualquier caso dado, hay algunos factores que apuntan a que las transmisiones tienen una ventaja de rendimiento donde cuentan: en grandes conjuntos de datos.
Hay algunos gastos fijos adicionales de inicio de la creación de un Stream
comparación con la creación de un Iterator
, unos pocos objetos más antes de comenzar a calcular. Si su conjunto de datos es grande, no importa; es un pequeño costo de inicio amortizado en una gran cantidad de cálculos. (Y si su conjunto de datos es pequeño, probablemente tampoco importe, porque si su programa está funcionando en pequeños conjuntos de datos, el rendimiento generalmente tampoco es su preocupación principal). Lo que sí importa es cuando va en paralelo; cualquier tiempo dedicado a configurar la tubería entra en la fracción en serie de la ley de Amdahl; si nos fijamos en la implementación, trabajamos duro para mantener la cuenta regresiva del objeto durante la configuración del flujo, pero me gustaría encontrar formas de reducirla, ya que tiene un efecto directo en el tamaño del conjunto de datos de punto de equilibrio donde el paralelo comienza a ganar secuencial.
Pero, más importante que el costo de inicio fijo es el costo de acceso por elemento. Aquí, las transmisiones en realidad ganan, ya menudo ganan a lo grande, lo que algunos pueden sorprender. (En nuestras pruebas de rendimiento, rutinariamente vemos flujos de transmisión que pueden superar a sus contrapartidas de Collection
). Y hay una explicación simple para esto: Spliterator
tiene costos de acceso por elemento fundamentalmente menores que Iterator
, incluso secuencialmente. Hay varias razones para esto.
El protocolo Iterator es fundamentalmente menos eficiente. Requiere llamar a dos métodos para obtener cada elemento. Además, como los iteradores deben ser robustos a cosas como llamar a
next()
sinhasNext()
, ohasNext()
varias veces sinnext()
, ambos métodos generalmente tienen que hacer una codificación defensiva (y generalmente más statefulness y bifurcación), que se suma a la ineficiencia. Por otro lado, incluso la forma lenta de atravesar un spliterator (tryAdvance
) no tiene esta carga. (Es incluso peor para las estructuras de datos concurrentes, porque lanext
/hasNext
dualidad es fundamentalmente picante, y las implementaciones deIterator
tienen que trabajar más para defenderse de las modificaciones simultáneas que las implementaciones deSpliterator
).Además,
Spliterator
ofrece una iteración de "vía rápida" -forEachRemaining
- que se puede usar la mayor parte del tiempo (reducción, paraEach), reduciendo aún más la sobrecarga del código de iteración que media el acceso a las partes internas de la estructura de datos. Esto también tiende a alinearse muy bien, lo que a su vez aumenta la efectividad de otras optimizaciones como el movimiento del código, la eliminación de cheques de límites, etc.Además, el recorrido a través de
Spliterator
tiende a tener muchas menos escrituras en montón que conIterator
. ConIterator
, cada elemento causa una o más escrituras en montón (a menos que elIterator
se pueda escalar mediante análisis de escape y sus campos en registros). Entre otros problemas, esto causa actividad de marca de tarjeta GC, lo que lleva a la contención de la línea de caché para las marcas de tarjeta. Por otro lado, losSpliterators
tienden a tener menos estado, y la resistenciaforEachRemaining
implementacionesforEachRemaining
tiende a diferir la escritura de todo al montón hasta el final del recorrido, en lugar de almacenar su estado de iteración en los locales que se asignan naturalmente a los registros, lo que resulta en memoria reducida actividad del autobús
Resumen: no te preocupes, sé feliz. Spliterator
es un mejor Iterator
, incluso sin paralelismo. (Por lo general, también son más fáciles de escribir y más difíciles de equivocarse).