Testing Concurrent Java Programs

Beim Testen von Java Programmen die Nebenläufigkeiten und Synchronisation enthalten, bekommt man schnell Probleme. Zum einen stellt man fest, dass es recht schwierig ist Testfälle zu konstruieren, die alle relevanten Codeteile durchlaufen. Und selbst wenn das geschafft ist, kann man noch nicht sicher sein, dass das Programm sich unter realen Bedingung korrekt verhält.

Die zeitlichen Anläufe, der nicht vorhersagbare Wettbewerb vieler Threads lassen sich schlecht oder gar nicht simulieren. Selbst wenn man Tests entwirft, die mit mehreren Threads arbeiten, stellt man schnell fest, das die Testabläufe fast vollkommen deterministisch sind und das zufällige Zeitverhalten einer echten Anwendung in keiner Weise erreicht wird. Deshalb findet man mit solchen "fast" deterministischen Tests auch potentielle Deadlocks oder Race-Conditions meistens nicht. Diese Probleme tauchen dann erst im Betrieb auf …

Zufall einfügen

Was hilft, ist den Test weniger vorhersagbar zu machen, das Zeitverhalten zu variieren. Das kann man erreichen, in dem man etwas "Zufall" in den Code einfügt.

Bytecode Instrumentation

Um nicht den Quellcode oder Testcode mit "Zufalls"-Anweisungen zu zuschütten, kann man auch den Weg der Bytecode Instrumentation wählen. Hier wird der fertig übersetzte Code vorzugsweise beim Laden in die JavaVM mit Zufallsanweisungen angereichert.

Von den verschiedenen Frameworks zur Bytecode Manipulation scheint mir ASM das flexibelste zu sein. Zwar lassen sich nicht alle Konstruktionen so elegant ausdrücken wie in anderen Frameworks, aber dafür hat man kaum Einschränkungen.

Transforming ClassLoader

Mit Hilfe eines ClassLoaders, der die zu testenden Klassen lädt und einer Basis-Klasse die TestCase erweitert und als Basis für Concurrent-Tests dient, ist eine einfache JUnit Integration möglich.

package rinke.solutions.tool.test;

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.objectweb.asm.ClassAdapter;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;

public class TransformingClassLoader extends ClassLoader {

  Set<String> whiteList = new HashSet<String>();

  Set<String> blackList = new HashSet<String>();

  private static Log log = LogFactory.getLog(TransformingClassLoader.class);

  public TransformingClassLoader() {
   
super();
    blackList.add
("java\\..*");
    blackList.add
("javax\\..*");
    blackList.add
("sun\\..*");
 
}

  protected Class<?> loadAndInstrumentClass(String name)
     
throws ClassNotFoundException {

    log.debug("loading class " + name + " ...");
   
try {
     
ClassReader cr = new ClassReader(name);
      ClassWriter cw =
new ClassWriter(true);
      ClassAdapter ca =
new ConcurrentClassAdapter(cw);

      cr.accept(ca, false);

      byte[] bytecode = cw.toByteArray();
     
return defineClass(name, bytecode, 0, bytecode.length);

    } catch (IOException e) {
     
throw new ClassNotFoundException(e.getMessage(), e);
   
}
  }

  @Override
 
public Class<?> loadClass(String name) throws ClassNotFoundException {
   
if (inWhiteList(name) ) {
     
return loadAndInstrumentClass(name);
   
} else if( inBlackList(name)) {
     
return super.loadClass(name);
   
} else {
     
if( whiteList.size() > 0 ) {
       
return super.loadClass(name);
     
} else {
       
return loadAndInstrumentClass(name);
     
}
    }
  }

  private boolean inWhiteList(String name) {
   
if( whiteList.size() > 0 ) {
     
for (String pat : whiteList) {
       
if( Pattern.matches(pat, name)) return true;
     
}
    }
   
return false;
 
}

  private boolean inBlackList(String name) {
   
for(String pat : blackList) {
     
if( Pattern.matches(pat, name)) return true;
   
}
   
return false;
 
}

}

Wie man sieht kennt der ClassLoader eine BlackList und eine WhiteList mit der sich über RegEx steuern läßt welche Klassen instrumentiert werden sollen und welche nicht. Ausserdem beachtet der ConcurrentClassAdapter eine Annotation, die auf Class-Level gesetzt, das instrumentieren einer Klasse verhindert.

ConcurrentClassAdapter

Der ConcurrentClassAdapter der in ASM typischer Weise in den Eventstrom zwischen Reader und Writer geschaltet wird, hat nun folgende Aufgaben:

1. Wird für jede Methode ein spezieller CodeAdapter eingefügt.

2. Synchronized Methoden werden mit einem Wrapper versehen, der den Aufruf der synchronized Methode abfängt und vor und nach dem Aufruf der ursprünglichen Methode "Zufall" einfügt.

3. Wird die Annotation "DontInstrument" auf Class-Level gefunden, werden alle weiteren Aktionen übersprungen und die Klasse normal geladen.

Code siehe Archiv-Download am Ende …

ConcurrentCodeAdapter

Der CodeAdapter wird für jede Methode gerufen und hält Ausschau nach Aufrufen von wait(), nach dem Betreten oder Verlassen von Synchronized-Blöcken oder nach dem Aufruf von "synchronized"-Methoden. Immer dann wenn ein solche Punkte im Bytecode erreicht ist, der aus der Sicht der Concurrency interessant ist, wird ein Aufruf an der Concurrency-Runtime eingefügt. Diese Runtime steuert den Zufall und protokolliert was das Programm (die Threads) gerade tun.

Code-Abschnitt der das Betreten und Verlassen von Synchronized-Blöcken instrumentiert:

  @Override
   
public void visitInsn(int opcode) {
       
if( opcode == MONITORENTER ) {
         
super.visitLdcInsn(source + ": " +actLine);
         
super.visitMethodInsn(INVOKESTATIC, runtimeClass, "monitorEnter", "(Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object;");
         
// dup lock
         
super.visitInsn(DUP);
         
super.visitInsn(opcode);
         
         
super.visitLdcInsn(source + ": " +actLine);
         
super.visitMethodInsn(INVOKESTATIC, runtimeClass, "monitorEntered", "(Ljava/lang/Object;Ljava/lang/String;)V");
       
} else if( opcode == MONITOREXIT ) {
         
super.visitLdcInsn(source + ": " +actLine);
         
super.visitMethodInsn(INVOKESTATIC, runtimeClass, "monitorExit", "(Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object;");
           
super.visitInsn(opcode);
       
} else {
           
super.visitInsn(opcode);
       
}
    }

ConcurrentRuntime

Die Runtime Umgebung macht nicht viel mehr als bei allen relevanten Stellen Zufall in Form von zufälligen sleep(xxx) oder yield() einzufügen (einstellbar).

Darüber hinaus werden die Threads, die bestimmte Locks besitzen oder darauf warten protokolliert, so dass der Concurrency-Zustand immer bekannt ist. Weiterhin misst die Runtime sowas wie die Concurrency-Coverage, prüft also ob Threads von Synchronized-Blöcken blockiert wurden oder ob das Halten von Locks in einer synchronized Methode an anderer Stelle zum Warten geführt hat.

Download

Falls Interesse besteht kann ich die kompletten Klassen hier zum Download anbieten.

Bislang hat sich einer gemeldet 😉 also hier gibts das komplette Projekt. Dependencies sind asm-all und commons-logging.

Dieser Beitrag wurde unter Java veröffentlicht. Setze ein Lesezeichen auf den Permalink.

3 Antworten auf Testing Concurrent Java Programs

Hinterlasse eine Antwort

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind markiert *