【问题标题】:Does synchronisation with ConcurrentHashMap's .compute() guarantee visibility?与 ConcurrentHashMap 的 .compute() 同步是否保证可见性?
【发布时间】:2021-03-10 21:03:05
【问题描述】:

ConcurrentHashMap.compute() 内,我递增和递减位于共享内存中的一些长值。读取、递增/递减仅在 compute 方法中对同一键执行。 因此对 long 值的访问是通过锁定 ConcurrentHashMap 段来同步的,因此递增/递减是原子。我的问题是:地图上的这种同步是否保证长期价值的可见性?我可以依赖 Map 的内部同步还是应该做多值volatile

我知道,当您在锁上显式同步时,可以保证可见性。但我对ConcurrentHashMap 的内部结构并不完全了解。或者也许我今天可以信任它,但明天ConcurrentHashMap 的内部结构可能会发生某种变化:独占访问权将被保留,但可见性将消失……这是使我的长期价值不稳定的论据。

下面我将发布一个简化的示例。根据测试,今天没有比赛条件。但是,如果没有 volatile for long value,我可以长期信任此代码吗?

class LongHolder {

    private final ConcurrentMap<Object, Object> syncMap = new ConcurrentHashMap<>();
    private long value = 0;

    public void increment() {
        syncMap.compute("1", (k, v) -> {
            if (++value == 2000000) {
                System.out.println("Expected final state. If this gets printed, this simple test did not detect visibility problem");
            }
            return null;
        });
    }
}

class IncrementRunnable implements Runnable {

    private final LongHolder longHolder;

    IncrementRunnable(LongHolder longHolder) {
        this.longHolder = longHolder;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000000; i++) {
            longHolder.increment();
        }
    }
}


public class ConcurrentMapExample {
    public static void main(String[] args) throws InterruptedException {
        LongHolder longholder = new LongHolder();
        Thread t1 = new Thread(new IncrementRunnable(longholder));
        Thread t2 = new Thread(new IncrementRunnable(longholder));
        t1.start();
        t2.start();
    }
}

UPD:添加另一个更接近我正在处理的代码的示例。当没有其他人使用该对象时,我想删除地图条目。请注意,long 值的读取和写入仅发生在 ConcurrentHashMap.compute 的重映射函数内部:

public class ObjectProvider {

    private final ConcurrentMap<Long, CountingObject> map = new ConcurrentHashMap<>();

    public CountingObject takeObjectForId(Long id) {
        return map.compute(id, (k, v) -> {
            CountingObject returnLock;
            returnLock = v == null ? new CountingObject() : v;

            returnLock.incrementUsages();
            return returnLock;
        });
    }

    public void releaseObjectForId(Long id, CountingObject o) {
        map.compute(id, (k, v) -> o.decrementUsages() == 0 ? null : o);
    }
}

class CountingObject {
    private int usages;

    public void incrementUsages() {
        --usages;
    }

    public int decrementUsages() {
        return --usages;
    }
}

UPD2:我承认我之前没有提供最简单的代码示例,现在发布一个真实的代码:

public class LockerUtility<T> {

    private final ConcurrentMap<T, CountingLock> locks = new ConcurrentHashMap<>();

    public void executeLocked(T entityId, Runnable synchronizedCode) {
        CountingLock lock = synchronizedTakeEntityLock(entityId);
        try {
            lock.lock();
            try {
                synchronizedCode.run();
            } finally {
                lock.unlock();
            }
        } finally {
            synchronizedReturnEntityLock(entityId, lock);
        }

    }

    private CountingLock synchronizedTakeEntityLock(T id) {
        return locks.compute(id, (k, l) -> {
            CountingLock returnLock;
            returnLock = l == null ? new CountingLock() : l;

            returnLock.takeForUsage();
            return returnLock;
        });
    }

    private void synchronizedReturnEntityLock(T lockId, CountingLock lock) {
        locks.compute(lockId, (i, v) -> lock.returnBack() == 0 ? null : lock);
    }

    private static class CountingLock extends ReentrantLock {
        private volatile long usages = 0;

        public void takeForUsage() {
            usages++;
        }

        public long returnBack() {
            return --usages;
        }
    }
}

【问题讨论】:

    标签: java java.util.concurrent concurrenthashmap memory-visibility


    【解决方案1】:

    不,这种方法行不通,甚至都行不通。您将不得不使用AtomicLongLongAdder 或类似的方法来确保它是正确的线程安全的。 ConcurrentHashMap 现在甚至无法使用分段锁。

    另外,你的测试并不能证明什么。根据定义,并发问题并非每次都会发生。甚至不是每百万次。

    您必须使用适当的并发Long 累加器,例如AtomicLongLongAdder

    【讨论】:

    • 我没有在我的问题中强调 SAME 键一直用于单个 long 值。仍然不明白:为什么原子性不适用于增加 long 值?是否有可能多个线程同时站在compute 的重映射BiFunction 内部?
    • 看起来不像,但这不会给您带来可见性效果。其他线程不一定会看到counter 的更新。 能保证可见性。
    【解决方案2】:

    不要被compute文档中的那一行所迷惑:

    整个方法调用都是原子执行的

    这确实适用于副作用,就像你在 value++ 中所看到的那样;它只适用于ConcurrentHashMap的内部数据。

    您想念的第一件事是CHM 中的locking,实现发生了很大变化(正如其他答案所指出的)。但即使没有,你的理解是:

    我知道,当您在锁上显式同步时,可以保证可见性

    有缺陷。 JLS 表示当readerwriter 使用相同的锁 时,这是可以保证的;在您的情况下显然不会发生;因此,没有任何保证。通常happens-before 保证(您在此处需要)仅适用于阅读器和作者的配对。

    【讨论】:

    • 你的回答是否意味着computes 的原子性可以通过在CHM 内部使用不同的锁来保证?
    • @Kirill 不,我的意思是你的getValue 没有使用与compute 相同的锁
    • 同意,我的问题示例在阅读最终结果方面绝对存在缺陷。我调整了这个例子。在我的真实代码中,long 值的读取和写入仅发生在compute 内部。仍然无法保证能见度?
    • @Kirill 仍然没有,compute 可能在内部锁定完全不同的读取和写入。在这种情况下,该方法(包文档也是如此)没有提及任何关于 happens-before 的内容。我不会依赖它
    • @Kirill 您的编辑并没有改善情况,而是相当可怕。即使您碰巧编写了一个明显安全的变体,我们也必须假设您的真实案例再次看起来不同,并且我们这边的任何陈述都会产生误导。如果您决定再次编辑您的问题并且答案与问题的示例不匹配,这同样适用。除此之外,即使您从问题的代码中删除查询方法,您也会分发一个对象,如果没有人查询其状态,那么该对象将完全无用,因此这些(损坏的)查询肯定存在于真实代码中。
    猜你喜欢
    • 2018-02-28
    • 2012-10-12
    • 2010-11-20
    • 1970-01-01
    • 2017-08-09
    • 1970-01-01
    • 1970-01-01
    • 2019-10-02
    相关资源
    最近更新 更多