Гарантирует ли синхронизация с ConcurrentHashMap .compute() видимость?

Внутри ConcurrentHashMap.compute() я увеличиваю и уменьшаю какое-то длинное значение, расположенное в общей памяти. Чтение, увеличение/уменьшение выполняется только в методе compute для одного и того же ключа. Таким образом, доступ к длинному значению синхронизируется путем блокировки сегмента ConcurrentHashMap, поэтому увеличение/уменьшение является атомарным. У меня такой вопрос: гарантирует ли эта синхронизация на карте видимость длинного значения? Могу ли я полагаться на внутреннюю синхронизацию Map или мне следует сделать длинное значение volatile?

Я знаю, что когда вы явно синхронизируете на блокировке, видимость гарантируется. Но у меня нет идеального понимания ConcurrentHashMap внутренностей. Или, может быть, я могу доверять этому сегодня, но завтра внутренности ConcurrentHashMap могут как-то измениться: монопольный доступ сохранится, но исчезнет видимость... и это аргумент, чтобы сделать мое длинное значение изменчивым.

Ниже я опубликую упрощенный пример. Судя по тесту, сегодня гонки нет. Но могу ли я доверять этому коду в долгосрочной перспективе без volatile вместо 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: добавил еще один пример, который ближе к коду, над которым я работаю. Я хотел бы удалить записи карты, когда никто другой не использует объект. Обратите внимание, что чтение и запись длинного значения происходит только внутри функции переназначения 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;
        }
    }
}

person Kirill    schedule 10.03.2021    source источник


Ответы (2)


Нет, этот подход не будет работать, даже с volatile. Вам нужно будет использовать AtomicLong, LongAdder или тому подобное, чтобы сделать это должным образом потокобезопасным. ConcurrentHashMap в наши дни даже не работает с сегментированными блокировками.

Кроме того, ваш тест ничего не доказывает. Проблемы параллелизма по определению возникают не каждый раз. Даже не каждый миллионный раз.

Вы должны использовать правильный параллельный аккумулятор Long, такой как AtomicLong или LongAdder.

person Louis Wasserman    schedule 10.03.2021
comment
В своем вопросе я не акцентировал внимание на том, что один и тот же ключ все время используется для одного длинного значения. Все еще не понимаю: почему атомарность не будет работать для увеличения длинного значения? Возможно ли, чтобы несколько потоков находились внутри переназначения compute BiFunction в один и тот же момент времени? - person Kirill; 11.03.2021
comment
Не похоже, но это не дает вам эффектов видимости. Другие потоки не обязательно увидят обновление до counter. Видимость не гарантируется. - person Louis Wasserman; 11.03.2021

Не ведитесь на строку в документации compute:

Весь вызов метода выполняется атомарно

Это работает для побочных эффектов, как у вас в этом value++; это работает только для внутренних данных ConcurrentHashMap.

Первое, что вы упускаете из виду, это то, что locking в CHM реализация сильно изменилась (как было отмечено в другом ответе). Но даже если это не так, ваше понимание:

Я знаю, что при явной синхронизации на локе видимость гарантирована

имеет недостатки. JLS говорит, что это гарантировано, когда и reader, и writer используют одну и ту же блокировку; чего в вашем случае явно не бывает; как таковых никаких гарантий нет. В общем happens-before гарантии (которые вам здесь потребуются) работают только для пар, как для чтения, так и для записи.

person Eugene    schedule 10.03.2021
comment
Означает ли ваш ответ, что атомарность computes может быть гарантирована за счет использования различных блокировок внутри CHM? - person Kirill; 11.03.2021
comment
@ Кирилл нет, я имел в виду, что твой getValue не использует ту же блокировку, что и compute - person Eugene; 11.03.2021
comment
согласен, мой пример для вопроса определенно ошибочен в отношении чтения конечного результата. Я поправил пример. В моем реальном коде чтение и запись длинного значения происходит ТОЛЬКО внутри compute. Все еще не гарантируется видимость? - person Kirill; 11.03.2021
comment
@Kirill Меня тоже смущает ваше редактирование, у вас все еще есть getValue ... можете ли вы пересмотреть и привести пример того, что вы имеете в виду? - person Eugene; 11.03.2021
comment
getValue удалено. Любое чтение/запись происходит внутри compute - person Kirill; 11.03.2021
comment
@ Кирилл по-прежнему нет, compute может блокироваться для чтения и записи внутри себя совершенно по-разному. Метод (как и документация пакета) ничего не упоминает о happens-before в этом сценарии. я бы не стал на это полагаться - person Eugene; 11.03.2021
comment
@Кирилл, твои правки не улучшают ситуацию, а скорее пугают. Даже если вам случится написать кажущийся безопасным вариант, мы должны предположить, что ваш реальный случай снова выглядит иначе, и любое заявление с нашей стороны будет вводить в заблуждение. То же самое происходит, если вы решите снова отредактировать свой вопрос, а ответы не соответствуют примерам вопроса. Кроме того, даже если вы удалите метод запроса из кода вопроса, вы раздаете объект, который был бы совершенно бесполезен, если бы никто никогда не запрашивал его состояние, поэтому такие (неработающие) запросы наверняка существуют в реальном коде. - person Holger; 18.03.2021
comment
@Holger, я должен признать, что мне несколько раз не удавалось привести хороший пример, объект, содержащий состояние, должен был быть закрытым, иначе это вводит в заблуждение. Я добавил реальный и полный фрагмент кода и надеюсь, что теперь и моя мотивация для такого кода, и исходный вопрос ясны. Любое полезное чтение/запись длинного значения происходит в пределах CHM.compute. Вопрос тот же: гарантирована ли видимость счетчика? - person Kirill; 18.03.2021