Buscar..


Introducción

Este tema describe una serie de "errores" (es decir, los errores que cometen los programadores java novatos) que se relacionan con el rendimiento de la aplicación Java.

Observaciones

Este tema describe algunas prácticas de codificación "micro" de Java que son ineficientes. En la mayoría de los casos, las ineficiencias son relativamente pequeñas, pero aún así vale la pena evitarlas.

Pitfall - Los gastos generales de crear mensajes de registro

TRACE niveles de registro TRACE y DEBUG están ahí para poder transmitir detalles sobre el funcionamiento del código dado en tiempo de ejecución. Por lo general, se recomienda establecer el nivel de registro por encima de estos, sin embargo, se debe tener cuidado con estas afirmaciones para que no afecten el rendimiento, incluso cuando aparentemente están "desactivadas".

Considere esta declaración de registro:

// Processing a request of some kind, logging the parameters
LOG.debug("Request coming from " + myInetAddress.toString() 
          + " parameters: " + Arrays.toString(veryLongParamArray));

Incluso cuando el nivel de registro se establece en INFO , los argumentos pasados ​​a debug() se evaluarán en cada ejecución de la línea. Esto hace que consuma innecesariamente en varios aspectos:

  • Concatenación de String : se crearán múltiples instancias de String
  • InetAddress podría incluso hacer una búsqueda de DNS.
  • El veryLongParamArray puede ser muy largo: crear una cadena a partir de ella consume memoria, lleva tiempo

Solución

La mayoría de los marcos de registro proporcionan medios para crear mensajes de registro utilizando cadenas de arreglos y referencias de objetos. El mensaje de registro se evaluará solo si el mensaje está realmente registrado. Ejemplo:

// No toString() evaluation, no string concatenation if debug is disabled
LOG.debug("Request coming from {} parameters: {}", myInetAddress, parameters));

Esto funciona muy bien siempre que todos los parámetros se puedan convertir en cadenas usando String.valueOf (Object) . Si la composición del mensaje de registro es más compleja, el nivel de registro se puede verificar antes del registro:

if (LOG.isDebugEnabled()) {
    // Argument expression evaluated only when DEBUG is enabled
    LOG.debug("Request coming from {}, parameters: {}", myInetAddress,
              Arrays.toString(veryLongParamArray);
}

Aquí, LOG.debug() con el costoso Arrays.toString(Obect[]) se procesa solo cuando DEBUG está realmente habilitado.

Pitfall - La concatenación de cadenas en un bucle no se escala

Considere el siguiente código como una ilustración:

public String joinWords(List<String> words) {
    String message = "";
    for (String word : words) {
        message = message + " " + word;
    }
    return message;
}

Desafortunadamente, este código es ineficiente si la lista de words es larga. La raíz del problema es esta declaración:

message = message + " " + word;

Para cada iteración de bucle, esta declaración crea una nueva cadena de message contiene una copia de todos los caracteres en la cadena de message original con caracteres adicionales añadidos. Esto genera una gran cantidad de cadenas temporales, y hace una gran cantidad de copias.

Cuando analizamos joinWords , asumiendo que hay N palabras con una longitud promedio de M, encontramos que se crean cadenas temporales O (N) y se copiarán caracteres O (MN 2 ) en el proceso. El componente N 2 es particularmente preocupante.

El enfoque recomendado para este tipo de problema 1 es utilizar un StringBuilder lugar de la concatenación de cadenas de la siguiente manera:

public String joinWords2(List<String> words) {
    StringBuilder message = new StringBuilder();
    for (String word : words) {
        message.append(" ").append(word);
    }
    return message.toString();
}

El análisis de joinWords2 debe tener en cuenta los gastos generales de "hacer crecer" la matriz de respaldo StringBuilder que contiene los caracteres del constructor. Sin embargo, resulta que la cantidad de nuevos objetos creados es O (logN) y que la cantidad de caracteres copiados es O (MN). El último incluye caracteres copiados en la llamada final toString() .

(Puede ser posible sintonizar esto aún más, creando el StringBuilder con la capacidad correcta para comenzar. Sin embargo, la complejidad general sigue siendo la misma).

Volviendo al método original de joinWords , resulta que la declaración crítica será optimizada por un compilador típico de Java a algo como esto:

  StringBuilder tmp = new StringBuilder();
  tmp.append(message).append(" ").append(word);
  message = tmp.toString();

Sin embargo, el compilador de Java no "levantará" el StringBuilder fuera del bucle, como hicimos a mano en el código para joinWords2 .

Referencia:


1 - En Java 8 y Joiner posteriores, la clase Joiner puede usarse para resolver este problema en particular. Sin embargo, de eso no se trata realmente este ejemplo.

Pitfall: el uso de 'nuevo' para crear instancias de contenedor primitivas es ineficiente

El lenguaje Java le permite usar lo new para crear instancias Integer , Boolean , etc., pero generalmente es una mala idea. Es mejor usar el autoboxing (Java 5 y posterior) o el método valueOf .

 Integer i1 = new Integer(1);      // BAD
 Integer i2 = 2;                   // BEST (autoboxing)
 Integer i3 = Integer.valueOf(3);  // OK

La razón por la que el uso de un new Integer(int) explícitamente es una mala idea es que crea un nuevo objeto (a menos que el compilador JIT lo optimice). Por el contrario, cuando se usa el autoboxing o una llamada explícita a valueOf , el tiempo de ejecución de Java intentará reutilizar un objeto Integer desde un caché de objetos preexistentes. Cada vez que el tiempo de ejecución tiene un "hit" de caché, evita la creación de un objeto. Esto también ahorra memoria del montón y reduce los gastos generales del GC causados ​​por la rotación de objetos.

Notas:

  1. En las implementaciones recientes de Java, el autoboxing se implementa llamando a valueOf , y hay cachés para Boolean , Byte , Short , Integer , Long y Character .
  2. El comportamiento de almacenamiento en caché para los tipos integrales está ordenado por la especificación del lenguaje Java.

Pitfall - Llamar 'nueva cadena (String)' es ineficiente

Usar una new String(String) para duplicar una cadena es ineficiente y casi siempre es innecesario.

  • Los objetos de cadena son inmutables, por lo que no es necesario copiarlos para protegerse contra los cambios.
  • En algunas versiones anteriores de Java, los objetos String pueden compartir matrices de respaldo con otros objetos String . En esas versiones, es posible perder memoria creando una subcadena (pequeña) de una cadena (grande) y reteniéndola. Sin embargo, a partir de Java 7 en adelante, las matrices de respaldo de String no se comparten.

En ausencia de cualquier beneficio tangible, llamar new String(String) es simplemente un desperdicio:

  • Hacer la copia lleva tiempo de CPU.
  • La copia utiliza más memoria, lo que aumenta la huella de memoria de la aplicación y / o aumenta los gastos generales del GC.
  • Las operaciones como equals(Object) y hashCode() pueden ser más lentas si se copian los objetos String.

Pitfall - Calling System.gc () es ineficiente

Es (casi siempre) una mala idea llamar a System.gc() .

El javadoc para el método gc() especifica lo siguiente:

"Llamar al método gc sugiere que la Máquina Virtual de Java haga un esfuerzo por reciclar los objetos no utilizados para que la memoria que ocupan actualmente esté disponible para una reutilización rápida. Cuando el control regresa de la llamada al método, la Máquina Virtual de Java ha hecho un mejor esfuerzo para reclamar espacio de todos los objetos descartados ".

Hay un par de puntos importantes que se pueden extraer de esto:

  1. El uso de la palabra "sugiere" en lugar de (decir) "dice" significa que la JVM es libre de ignorar la sugerencia. El comportamiento predeterminado de la JVM (lanzamientos recientes) es seguir la sugerencia, pero esto puede -XX:+DisableExplicitGC configurando -XX:+DisableExplicitGC cuando se -XX:+DisableExplicitGC la JVM.

  2. La frase "un mejor esfuerzo para recuperar espacio de todos los objetos descartados" implica que llamar a gc activará una recolección de basura "completa".

Entonces, ¿por qué es una mala idea llamar a System.gc() ?

En primer lugar, ejecutar una recolección de basura completa es costoso. Un GC completo implica visitar y "marcar" todos los objetos a los que todavía se puede acceder; Es decir, todo objeto que no sea basura. Si dispara esto cuando no hay mucha basura que recoger, entonces el GC hace mucho trabajo por un beneficio relativamente pequeño.

En segundo lugar, una recolección de basura completa puede perturbar las propiedades de "localidad" de los objetos que no se recolectan. Los objetos que se asignan por el mismo subproceso casi al mismo tiempo tienden a asignarse juntos en la memoria. Esto es bueno. Es probable que los objetos que se asignan al mismo tiempo estén relacionados; es decir, hacer referencia entre sí. Si su aplicación utiliza esas referencias, es probable que el acceso a la memoria sea más rápido debido a los diversos efectos de la memoria y el almacenamiento en caché de la página. Desafortunadamente, una colección de basura completa tiende a mover objetos, de modo que los objetos que una vez estuvieron cerca ahora están más separados.

Tercero, la ejecución de una recolección de basura completa puede hacer que su aplicación se detenga hasta que se complete la recolección. Mientras esto suceda, su aplicación no responderá.

De hecho, la mejor estrategia es dejar que la JVM decida cuándo ejecutar el GC y qué tipo de colección ejecutar. Si no interfiere, la JVM elegirá un tiempo y un tipo de colección que optimice el rendimiento o minimice los tiempos de pausa del GC.


Al principio dijimos "... (casi siempre) una mala idea ...". De hecho, hay un par de escenarios en los que podría ser una buena idea:

  1. Si está implementando una prueba unitaria para algún código que es sensible a la recolección de basura (por ejemplo, algo que involucra finalizadores o referencias débiles / blandas / fantasmas), entonces puede ser necesario llamar a System.gc() .

  2. En algunas aplicaciones interactivas, puede haber puntos particulares en el tiempo donde el usuario no se preocupará si hay una pausa de recolección de basura. Un ejemplo es un juego donde hay pausas naturales en el "juego"; por ejemplo, cuando se carga un nuevo nivel.

Pitfall - El uso excesivo de tipos de envoltorios primitivos es ineficiente

Considera estas dos piezas de código:

int a = 1000;
int b = a + 1;

y

Integer a = 1000;
Integer b = a + 1;

Pregunta: ¿Qué versión es más eficiente?

Respuesta: Las dos versiones parecen casi idénticas, pero la primera es mucho más eficiente que la segunda.

La segunda versión está utilizando una representación para los números que ocupa más espacio, y se basa en el boxeo automático y el boxeo automático entre bastidores. De hecho, la segunda versión es directamente equivalente al siguiente código:

Integer a = Integer.valueOf(1000);               // box 1000
Integer b = Integer.valueOf(a.intValue() + 1);   // unbox 1000, add 1, box 1001

Comparando esto con la otra versión que usa int , hay claramente tres llamadas de método adicionales cuando se usa Integer . En el caso de valueOf , cada llamada creará e inicializará un nuevo objeto Integer . Es probable que todo este trabajo extra de boxeo y desempaquetado haga que la segunda versión sea un orden de magnitud más lenta que la primera.

Además de eso, la segunda versión está asignando objetos en el montón en cada llamada valueOf . Si bien la utilización del espacio es específica de la plataforma, es probable que se encuentre en la región de 16 bytes para cada objeto Integer . En contraste, la versión int necesita cero espacio de almacenamiento adicional, asumiendo que a y b son variables locales.


Otra razón importante por la que los primitivos son más rápidos que sus equivalentes en caja es la forma en que sus respectivos tipos de matrices se presentan en la memoria.

Si toma int[] y Integer[] como ejemplo, en el caso de int[] los valores int se establecen de forma contigua en la memoria. Pero en el caso de un Integer[] no son los valores que se presentan, sino las referencias (punteros) a los objetos Integer , que a su vez contienen los valores int reales.

Además de ser un nivel adicional de direccionamiento indirecto, este puede ser un gran tanque cuando se trata de la localidad de caché cuando se itera sobre los valores. En el caso de un int[] la CPU podría obtener todos los valores de la matriz, en su caché a la vez, porque son contiguos en la memoria. Pero en el caso de un Integer[] la CPU potencialmente tiene que hacer una recuperación de memoria adicional para cada elemento, ya que la matriz solo contiene referencias a los valores reales.


En resumen, el uso de tipos primitivos de envoltura es relativamente costoso tanto en recursos de CPU como de memoria. Su uso innecesario es eficiente.

Pitfall - Iterar las claves de un mapa puede ser ineficiente

El siguiente código de ejemplo es más lento de lo que debe ser:

Map<String, String> map = new HashMap<>(); 
for (String key : map.keySet()) {
    String value = map.get(key);
    // Do something with key and value
}

Esto se debe a que requiere una búsqueda en el mapa (el método get() ) para cada clave en el mapa. Es posible que esta búsqueda no sea eficiente (en un HashMap, implica llamar a hashCode en la clave, luego buscar el depósito correcto en las estructuras de datos internas y, a veces, incluso llamar a equals ). En un mapa grande, esto puede no ser una sobrecarga trivial.

La forma correcta de evitar esto es iterar en las entradas del mapa, que se detalla en el tema Colecciones.

Pitfall: el uso de size () para comprobar si una colección está vacía es ineficiente.

El Java Collections Framework proporciona dos métodos relacionados para todos los objetos de la Collection :

  • size() devuelve el número de entradas en una Collection , y
  • isEmpty() método isEmpty() devuelve verdadero si (y solo si) la Collection está vacía.

Ambos métodos se pueden utilizar para probar el vacío de la colección. Por ejemplo:

Collection<String> strings = new ArrayList<>();
boolean isEmpty_wrong = strings.size() == 0; // Avoid this
boolean isEmpty = strings.isEmpty();         // Best

Si bien estos enfoques parecen iguales, algunas implementaciones de colección no almacenan el tamaño. Para tal colección, la implementación de size() necesita calcular el tamaño cada vez que se llama. Por ejemplo:

  • Una clase de lista vinculada simple (pero no el java.util.LinkedList ) puede necesitar atravesar la lista para contar los elementos.
  • La clase ConcurrentHashMap necesita sumar las entradas en todos los "segmentos" del mapa.
  • Una implementación perezosa de una colección podría necesitar realizar la colección completa en la memoria para contar los elementos.

Por el contrario, un método isEmpty() solo necesita probar si hay al menos un elemento en la colección. Esto no implica contar los elementos.

Si bien size() == 0 no siempre es menos eficiente que isEmpty() , es inconcebible que un isEmpty() implementado correctamente sea menos eficiente que size() == 0 . Por isEmpty() tanto, se prefiere isEmpty() .

Pitfall - Problemas de eficiencia con expresiones regulares

La coincidencia de expresiones regulares es una herramienta poderosa (en Java y en otros contextos) pero tiene algunos inconvenientes. Uno de estos que las expresiones regulares tiende a ser bastante caro.

Las instancias de Pattern y Matcher deben ser reutilizadas

Considere el siguiente ejemplo:

/**
 * Test if all strings in a list consist of English letters and numbers.
 * @param strings the list to be checked
 * @return 'true' if an only if all strings satisfy the criteria
 * @throws NullPointerException if 'strings' is 'null' or a 'null' element.
 */
public boolean allAlphanumeric(List<String> strings) {
    for (String s : strings) {
        if (!s.matches("[A-Za-z0-9]*")) {
            return false;
        }  
    }
    return true;
}

Este código es correcto, pero es ineficiente. El problema está en la matches(...) llamada. Bajo el capó, s.matches("[A-Za-z0-9]*") es equivalente a esto:

Pattern.matches(s, "[A-Za-z0-9]*")

que a su vez es equivalente a

Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()

La Pattern.compile("[A-Za-z0-9]*") analiza la expresión regular, la analiza y construye un objeto Pattern que contiene la estructura de datos que utilizará el motor de expresiones regulares. Este es un cálculo no trivial. Luego se crea un objeto Matcher para envolver el argumento s . Finalmente, llamamos match() para hacer la coincidencia de patrón real.

El problema es que todo este trabajo se repite para cada iteración de bucle. La solución es reestructurar el código de la siguiente manera:

private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");

public boolean allAlphanumeric(List<String> strings) {
    Matcher matcher = ALPHA_NUMERIC.matcher("");
    for (String s : strings) {
        matcher.reset(s);
        if (!matcher.matches()) {
            return false;
        }  
    }
    return true;
}

Tenga en cuenta que el javadoc para los estados de Pattern :

Las instancias de esta clase son inmutables y son seguras para el uso de múltiples subprocesos simultáneos. Las instancias de la clase Matcher no son seguras para tal uso.

No uses match () cuando deberías usar find ()

Supongamos que desea probar si una cadena s contiene tres o más dígitos seguidos. Usted puede expresar esto de varias maneras, incluyendo:

  if (s.matches(".*[0-9]{3}.*")) {
      System.out.println("matches");
  }

o

  if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
      System.out.println("matches");
  }

El primero es más conciso, pero también es probable que sea menos eficiente. A primera vista, la primera versión intentará hacer coincidir toda la cadena con el patrón. Además, dado que ". *" Es un patrón "codicioso", es probable que el emparejador del patrón avance "con entusiasmo" hasta el final de la cadena y retroceda hasta que encuentre una coincidencia.

Por el contrario, la segunda versión buscará de izquierda a derecha y dejará de buscar tan pronto como encuentre los 3 dígitos seguidos.

Usar alternativas más eficientes a las expresiones regulares.

Las expresiones regulares son una herramienta poderosa, pero no deberían ser su única herramienta. Muchas tareas se pueden hacer de manera más eficiente de otras maneras. Por ejemplo:

 Pattern.compile("ABC").matcher(s).find()

hace lo mismo que:

 s.contains("ABC")

Excepto que este último es mucho más eficiente. (Incluso si puede amortizar el costo de compilar la expresión regular).

A menudo, la forma no regex es más complicada. Por ejemplo, la prueba realizada por la matches() llama al método allAlplanumeric anterior allAlplanumeric puede reescribirse como:

 public boolean matches(String s) {
     for (char c : s) {
         if ((c >= 'A' && c <= 'Z') ||
             (c >= 'a' && c <= 'z') ||
             (c >= '0' && c <= '9')) {
              return false;
         }
     }
     return true;
 }

Ahora es más código que usar un Matcher , pero también va a ser mucho más rápido.

Retroceso catastrófico

(Esto es potencialmente un problema con todas las implementaciones de expresiones regulares, pero lo mencionaremos aquí porque es un escollo para Pattern uso del Pattern ).

Considere este ejemplo (artificial):

Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());

La primera println llamada imprimir rápidamente true . El segundo imprimirá false . Finalmente. De hecho, si experimenta con el código anterior, verá que cada vez que agregue una A antes de la C , el tiempo se duplicará.

Este es el comportamiento es un ejemplo de retroceso catastrófico . El motor de coincidencia de patrones que implementa la coincidencia de expresiones regulares está probando infructuosamente todas las formas posibles en que el patrón podría coincidir.

Veamos lo que realmente significa (A+)+B Superficialmente, parece decir "uno o más caracteres A seguidos de un valor B ", pero en realidad dice uno o más grupos, cada uno de los cuales consta de uno o más caracteres A Así por ejemplo:

  • 'AB' solo coincide de una manera: '(A) B'
  • 'AAB' coincide de dos maneras: '(AA) B' o '(A) (A) B`
  • 'AAAB' coincide de cuatro maneras: '(AAA) B' o '(AA) (A) B or '(A)(AA)B o '(A) (A) (A) B`
  • y así

En otras palabras, el número de posibles coincidencias es 2 N, donde N es el número de caracteres A

El ejemplo anterior está claramente diseñado, pero los patrones que muestran este tipo de características de rendimiento (es decir, O(2^N) u O(N^K) para un K grande aparecen con frecuencia cuando se utilizan expresiones regulares mal consideradas. Hay algunos remedios estándar:

  • Evite anidar patrones repetitivos dentro de otros patrones repetitivos.
  • Evite utilizar demasiados patrones de repetición.
  • Use la repetición sin retroceso según sea apropiado.
  • No utilice expresiones regulares para tareas de análisis complicadas. (Escriba un analizador adecuado en su lugar.)

Finalmente, tenga cuidado con las situaciones en las que un usuario o un cliente de API puede suministrar una cadena de expresiones regulares con características patológicas. Eso puede llevar a una "denegación de servicio" accidental o deliberada.

Referencias:

Pitfall - Interning Strings para que puedas usar == es una mala idea

Cuando algunos programadores ven este consejo:

"Probar cadenas usando == es incorrecto (a menos que las cadenas estén internadas)"

su reacción inicial es aplicar cadenas internas para que puedan usar == . (Después de todo == es más rápido que llamar a String.equals(...) , ¿no es así?)

Este es el enfoque equivocado, desde una serie de perspectivas:

Fragilidad

En primer lugar, solo puede usar de forma segura == si sabe que todos los objetos String que está probando han sido internados. El JLS garantiza que los literales de cadena en su código fuente se habrán internado. Sin embargo, ninguna de las API de Java SE estándar garantiza devolver cadenas internadas, aparte de String.intern(String) . Si pierde solo una fuente de objetos String que no han sido internados, su aplicación no será confiable. Esa falta de fiabilidad se manifestará como falsos negativos en lugar de excepciones que pueden dificultar su detección.

Costos de usar 'intern ()'

Bajo el capó, el internado funciona manteniendo una tabla hash que contiene objetos String previamente internados. Se utiliza algún tipo de mecanismo de referencia débil para que la tabla hash de internado no se convierta en una fuga de almacenamiento. Mientras que la tabla hash se implementa en código nativo (a diferencia de HashMap , HashTable y así sucesivamente), los intern llamadas son todavía relativamente costoso en términos de CPU y de memoria utilizadas.

Este costo debe compararse con el ahorro que obtendremos utilizando == lugar de equals . De hecho, no vamos a interrumpir el equilibrio a menos que cada cadena internada se compare unas pocas veces "varias veces".

(Aparte: las pocas situaciones en las que vale la pena realizar una pasantía tienden a ser reducir la huella de memoria de una aplicación donde las mismas cadenas se repiten muchas veces, y esas cadenas tienen una larga vida útil).

El impacto en la recolección de basura.

Además de los costos directos de CPU y memoria descritos anteriormente, las cadenas internas afectan el rendimiento del recolector de basura.

Para las versiones de Java anteriores a Java 7, las cadenas internas se mantienen en el espacio "PermGen" que se recopila con poca frecuencia. Si es necesario recopilar PermGen, esto (normalmente) activa una recolección de basura completa. Si el espacio de PermGen se llena completamente, la JVM se bloquea, incluso si había espacio libre en los espacios de almacenamiento dinámico normales.

En Java 7, el grupo de cadenas se movió de "PermGen" al montón normal. Sin embargo, la tabla hash seguirá siendo una estructura de datos de larga duración, lo que hará que las cadenas internas sean de larga duración. (Incluso si los objetos de cadena internados se asignaran en el espacio del Edén, probablemente se promoverían antes de ser recolectados).

Por lo tanto, en todos los casos, internar una cadena prolongará su tiempo de vida en relación con una cadena normal. Eso aumentará los gastos generales de recolección de basura durante la vida útil de la JVM.

El segundo problema es que la tabla hash necesita usar un mecanismo de referencia débil de algún tipo para evitar que la cadena pierda la memoria interna. Pero tal mecanismo es más trabajo para el recolector de basura.

Estos gastos generales de recolección de basura son difíciles de cuantificar, pero existen pocas dudas de que existan. Si usas intern extensivamente, podrían ser importantes.

El tamaño de hashtable del grupo de cadenas

De acuerdo con esta fuente , desde Java 6 en adelante, el conjunto de cadenas se implementa como una tabla hash de tamaño fijo con cadenas para tratar con las cadenas que se agrupan en el mismo grupo. En las primeras versiones de Java 6, la tabla hash tenía un tamaño constante (cableado). Se agregó un parámetro de ajuste ( -XX:StringTableSize ) como una actualización de mitad de vida a Java 6. Luego, en una actualización de mitad de vida a Java 7, el tamaño predeterminado de la agrupación se cambió de 1009 a 60013 .

La conclusión es que si tiene la intención de utilizar intern en su código, es recomendable elegir una versión de Java en la que el tamaño de la tabla hash sea ajustable y asegurarse de que ajusta el tamaño de manera adecuada. De lo contrario, el rendimiento del intern puede degradarse a medida que el grupo se hace más grande.

Interning como un potencial vector de denegación de servicio

El algoritmo de hashcode para cadenas es bien conocido. Si internas cadenas proporcionadas por usuarios o aplicaciones malintencionados, esto podría usarse como parte de un ataque de denegación de servicio (DoS). Si el agente malicioso dispone que todas las cadenas que proporciona tienen el mismo código hash, esto podría llevar a una tabla hash no balanceada y un rendimiento O(N) para el intern ... donde N es el número de cadenas colisionadas.

(Hay formas más simples / más efectivas de lanzar un ataque DoS contra un servicio. Sin embargo, este vector podría usarse si el objetivo del ataque DoS es romper la seguridad o evadir las defensas DoS de primera línea).

Pitfall - Las lecturas / escrituras pequeñas en flujos no almacenados en búfer son ineficientes

Considere el siguiente código para copiar un archivo a otro:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new FileInputStream(args[0]);
             OutputStream os = new FileOutputStream(args[1])) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

(Hemos deliberado omitido la verificación normal de los argumentos, el informe de errores, etc., ya que no son relevantes para el punto de este ejemplo).

Si compila el código anterior y lo utiliza para copiar un archivo enorme, notará que es muy lento. De hecho, será al menos un par de órdenes de magnitud más lento que las utilidades estándar de copia de archivos del sistema operativo.

Añada las mediciones de rendimiento reales aquí! )

La razón principal por la que el ejemplo anterior es lento (en el caso del archivo grande) es que está realizando lecturas de un byte y escrituras de un byte en flujos de bytes sin búfer. La forma sencilla de mejorar el rendimiento es envolver las secuencias con secuencias almacenadas en búfer. Por ejemplo:

import java.io.*;

public class FileCopy {

    public static void main(String[] args) throws Exception {
        try (InputStream is = new BufferedInputStream(
                     new FileInputStream(args[0]));
             OutputStream os = new BufferedOutputStream(
                     new FileOutputStream(args[1]))) {
           int octet;
           while ((octet = is.read()) != -1) {
               os.write(octet);
           }
        }
    }
}

Estos pequeños cambios mejorarán la velocidad de copia de datos en al menos un par de órdenes de magnitud, dependiendo de diversos factores relacionados con la plataforma. Las envolturas de flujo almacenadas en búfer hacen que los datos se lean y escriban en trozos más grandes. Ambas instancias tienen buffers implementados como matrices de bytes.

  • Con is , los datos se leen del archivo en el búfer unos pocos kilobytes a la vez. Cuando se llama a read() , la implementación normalmente devolverá un byte desde el búfer. Solo se leerá de la secuencia de entrada subyacente si el búfer se ha vaciado.

  • El comportamiento para os es análogo. Las llamadas a os.write(int) escriben bytes únicos en el búfer. Los datos solo se escriben en el flujo de salida cuando el búfer está lleno, o cuando os se vacía o se cierra.

¿Qué pasa con las corrientes basadas en caracteres?

Como debe saber, Java I / O proporciona diferentes API para leer y escribir datos binarios y de texto.

  • InputStream y OutputStream son las API base para E / S binarias basadas en flujo
  • Reader y Writer son las API básicas para la E / S de texto basada en flujo.

Para texto de E / S, BufferedReader y BufferedWriter son los equivalentes de BufferedInputStream y BufferedOutputStream .

¿Por qué los flujos amortiguados hacen tanta diferencia?

La verdadera razón por la que las transmisiones en búfer ayudan al rendimiento es la forma en que una aplicación habla con el sistema operativo:

  • El método Java en una aplicación Java o las llamadas a procedimientos nativos en las bibliotecas de tiempo de ejecución nativas de la JVM son rápidas. Por lo general, toman un par de instrucciones de la máquina y tienen un impacto mínimo en el rendimiento.

  • Por el contrario, las llamadas en tiempo de ejecución de JVM al sistema operativo no son rápidas. Implican algo conocido como "syscall". El patrón típico para un syscall es el siguiente:

    1. Ponga los argumentos de syscall en los registros.
    2. Ejecutar una instrucción de trampa SYSENTER.
    3. El manejador de trampas cambió a un estado privilegiado y cambia las asignaciones de memoria virtual. Luego se envía al código para manejar el syscall específico.
    4. El controlador syscall comprueba los argumentos, teniendo cuidado de que no se le diga que acceda a la memoria que el proceso del usuario no debería ver.
    5. Se realiza el trabajo específico de syscall. En el caso de un syscall de read , esto puede implicar:
      1. verificar que haya datos para leer en la posición actual del descriptor de archivo
      2. llamar al controlador del sistema de archivos para obtener los datos requeridos del disco (o donde sea que estén almacenados) en el caché del búfer,
      3. copiar datos desde la memoria caché del búfer a la dirección proporcionada por la JVM
      4. Ajuste de la posición del descriptor de archivos de Pointstream
    6. Regreso del syscall. Esto implica cambiar de nuevo las asignaciones de VM y cambiar de estado privilegiado.

Como se puede imaginar, realizar una sola llamada puede miles de instrucciones de la máquina. De manera conservadora, al menos dos órdenes de magnitud más largos que una llamada de método regular. (Probablemente tres o más).

Teniendo en cuenta esto, la razón por la que los flujos de búferes hacen una gran diferencia es que reducen drásticamente el número de syscalls. En lugar de hacer un syscall para cada llamada de read() , la secuencia de entrada almacenada en el búfer lee una gran cantidad de datos en un búfer según se requiera. La mayoría de las llamadas de read() en el flujo almacenado en búfer realizan algunas comprobaciones de límites simples y devuelven un byte que se leyó anteriormente. Un razonamiento similar se aplica en el caso del flujo de salida, y también en los casos del flujo de caracteres.

(Algunas personas piensan que el rendimiento de E / S en búfer proviene de la falta de coincidencia entre el tamaño de la solicitud de lectura y el tamaño de un bloque de disco, la latencia de rotación del disco y cosas así. De hecho, un sistema operativo moderno utiliza una serie de estrategias para garantizar que la aplicación normalmente no necesita esperar por el disco. Esta no es la explicación real.)

¿Las transmisiones amortiguadas son siempre una victoria?

No siempre. Las transmisiones en búfer son definitivamente una ganancia si su aplicación va a hacer muchas "pequeñas" lecturas o escrituras. Sin embargo, si su aplicación solo necesita realizar lecturas grandes o escrituras a / desde un byte[] grande byte[] o char[] , las secuencias con búfer no le brindarán beneficios reales. De hecho, incluso podría haber una (pequeña) penalización de rendimiento.

¿Es esta la forma más rápida de copiar un archivo en Java?

No, no lo es. Cuando utiliza las API basadas en flujo de Java para copiar un archivo, incurre en el costo de al menos una copia extra de memoria a memoria de los datos. Es posible evitar esto si utiliza las API de NIO ByteBuffer y Channel . ( Agregue un enlace a un ejemplo separado aquí ) .



Modified text is an extract of the original Stack Overflow Documentation
Licenciado bajo CC BY-SA 3.0
No afiliado a Stack Overflow