【发布时间】:2020-01-26 14:49:59
【问题描述】:
我正在尝试编写一个测试来证明在多线程环境中为类字段分配新引用不是线程安全的,更具体地说,如果该字段未声明为 volatile 或AtomicReference。
我使用的场景是PropertiesLoader 类(如下所示),它应该加载存储在Map<String, String> 中的一组属性(当前只使用一个属性),并且还尝试支持重新加载。因此,有许多线程正在读取一个属性,并且在某个时间点,另一个线程正在重新加载一个需要对读取线程可见的新值。
测试的目的如下:
- 它调用读取器线程,这些线程正在自旋等待,直到它们“看到” 属性值变化
- 在某些时候,编写器线程会创建一个带有新属性值的新映射,并将该映射分配给相关字段 (
PropertyLoader.propertiesMap) - 如果所有读取线程都看到新值,则测试完成,否则将永远挂起。
现在我知道严格来说,没有测试可以证明某些代码的线程安全性(或缺乏它),但在这种情况下,我觉得它应该相对容易至少从经验上证明这个问题。
我尝试使用HashMap 实现来存储属性,在这种情况下,即使我只使用一个读取线程,测试也会按预期挂起。
但是,如果使用ConcurrentHashMap 实现,则无论使用多少读取线程,测试都不会挂起(我也尝试在读取线程中随机等待但没有成功)。
据我了解,ConcurrentHashMap 是线程安全的这一事实不应影响分配给它的字段的可见性。因此,该字段仍需要 volatile/AtomicReference。然而,上面的测试似乎与此相矛盾,因为它表现得好像地图总是安全地发布而不需要额外的同步。
我的理解错了吗?也许ConcurrentHashMap 做出了一些我不知道的额外同步承诺?
任何帮助将不胜感激。
附:下面的代码应该可以作为 Junit 测试执行。我已经在一台装有 AMD Ryzen 5、Windows 10、JDK 1.8.0_201 的机器上和第二台机器 i7 Intel、Fedora 30、JDK 1.8.xx(不记得 JDK 的确切版本)上运行它,结果相同。
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();
}
}
}
【问题讨论】:
-
据我所知你是对的,这应该是一个等待发生的错误。看看其他人是否有很好的解释会很有趣。
-
我认为,在访问包含
ConcurrentHashMap的变量时,忽略同步绝对不是一个好主意。但这并不保证会失败。ConcurrentHashMap在内部进行一些同步并使用易失语义访问变量。也许,这意外地使得在这个例子中可以安全地访问变量而无需任何同步。 -
@Donat 我知道这不是一个好主意,我在帖子中提到了这一点。我只是想编写一个测试来证明在
ConcurrentHashMap的情况下我无法做到的事实
标签: java multithreading thread-safety safe-publication