diff --git a/pom.xml b/pom.xml index 36c092b..a4f049e 100644 --- a/pom.xml +++ b/pom.xml @@ -27,9 +27,9 @@ - junit - junit - 4.12 + org.junit.jupiter + junit-jupiter + RELEASE test diff --git a/src/main/java/de/comparus/opensource/longmap/CollisionAwareLongBucketIterator.java b/src/main/java/de/comparus/opensource/longmap/CollisionAwareLongBucketIterator.java new file mode 100644 index 0000000..c856ce6 --- /dev/null +++ b/src/main/java/de/comparus/opensource/longmap/CollisionAwareLongBucketIterator.java @@ -0,0 +1,46 @@ +package de.comparus.opensource.longmap; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +class CollisionAwareLongBucketIterator implements Iterator> { + + private final LongMapNode[] buckets; + private LongMapNode next; + private int currentIndex = 0; + + public CollisionAwareLongBucketIterator(LongMapNode[] buckets) { + this.buckets = buckets; + next = getNext(); + } + + @Override + public boolean hasNext() { + return next != null; + } + + @Override + public LongMapNode next() { + if (next == null) { + throw new NoSuchElementException("There is no next element to iterate upon."); + } + + LongMapNode current = next; + next = getNext(); + return current; + } + + private LongMapNode getNext() { + if (next != null && next.getCollision() != null) { + return next.getCollision(); + } + + while (currentIndex < buckets.length) { + if (buckets[currentIndex] != null) { + return buckets[currentIndex++]; + } + currentIndex++; + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java b/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java index 2f0b54b..878c429 100644 --- a/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java +++ b/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java @@ -1,43 +1,191 @@ package de.comparus.opensource.longmap; -public class LongMapImpl implements LongMap { +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Iterator; + +public class LongMapImpl implements LongMap, Iterable> { + private static final int ARRAY_MAX_SIZE = Integer.MAX_VALUE - 8; + private static final int DEFAULT_CAPACITY = 8; + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + private static final int RESIZE_FACTOR = 2; + final float loadFactor; + private int bucketCount; + private LongMapNode[] buckets; + + public LongMapImpl() { + this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR); + } + + @SuppressWarnings("unchecked") + public LongMapImpl(int capacity, float loadFactor) { + this.loadFactor = loadFactor; + this.buckets = new LongMapNode[capacity]; + } + public V put(long key, V value) { - return null; + if (bucketCount == ARRAY_MAX_SIZE) { + throw new IllegalStateException(String.format( + "Instance of Map is capped. It can only store %s mappings at max.", ARRAY_MAX_SIZE + )); + } + + if (isThresholdExceeded(key)) { + resize(); + } + + int bucketIndex = getIndex(key); + LongMapNode currentBucket = buckets[bucketIndex]; + + if (currentBucket == null) { + buckets[bucketIndex] = new LongMapNode<>(key, value); + bucketCount++; + return null; + } + + V storedValue = currentBucket.put(key, value); + + if (storedValue == null) { + bucketCount++; + } + return storedValue; } public V get(long key) { - return null; + int index = getIndex(key); + return buckets[index] == null + ? null + : buckets[index].get(key); } public V remove(long key) { + int index = getIndex(key); + LongMapNode currentBucket = buckets[index]; + + if (currentBucket != null) { + if (currentBucket.getKey() == key) { + V result = currentBucket.getValue(); + buckets[index] = currentBucket.getCollision(); + bucketCount--; + return result; + } else { + V removedValue = currentBucket.remove(key); + + if (removedValue != null) { + bucketCount--; + } + + return removedValue; + } + } return null; } public boolean isEmpty() { - return false; + return bucketCount == 0; } public boolean containsKey(long key) { - return false; + return get(key) != null; } public boolean containsValue(V value) { + for (LongMapNode bucket : this) { + if (bucket.getValue() == value || (bucket.getValue() != null && bucket.getValue().equals(value))) { + return true; + } + } return false; } public long[] keys() { - return null; + if (bucketCount == 0) { + return null; + } + long[] keys = new long[bucketCount]; + int keyIndex = bucketCount; + for (LongMapNode bucket : this) { + keys[--keyIndex] = bucket.getKey(); + } + return keys; } + @SuppressWarnings("unchecked") public V[] values() { - return null; + if (bucketCount == 0) { + return null; + } + V[] values = (V[]) Array.newInstance(this.iterator().next().getValue().getClass(), bucketCount); + int valueIndex = bucketCount; + for (LongMapNode bucket : this) { + values[--valueIndex] = bucket.getValue(); + } + return values; } public long size() { - return 0; + return bucketCount; } public void clear() { + Arrays.fill(buckets, null); + bucketCount = 0; + } + + @Override + public Iterator> iterator() { + return new CollisionAwareLongBucketIterator<>(buckets); + } + + @SuppressWarnings("unchecked") + private void resize() { + if (buckets.length == ARRAY_MAX_SIZE) { + return; + } + + LongMapNode[] newBuckets = new LongMapNode[getNewSize()]; + for (LongMapNode currentBucket : buckets) { + while (currentBucket != null) { + int newIndex = getIndex(currentBucket.getKey(), newBuckets); + LongMapNode collision = currentBucket.getCollision(); + currentBucket.setCollision(null); + + LongMapNode rehashedBucket = newBuckets[newIndex]; + + if (rehashedBucket == null) { + newBuckets[newIndex] = currentBucket; + } else { + rehashedBucket.collide(currentBucket); + } + currentBucket = collision; + } + } + this.buckets = newBuckets; + } + + private int getIndex(long key) { + return getIndex(key, buckets); + } + + private int getNewSize() { + if (buckets.length == 0) { + return DEFAULT_CAPACITY; + } + return ARRAY_MAX_SIZE / RESIZE_FACTOR > buckets.length + ? buckets.length * RESIZE_FACTOR + : ARRAY_MAX_SIZE; + } + + private boolean isThresholdExceeded(long key) { + return !containsKey(key) + && (buckets.length == 0 || (int) (buckets.length * loadFactor) <= bucketCount); + } + + private int getIndex(long key, LongMapNode[] storage) { + return (Long.hashCode(key) & 0x7FFFFFFF) % storage.length; + } + public int getCapacity() { + return buckets.length; } } diff --git a/src/main/java/de/comparus/opensource/longmap/LongMapNode.java b/src/main/java/de/comparus/opensource/longmap/LongMapNode.java new file mode 100644 index 0000000..74f6c70 --- /dev/null +++ b/src/main/java/de/comparus/opensource/longmap/LongMapNode.java @@ -0,0 +1,92 @@ +package de.comparus.opensource.longmap; + +import java.util.Objects; + +class LongMapNode { + + private final long key; + private T value; + private LongMapNode collision; + + public LongMapNode(long key, T value) { + this.key = key; + this.value = value; + } + + public final long getKey() { + return key; + } + + public final T getValue() { + return value; + } + + public final void setValue(T newValue) { + this.value = newValue; + } + + @Override + public final String toString() { + return key + "=" + value; + } + + @Override + public final int hashCode() { + return Objects.hash(key, value); + } + + public LongMapNode getCollision() { + return collision; + } + + public void setCollision(LongMapNode collision) { + this.collision = collision; + } + + public T remove(long key) { + if (collision == null) { + return null; + } + + if (collision.getKey() == key) { + T removed = collision.getValue(); + collision = collision.getCollision(); + return removed; + } else { + return collision.remove(key); + } + } + + public T get(long key) { + if (this.key == key) { + return this.value; + } + if (collision != null) { + return collision.get(key); + } + return null; + } + + public T put(long key, T value) { + if (this.getKey() == key) { + T result = this.getValue(); + this.setValue(value); + return result; + } + + if (collision != null) { + return collision.put(key, value); + } + + collision = new LongMapNode<>(key, value); + return null; + } + + public void collide(LongMapNode anotherBucket) { + if (collision != null) { + collision.collide(anotherBucket); + } else { + collision = anotherBucket; + } + } +} diff --git a/src/test/java/LongMapImplTest.java b/src/test/java/LongMapImplTest.java new file mode 100644 index 0000000..8ec077b --- /dev/null +++ b/src/test/java/LongMapImplTest.java @@ -0,0 +1,290 @@ +import de.comparus.opensource.longmap.LongMap; +import de.comparus.opensource.longmap.LongMapImpl; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.logging.Logger; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(TimingExtension.class) +class LongMapImplTest { + private static final Logger LOGGER = Logger.getLogger(TimingExtension.class.getName()); + private static final String PREFIX = "value_"; + private final LongMapImpl stringLongMap = new LongMapImpl<>(); + private LongMap integerLongMap; + + + @RepeatedTest(10) + public void put_new_then_store_correctly() { + putRandom(100); + + int expectedSize = 100; + assertEquals(expectedSize, stringLongMap.size()); + + int expectedCapacity = 256; + assertEquals(expectedCapacity, stringLongMap.getCapacity()); + } + + @Test + public void put_existing_then_update_correctly() { + int mappingsNumber = 100; + long[] keys = putRandom(mappingsNumber); + Arrays.stream(keys) + .forEach(key -> { + String newValue = PREFIX + key; + String oldValue = stringLongMap.put(key, newValue); + String updatedValue = stringLongMap.get(key); + + assertEquals(PREFIX + key, oldValue); + assertEquals(newValue, updatedValue); + }); + + assertEquals(mappingsNumber, stringLongMap.size()); + + int expectedCapacity = 256; + assertEquals(expectedCapacity, stringLongMap.getCapacity()); + } + + @RepeatedTest(10) + public void get_then_return_correctly() { + long[] keys = putRandom(100); + + Arrays.stream(keys) + .forEach(key -> { + String value = stringLongMap.get(key); + assertNotNull(value); + assertEquals(PREFIX + key, value); + }); + } + + @Test + public void get_and_map_is_empty_then_return_null() { + assertNull(stringLongMap.get(100L)); + } + + @Test + public void get_with_wrong_key_then_return_null() { + put(6); + assertNull(stringLongMap.get(7)); + } + + @Test + public void remove_then_delete_correctly() { + int mappingsNumber = 100; + int toBeRemoved = 25; + long[] keys = putRandom(mappingsNumber); + Arrays.stream(keys) + .skip(50) + .limit(toBeRemoved) + .forEach(key -> { + String removed = stringLongMap.remove(key); + assertNotNull(removed); + assertEquals(PREFIX + key, removed); + assertNull(stringLongMap.get(key)); + }); + + assertEquals(mappingsNumber - toBeRemoved, stringLongMap.size()); + } + + @Test + public void remove_with_wrong_key_then_no_delete() { + put(6); + String removed = stringLongMap.remove(10); + + assertNull(removed); + assertEquals(6, stringLongMap.size()); + } + + @Test + public void empty_then_return_true_otherwise_false() { + assertTrue(stringLongMap.isEmpty()); + + put(2); + assertFalse(stringLongMap.isEmpty()); + + stringLongMap.remove(0); + stringLongMap.remove(1); + assertTrue(stringLongMap.isEmpty()); + + put(2); + stringLongMap.clear(); + assertTrue(stringLongMap.isEmpty()); + + put(2); + stringLongMap.remove(0); + assertFalse(stringLongMap.isEmpty()); + } + + @Test + public void contains_key_then_return_true_otherwise_false() { + long[] keys = putRandom(10); + assertTrue(stringLongMap.containsKey(keys[5])); + + stringLongMap.remove(keys[5]); + assertFalse(stringLongMap.containsKey(keys[5])); + + stringLongMap.clear(); + assertFalse(stringLongMap.containsKey(keys[5])); + } + + @Test + public void contains_value_then_return_true_otherwise_false() { + long[] keys = putRandom(10); + String valueOne = PREFIX + keys[3]; + String valueTwo = PREFIX + keys[7]; + assertTrue(stringLongMap.containsValue(valueOne)); + assertTrue(stringLongMap.containsValue(valueTwo)); + assertFalse(stringLongMap.containsValue(PREFIX + "wrong")); + + stringLongMap.remove(keys[3]); + assertFalse(stringLongMap.containsValue(valueOne)); + + stringLongMap.clear(); + assertFalse(stringLongMap.containsValue(valueTwo)); + } + + @Test + public void contains_null_value_then_return_true() { + stringLongMap.put(100L, null); + assertTrue(stringLongMap.containsValue(null)); + + stringLongMap.put(100L, "val"); + assertFalse(stringLongMap.containsValue(null)); + } + + @Test + public void get_keys_then_return_correct_keys_array() { + long[] expectedKeys = Arrays.stream(putRandom(40)).sorted().toArray(); + long[] actualKeys = Arrays.stream(stringLongMap.keys()).sorted().toArray(); + assertEquals(stringLongMap.size(), actualKeys.length); + assertArrayEquals(expectedKeys, actualKeys); + + stringLongMap.remove(expectedKeys[10]); + stringLongMap.remove(expectedKeys[25]); + + long[] actualUpdatedKeys = Arrays.stream(stringLongMap.keys()).sorted().toArray(); + long[] expectedUpdatedKeys = Arrays.stream(expectedKeys) + .filter(key -> key != expectedKeys[10] && key != expectedKeys[25]) + .sorted() + .toArray(); + assertEquals(stringLongMap.size(), expectedUpdatedKeys.length); + assertArrayEquals(expectedUpdatedKeys, actualUpdatedKeys); + + stringLongMap.clear(); + long[] emptyKeys = stringLongMap.keys(); + assertNull(emptyKeys); + } + + @Test + public void get_values_then_return_correct_value_array() { + int mappingsNumber = 20; + long[] keys = putRandom(mappingsNumber); + + String[] actualValues = Arrays.stream(stringLongMap.values()).sorted().toArray(String[]::new); + String[] expectedValues = Arrays.stream(keys) + .mapToObj(key -> PREFIX + key) + .sorted() + .toArray(String[]::new); + assertEquals(stringLongMap.size(), actualValues.length); + assertArrayEquals(expectedValues, actualValues); + + stringLongMap.remove(keys[11]); + stringLongMap.remove(keys[19]); + + String[] actualUpdatedValues = Arrays.stream(stringLongMap.values()).sorted().toArray(String[]::new); + String[] expectedUpdatedValues = Arrays.stream(keys) + .filter(key -> key != keys[11] && key != keys[19]) + .mapToObj(key -> PREFIX + key) + .sorted() + .toArray(String[]::new); + assertEquals(actualUpdatedValues.length, expectedUpdatedValues.length); + assertEquals(stringLongMap.size(), actualUpdatedValues.length); + assertArrayEquals(expectedUpdatedValues, actualUpdatedValues); + + stringLongMap.clear(); + String[] emptyValues = stringLongMap.values(); + assertNull(emptyValues); + } + + @Test + public void get_size_return_correct_value() { + assertEquals(0, stringLongMap.size()); + + long[] keys = putRandom(100); + assertEquals(100, stringLongMap.size()); + + stringLongMap.remove(keys[2]); + stringLongMap.remove(keys[10]); + stringLongMap.remove(keys[59]); + assertEquals(97, stringLongMap.size()); + + stringLongMap.clear(); + assertEquals(0, stringLongMap.size()); + } + + + private void put(long mappingsNumber) { + LongStream + .range(0L, mappingsNumber) + .forEach(key -> stringLongMap.put(key, PREFIX + key)); + } + + private long[] putRandom(long mappingsNumber) { + Random random = new Random(); + return LongStream + .range(0L, mappingsNumber) + .map(l -> random.nextLong()) + .peek(key -> stringLongMap.put(key, PREFIX + key)) + .toArray(); + } + + @Test + void putMemoryUsage() { + integerLongMap = new LongMapImpl<>(); + + Runtime runtime = Runtime.getRuntime(); + long memoryBeforeFillingMap = runtime.totalMemory() - runtime.freeMemory(); + + fillMap(10000); + + long memoryAfterFillingMap = runtime.totalMemory() - runtime.freeMemory(); + LOGGER.info(() -> + String.format("Filling LongMap took %s bytes", memoryBeforeFillingMap - memoryAfterFillingMap)); + } + + @Test + void putJavaHashMapMemoryUsage() { + Map example = new HashMap<>(); + Runtime runtime = Runtime.getRuntime(); + long memoryBeforeFillingMap = runtime.totalMemory() - runtime.freeMemory(); + + fillJavaMap(10000, example); + long memoryAfterFillingMap = runtime.totalMemory() - runtime.freeMemory(); + LOGGER.info(() -> + String.format("Filling HashMap took %s bytes", memoryBeforeFillingMap - memoryAfterFillingMap)); + } + + @Test + void empty_bucket_should_return_null() { + integerLongMap = new LongMapImpl<>(); + + fillMap(10); + + assertNull(integerLongMap.get(2473248623L)); + } + + private void fillMap(int amount) { + TestUtil.apply(amount, TestUtil.BOUND, (key, value) -> integerLongMap.put(key, value)); + } + + private void fillJavaMap(int amount, Map example) { + TestUtil.apply(amount, TestUtil.BOUND, example::put); + } +} \ No newline at end of file diff --git a/src/test/java/TestUtil.java b/src/test/java/TestUtil.java new file mode 100644 index 0000000..2c1bcd5 --- /dev/null +++ b/src/test/java/TestUtil.java @@ -0,0 +1,16 @@ +import java.util.Random; +import java.util.function.BiFunction; + +public class TestUtil { + public static final int BOUND = 32000; + private static final Random RANDOM = new Random(); + + public static void apply(int amount, int bound, BiFunction operator) { + for (int i = 0; i < amount; i++) { + long key = RANDOM.nextLong(); + int value = RANDOM.nextInt(bound); + + operator.apply(key, value); + } + } +} \ No newline at end of file diff --git a/src/test/java/TimingExtension.java b/src/test/java/TimingExtension.java new file mode 100644 index 0000000..b2da010 --- /dev/null +++ b/src/test/java/TimingExtension.java @@ -0,0 +1,34 @@ + +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import java.lang.reflect.Method; +import java.util.logging.Logger; + +public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { + + private static final Logger LOGGER = Logger.getLogger(TimingExtension.class.getName()); + + private static final String START_TIME = "start time"; + + @Override + public void beforeTestExecution(ExtensionContext context) { + getStore(context).put(START_TIME, System.currentTimeMillis()); + } + + @Override + public void afterTestExecution(ExtensionContext context) { + Method testMethod = context.getRequiredTestMethod(); + long startTime = getStore(context).remove(START_TIME, long.class); + long duration = System.currentTimeMillis() - startTime; + + LOGGER.info(() -> + String.format("Method [%s] took %s ms.", testMethod.getName(), duration)); + } + + private ExtensionContext.Store getStore(ExtensionContext context) { + return context.getStore(ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod())); + } + +} \ No newline at end of file