40. Реализовать хранилище с истекающими ключами
Условие задачи:
Необходимо реализовать класс, который хранит пары ключ → значение с ограниченным временем жизни (TTL).
Методы:
set(String key, String value, int duration)— сохраняет значение наdurationмиллисекунд;если на момент вызова
set()такой же неистёкший ключ уже существует, вернутьtrue, иначеfalse;повторный
set()должен заменить значение и заново запустить TTL;get(String key)— вернуть значение, если ключ ещё действует, иначе"-1";count()— вернуть количество неистёкших ключей.
Истёкшие записи могут удаляться лениво — при обращении к хранилищу.
Спойлеры к решению
Подсказки
💡 Для измерения временного интервала лучше использовать
System.nanoTime(), а не системные часы.💡 Если класс используется конкурентно, операция проверки существующего ключа и его замены должна быть атомарной.
💡 Для этого удобно использовать
ConcurrentHashMap.compute().💡 При удалении просроченной записи важно не удалить новое значение, которое другой поток уже успел записать по тому же ключу.
Решение
public class ExpiringMap {
private static class Entry {
private final String value;
private final long expiresAt;
private Entry(String value, long expiresAt) {
this.value = value;
this.expiresAt = expiresAt;
}
private boolean isExpired(long now) {
return now - expiresAt >= 0;
}
}
private final ConcurrentHashMap<String, Entry> storage =
new ConcurrentHashMap<>();
public boolean set(String key, String value, int duration) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
if (duration < 0) {
throw new IllegalArgumentException(
"duration не может быть отрицательным"
);
}
AtomicBoolean existed = new AtomicBoolean(false);
storage.compute(key, (k, oldEntry) -> {
long now = System.nanoTime();
existed.set(
oldEntry != null
&& !oldEntry.isExpired(now)
);
long expiresAt =
now + TimeUnit.MILLISECONDS.toNanos(duration);
return new Entry(value, expiresAt);
});
return existed.get();
}
public String get(String key) {
Objects.requireNonNull(key, "key");
Entry entry = storage.get(key);
if (entry == null) {
return "-1";
}
if (entry.isExpired(System.nanoTime())) {
storage.remove(key, entry);
return "-1";
}
return entry.value;
}
public int count() {
long now = System.nanoTime();
int count = 0;
for (Map.Entry<String, Entry> mapEntry
: storage.entrySet()) {
Entry entry = mapEntry.getValue();
if (entry.isExpired(now)) {
storage.remove(mapEntry.getKey(), entry);
} else {
count++;
}
}
return count;
}
}
Каждая запись хранит не только значение, но и момент окончания её действия:
private final String value;
private final long expiresAt;
Для TTL используется:
System.nanoTime()
Он подходит для измерения интервалов времени и не зависит от перевода системных часов вперёд или назад.
В set() используется:
storage.compute(key, ...)
Операция выполняется атомарно для конкретного ключа. Поэтому два потока не смогут одновременно проверить старое значение и независимо перезаписать его, получив некорректный результат true/false.
Например:
map.set("name", "Ivan", 1000); // false
map.set("name", "Petr", 1000); // true
Второй вызов возвращает true, поскольку на момент обновления ключ ещё существует, но его значение и TTL всё равно заменяются.
При истечении срока в get() используется:
storage.remove(key, entry);
а не просто:
storage.remove(key);
Это важно при конкурентном доступе. Если другой поток уже заменил просроченную запись новой, условное удаление не затронет новое значение.
Пример:
ExpiringMap map = new ExpiringMap();
System.out.println(
map.set("a", "hello", 1000)
); // false
System.out.println(
map.get("a")
); // hello
System.out.println(
map.count()
); // 1
После окончания TTL:
get("a") → "-1"
count() → 0
set() и get() в среднем работают за O(1).
count() требует просмотра хранилища, поэтому работает за O(n), где n — количество записей.
При конкурентных изменениях ConcurrentHashMap даёт слабосогласованный обход, поэтому count() не является атомарным снимком всей карты. Если требуется строго точное значение относительно одновременно выполняющихся set() и get(), операции придётся дополнительно координировать общей блокировкой.
Также контракт с "-1" неоднозначен, если "-1" является допустимым значением. В реальном API для отсутствующего ключа удобнее возвращать Optional<String>.