HashMap 同步 `put` 而不是 `get`

HashMap synchronized `put` but not `get`

我有以下代码片段,我想看看它是否可以 crash/misbehave 在某个时候。从多个线程调用 HashMap,其中 put 在同步块内,而 get 不在。这段代码有什么问题吗?如果是这样,考虑到我只使用 putget 并且没有 putAllclear 或任何操作,我需要做哪些修改才能看到这种情况发生涉及。

import java.util.HashMap;
import java.util.Map;

public class Main {
    Map<Integer, String> instanceMap = new HashMap<>();

    public static void main(String[] args) {
        System.out.println("Hello");

        Main main = new Main();

        Thread thread1 = new Thread("Thread 1"){
            public void run(){
                System.out.println("Thread 1 running");
                for (int i = 0; i <= 100; i++) {
                    System.out.println("Thread 1 " + i + "-" + main.getVal(i));
                }
            }
        };
        thread1.start();


        Thread thread2 = new Thread("Thread 2"){
            public void run(){
                System.out.println("Thread 2 running");
                for (int i = 0; i <= 100; i++) {
                    System.out.println("Thread 2 " + i + "-" + main.getVal(i));
                }
            }
        };
        thread2.start();
    }

    private String getVal(int key) {
        check(key);
        return instanceMap.get(key);
    }

    private void check(int key) {
        if (!instanceMap.containsKey(key)) {
            synchronized (instanceMap) {
                if (!instanceMap.containsKey(key)) {
                    // System.out.println(Thread.currentThread().getName());
                    instanceMap.put(key, "" + key);
                }
            }
        }
    }
}

我查看的内容:

我稍微修改了你的代码:

  1. 从“热”循环中移除System.out.println(),它是内部同步的
  2. 增加迭代次数
  3. 将打印更改为仅在出现意外值时打印

我们可以做和尝试的还有很多,但这已经失败了,所以我就此打住。下一步我们将把整个东西重写为 jcsctress.

瞧,正如预期的那样,有时这种情况会发生在我装有 Temurin 17 的 Intel MacBook Pro 上:

Exception in thread "Thread 2" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
    at com.gitlab.janecekpetr.playground.Playground.getVal(Playground.java:35)
    at com.gitlab.janecekpetr.playground.Playground.lambda[=10=](Playground.java:21)
    at java.base/java.lang.Thread.run(Thread.java:833)

代码:

private record Val(int index, int value) {}

private static final int MAX = 100_000;
private final Map<Integer, Integer> instanceMap = new HashMap<>();

public static void main(String... args) {
    Playground main = new Playground();

    Runnable runnable = () -> {
        System.out.println(Thread.currentThread().getName() + " running");
        Val[] vals = new Val[MAX];
        for (int i = 0; i < MAX; i++) {
            vals[i] = new Val(i, main.getVal(i));
        }
        System.out.println(Stream.of(vals).filter(val -> val.index() != val.value()).toList());
    };

    Thread thread1 = new Thread(runnable, "Thread 1");
    thread1.start();

    Thread thread2 = new Thread(runnable, "Thread 2");
    thread2.start();
}

private int getVal(int key) {
    check(key);
    return instanceMap.get(key);
}

private void check(int key) {
    if (!instanceMap.containsKey(key)) {
        synchronized (instanceMap) {
            if (!instanceMap.containsKey(key)) {
                instanceMap.put(key, key);
            }
        }
    }
}

具体解释@PetrJaneček 的回答中出色的侦查工作:

java 中的每个字段都附有一个邪恶的硬币。每当任何线程读取该字段时,它都会抛硬币。这不是一枚公平的硬币——它是邪恶的。如果这会毁了你的一天,它会连续翻转 10,000 次(例如,你可能有依赖于以某种方式着陆的硬币翻转的代码,否则它将无法工作。硬币是邪恶的:你可能 运行 遇到这样的情况,那就是毁了你的一天,在你所有的广泛测试中,硬币会翻转正面,在生产的第一周,所有的正面都会翻转。然后新的大潜在客户演示你的应用程序,硬币开始向你甩尾巴)。

coinflip 决定使用字段的哪个变体 - 因为每个线程可能有也可能没有该字段的本地缓存。当您从任何线程写入字段时?硬币被翻转,反面朝上,本地缓存被更新,没有更多的事情发生。从任何线程读取?硬币被翻转。在尾部,使用本地缓存。

这当然不是真正发生的事情(你的 JVM 实际上并没有邪恶的硬币,也不是为了得到你),而是 JMM(Java 内存模型),以及现代硬件的现实, 意味着这种抽象工作得很好:它会在编写并发代码时可靠地得出正确的答案,也就是说,任何被多个线程触及的字段 必须 围绕它,或者在 multi-thread 访问 'session'.

的整个持续时间内绝不能改变

您可以通过建立 so-called Happens Before 关系强制 JVM 按您想要的方式掷硬币。这是 JMM 使用的明确术语。如果 2 行代码有 Happens-Before 关系(一个定义为 'happening before' 另一个,根据 JMM 的 HB 关系建立操作列表),那么这是不可能的(缺少错误JVM 本身)观察 HA 行的任何副作用,同时不观察 HB 行的所有副作用。 (也就是说:就您的代码而言,'happens before' 行发生在 'happens after' 行之前,尽管这有点像 schrodiner 的猫情况。如果您的代码实际上没有查看这些文件以一种你永远能够分辨的方式,然后 JVM 可以自由地不这样做。它不会,你可以相信邪恶的硬币是邪恶的:如果 JMM 采取 'right' , CPU、OS、JVM 发行版、版本和月相的某种组合来组合使用它。

一小部分常见的 HB/HA 建立条件:

  • synchronized(lock) 块内的第一行是相对于在任何其他线程中命中该块的 HA。
  • 退出 synchronized(lock) 块相对于进入任何 synchronized(lock) 块的任何其他线程都是 HB,假设两个 lock 是相同的引用。
  • thread.start() 是相对于线程将 运行.
  • 的第一行的 HB
  • 'natural' HB/HA:如果 X 和 Y 是同一线程的 运行 并且 X 在您的代码中是 'before it',则 X 行是相对于行 Y 的 HB .您不能编写 x = 5; y = x; 并让 y 由未见证 x = 5 发生的 x 版本设置(当然,如果另一个线程是 修改x,除非你有HB/HA修改x
  • 写入和读取 volatile 建立 HB/HA 但您通常无法获得关于哪个方向的任何保证运行。

这解释了您的代码可能失败的方式:get() 调用与调用 put() 的其他线程完全没有 HB/HA 关系,因此 get() 调用可能会也可能不会使用 HashMap 内部使用的各种字段的本地缓存变体,这取决于邪恶的硬币( 当然会击中某些字段;它将是 private 字段在 HashMap 实现的某个地方,所以你不知道是哪些,但是 HashMap 显然有 long-lived 状态,这意味着涉及字段)。

那么,为什么您实际上没有设法 'see' 您的代码像 JMM 所说的那样爆炸? 因为硬币是邪恶的。你 不能 依赖这条推理线:“我写了一些代码,如果我需要的同步没有按照我想要的方式发生,我应该会失败。我 运行 整个很多次,它从未失败过,因此,显然这段代码是 concurrency-safe,我可以在我的生产代码中使用它”。 根本不可靠。这就是为什么你需要思考:邪恶!那枚硬币是用来抓我的!因为如果不这样做,您可能会想像这样编写测试代码。

您应该害怕编写多个线程与同一字段交互的代码。你应该向后弯腰以避免它。使用消息queues。通过使用数据库在线程之间进行聊天,数据库对这些东西有更好的原语(t运行sactions 和隔离级别)。重写代码,以便它预先接受一堆参数,然后 运行s 根本不通过字段与其他线程交互,直到全部完成,然后它 returns 结果(然后使用例如fork/join 框架,使一切正常)。使您的网络服务器性能良好并使用所有内核只需依赖于每个传入请求都将是其自己的线程这一事实,因此您使用所有内核唯一需要发生的事情就是让很多人访问您的服务器同一时间。如果您没有足够的请求,那太好了!您的服务器不忙,所以您没有使用所有内核也没关系。

如果您真的认为从多个线程与同一个场交互是正确的答案,您需要考虑 NASA 编程火星探测器在与这些场交互的线路上,因为测试根本不可靠。这并不像听起来那么难 - 特别是如果您将与相关领域的实际互动降到最低限度并不断思考:“我是否建立了 HB/HA”?

在这种情况下,我认为 Petr 的判断是正确的:System.out.println 非常慢并且会执行各种同步操作。 JMM是一揽子交易,并且是可交换的:一旦HB/HA成立,HB行改变的所有内容对于HA行中的代码都是可见的,并且添加自然规则,这意味着HA行之后的所有代码都不可能观察一个宇宙,其中 HB 线之前的任何线 尚不可见 。换句话说,System.out.println 语句 HB/HA 以某种顺序彼此,但你不能依赖它(System.out 不指定同步。但是,几乎每个实现都. 你不应该依赖实现细节,我可以简单地为你写一些 java 合法的代码,编译,运行s,并且不违反合同,因为你可以设置 System.outSystem.setOut - 在与 System.out! 交互时不同步。在这种情况下,邪恶的硬币通过 System.out.

的故意未指定行为采取 'accidental' 同步的形式

下面的解释更符合JMM中使用的术语。如果您想更深入地了解该主题,可能会有用。

2 个操作在访问相同地址时发生冲突并且至少有 1 个写入。

2 当操作未按先行关系排序时(它们之间没有先行边),它们是并发的。

2 个操作在冲突和并发时处于数据竞争状态。

当您的程序中存在数据竞争时,可能会发生奇怪的问题,例如指令的意外重新排序、可见性问题或原子性问题。

那么是什么构成了 happens-before 关系。如果易失性读取观察到特定的易失性写入,则写入和读取之间存在先行边沿。这意味着读取不仅会看到写入,还会看到写入之前发生的所有事情。还有其他发生前边沿的来源,例如监视器的释放和同一监视器的后续获取。当 A 按程序顺序出现在 B 之前时,A、B 之间存在先行边。注意:happens-before 关系是传递的,所以如果 A happens-before B 和 B happens-before C,那么 A happens-before C.

在您的情况下,您有一个 get/put 操作冲突,因为它们访问相同的地址并且至少有 1 个写入。

put/get 操作是并发的,因为在写入和读取之间没有先发生边,因为即使写入释放监视器,get 也不会获取它。

由于 put/get 操作是并发和冲突的,它们处于数据竞争中。

解决此问题的最简单方法是在同步块中执行 map.get(使用相同的监视器)。这将引入所需的 happens-before 边缘并使操作顺序而不是并发,因此数据竞争消失。

一个性能更好的解决方案是使用 ConcurrentHashMap。不是单个中央锁,而是有许多锁,它们可以并发获取以提高可伸缩性和性能。我不打算深入研究 ConcurrentHashMap 的优化,因为会造成混淆。

[编辑] 除了数据竞争之外,您的代码还存在竞争条件问题。