java java-8 domain-driven-design java-stream domain-model

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:

  • Collection.forEach

    final E[] elementData = (E[]) this.elementData; final int size = this.size; for (int i=0; modCount == expectedModCount && i < size; i++) { action.accept(elementData[i]); }

  • Iterator.forEachRemaining

    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 a Spliterator.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 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.

  1. 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() sin hasNext() , o hasNext() varias veces sin next() , 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 la next / hasNext dualidad es fundamentalmente picante, y las implementaciones de Iterator tienen que trabajar más para defenderse de las modificaciones simultáneas que las implementaciones de Spliterator ).

  2. 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.

  3. Además, el recorrido a través de Spliterator tiende a tener muchas menos escrituras en montón que con Iterator . Con Iterator , cada elemento causa una o más escrituras en montón (a menos que el Iterator 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, los Spliterators tienden a tener menos estado, y la resistencia forEachRemaining implementaciones forEachRemaining 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).