Implementación de Caches Inteligentes Mediante "Soft References"
Cómo podemos mejorar el rendimiento y la robustez de nuestras aplicaciones
utilizando "Soft References", para evitar problemas de escasez de
memoria durante picos de carga imprevistos.
Fecha de creación: 02.10.2001
Revisión 1.0 (25.04.2005)
Daniel López Janáriz
D DOT Lopez AT uib DOT es
Copyright (c) 2003, Daniel López Janáriz. Este documento puede ser distribuido solo bajo los términos y condiciones de la licencia de Documentación de javaHispano v1.0 o posterior (la última versión se encuentra en http://www.javahispano.org/licencias/).
Introducción: Utilización de caches
En casi todos los sistemas informáticos podemos encontrar operaciones
que se repiten frecuentemente, a veces con ligeras variaciones y a veces exactamente
igual una y otra vez. Por ello, una técnica muy habitual para aumentar
el rendimiento de estos sistemas es almacenar, para su posterior reutilización,
los resultados parciales de las operaciones que más se repiten. Dicha
técnica, que suele denominarse vulgarmente como "implementación
de una cache", en alusión a la memoria intermedia donde se almacenan
los resultados parciales, nos evita tener que calcular cada vez las operaciones
completas, con el consiguiente ahorro en tiempo que ello supone.
Figura 1: Funcionamiento básico de un
mecanismo de cache
La implementación de "caches", muy utilizada en sistemas hardware,
puede sernos también muy útil en nuestras aplicaciones en Java.
Un ejemplo muy típico de ello se da en las aplicaciones web que utilizan
XML y XSLT para generar la interfaz HTML. Aunque la explicación de cómo
se utiliza XML y XSLT para generar HTML queda fuera del ámbito de este
artículo, baste decir que, usar una "cache" para almacenar
las hojas XSLT una vez han sido compiladas, puede significar una mejora del
rendimiento de hasta un 200%.
A continuación, mostraremos como podemos organizar nuestros programas
en Java para introducir una "cache", después veremos como podemos
mejorar el diseño.
Para implementar una "cache", lo primero que haríamos sería
evitar que el programa principal llame directamente al método que calcula
los resultados intermedios. Así pues, dejaremos un hueco para poder introducir
la "cache". Mostramos como hacerlo en el listado 2.
...
/**
* Método intermedio utilizado por el programa principal para
* obtener un calculo intermedio.
*/
public Object getResultadoIntermedio(Object parametros)
{
// Llama directamente al método para calcular los resultados.
// Luego añadiremos aquí la "cache".
return calcularResultadoIntermedio(parametros)
}
/**
* Método para calcular resultado intermedio.
* Dicho método no es llamado directamente si no que
* únicamente se le puede llamar a través del método
* "getResultadoIntermedio".
*/
Object calcularResultadoIntermedio(Object parametros)
{
...//implementación de los cálculos
}
...
Listado 2
En el listado 3 modificamos el método getResultadoIntermediopara incluir
la "cache". El punto importante reside en el hecho de que ahora, si
el resultado intermedio ya está almacenado en la "cache", no
tenemos que volver a calcularlo si no que lo devolvemos directamente.
/**
* Hashtable que representara nuestra "cache"
*/
Hashtable laCache;
/**
* Método utilizado para recuperar un objeto de la
* cache. Si está ya almacenado, sólo lo devuelve, si no
* lo crea primero, lo almacena en la cache para usos posteriores
* y después lo devuelve.
*/
public Object getResultadoIntermedio(Object parametros)
{
Object resultadoTemporal = null;
// Primero comprobaremos si la cache ha sido
// inicializada. Si no es así, lo hacemos ahora.
// Nota: Ponemos la comparación al revés para evitar la
// equivocación de poner una asignación (this.laCache = null)
if(null == this.laCache)
{
this.laCache = new Hashtable();
}
// Comprobamos si ya está en la cache
if(this.laCache.containsKey(parametros))
{
// Si está, simplemente lo devolvemos
resultadoTemporal = this.laCache.get(parametros);
}
else
{
// Si no está, lo calculamos
resultadoTemporal = calcularResultadoIntermedio(parametros);
// Y después lo almacenamos para posterior reutilización
this.laCache.put(parametros,resultadoTemporal);
}
return resultadoTemporal;
}
Listado 3
La implementación de "caches"
puede incrementar significativamente el rendimiento de nuestras aplicaciones.
Problema principal: El crecimiento desmesurado de la cache
Una vez ya sabemos como implementar una "cache" en nuestros programas,
debemos tener en cuenta los problemas que nos puede ocasionar. Para ello tendremos
en cuenta que la utilidad real de dicha técnica depende en gran medida
de dos factores fundamentales:
El tamaño de la "cache" donde almacenaremos los resultados
parciales
Los mecanismos de gestión de la "cache"
Un tamaño inadecuado o una mala gestión de la "cache"
pueden transformar en inútiles nuestros esfuerzos para mejorar el rendimiento
de nuestra aplicación.
La mayor dificultad a la que nos enfrentamos al implementar una "cache"
en nuestros programas en Java es cómo controlar el consumo de memoria.
Por un lado, si imponemos un limite artificial al tamaño de la "cache"
corremos el riesgo de que sea insuficiente, y no consigamos sacarle el máximo
rendimiento al sistema. Por otro lado, si no imponemos un límite, nos
arriesgamos a que la "cache" consuma toda la memoria y a que nuestro
programa aborte con una "OutOfMemoryException". La solución
sería implementar una "cache" que se pudiera adaptar inteligentemente
a la memoria disponible, y que liberase parte de su contenido cuando la aplicación
lo necesitara. Suena bien. ¿Verdad? Pues una implementación de
este tipo, la cual sería bastante complicado de realizar de forma manual,
se puede conseguir fácilmente gracias a uno de los últimos avances
introducidos en el API de Java: Las "Soft References"
El mayor problema de las "caches"
es el consumo de memoria, puesto que su crecimiento desmesurado puede
consumir toda la memoria disponible
Las "Soft References"
Las "Soft References" introducidas en la versión 1.2 del API
de Java, en el paquete java.ref, son referencias especiales que se usan para
almacenar objetos que pueden ser reciclados por el Garbage Collector(GC) cuando
la Máquina Virtual Java(JVM) necesite más memoria. Es decir, en
caso de que nuestro programa requiera memoria adicional, el GC se activa y recicla
todos los objetos que ya no se utilizan para poder hacer uso ese espacio de
memoria. Hasta aquí todo normal. Sin embargo, si una vez reciclado ese
espacio aun necesitamos más memoria, el GC tiene permiso para reciclar
aquellos objetos que sean únicamente accesibles por "Soft References".
Figura 2: Las "Soft References" permiten
al GC reciclar los objetos a los que apuntan
¿Cómo podemos utilizar las "Soft References" para mejorar
nuestras "caches" en Java? Muy fácil: En vez de almacenar en
la "cache" referencias directas a los resultados intermedios, almacenaremos
dichos objetos pero apuntados únicamente a través de "Soft
References". Mostramos cómo hacerlo en el listado número
4.
...
/**
* Método utilizado para recuperar un objeto de la
* cache. Si está ya almacenado, sólo lo devuelve, si no
* lo crea primero, lo almacena en la cache para usos posteriores
* y después lo devuelve. El almacenamiento en la cache
* se hace a través de Soft References
*/
public Object getResultadoIntermedio(Object parametros)
{
... // Hasta aquí todo igual.
// Comprobamos si ya está en la cache
if(this.laCache.containsKey(parametros))
{
// Si está, lo intentamos recuperar
SoftReference laReferencia = (SoftReference)this.laCache.get(parametros);
// Si el resultado ha sido reclamado por el GC
// la SoftReference devuelve null y tendremos que
// calcular de nuevo el resultado
resultadoTemporal = laReferencia.get();
}
// Si no estaba en la cache o ha sido reclamado por el GC,
// lo calculamos
if(resultadoTemporal==null)
{
resultadoTemporal = calcularResultadoIntermedio(parametros);
// Y después lo almacenamos para posterior reutilización
// para almacenarlo usaremos una SoftReference y así
// permitimos al GC reclamar al objeto en caso necesario
SoftReference referenciaIntermedia = new
SoftReference(resultadoTemporal);
this.laCache.put(parametros, referenciaIntermedia);
}
return resultadoTemporal;
}
Listado 4
Figura 3: Implementación de una cache
con Soft References
Estos cambios tan simples permite al GC reciclar en caso necesario los objetos
almacenados en nuestra "cache", de forma que no nos quedemos sin memoria
en nuestro programa por tener la "cache" demasiado llena. En el programa
principal no tenemos que cambiar nada ya que hemos encapsulado la utilización
de la "cache" dentro de un método que se encarga de todo.
A través de las "Soft References"
damos permiso al GC para que pueda reclamar algunos objetos en caso
de emergencia, aunque sigan teniendo referencias desde nuestro programa.
A la hora de recuperar los objetos de la "cache" para su posterior
reutilización hay que tener en cuenta algunos detalles:
Como el GC podría ponerse en marcha en cualquier momento y eliminar
el objeto que queremos usar, conviene comprobar siempre los objetos antes
de usarlos para confirmar que no han sido reciclados.
Para asegurarnos que el GC no eliminará el objeto mientras estamos
trabajando con él, basta con apuntarlo con una referencia normal ya
que el GC sólo puede reclamar aquellos objetos que sólo estén
apuntados por "Soft References". Por ello es conveniente asignarle
al objeto una referencia normal, en vez de trabajar directamente con la "Soft
Reference" y utilizar SoftReference.get().
Con la misma idea que el punto anterior, para que nuestra técnica
no pierda su utilidad hay que tener cuidado de no guardar ninguna referencia
directa a los objetos almacenados en la "cache". Por eso el programa
principal debe desechar las referencias a los resultados intermedios en cuanto
le sea posible.
Figura 4: Usando una cache con Soft References
Analizando los cambios, podemos ver que añadiendo unas simples líneas
de código mejoramos el comportamiento de nuestro programa haciéndolo
más robusto. Esta técnica es de especial utilidad en aplicaciones
que pueden recibir aleatoriamente picos de carga muy elevados, como es el caso
de muchas aplicaciones web. Durante estos periodos es admisible que el rendimiento
de la aplicación baje, pero de ningún modo podemos permitir que
la aplicación se bloquee. Gracias a esta técnica aseguramos que,
en caso de emergencia, el espacio utilizado por la "cache" pueda ser
reclamado, y que, aunque el rendimiento baje, la aplicación sobreviva.
Cuando el pico de carga termine, la aplicación podrá usar el espacio
sobrante para la "cache" y aumentar de esa forma el rendimiento.
Cabe resaltar que la implementación mostrada en los listados anteriores
es muy simple y que, por ejemplo, no contempla el acceso concurrente a la "cache".
En un apartado posterior ofreceremos una versión más depurada
de cómo podríamos implementar la misma "cache" teniendo
en cuenta estos problemas y utilizando un diseño orientado a objetos.
Unas pocas lineas de código pueden
mejorar significativamente la robustez de nuestro programa, protegiéndolo
frente a picos de carga.
Problemas a evitar
En este apartado describiremos cuáles son los principales riesgos a
los que nos enfrentaremos al usar "Soft References" y qué debemos
hacer para evitarlos.
La primera dificultad asociada al uso de esta técnica, y que debemos
evitar, es que, al dejar completamente al cargo del GC la gestión de
la memoria, podemos estar encubriendo un fallo en el cálculo de la memoria
que necesita nuestro programa para ejecutarse normalmente. Es decir, si la memoria
asignada en condiciones normales a nuestro programa es insuficiente, gracias
al uso de "Soft References" evitaremos que el programa aborte con
un error por falta de memoria, pero el GC se ejecutará muy a menudo y
nuestra "cache" estará continuamente llenándose y vaciándose.
Esta circunstancia produciría un deterioro bastante importante del rendimiento
de nuestro programa. Por esta razón conviene controlar de alguna forma,
por ejemplo a través de ficheros de trazas(vulgarmente conocidos como
logs) el uso que se está haciendo de la "cache", si el GC la
vacía muy a menudo etc.
Si el GC reclama muy a menudo los objetos de la "cache" entonces debemos
revisar la memoria asignada al programa y comprobar que es la adecuada para
un funcionamiento normal. La técnica de las "Soft References"
debe ser utilizada para dar robustez a los programas frente a picos anormales
de carga, pero no debe usarse para evitar tener que controlar la memoria utilizada.
Un segundo problema, muy similar al primero, se daría cuando el crecimiento
de la "cache" es demasiado grande debido a que el numero de objetos
a almacenar es "ilimitado". Entendiendo en este caso por ilimitado
un número lo suficientemente grande para que no nos quepa en la memoria
de la que disponemos. Este caso es frecuente cuando se almacenan objetos de
una base de datos con muchos elementos, o cuando los objetos son muy grandes
(ficheros de sonido, imágenes...). De nuevo nos encontramos con el mismo
problema que en el punto anterior, ya que el GC se ejecutaría continuamente
deteriorando el rendimiento. Sin embargo, esta vez no podemos solucionarlo configurando
adecuadamente la memoria asignada a nuestro programa puesto que ya sabemos que
será insuficiente. Aparte de la opción obvia de no hacer nada
y dejar que el GC haga lo que pueda, tenemos varias opciones cuyo denominador
común es añadirle más inteligencia a la "cache"
para evitar que el GC tenga que ejecutarse tan a menudo.
Una primera opción, si podemos determinar a priori cuales son los
elementos que más se utilizan, es limitar de alguna forma el tamaño
de la "cache" y almacenar en ella sólo dichos elementos.
El resto de elementos tendrían que ser calculados de nuevo cada vez
y con ellos perderíamos para ellos la ventaja de la "cache",
pero si calculamos bien cuales son los elementos más utilizados podemos
conseguir un buen equilibrio entre utilización de memoria y rendimiento.
La segunda opción sería algo más compleja y se basaría
en almacenar en la cache los objetos que más se utilizan a través
de referencias normales y el resto usando "Soft References". De
esta forma nos aseguraríamos que el GC no reclama nunca los objetos
que más se utilizan y le dejaríamos reclamar el resto en caso
necesario. La clasificación de objetos según se usen más
o menos podría hacerse dinámicamente aunque ello aumentaría
la complejidad de nuestra "cache".
Por último, sólo reseñar que si en nuestra aplicación
el numero de objetos utilizados es muy alto y no se puede determinar un grupo
de objetos que se utilice más que el resto, entonces no tiene sentido
usar una "cache", por lo que la técnica de las "Soft References"
tampoco.
Debemos tener cuidado al usar la técnica
de las "Soft References" para que la ejecución continuada
del GC no perjudique el rendimiento de nuestro programa.
Notas sobre la implementación
Ahora pasaremos a tratar algunos detalles de la implementación mostrada
en el listado 4 y ofreceremos una implementación alternativa que se ajusta
más a un buen diseño orientado a objetos.
Problemas del listado 4
Como ya hemos mencionado, la implementación del listado 4 es muy simple
y contiene algunos problemas o defectos que podríamos mejorar.
Entre ellos:
El acceso a la "cache" no está sincronizado y esto podría
causarnos algún quebradero de cabeza si la accedemos desde un servlet,
por ejemplo, que permita un acceso concurrente. La solución está
bastante clara y no es otra que sincronizar el acceso a la "cache"
y sus contenidos.
Un problema que debemos tener en cuenta al usar un objeto Hashtable como
"cache" es la clase objetos que vamos a usar como claves. Explicar
en profundidad dicho problema y cómo solucionarlo no es tema de este
artículo, pero baste mencionar que si se usa como clave una clase propia,
en vez de una clase básica del API como String o Integer, hay que acordarse
de implementar adecuadamente los métodos equals y hashcode en esa clase.
Para más información, consultar la documentación del
API sobre la clase java.util.Hashtable.
Por último, como problema de diseño podríamos mencionar
que la implementación que hemos mostrado, encapsulando la "cache"
en un método, no es lo más adecuado desde el punto de vista
de la orientación a objetos. Por eso es mejor crear una nueva clase
que encapsule completamente la "cache" y sus métodos de acceso.
Este sistema nos permitiría además sincronizar el acceso de
una forma más sencilla.
Nueva implementación
Para solucionar dos de los problemas mencionados en el apartado anterior crearemos
una nueva clase que encapsule la "cache" y sincronice el acceso para
que no tengamos problemas al usarla concurrentemente.
A la hora de crear una nueva clase se nos presenta una típica disyuntiva
del diseño orientado a objetos: ¿herencia o composición?
Dado que una Hashtable se adapta casi perfectamente a la idea que tenemos de
una "cache", una primera idea sería heredar de la clase Hashtable
y modificar los métodos put y get para que usaran "Soft References".
Sin embargo, dado que la clase Hashtable implementa más métodos
para manejar los datos, como putAll, remove... entonces también tendríamos
que implementarlos para evitar que se usaran de forma incorrecta. Además,
si más adelante quisiéramos añadirle inteligencia a nuestra
"cache", diferenciando, por ejemplo, entre los objetos más
utilizados y el resto, entonces quizá nos convendría usar más
de una Hashtable.
Por ello nos decantaremos por usar la técnica de la composición
y crear una clase que contiene un objeto Hashtable como miembro. Además,
para tener una implementación de "cache" independiente de nuestro
programa, los objetos a reutilizar no los calculará directamente la "cache"
si no que para ello utilizaremos una interfaz, que deberá ser implementada
cualquier programa en el que queramos usar una "cache". Mostramos
el resultado en los siguientes listados:
En el listado 5 se muestra el interfaz que deberá implementar cualquier
programa que quiera utilizar una "cache", de esta forma evitamos tener
que escribir el código que calcula los resultados intermedios dentro
de la "cache", y así podemos utilizarla para cualquier tipo
de programa.
package mis.utils;
/**
* Interfaz que deben implementar las clases que quieren utilizar
* una Cache. Esta interfaz será utilizada desde la Cache para
* poder crear los resultados intermedios, en caso de no tenerlos
* ya almacenados
*/
public interface CalculadorResultados
{
/*
* Cálcula un resultado intermedio en función de los parámetros.
*/
public Object calcularResultadoIntermedio(Object parametros);
}
Listado 5
En el listado 6 mostramos el código de la implementación de la
"cache" propiamente dicha. Cabe resaltar el constructor, a través
del cual le pasamos una referencia al objeto que implementa la interfaz del
listado 5.
package mis.utils;
/**
* Clase que encapsula una cache. Su implementación puede ir desde
* usar una simple Hashtable para almacenar los resultados a usar
* varias Hashtables y Soft References para implementar una cache
* inteligente
*/
import java.util.Hashtable;
public class CacheSimple
{
/**
* Hashtable donde almacenaremos los objetos a reutilizar
*/
private Hashtable tablaDeElementos;
/**
* Referencia al objeto que calculará los resultados a almacenar
* en la cache en caso de que no lo tengamos ya almacenado.
*/
private CalculadorResultados elCalculador;
/**
* Constructor, se encarga de inicializar todos los recursos
*/
public CacheSimple(CalculadorResultados calculador)
{
this.tablaDeElementos = new Hashtable();
this.elCalculador = calculador;
}
/**
* Método para recuperar un objeto de la cache. Si no lo tenemos
* almacenado, lo calculamos a través de la referencia que
* tenemos al objeto calculador de resultados intermedios. El
* método está sincronizado para evitar problemas de acceso
* concurrente.
*/
public synchronized Object getResultado(Object parametros)
{
Object resultadoTemporal = null;
// Comprobamos si ya está en la cache
if(this.tablaDeElementos.containsKey(parametros))
{
// Si está, lo intentamos recuperar
SoftReference laReferencia =
(SoftReference)this.tablaDeElementos.get(parametros);
// Si el resultado ha sido reclamado por el GC
// la SoftReference devuelve null y tendremos que
// calcular de nuevo el resultado
resultadoTemporal = laReferencia.get();
}
// Si no estaba en la cache o ha sido reclamado por el GC,
// lo calculamos
if(resultadoTemporal==null)
{
resultadoTemporal = this.calculadorResultados.calcularResultadoIntermedio(parametros);
// Y después lo almacenamos para posterior reutilización
// para almacenarlo usaremos una SoftRefernce y así
// permitimos al GC reclamar al objeto en caso necesario
SoftReference referenciaIntermedia = new SoftReference(resultadoTemporal);
this. tablaDeElementos.put(parametros, referenciaIntermedia);
}
return resultadoTemporal;
}
}
Listado 6
Por último, en el listado 7 mostramos el esqueleto de un programa que
utiliza una "cache".
/**
* Clase que utiliza una "cache" para hacer su trabajo. Implementa
* la interfaz CalculadorResultados para calcular los resultados
* intermedios que se almacenaran en la ella.
*/
import mis.utils.CalculadorResultados;
import mis.utils.CacheSimple;
public class ProgramaQueUsaCache
implements CalculadorResultados
{
/**
* La "cache".
*/
CacheSimple laCache;
/**
* Implementación de la interfaz CalculadorResultados
* Cálcula un resultado intermedio en función de los parámetros.
*/
public Object calcularResultadoIntermedio(Object parametros)
{
// Implementación del cálculo un resultado en función de
// los parámetros y devolución del objeto resultado
}
/**
* Constructor, en el podemos inicializar la "cache", pasándole
* una referencia al objeto mismo.
*/
public ProgramaQueUsaCache()
{
laCache = new CacheSimple(this);
}
...
// A la hora de ejecutar nuestro programa, solo tenemos que preocuparnos de
// pedirle a la "cache" unos resultados
Objeto elResultado = laCache.getResultado(parametros)
...
}
Listado 7
Nota: Si quisiéramos perfeccionar aun más la implementación,
podríamos usar una interfaz que se llamara mis.utils.Cache e implementar
dicha interfaz de varias formas: Cache simple, Cache con "Soft References",
Cache inteligente... Después escribiríamos nuestro programa principal
de forma que utilizará la "cache" a través únicamente
de la interfaz y podríamos incluso decidir en tiempo de ejecución
qué implementación utilizar. Ello nos permitiría, entre
otras cosas, independizar nuestro programa de la implementación de la
"cache" y poder comprobar fácilmente cuales son los beneficios
reales de usar una u otra implementación.
Utilizando apropiadamente la orientación
a objetos, podemos hacer que nuestros programas sean más flexibles
e independientes unos de otros.
Conclusión
Después de analizar las técnicas propuestas, sus ventajas y posibles
inconvenientes, podemos sacar unas conclusiones para su correcta utilización:
Antes de implementar una "cache" en nuestros programas, debemos
comprobar que se dan las circunstancias adecuadas para poder utilizarla correctamente.
Es decir, que en nuestro programa tenemos una serie de elementos que cuesta
mucho obtener, que se utilizan muchas veces, y que su número es limitado
o que al menos el número de elementos que se utiliza más a menudo
sí lo es. Dichos elementos pueden ser datos extraídos de un
fichero o una base de datos, ficheros de imágenes, cálculos
de algún tipo etc., lo importante es que si no se dan las circunstancias
que hemos descrito, posiblemente no obtengamos ningún beneficio de
implementar una "cache".
Una vez determinado que una "cache" nos es útil, podemos
aumentar la robustez del programa añadiendo simplemente una lineas
de código para que los objetos de la "cache" se almacenen
a través de "Soft References". De esta forma, conseguimos
que la memoria usada en nuestra "cache" pueda ser recuperada por
nuestro programa en caso de emergencia.
Para no ocultar defectos de asignación de memoria a nuestro programa,
es conveniente controlar el comportamiento de la "cache" y comprobar
que el GC no esta continuamente ejecutándose, lo cual degradaría
el rendimiento de nuestro programa. En caso de darse esta circunstancia, debemos
revisar la memoria asignada a nuestro programa para ejecutarse, o introducir
una "cache" más sofisticada que no consuma tanta memoria.
Es una buena práctica de programación introducir la "cache"
de la forma más transparente posible. Con transparente queremos decir
que, el hecho de estar usando una "cache", debe modificar lo menos
posible la estructura de nuestro programa. De esta forma podremos comprobar
más fácilmente si estamos obteniendo una mejora en el rendimiento
ya que eliminar o deshabilitar la "cache" es mucho más sencillo.
Por último, resaltar que implementar un diseño orientado a
objetos de la forma correcta nos ayudará a hacer nuestros programas
más fáciles de depurar, mantener y reutilizar. Lo cual no es
una mala idea ;).
Daniel López Janáriz Daniel trabaja en el Centro de Tecnologías de la Información de la Universidad de las Islas Baleares (CTI@UIB.ES) como coordinador del desarrollo web. Además de programar en Java, diseñar aplicaciones web y administrar el Rincón Java, también le gusta invertir el tiempo con otras cosas importantes como salir con la novia, los amigos, ir al cine, leer... No todo es informática en la vida.