容器主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。
Collection
List
主要分为以下三种
ArrayList
基于动态数组实现,支持随机访问。ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入多个null元素(多个null的位置如何确定?),底层通过数组实现。
方法
1、add(Object element) 向列表的尾部添加指定的元素。add(int index, E e)向指定位置添加元素
2、size() 返回列表中的元素个数。
3、get(int index) 返回列表中指定位置的元素,index从0开始。
4、isEmpty()判断是否为空
扩容
每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍。
最核心的是grow()方法,调用Arrays.copyOf方法将elementData数组指向新的内存空间,并将elementData的数据复制到新的内存空间。
- 简单来说就是在增长数组的时候,与所需的最小的容量进行比较
- 保证要扩容的大小大于最小满足的容量
- 如果已经大于了最大的数组大小,再做一次最大的容量处理
在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。
数组(Array)和列表(ArrayList)有什么区别
- 数据类型:Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型。
- 容量上:Array 大小固定,ArrayList 的大小是动态变化的。
- 操作上:ArrayList提供更多的方法和特性,如:addAll(),removeAll(),iterator()等等。
使用基本数据类型或者知道数据元素数量的时候可以考虑 Array;ArrayList 处理固定数量的基本类型数据类型时会自动装箱来减少编码工作量,但是相对较慢。
ArrayList 与 LinkedList 区别
-
数据结构: Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构;
-
插入和删除:Arraylist 查询快,LinkedList 增加、删除更快
-
内存空间:LinkedList 比 ArrayList 更占内存,因为 LinkedList
为每一个节点存储了两个引用,一个指向前一个元素,一个指向下一个元素。
ArrayList 与 Vector 区别
● Vector是线程安全的,ArrayList不是线程安全的。其中,Vector在关键性的方法前面都加了synchronized关键字,开销就比ArrayList 要大。
● ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间。
LinkedList
LinkedList同时实现了List接口和Deque对口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(stack)。
底层数据结构
LinkedList底层通过双向链表实现,通过first和last引用分别指向链表的第一个和最后一个元素。当链表为空的时候first和last都指向null。
可以了解一下内部结构:
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
Node是私有的内部类:
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
构造函数
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
getFirst(), getLast()
/**
* Returns the first element in this list.
*
* @return the first element in this list
* @throws NoSuchElementException if this list is empty
*/
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
/**
* Returns the last element in this list.
*
* @return the last element in this list
* @throws NoSuchElementException if this list is empty
*/
public E getLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
add()
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* Links e as last element.
*/
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
removeFirest(), removeLast(), remove(e), remove(index)
remove()方法也有两个版本,一个是删除跟指定元素相等的第一个元素remove(Object o),另一个是删除指定下标处的元素remove(int index)。
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}
/**
* Unlinks non-null node x.
*/
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {// 第一个元素
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {// 最后一个元素
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null; // GC
size--;
modCount++;
return element;
}
clear()
为了让GC更快可以回收放置的元素,需要将node之间的引用关系赋空。
/**
* Removes all of the elements from this list.
* The list will be empty after this call returns.
*/
public void clear() {
// Clearing all of the links between nodes is "unnecessary", but:
// - helps a generational GC if the discarded nodes inhabit
// more than one generation
// - is sure to free memory even if there is a reachable Iterator
for (Node<E> x = first; x != null; ) {
Node<E> next = x.next;
x.item = null;
x.next = null;
x.prev = null;
x = next;
}
first = last = null;
size = 0;
modCount++;
}
Vector
Vector与ArrayList类似,不过是线程安全的。
Set
HashSet
HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。
由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性。
基本特点
● HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合。
● HashSet 允许有 null 值。
● HashSet 是无序的,即不会记录插入的顺序。
● HashSet 不是线程安全的。
方法
添加元素:add() 方法
判断元素是否存在: contains()
删除元素: remove()
计算集合容量: size()
遍历:for-each
Queue
Queue接口继承自Collection接口,Queue只是一个接口,当需要使用队列时首选ArrayDeque(次选是LinkedList)。包含以下6种方法
Deque
Deque的含义是“double ended queue”,即双端队列,它既可以当作栈使用,也可以当作队列使用。英文读作"deck".
Deque 继承自 Queue接口,除了支持Queue的方法之外,还支持insert,
remove和examine操作,由于Deque是双向的,所以可以对队列的头和尾都进行操作.当把Deque当做FIFO的queue来使用时,元素是从deque的尾部添加,从头部进行删除的;
所以deque的部分方法是和queue是等同的。
PriorityQueue
PriorityQueue,即优先队列。优先队列的作用是能保证每次取出的元素都是队列中最小的.
PriorityQueue的peek()和element操作是常数时间,add(), offer(), 无参数的remove()以及poll()方法的时间复杂度都是log(N)。
remove()和poll()
remove()和poll()方法的语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回null。由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。
Map
HashMap
HashMap实现了Map接口,跟TreeMap不同,该容器不保证元素顺序,根据需要该容器可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。
底层结构
JDK1.7与1.8的区别
JDK1.7 :由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
JDK1.8 :由“数组+链表/红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。
● 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
● 当链表超过 8 且数据总量超过 64 才会转红黑树。
扩容机制
hashmap什么时候进行扩容呢?
当hashmap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作。
如何扩容?
使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
jdk1.7:
transfer方法的作用是把原table的Node放到新的table中,jdk1.7使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
jdk1.8:
- 扩容时有个判断 e.hash & oldCap 是否为零,不需要像 JDK1.7 的实现那样重新计算hash ,只需要看看原来的 hash 值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引 + oldCap ”。
- JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(头插法)。JDK1.8 不会倒置,使用尾插法。
举个例子,假设table原长度是16,扩容后长度32,那么一个hash值在扩容前后的table下标是这么计算:
hash值的每个二进制位用abcde来表示,那么,hash和新旧table按位与的结果,最后4位显然是相同的,唯一可能出现的区别就在第5位,也就是hash值的b所在的那一位,如果b所在的那一位是0,那么新table按位与的结果和旧table的结果就相同,反之如果b所在的那一位是1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制10000就是旧table的长度16。
换言之,hash值的新散列下标是不是需要加上旧table长度,只需要看看hash值第5位是不是1就行了,位运算的方法就是hash值和10000(也就是旧table长度)来按位与,其结果只可能是10000或者00000。
————————————————
所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。
HashMap容量
为什么是2的n次方?
- 在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。我们一般根据key得到hashcode值,然后跟数组的长度-1做一次“与”运算(&),这样的效率要比取模运算快得多。
- 当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,可以让0与1分布均匀,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小。
- 默认初始容量是16,即使指定了不是2的n次方的容量,也会扩容成比这个指定容量大一点的2的n次方,所以容量始终保持为2的n次方
看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
也就是说length-1在二进制来说每位都是1,这样可以保证最大的程度的散列hash值,否则,当有一位是0时,不管hash值对应位是1还是0,按位与后的结果都是0,会造成散列结果的重复。
为什么可以插入空值
在HashMap中添加key==null的Entry时会调用putForNullKey方法
1.先在table[0]的链表中寻找null key,如果有null key就直接覆盖原来的value,返回原来的value;
2.如果在table[0]中没有找到,就进行头插,但是要先判断是否要扩容,需要就扩容,然后进行头插,此时table[0]就是新插入的null key Entry了。
线程安全问题
如何保证线程安全?
要想实现线程安全,那么需要调用 collections 类的静态方法synchronizeMap()实现。
● HashMap 本身非线程安全的,但是当使用 Collections.synchronizedMap(new HashMap()) 进行包装后就返回一个线程安全的Map。其中每个方法都用synchronized关键字修饰后再返回,实现线程安全
或者使用粒度更小的ConcurrentHashMap。
Collections.synchronizedMap()与ConcurrentHashMap主要区别
Collections.synchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步,而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。这样,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,所以,即使在遍历map时,其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。
1.7-get()
算法思想是首先通过hash()函数得到对应bucket的下标,然后依次遍历冲突链表,通过key.equals(k)方法来判断是否是要找的那个entry。该方法调用了getEntry(Object key)得到相应的entry,然后返回entry.getValue()。
hash(k)&(table.length-1)等价于hash(k)%table.length,原因是HashMap要求table.length必须是2的指数,因此table.length-1就是二进制低位全是1,跟hash(k)相与会将哈希值的高位全抹掉,剩下的就是余数了。
1.7-put()
put(K key, V value)方法是将指定的key, value对添加到map里。该方法首先会对map做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)方法插入新的entry,插入方式为头插法。
//addEntry()
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 = hash & (table.length-1);//hash%table.length
}
//在冲突链表头部插入新的entry
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
1.8-get()
相对于 put 来说,get 真的太简单了。
● 计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)
● 判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步
● 判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步
● 遍历链表,直到找到相等(==或equals)的 key
1.8-put()
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
// 第五个参数 evict 我们这里不关心
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度
// 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {// 数组该位置有数据
Node<K,V> e; K k;
// 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果该节点是代表红黑树的节点,调用红黑树的插值方法,本文不展开说红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 到这里,说明数组该位置上是一个链表
for (int binCount = 0; ; ++binCount) {
// 插入到链表的最后面(Java7 是插入到链表的最前面)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
// 会触发下面的 treeifyBin,也就是将链表转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果在该链表中找到了"相等"的 key(== 或 equals)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
break;
p = e;
}
}
// e!=null 说明存在旧值的key与要插入的key"相等"
// 对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
性能
有两个参数可以影响HashMap的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
hashCode()和equals()
hashCode()方法决定了对象会被放到哪个bucket里,当多个对象的哈希值冲突时,equals()方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到HashMap或HashSet中,需重写hashCode()和equals()方法。不然可能导致相同的两个对象同时存在于HashMap当中。