Un caso para mantener primitivas en Java

Primitivas han sido parte del lenguaje de programación Java desde su lanzamiento inicial en 1996, y sin embargo siguen siendo una de las características del lenguaje más controvertidas. John Moore hace un fuerte argumento para mantener primitivos en el lenguaje Java comparando puntos de referencia Java simples, tanto con primitivos como sin ellos. Luego compara el rendimiento de Java con el de Scala, C++ y JavaScript en un tipo particular de aplicación, donde las primitivas hacen una diferencia notable.

Pregunta: ¿Cuáles son los tres factores más importantes en la compra de bienes raíces?
Respuesta: Ubicación, ubicación, ubicación.

Este viejo y de uso frecuente adagio pretende implicar que la ubicación domina por completo todos los demás factores cuando se trata de bienes raíces. En un argumento similar, los tres factores más importantes a considerar para usar tipos primitivos en Java son rendimiento, rendimiento, rendimiento. Hay dos diferencias entre el argumento de los bienes raíces y el argumento de los primitivos. En primer lugar, con los bienes raíces, la ubicación domina en casi todas las situaciones, pero las ganancias de rendimiento del uso de tipos primitivos pueden variar mucho de un tipo de aplicación a otro. En segundo lugar, con los bienes raíces, hay otros factores a considerar, aunque generalmente son menores en comparación con la ubicación. Con los tipos primitivos, solo hay una razón para usarlos: el rendimiento; y solo si la aplicación es del tipo que puede beneficiarse de su uso.

Las primitivas ofrecen poco valor a la mayoría de las aplicaciones de Internet y relacionadas con el negocio que utilizan un modelo de programación cliente-servidor con una base de datos en el backend. Pero el rendimiento de las aplicaciones que están dominadas por cálculos numéricos puede beneficiarse enormemente del uso de primitivas.

La inclusión de primitivas en Java ha sido una de las decisiones de diseño de lenguaje más controvertidas, como lo demuestra el número de artículos y publicaciones en foros relacionados con esta decisión. Simon Ritter señaló en su discurso de apertura de JAX London en noviembre de 2011 que se estaba considerando seriamente la eliminación de primitivos en una versión futura de Java (ver diapositiva 41). En este artículo, presentaré brevemente las primitivas y el sistema de tipo dual de Java. Usando ejemplos de código y puntos de referencia simples, explicaré por qué se necesitan primitivas de Java para ciertos tipos de aplicaciones. También compararé el rendimiento de Java con el de Scala, C++ y JavaScript.

Primitivas frente a objetos

Como probablemente ya sepa si está leyendo este artículo, Java tiene un sistema de tipo dual, generalmente denominado tipos primitivos y tipos de objetos, a menudo abreviados simplemente como primitivos y objetos. Hay ocho tipos primitivos predefinidos en Java, y sus nombres son palabras clave reservadas. Los ejemplos comúnmente utilizados incluyen int, double y boolean. Esencialmente, todos los demás tipos de Java, incluidos todos los tipos definidos por el usuario, son tipos de objetos. (Digo «esencialmente» porque los tipos de matriz son un poco híbridos, pero se parecen mucho más a los tipos de objeto que a los tipos primitivos.) Para cada tipo primitivo hay una clase de envoltura correspondiente que es un tipo de objeto; los ejemplos incluyen Integer para int, Double para double y Boolean para boolean.

Los tipos primitivos se basan en valores, pero los tipos de objetos se basan en referencias, y ahí radica tanto el poder como la fuente de controversia de los tipos primitivos. Para ilustrar la diferencia, considere las dos declaraciones siguientes. La primera declaración usa un tipo primitivo y la segunda usa una clase wrapper.

int n1 = 100;Integer n2 = new Integer(100);

Usando autoboxing, una característica añadida a JDK 5, podría acortar la segunda declaración a simplemente

Integer n2 = 100;

pero la semántica subyacente no cambia. El autoboxing simplifica el uso de clases de envoltura y reduce la cantidad de código que un programador tiene que escribir, pero no cambia nada en tiempo de ejecución.

La diferencia entre el objeto primitivo n1 y el objeto contenedor n2 se ilustra en el diagrama de la Figura 1.

John I. Moore, Jr.

Figura 1. Diseño de memoria de primitivas versus objetos

La variable n1 contiene un valor entero, pero la variable n2 contiene una referencia a un objeto, y es el objeto el que contiene el valor entero. Además, el objeto al que hace referencia n2 también contiene una referencia al objeto de clase Double.

El problema con los primitivos

Antes de intentar convencerte de la necesidad de los tipos primitivos, debo reconocer que muchas personas no estarán de acuerdo conmigo. Sherman Alpert en» Tipos primitivos considerados dañinos «argumenta que los primitivos son dañinos porque mezclan» semántica de procedimiento en un modelo orientado a objetos uniforme. Los primitivos no son objetos de primera clase, pero existen en un lenguaje que involucra, principalmente, objetos de primera clase.»Las primitivas y los objetos (en forma de clases de envoltura) proporcionan dos formas de manejar tipos lógicamente similares, pero tienen una semántica subyacente muy diferente. Por ejemplo, ¿cómo deben compararse dos instancias para la igualdad? Para los tipos primitivos, se usa el operador ==, pero para los objetos la opción preferida es llamar al método equals(), que no es una opción para los primitivos. De manera similar, existen diferentes semánticas al asignar valores o pasar parámetros. Incluso los valores predeterminados son diferentes; por ejemplo, 0 para int frente a null para Integer.

Para obtener más información sobre este tema, consulte la publicación de blog de Eric Bruno, «Una discusión primitiva moderna», que resume algunos de los pros y los contras de los primitivos. Una serie de discusiones sobre el desbordamiento de pila también se centran en las primitivas, incluyendo «¿Por qué la gente todavía usa tipos primitivos en Java?»y» ¿Hay alguna razón para usar siempre Objetos en lugar de primitivos?.»Programmers Stack Exchange alberga una discusión similar titulada» ¿Cuándo usar clase vs primitiva en Java?».

Utilización de memoria

A double en Java siempre ocupa 64 bits de memoria, pero el tamaño de una referencia depende de la máquina virtual Java (JVM). Mi computadora ejecuta la versión de 64 bits de Windows 7 y una JVM de 64 bits, y por lo tanto una referencia en mi computadora ocupa 64 bits. Basado en el diagrama de la Figura 1 yo esperaría un solo double como n1 a ocupar 8 bytes (64 bits), y yo esperaría que una sola Double como n2 para ocupar 24 bytes — 8 para la referencia al objeto, 8 para la double valor almacenado en el objeto, y 8 para la referencia a la clase de objeto para Double. Además, Java utiliza memoria adicional para admitir la recolección de basura para tipos de objetos, pero no para tipos primitivos. Vamos a comprobarlo.

Usando un enfoque similar al de Glen McCluskey en » Tipos primitivos de Java vs. envolturas,» el método que se muestra en el Listado 1 mide el número de bytes ocupados por una matriz n por n (matriz bidimensional) de double.

Listado 1. Calculando la utilización de memoria del tipo double

Modificando el código en el Listado 1 con los cambios de tipo obvios (no mostrados), también podemos medir el número de bytes ocupados por una matriz n por n de Double. Cuando pruebo estos dos métodos en mi computadora usando matrices de 1000 por 1000, obtengo los resultados que se muestran en la Tabla 1 a continuación. Como se ilustra, la versión para el tipo primitivo double equivale a un poco más de 8 bytes por entrada en la matriz, aproximadamente lo que esperaba. Sin embargo, la versión para el tipo de objeto Double requería un poco más de 28 bytes por entrada en la matriz. Por lo tanto, en este caso, la utilización de memoria de Double es más de tres veces la utilización de memoria de double, lo que no debería sorprender a nadie que entienda el diseño de memoria ilustrado en la Figura 1 anterior.

Rendimiento en tiempo de ejecución

Para comparar el rendimiento en tiempo de ejecución de primitivas y objetos, necesitamos un algoritmo dominado por cálculos numéricos. Para este artículo he elegido la multiplicación de matrices, y computo el tiempo necesario para multiplicar dos matrices de 1000 por 1000. Codifiqué la multiplicación de matrices para double de una manera sencilla, como se muestra en la Lista 2 a continuación. Si bien puede haber formas más rápidas de implementar la multiplicación de matrices (tal vez utilizando la concurrencia), ese punto no es realmente relevante para este artículo. Todo lo que necesito es código común en dos métodos similares, uno usando el primitivo double y otro usando la clase wrapper Double. El código para multiplicar dos matrices de tipo Double es exactamente igual que en el Listado 2 con los cambios de tipo obvios.

Listado 2. Multiplicando dos matrices de tipo double

Ejecuté los dos métodos para multiplicar dos matrices de 1000 por 1000 en mi computadora varias veces y medí los resultados. Los tiempos medios se muestran en el cuadro 2. Por lo tanto, en este caso, el rendimiento en tiempo de ejecución de double es más de cuatro veces más rápido que el de Double. Eso es simplemente demasiada diferencia como para ignorarla.

El punto de referencia SciMark 2.0

Hasta ahora he utilizado el punto de referencia único y simple de la multiplicación de matrices para demostrar que las primitivas pueden producir un rendimiento informático significativamente mayor que los objetos. Para reforzar mis afirmaciones usaré un punto de referencia más científico. SciMark 2.0 es un punto de referencia de Java para computación científica y numérica disponible en el Instituto Nacional de Estándares y Tecnología (NIST). Descargué el código fuente de este punto de referencia y creé dos versiones, la versión original con primitivas y una segunda versión con clases de envoltura. Para la segunda versión, reemplacé int por Integer y double por Double para obtener el efecto completo de usar clases de envoltura. Ambas versiones están disponibles en el código fuente de este artículo.

descargar

John I. Moore, Jr.

El punto de referencia SciMark mide el rendimiento de varias rutinas computacionales e informa de una puntuación compuesta en Mflops aproximados (millones de operaciones de coma flotante por segundo). Por lo tanto, los números más grandes son mejores para este punto de referencia. La tabla 3 muestra los puntajes compuestos promedio de varias ejecuciones de cada versión de este punto de referencia en mi computadora. Como se muestra, el rendimiento en tiempo de ejecución de las dos versiones de la referencia SciMark 2.0 fue consistente con los resultados de multiplicación de matrices anteriores en que la versión con primitivas fue casi cinco veces más rápida que la versión que usa clases de envoltura.

Ha visto algunas variaciones de programas Java haciendo cálculos numéricos, utilizando tanto un punto de referencia de cosecha propia como uno más científico. Pero, ¿cómo se compara Java con otros idiomas? Concluiré con un vistazo rápido a cómo se compara el rendimiento de Java con el de otros tres lenguajes de programación: Scala, C++ y JavaScript.

Benchmarking Scala

Scala es un lenguaje de programación que se ejecuta en la JVM y parece estar ganando popularidad. Scala tiene un sistema de tipos unificado, lo que significa que no distingue entre primitivas y objetos. Según Erik Osheim en la clase de tipo numérico de Scala (Pt. 1), Scala usa tipos primitivos cuando es posible, pero usará objetos si es necesario. De manera similar, la descripción de Martin Odersky de los arreglos de Scala dice que»… un array Scala Array se representa como Java int, un Array se representa como Java double…»

Entonces, ¿esto significa que el sistema de tipos unificados de Scala tendrá un rendimiento en tiempo de ejecución comparable a los tipos primitivos de Java? Veamos.

No soy tan competente con Scala como con Java, pero intenté convertir el código para el punto de referencia de multiplicación de matrices directamente de Java a Scala. El resultado se muestra en el listado 3 a continuación. Cuando ejecuté la versión Scala del benchmark en mi computadora, promedió 12.30 segundos, lo que sitúa el rendimiento de Scala muy cerca del de Java con primitivas. Ese resultado es mucho mejor de lo que esperaba y respalda las afirmaciones sobre cómo Scala maneja los tipos numéricos.

descargar

John I. Moore, Jr.

Listado 3. Multiplicando dos matrices en Scala

Benchmarking C++

Dado que C++ se ejecuta directamente en «bare metal» en lugar de en una máquina virtual, uno esperaría naturalmente que C++ se ejecutara más rápido que Java. Además, el rendimiento de Java se reduce ligeramente por el hecho de que Java comprueba los accesos a las matrices para asegurarse de que cada índice esté dentro de los límites declarados para la matriz, mientras que C++ no lo hace (una característica de C++ que puede provocar desbordamientos de búfer, que pueden ser explotados por hackers). Encontré que C++ es algo más incómodo que Java en el manejo de matrices bidimensionales básicas, pero afortunadamente gran parte de esta incomodidad puede ocultarse dentro de las partes privadas de una clase. Para C++, creé una versión simple de una clase Matrix, y sobrecargé el operador * para multiplicar dos matrices, pero el algoritmo básico de multiplicación de matrices se convirtió directamente desde la versión Java. El código fuente de C++ se muestra en el listado 4.

descargar

John I. Moore, Jr.

Listado 4. Multiplicando dos matrices en C++

Usando Eclipse CDT (Eclipse para desarrolladores de C++) con el compilador de C++ de MinGW, es posible crear versiones de depuración y lanzamiento de una aplicación. Para probar C++, ejecuté la versión de lanzamiento varias veces y promedié los resultados. Como era de esperar, C++ funcionó notablemente más rápido en este simple punto de referencia, con un promedio de 7,58 segundos en mi computadora. Si el rendimiento bruto es el factor principal para seleccionar un lenguaje de programación, entonces C++ es el lenguaje de elección para aplicaciones de uso intensivo de números.

Benchmarking JavaScript

Bien, este me sorprendió. Dado que JavaScript es un lenguaje muy dinámico, esperaba que su rendimiento fuera el peor de todos, incluso peor que Java con clases de envoltura. Pero, de hecho, el rendimiento de JavaScript era mucho más cercano al de Java con primitivas. Para probar JavaScript he instalado Node.js, un motor JavaScript con reputación de ser muy eficiente. Los resultados promediaron 15,91 segundos. El listado 5 muestra la versión JavaScript del punto de referencia de multiplicación de matrices que ejecuté en el nodo.js

descargar

John I. Moore, Jr.

Listado 5. Multiplicar dos matrices en JavaScript

En conclusión

Cuando Java llegó por primera vez a la escena hace unos 18 años, no era el mejor lenguaje desde una perspectiva de rendimiento para aplicaciones dominadas por cálculos numéricos. Pero con el tiempo, con los avances tecnológicos en áreas como la compilación justo a tiempo (JIT) (también conocida como compilación adaptativa o dinámica), el rendimiento de Java para este tipo de aplicaciones es ahora comparable al de los lenguajes que se compilan en código nativo cuando se usan tipos primitivos.

Además, las primitivas eliminan la necesidad de recolección de basura, proporcionando así otra ventaja de rendimiento de las primitivas sobre los tipos de objetos. La tabla 4 resume el rendimiento en tiempo de ejecución del punto de referencia de multiplicación de matrices en mi computadora. Otros factores, como la capacidad de mantenimiento, la portabilidad y la experiencia del desarrollador, hacen de Java una mejor opción para muchas de estas aplicaciones.

Como se discutió anteriormente, Oracle parece estar considerando seriamente la eliminación de primitivas en una versión futura de Java. A menos que el compilador de Java pueda generar código con un rendimiento comparable al de las primitivas, creo que su eliminación de Java impediría el uso de Java para ciertas clases de aplicaciones, es decir, aquellas aplicaciones dominadas por cálculos numéricos. En este artículo he utilizado un punto de referencia simple basado en la multiplicación de matrices y un punto de referencia más científico, SciMark 2.0, para argumentar este punto.

Sobre el autor

John I. Moore, Jr. Profesor de Matemáticas e Informática en La Ciudadela, tiene una amplia experiencia en la industria y el mundo académico, con experiencia específica en las áreas de tecnología orientada a objetos, ingeniería de software y matemáticas aplicadas. Durante más de tres décadas ha diseñado y desarrollado software utilizando bases de datos relacionales y varios lenguajes de alto orden, y ha trabajado extensamente en Java desde la versión 1.1. Además, ha desarrollado e impartido numerosos cursos académicos y seminarios industriales sobre temas avanzados en informática.

Más información

  1. Paul Krill escribió sobre los planes de largo alcance de Oracle para Java en «Oracle lays out long-range Java intentions» (JavaWorld, marzo de 2012). Este artículo, junto con el hilo de comentarios asociado, me motivó a escribir esta defensa de los primitivos.
  2. Szymon Guz escribe sobre sus resultados en la evaluación comparativa de tipos primitivos y clases de envoltura en «Primitives and objects benchmark in Java» (SimonOnSoftware, enero de 2011).
  3. En el sitio web de support for Programming — Principles and Practice Using C++ (Addison-Wesley, 2009), el creador de C++ Bjarne Stroustrup proporciona una implementación para una clase matrix que es mucho más completa que la que acompaña a este artículo.
  4. John Rose, Brian Goetz y Guy Steele discuten un concepto llamado tipos de valor en «Estado de los valores» (OpenJDK.net, abril de 2014). Los tipos de valor se pueden considerar como tipos agregados inmutables definidos por el usuario sin identidad, combinando propiedades de objetos y primitivos. El mantra para los tipos de valor es » códigos como una clase, funciona como un int.»

Deja una respuesta

Tu dirección de correo electrónico no será publicada.