Capítulo 1. Introducción Introducción
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Este es un libro sobre el arte y la ciencia del rendimiento de Java.
La parte científica de esta afirmación no es sorprendente; las discusiones sobre el rendimiento incluyen muchos números, mediciones y análisis. La mayoría de los ingenieros de rendimiento tienen formación en ciencias, y aplicar el rigor científico es una parte crucial para lograr el máximo rendimiento.
¿Qué hay de la parte artística? La idea de que el ajuste del rendimiento es en parte arte y en parte ciencia no es nueva, pero rara vez se reconoce explícitamente en los debates sobre rendimiento. Esto se debe en parte a que la idea de "arte" va en contra de nuestra formación. Pero lo que a algunas personas les parece arte se basa fundamentalmente en un profundo conocimiento y experiencia. Se dice que la magia es indistinguible de las tecnologías suficientemente avanzadas, y sin duda es cierto que un teléfono móvil le parecería mágico a un caballero de la Mesa Redonda. Del mismo modo, el trabajo realizado por un buen ingeniero de rendimiento puede parecer arte, pero ese arte es en realidad una aplicación de conocimientos profundos, experiencia e intuición.
Este libro no puede ayudarte con la parte de experiencia e intuición de esa ecuación, pero puede proporcionarte los conocimientos profundos, con la visión de que aplicar los conocimientos a lo largo del tiempo te ayudará a desarrollar las habilidades necesarias para ser un buen ingeniero de rendimiento de Java. El objetivo es proporcionarte un conocimiento profundo de los aspectos de rendimiento de la plataforma Java.
Estos conocimientos se dividen en dos grandes categorías.La primera es el rendimiento de la propia Máquina Virtual Java (JVM): la forma en que se configura la JVM afecta a muchos aspectos del rendimiento de un programa. Los desarrolladores con experiencia en otros lenguajes pueden encontrar algo molesta la necesidad de afinar, aunque en realidad afinar la JVM es completamente análogo a probar y elegir las banderas del compilador durante la compilación para los programadores de C++, o a establecer las variables apropiadas en un archivo php.ini para los programadores de PHP, etc.
El segundo aspecto es comprender cómo afectan al rendimiento las características de la plataforma Java. Fíjate en el uso de la palabra plataforma aquí: algunas características (por ejemplo, los hilos y la sincronización) forman parte del lenguaje, y otras (por ejemplo, el manejo de cadenas) forman parte de la API estándar de Java. Aunque existen distinciones importantes entre el lenguaje Java y la API de Java, en este caso se tratarán de forma similar. Este libro cubre ambas facetas de la plataforma.
El rendimiento de la JVM se basa en gran medida en el ajuste de las banderas, mientras que el rendimiento de la plataforma se determina más por el uso de buenas prácticas dentro del código de tu aplicación. Durante mucho tiempo, se consideraron áreas de especialización separadas: los desarrolladores codifican, y el grupo de rendimiento comprueba y recomienda correcciones para los problemas de rendimiento. Nunca fue una distinción especialmente útil: cualquiera que trabaje con Java debería ser igualmente experto en comprender cómo se comporta el código en la JVM y qué tipos de ajustes pueden mejorar su rendimiento. A medida que los proyectos pasan a un modelo devops, esta distinción empieza a ser menos estricta. El conocimiento de la esfera completa es lo que dará a tu trabajo la pátina del arte.
Breve resumen
Pero lo primero es lo primero: El Capítulo 2 trata de las metodologías generales para probar las aplicaciones Java, incluidos los escollos de la evaluación comparativa de Java. Dado que el análisis del rendimiento requiere visibilidad sobre lo que está haciendo la aplicación, el Capítulo 3 ofrece una visión general de algunas de las herramientas disponibles para monitorizar aplicaciones Java.
Luego llega el momento de sumergirse en el rendimiento, centrándose primero en aspectos comunes de ajuste: compilación just-in-time(Capítulo 4) y recogida de basura(Capítulo 5 y Capítulo 6). Los capítulos restantes se centran en las mejores prácticas de uso de diversas partes de la plataforma Java: uso de la memoria con el montón de Java(Capítulo 7), uso de la memoria nativa(Capítulo 8), rendimiento de los hilos(Capítulo 9), tecnología de servidor Java(Capítulo 10), acceso a bases de datos,(Capítulo 11) y consejos generales sobre la API de Java SE(Capítulo 12).
Enel Apéndice Ase enumeran todos los indicadores de ajuste tratados en este libro, con referencias cruzadas al capítulo en el que se examinan.
Plataformas y Convenios
Aunque este libro trata sobre el rendimiento de Java, ese rendimiento se verá influido por algunos factores: la propia versión de Java, por supuesto, así como las plataformas de hardware y software en las que se ejecute.
Plataformas Java
Este libro cubre el rendimiento de la Máquina Virtual Java (JVM) Oracle HotSpot y el Kit de Desarrollo Java (JDK) , versiones 8 y 11. También se conoce como Java Standard Edition (SE). El Entorno de Ejecución de Java (JRE) es un subconjunto del JDK que sólo contiene la JVM, pero como las herramientas del JDK son importantes para el análisis del rendimiento, el JDK es el tema central de este libro. En la práctica, eso significa que también cubre las plataformas derivadas del repositorio OpenJDK de esa tecnología, que incluye las JVM liberadas del proyecto AdoptOpenJDK. En sentido estricto, los binarios de Oracle requieren una licencia para su uso en producción, y los binarios de AdoptOpenJdK vienen con una licencia de código abierto. Para nuestros propósitos, consideraremos que las dos versiones son la misma cosa, a la que nos referiremos como JDK o plataforma Java.1
Estas versiones han pasado por varias versiones de corrección de errores. Mientras escribo esto, la versión actual de Java 8 es jdk8u222 (versión 222), y la versión actual de Java 11 es 11.0.5. Es importante utilizar al menos estas versiones (si no posteriores), sobre todo en el caso de Java 8. Las primeras versiones de Java 8 (hasta aproximadamente jdk8u60) no contienen muchas de las importantes mejoras de rendimiento y características que se tratan a lo largo de este libro (sobre todo en lo que se refiere a la recolección de basura y al recolector de basura G1).
Estas versiones del JDK se seleccionaron porque cuentan con soporte a largo plazo (LTS) de Oracle. La comunidad Java es libre de desarrollar sus propios modelos de soporte, pero hasta ahora han seguido el modelo de Oracle. Así que estas versiones tendrán soporte y estarán disponibles durante bastante tiempo: al menos hasta 2023 para Java 8 (a través de AdoptOpenJDK; más tarde a través de contratos ampliados de soporte de Oracle), y al menos hasta 2022 para Java 11. La próxima versión a largo plazo está prevista para finales de 2021.
En cuanto a las versiones intermedias, el debate sobre Java 11 incluye obviamente características que estuvieron disponibles por primera vez en Java 9 o Java 10, aunque esas versiones no cuenten con el apoyo de Oracle ni de la comunidad en general. De hecho, soy algo impreciso al hablar de esas características; puede parecer que digo que las características X e Y se incluyeron originalmente en Java 11 cuando pueden haber estado disponibles en Java 9 o 10. Java 11 es la primera versión LTS que incluye esas funciones, y esa es la parte importante: puesto que Java 9 y 10 no están en uso, no importa realmente cuándo apareció la función por primera vez. Del mismo modo, aunque Java 13 estará disponible en el momento de la publicación de este libro, no hay mucha cobertura de Java 12 o Java 13. Puedes utilizar esas versiones en producción, pero sólo durante seis meses, después de los cuales tendrás que actualizarte a una nueva versión (así que cuando estés leyendo esto, Java 12 ya no será compatible, y si Java 13 es compatible, pronto será sustituido por Java 14). Echaremos un vistazo a algunas características de estas versiones provisionales, pero dado que no es probable que estas versiones se pongan en producción en la mayoría de los entornos, nos centraremos en Java 8 y 11.
Existen otras implementaciones de la especificación del lenguaje Java, incluidas bifurcaciones de la implementación de código abierto. AdoptOpenJDK suministra una de ellas (Eclipse OpenJ9), y hay otras disponibles de otros proveedores. Aunque todas estas plataformas deben superar una prueba de compatibilidad para poder utilizar el nombre Java, esa compatibilidad no siempre se extiende a los temas tratados en este libro. Esto es especialmente cierto en el caso de las banderas de ajuste. Todas las implementaciones de JVM tienen uno o varios recolectores de basura, pero las banderas para ajustar la implementación de GC de cada proveedor son específicas de cada producto. Por tanto, aunque los conceptos de este libro se aplican a cualquier implementación de Java, las banderas y recomendaciones específicas sólo se aplican a la JVM HotSpot.
Esa advertencia es aplicable a versiones anteriores de la JVM HotSpot: las banderas y sus valores por defecto cambian de una versión a otra. Las banderas que se comentan aquí son válidas para Java 8 (concretamente, la versión 222) y 11 (concretamente, 11.0.5). Las versiones posteriores podrían cambiar ligeramente parte de esta información. Consulta siempre las notas de la versión para conocer los cambios importantes.
A nivel de API, las distintas implementaciones de JVM son mucho más compatibles, aunque incluso en ese caso pueden existir sutiles diferencias entre la forma en que se implementa una clase concreta en la plataforma Oracle HotSpot Java y en una plataforma alternativa. Las clases deben ser funcionalmente equivalentes, pero la implementación real puede cambiar. Afortunadamente, esto es poco frecuente y es poco probable que afecte drásticamente al rendimiento.
Para el resto de este libro, los términos Java y JVM deben entenderse referidos específicamente a la implementación de Oracle HotSpot. En sentido estricto, decir "La JVM no compila código en la primera ejecución" es incorrecto; algunas implementaciones de Java sí compilan código la primera vez que se ejecuta. Pero esa forma abreviada es mucho más fácil que seguir escribiendo (y leyendo): "La JVM de Oracle HotSpot...".
Banderas de ajuste de la JVM
Salvo algunas excepciones, la JVM acepta dos tipos de banderas: las booleanas y las que requieren un parámetro.
Las banderas booleanas utilizan esta sintaxis:-XX:+
FlagName
activa la bandera, y-XX:-
FlagName
desactiva la bandera.
Las banderas que requieren un parámetro utilizan esta sintaxis:-XX:
FlagName
=something
, que significa establecer el valor deFlagName
asomething
En el texto, el valor de la bandera se suele representar con algo que indica un valor arbitrario. Por ejemplo-XX:NewRatio=
N
significa que la banderaNewRatio
puede tener un valor arbitrario N
(donde las implicaciones de N
son el centro de la discusión).
El valor por defecto de cada bandera se discute cuando se introduce la bandera. Ese valor por defecto se basa a menudo en una combinación de factores: la plataforma en la que se ejecuta la JVM y otros argumentos de la línea de comandos a la JVM. En caso de duda, "Información básica de la VM" muestra cómo utilizar la bandera-XX:+PrintFlagsFinal
(por defecto, false
) para determinar el valor por defecto de una determinada bandera en un entorno concreto, dada una determinada línea de comandos. El proceso de ajustar automáticamente las banderas en función del entorno se denomina ergonomía.
La JVM que se descarga de los sitios de Oracle y AdoptOpenJDK se denominacompilación de producto de la JVM. Cuando la JVM se construye a partir del código fuente, se pueden producir muchas compilaciones: compilaciones de depuración, compilaciones para desarrolladores, etc. Estas versiones suelen tener funciones adicionales. En particular, las compilaciones para desarrolladores incluyen un conjunto aún mayor de indicadores de ajuste para que los desarrolladores puedan experimentar con las operaciones más minuciosas de varios algoritmos utilizados por la JVM. Por lo general, en este libro no se tienen en cuenta estas opciones.
Plataformas de hardware
Cuando se publicó la primera edición de este libro, el panorama del hardware tenía un aspecto diferente al actual. Las máquinas multinúcleo eran populares, pero las plataformas de 32 bits y las de una sola CPU seguían siendo muy utilizadas. Otras plataformas que se utilizan hoy en día -las máquinas virtuales y los contenedores de software- estaban empezando a cobrar importancia. Aquí tienes una visión general de cómo esas plataformas afectan a los temas de este libro.
Hardware multinúcleo
Prácticamente todas las máquinas actuales tienen varios núcleos de ejecución, que aparecen ante la JVM (y ante cualquier otro programa) como varias CPU. Normalmente, cada núcleo está habilitado para hyper-threading. Hiperhilo es el término que prefiere Intel, aunque AMD (y otros) utilizan el término multihilo simultáneo, y algunos fabricantes de chips se refieren a hebras de hardware dentro de un núcleo. Todos son lo mismo, y nos referiremos a esta tecnología como hyper-threading.
Desde el punto de vista del rendimiento, lo importante de una máquina es su número de núcleos. Tomemos una máquina básica de cuatro núcleos: cada núcleo puede (en su mayor parte) procesar independientemente de los demás, por lo que una máquina con cuatro núcleos puede alcanzar un rendimiento cuatro veces superior al de una máquina con un solo núcleo. (Esto depende de otros factores del software, por supuesto).
En la mayoría de los casos, cada núcleo contendrá dos hilos hardware o hiperhilos. Estos hilos no son independientes entre sí: el núcleo sólo puede ejecutar uno de ellos a la vez. A menudo, el hilo se atascará: por ejemplo, necesitará cargar un valor de la memoria principal, y ese proceso puede llevar unos cuantos ciclos. En un núcleo con un único hilo, el hilo se detiene en ese punto, y esos ciclos de CPU se desperdician. En un núcleo con dos hilos, el núcleo puede cambiar y ejecutar instrucciones del otro hilo.
Así, nuestra máquina de cuatro núcleos con hyper-threading activado parece como si pudiera ejecutar instrucciones de ocho hilos a la vez (aunque, técnicamente, sólo puede ejecutar cuatro instrucciones por ciclo de CPU). Para el sistema operativo -y, por tanto, para Java y otras aplicaciones- la máquina parece tener ocho CPU. Pero todas esas CPU no son iguales desde el punto de vista del rendimiento. Si ejecutamos una tarea ligada a la CPU, utilizará un núcleo; una segunda tarea ligada a la CPU utilizará un segundo núcleo; y así hasta cuatro: podemos ejecutar cuatro tareas independientes ligadas a la CPU y obtener nuestro cuádruple aumento de rendimiento.
Si añadimos una quinta tarea, sólo podrá ejecutarse cuando una de las otras tareas se atasque, lo que por término medio ocurre entre el 20% y el 40% de las veces. Cada tarea adicional se enfrenta al mismo reto. Así que añadir una quinta tarea sólo añade un 30% más de rendimiento; al final, las ocho CPU nos darán entre cinco y seis veces el rendimiento de un solo núcleo (sin hyper-threading).
Verás este ejemplo en algunas secciones. La recogida de basura es en gran medida una tarea ligada a la CPU, por lo que el Capítulo 5 muestra cómo el hiperhilo afecta a la paralelización de los algoritmos de recogida de basura. El Capítulo 9 trata en general de cómo explotar al máximo las funciones de subprocesamiento de Java, por lo que allí también verás un ejemplo del escalado de núcleos hiperprocesados.
Contenedores de software
El mayor cambio en las Implementaciones de Java en los últimos años es que ahora se implementan con frecuencia dentro de un contenedor de software. Este cambio no se limita a Java, por supuesto; es una tendencia del sector acelerada por el paso a la computación en nube.
Aquí hay dos contenedores importantes. El primero es la máquina virtual, que establece una copia completamente aislada del sistema operativo en un subconjunto del hardware en el que se ejecuta la máquina virtual. Ésta es la base de la computación en nube: tu proveedor de computación en nube tiene un centro de datos con máquinas muy grandes. Estas máquinas tienen potencialmente 128 núcleos, aunque probablemente sean más pequeñas debido a la eficiencia de costes. Desde la perspectiva de la máquina virtual, eso no importa realmente: la máquina virtual tiene acceso a un subconjunto de ese hardware. Por tanto, una máquina virtual determinada puede tener dos núcleos (y cuatro CPU, ya que suelen ser hiperprocesadas) y 16 GB de memoria.
Desde la perspectiva de Java (y de otras aplicaciones), esa máquina virtual es indistinguible de una máquina normal con dos núcleos y 16 GB de memoria. A efectos de ajuste y rendimiento, sólo tienes que considerarla de la misma manera.
El segundo contenedor digno de mención es el contenedor Docker. Un proceso Java que se ejecuta dentro de un contenedor Docker no sabe necesariamente que está en tal contenedor (aunque podría averiguarlo mediante inspección), pero el contenedor Docker es sólo un proceso (potencialmente con limitaciones de recursos) dentro de un SO en ejecución. Como tal, su aislamiento del uso de CPU y memoria de otros procesos es algo diferente. Como verás, la forma en que Java gestiona esto difiere entre las primeras versiones de Java 8 (hasta la actualización 192) y las versiones posteriores de Java 8 (y todas las versiones de Java 11).
Por defecto, un contenedor Docker es libre de utilizar todos los recursos de la máquina: puede utilizar todas las CPU y toda la memoria disponibles en la máquina. Eso está bien si queremos utilizar Docker simplemente para agilizar la implementación de nuestra única aplicación en la máquina (y, por tanto, la máquina sólo ejecutará ese contenedor Docker). Pero con frecuencia queremos implementar varios contenedores Docker en una máquina y restringir los recursos de cada contenedor. En efecto, dada nuestra máquina de cuatro núcleos con 16 GB de memoria, podríamos querer ejecutar dos contenedores Docker, cada uno con acceso a sólo dos núcleos y 8 GB de memoria.
Configurar Docker para que lo haga es bastante sencillo, pero pueden surgir complicaciones a nivel de Java. Numerosos recursos de Java se configuran automáticamente (o ergonómicamente) en función del tamaño de la máquina que ejecuta la JVM. Esto incluye el tamaño predeterminado del montón y el número de hilos utilizados por el recolector de basura, que se explican detalladamente en el Capítulo 5, y algunos ajustes del grupo de hilos, que se mencionan en el Capítulo 9.
Si estás ejecutando una versión reciente de Java 8 (actualización de la versión 192 o posterior) o Java 11, la JVM maneja esto como cabría esperar: si limitas el contenedor Docker a utilizar sólo dos núcleos, los valores establecidos ergonómicamente en función de la cantidad de CPU de la máquina se basarán en el límite del contenedor Docker.2 Del mismo modo, el montón y otros ajustes que por defecto se basan en la cantidad de memoria de una máquina se basan en cualquier límite de memoria dado al contenedor Docker.
En versiones anteriores de Java 8, la JVM no tiene conocimiento de los límites que impondrá el contenedor: cuando inspeccione el entorno para averiguar cuánta memoria hay disponible para poder calcular su tamaño de montón por defecto, verá toda la memoria de la máquina (en lugar de, como preferiríamos, la cantidad de memoria que el contenedor Docker tiene permitido utilizar). Del mismo modo, cuando compruebe cuántas CPU están disponibles para ajustar el recolector de basura, verá todas las CPU de la máquina, en lugar del número de CPU asignadas al contenedor Docker. Como resultado, la JVM se ejecutará de forma subóptima: iniciará demasiados hilos y configurará un montón demasiado grande. Tener demasiados hilos provocará cierta degradación del rendimiento, pero el verdadero problema aquí es la memoria: el tamaño máximo del montón será potencialmente mayor que la memoria asignada al contenedor Docker. Cuando el montón alcance ese tamaño, el contenedor Docker (y, por tanto, la JVM) morirá.
En las primeras versiones de Java 8, puedes ajustar a mano los valores adecuados para el uso de memoria y CPU. A medida que nos encontremos con esos ajustes, señalaré los que habrá que ajustar para esta situación, pero es mejor simplemente actualizar a una versión posterior de Java 8 (o Java 11).
Los contenedores Docker suponen un reto adicional para Java: Java viene con un rico conjunto de herramientas para diagnosticar problemas de rendimiento. A menudo, éstas no están disponibles en un contenedor Docker. Trataremos este tema con más detalle en el Capítulo 3.
La historia completa de la actuación
Este libro se centra en cómo utilizar mejor la JVM y las API de la plataforma Java para que los programas se ejecuten más rápido, pero hay muchas influencias externas que afectan al rendimiento. Esas influencias aparecen de vez en cuando en la discusión, pero como no son específicas de Java, no se tratan necesariamente en detalle. El rendimiento de la JVM y de la plataforma Java es una pequeña parte de la consecución de un rendimiento rápido.
Esta sección presenta las influencias externas que son al menos tan importantes como los temas de ajuste de Java tratados en este libro. El enfoque basado en el conocimiento de Java de este libro complementa estas influencias, pero muchas de ellas están fuera del alcance de lo que trataremos.
Escribe mejores algoritmos
Muchos detalles de Java afectan al rendimiento de una aplicación, y se discuten muchas banderas de ajuste. Pero no hay una opción mágica-XX:+RunReallyFast
mágica.
En última instancia, el rendimiento de una aplicación se basa en lo bien que esté escrita. Si el programa recorre en bucle todos los elementos de una matriz, la JVM optimizará la forma en que realiza la comprobación de los límites de la matriz para que el bucle se ejecute más rápido, y puede desenrollar las operaciones del bucle para proporcionar un aumento adicional de la velocidad. Pero si el objetivo del bucle es encontrar un elemento concreto, ninguna optimizacióndel mundo hará que el código basado en matrices sea tan rápido como una versión diferente que utilice un mapa hash.
Un buen algoritmo es lo más importante cuando se trata de un rendimiento rápido.
Escribe menos código
Algunos escribimos programas por dinero, otros por diversión, otros para devolver algo a una comunidad, pero todos escribimos programas (o trabajamos en equipos que escriben programas). Es difícil sentir que contribuyes a un proyecto podando código, y algunos directores siguen evaluando a los desarrolladores por la cantidad de código que escriben.
Lo entiendo, pero el conflicto aquí es que un programa pequeño bien escrito se ejecutará más rápido que un programa grande bien escrito. Esto es cierto en general para todos los programas informáticos, y se aplica específicamente a los programas Java. Cuanto más código haya que compilar, más tiempo pasará hasta que ese código se ejecute rápidamente. Cuantos más objetos haya que asignar y desechar, más trabajo tendrá que hacer el recolector de basura. Cuantos más objetos haya que asignar y retener, más tiempo tardará un ciclo GC. Cuantas más clases haya que cargar del disco a la JVM, más tiempo tardará en iniciarse un programa. Cuanto más código se ejecute, menos posibilidades habrá de que quepa en las cachés de hardware de la máquina. Y cuanto más código haya que ejecutar, más tardará esa ejecución.
Pienso en esto como el principio de "la muerte por 1.000 cortes". Los desarrolladores argumentarán que sólo están añadiendo una función muy pequeña y que no les llevará nada de tiempo (sobre todo si la función no se utiliza). Y entonces otros desarrolladores del mismo proyecto hacen la misma afirmación, y de repente el rendimiento ha retrocedido un tanto por ciento. El ciclo se repite en la siguiente versión, y ahora el rendimiento del programa ha retrocedido un 10%. Un par de veces durante el proceso, las pruebas de rendimiento pueden alcanzar un determinado umbral de recursos: un punto crítico en el uso de la memoria, un desbordamiento de la caché de código o algo parecido. En esos casos, las pruebas de rendimiento regulares detectarán esa condición concreta, y el equipo de rendimiento podrá solucionar lo que parece ser una regresión importante. Pero con el tiempo, a medida que las pequeñas regresiones vayan apareciendo, será cada vez más difícil solucionarlas.
No estoy defendiendo que nunca debas añadir una nueva función o un nuevo código a tu producto; es evidente que se obtienen beneficios mejorando los programas. Pero sé consciente de las compensaciones que estás haciendo, y cuando puedas, racionaliza.
Oh, adelante, optimiza prematuramente
A Donald Knuth se le atribuye la acuñación del término optimización prematura, que a menudo utilizan los desarrolladores para afirmar que el rendimiento de su código no importa, y si importa, no lo sabremos hasta que se ejecute el código. La cita completa, si nunca te has topado con ella, es "Deberíamos olvidarnos de las pequeñas eficiencias, digamos un 97% de las veces; la optimización prematura es la raíz de todos los males".3
El sentido de esta máxima es que, al final, debes escribir un código limpio y sencillo que sea fácil de leer y entender. En este contexto, por optimizar se entiende emplear cambios algorítmicos y de diseño que complican la estructura del programa pero proporcionan un mejor rendimiento. De hecho, es mejor no hacer ese tipo de optimizaciones hasta que el perfilado de un programa muestre que se obtiene un gran beneficio al realizarlas.
Sin embargo, lo que la optimización no significa en este contexto es evitar construcciones de código que se sabe que son malas para el rendimiento. Cada línea de código implica una elección, y si puedes elegir entre dos formas sencillas y directas de programar, elige la de mejor rendimiento.
A un nivel, esto lo entienden bien los desarrolladores Java experimentados (es un ejemplo de su arte, ya que lo han aprendido con el tiempo). Considera este código:
log
.
log
(
Level
.
FINE
,
"I am here, and the value of X is "
+
calcX
()
+
" and Y is "
+
calcY
());
Este código realiza una concatenación de cadenas que probablemente sea innecesaria, ya que el mensaje no se registrará a menos que el nivel de registro esté muy alto. Si el mensaje no se imprime, también se realizan llamadas innecesarias a los métodoscalcX()
ycalcY()
. Los desarrolladores Java experimentados rechazarán esto por reflejo; algunos IDE incluso marcarán el código y sugerirán que se cambie. (Aunque las herramientas no son perfectas: el IDE NetBeans marcará la concatenación de cadenas, pero la mejora sugerida mantiene las llamadas a métodos innecesarios).
Este código de registro está mejor escrito así:
if
(
log
.
isLoggable
(
Level
.
FINE
))
{
log
.
log
(
Level
.
FINE
,
"I am here, and the value of X is {} and Y is {}"
,
new
Object
[]{
calcX
(),
calcY
()});
}
Esto evita totalmente la concatenación de cadenas (el formato del mensaje no es necesariamente más eficiente, pero es más limpio), y no hay llamadas a métodos ni asignación de la matriz de objetos, a menos que se haya activado el registro.
Escribir el código de esta forma sigue siendo limpio y fácil de leer; no requirió más esfuerzo que escribir el código original. Bueno, vale, requirió unas cuantas pulsaciones más y una línea de lógica adicional. Pero no es el tipo de optimización prematura que debe evitarse; es el tipo de elección que los buenos programadores aprenden a hacer.
No dejes que el dogma descontextualizado de los héroes pioneros te impida pensar en el código que estás escribiendo. Verás otros ejemplos de esto a lo largo de este libro, incluso en el Capítulo 9, que analiza el rendimiento de una construcción de bucle de apariencia benigna para procesar un vector de objetos.
Busca en otra parte: La base de datos siempre es el cuello de botella
Si estás desarrollando aplicaciones Java autónomas que no utilizan recursos externos, el rendimiento de esa aplicación es (casi) lo único que importa. Una vez que se añade un recurso externo (una base de datos, por ejemplo), el rendimiento de ambos programas es importante. Y en un entorno distribuido -por ejemplo, con un servidor Java REST, un equilibrador de carga, una base de datos y un sistema de información empresarial backend- el rendimiento del servidor Java puede ser el menor de los problemas de rendimiento.
Éste no es un libro sobre el rendimiento holístico del sistema. Hay que medir y analizar el uso de la CPU, las latencias de E/S y el rendimiento de todas las partes del sistema; sólo entonces podremos determinar qué componente está causando el cuello de botella en el rendimiento. Existen excelentes recursos sobre ese tema, y esos enfoques y herramientas no son específicos de Java. Supongo que ya has hecho ese análisis y has determinado que es el componente Java de tu entorno el que hay que mejorar.
Por otra parte, no pases por alto ese análisis inicial. Si la base de datos es el cuello de botella (y aquí va una pista: lo es), afinar la aplicación Java que accede a la base de datos no ayudará en absoluto al rendimiento general. De hecho, podría ser contraproducente. Por regla general, cuando se aumenta la carga en un sistema que está sobrecargado, el rendimiento de ese sistema empeora. Si se cambia algo en la aplicación Java que la hace más eficiente -lo que sólo aumenta la carga en una base de datos ya sobrecargada-, el rendimiento global puede en realidad bajar. El peligro es entonces llegar a la conclusión incorrecta de que no debe utilizarse la mejora concreta de la JVM.
Este principio -que aumentar la carga de un componente de un sistema que funciona mal ralentizará todo el sistema- no se limita a una base de datos, sino que se aplica cuando se añade carga a un servidor que está saturado de CPU o si más hilos empiezan a acceder a un bloqueo que ya tiene hilos esperando por él, o cualquier otra situación. En el Capítulo 9 se muestra un ejemplo extremo de esto que sólo afecta a la JVM.
Optimizar para el caso común
Es tentador -sobre todo teniendo en cuenta el síndrome de la "muerte por 1.000 cortes"- tratar todos los aspectos del rendimiento como igualmente importantes. Pero deberíamos centrarnos en los casos de uso común. Este principio se manifiesta de varias maneras:
-
Optimiza el código perfilándolo y centrándote en las operaciones del perfil que requieren más tiempo. Ten en cuenta, sin embargo, que esto no significa fijarse sólo en los métodos hoja de un perfil (ver Capítulo 3).
-
Aplica la navaja de Occam para diagnosticar los problemas de rendimiento. La explicación más sencilla para un problema de rendimiento es la causa más concebible: un fallo de rendimiento en el nuevo código es más probable que un problema de configuración en una máquina, que a su vez es más probable que un fallo en la JVM o en el sistema operativo. Los fallos oscuros del SO o de la JVM existen, y a medida que se descartan causas más creíbles para un problema de rendimiento, se hace posible que de alguna manera el caso de prueba en cuestión haya desencadenado ese fallo latente. Pero no saltes primero al caso improbable.
-
Escribe algoritmos sencillos para las operaciones más habituales de una aplicación. Digamos que un programa estima una fórmula matemática, y el usuario puede elegir si desea obtener una respuesta con un margen de error del 10% o del 1%. Si la mayoría de los usuarios se conforman con el margen del 10%, optimiza esa ruta de código, aunque ello suponga ralentizar el código que proporciona el margen de error del 1%.
Resumen
Java tiene características y herramientas que permiten obtener el mejor rendimiento de una aplicación Java. Este libro te ayudará a comprender la mejor manera de utilizar todas las funciones de la JVM para conseguir programas de ejecución rápida.
En muchos casos, sin embargo, recuerda que la JVM es una pequeña parte del panorama general del rendimiento. Se requiere un enfoque sistémico del rendimiento en entornos Java en los que el rendimiento de las bases de datos y otros sistemas backend es al menos tan importante como el rendimiento de la JVM. Ese nivel de análisis del rendimiento no es el objetivo de este libro: se supone que se ha actuado con la diligencia debida para asegurarse de que el componente Java del entorno es el cuello de botella importante del sistema.
Sin embargo, la interacción entre la JVM y otras áreas del sistema es igualmente importante, tanto si esa interacción es directa (por ejemplo, la mejor forma de hacer llamadas a la base de datos) o indirecta (por ejemplo, optimizar el uso de memoria nativa de una aplicación que comparte máquina con varios componentes de un gran sistema). La información de este libro también debería ayudar a resolver problemas de rendimiento de este tipo.
1 En raras ocasiones, existen diferencias entre ambas; por ejemplo, las versiones AdoptOpenJDK de Java contienen nuevos recolectores de basura en JDK 11. Señalaré esas diferencias cuando se produzcan.
2 Puedes especificar valores fraccionarios para los límites de CPU en Docker. Java redondea todos los valores fraccionarios al entero inmediatamente superior.
3 Existe cierta controversia sobre quién dijo esto originalmente, Donald Knuth o Topy Hoare, pero aparece en un artículo de Knuth titulado "Programación estructurada con declaraciones goto
". Y en su contexto, es un argumento a favor de optimizar el código, aunque requiera soluciones poco elegantes como una declaración goto
.
Get Rendimiento de Java, 2ª Edición now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.