与 ConcurrentHashMap 的 .compute() 同步是否保证可见性?

问题描述

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

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

下面我将发布一个简化的示例。根据测试,今天没有比赛条件。但是我可以长期信任此代码而没有 volatilelong 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,v) -> {
            CountingObject returnLock;
            returnLock = v == null ? new CountingObject() : v;

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

    public void releaSEObjectForId(Long id,CountingObject o) {
        map.compute(id,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,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;
        }
    }
}

解决方法

不,这种方法行不通,甚至对于 volatile 都行不通。您必须使用 AtomicLongLongAdder 或类似方法来确保线程安全。如今,ConcurrentHashMap 甚至无法使用分段锁。

此外,您的测试也不能证明任何事情。根据定义,并发问题不会每次都发生。甚至不是每百万次。

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

,

不要被 compute 文档中的那句话所迷惑:

整个方法调用以原子方式执行

这确实对副作用有效,就像您在 value++ 中所做的那样;它仅适用于 ConcurrentHashMap 的内部数据。

您错过的第一件事是 locking 中的 CHM,实现发生了很大变化(正如另一个答案所指出的那样)。但即使没有,你的理解是:

我知道当你在锁上显式同步时,可见性是有保证的

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