Skip to content

Commit 0a19500

Browse files
committed
Add cache hit/miss statistics (hitCount, missCount, hitRate)
LruCache now tracks lifetime hit/miss counters. Every get() increments hitCount() on a live, non-expired entry, or missCount() on an absent or expired one. hitRate() returns hitCount / (hitCount + missCount), or 0.0 before any get() call to avoid a divide-by-zero. Following Guava's CacheStats convention, the counters are cumulative for the life of the cache instance and are not reset by put() or clear() - documented in the LruCache class javadoc and the new README "Cache Statistics" section, which also covers a new hand-calculated test that walks a specific put/get sequence (including a capacity eviction and a post-clear get) and asserts exact expected hit/miss/rate values.
1 parent 059a986 commit 0a19500

3 files changed

Lines changed: 183 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,70 @@ public class Example {
5050
}
5151
```
5252

53+
Another common pattern - a fixed-size cache in front of an expensive lookup, falling back on a miss:
54+
55+
```java
56+
import dev.kasapdev.simplecache.LruCache;
57+
58+
public class LookupExample {
59+
60+
private static final LruCache<Long, String> cache = new LruCache<>(200);
61+
62+
public static void main(String[] args) {
63+
System.out.println(fetchUserName(42L)); // miss -> loads and caches
64+
System.out.println(fetchUserName(42L)); // hit -> served from cache
65+
}
66+
67+
private static String fetchUserName(long userId) {
68+
String cached = cache.get(userId);
69+
if (cached != null) {
70+
return cached;
71+
}
72+
String loaded = loadUserNameFromDatabase(userId); // expensive
73+
cache.put(userId, loaded);
74+
return loaded;
75+
}
76+
77+
private static String loadUserNameFromDatabase(long userId) {
78+
return "user-" + userId;
79+
}
80+
}
81+
```
82+
83+
## Cache Statistics
84+
85+
Every `LruCache` tracks lifetime hit/miss counts, so you can see how effective it
86+
actually is in production instead of guessing:
87+
88+
```java
89+
import dev.kasapdev.simplecache.LruCache;
90+
91+
public class StatsExample {
92+
public static void main(String[] args) {
93+
LruCache<String, String> cache = new LruCache<>(100);
94+
cache.put("user:42", "Kayra");
95+
96+
cache.get("user:42"); // hit
97+
cache.get("user:99"); // miss (never put)
98+
cache.get("user:42"); // hit
99+
100+
System.out.println(cache.hitCount()); // 2
101+
System.out.println(cache.missCount()); // 1
102+
System.out.println(cache.hitRate()); // 0.6666666666666666
103+
}
104+
}
105+
```
106+
107+
- `hitCount()` / `missCount()` are incremented by every call to `get(key)`: a hit
108+
is a `get` that finds a live, non-expired entry; a miss is a `get` that finds
109+
nothing (absent, or present but TTL-expired).
110+
- `hitRate()` is `hitCount / (hitCount + missCount)`, returning `0.0` before the
111+
first `get` call rather than dividing by zero.
112+
- Like Guava's `CacheStats`, these counters are cumulative for the lifetime of the
113+
cache instance and are **not** reset by `put` or `clear()` - they measure how
114+
well the cache has been performing overall, independent of what its current
115+
contents happen to be.
116+
53117
## API
54118

55119
### `LruCache<K, V>`
@@ -62,13 +126,17 @@ public class Example {
62126
| `void put(K key, V value)` | Inserts/updates `key` using the cache's default TTL. Evicts the least-recently-used entry if the cache is full and `key` is new. |
63127
| `void put(K key, V value, long ttlMillis)` | Inserts/updates `key` with an explicit TTL override for this entry only. `ttlMillis <= 0` means this entry never expires. |
64128
| `int size()` | Current number of entries held (including any expired-but-not-yet-evicted entries). |
65-
| `void clear()` | Removes all entries. |
129+
| `void clear()` | Removes all entries. Does not reset hit/miss statistics. |
130+
| `long hitCount()` | Lifetime number of `get` calls that found a live entry. |
131+
| `long missCount()` | Lifetime number of `get` calls that found nothing (absent or expired). |
132+
| `double hitRate()` | `hitCount / (hitCount + missCount)`; `0.0` if `get` has never been called. |
66133

67134
### Behavior notes
68135

69136
- Eviction is genuinely LRU: both `get` and `put` on an existing key refresh that entry's recency.
70137
- TTL and LRU are independent: an expired entry is treated as absent by `get` even if the LRU structure hasn't evicted it yet (e.g. because the cache isn't full).
71138
- All public methods are safe to call concurrently from multiple threads (guarded by a single internal `ReentrantLock`).
139+
- Hit/miss statistics are cumulative for the life of the cache instance and are not reset by `put` or `clear()` - see [Cache Statistics](#cache-statistics).
72140

73141
## License
74142

‎src/main/java/dev/kasapdev/simplecache/LruCache.java‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@
2222
* <p>All public methods are safe for concurrent use from multiple threads; a
2323
* single {@link ReentrantLock} guards all access to the underlying map.
2424
*
25+
* <p>The cache also tracks lifetime hit/miss statistics via {@link #hitCount()},
26+
* {@link #missCount()} and {@link #hitRate()}. Every call to {@link #get(Object)}
27+
* counts as either a hit (a live, non-expired entry was found) or a miss (the key
28+
* was absent, or present but expired). These counters are cumulative for the life
29+
* of the cache instance: like Guava's {@code CacheStats}, they measure historical
30+
* hit effectiveness and are deliberately <em>not</em> reset by {@link #put(Object, Object)},
31+
* {@link #put(Object, Object, long)} or {@link #clear()} - clearing the cache's
32+
* contents is a different operation from resetting how well it has been performing.
33+
*
2534
* @param <K> the type of keys maintained by this cache
2635
* @param <V> the type of mapped values
2736
*/
@@ -34,6 +43,8 @@ public final class LruCache<K, V> {
3443
private final long defaultTtlMillis;
3544
private final LinkedHashMap<K, Entry<V>> map;
3645
private final ReentrantLock lock = new ReentrantLock();
46+
private long hitCount;
47+
private long missCount;
3748

3849
/**
3950
* Creates a cache with the given fixed capacity and no default TTL
@@ -73,18 +84,25 @@ protected boolean removeEldestEntry(Map.Entry<K, Entry<V>> eldest) {
7384
/**
7485
* Returns the value associated with {@code key}, or {@code null} if absent
7586
* or expired. A successful lookup marks the entry as most-recently-used.
87+
*
88+
* <p>Every call counts toward this cache's hit/miss statistics: finding a
89+
* live entry increments {@link #hitCount()}, while a missing or expired
90+
* entry increments {@link #missCount()}.
7691
*/
7792
public V get(K key) {
7893
lock.lock();
7994
try {
8095
Entry<V> entry = map.get(key);
8196
if (entry == null) {
97+
missCount++;
8298
return null;
8399
}
84100
if (isExpired(entry)) {
85101
map.remove(key);
102+
missCount++;
86103
return null;
87104
}
105+
hitCount++;
88106
return entry.value;
89107
} finally {
90108
lock.unlock();
@@ -127,7 +145,7 @@ public int size() {
127145
}
128146
}
129147

130-
/** Removes all entries from the cache. */
148+
/** Removes all entries from the cache. Does not reset hit/miss statistics; see the class javadoc. */
131149
public void clear() {
132150
lock.lock();
133151
try {
@@ -137,6 +155,48 @@ public void clear() {
137155
}
138156
}
139157

158+
/**
159+
* Returns the number of {@link #get(Object)} calls that found a live,
160+
* non-expired entry, since this cache was created.
161+
*/
162+
public long hitCount() {
163+
lock.lock();
164+
try {
165+
return hitCount;
166+
} finally {
167+
lock.unlock();
168+
}
169+
}
170+
171+
/**
172+
* Returns the number of {@link #get(Object)} calls that found no entry
173+
* (absent or expired), since this cache was created.
174+
*/
175+
public long missCount() {
176+
lock.lock();
177+
try {
178+
return missCount;
179+
} finally {
180+
lock.unlock();
181+
}
182+
}
183+
184+
/**
185+
* Returns the ratio of hits to total {@link #get(Object)} calls
186+
* ({@code hitCount / (hitCount + missCount)}), as a value between
187+
* {@code 0.0} and {@code 1.0} inclusive. Returns {@code 0.0} if
188+
* {@code get} has never been called, to avoid dividing by zero.
189+
*/
190+
public double hitRate() {
191+
lock.lock();
192+
try {
193+
long total = hitCount + missCount;
194+
return total == 0 ? 0.0 : (double) hitCount / total;
195+
} finally {
196+
lock.unlock();
197+
}
198+
}
199+
140200
private boolean isExpired(Entry<V> entry) {
141201
return entry.expiresAt != NO_EXPIRY && System.currentTimeMillis() >= entry.expiresAt;
142202
}

‎src/test/java/dev/kasapdev/simplecache/LruCacheTest.java‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,62 @@ public static void main(String[] args) throws Exception {
2020
testInvalidCapacityRejected();
2121
testSizeCountsExpiredButNotYetEvictedEntries();
2222
testRePuttingExistingKeyDoesNotEvictOthers();
23+
testStatsAreZeroBeforeAnyGet();
24+
testHitAndMissCountsWithExactExpectedValues();
2325
TestKit.finish();
2426
}
2527

28+
private static void testStatsAreZeroBeforeAnyGet() {
29+
LruCache<String, String> cache = new LruCache<>(3);
30+
TestKit.check("hitCount is 0 before any get", cache.hitCount() == 0);
31+
TestKit.check("missCount is 0 before any get", cache.missCount() == 0);
32+
TestKit.check("hitRate is 0.0 before any get (no divide-by-zero)", cache.hitRate() == 0.0);
33+
cache.put("a", "A");
34+
TestKit.check("put alone does not affect hitCount", cache.hitCount() == 0);
35+
TestKit.check("put alone does not affect missCount", cache.missCount() == 0);
36+
}
37+
38+
private static void testHitAndMissCountsWithExactExpectedValues() {
39+
// Capacity 2, no TTL. Hand-tracked sequence of gets, mixing hits, misses,
40+
// and a miss caused by capacity eviction.
41+
LruCache<String, String> cache = new LruCache<>(2);
42+
cache.put("a", "A");
43+
cache.put("b", "B");
44+
45+
cache.get("a"); // 1: hit (a present) -> hits=1 misses=0
46+
cache.get("b"); // 2: hit (b present) -> hits=2 misses=0
47+
cache.get("missing"); // 3: miss (never put) -> hits=2 misses=1
48+
49+
// Capacity is 2 and recency order right now (oldest -> newest) is a, b
50+
// (both gets above refreshed recency in that order). Inserting "c" evicts "a".
51+
cache.put("c", "C");
52+
53+
cache.get("a"); // 4: miss (evicted by capacity) -> hits=2 misses=2
54+
cache.get("b"); // 5: hit (survived eviction) -> hits=3 misses=2
55+
cache.get("c"); // 6: hit (just inserted) -> hits=4 misses=2
56+
cache.get("missing"); // 7: miss (still never put) -> hits=4 misses=3
57+
58+
// Overwriting an existing key is not a get, so it must not move the counters.
59+
cache.put("b", "B-updated");
60+
cache.get("b"); // 8: hit (updated value, still present) -> hits=5 misses=3
61+
62+
TestKit.check("hitCount matches hand-calculated expected value", cache.hitCount() == 5);
63+
TestKit.check("missCount matches hand-calculated expected value", cache.missCount() == 3);
64+
// hitRate = hits / (hits + misses) = 5 / 8 = 0.625
65+
TestKit.check("hitRate matches hand-calculated expected value", cache.hitRate() == 0.625);
66+
67+
// clear() must not reset lifetime stats (documented, Guava-style behavior).
68+
cache.clear();
69+
TestKit.check("hitCount is unchanged by clear()", cache.hitCount() == 5);
70+
TestKit.check("missCount is unchanged by clear()", cache.missCount() == 3);
71+
TestKit.check("hitRate is unchanged by clear()", cache.hitRate() == 0.625);
72+
73+
// A get() after clear() on a now-absent key is a miss, and does update stats.
74+
cache.get("b"); // 9: miss (cleared) -> hits=5 misses=4
75+
TestKit.check("missCount increments for a get() after clear()", cache.missCount() == 4);
76+
TestKit.check("hitRate reflects the post-clear miss: 5 / 9", cache.hitRate() == 5.0 / 9.0);
77+
}
78+
2679
private static void testInvalidCapacityRejected() {
2780
boolean threwZero = false;
2881
try {

0 commit comments

Comments
 (0)