From 376f4b251652a076c3f8ab0d0cb08752f3e153ac Mon Sep 17 00:00:00 2001 From: nmittler Date: Tue, 10 Jun 2014 10:31:55 -0700 Subject: [PATCH] Adding int-to-object map implementation. Motivation: Maps with integer keys are used in several places (HTTP/2 code, for example). To reduce the memory footprint of these structures, we need a specialized map class that uses ints as keys. Modifications: Added IntObjectHashMap, which is uses open addressing and double hashing for collision resolution. Result: A new int-based map class that can be shared across Netty. --- .../util/collection/IntObjectHashMap.java | 483 ++++++++++++++++++ .../netty/util/collection/IntObjectMap.java | 116 +++++ .../netty/util/collection/package-info.java | 20 + .../util/collection/IntObjectHashMapTest.java | 286 +++++++++++ 4 files changed, 905 insertions(+) create mode 100644 common/src/main/java/io/netty/util/collection/IntObjectHashMap.java create mode 100644 common/src/main/java/io/netty/util/collection/IntObjectMap.java create mode 100644 common/src/main/java/io/netty/util/collection/package-info.java create mode 100644 common/src/test/java/io/netty/util/collection/IntObjectHashMapTest.java diff --git a/common/src/main/java/io/netty/util/collection/IntObjectHashMap.java b/common/src/main/java/io/netty/util/collection/IntObjectHashMap.java new file mode 100644 index 0000000000..d01676094c --- /dev/null +++ b/common/src/main/java/io/netty/util/collection/IntObjectHashMap.java @@ -0,0 +1,483 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.util.collection; + +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * A hash map implementation of {@link IntObjectMap} that uses open addressing for keys. To minimize + * the memory footprint, this class uses open addressing rather than chaining. Collisions are + * resolved using double hashing. + * + * @param The value type stored in the map. + */ +public class IntObjectHashMap implements IntObjectMap, Iterable> { + + /** State indicating that a slot is available.*/ + private static final byte AVAILABLE = 0; + + /** State indicating that a slot is occupied. */ + private static final byte OCCUPIED = 1; + + /** State indicating that a slot was removed. */ + private static final byte REMOVED = 2; + + /** Default initial capacity. Used if not specified in the constructor */ + private static final int DEFAULT_CAPACITY = 11; + + /** Default load factor. Used if not specified in the constructor */ + private static final float DEFAULT_LOAD_FACTOR = 0.5f; + + /** The maximum number of elements allowed without allocating more space. */ + private int maxSize; + + /** The load factor for the map. Used to calculate {@link maxSize}. */ + private final float loadFactor; + + private byte[] states; + private int[] keys; + private V[] values; + private int size; + private int available; + + public IntObjectHashMap() { + this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR); + } + + public IntObjectHashMap(int initialCapacity) { + this(initialCapacity, DEFAULT_LOAD_FACTOR); + } + + public IntObjectHashMap(int initialCapacity, float loadFactor) { + if (initialCapacity < 1) { + throw new IllegalArgumentException("initialCapacity must be >= 1"); + } + if (loadFactor <= 0.0f) { + throw new IllegalArgumentException("loadFactor must be > 0"); + } + + this.loadFactor = loadFactor; + + // Allocate the arrays. + states = new byte[initialCapacity]; + keys = new int[initialCapacity]; + @SuppressWarnings("unchecked") + V[] temp = (V[]) new Object[initialCapacity]; + values = temp; + + // Initialize the maximum size value. + maxSize = calcMaxSize(initialCapacity); + + // Initialize the available element count + available = initialCapacity - size; + } + + @Override + public V get(int key) { + int index = indexOf(key); + return index < 0 ? null : values[index]; + } + + @Override + public V put(int key, V value) { + int hash = hash(key); + int capacity = capacity(); + int index = hash % capacity; + int increment = 1 + (hash % (capacity - 2)); + final int startIndex = index; + int firstRemovedIndex = -1; + do { + switch (states[index]) { + case AVAILABLE: + // We only stop probing at a AVAILABLE node, since the value may still exist + // beyond + // a REMOVED node. + if (firstRemovedIndex != -1) { + // We encountered a REMOVED node prior. Store the entry there so that + // retrieval + // will be faster. + insertAt(firstRemovedIndex, key, value); + return null; + } + + // No REMOVED node, just store the entry here. + insertAt(index, key, value); + return null; + case OCCUPIED: + if (keys[index] == key) { + V previousValue = values[index]; + insertAt(index, key, value); + return previousValue; + } + break; + case REMOVED: + // Check for first removed index. + if (firstRemovedIndex == -1) { + firstRemovedIndex = index; + } + break; + default: + throw new AssertionError("Invalid state: " + states[index]); + } + + // REMOVED or OCCUPIED but wrong key, keep probing ... + index += increment; + if (index >= capacity) { + // Handle wrap-around by decrement rather than mod. + index -= capacity; + } + } while (index != startIndex); + + if (firstRemovedIndex == -1) { + // Should never happen. + throw new AssertionError("Unable to insert"); + } + + // Never found a AVAILABLE slot, just use the first REMOVED. + insertAt(firstRemovedIndex, key, value); + return null; + } + + @Override + public void putAll(IntObjectMap sourceMap) { + if (sourceMap instanceof IntObjectHashMap) { + // Optimization - iterate through the arrays. + IntObjectHashMap source = (IntObjectHashMap) sourceMap; + int i = -1; + while ((i = source.nextEntryIndex(i + 1)) >= 0) { + put(source.keys[i], source.values[i]); + } + return; + } + + // Otherwise, just add each entry. + for (Entry entry : sourceMap.entries()) { + put(entry.key(), entry.value()); + } + } + + @Override + public V remove(int key) { + int index = indexOf(key); + if (index < 0) { + return null; + } + + V prev = values[index]; + removeAt(index); + return prev; + } + + @Override + public int size() { + return size; + } + + @Override + public boolean isEmpty() { + return size == 0; + } + + @Override + public void clear() { + Arrays.fill(states, AVAILABLE); + Arrays.fill(values, null); + size = 0; + available = capacity(); + } + + @Override + public boolean containsKey(int key) { + return indexOf(key) >= 0; + } + + @Override + public boolean containsValue(V value) { + int i = -1; + while ((i = nextEntryIndex(i + 1)) >= 0) { + V next = values[i]; + if (value == next || (value != null && value.equals(next))) { + return true; + } + } + return false; + } + + @Override + public Iterable> entries() { + return this; + } + + @Override + public Iterator> iterator() { + return new IteratorImpl(); + } + + @Override + public int[] keys() { + int[] outKeys = new int[size()]; + copyEntries(keys, outKeys); + return outKeys; + } + + @Override + public V[] values(Class clazz) { + @SuppressWarnings("unchecked") + V[] outValues = (V[]) Array.newInstance(clazz, size()); + copyEntries(values, outValues); + return outValues; + } + + /** + * Copies the occupied entries from the source to the target array. + */ + private void copyEntries(Object sourceArray, Object targetArray) { + int sourceIx = -1; + int targetIx = 0; + while ((sourceIx = nextEntryIndex(sourceIx + 1)) >= 0) { + Object obj = Array.get(sourceArray, sourceIx); + Array.set(targetArray, targetIx++, obj); + } + } + + /** + * Locates the index for the given key. This method probes using double hashing. + * + * @param key the key for an entry in the map. + * @return the index where the key was found, or {@code -1} if no entry is found for that key. + */ + private int indexOf(int key) { + int hash = hash(key); + int capacity = capacity(); + int increment = 1 + (hash % (capacity - 2)); + int index = hash % capacity; + int startIndex = index; + do { + switch(states[index]) { + case AVAILABLE: + // It's available, so no chance that this value exists anywhere in the map. + return -1; + case OCCUPIED: + if (key == keys[index]) { + // Found it! + return index; + } + break; + default: + break; + } + + // REMOVED or OCCUPIED but wrong key, keep probing ... + index += increment; + if (index >= capacity) { + // Handle wrap-around by decrement rather than mod. + index -= capacity; + } + } while (index != startIndex); + + // Got back to the beginning. Not found. + return -1; + } + + /** + * Determines the current capacity (i.e. size of the arrays). + */ + private int capacity() { + return keys.length; + } + + /** + * Creates a hash value for the given key. + */ + private int hash(int key) { + // Just make sure the integer is positive. + return key & Integer.MAX_VALUE; + } + + /** + * Performs an insert of the key/value at the given index position. If necessary, performs a + * rehash of the map. + * + * @param index the index at which to insert the key/value + * @param key the entry key + * @param value the entry value + */ + private void insertAt(int index, int key, V value) { + byte state = states[index]; + if (state != OCCUPIED) { + // Added a new mapping, increment the size. + size++; + + if (state == AVAILABLE) { + // Consumed a OCCUPIED slot, decrement the number of available slots. + available--; + } + } + + keys[index] = key; + values[index] = value; + states[index] = OCCUPIED; + + if (size > maxSize) { + // Need to grow the arrays. + // TODO: consider using the next prime greater than capacity * 2. + rehash(capacity() * 2); + } else if (available == 0) { + // Open addressing requires that we have at least 1 slot available. Need to refresh + // the arrays to clear any removed elements. + rehash(capacity()); + } + } + + /** + * Marks the entry at the given index position as {@link REMOVED} and sets the value to + * {@code null}. + *

+ * TODO: consider performing re-compaction. + * + * @param index the index position of the element to remove. + */ + private void removeAt(int index) { + if (states[index] == OCCUPIED) { + size--; + } + states[index] = REMOVED; + values[index] = null; + } + + /** + * Calculates the maximum size allowed before rehashing. + */ + private int calcMaxSize(int capacity) { + // Clip the upper bound so that there will always be at least one + // available slot. + int upperBound = capacity - 1; + return Math.min(upperBound, (int) (capacity * loadFactor)); + } + + /** + * Rehashes the map for the given capacity. + * + * @param newCapacity the new capacity for the map. + */ + private void rehash(int newCapacity) { + int oldCapacity = capacity(); + int[] oldKeys = keys; + V[] oldVals = values; + byte[] oldStates = states; + + // New states array is automatically initialized to AVAILABLE (i.e. 0 == AVAILABLE). + states = new byte[newCapacity]; + keys = new int[newCapacity]; + @SuppressWarnings("unchecked") + V[] temp = (V[]) new Object[newCapacity]; + values = temp; + + size = 0; + available = newCapacity; + maxSize = calcMaxSize(newCapacity); + + // Insert the new states. + for (int i = 0; i < oldCapacity; ++i) { + if (oldStates[i] == OCCUPIED) { + put(oldKeys[i], oldVals[i]); + } + } + } + + /** + * Returns the next index of the next entry in the map. + * + * @param index the index at which to begin the search. + * @return the index of the next entry, or {@code -1} if not found. + */ + private int nextEntryIndex(int index) { + int capacity = capacity(); + for (; index < capacity; ++index) { + if (states[index] == OCCUPIED) { + return index; + } + } + return -1; + } + + /** + * Iterator for traversing the entries in this map. + */ + private final class IteratorImpl implements Iterator> { + int prevIndex; + int nextIndex; + + IteratorImpl() { + prevIndex = -1; + nextIndex = nextEntryIndex(0); + } + + @Override + public boolean hasNext() { + return nextIndex >= 0; + } + + @Override + public Entry next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + + prevIndex = nextIndex; + nextIndex = nextEntryIndex(nextIndex + 1); + return new EntryImpl(prevIndex); + } + + @Override + public void remove() { + if (prevIndex < 0) { + throw new IllegalStateException("Next must be called before removing."); + } + removeAt(prevIndex); + prevIndex = -1; + } + } + + /** + * {@link Entry} implementation that just references the key/value at the given index position. + */ + private final class EntryImpl implements Entry { + final int index; + + EntryImpl(int index) { + this.index = index; + } + + @Override + public int key() { + return keys[index]; + } + + @Override + public V value() { + return values[index]; + } + + @Override + public void setValue(V value) { + values[index] = value; + } + } +} diff --git a/common/src/main/java/io/netty/util/collection/IntObjectMap.java b/common/src/main/java/io/netty/util/collection/IntObjectMap.java new file mode 100644 index 0000000000..ae1c9629b6 --- /dev/null +++ b/common/src/main/java/io/netty/util/collection/IntObjectMap.java @@ -0,0 +1,116 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.util.collection; + +/** + * Interface for a primitive map that uses {@code int}s as keys. + * + * @param the value type stored in the map. + */ +public interface IntObjectMap { + + /** + * An Entry in the map. + * + * @param the value type stored in the map. + */ + interface Entry { + /** + * Gets the key for this entry. + */ + int key(); + + /** + * Gets the value for this entry. + */ + V value(); + + /** + * Sets the value for this entry. + */ + void setValue(V value); + } + + /** + * Gets the value in the map with the specified key. + * + * @param key the key whose associated value is to be returned. + * @return the value or {@code null} if the key was not found in the map. + */ + V get(int key); + + /** + * Puts the given entry into the map. + * + * @param key the key of the entry. + * @param value the value of the entry. + * @return the previous value for this key or {@code null} if there was no previous mapping. + */ + V put(int key, V value); + + /** + * Puts all of the entries from the given map into this map. + */ + void putAll(IntObjectMap sourceMap); + + /** + * Removes the entry with the specified key. + * + * @param key the key for the entry to be removed from this map. + * @return the previous value for the key, or {@code null} if there was no mapping. + */ + V remove(int key); + + /** + * Returns the number of entries contained in this map. + */ + int size(); + + /** + * Indicates whether or not this map is empty (i.e {@link #size()} == {@code 0]). + + */ + boolean isEmpty(); + + /** + * Clears all entries from this map. + */ + void clear(); + + /** + * Indicates whether or not this map contains a value for the specified key. + */ + boolean containsKey(int key); + + /** + * Indicates whether or not the map contains the specified value. + */ + boolean containsValue(V value); + + /** + * Gets an iterable collection of the entries contained in this map. + */ + Iterable> entries(); + + /** + * Gets the keys contained in this map. + */ + int[] keys(); + + /** + * Gets the values contained in this map. + */ + V[] values(Class clazz); +} diff --git a/common/src/main/java/io/netty/util/collection/package-info.java b/common/src/main/java/io/netty/util/collection/package-info.java new file mode 100644 index 0000000000..b5f6029edb --- /dev/null +++ b/common/src/main/java/io/netty/util/collection/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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. + */ + +/** + * Utility classes for commonly used collections. + */ +package io.netty.util.collection; diff --git a/common/src/test/java/io/netty/util/collection/IntObjectHashMapTest.java b/common/src/test/java/io/netty/util/collection/IntObjectHashMapTest.java new file mode 100644 index 0000000000..207817236c --- /dev/null +++ b/common/src/test/java/io/netty/util/collection/IntObjectHashMapTest.java @@ -0,0 +1,286 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.util.collection; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for {@link IntObjectHashMap}. + */ +public class IntObjectHashMapTest { + + private static class Value { + private final String name; + + public Value(String name) { + this.name = name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + Value other = (Value) obj; + if (name == null) { + if (other.name != null) { + return false; + } + } else if (!name.equals(other.name)) { + return false; + } + return true; + } + } + + private IntObjectHashMap map; + + @Before + public void setup() { + map = new IntObjectHashMap(); + } + + @Test + public void putNewMappingShouldSucceed() { + Value v = new Value("v"); + assertNull(map.put(1, v)); + assertEquals(1, map.size()); + assertTrue(map.containsKey(1)); + assertTrue(map.containsValue(v)); + assertEquals(v, map.get(1)); + } + + @Test + public void putShouldReplaceValue() { + Value v1 = new Value("v1"); + assertNull(map.put(1, v1)); + + // Replace the value. + Value v2 = new Value("v2"); + assertSame(v1, map.put(1, v2)); + + assertEquals(1, map.size()); + assertTrue(map.containsKey(1)); + assertTrue(map.containsValue(v2)); + assertEquals(v2, map.get(1)); + } + + @Test + public void putShouldGrowMap() { + for (int i = 0; i < 10000; ++i) { + Value v = new Value(Integer.toString(i)); + assertNull(map.put(i, v)); + assertEquals(i + 1, map.size()); + assertTrue(map.containsKey(i)); + assertTrue(map.containsValue(v)); + assertEquals(v, map.get(i)); + } + } + + @Test + public void removeMissingValueShouldReturnNull() { + assertNull(map.remove(1)); + assertEquals(0, map.size()); + } + + @Test + public void removeShouldReturnPreviousValue() { + Value v = new Value("v"); + map.put(1, v); + assertSame(v, map.remove(1)); + } + + /** + * This test is a bit internal-centric. We're just forcing a rehash to occur based on no longer + * having any FREE slots available. We do this by adding and then removing several keys up to + * the capacity, so that no rehash is done. We then add one more, which will cause the rehash + * due to a lack of free slots and verify that everything is still behaving properly + */ + @Test + public void noFreeSlotsShouldRehash() { + for (int i = 0; i < 10; ++i) { + map.put(i, new Value(Integer.toString(i))); + // Now mark it as REMOVED so that size won't cause the rehash. + map.remove(i); + assertEquals(0, map.size()); + } + + // Now add an entry to force the rehash since no FREE slots are available in the map. + Value v = new Value("v"); + map.put(1, v); + assertEquals(1, map.size()); + assertSame(v, map.get(1)); + } + + @Test + public void putAllShouldSucceed() { + Value v1 = new Value("v1"); + Value v2 = new Value("v2"); + Value v3 = new Value("v3"); + map.put(1, v1); + map.put(2, v2); + map.put(3, v3); + + IntObjectHashMap map2 = new IntObjectHashMap(); + map2.putAll(map); + assertEquals(3, map2.size()); + assertSame(v1, map2.get(1)); + assertSame(v2, map2.get(2)); + assertSame(v3, map2.get(3)); + } + + @Test + public void clearShouldSucceed() { + Value v1 = new Value("v1"); + Value v2 = new Value("v2"); + Value v3 = new Value("v3"); + map.put(1, v1); + map.put(2, v2); + map.put(3, v3); + map.clear(); + assertEquals(0, map.size()); + assertTrue(map.isEmpty()); + } + + @Test + public void containsValueShouldFindNull() { + map.put(1, new Value("v1")); + map.put(2, null); + map.put(3, new Value("v2")); + assertTrue(map.containsValue(null)); + } + + @Test + public void containsValueShouldFindInstance() { + Value v = new Value("v1"); + map.put(1, new Value("v2")); + map.put(2, new Value("v3")); + map.put(3, v); + assertTrue(map.containsValue(v)); + } + + @Test + public void containsValueShouldFindEquivalentValue() { + map.put(1, new Value("v1")); + map.put(2, new Value("v2")); + map.put(3, new Value("v3")); + assertTrue(map.containsValue(new Value("v2"))); + } + + @Test + public void containsValueNotFindMissingValue() { + map.put(1, new Value("v1")); + map.put(2, new Value("v2")); + map.put(3, new Value("v3")); + assertFalse(map.containsValue(new Value("v4"))); + } + + @Test + public void iteratorShouldTraverseEntries() { + map.put(1, new Value("v1")); + map.put(2, new Value("v2")); + map.put(3, new Value("v3")); + + // Add and then immediately remove another entry. + map.put(4, new Value("v4")); + map.remove(4); + + Set found = new HashSet(); + for (IntObjectMap.Entry entry : map.entries()) { + assertTrue(found.add(entry.key())); + } + assertEquals(3, found.size()); + assertTrue(found.contains(1)); + assertTrue(found.contains(2)); + assertTrue(found.contains(3)); + } + + @Test + public void keysShouldBeReturned() { + map.put(1, new Value("v1")); + map.put(2, new Value("v2")); + map.put(3, new Value("v3")); + + // Add and then immediately remove another entry. + map.put(4, new Value("v4")); + map.remove(4); + + int[] keys = map.keys(); + assertEquals(3, keys.length); + + Set expected = new HashSet(); + expected.add(1); + expected.add(2); + expected.add(3); + + Set found = new HashSet(); + for (int key : keys) { + assertTrue(found.add(key)); + } + assertEquals(expected, found); + } + + @Test + public void valuesShouldBeReturned() { + Value v1 = new Value("v1"); + Value v2 = new Value("v2"); + Value v3 = new Value("v3"); + map.put(1, v1); + map.put(2, v2); + map.put(3, v3); + + // Add and then immediately remove another entry. + map.put(4, new Value("v4")); + map.remove(4); + + Value[] values = map.values(Value.class); + assertEquals(3, values.length); + + Set expected = new HashSet(); + expected.add(v1); + expected.add(v2); + expected.add(v3); + + Set found = new HashSet(); + for (Value value : values) { + assertTrue(found.add(value)); + } + assertEquals(expected, found); + } +}