有一个类Counter
,它包含一组键,允许递增每个键的值并获取所有值。所以,我试图解决的任务与Atomically incrementing counters stored in ConcurrentHashMap中的任务相同。不同之处在于密钥集是无界的,因此经常添加新密钥。
为了减少内存消耗,我会在读取后清除值,这发生在Counter.getAndClear()
中。密钥也被删除,这似乎打破了。
一个线程增加随机密钥,另一个线程获取所有值的快照并清除它们。
代码如下:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.Map;
import java.util.HashMap;
import java.lang.Thread;
class HashMapTest {
private final static int hashMapInitSize = 170;
private final static int maxKeys = 100;
private final static int nIterations = 10_000_000;
private final static int sleepMs = 100;
private static class Counter {
private ConcurrentMap<String, Long> map;
public Counter() {
map = new ConcurrentHashMap<String, Long>(hashMapInitSize);
}
public void increment(String key) {
Long value;
do {
value = map.computeIfAbsent(key, k -> 0L);
} while (!map.replace(key, value, value + 1L));
}
public Map<String, Long> getAndClear() {
Map<String, Long> mapCopy = new HashMap<String, Long>();
for (String key : map.keySet()) {
Long removedValue = map.remove(key);
if (removedValue != null)
mapCopy.put(key, removedValue);
}
return mapCopy;
}
}
// The code below is used for testing
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread = new Thread(new Runnable() {
public void run() {
for (int j = 0; j < nIterations; j++) {
int index = ThreadLocalRandom.current().nextInt(maxKeys);
counter.increment(Integer.toString(index));
}
}
}, "incrementThread");
Thread readerThread = new Thread(new Runnable() {
public void run() {
long sum = 0;
boolean isDone = false;
while (!isDone) {
try {
Thread.sleep(sleepMs);
}
catch (InterruptedException e) {
isDone = true;
}
Map<String, Long> map = counter.getAndClear();
for (Map.Entry<String, Long> entry : map.entrySet()) {
Long value = entry.getValue();
sum += value;
}
System.out.println("mapSize: " + map.size());
}
System.out.println("sum: " + sum);
System.out.println("expected: " + nIterations);
}
}, "readerThread");
thread.start();
readerThread.start();
thread.join();
readerThread.interrupt();
readerThread.join();
// Ensure that counter is empty
System.out.println("elements left in map: " + counter.getAndClear().size());
}
}
测试时我注意到一些增量丢失了。我得到以下结果:
sum: 9993354
expected: 10000000
elements left in map: 0
如果您无法重现此错误(该总和小于预期),您可以尝试将maxKeys增加几个数量级或减少hashMapInitSize或增加nIterations(后者也会增加运行时间)。我还包括测试代码(主要方法),如果它有任何错误。
我怀疑在运行时增加ConcurrentHashMap的容量时会发生错误。在我的计算机上,代码似乎在hashMapInitSize
为170时正常工作,但在hashMapInitSize
为171时失败。我认为171的大小会触发容量增加(128 / 0.75 == 170.66,其中0.75是哈希映射的默认加载因子)。
所以,问题是:我正确使用remove
,replace
和computeIfAbsent
操作吗?我假设它们是ConcurrentHashMap
基于Use of ConcurrentHashMap eliminates data-visibility troubles?的答案的原子操作。如果是这样,为什么一些增量会丢失?
编辑:
我认为我错过了一个重要的细节,increment()
应该比getAndClear()
更频繁地调用,以便我尽量避免increment()
中的任何显式锁定。但是,我将在稍后测试不同版本的性能,看它是否真的是一个问题。
答案 0 :(得分:1)
我认为问题是在remove
上迭代时使用keySet
。这就是JavaDoc对Map#keySet()
所说的内容(我的重点):
返回此地图中包含的键的Set视图。该集由地图支持,因此对地图的更改将反映在集中,反之亦然。 如果在对集合进行迭代时修改了映射(除非通过迭代器自己的remove操作),迭代的结果是未定义的。
ConcurrentHashMap
的JavaDoc提供了更多线索:
类似地,Iterators,Spliterators和Enumerations在迭代器/枚举的创建时或之后的某个时刻返回反映哈希表状态的元素。
结论是,在迭代键时改变地图是不可预测的。
一种解决方案是为getAndClear()操作创建一个新映射,然后返回旧映射。必须保护开关,在下面的示例中,我使用了ReentrantReadWriteLock
:
class HashMapTest {
private final static int hashMapInitSize = 170;
private final static int maxKeys = 100;
private final static int nIterations = 10_000_000;
private final static int sleepMs = 100;
private static class Counter {
private ConcurrentMap<String, Long> map;
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
ReadLock readLock = lock.readLock();
WriteLock writeLock = lock.writeLock();
public Counter() {
map = new ConcurrentHashMap<>(hashMapInitSize);
}
public void increment(String key) {
readLock.lock();
try {
map.merge(key, 1L, Long::sum);
} finally {
readLock.unlock();
}
}
public Map<String, Long> getAndClear() {
ConcurrentMap<String, Long> oldMap;
writeLock.lock();
try {
oldMap = map;
map = new ConcurrentHashMap<>(hashMapInitSize);
} finally {
writeLock.unlock();
}
return oldMap;
}
}
// The code below is used for testing
public static void main(String[] args) throws InterruptedException {
final AtomicBoolean ready = new AtomicBoolean(false);
Counter counter = new Counter();
Thread thread = new Thread(new Runnable() {
public void run() {
for (int j = 0; j < nIterations; j++) {
int index = ThreadLocalRandom.current().nextInt(maxKeys);
counter.increment(Integer.toString(index));
}
}
}, "incrementThread");
Thread readerThread = new Thread(new Runnable() {
public void run() {
long sum = 0;
while (!ready.get()) {
try {
Thread.sleep(sleepMs);
} catch (InterruptedException e) {
//
}
Map<String, Long> map = counter.getAndClear();
for (Map.Entry<String, Long> entry : map.entrySet()) {
Long value = entry.getValue();
sum += value;
}
System.out.println("mapSize: " + map.size());
}
System.out.println("sum: " + sum);
System.out.println("expected: " + nIterations);
}
}, "readerThread");
thread.start();
readerThread.start();
thread.join();
ready.set(true);
readerThread.join();
// Ensure that counter is empty
System.out.println("elements left in map: " + counter.getAndClear().size());
}
}