java - guava expireafterwrite
forzando de manera confiable el desalojo del mapa de guayaba (7)
Acabo de agregar el método Cache.cleanUp()
a Guava. Una vez que migre de MapMaker
a CacheBuilder
, puede usarlo para forzar el desalojo.
EDITAR: He reorganizado esta pregunta para reflejar la nueva información que desde entonces estuvo disponible.
Esta pregunta se basa en las respuestas a una pregunta de Viliam sobre el uso del desalojo perezoso de Guava Maps: la pereza del desalojo en los mapas de Guava.
Lea primero esta pregunta y su respuesta, pero esencialmente la conclusión es que los mapas de Guayaba no calculan y ejecutan el desalojo de forma asíncrona. Dado el siguiente mapa:
ConcurrentMap<String, MyObject> cache = new MapMaker()
.expireAfterAccess(10, TimeUnit.MINUTES)
.makeMap();
Una vez que hayan transcurrido diez minutos desde el acceso a una entrada, no se desalojará hasta que el mapa se "toque" nuevamente. Las formas conocidas de hacer esto incluyen los accesores habituales: get()
y put()
y containsKey()
.
La primera parte de mi pregunta [resuelta]: ¿qué otras llamadas hacen que el mapa se "toque"? Específicamente, ¿alguien sabe si el size()
cae en esta categoría?
La razón para preguntarme esto es que he implementado una tarea programada para empujar ocasionalmente el mapa de Guava que estoy usando para el almacenamiento en caché, usando este método simple:
public static void nudgeEviction() {
cache.containsKey("");
}
Sin embargo, también estoy usando cache.size()
para informar mediante programación el número de objetos contenidos en el mapa, ya que una forma de confirmar esta estrategia está funcionando. Pero no he podido ver una diferencia con estos informes, y ahora me pregunto si el size()
también causa el desalojo.
Respuesta: Así que Mark señaló que en la versión 9, el desalojo se invoca solo mediante los métodos get()
, put()
y replace()
, lo que explicaría por qué no estaba viendo un efecto containsKey()
. Aparentemente, esto cambiará con la próxima versión de guava que se lanzará pronto, pero desafortunadamente, el lanzamiento de mi proyecto se establece antes.
Esto me pone en una situación interesante. Normalmente todavía podría tocar el mapa llamando a get("")
, pero en realidad estoy usando un mapa informático:
ConcurrentMap<String, MyObject> cache = new MapMaker()
.expireAfterAccess(10, TimeUnit.MINUTES)
.makeComputingMap(loadFunction);
donde loadFunction
carga el MyObject
correspondiente a la clave desde una base de datos. Parece que no tengo una forma fácil de forzar el desalojo hasta r10. Pero incluso la segunda parte de mi pregunta pone en duda incluso la posibilidad de forzar de manera confiable el desalojo:
La segunda parte de mi pregunta [resuelta]: En respuesta a una de las respuestas a la pregunta vinculada , ¿tocar el mapa desaloja de manera confiable todas las entradas caducadas? En la respuesta vinculada, Niraj Tolia indica lo contrario, diciendo que el desalojo solo se procesa en lotes, lo que significaría que se necesitarían múltiples llamadas para tocar el mapa para garantizar que se desalojaran todos los objetos caducados. No dio detalles, sin embargo, esto parece estar relacionado con la división del mapa en segmentos según el nivel de concurrencia. Suponiendo que usé r10, en el que una fórmula de containsKey("")
contiene el desalojo, ¿esto sería para todo el mapa o solo para uno de los segmentos?
Respuesta: maaartinus ha abordado esta parte de la pregunta:
Tenga en cuenta que
containsKey
postReadCleanup
y otros métodos de lectura que solo ejecutanpostReadCleanup
, que no hace nada más que en cada invocación 64 (consulte DRAIN_THRESHOLD). Además, parece que todos los métodos de limpieza funcionan solo con un solo segmento.
Por lo tanto, parece que llamar containsKey("")
no sería una solución viable, incluso en r10. Esto reduce mi pregunta al título: ¿Cómo puedo forzar de manera confiable el desalojo?
Nota: parte de la razón por la que mi aplicación web se ve notablemente afectada por este problema es que cuando implementé el almacenamiento en caché decidí usar varios mapas, uno para cada clase de mis objetos de datos. Por lo tanto, con este problema existe la posibilidad de que se ejecute un área de código, lo que hace que se almacene en caché un montón de objetos Foo
, y luego el caché de Foo
no se vuelve a tocar durante mucho tiempo, por lo que no desaloja nada. Mientras tanto, los objetos Bar
y Baz
se almacenan en caché de otras áreas del código y se consume la memoria. Estoy estableciendo un tamaño máximo en estos mapas, pero en el mejor de los casos esto es una salvaguarda endeble (supongo que su efecto es inmediato, todavía tengo que confirmarlo).
ACTUALIZACIÓN 1: Gracias a Darren por vincular los temas relevantes, ahora tienen mis votos. Parece que la resolución está en trámite, pero parece improbable que esté en r10. Mientras tanto, mi pregunta sigue siendo.
ACTUALIZACIÓN 2: En este punto, estoy esperando a que un miembro del equipo de Guayaba me brinde un comentario sobre el maaartinus hack y lo juntemos (ver las respuestas a continuación)
ÚLTIMA ACTUALIZACIÓN: comentarios recibidos!
Me preguntaba sobre el mismo problema que describió en la primera parte de su pregunta. Por lo que puedo decir al observar el código fuente de CustomConcurrentHashMap (versión 9) de Guava, parece que las entradas se desalojan en los métodos get()
, put()
y replace()
. El método de containsKey()
no parece invocar el desalojo. No estoy 100% seguro porque tomé un pase rápido en el código.
Actualizar:
También encontré una versión más reciente de CustomConcurrentHashmap en el repositorio de git de Guava y parece que containsKey()
la CustomConcurrentHashmap containsKey()
para invocar el desalojo.
Tanto la versión 9 como la última versión que acabo de encontrar no invocan el desalojo cuando se llama a size()
.
Actualización 2:
Recientemente me di cuenta de que Guava r10
(aún no se ha lanzado) tiene una nueva clase llamada CacheBuilder . Básicamente, esta clase es una versión bifurcada de MapMaker
pero con el almacenamiento en caché en mente. La documentación sugiere que apoyará algunos de los requisitos de desalojo que está buscando.
CustomConcurrentHashMap el código actualizado en la versión r10 de CustomConcurrentHashMap y encontré lo que parece un limpiador de mapas programado. Desafortunadamente, ese código parece inacabado en este punto, pero r10 se ve cada vez más prometedor cada día.
No sé si es apropiado para su caso de uso, pero su principal preocupación acerca de la falta de desalojo de caché de fondo parece ser el consumo de memoria, por lo que habría pensado que usar softValues () en el MapMaker para permitir que el recolector de basura reclamar las entradas de la memoria caché cuando se produce una situación de memoria baja. Fácilmente podría ser la solución para usted. He usado esto en un servidor de suscripción (ATOM) donde las entradas se sirven a través de un caché de guayaba usando SoftReferences para los valores.
No soy un gran fan de piratear o forzar código externo hasta que sea absolutamente necesario. Este problema se produce en parte debido a una decisión temprana para que MapMaker bifurque ConcurrentHashMap, arrastrando una gran cantidad de complejidad que podría haber sido diferida hasta después de que los algoritmos hayan sido resueltos. Al aplicar parches encima de MapMaker, el código es robusto a los cambios de la biblioteca, de modo que puede eliminar su solución en su propio horario.
Un enfoque fácil es usar una cola de prioridad de tareas de referencia débiles y un subproceso dedicado. Esto tiene la desventaja de crear muchas tareas obsoletas no operativas, que pueden llegar a ser excesivas debido a la penalización de inserción O (lg n). Funciona razonablemente bien para cachés pequeños, de uso menos frecuente. Fue el enfoque original adoptado por MapMaker y es simple de escribir su propio decorator .
Una opción más robusta es reflejar el modelo de amortización de bloqueo con una sola cola de vencimiento. El encabezado de la cola puede ser volátil, por lo que una lectura siempre puede echar un vistazo para determinar si ha caducado. Esto permite que todas las lecturas activen una caducidad y un subproceso de limpieza opcional que se verifique regularmente.
Por mucho, lo más simple es usar #concurrencyLevel (1) para forzar a MapMaker a usar un solo segmento. Esto reduce la concurrencia de escritura, pero la mayoría de los cachés se leen con mucha frecuencia, por lo que la pérdida es mínima. El truco original para empujar el mapa con una clave ficticia funcionaría bien. Este sería mi enfoque preferido, pero las otras dos opciones están bien si tiene altas cargas de escritura.
Sí, hemos revisado varias veces si estas tareas de limpieza deben realizarse en un subproceso en segundo plano (o grupo), o deben realizarse en subprocesos de usuario. Si se hicieran en un hilo de fondo, esto eventualmente sucedería automáticamente; tal como es, solo sucederá a medida que cada segmento se use. Aquí todavía estamos tratando de encontrar el enfoque correcto: no me sorprendería ver este cambio en una versión futura, pero tampoco puedo prometer nada o incluso hacer una estimación creíble de cómo cambiará. Aún así, ha presentado un caso de uso razonable para algún tipo de fondo o limpieza activada por el usuario.
Su hack es razonable, siempre y cuando tenga en cuenta que es un hack y que puede romperse (posiblemente de manera sutil) en futuras versiones. Como puede ver en la fuente, Segment.runCleanup () llama a runLockedCleanup y runUnlockedCleanup: runLockedCleanup () no tendrá efecto si no puede bloquear el segmento, pero si no puede bloquear el segmento es porque algún otro hilo tiene la segmento bloqueado, y se puede esperar que otro subproceso llame a runLockedCleanup como parte de su operación.
Además, en r10, hay CacheBuilder / Cache, análogo a MapMaker / Map. El caché es el enfoque preferido para muchos usuarios actuales de makeComputingMap. Utiliza un CustomConcurrentHashMap separado, en el paquete common.cache; Dependiendo de sus necesidades, es posible que desee que GuavaEvictionHacker funcione con ambos. (El mecanismo es el mismo, pero son clases diferentes y, por lo tanto, métodos diferentes).
Sobre la base de la respuesta de Maaartinus, se me ocurrió el siguiente código, que utiliza la reflexión en lugar de modificar directamente la fuente (si le parece útil, por favor, ¡vote su respuesta!). Aunque vendrá con una penalización de rendimiento por el uso de la reflexión, la diferencia debería ser despreciable, ya que lo ejecutaré una vez cada 20 minutos para cada Mapa de caché (también estoy guardando las búsquedas dinámicas en el bloque estático, lo que ayudará). He hecho algunas pruebas iniciales y parece funcionar como es debido:
public class GuavaEvictionHacker {
//Class objects necessary for reflection on Guava classes - see Guava docs for info
private static final Class<?> computingMapAdapterClass;
private static final Class<?> nullConcurrentMapClass;
private static final Class<?> nullComputingConcurrentMapClass;
private static final Class<?> customConcurrentHashMapClass;
private static final Class<?> computingConcurrentHashMapClass;
private static final Class<?> segmentClass;
//MapMaker$ComputingMapAdapter#cache points to the wrapped CustomConcurrentHashMap
private static final Field cacheField;
//CustomConcurrentHashMap#segments points to the array of Segments (map partitions)
private static final Field segmentsField;
//CustomConcurrentHashMap$Segment#runCleanup() enforces eviction on the calling Segment
private static final Method runCleanupMethod;
static {
try {
//look up Classes
computingMapAdapterClass = Class.forName("com.google.common.collect.MapMaker$ComputingMapAdapter");
nullConcurrentMapClass = Class.forName("com.google.common.collect.MapMaker$NullConcurrentMap");
nullComputingConcurrentMapClass = Class.forName("com.google.common.collect.MapMaker$NullComputingConcurrentMap");
customConcurrentHashMapClass = Class.forName("com.google.common.collect.CustomConcurrentHashMap");
computingConcurrentHashMapClass = Class.forName("com.google.common.collect.ComputingConcurrentHashMap");
segmentClass = Class.forName("com.google.common.collect.CustomConcurrentHashMap$Segment");
//look up Fields and set accessible
cacheField = computingMapAdapterClass.getDeclaredField("cache");
segmentsField = customConcurrentHashMapClass.getDeclaredField("segments");
cacheField.setAccessible(true);
segmentsField.setAccessible(true);
//look up the cleanup Method and set accessible
runCleanupMethod = segmentClass.getDeclaredMethod("runCleanup");
runCleanupMethod.setAccessible(true);
}
catch (ClassNotFoundException cnfe) {
throw new RuntimeException("ClassNotFoundException thrown in GuavaEvictionHacker static initialization block.", cnfe);
}
catch (NoSuchFieldException nsfe) {
throw new RuntimeException("NoSuchFieldException thrown in GuavaEvictionHacker static initialization block.", nsfe);
}
catch (NoSuchMethodException nsme) {
throw new RuntimeException("NoSuchMethodException thrown in GuavaEvictionHacker static initialization block.", nsme);
}
}
/**
* Forces eviction to take place on the provided Guava Map. The Map must be an instance
* of either {@code CustomConcurrentHashMap} or {@code MapMaker$ComputingMapAdapter}.
*
* @param guavaMap the Guava Map to force eviction on.
*/
public static void forceEvictionOnGuavaMap(ConcurrentMap<?, ?> guavaMap) {
try {
//we need to get the CustomConcurrentHashMap instance
Object customConcurrentHashMap;
//get the type of what was passed in
Class<?> guavaMapClass = guavaMap.getClass();
//if it''s a CustomConcurrentHashMap we have what we need
if (guavaMapClass == customConcurrentHashMapClass) {
customConcurrentHashMap = guavaMap;
}
//if it''s a NullConcurrentMap (auto-evictor), return early
else if (guavaMapClass == nullConcurrentMapClass) {
return;
}
//if it''s a computing map we need to pull the instance from the adapter''s "cache" field
else if (guavaMapClass == computingMapAdapterClass) {
customConcurrentHashMap = cacheField.get(guavaMap);
//get the type of what we pulled out
Class<?> innerCacheClass = customConcurrentHashMap.getClass();
//if it''s a NullComputingConcurrentMap (auto-evictor), return early
if (innerCacheClass == nullComputingConcurrentMapClass) {
return;
}
//otherwise make sure it''s a ComputingConcurrentHashMap - error if it isn''t
else if (innerCacheClass != computingConcurrentHashMapClass) {
throw new IllegalArgumentException("Provided ComputingMapAdapter''s inner cache was an unexpected type: " + innerCacheClass);
}
}
//error for anything else passed in
else {
throw new IllegalArgumentException("Provided ConcurrentMap was not an expected Guava Map: " + guavaMapClass);
}
//pull the array of Segments out of the CustomConcurrentHashMap instance
Object[] segments = (Object[])segmentsField.get(customConcurrentHashMap);
//loop over them and invoke the cleanup method on each one
for (Object segment : segments) {
runCleanupMethod.invoke(segment);
}
}
catch (IllegalAccessException iae) {
throw new RuntimeException(iae);
}
catch (InvocationTargetException ite) {
throw new RuntimeException(ite.getCause());
}
}
}
Estoy buscando comentarios sobre si este enfoque es aconsejable como un recurso provisional hasta que el problema se resuelva en una versión de Guava, en particular de los miembros del equipo de Guava cuando reciben un minuto.
EDITAR: actualizó la solución para permitir el desalojo automático de mapas ( NullConcurrentMap
o NullComputingConcurrentMap
residen en un NullConcurrentMap
NullComputingConcurrentMap
de ComputingMapAdapter
). Esto resultó ser necesario en mi caso, ya que estoy llamando a este método en todos mis mapas y algunos de ellos son auto-desalojadores.
Tenga en cuenta que containsKey
postReadCleanup
y otros métodos de lectura que solo ejecutan postReadCleanup
, que no hace nada más que en cada invocación 64 (consulte DRAIN_THRESHOLD). Además, parece que todos los métodos de limpieza funcionan solo con un solo segmento.
La forma más fácil de hacer cumplir el desalojo parece ser colocar algún objeto ficticio en cada segmento. Para que esto funcione, deberías analizar CustomConcurrentHashMap.hash(Object)
, que seguramente no es una buena idea, ya que este método puede cambiar en cualquier momento. Además, dependiendo de la clase de clave, puede ser difícil encontrar una clave con un código hash que garantice que aterrice en un segmento determinado.
Podría usar lecturas en su lugar, pero tendría que repetirlas 64 veces por segmento. Aquí, sería fácil encontrar una clave con un código hash apropiado, ya que aquí se permite cualquier objeto como argumento.
Tal vez podría piratear el código fuente de CustomConcurrentHashMap
, podría ser tan trivial como
public void runCleanup() {
final Segment<K, V>[] segments = this.segments;
for (int i = 0; i < segments.length; ++i) {
segments[i].runCleanup();
}
}
pero no lo haría sin muchas pruebas y / o una aprobación por parte de un miembro del equipo de guayabas.