Итерация значений из кэша Guava приводит к потере данных

Я начал тестировать способы поиска ключа по значению в кеше Guava и заметил странное поведение, связанное с уровнем параллелизма. Я не уверен, является ли это ошибкой или неопределенным поведением или, может быть, даже ожидаемым, но не указанным.

Мой тест должен находить ключ по значению в кеше Guava, что, как я знаю, не обычное дело.

Это мой полный эталонный класс:

@Fork(4)
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 1, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 4, time = 100, timeUnit = TimeUnit.MILLISECONDS)
public class ValueByKey {

    private Long counter = 0L;

    private final int MAX = 2500;

    private final LoadingCache<String, Long> stringToLong = CacheBuilder.newBuilder()
        .concurrencyLevel(1)
        .maximumSize(MAX + 5)
        .build(new CacheLoader<String, Long>() {
            public Long load(String mString) {
                return generateIdByString(mString);
            }
        });

    private final Map<String, Long> mHashMap = new Hashtable<>(MAX);
    private final Map<String, Long> concurrentHashMap = new ConcurrentHashMap<>(MAX);

    @Setup(Level.Trial)
    public void setup() {
        // Populate guava cache
        for(int i = 0; i <= MAX; i++) {
            try {
                stringToLong.get(UUID.randomUUID().toString());
            } catch (ExecutionException e) {
                e.printStackTrace();
                System.exit(1);
            }
        }
    }

    @Benchmark
    public String stringToIdByIteration() {
        Long randomNum = ThreadLocalRandom.current().nextLong(1L, MAX);

        for(Map.Entry<String, Long> entry : stringToLong.asMap().entrySet()) {
            if(Objects.equals(randomNum, entry.getValue())) {
                return entry.getKey();
            }
        }
        System.out.println("Returning null as value not found " + randomNum);
        return null;
    }

    @Benchmark
    public String stringToIdByIterationHashTable() {
        Long randomNum = ThreadLocalRandom.current().nextLong(1L, MAX);

        for(Map.Entry<String, Long> entry : mHashMap.entrySet()) {
            if(Objects.equals(randomNum, entry.getValue())) {
                return entry.getKey();
            }
        }
        System.out.println("Returning null as value not found " + randomNum);
        return null;
    }

@Benchmark
    public String stringToIdByIterationConcurrentHashMap() {
        Long randomNum = ThreadLocalRandom.current().nextLong(1L, MAX);

        for(Map.Entry<String, Long> entry : concurrentHashMap.entrySet()) {
            if(Objects.equals(randomNum, entry.getValue())) {
                return entry.getKey();
            }
        }
        System.out.println("concurrentHashMap Returning null as value not found " + randomNum);
        return null;
    }

    private Long generateIdByString(final String mString) {
        mHashMap.put(mString, counter++);
        concurrentHashMap.put(mString, counter);
        return counter;
    }

}

Я заметил, что когда я меняю .concurrencyLevel(1) на число, отличное от 1, я начинаю терять данные. Следующий вывод относится к уровню параллелизма 4:

Iteration   1: Returning null as value not found 107
Returning null as value not found 43
Returning null as value not found 20
Returning null as value not found 77
Returning null as value not found 127
Returning null as value not found 35
Returning null as value not found 83
Returning null as value not found 43
Returning null as value not found 127
Returning null as value not found 107
Returning null as value not found 83
Returning null as value not found 82
Returning null as value not found 40
Returning null as value not found 58
Returning null as value not found 127
Returning null as value not found 114
Returning null as value not found 119
Returning null as value not found 43
Returning null as value not found 114
Returning null as value not found 18
Returning null as value not found 58
66.778 us/op

Я заметил, что никогда не теряю данные при использовании HashMap или HashTable для использования одного и того же кода, он также работает намного лучше:

Benchmark Mode Cnt Score Error Units ValueByKey.stringToIdByIteration avgt 16 58.637 ± 15.094 us/op ValueByKey.stringToIdByIterationConcurrentHashMap avgt 16 16.148 ± 2.046 us/op ValueByKey.stringToIdByIterationHashTable avgt 16 11.705 ± 1.095 us/op

Является ли мой код неправильным или Guava не может правильно обрабатывать секционированные HashTable с уровнем параллелизма выше 1?

  • Параметр уровня параллелизма используется для внутреннего разделения таблицы таким образом, чтобы обновления могли выполняться без конфликтов.
  • Идеальным параметром было бы максимальное количество потоков, которые потенциально могут одновременно обращаться к кешу.

person agilob    schedule 26.01.2018    source источник
comment
Я бы предложил запустить тот же тест против java ConcurrentHashMap, чтобы увидеть, можете ли вы наблюдать там подобные проблемы. Если да, то это будет вопрос HashMap по сравнению с ConcurrentHashMap (который, я думаю, лежит в основе проблемы), без дополнительной сложности кеша guava.   -  person Artur Biesiadowski    schedule 26.01.2018
comment
@ArturBiesiadowski Обновленный вопрос с ConcurrentHashMap, ведет себя так же, как HashTable и HashMap, не отбрасывает никаких значений. Я могу добавить, что вижу такое же поведение при повторении с использованием потока или parallelStream -> фильтр -> лимит -> уменьшение.   -  person agilob    schedule 26.01.2018


Ответы (1)


Отсутствие кеша гарантирует постоянное попадание в кеш

Наличие/отсутствие данных в кеше определяется политикой вытеснения (и в первую очередь загружаемыми в кеш данными).

Поскольку вы использовали CacheBuilder.maximumSize(MAX + 5), ваш кеш будет использовать вытеснение на основе размера и начнет удалять элементы до того, как достигнет заданного максимального размера.

С уровнем параллелизма, установленным на 4, Guava Cache играет осторожно и устанавливает порог вытеснения немного ниже, что имеет смысл, поскольку элементы могут продолжать поступать по мере их вытеснения.

Вот почему ваши элементы начинают «исчезать».

Чтобы проверить это, сделайте так, чтобы ваш класс реализовывал интерфейс RemovalListener:

public class ValueByKey implements RemovalListener<String, Long> { 
    //...
    @Override
    public void onRemoval(RemovalNotification<String, Long> notification) {
        System.out.println("removed: " + notification.getKey() + " -> " + notification.getValue());
    }
    //...
}

... и во время выполнения тестов вы заметите исключения, которые соответствуют отсутствующим значениям:

# Warmup Iteration   1: 
removed: 110c0a73-1dc3-40ee-8909-969e6dee0ea0 -> 3
removed: 6417015a-f154-467f-b3bf-3b95831ac5b7 -> 6
removed: 5bc206f9-67ec-49a2-8471-b386ffc03988 -> 14
removed: 3c0a33e1-1fe1-4e42-b262-bf6a3e8c53f7 -> 21
Returning null as value not found 14
Returning null as value not found 14
Returning null as value not found 3
64.778 us/op
Iteration   1: 
Returning null as value not found 21
Returning null as value not found 21
Returning null as value not found 6
37.719 us/op
[...]

Я могу себе представить, что вычисление порога для выселения может быть сложным, но на моей машине увеличение максимального размера на 5 % (CacheBuilder.maximumSize(Math.round(MAX * 1.05))) предотвратило ВСЕ выселения при выполнении тестов.

person diginoise    schedule 26.01.2018
comment
Хорошо, может ли случиться что-нибудь плохое, если я не установлю максимальный размер? - person agilob; 26.01.2018
comment
Вы можете выбрать вытеснение на основе времени (см. expireAfterAccess и expireAfterWrite или вытеснение на основе ссылки, где оно напоминает слабую или мягкую ссылку, позволяющую сборщику мусора очищать неиспользуемые элементы. Если вы хотите сохранить все элементы навсегда, не используйте кеш. - person diginoise; 26.01.2018
comment
Да, я хочу использовать expireAfterAccess с 30-минутным выселением (не в тестах, а на проде). Я думал, что мне также нужно максимум 2500 элементов в кеше, но, видимо, это не тот путь. - person agilob; 26.01.2018
comment
Caffeine удаляется после порога, так что это может быть еще один вариант. - person Ben Manes; 26.01.2018