Suche
Java in the Enterprise: Caching in Java EE, Teil 1

Eine Einführung in das Java Caching API (JSR 107)

Anatole Tresch

Anatole Tresch

Caching in Java – mit diesem Thema startet Anatole Tresch in seine neue JAXenter-Kolumne „Java in the Enterprise“. Hier dreht sich in den nächsten Monaten alles rund die Programmierung mit Java-Enterprise-Technologien. Zu Beginn wird das neue Caching API aus JSR 107 in einer zweiteiligen Serie genauer unter die Lupe genommen. Dabei kommen Konzept, Funktionsweise und auch so mancher Kritikpunkt zur Sprache…

Caching in Java EE, Teil 1

Dieser Beitrag gibt eine kurze Einführung in das Thema Caching in Java EE. Zuerst betrachten wir einige generelle Aspekte, um uns dann im zweiten Teil die Grundlagen des JSR-107 (Java Caching API) genauer anzusehen.

Hintergrund

Lange Zeit lösten Performanzprobleme sich quasi „automatisch“. Man musste einfach genügend lange warten und sich auf das Mooresche Gesetz [1] verlassen. Nach diesem verdoppelt sich die Rechenleistung im Schnitt alle 8 Monate. Also Kaffee trinken und dann im lokalen Hardware-Shop neue Hardware kaufen… Bingo: Problem gelöst!

Jedoch sind die Hardware-Hersteller zwischenzeitlich an die physikalischen Grenzen des Machbaren gelangt, und Rechenleistung lässt sich nur noch durch zusätzliche Prozessoren und Server erhöhen. In diesem Zusammenhang kommt nun aber Amdahls Gesetz zum Zuge [2], welches besagt, dass zusätzliche Verarbeitungsleistung und Durchsatz sich nicht durch das lineare Addieren der hinzugefügten Prozessorleistung berechnen lässt.

Somit können zusätzliche Prozessorkerne, Prozessoren und Server auch nicht automatisch alle Performance-Probleme lösen. Das Problem verschärft sich dadurch, dass viele Software-Systeme bereits ein beachtliches Alter erreicht haben und oft eine serielle Ausführungslogik umsetzen. Das heißt: Während eine Aufgabe zu einem Zeitpunkt ausgeführt wird, sind andere Aufgaben blockiert, bis die aktuelle Aufgabe abgeschlossen wurde.

Typische Beispiele sind Datenbanken mit mehreren Anfragen auf die gleichen Daten. Ohne Serialisierung können Inkonsistenzen auftreten (z.B. lost updates, divergierende Werte etc.). Mit so genannten Isolation Levels bieten relationale Datenbanken und der JDBC-Standard durchaus Mechanismen an, das Verhalten zu kontrollieren. Dennoch greifen auch diese Mechanismen nur teilweise, wenn schlussendlich die Daten über das lokale IO-Subsystem der Datenbank synchronisiert werden müssen.

Wir müssen also aktiv etwas an unserer Software ändern, um aus der neuen Architektur maximalen Nutzen zu ziehen. Das kann durchaus auch bedeuten, dass Software, welche nicht mit Parallelität im Hinterkopf gebaut wurde, ganz oder teilweise neu geschrieben oder ersetzt werden muss. Dummerweise verhält es sich aber oftmals wie bei Sisyphus, da ihre Backends nicht angepasst werden können und somit all ihre Optimierungsansätze zum Scheitern verurteilt sind. Das sind mögliche Situationen, wo Caching wertvolle Hilfe leisten kann.

Wann Caching?

Aber lasst uns dennoch kurz inne halten: Ist es immer sinnvoll, Dinge zu cachen? Meine Empfehlungen sind:

  1. Tut es nicht! In vielen Fällen gibt es andere Designprobleme, die Eure Anwendung langsamer machen. Versucht zuerst, diese anzugehen, bevor ihr Caching einsetzt.
  2. Ihr wollt also Caching benutzen? Bitte denkt ein zweites Mal darüber nach, ob ihr es wirklich nutzen wollt.
  3. OK, ihr seid euch sicher, dass ihr Caching einbauen wollt, um eure Performance-Probleme zu lösen. Verschafft euch Klarheit, wo im Code Ihr Caching-Mechanismen hinzufügen möchtet. Entscheidet für jeden Anwendungsfall, welcher Cache-Typ am besten passt: lokal, geclustered oder verteilt. Wenn mehrere Server-Instanzen auf Client-Requests antworten, oder ihr Backend auch von anderen Applikationen genützt wird, so können alle Arten von Caches zu Inkonsistenzen führen. Caching-Cluster konsumieren oft erhebliche Ressourcen für die Synchronisierung des Zustanes. Verteiltes Caching mit vielen Cache-Misses kann schließlich euer System auch langsamer machen als zuvor.
  4. Das führt uns direkt zur letzten Empfehlung: „Messen, nicht vertrauen„. Stellt mittels Messungen sicher, dass Caching auch wirklich die erhofften Performanz-Verbesserungen bringt.

Wie bereits angesprochen, gibt es verschiedene Typen von Caches:

  • Lokale Caches werden nur im lokalen Speicher und auf lokalen Speichergeräten gespeichert und werden nicht mit anderen Anwendungen synchronisiert.
  • Clustered-Caches synchronisieren typischerweise ihren Zustand mit allen anderen Peer-Instanzen im Cluster.
  • Verteilte Caches verteilen die Cache-Inhalte über den Cluster, wobei nicht jeder Knoten alle Daten vorhalten muss. Trotzdem verhindert die Datenredundanz auch Datenverlust im Falle von Fehlern.

In der Praxis können all diese Varianten nebeneinander existieren. Die gewünschten Performanz- und Konsistenz-Anforderungen als auch die Anwendungsfälle definieren schlussendlich, welcher Caching-Typ am besten passt. Die gute Sache ist, dass aus der Perspektive eines Benutzers auf alle diese Varianten von Caches immer mit dem gleichen API zugegriffen werden kann. Ein solches API definiert JSR 107, welchen wir uns nachfolgend nun genauer ansehen wollen.

Was ist ein Cache?

Ich würde einen Cache als eine einfach zugängliche Datenstruktur, die Thread-sicheren Zugriff auf Speicherdaten ermöglicht, definieren. JSR 107 modelliert einen Cache wie folgt:

public interface Cache<K,V> extends Iterable<Cache.Entry>K, V>>, Closeable{
  String getName();
  V get(K key);
  Map<K, V> getAll(Set<? extends K> keys);
  boolean containsKey(K key);
  void put(K key, V value);
  void putAll(Map<? extends K, ? extends V> map);
  boolean putIfAbsent(K key, V value);
  boolean remove(K key);
  void removeAll(Set<? extends K> keys);
  void clear();
  ...
}

Dabei fällt sofort auf, dass die Methoden sehr viele Ähnlichkeiten zur Klasse java.util.Map aufweisen. Einzig die Methode getName () fällt hier aus dem Rahmen, welche es ermöglicht, Caches zu identifizieren (zu beachten ist, dass JSR 107 einige Einschränkungen und Namenskonventionen für gültige Cache-Namen definiert, welche wir hier nicht weiter diskutieren). Dennoch gibt es auch einige kleine Unterschiede: So geben put, putAll und putIfAbsent keine zuvor gespeicherten Werte zurück. Einer der Gründe ist, dass der Wert schlicht nicht bekannt oder lokal gespeichert ist (z.B. bei einem verteilten Cache) und es aus Performanzgründen keinen Sinn ergibt, diese zu eruieren.

Hinter den drei Punkten am Ende verbergen sich noch weitere Funktionen, welche wir aber erst im zweiten Teil dieser Serie diskutieren werden.

Der Zugriff auf Caches

Somit stellt sich als nächstes die Frage, wie wir einen Cache erzeugen und darauf zugreifen können. Das Caching API bietet hierzu verschiedene Möglichkeiten an. Am einfachsten ist es, wenn ein Cache bereits vorhanden bzw. konfiguriert ist und mit Standard-Einstellungen gearbeitet werden kann. In dem Fall kann der Cache mit Namen direkt vom Caching Singleton bezogen werden:

Cache<String,String> cache = Caching.getCache(“myCache”, 
                                              String.class, String.class);

Dabei wird auf (konfigurierbare) default CachingProvider und CachingManager zurückgegriffen. Trotzdem muss wohl für viele Use Cases das kompliziertere API benützt werden. Soll z.B. eine neue Cache-Instanz erzeugt und programmatisch konfiguriert werden, so muss folgender Code geschrieben werden:

CacheManager manager = 
                 Caching.getCachingProvider().getCacheManager();

MutableConfiguration<String, String> config =
                               new MutableConfiguration<String, String>();
config.setTypes(String.class, String.class)
      .setStoreByValue(false)
      .setStatisticsEnabled(true)
      .setExpiryPolicyFactory(FactoryBuilder.factoryOf(
         new AccessedExpiryPolicy(new Duration(TimeUnit.HOURS, 1))));

Cache cache = manager.createCache("helloCache", config);

Somit ist es sinnvoll, uns den CacheManager als nächstes zu betrachten, der Methoden für das Erzeugen und den Zugriff von Caches zur Verfügung stellt:

public interface CacheManager{
   CachingProvider getCachingProvider();
   <K, V, C extends Configuration<K, V>> Cache<K, V> createCache(
                 String name, C configuration) 
      throws IllegalArgumentException;

   <K, V> Cache<K, V> getCache(String name, 
                               Class<K> keyType, Class<V> valueType);
   <K, V> Cache<K, V> getCache(String name);
   Iterable<String> getCacheNames();

   void destroyCache(String cacheName);
   void enableManagement(String cacheName, boolean enable);
   void enableStatistics(String cacheName, boolean enable);

   URI getURI();
   ClassLoader getClassLoader();
   Properties getProperties();

   void close();
   boolean isClosed();
   <T> T unwrap(Class<T> cacheImplClass);
}

Zur Laufzeit ist es durchaus möglich, dass verschiedene CacheManagers co-existent operieren. Cache Manager können dabei grundsätzlich über den CachingProvider, der SPI-Schnittstelle des JSRs, bezogen werden. Für erweiterte Funktionalität kann die Manager-Instanz unwrapped werden, um einen definierten Zugriff auf den konkreten Implementationstyp zu erhalten. CachingProvider können schließlich vom Caching Singleton bezogen werden:

CachingProvider prov = Caching.getCacheProvider();

Der gezeigte Aufruf wird den default CachingProvider zurückliefern, welcher mittels eines javax.cache.CachingProvider-System-Property konfiguriert werden kann.

Andernfalls kann ein registrierter CachingProvider wie folgt selektiert werden:

  • mit dem voll qualifizierten Klassennamen
  • dem ClassLoader
  • oder beidem.

Also wenden wir uns kurz dem Caching Singleton zu:

public final class Caching {

  private Caching() { }

  public static ClassLoader getDefaultClassLoader() ;
  public static void setDefaultClassLoader(ClassLoader classLoader);
  public static CachingProvider getCachingProvider();
  public static CachingProvider getCachingProvider(
                                       ClassLoader classLoader);
  public static Iterable<CachingProvider> getCachingProviders();
  public static Iterable<CachingProvider> getCachingProviders(
                                       ClassLoader classLoader);
  public static CachingProvider getCachingProvider(
                                       String fullyQualifiedClassName) ;
  public static CachingProvider getCachingProvider(
                                       String fullyQualifiedClassName,
                                       ClassLoader classLoader);
  public static <K, V> Cache<K, V> getCache(String cacheName, 
                                       Class<K> keyType,
                          	         Class<V> valueType);
}

Manchem von euch mag hier einiges ins Auge springen. So fällt es auf, dass entweder auf defaults zurückgegriffen werden muss, oder der Aufrufer muss explizit den Implementationsklassennamen des Providers übergeben! Zudem kann das Setzen eines default ClassLoader in Laufzeitumgebungen mit mehreren ClassLoadern, wie Java EE oder OSGi es sind, zu unschönen Nebeneffekten führen.

Grundsätzlich empfinde ich das Übergeben von Implementations-Klassennamen, um die Provider-Implementation zu identifizieren, als sehr unglücklich. Viel besser wäre es gewesen, den einzelnen Providern beliebige, aber eindeutige Namen zuweisen zu können. Dies würde auch das Mocking derselben vereinfachen. Leider sind diese Aspekte obendrein noch im Caching Singleton hard-codiert. Es existiert also keine SPI, welche es erlaubt, das Verhalten des Caching Singleton anzupassen.

Hat man nun schließlich eine Referenz auf einen CacheProvider, kann man sich eine Instanz eines CacheManager holen. Dabei stehen folgende Methoden zur Verfügung:

CacheManager getCacheManager(
                        URI uri, ClassLoader cl, Properties properties);
CacheManager getCacheManager(URI uri, ClassLoader cl);
CacheManager getCacheManager();

Auch hier werden, sofern man nichts übergibt, Default-Werte für URI, ClassLoader und Properties verwendet. Der Aufbau der übergebenen URIs ist dabei implementationsabhängig.

Cache-Events

Bei Operationen auf einem Cache werden entsprechende CacheEntryEvents erzeugt, je nachdem, ob ein Eintrag CREATED,UPDATED, REMOVED oder EXPIRED ist. Will man diese Ereignisse behandeln, so ist, je nach Ereignistyp, eine von vier Unterschnittstellen von CacheEntryListener zu implementieren. Um einen Listener zu registrieren, muss ein CacheEntryListenerConfiguration übergeben werden. Diese Schnittstelle ermöglicht es, noch zusätzliche Eigenschaften und einen optionalen CacheEntryEventFilter zu konfigurieren. Eine Listener-Konfiguration kann sodann bei der Cache-Erstellung oder aber später durch den Aufruf von Cache.registerCacheEntryListener übergeben werden. Als Convenience stellt JSR 107 eine MutableCacheEntryListenerConfiguration-Klasse zur Verfügung, welche bereits CacheEntryListenerConfiguration implementiert.

Zusammenfassung

In diesem Beitrag wurden die grundlegenden Teile des JSR 107 API gezeigt. Das API ist relativ einfach und leicht zu bedienen. Dennoch haben wir auch gesehen, dass es einige Makel gibt, welche mich persönlich überraschen, insbesondere für einen JSR, der so lange Zeit gelaufen ist. Dennoch gibt es noch viel mehr zu entdecken, so dass es sich lohnt, für den zweiten Teil dieser Serie am Thema dran zu bleiben.

Geschrieben von
Anatole Tresch
Anatole Tresch
A​natole Tresch studierte Wirtschaftsinformatik und war anschließend mehrere Jahre lang als Managing Partner und Berater aktiv. ​Die letzten Jahre war ​Anatole Tresch als technischer Architekt und Koordinator bei der Credit Suisse​ tätig. Aktuell arbeitet Anatole ​als Principal Consultant für die Trivadis AG, ist Specification Lead des JSR 354 (Java Money & Currency) und PPMC Member von Apache Tamaya. Twitter: @atsticks
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: