java容器
以下内容均来自www.cyc2018.xyz/Java/Java%2…
一、概览
容器主要Collection和Map两种,Collection都是存储对象的集合,而Map都是存储键值对的映射。
Collection
Collection 下面又细分为 Set、List、Queue接口。
Set
TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作,在查找效率上不如HashSet,时间复杂度未O(logN)。
HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作,并且没有元素的插入顺序的信息,因此通过Iterator遍历得到的结果是不确定的,查找的时间复杂度是O(1)。
LinkedHashSet:查找的效率和HashSet一致,并且内部使用了双向链表维护元素的插入顺序,因此插入数据的效率较高。
List
ArrayList:底层是数组,支持随机访问,查找效率较高,插入新元素消耗较大。
Vector:类似ArrayList,不过线程安全,但牺牲了性能。
LinkedList:基于双向链表实现,只能顺序访问,但在插入删除操作的效率较高。还能用于实现栈、队列和双向队列。
Queue
LinkedList:用于实现双向队列。
PriorityQueue:基于堆结构实现,可以用来实现优先队列。
Map
TreeMap:基于红黑树实现。
HashMap:底层是数组+链表,jdk1.8后加入了红黑树,当达到一定的要求后,链表会转换成红黑树,但同样的,红黑树也会因为元素的减少而转换成链表。
HashTable:类似于HashMap,但线程安全,但不建议使用,最好使用ConcurrentHashMap来保证线程安全,效率更高。
LinkedHashMap:使用双向链表维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。
二、容器中的设计模式
迭代器模式
Collection继承了Iterable接口,它的iterator()方法能够产生一个iterator对象,通过这个对象去遍历Collection中的元素。
List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
System.out.println(item);
}
适配器模式
通过java.util.Arrays类的asList方法将数组转换成List类型。
Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);//将数组转成List
List list = Arrays.asList(1, 2, 3);//直接输入数组元素
三、源码分析
以下代码若无特别说明,均基于jdk1.8。
ArrayList
1.概览
ArrayList基于数组实现,因为实现了RandomAccess接口,因此支持快速随机访问。数组的默认大小为10。
2.扩容
每次添加元素都会调用ensureCapacityInternal()方法来确保容量足够,若不够,则会调用grow()方法来进行扩容,一般情况会扩容为oldCapacity + (oldCapacity >> 1),也就是每次增加一半的容量;如果还是小于传入的minCapacity,则扩容为minCapacity;若超出了最大的MAX_ARRAY_SIZE,则扩容为Integer.MAX_VALUE。扩容的同时需要调用Arrays.copyOf()方法来将原数组复制到新数组,所以代价较大,所以如果在创建时就能确定元素的个数,那么指定一个足够的大小会减少不必要的扩容操作,提高效率。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
3.删除元素
删除元素需要调用System.arrayCopy()将index后面的所有元素都往前一个位置,因此付出的代价非常高。
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
4.序列化
如果数组的容量很大,但实际存放的元素很少,会有大量的null存在,所以给elementData数组申明了transient,这样默认就不会序列化这个数组。
ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
// Read in size, and any hidden stuff
s.defaultReadObject();
// Read in capacity
s.readInt(); // ignored
if (size > 0) {
// be like clone(), allocate array based upon size not capacity
ensureCapacityInternal(size);
Object[] a = elementData;
// Read in all elements in the proper order.
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();
// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);
// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
5.fast-fail
使用modCount来记录list结构变化次数,结构变化至少需要一个元素的增加或删除及以上或者修改内部数组的大小,只修改元素的值并不会发生结构变化。 在进行序列化或者迭代时,需要比较操作前后的modCount是否相等,如果不相等,则抛出ConcurrentModificationExcepiton。
Vector
1.同步
使用了synchronized进行同步。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
2.扩容
Vector的构造函数可以传入capacityIncrement参数,这个参数用于扩容时每次扩大的容量,如果大于0则增加capacityIncrement;否则按照原来的容量扩容1倍。
public Vector(int initialCapacity, int capacityIncrement) {
super();
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
this.elementData = new Object[initialCapacity];
this.capacityIncrement = capacityIncrement;
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
如果调用没有capacityIncrement参数的构造函数,默认的capacityIncrement是设置的0,所以使用这个构造函数创建的实例,每次扩容都是扩容1倍。
3.与ArrayList的比较
Vector是同步的,所以开销相比ArrayList更大,访问的速度也就更慢了。默认的扩容大小不同,Vector是1倍,而ArrayList是0.5倍。
4.替代方案
可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。
List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);
也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。
List<String> list = new CopyOnWriteArrayList<>();
LinkedList
1、概览
基于双向链表实现,使用Node来存储节点信息。
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
2、与ArrayList的比较
ArrayList基于动态数组实现,而LinkedList则基于双向链表实现。区别就是数组与链表的区别:
1、数组支持随机访问,但插入删除较慢,因为如果不是在最后一个位置上插入删除,都需要复制到新数组,所以相对较慢。 2、链表插入删除很快,只需要改变指针,但不支持随机访问,只能遍历查找想要的元素。
HashMap
Hashmap的源码以jdk1.7为主。
1、存储结构
hashmap的存储结构是数组+链表(jdk1.7),实际就是一个Entry类型数组table,然后Entry类型是一个单向链表的结构。
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
}
2、拉链法的工作原理
HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
新建一个HashMap,默认的大小是16,首先插入("k1","v1"),计算得到hashcode是116,使用除留余数法得到所在的桶下标 115%16=3。然后插入("k2","v2")("k3","v3"),k2和k3的hashcode都是118,使用除留余数法得到所在的桶下标 118%16=6,那么k3插入的位置是k2的前面,因为采用的是头插法,所以是插在链表的头部。
3、put
put方法首先通过hash值和table的长度来确定存放的桶下标,然后对该下表的链表进行遍历,如果找到hash值相同且equals的key,那么就覆盖value,否则就插入到头部(因为addEntry方法采用的是头插法)。
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 键为 null 单独处理
if (key == null)
return putForNullKey(value);
int hash = hash(key);
// 确定桶下标
int i = indexFor(hash, table.length);
// 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// 插入新键值对
addEntry(hash, key, value, i);
return null;
}
put允许存放key为null的键值对,但由于null无法调用hashcode方法来计算hash值,所以指定了第0个桶存放key为null的键值对。
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
addEntry采用了头插法,首先判断size是否大于threshold并且table的bucketIndex位置是否存在链表,如果有一个为否,则进行扩容,然后通过hash值计算出bucketIndex。然后进入createEntry方法,new了一个新的Entry实例给这个e作为next,所以新插入的元素就是头部。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 头插法,链表头部指向新的键值对
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
4、确定桶下标
int hash = hash(key);
int i = indexFor(hash, table.length);
计算hash值
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
计算下标 确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
如果进行了扩容操作,需要重新计算下标。
static int indexFor(int h, int length) {
return h & (length-1);
}
5、扩容
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
每次扩容为翻倍,扩容需要将原数组的所有数据移到新数组中,这一步开销很大。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
6、计算数组容量
如果通过构造函数创建HashMap,传入的容量值不是2的次方,则会被tableSizeFor方法转换成2的n次方。
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
7、链表转红黑树
jdk1.8在hashmap结构中新增了红黑树,只要Entry数组的某个位置存储的链表长度大于等于8就会将链表转换成红黑树,并且红黑树节点后面如果减少到6个就会再次变回链表。
8、与HashTable比较
- Hashtable 使用 synchronized 来进行同步。
- HashMap 可以插入键为 null 的 Entry。
- HashMap 的迭代器是 fail-fast 迭代器。
- HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
ConcurrentHashMap
1、存储结构
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。
Segment 继承自 ReentrantLock。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
transient volatile HashEntry<K,V>[] table;
transient int count;
transient int modCount;
transient int threshold;
final float loadFactor;
}
final Segment<K,V>[] segments;
默认的并发级别为 16,也就是说默认创建 16 个 Segment。
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
2、size 操作
每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。
/**
* The number of elements. Accessed only either within locks
* or among other volatile reads that maintain visibility.
*/
transient int count;
在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。
ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。
尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。
如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
/**
* Number of unsynchronized retries in size and containsValue
* methods before resorting to locking. This is used to avoid
* unbounded retries if tables undergo continuous modification
* which would make it impossible to obtain an accurate result.
*/
static final int RETRIES_BEFORE_LOCK = 2;
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
// 超过尝试次数,则对每个 Segment 加锁
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 连续两次得到的结果一致,则认为这个结果是正确的
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
3、JDK 1.8 的改动
JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。
JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。
并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。
LinkedHashMap
继承了HashMap,因此具有和HashMap一样的快速查找特性。内部维护了一个双向链表,用来维护插入顺序或者LRU顺序。
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序。
final boolean accessOrder;
LinkedHashMap有两个用于维护顺序的函数
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
afterNodeAccess()
如果一个节点被访问并且accessOrder是true,那么这个节点就会被移到链表尾部,这样保证了链表尾部永远是最近访问过的节点,而首部是最久没使用过的节点。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
afterNodeInsertion()
在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。
evict 只有在构建 Map 的时候才为 false,在这里为 true。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
LRU 缓存
以下是使用 LinkedHashMap 实现的一个 LRU 缓存:
- 设定最大缓存空间 MAX_ENTRIES 为 3;
- 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
- 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。
class LRUCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_ENTRIES = 3;
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_ENTRIES;
}
LRUCache() {
super(MAX_ENTRIES, 0.75f, true);
}
}
public static void main(String[] args) {
LRUCache<Integer, String> cache = new LRUCache<>();
cache.put(1, "a");
cache.put(2, "b");
cache.put(3, "c");
cache.get(1);
cache.put(4, "d");
System.out.println(cache.keySet());
}
[3, 1, 4]