本文分为List、Set两大部分,从底层逻辑,对List和Set进行分析。
一、List
List继承自Collection集合,排列有序且可重复,有索引,可以根据索引检索。
1. LinkedList
基于链表实现,线程不安全;可以在任意位置高效插入和删除一个有序序列。
1.1 容量和扩容
没有初始容量,也没有扩容机制。
1.2 创建
// 创建一个双向链表,每个节点含有一个数据,并包含上一个节点对象的引用和下一个节点对象的引用
LinkedList<String> linkedList = new LinkedList<>();
1.3 添加元素
// 添加元素到最后位置
linkedList.add("aaa");
// 向指定位置添加元素
// 如果index=size,则添加到集合末尾;
// 否则,添加到指定位置,同时修改节点的上一个对象和下一个对象的引用
linkedList.add(2, "bbb");
添加元素后,会修改上一个元素和下一个元素的引用。
1.4 删除元素
// 删除第一个元素
linkedList.remove();
linkedList.removeFirst();
// 删除指定下标的元素
linkedList.remove(2);
// 删除指定元素
linkedList.remove("aa");
// 删除最后一个元素
linkedList.removeLast();
删除元素后,会修改上一个元素和下一个元素的引用。
1.5 遍历
①迭代器遍历
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
String string = iterator.next();
}
迭代器会保存一个光标,表示后续调用next将返回的元素的索引。在调用hasNext()方法时,判断光标是否等于集合长度:
public boolean hasNext() {
return cursor != size();
}
调用next()方法时,先检查创建迭代器之后,list有没有改动,没有改动则继承执行,将光标所在位置的元素返回,并将光标移动到下一位:
public E next() {
checkForComodification();
try {
int i = cursor;
E next = get(i);
lastRet = i;
cursor = i + 1;
return next;
} catch (IndexOutOfBoundsException e) {
checkForComodification();
throw new NoSuchElementException();
}
}
②for循环通过下标遍历
for (int i = 0; i < linkedList.size(); i++) {
String string = linkedList.get(i);
}
第一步,检查index是否为该集合的index
private boolean isElementIndex(int index) {
return index >= 0 && index < size;
}
通过检查之后,返回node(index).item;
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
如果index小于集合元素个数的一般,从第一个下标开始往后遍历,一直取到index下标对应的元素,返回该元素;否则,从最后一个元素开始往前遍历,一直取到index下标对应的位置,返回该元素。
每次根据index查找元素,都需要重复上面的步骤,从列表的头或者尾重新开始遍历。
③两种遍历方式对比
集合中的元素均为10个左右字节长度的任意小写英文字母组成;
100000个元素 | 10000个元素 | |||
测试轮数 | 迭代器遍历(ms) | for循环get(index)方法遍历 | 迭代器遍历(ms) | for循环get(index)方法遍历 |
1 | 3 | 25458 | 2 | 155 |
2 | 2 | 24644 | 1 | 145 |
3 | 2 | 24000 | 1 | 151 |
4 | 1 | 23698 | 0 | 158 |
5 | 1 | 24215 | 0 | 176 |
6 | 2 | 23813 | 0 | 161 |
7 | 4 | 28089 | 0 | 143 |
8 | 2 | 25631 | 1 | 153 |
9 | 3 | 26837 | 1 | 144 |
10 | 2 | 23521 | 0 | 147 |
Average | 2.2 | 24990.6 | 0.6 | 153.3 |
经过对比,集合元素个数为100000时,for循环通过下标遍历耗时是迭代器遍历耗时的11359倍;集合元素个数为10000时,for循环通过下标遍历耗时是迭代器遍历耗时的255倍;集合元素越多,耗时差距越大。
分析:通过下标遍历,每次都需要从头或者从尾遍历,而通过迭代器遍历,迭代器会保存下一个元素的索引,可以直接取到下一个元素。因此,迭代器遍历更高效。
2. ArrayList
基于数组实现,线程不安全。可以动态地增长和缩减的一个索引序列。
2.1 组成
ArrayList对象包含几个重要组成部分:
* DEFAULT_CAPACITY:默认的初始容量为10
* Object[] EMPTY_ELEMENTDATA:为一个空的实例提供共的数组实例
* Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA:为空实例提供默认大小的数组实例。与EMPTY_ELEMENTDATA区分开,以便知道在添加第一个元素时需要膨胀多少
* Object[] elementData:用于存储数组元素的数组缓冲区,数组的容量就是数组缓存区的长度。任何一个带有默认容量的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA 在第一次被添加元素时(add),容量被扩容到默认容量
* size:数组包含的元素的个数
2.2 创建ArrayList对象
// 指定容量创建ArrayList
// initialCapacity>0时,this.elementData=new Object[initialCapacity]
// initialCapacity==0时,this.elementData=EMPTY_ELEMENTDATA,创建一个空集合
ArrayList<String> arrayList = new ArrayList(initialCapacity);
// this.elementData=DEFAULTCAPACITY_EMPTY_ELEMENTDATA
// (构建一个初始容量为10的空list)
ArrayList<String> arrayList = new ArrayList();
2.3 扩容
每次扩容为原容量的1.5倍
private void grow(int minCapacity){
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = Integer.MAX_VALUE;
elementData = Arrays.copyOf(elementData, newCapacity);
}
2.4 添加元素
// 将元素添加到list的末尾
arrayList.add("aaa");
// 将元素添加到指定位置,指定位置往后的数据,依次往后移动一位
arrayList.add(1, "bbb");
2.5 删除元素
// 将指定下标的元素删除,下标位置往后的数据,依次往前移动一位
arrayList.remove(2);
// 将指定元素删除(从下标为0开始遍历,直到找到该元素,将其删除),该元素对应下标往后的数据,依次往前移动一位
arrayList.remove("aaa");
2.6 遍历
①迭代器遍历
Iterator<String> iterator = arrayList.iterator();
while (iterator.hasNext()) {
String str = iterator.next();
}
获取一个迭代器,Itr(),实现了Iterator<E>接口;有 cursor lastRet expectedModCount 三个属性
a. hasNext()方法, return cursor != size;
b. next()方法,检查 modCount != expectedModCount 时报错;检查通过,返回光标所在位置的元素,并将光标移动到下一个位置;
ListIterator<String> listIterator = arrayList.listIterator();
while (listIterator.hasPrevious()) {
String string = listIterator.previous();
}
获取一个迭代器,ListItr,继承了Itr(),实现了ListIterator<E>接口--AbstractList的优化版本,可以双向遍历
a. hasPrevious(), return cursor != 0;
b. previous(), 检查 modCount != expectedModCount 时报错;检查通过,返回光标所在位置前一个位置的元素,并将光标移动到前一个位置;
ListIterator<E>接口继承自Iterator<E>接口,所以Iterator<E>有的方法:hasNext()、next()、remove()方法,ListIterator<E>都有
ListIterator<E>还有几个特有的方法:hasPrevious()、previous()、nextIndex()、previousIndex()、set(E e)、add(E e);除了向前遍历,其他的可以作为了解,用的不多
②随机访问index,通过索引值遍历
for (int i = 0; i < length; i++) {
String str = arrayList.get(i);
}
检查index是否超范围:index >= size --> throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
没超范围的话,返回elementData[index] 直接获取下标对应的元素
③增强for循环
for (String string : arrayList) {
String str = string;
}
理论上,遍历ArrayList,使用随机访问 即通过索引值访问,效率最高;使用迭代器效率最低。实际测试,差距不大。10000000个元素,耗时都在40毫秒多一点。
测试轮数 | 迭代器 | 随机访问 | 增强for循环 |
1 | 53 | 43 | 45 |
2 | 62 | 47 | 41 |
3 | 41 | 39 | 40 |
4 | 39 | 43 | 39 |
5 | 38 | 39 | 39 |
6 | 39 | 39 | 40 |
7 | 39 | 39 | 39 |
8 | 38 | 40 | 42 |
9 | 44 | 43 | 40 |
10 | 40 | 40 | 41 |
Average | 43.3 | 41.2 | 40.6 |
3. Vector
和ArrayList类似,区别在于,Vector是线程安全,Vector所有的方法都是同步的,可以安全地从两个线程访问同一个Vector对象;但是只从一个线程访问Vector时,代码就会在同步操作上白白浪费大量的时间;而ArrayList方法不是同步的,因此,在不需要同步时使用ArrayList。
二、Set
Set继承自collection集合,排列无序且不可重复,没有索引,不能根据索引检索。
1. HashSet
1.1 原理
底层是HashMap,默认容量是 1 << 4(2的4次幂,16),最大容量为1<<30(2的30次幂),默认加载因子为0.75
散列集迭代器将依次访问所有的桶。散列集将元素分散在表中,以随机的顺序访问元素,元素无序存放。
存放元素时,根据hashCode计算出桶值决定元素的存储位置,桶中没有元素时才存放,元素不可重复。
声明HashSet,默认容量为16,加载因子是0.75,容量使用达到75%时扩容,容量为原先容量的2倍;(都是2的幂值,扩容时,容量变为2的下一个幂值);容量和加载因子可以在声明HashSet的时候指定,如果可以预先判断需要多少容量,可以指定容量,避免后期扩容带来的性能损耗。
1.2 创建
// 构造一个空的散列集
HashSet<String> hashSet1 = new HashSet<>();
// 构造一个空的具有指定容量的散列集
HashSet<String> hashSet2 = new HashSet<>(16);
// 构造一个空的具有指定容量和指定加载因子的散列集
HashSet<String> hashSet3 = new HashSet<>(16, 0.8f);
1.3 操作
iterator()、size()、isEmpty()、contains(Object o)、add(E e)、remove(Object o)、clear()等常用操作,底层都是调用map的对应操作。
比如:
add(element):添加指定元素到集合中,底层调用map.put(element, PRESENT),集合中不存在该元素时,添加到集合中,该元素作为map的key、value为指定元素PRESENT = new Object(),并返回true;如果该集合中包含该元素,则不改变集合的元素,并返回false
remove(element):从list中删除指定元素,底层调用map.remove(element),element作为key,从map中移除
1.4 遍历
①迭代器遍历
Iterator<String> iterator = hashSet.iterator();
while (iterator.hasNext()) {
String string = iterator.next();
}
迭代器会保存当前的索引、保存当前节点和下一个节点的信息,每次取下一个元素,需要从下一个节点去数据(包含索引的一些判断,比如是否大于集合的长度)。
②增强for循环
for (String string : hashSet) {
String str = string;
}
两种方式对比:
测试轮数 | 迭代器 | 增强for循环 |
1 | 38 | 35 |
2 | 43 | 32 |
3 | 39 | 43 |
4 | 36 | 40 |
5 | 43 | 49 |
6 | 51 | 44 |
7 | 37 | 35 |
8 | 33 | 33 |
9 | 32 | 41 |
10 | 40 | 35 |
Average | 39.2 | 38.7 |
通过数据观察,两种方式遍历,差距不是很大。
2. LinkedHashSet
2.1 底层原理
继承自HashSet,实现Set,底层是LinkedHashMap;同样根据元素的hashCode计算出元素的存储位置,但同时使用链表维护元素的次序,遍历和插入顺序一致;
和HashSet相比,LinkedHashSet需要维护元素的插入顺序,因此性能略低于HashSet,但是在迭代遍历Set里的全部元素时性能会更好,因为它以链表维护内部结构,直接取下一个节点数据,不需要判断;HashSet迭代器遍历时,需要先根据索引判断是否大于集合长度和下一个索引对应的元素是否为空,会更耗时。
2.2 创建
// 构造一个空的散列集
LinkedHashSet<String> hashSet1 = new LinkedHashSet<>();
// 构造一个空的具有指定容量的散列集
LinkedHashSet<String> hashSet2 = new LinkedHashSet<>(16);
// 构造一个空的具有指定容量和指定加载因子的散列集
LinkedHashSet<String> hashSet3 = new LinkedHashSet<>(16, 0.8f);
2.3 操作
类似于HashSet,比如put,只不过每个数据节点会保存上一个节点和下一个节点的引用。
2.4 遍历
①迭代器遍历
Iterator<String> iterator = linkedHashSet.iterator();
while (iterator.hasNext()) {
String string = iterator.next();
}
底层调用LinkedHashMap的LinkedKeySet的iterator()方法,返回的是一个 new LinkedKeyIterator(); LinkedKeyIterator继承自LinkedHashIterator,每个节点会保存上一个节点和下一个节点的引用,遍历的时候直接取。
②增强for循环
for (String string : linkedHashSet) {
String value = string;
}
两种方式对比:
测试轮数 | 迭代器 | 增强for循环 |
1 | 18 | 18 |
2 | 22 | 19 |
3 | 16 | 15 |
4 | 16 | 16 |
5 | 16 | 15 |
6 | 16 | 17 |
7 | 18 | 17 |
8 | 17 | 22 |
9 | 43 | 19 |
10 | 17 | 18 |
Average | 19.9 | 17.6 |
通过数据观察,两种方式遍历,差距不是很大。
3. TreeSet
3.1 原理
底层是TreeMap,TreeMap实现了NavigableMap接口;元素有序,可以以任意顺序将元素存进去,遍历时,元素将按照排序后的顺序被遍历。每次将一个元素添加进去时,都会将其放置在正确的排序位置上。
3.2 创建
// 创建一个空的TreeSet集合,按照key自然顺序排序
TreeSet<String> treeSet = new TreeSet<>();
3.3 操作
add(element):向集合中添加一个元素,底层调用TreeMap的put(key, value)方法,key为添加到集合中的element,value是一个固定值(new Object);
Iterator.remove、Set.remove、removeAll、retainAll、clear,底层是删除对应的map中的映射。
3.4 遍历
①迭代器
Iterator<String> iterator = treeSet.iterator();
while (iterator.hasNext()) {
String string = iterator.next();
}
②增强for循环
for (String string : treeSet) {
String value = string;
}
两种方式对比:
测试轮数 | 迭代器 | 增强for循环 |
1 | 54 | 55 |
2 | 50 | 54 |
3 | 61 | 56 |
4 | 49 | 47 |
5 | 44 | 44 |
6 | 43 | 44 |
7 | 48 | 53 |
8 | 49 | 47 |
9 | 44 | 42 |
10 | 44 | 46 |
Average | 48.6 | 48.8 |
通过数据观察,两种方式遍历,差距不是很大。
*. HashSet 和 TreeSet插入数据效率对比
测试轮数 | HashSet | TreeSet |
1 | 309 | 694 |
2 | 304 | 381 |
3 | 441 | 419 |
4 | 215 | 371 |
5 | 211 | 371 |
6 | 211 | 386 |
7 | 215 | 340 |
8 | 239 | 337 |
9 | 213 | 355 |
10 | 211 | 342 |
Average | 256.9 | 399.6 |
经过对比,Treeset耗时更久;对于某些不需要排序的数据来说,不需要使用TreeSet来保证元素的顺序,不必付出排序的开销;散列函数只需要将对象适当地打乱存放,而比较函数必须精确地区分各个对象。