Programación de rendimiento de Java, Parte 2: El costo de la conversión

Para este segundo artículo de nuestra serie sobre el rendimiento de Java, el enfoque cambia a la conversión: qué es, cuánto cuesta y cómo podemos (a veces) evitarlo. Este mes, comenzamos con una revisión rápida de los conceptos básicos de las clases, los objetos y las referencias, luego seguimos con un vistazo a algunas cifras de desempeño hardcore (en una barra lateral, ¡para no ofender a los aprensivos!) Y las pautas sobre el tipos de operaciones que tienen más probabilidades de provocar indigestión en su máquina virtual Java (JVM). Finalmente, terminamos con una mirada en profundidad a cómo podemos evitar los efectos comunes de estructuración de clases que pueden causar el casting.

Programación de rendimiento de Java: ¡Lea toda la serie!

  • Parte 1. Aprenda a reducir la sobrecarga del programa y mejorar el rendimiento controlando la creación de objetos y la recolección de basura.
  • Parte 2. Reducir la sobrecarga y los errores de ejecución mediante código de seguridad de tipos
  • Parte 3. Vea cómo las alternativas de colección se miden en rendimiento y descubra cómo aprovechar al máximo cada tipo

Tipos de objetos y referencias en Java

El mes pasado, discutimos la distinción básica entre tipos primitivos y objetos en Java. Tanto el número de tipos primitivos como las relaciones entre ellos (en particular las conversiones entre tipos) están fijados por la definición del lenguaje. Los objetos, por otro lado, son de tipos ilimitados y pueden estar relacionados con cualquier número de otros tipos.

Cada definición de clase en un programa Java define un nuevo tipo de objeto. Esto incluye todas las clases de las bibliotecas de Java, por lo que cualquier programa dado puede estar usando cientos o incluso miles de diferentes tipos de objetos. Algunos de estos tipos están especificados por la definición del lenguaje Java por tener ciertos usos o manejos especiales (como el uso de java.lang.StringBufferpara java.lang.Stringoperaciones de concatenación). Aparte de estas pocas excepciones, sin embargo, todos los tipos son tratados básicamente de la misma manera por el compilador de Java y la JVM utilizada para ejecutar el programa.

Si una definición de clase no especifica (por medio de la extendscláusula en el encabezado de definición de clase) otra clase como padre o superclase, implícitamente extiende la java.lang.Objectclase. Esto significa que, en última instancia java.lang.Object, cada clase se extiende , ya sea directamente o mediante una secuencia de uno o más niveles de clases principales.

Los objetos en sí mismos son siempre instancias de clases, y el tipo de un objeto es la clase de la que es una instancia. Sin embargo, en Java, nunca tratamos directamente con objetos; trabajamos con referencias a objetos. Por ejemplo, la línea:

 java.awt.Component myComponent; 

no crea un java.awt.Componentobjeto; crea una variable de referencia de tipo java.lang.Component. Aunque las referencias tienen tipos al igual que los objetos, no existe una coincidencia precisa entre los tipos de referencia y de objeto: un valor de referencia puede ser null, un objeto del mismo tipo que la referencia o un objeto de cualquier subclase (es decir, una clase descendiente de) el tipo de referencia. En este caso particular, java.awt.Componentes una clase abstracta, por lo que sabemos que nunca puede haber un objeto del mismo tipo que nuestra referencia, pero ciertamente puede haber objetos de subclases de ese tipo de referencia.

Polimorfismo y fundición

El tipo de una referencia determina cómo se puede utilizar el objeto referenciado , es decir, el objeto que es el valor de la referencia. Por ejemplo, en el ejemplo anterior, el uso de código myComponentpodría invocar cualquiera de los métodos definidos por la clase java.awt.Component, o cualquiera de sus superclases, en el objeto referenciado.

Sin embargo, el método realmente ejecutado por una llamada no está determinado por el tipo de referencia en sí, sino por el tipo del objeto referenciado. Este es el principio básico del polimorfismo : las subclases pueden anular los métodos definidos en la clase principal para implementar un comportamiento diferente. En el caso de nuestra variable de ejemplo, si el objeto referenciado fuera en realidad una instancia de java.awt.Button, el cambio de estado resultante de una setLabel("Push Me")llamada sería diferente del resultante si el objeto referenciado fuera una instancia de java.awt.Label.

Además de las definiciones de clases, los programas Java también utilizan definiciones de interfaz. La diferencia entre una interfaz y una clase es que una interfaz solo especifica un conjunto de comportamientos (y, en algunos casos, constantes), mientras que una clase define una implementación. Dado que las interfaces no definen implementaciones, los objetos nunca pueden ser instancias de una interfaz. Sin embargo, pueden ser instancias de clases que implementan una interfaz. Las referencias pueden ser de tipos de interfaz, en cuyo caso los objetos referenciados pueden ser instancias de cualquier clase que implemente la interfaz (ya sea directamente o a través de alguna clase antecesora).

La fundición se utiliza para convertir entre tipos, entre tipos de referencia en particular, para el tipo de operación de fundición en la que estamos interesados ​​aquí. Las operaciones ascendentes (también llamadas conversiones de ampliación en la especificación del lenguaje Java) convierten una referencia de subclase en una referencia de clase antecesora. Esta operación de conversión es normalmente automática, ya que siempre es segura y puede ser implementada directamente por el compilador.

Las operaciones Downcast (también llamadas conversiones de restricción en la especificación del lenguaje Java) convierten una referencia de clase antecesora en una referencia de subclase. Esta operación de conversión crea una sobrecarga de ejecución, ya que Java requiere que la conversión se verifique en tiempo de ejecución para asegurarse de que sea válida. Si el objeto al que se hace referencia no es una instancia del tipo de destino para el reparto o una subclase de ese tipo, el intento de lanzamiento no está permitido y debe lanzar un java.lang.ClassCastException.

El instanceofoperador en Java le permite determinar si se permite o no una operación de conversión específica sin intentar realmente la operación. Dado que el costo de rendimiento de una verificación es mucho menor que el de la excepción generada por un intento de lanzamiento no permitido, generalmente es aconsejable usar una instanceofprueba siempre que no esté seguro de que el tipo de referencia sea el que le gustaría que fuera. . Sin embargo, antes de hacerlo, debe asegurarse de tener una forma razonable de tratar con una referencia de un tipo no deseado; de lo contrario, puede dejar que se lance la excepción y manejarla en un nivel superior en su código.

Lanzando precaución a los vientos

La conversión permite el uso de programación genérica en Java, donde el código se escribe para trabajar con todos los objetos de clases descendientes de alguna clase base (a menudo java.lang.Object, para clases de utilidad). Sin embargo, el uso de yesos causa un conjunto único de problemas. En la siguiente sección veremos el impacto en el rendimiento, pero primero consideremos el efecto en el código en sí. Aquí hay una muestra que usa la java.lang.Vectorclase de colección genérica :

vector privado someNumbers; ... public void doSomething () {... int n = ... Integer number = (Integer) someNumbers.elementAt (n); ...}

Este código presenta problemas potenciales en términos de claridad y facilidad de mantenimiento. Si alguien que no sea el desarrollador original modificara el código en algún momento, podría pensar razonablemente que podría agregar un java.lang.Doublea las someNumberscolecciones, ya que esta es una subclase de java.lang.Number. Todo se compilaría bien si intentara esto, pero en algún punto indeterminado de la ejecución probablemente obtendría un java.lang.ClassCastExceptionlanzamiento cuando el intento de lanzamiento de a java.lang.Integerse ejecutara por su valor agregado.

El problema aquí es que el uso de conversión omite las comprobaciones de seguridad integradas en el compilador de Java; el programador termina buscando errores durante la ejecución, ya que el compilador no los detecta. Esto no es desastroso en sí mismo, pero este tipo de error de uso a menudo se esconde de manera bastante inteligente mientras está probando su código, solo para revelarse cuando el programa se pone en producción.

No es sorprendente que la compatibilidad con una técnica que permita al compilador detectar este tipo de error de uso sea una de las mejoras más solicitadas de Java. Ahora hay un proyecto en progreso en el Proceso de la Comunidad Java que está investigando agregar solo este soporte: número de proyecto JSR-000014, Agregar tipos genéricos al lenguaje de programación Java (consulte la sección Recursos a continuación para obtener más detalles). El próximo mes, veremos este proyecto con más detalle y discutiremos cómo es probable que ayude y dónde es probable que nos deje con ganas de más.

El problema del rendimiento

Desde hace mucho tiempo se reconoce que la conversión puede ser perjudicial para el rendimiento en Java y que puede mejorar el rendimiento minimizando la conversión en código muy utilizado. Las llamadas a métodos, especialmente las llamadas a través de interfaces, también se mencionan a menudo como posibles cuellos de botella en el rendimiento. Sin embargo, la generación actual de JVM ha recorrido un largo camino con respecto a sus predecesoras, y vale la pena verificar qué tan bien se mantienen estos principios en la actualidad.

Para este artículo, desarrollé una serie de pruebas para ver qué tan importantes son estos factores para el rendimiento con las JVM actuales. Los resultados de la prueba se resumen en dos tablas en la barra lateral, la Tabla 1 muestra la sobrecarga de llamadas al método y la Tabla 2 sobrecarga de fundición. El código fuente completo del programa de prueba también está disponible en línea (consulte la sección Recursos a continuación para obtener más detalles).

Para resumir estas conclusiones para los lectores que no quieran leer los detalles de las tablas, ciertos tipos de llamadas y conversiones de métodos siguen siendo bastante costosos, y en algunos casos demoran casi tanto como una simple asignación de objetos. Siempre que sea posible, estos tipos de operaciones deben evitarse en el código que debe optimizarse para el rendimiento.

En particular, las llamadas a métodos reemplazados (métodos que se reemplazan en cualquier clase cargada, no solo la clase real del objeto) y las llamadas a través de interfaces son considerablemente más costosas que las llamadas a métodos simples. La versión beta de HotSpot Server JVM 2.0 utilizada en la prueba incluso convertirá muchas llamadas a métodos simples en código en línea, evitando cualquier sobrecarga para tales operaciones. Sin embargo, HotSpot muestra el peor rendimiento entre las JVM probadas para los métodos anulados y las llamadas a través de interfaces.

Para el casting (downcasting, por supuesto), las JVM probadas generalmente mantienen el rendimiento a un nivel razonable. HotSpot hace un trabajo excepcional con esto en la mayoría de las pruebas comparativas y, al igual que con las llamadas al método, en muchos casos simples es capaz de eliminar casi por completo la sobrecarga de la transmisión. Para situaciones más complicadas, como conversiones seguidas de llamadas a métodos anulados, todas las JVM probadas muestran una degradación notable del rendimiento.

La versión probada de HotSpot también mostró un rendimiento extremadamente bajo cuando un objeto fue lanzado a diferentes tipos de referencia en sucesión (en lugar de ser siempre lanzado al mismo tipo de destino). Esta situación surge regularmente en bibliotecas como Swing que utilizan una jerarquía profunda de clases.

In most cases, the overhead of both method calls and casting is small in comparison with the object-allocation times looked at in last month's article. However, these operations will often be used far more frequently than object allocations, so they can still be a significant source of performance problems.

In the remainder of this article, we'll discuss some specific techniques for reducing the need for casting in your code. Specifically, we'll look at how casting often arises from the way subclasses interact with base classes, and explore some techniques for eliminating this type of casting. Next month, in the second part of this look at casting, we'll consider another common cause of casting, the use of generic collections.

Base classes and casting

There are several common uses of casting in Java programs. For instance, casting is often used for the generic handling of some functionality in a base class that may be extended by a number of subclasses. The following code shows a somewhat contrived illustration of this usage:

 // simple base class with subclasses public abstract class BaseWidget { ... } public class SubWidget extends BaseWidget { ... public void doSubWidgetSomething() { ... } } ... // base class with subclasses, using the prior set of classes public abstract class BaseGorph { // the Widget associated with this Gorph private BaseWidget myWidget; ... // set the Widget associated with this Gorph (only allowed for subclasses) protected void setWidget(BaseWidget widget) { myWidget = widget; } // get the Widget associated with this Gorph public BaseWidget getWidget() { return myWidget; } ... // return a Gorph with some relation to this Gorph // this will always be the same type as it's called on, but we can only // return an instance of our base class public abstract BaseGorph otherGorph() { ... } } // Gorph subclass using a Widget subclass public class SubGorph extends BaseGorph { // return a Gorph with some relation to this Gorph public BaseGorph otherGorph() { ... } ... public void anyMethod() { ... // set the Widget we're using SubWidget widget = ... setWidget(widget); ... // use our Widget ((SubWidget)getWidget()).doSubWidgetSomething(); ... // use our otherGorph SubGorph other = (SubGorph) otherGorph(); ... } }