16 KiB
Classloader Deep Dive
Comprehensive guide to understanding and debugging classloader issues in the Cassandra in-JVM dtest framework.
Classloader Architecture
The Three ClassLoader Layers
The in-JVM dtest framework uses a three-tier classloader hierarchy:
┌─────────────────────────────────────────┐
│ Test ClassLoader (Your Tests) │
│ - JUnit test code │
│ - Test utilities │
│ - Direct Cluster/Instance access │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Shared ClassLoader │
│ - Primitives (String, Integer, etc.) │
│ - Config objects (@Shared) │
│ - API interfaces (ICoordinator, etc.) │
│ - Serializable contracts │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Instance ClassLoaders (Per Node) │
│ - DatabaseDescriptor │
│ - StorageService │
│ - All Cassandra internal classes │
│ - Logging configuration │
│ - Each instance gets own copy │
└─────────────────────────────────────────┘
What Gets Loaded Where
Shared ClassLoader (available to all):
- Java primitives: String, Integer, Long, etc.
- Config objects: InstanceConfig, IInstanceConfig
- API interfaces: ICoordinator, IInstance, IInvokableInstance
- ByteBuffer and related classes
- Classes marked with
@Sharedannotation - Serialization infrastructure
Instance ClassLoader (per-instance isolation):
- DatabaseDescriptor (each instance has own static state)
- StorageService (separate singleton per instance)
- All Cassandra core classes (schema, storage, etc.)
- Logging configuration (logback per instance)
- Classes marked with
@Isolatedannotation - Everything else in org.apache.cassandra.*
Why This Matters
Each instance needs its own isolated copy of Cassandra's static state:
- DatabaseDescriptor holds configuration → each node needs different config
- StorageService is a singleton → each node needs separate instance
- Schema metadata is static → each node may have different schema versions
Code Transfer Mechanism
How Lambdas Cross Boundaries
When you call runOnInstance(() -> { ... }):
-
Serialization (Test ClassLoader):
Lambda → Java Serialization → byte[] -
Transfer (Across Boundary):
byte[] passed to IsolatedExecutor -
Deserialization (Instance ClassLoader):
byte[] → Java Deserialization → Lambda (in instance classloader) -
Execution (Instance Context):
Lambda runs with access to instance's Cassandra classes
What Can Be Captured
Safe to Capture (automatically serialized):
// Primitives
final int value = 42;
final String name = "test";
final long timestamp = System.currentTimeMillis();
cluster.get(1).runOnInstance(() -> {
System.out.println(value); // OK
System.out.println(name); // OK
System.out.println(timestamp); // OK
});
Unsafe to Capture (serialization will fail):
// Non-serializable objects from test
FileOutputStream fos = new FileOutputStream("test.txt");
AtomicInteger counter = new AtomicInteger(0);
DatabaseDescriptor descriptor = ...; // From test classloader
cluster.get(1).runOnInstance(() -> {
fos.write(...); // FAIL: NotSerializableException
counter.incrementAndGet(); // FAIL: NotSerializableException
descriptor.get...(); // FAIL: Even if serializable, wrong classloader
});
Common Issues and Solutions
Issue 1: ClassCastException
Symptom:
java.lang.ClassCastException: org.apache.cassandra.dht.Murmur3Partitioner$LongToken cannot be cast to org.apache.cassandra.dht.Token
Cause: Object created in test classloader, trying to use in instance classloader. Even though the classes have the same name, they're loaded by different classloaders and are considered different types.
Wrong:
// Create token in test classloader
Token token = new Murmur3Partitioner.LongToken(0);
// Try to use in instance
cluster.get(1).runOnInstance(() -> {
DatabaseDescriptor.setPartitionerUnsafe(token); // FAIL: ClassCastException
});
Right:
// Create token inside instance classloader
cluster.get(1).runOnInstance(() -> {
Token token = new Murmur3Partitioner.LongToken(0);
DatabaseDescriptor.setPartitionerUnsafe(token); // OK
});
Why This Happens:
Test CL: Token.class → Object ID 0x1234
Instance CL: Token.class → Object ID 0x5678
0x1234 cannot be cast to 0x5678 even if identical bytecode
Issue 2: NotSerializableException
Symptom:
java.io.NotSerializableException: MyCustomClass
Cause: Lambda captured non-serializable object from test scope.
Wrong:
List<String> names = new ArrayList<>(); // ArrayList is Serializable
Thread thread = new Thread(() -> {}); // Thread is NOT Serializable
cluster.get(1).runOnInstance(() -> {
names.add("test"); // OK - ArrayList is Serializable
thread.start(); // FAIL - Thread is not Serializable
});
Right:
// Only capture serializable primitives
final String threadName = "test-thread";
cluster.get(1).runOnInstance(() -> {
Thread thread = new Thread(() -> {}, threadName); // Create inside
thread.start(); // OK
});
Issue 3: Static State Not Shared
Symptom: Static fields modified in one instance don't affect others.
Cause: Each instance has separate copy of static state.
Wrong Assumption:
// Set on instance 1
cluster.get(1).runOnInstance(() -> {
DatabaseDescriptor.setPartitioner(...);
});
// Expect to see on instance 2
cluster.get(2).runOnInstance(() -> {
// DatabaseDescriptor is DIFFERENT instance here
Partitioner p = DatabaseDescriptor.getPartitioner(); // Won't see change from node 1
});
Right Approach:
// Configure each instance separately
cluster.stream().forEach(instance -> {
instance.runOnInstance(() -> {
DatabaseDescriptor.setPartitioner(...);
});
});
Issue 4: ClassNotFoundException
Symptom:
java.lang.ClassNotFoundException: com.mycompany.TestUtils
Cause: Class from test classpath not available in instance classloader.
Wrong:
// TestUtils only in test classpath
import com.mycompany.TestUtils;
cluster.get(1).runOnInstance(() -> {
TestUtils.doSomething(); // FAIL: ClassNotFoundException
});
Right:
// Either:
// 1. Move TestUtils to Cassandra dependencies
// 2. Mark TestUtils as @Shared
// 3. Use only Cassandra classes inside runOnInstance
cluster.get(1).runOnInstance(() -> {
// Use only Cassandra classes
StorageService.instance.method(); // OK
});
Issue 5: Lambda Capture Confusion
Symptom: Unexpected behavior or null values inside lambda.
Cause: Captured variables must be effectively final.
Wrong:
int counter = 0;
for (int i = 0; i < 3; i++) {
cluster.get(i + 1).runOnInstance(() -> {
System.out.println(counter); // FAIL: counter must be final
});
counter++;
}
Right:
for (int i = 0; i < 3; i++) {
final int nodeId = i + 1; // Effectively final
cluster.get(nodeId).runOnInstance(() -> {
System.out.println("Node: " + nodeId); // OK
});
}
Debugging Techniques
Technique 1: Identify ClassLoader
Print which classloader loaded a class:
cluster.get(1).runOnInstance(() -> {
System.out.println("Current thread CL: " +
Thread.currentThread().getContextClassLoader());
System.out.println("Token CL: " +
Token.class.getClassLoader());
System.out.println("DatabaseDescriptor CL: " +
DatabaseDescriptor.class.getClassLoader());
});
Expected output:
Current thread CL: IsolatedClassLoader@abc123
Token CL: IsolatedClassLoader@abc123
DatabaseDescriptor CL: IsolatedClassLoader@abc123
Technique 2: Verify Serialization
Test if object can be serialized before using in lambda:
public static boolean isSerializable(Object obj) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
oos.close();
return true;
} catch (NotSerializableException e) {
System.out.println("NOT serializable: " + e.getMessage());
return false;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// Usage
MyObject obj = new MyObject();
if (isSerializable(obj)) {
// Safe to capture in lambda
final MyObject capturedObj = obj;
cluster.get(1).runOnInstance(() -> {
// Use capturedObj
});
} else {
// Must create inside runOnInstance
}
Technique 3: Explicit Transfer
Use IsolatedExecutor.transfer() to explicitly move objects:
import org.apache.cassandra.distributed.impl.IsolatedExecutor;
// Create in test classloader
MySerializableObject obj = new MySerializableObject();
// Explicitly transfer to instance classloader
cluster.get(1).runOnInstance(() -> {
MySerializableObject transferred = (MySerializableObject)
IsolatedExecutor.transferAdhoc(obj, Thread.currentThread().getContextClassLoader());
// Now 'transferred' is in correct classloader
useObject(transferred);
});
Technique 4: Inspect Lambda Captures
Use reflection to see what a lambda captured:
import java.lang.reflect.Field;
Consumer<Object> lambda = (obj) -> {
System.out.println(obj);
};
// Inspect captured fields
for (Field field : lambda.getClass().getDeclaredFields()) {
field.setAccessible(true);
System.out.println("Captured: " + field.getName() +
" = " + field.get(lambda));
}
Technique 5: Compare Class Identity
Check if two class references are actually the same:
Class<?> testToken = Token.class; // From test classloader
cluster.get(1).runOnInstance(() -> {
Class<?> instanceToken = Token.class; // From instance classloader
// These will be DIFFERENT
System.out.println("Test CL Token: " + System.identityHashCode(testToken));
System.out.println("Instance CL Token: " + System.identityHashCode(instanceToken));
System.out.println("Same class? " + (testToken == instanceToken)); // false!
});
Annotations: @Shared and @Isolated
@Shared Annotation
Mark classes to be loaded in shared classloader:
import org.apache.cassandra.utils.Shared;
@Shared
public class MySharedData implements Serializable {
private String value;
// Can be used across classloader boundaries
public MySharedData(String value) {
this.value = value;
}
}
Use @Shared for:
- Config/DTO classes
- Data transfer objects
- Immutable value classes
- Things that need to cross boundaries
@Isolated Annotation
Mark classes to be loaded per-instance:
import org.apache.cassandra.utils.Isolated;
@Isolated
public class MyInstanceState {
private static MyInstanceState instance;
// Each Cassandra instance gets own copy
public static MyInstanceState getInstance() {
if (instance == null)
instance = new MyInstanceState();
return instance;
}
}
Use @Isolated for:
- Stateful singletons
- Classes with static state
- Instance-specific configuration
Best Practices
1. Minimize Lambda Captures
Bad:
List<String> results = new ArrayList<>();
Map<String, Integer> counts = new HashMap<>();
AtomicInteger total = new AtomicInteger();
cluster.get(1).runOnInstance(() -> {
// Captures 3 objects
results.add("test");
counts.put("key", 1);
total.incrementAndGet();
});
Good:
cluster.get(1).runOnInstance(() -> {
// Create everything inside
List<String> results = new ArrayList<>();
results.add("test");
// Process locally
});
2. Use Return Values
Bad:
AtomicInteger result = new AtomicInteger();
cluster.get(1).runOnInstance(() -> {
int count = countSomething();
result.set(count); // Won't work - different classloader
});
Good:
int result = cluster.get(1).callOnInstance(() -> {
return countSomething(); // Return primitive
});
3. Create Objects in Target ClassLoader
Bad:
Token token = new Murmur3Partitioner.LongToken(0);
cluster.get(1).runOnInstance(() -> {
useToken(token); // ClassCastException
});
Good:
final long tokenValue = 0;
cluster.get(1).runOnInstance(() -> {
Token token = new Murmur3Partitioner.LongToken(tokenValue);
useToken(token); // OK
});
4. Use Primitives for Communication
Bad:
MyComplexObject obj = new MyComplexObject(...);
cluster.get(1).runOnInstance(() -> {
process(obj); // May fail serialization
});
Good:
final int id = obj.getId();
final String name = obj.getName();
cluster.get(1).runOnInstance(() -> {
MyComplexObject obj = new MyComplexObject(id, name);
process(obj); // OK - created in instance classloader
});
5. Coordinate via CQL
Bad:
// Try to share objects between instances
Object sharedState = cluster.get(1).callOnInstance(() -> {
return getState();
});
cluster.get(2).runOnInstance(() -> {
setState(sharedState); // ClassCastException
});
Good:
// Share via CQL
cluster.coordinator(1).execute(
"INSERT INTO shared.state (id, value) VALUES (?, ?)",
ConsistencyLevel.QUORUM, 1, "state-value");
// Read on other instance
Object[][] rows = cluster.coordinator(2).execute(
"SELECT value FROM shared.state WHERE id = ?",
ConsistencyLevel.QUORUM, 1);
Advanced: Understanding IsolatedExecutor
Serialization Process
// In test classloader
SerializableCallable<String> lambda = () -> "Hello";
// IsolatedExecutor.transfer():
byte[] serialized = serializeOneObject(lambda); // Test CL → bytes
// Transfer to instance classloader
Object deserialized = deserializeOneObject(serialized); // bytes → Instance CL
// Now deserialized is in instance classloader
Custom Transfer
For complex scenarios:
// Serialize in test classloader
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(myObject);
byte[] bytes = baos.toByteArray();
// Transfer and deserialize in instance classloader
cluster.get(1).runOnInstance(() -> {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
// Force resolution in instance classloader
return Class.forName(desc.getName(), false,
Thread.currentThread().getContextClassLoader());
}
};
Object obj = ois.readObject();
// obj is now in instance classloader
});
Troubleshooting Checklist
When encountering classloader issues:
-
Check what you're capturing
- Is it a primitive? → OK
- Is it Serializable? → Maybe OK
- Is it a Cassandra class? → Create inside runOnInstance
-
Verify serialization
- Use isSerializable() helper
- Check for non-serializable fields
-
Identify the classloader
- Print classloader info
- Compare class identity
-
Simplify the lambda
- Remove captures one by one
- Create objects inside
-
Use primitives
- Extract primitive values
- Pass only primitives
-
Return, don't modify
- Return results from callOnInstance
- Don't try to modify captured objects