Java虚拟线程和ConcurrentHashMap

mil*_*lan 6 java deadlock virtual-threads

我偶然发现了虚拟线程和 ConcurrentHashMap 的问题。

正如下面的代码所示,如​​果您在 中输入锁定computeIfAbsent,VT 可能永远不会唤醒。这实际上取决于 VT 在载体线程上的调度方式,但我可以轻松地重现这一点。

我知道 ConcurrentHashMap 使用synchronized,而 VT 不能很好地使用synchronized,但没有地方说要避免ConcurrentHashMap,因为那会非常令人失望。

class Scratch {
    public static void main(String[] args) throws InterruptedException {
        var lock = new ReentrantLock();
        var map = new ConcurrentHashMap<Integer, String>();

        var callables = new ArrayList<Callable<Void>>();
        for (int i = 0; i < 32 /* more than threads in FJ pool */; i++) {
            callables.add(() -> {
                System.out.println("Spawned thread " + Thread.currentThread().threadId());
                map.computeIfAbsent(1, k -> {
                    System.out.println("on lock() " + Thread.currentThread().threadId());
                    lock.lock();
                    try {
                        System.out.println("within lock() " + Thread.currentThread().threadId());
                    } finally {
                        lock.unlock();
                    }
                    return "";
                });
                System.out.println("Exiting " + Thread.currentThread().threadId());
                return null;
            });
        }

        new Thread(() -> {
            System.out.println("locking " + Thread.currentThread().threadId());
            lock.lock();
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("unlocking " + Thread.currentThread().threadId());
            lock.unlock();
            System.out.println("unlocked " + Thread.currentThread().threadId());
        }).start();

        while (!lock.isLocked()) {
            Thread.sleep(10);
        }

        // it works with Executors.newCachedThreadPool()
        try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
            es.invokeAll(callables);
        }
        System.out.println("done");
    }
}
Run Code Online (Sandbox Code Playgroud)

rzw*_*oot 2

JDK 核心库通常不会在其 javadoc 中指定甚至脚注有关线程方面各种非常有用的内容的 impl 细节:它们很少记录它们的 color,并且确实没有很好地记录它们的锁。它们应该 - 这直接影响库调用者的代码,因此必须记录下来。要点是,即使您认为“这是一个错误,可以修复它”,“修复”将是:“现在 javadoc 已更新,告诉您,它确实使用synchronized”。

情况:无需修改

如果在开始第一个作业之前地图已完全形成(即在ConcurrentHashMap修改它的实例上没有调用任何方法 - no put、 no computeIfAbsent、 noclear等等),那么您可以继续使用 CHM:它是只读的方法(.get.getOrDefaultsize()、诸如此类的东西)不做synchronized () {}任何事情。

这种情况下的答案是:“不,你很好。不用担心,这synchronized是在 ConcurrentHashMap.java 的源代码中 - 它在这里根本不会影响你”。

情况:工作期间修改

如果任务确实需要作业(或第一个作业开始后的其他进程)能够修改地图,那么从根本上讲,您需要设置同步:您不能只是告诉系统只做一些百万个工作并以任何它喜欢的方式运行它们(例如:制造几百万个 VT,将它们全部扔到队列中,让一群承运人来应付这批工作),因为从根本上说,手头的工作无法完成way:这就是“同步”的全部内容,真的:如果您需要将数据从一个作业发送到另一个作业,那么这实际上非常复杂,并且需要一个外部同步系统(例如数据库或消息队列) ),或告诉 CPU 执行任务的同步原语(synchronizedvolatile、 或诸如 Compare-And-Set 之类的原语构造,由 、 等内容使用AtomicReference,并可在各种核心 API 中使用)。

所有这些原语都具有与其相关的巨大成本。这应该是显而易见的:CPU 从其 L1 缓存块之一读取某些数据所需的时间约为单个管道的单个周期的数量级,而尝试将任何数据发送到另一个核心则需要数百个时间。这样的循环。核心之间的巨大距离已经意味着它无法以光速在单个周期内完成。

CHM 无法用魔法消除这一点。

温和地说,VT 在这种情况下确实“不太好”。只是不要在这里使用它们。这听起来像是无用的建议。但也许不完全是:

解决方案 1 - 拆分单个作业

您能否隔离需要与该映射交互的代码部分(根据定义,或者我们不会出现在答案的这一部分中,在 VT 之间共享的映射),提取出一大堆代码可以用map-reduce术语来表示(代码获取完成其工作所需的数据作为参数,并通过返回来报告其结果 - 它不需要修改任何字段,也不需要读取任何字段正在被作业代码之外的内容修改,即根本不需要该映射)?

如果可能的话,那么这样做:有一个单独的处理程序来执行从 CHM 中提取所需内容、注册作业、处理已完成作业的报告、将作业结果重新集成回 CHM 的任务。在这一点上,可能值得将总任务的这一部分(与地图交互的代码)减少到单个线程,因为由于所有正在发生的事情,它可能主要以单线程性能运行synchronized,然后就可以了使用普通的简HashMap

解决方案 2 - 不同的数据结构

如果这不是一个选择,那么您需要一个与 CHM 提供的数据概念不同的数据概念。这个东西可以用数据库访问来表示吗?像 postgres 这样的数据库可以负责应用乐观锁定。这可能是一场灾难(如果您与该地图进行大量交互,在这种情况下,可能根本不可能通过并发来有意义地加速它!),但是如果工作不是“主要是读取和写入该地图” ',这可能会提供比 CHM 更快的性能。

解决方案 3 - 不同的工作划分

如果有 1000 个作业,并且可以将这些作业分为 8 个子集,并且子集 A 中的任何作业都不会影响子集 B 必须执行的操作(也许它可能会修改您的 CHM,但不会以某种方式修改)这对子集 B、C、D、E、F、G 或 H 中的作业有任何影响),那么您可以创建 8 个作业而不是 1000 个作业,每个作业完全隔离(不需要 CHM,作业制作地图、填充地图并将其作为最终产品返回),然后只需将 8 个子结果合并为一个更大的结果即可。

解决方案 4 - 将锤子收起

如果以上解决方案都不能解决问题,那么这份工作就基本上就是这样了。VT 并不是神奇的“快速”果汁。它们是一种在当前硬件上可以快速完成的工作的方法,但是在引入 VT之前,用 java 语言编写代码有点烦人和复杂,这样就会达到性能峰值,而理论上的问题可以达到 - 并使其更容易编写。

它们不会打开让您编写以前无法编写的代码的大门。对于所有语言功能来说更普遍的是(语言是图灵完备的。你可以用 em 做计算机能做的一切 - 只是,有些工作用语言编写是不必要的复杂,而新的 lang 功能可能会解决这个问题)。

如果您已经拥有无需 VT 即可运行的代码,那么可能已经接近峰值性能,并且将其重构为使用 VT 既不会使其运行速度更快,也不会使其更易于维护/理解。

解决方案 5 - 不用担心

也许您使用 CHM 根本不是为了它的“并发”方面,而只是为了它的 API:CHM 有各种法线贴图没有的方法,这些方法实际上与“并发”无关。例如,chm.keySet(defaultValue)给您一个具有以下实现的集合add:将事物添加到键集中将它们添加到映射中,并将默认值作为“值”部分。HashMap没有这个方法。我不知道为什么不。

同样,还有诸如 之类的东西reduceEntriesToInt,您可能会喜欢。也许您可以通过使用简单的 HashMap 并使用hashMap.entrySet().stream().reduce来替换它。

但是,如果这就是您正在做的事情:

  1. 每项工作都会产生一个CHM。该对象永远不会与任何其他作业共享。它是通过方法创建的,从未分配给字段,只是使用并丢弃。
  2. 它不是一个普通的 HashMap,只是因为您需要 CHM 有而 HM 没有的那些方法。

然后 CHM 最终会出现“无用的同步”——成为唯一在对象上同步的线程,甚至没有其他线程引用监视器,更不用说尝试synchronized()对其进行同步了。热点应该非常擅长消除这些监视器获取。您是否存在实际的性能问题(例如,您测量了它,并且它比您想象的要慢),或者,您是否只是担心格言“VT 和synchronized彼此不能很好地配合”因此意味着使用工作本地 CHM 是一个问题吗?“VT 和synchronized彼此不能很好相处”是一种过于简单化的说法,因为实际的格言是“VT 和等待获取监视器彼此不能很好相处”——如果每个 CHM 都在工作,这种情况就不会发生——当地的。

运行你的探查器来确保,JVM 会进行大量“无用的同步”,而 Hotspot 的作用就是优化常见模式。这是个好消息:

  1. 无用的同步是一种常见模式。
  2. 无用的同步很容易优化(只是......不获取任何东西)
  3. 无用的同步很容易被检测到。
  4. 因此,JVM 热点引擎擅长消除这种成本,并且会这样做,而且成本低廉。

要么#4 是正确的,要么“JVM 热点工程师不称职”是正确的。可能是,但这是一个非同寻常的说法,没有证据支持它。