I am trying to write a test that demonstrates that assigning a new reference to a class' field in a multi-threading environment is not thread-safe and more specifically has visibility problems if that field is not declared as volatile
or AtomicReference
.
The scenario I use is a PropertiesLoader
class (shown below), which is supposed to load a set of properties (currently only one property is used) stored in a Map<String, String>
and also tries to support reloading. So there are many threads reading a property and at some point in time another thread is reloading a new value that needs to be visible to the reading threads.
The test is intended to work as following:
- it invokes the reader threads which are spin-waiting until they "see" the property value change
- at some point the writer thread creates a new map with a new value for the property and assigns that map to the field in question (
PropertyLoader.propertiesMap
) - if all reader threads see the new value the test is completed otherwise it hangs forever.
Now I know that strictly speaking, there is no test that can prove the thread-safeness of some code (or the lack of it) but in this case I feel like it should be relatively easy to demonstrate the problem at least empirically.
I have tried using a HashMap
implementation to store the properties and in this case the test hangs as expected even if I use only one reading thread.
If however, a ConcurrentHashMap
implementation is used, the test never hangs no matter how many reading threads are being used (I have also tried waiting randomly in the reader threads with no success).
As far as my understanding goes, the fact that ConcurrentHashMap
is thread-safe should not affect the visibility of the field where it is assigned to. So volatile
/AtomicReference
is still required for that field. However the above test seems to contradicts this since it behaves as if the map is always safely published without the need of additional synchronization.
Is my understanding wrong? Perhaps ConcurrentHashMap
makes some additional synchronization promises that I am not aware of?
Any help would be highly appreciated.
P.S. The code below should be executable as is as a Junit test. I have run it in a machine with AMD Ryzen 5, Windows 10, JDK 1.8.0_201 and in a second machine i7 Intel, Fedora 30, JDK 1.8.xx (not remember the exact version of JDK) with the same results.
import org.junit.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class PropertiesLoaderTest {
private static final String NEW_VALUE = "newValue";
private static final String OLD_VALUE = "oldValue";
private static final String PROPERTY = "property";
/**
* Controls if the reference we are testing for visibility issues ({@link PropertiesLoader#propertyMap} will
* be assigned a HashMap or ConcurrentHashMap implementation during {@link PropertiesLoader#load(boolean)}
*/
private static boolean USE_SIMPLE_MAP = false;
@Test
public void testReload() throws Exception {
PropertiesLoader loader = new PropertiesLoader();
Random random = new Random();
int readerThreads = 5;
int totalThreads = readerThreads + 1;
final CountDownLatch startLatch = new CountDownLatch(1);
final CountDownLatch finishLatch = new CountDownLatch(totalThreads);
// start reader threads that read the property trying to see the new property value
for (int i = 0; i < readerThreads; i++) {
startThread("reader-thread-" + i, startLatch, finishLatch, () -> {
while (true) {
String value = loader.getProperty(PROPERTY);
if (NEW_VALUE.equals(value)) {
log("Saw new value: " + value + " for property: " + PROPERTY);
break;
}
}
});
}
// start writer thread (i.e. the thread that reloads the properties)
startThread("writer-thread", startLatch, finishLatch, () -> {
Thread.sleep(random.nextInt(500));
log("starting reload...");
loader.reloadProperties();
log("finished reload...");
});
log("Firing " + readerThreads + " threads and 1 writer thread...");
startLatch.countDown();
log("Waiting for all threads to finish...");
finishLatch.await();
log("All threads finished. Test successful");
}
static class PropertiesLoader {
// The reference in question: this is assigned in the constructor and again when calling reloadProperties()
// It is not volatile nor AtomicReference so there are visibility concerns
Map<String, String> propertyMap;
PropertiesLoader() {
this.propertyMap = load(false);
}
public void reloadProperties() {
this.propertyMap = load(true);
}
public String getProperty(String propertyName) {
return propertyMap.get(propertyName);
}
private static Map<String, String> load(boolean isReload) {
// using a simple HashMap always hang the test as expected: the new reference cannot be
// seen by the reader thread
// using a ConcurrentHashMap always allow the test to finish no matter how many reader
// threads are used
Map<String, String> newMap = USE_SIMPLE_MAP ? new HashMap<>() : new ConcurrentHashMap<>();
newMap.put(PROPERTY, isReload ? NEW_VALUE : OLD_VALUE);
return newMap;
}
}
static void log(String msg) {
//System.out.println(Thread.currentThread().getName() + " - " + msg);
}
static void startThread(String name, CountDownLatch start, CountDownLatch finish, ThreadTask task) {
Thread t = new Thread(new ThreadTaskRunner(name, start, finish, task));
t.start();
}
@FunctionalInterface
interface ThreadTask {
void execute() throws Exception;
}
static class ThreadTaskRunner implements Runnable {
final CountDownLatch start;
final CountDownLatch finish;
final ThreadTask task;
final String name;
protected ThreadTaskRunner(String name, CountDownLatch start, CountDownLatch finish, ThreadTask task) {
this.start = start;
this.finish = finish;
this.task = task;
this.name = name;
}
@Override
public void run() {
try {
Thread.currentThread().setName(name);
start.await();
log("thread started");
task.execute();
log("thread finished successfully");
} catch (Exception e) {
log("Error: " + e.getMessage());
}
finish.countDown();
}
}
}