blob: 9c92a1e71da777a10c680576f8797afee41ac4fc [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.sis.util.collection;
import java.util.Arrays;
import java.util.Set;
import java.util.Map;
import java.util.HashMap;
import java.util.AbstractMap.SimpleEntry;
import java.util.concurrent.atomic.AtomicReference;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import static java.lang.StrictMath.*;
import org.apache.sis.math.Statistics;
import org.apache.sis.math.StatisticsFormat;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.privy.Constants;
// Test dependencies
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.apache.sis.test.TestUtilities;
import org.apache.sis.test.Benchmark;
import static org.apache.sis.test.Assertions.assertMapEquals;
/**
* Tests the {@link Cache} with simple tests and a {@linkplain #stress() stress} test.
*
* @author Martin Desruisseaux (Geomatys)
*/
public final class CacheTest extends TestCaseWithGC {
/**
* Creates a new test case.
*/
public CacheTest() {
}
/**
* Tests {@link Cache} as a {@link java.util.Map} using strong references.
* The tested {@code Cache} shall behave like a standard {@link HashMap},
* except for element order.
*
* @see WeakValueHashMapTest#testStrongReferences()
*/
@Test
public void testStrongReferences() {
WeakValueHashMapTest.testStrongReferences(new Cache<>(WeakValueHashMapTest.SAMPLE_SIZE, 0, false));
}
/**
* Tests {@link Cache} as a {@link java.util.Map} using weak references. In this test, we
* have to keep in mind than some elements in {@code weakMap} may disappear at any time.
*
* @throws InterruptedException if the test has been interrupted.
*
* @see WeakValueHashMapTest#testWeakReferences()
*/
@Test
public void testWeakReferences() throws InterruptedException {
WeakValueHashMapTest.testWeakReferences(new Cache<>(WeakValueHashMapTest.SAMPLE_SIZE, 0, false));
}
/**
* Tests adding a single value using the {@link Cache.Handler#putAndUnlock(Object)} method.
* This method does all the operations in a single thread.
*/
@Test
public void testPutAndUnlock() {
final String key = "The key";
final String value = "The value";
final Cache<String,String> cache = new Cache<>();
assertTrue(cache.isEmpty(), "No initial value expected.");
assertNull(cache.peek(key), "No initial value expected.");
final Cache.Handler<String> handler = cache.lock(key);
assertNull(handler.peek(), "No initial value expected.");
handler.putAndUnlock(value);
assertEquals(1, cache.size());
assertEquals(value, cache.peek(key));
assertEquals(Set.of(key), cache.keySet());
assertEquals(Set.of(new SimpleEntry<>(key, value)), cache.entrySet());
}
/**
* Tests the cache when a thread is blocking a second one.
* The second thread tries to write a value while the first thread holds the lock.
*
* @throws InterruptedException if the test has been interrupted.
*/
@Test
public void testThreadBlocking() throws InterruptedException {
final String keyByMainThread = "keyByMainThread";
final String valueByMainThread = "valueByMainThread";
final String keyByOtherThread = "keyByOtherThread";
final String valueByOtherThread = "valueByOtherThread";
final Cache<String,String> cache = new Cache<>();
final class OtherThread extends Thread {
/**
* If an error occurred, the cause. It may be an {@link AssertionError}.
*/
Throwable failure;
/**
* Creates a new thread.
*/
OtherThread() {
super(TestUtilities.THREADS, "CacheTest.testThreadBlocking()");
}
/**
* Reads the value added by the main thread, then adds another value.
* The first operation shall block while the main thread holds the lock.
*/
@Override public void run() {
try {
final Cache.Handler<String> handler = cache.lock(keyByMainThread);
assertTrue(handler instanceof Cache<?,?>.Work.Wait);
assertSame(valueByMainThread, handler.peek());
handler.putAndUnlock(valueByMainThread);
assertSame(valueByMainThread, cache.peek(keyByMainThread));
} catch (Throwable e) {
failure = e;
}
try {
final Cache.Handler<String> handler = cache.lock(keyByOtherThread);
assertTrue(handler instanceof Cache<?,?>.Work);
assertNull(handler.peek());
handler.putAndUnlock(valueByOtherThread);
assertSame(valueByOtherThread, cache.peek(keyByOtherThread));
} catch (Throwable e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
}
}
/*
* Gets the lock, then start the second thread which will try to write
* a value for the same key. The second thread shall block.
*/
final Cache.Handler<String> handler = cache.lock(keyByMainThread);
assertTrue(handler instanceof Cache<?,?>.Work);
final OtherThread thread = new OtherThread();
thread.start();
waitForBlockedState(thread);
assertNull(cache.peek(keyByOtherThread), "The blocked thread shall not have added a value.");
/*
* Write. This will release the lock and let the other thread continue its job.
*/
handler.putAndUnlock(valueByMainThread);
thread.join();
TestUtilities.rethrownIfNotNull(thread.failure);
/*
* Checks the map content.
*/
final var expected = new HashMap<String,String>(4);
assertNull(expected.put( keyByMainThread, valueByMainThread));
assertNull(expected.put(keyByOtherThread, valueByOtherThread));
assertMapEquals(expected, cache);
}
/**
* Validates the entries created by the {@link #stress()} test. The check performed in
* this method shall obviously be consistent with the values created by {@code stress()}.
*
* @param name the name of the value being measured.
* @param cache the cache to validate.
* @return Statistics on the key values of the given map.
*/
private static Statistics validateStressEntries(final String name, final Map<Integer,IntObject> cache) {
final var statistics = new Statistics(name);
for (final Map.Entry<Integer,IntObject> entry : cache.entrySet()) {
final int key = entry.getKey();
final IntObject value = entry.getValue();
assertEquals(key*key, value.value);
statistics.accept(key);
}
return statistics;
}
/**
* Starts many threads writing in the same cache, with a high probability that two threads
* ask for the same key in some occasions.
*
* @throws InterruptedException if the test has been interrupted.
*/
@Test
@Benchmark
@Tag(Benchmark.TAG)
public void stress() throws InterruptedException {
final int count = 5000;
final Cache<Integer,IntObject> cache = new Cache<>();
final AtomicReference<Throwable> failures = new AtomicReference<>();
final class WriterThread extends Thread {
/**
* Incremented every time a value has been added. This is not the number of time the
* loop has been executed, since this variable is not incremented when a value already
* exists for a key.
*/
int addCount;
/**
* Creates a new thread.
*/
WriterThread(final int i) {
super(TestUtilities.THREADS, "CacheTest.stress() #" + i);
}
/**
* Puts random values in the map.
*/
@SuppressWarnings("CallToThreadYield")
@Override public void run() {
for (int i=0; i<count; i++) {
final Integer key = i;
final IntObject expected = new IntObject(i * i); // We really want new instance.
final IntObject value;
try {
value = cache.getOrCreate(key, () -> expected);
assertEquals(expected, value);
} catch (Throwable e) {
if (!failures.compareAndSet(null, e)) {
failures.get().addSuppressed(e);
}
continue;
}
if (expected == value) { // Identity comparison (not value comparison).
addCount++;
Thread.yield(); // Gives a chance to other threads.
}
}
}
}
final WriterThread[] threads = new WriterThread[50];
Arrays.setAll(threads, WriterThread::new);
Arrays.stream(threads).forEach(Thread::start);
for (WriterThread thread : threads) {
thread.join();
}
TestUtilities.rethrownIfNotNull(failures.get());
/*
* Verifies the values.
*/
final Statistics beforeGC = validateStressEntries("Before GC", cache);
assertTrue(cache.size() <= count, "Should not have more entries than what we put in.");
assertFalse(cache.isEmpty(), "Some entries should be retained by strong references.");
/*
* If verbose test output is enabled, report the number of cache hits.
* The numbers below are for tuning the test only. The output is somewhat
* random so we cannot check it in a test suite. However if the test is
* properly tuned, most values should be non-zero.
*/
@SuppressWarnings("LocalVariableHidesMemberVariable")
final PrintWriter out = CacheTest.out;
TestUtilities.printSeparator("CacheTest.stress() - testing concurrent accesses");
out.print("There is "); out.print(threads.length); out.print(" threads, each of them"
+ " fetching or creating "); out.print(count); out.println(" values.");
out.println("Number of times a new value has been created, for each thread:");
for (int i=0; i<threads.length;) {
final String n = String.valueOf(threads[i++].addCount);
out.print(CharSequences.spaces(6 - n.length()));
out.print(n);
if ((i % 10) == 0) {
out.println();
}
}
out.println();
out.println("Now observe how the background thread cleans the cache.");
long time = System.nanoTime();
for (int i=0; i<10; i++) {
final long t = System.nanoTime();
out.printf("Cache size: %4d (after %3d ms)%n", cache.size(),
round((t - time) / (double) Constants.NANOS_PER_MILLISECOND));
time = t;
Thread.sleep(250);
if (i >= 2) {
System.gc();
}
}
out.println();
/*
* Gets the statistics of key values after garbage collection. Most values should
* be higher, because oldest values (which should have been garbage collected first)
* have lower values. If verbose output is enabled, then we will print the statistics
* before to perform the actual check in order to allow the developer to have more
* information in case of failure.
*
* The mean value is often greater, but not always. Since we have fewer remaining values
* (100 instead of 10000), the remaining low values will have a much greater impact on
* the mean. Only the check on the minimal value is fully reliable.
*/
final Statistics afterGC = validateStressEntries("After GC", cache);
out.println("Statistics on the keys before and after garbage collection.");
out.println("The minimum value shall always be equal or greater after GC.");
out.println("The mean value is usually greater too, except by coincidence.");
final StatisticsFormat format = StatisticsFormat.getInstance();
format.setBorderWidth(1);
try {
format.format(new Statistics[] {beforeGC, afterGC}, out);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
assertTrue(afterGC.minimum() >= beforeGC.minimum(),
"Minimum key value should be greater after garbage collection.");
}
}