ConcurrentHashMap不能按预期工作。

11

我正在为电子选举计票,初始版本中只有一个政党。每个选民将有不同的线程,并且线程将更新给定政党的投票数。

我决定使用ConcurrentHashMap,但结果并非我所预期...

Map<String, Integer> voting = new ConcurrentHashMap<>();

for (int i = 0; i < 16; i++) {
  new Thread(() -> {
    voting.put("GERB", voting.getOrDefault("GERB", 0) + 1);
  }).start();
}

for (int i = 0; i < 100; i++) {
  voting.put("GERB", voting.getOrDefault("GERB", 0) + 1);
}

Thread.sleep(5000); // Waits for the threads to finish

for (String s : voting.keySet()) {
  System.out.println(s + ": " + voting.get(s));
}

结果每次都不同 - 范围在114到116之间。

ConcurrentHashMap不是应该是同步的吗?

3个回答

12

这里有一个复合操作。你会得到给定键的映射值,将其增加一,然后将它再次放回相同键的映射中。你必须确保所有这些语句都原子执行。但是给定的实现没有强制执行这个前提条件。因此你最终会出现安全失败。

要解决这个问题,你可以使用在ConcurrentHashMap中定义的原子merge操作。整个方法调用是原子性的。下面是它的样子。

Map<String, Integer> voting = new ConcurrentHashMap<>();

for (int i = 0; i < 16; i++)
    new Thread(() -> {
        voting.merge("GERB", 1, Integer::sum);
    }).start();

for (int i = 0; i < 100; i++)
    voting.merge("GERB", 1, Integer::sum);

Thread.sleep(5000); // Waits for the threads to finish

for (String s : voting.keySet())
    System.out.println(s + ": " + voting.get(s));
运行该程序将产生以下输出:

GERB: 116


3

您可以将这行代码 voting.put("GERB", voting.getOrDefault("GERB", 0) + 1); 分为三个步骤:

int temp=voting.getOrDefault("GERB",0); //1
temp++;                                 //2
voting.put("GERB",temp);                //3

在第一行和第三行之间,因为该方法已经返回,其他线程可以更改与“GERB”相关联的值,没有任何东西可以阻止其他线程进行更改。所以当你调用 voting.put("GERB",temp) 时,你覆盖了它们的值,这使得它们的更新丢失。


3
假设有两个或者更多的线程执行以下代码:voting.put("GERB", voting.getOrDefault("GERB", 0) + 1),并且现在假设key "GERB"对应的value等于10。
  1. 线程#1获取voting.getOrDefault("GERB", 0)的值。它为10
  2. 线程#2获取voting.getOrDefault("GERB", 0)的值。它为10
  3. 线程#1将其加1,现在它是11
  4. 线程#2将其加1,现在它也是11
  5. 线程#1将值11写回到voting
  6. 线程#2将值11写回到voting
尽管2个线程都已经完成了处理,但由于并发操作的影响,最终结果只会增加1。
因此,ConcurrentHashMap的方法是同步的。这意味着,当一个线程执行例如put的操作时,另一个线程要等待。但是它们不以任何方式同步外部线程。
如果你需要执行多次调用,则必须自行同步它们。例如:
final Map<String, Integer> voting = new ConcurrentHashMap<>();

for (int i = 0; i < 16; i++) {
  new Thread(() -> {
    synchronized (voting) { // synchronize the whole operation over the same object
       voting.put("GERB", voting.getOrDefault("GERB", 0) + 1);
    }
  }).start();
}

更新 在评论中指出,对于voting对象的同步并不能保证与ConcurentHahMap的方法本身同步。如果那些调用可以并发执行,你必须为每个对voting方法的调用执行同步。事实上,你可以使用任何其他对象进行同步(不一定是voting):它只需要对所有线程都相同。

但正如@Holger所指出的那样,这违背了ConcurentHashMap的初衷。 为了利用ConcurentHashMap的原子机制而不锁定线程,可以使用replace方法以便在值被另一个线程更改时重试操作:

for (int i = 0; i < 16; i++) {
  new Thread(() -> {
    Integer oldValue, newValue;
    do {
       oldValue = voting.getOrDefault("GERB", 0);
       newValue = oldValue + 1; // do some actions over the value
    } while (!voting.replace("GERB", oldValue, newValue)); // repeat if the value was changed
  }).start();
}

4
ConcurrentHashMap类的方法同步,它们在彼此之间是原子性的,但这是一种不同的机制。因此,当您使用synchronized(voting)时,您是在保护其他线程使用synchronized (voting),但并不能防止未经同步的put调用等并发修改。您需要在每个访问上都包装一个这样的同步块才能使其正常工作,这违背了使用ConcurrentHashMap的整个目的。 - Holger
@Holger,没错。我更新了答案以澄清这一点。 - AterLux
那么,ConcurrentHashMap 只是确保如果一个线程调用了 'put',另一个线程就不能调用它,但不会锁定其他方法,比如 'get'? - Vallerious
@Vallerious 它并不锁定整个方法,只是保证结果的一致性。即使在不同线程中同时执行putget方法,也不会损坏映射或值。即get方法将返回先前的值或新值。 - AterLux

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接