【本篇是集合中的List篇,以下涉及源码基于JDK1.8】
本篇是Java基础中最重要的知识点集合List篇。Java集合是java提供的工具包,包含了常用的数据结构:集合、链表、队列、栈、数组、映射等。Java集合工具包位置是java.util.*,Java集合主要可以划分为4个部分:List列表、Set集合、Map映射、工具类(Iterator迭代器、Enumeration枚举类、Arrays和Collections)。
Colletcion和Map结构如下图所示:
大致介绍一下:Collection和Map是两个高度抽象的接口;
- Collection抽象的是集合,包含了集合的基本操作和属性,Collection主要包含List和Set两大分支。
- List是有序的链表,允许存储重复的元素,List的主要实现类有LinkedList, ArrayList, Vector, Stack。
- Set是不允许存在重复元素的集合,Set的主要实现类有HastSet和TreeSet(依赖哈希实现,后面介绍)。
- Map是一个映射接口,即存储Key-Value键值对的集合(和redis存储类似),AbstractMap是个抽象类,它实现了Map接口中的大部分API,而常见的HashMap,TreeMap都是继承于AbstractMap。HashTable继承于Dictionary,但也实现了Map接口。
集合是Java中用来存储多个对象的一个容器,我们知道容器数组,数组长度不可变,且只能存储同样类型的元素,数组可以存储基本类型或者引用类型;而集合长度可变,可以存储不同类型元素(但是我们一般不这么干),集合只能存储引用类型(存储的基本类型会变成包装类);
集合的Fail-Fast机制?
fail-fast 机制是java集合(Collection)中的一种错误机制。当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。
例如:当某一个线程A通过 iterator 去遍历某集合的过程中,若该集合的内容被其他线程所改变了;那么线程A访问集合时,就会抛出ConcurrentModificationException 异常,产生 fail-fast 事件;当然,不仅是多个线程,单个线程也会出现 fail-fast 机制,比如单线程下的iterator迭代器遍历时调用集合的增删改等操作会抛出java.util.ConcurrentModificationException,从而产生fail-fast机制。
List接口
List定义如下:
public interface List<E> extends Collection<E> { }
List是一个继承于Collection的接口,即List是集合中的一种。List集合的特点就是:可存储重复元素,有序(存储顺序和取出顺序一致),如下图所示;
List集合常用子类:
- ArrayList:底层数据结构是数组,线程不安全
- LinkedList:底层数据结构是链表,线程不安全
- Vector:底层数据结构是数组,线程安全
- Stack:底层数据结构是数组,继承于Vactor
ArrayList
ArrayList定义:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable { }
ArrayList简介:
- ArrayList是实现List接口的可变数组,并允许null在内的重复元素;
- 底层数组实现,扩容时将老数组元素拷贝到新数组中,每次扩容是其容量的1.5倍,操作代价高;
- 采用了Fail-Fast机制,面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险;
- ArrayList是线程不安全的,所以在单线程中才使用ArrayList,而在多线程中可以选择Vector或者CopyOnWriteArrayList。
ArrayList核心函数:
- 构造函数
- add方法(重点)
先来分析第一个add(E e):
进去ensureCapacityInternal()方法看:
执行完第3步,如果需要扩容,则进入grow()方法:
再来分析第二个add(int index, E element) :
总结:在add函数中调用函数ensureCapacityInternal,此函数为确保elementData数组有合适的大小,如果需要容量小于10,则数组容量为10;如果需要扩容,则正常每次以1.5倍旧容量扩容,第一次扩容后,如果容量还是小于minCapacity,就将容量扩充为minCapacity。特殊情况下(新扩展数组大小已经达到了最大值)则只取最大值(一般用不到那么大)。
- set方法:
- get方法:
- indexOf方法:
- remove方法:
重点关注问题:
ArrayList默认大小(为什么是这个?),扩容机制?
ArrayList的默认初始化大小是10(在新建的时候还是空,只有当放入第一个元素的时候才会变成10),若知道ArrayList的大致容量,可以在初始化的时候指定大小,可以在适当程度减少扩容的性能消耗(看下一个问题解析)
至于为何是10,据说是因为sun的程序员对一系列广泛使用的程序代码进行了调研,结果就是10这个长度的数组是最常用的最有效率的。也有说就是随便起的一个数字,8个12个都没什么区别,只是因为10这个数组比较的圆满而已。
ArrayList的扩容机制是:当添加元素的时候数组是空的,则直接给一个10长度的数组。当需要长度的数组大于现在长度的数组的时候,通过新=旧+旧>>1(即新=1.5倍的旧)来扩容,当扩容的大小还是不够需要的长度的时候,则将数组大小直接置为需要的长度(这一点切记!)。
ArrayList特点访问速度块,为什么?插入删除一定慢吗?适合做队列吗?
ArrayList从结构上来看属于数组,也就是内存中的一块连续空间,当我们get(index)时,可以直接根据数组的首地址和偏移量计算出我们想要元素的位置,我们可以直接访问该地址的元素,所以查询速度是O(1)级别的。
我们平时会说ArrayList插入删除这种操作慢,查询速度快,其实也不是绝对的。当数组很大时,插入删除的位置决定速度的快慢,假设数组当前大小是一千万,我们在数组的index为0的位置插入或者删除一个元素,需要移动后面所有的元素,消耗是很大的。但是如果在数组末端index操作,这样只会移动少量元素,速度还是挺快的(插入时如果在加上数组扩容,会更消耗内存)。
个人觉得不太适合做队列,基于上面的分析,队列会涉及到大量的增加和删除(也就是移位操作),在ArrayList中效率还是不高。
ArrayList 底层实现就是数组,访问速度本身就很快,为何还要实现 RandomAccess ?
RandomAccess是一个空的接口, 空接口一般只是作为一个标识, 如Serializable接口.
JDK文档说明RandomAccess是一个标记接口(Marker interface), 被用于List接口的实现类, 表明这个实现类支持快速随机访问功能(如ArrayList). 当程序在遍历这中List的实现类时, 可以根据这个标识来选择更高效的遍历方式。
ArrayList是线程不安全的,为什么?怎么办?
ArrayList线程不安全的表现是在多个线程进行add操作时可能会导致elementData数组越界。发生在什么情况呢?
我们可以想象,一个线程A调用add()方法,获取到size大小是9,调用ensureCapacityInternal方法进行容量判断,此时线程B也进入add()方法,也一样获取到9并进行容量判断。线程A发现不需要扩容返回,此时线程B也返回不需要容纳返回。线程A如果先进行插入元素之后,线程B如果再执行 elementData[size++] = e便会出现数组越界的现象。
解决线程不安全的方法:
- 使用synchronized关键字,锁起来,效率较低。(也可以自己实现ArrayList的子类,并进行同步操作)
- 使用Vector,内部函数基本都是通过synchronized关键字实现,所以是线程安全的。这种方式严重影响效率,所以并不推荐使用Vector。
- 使用Collections.synchronizedList,即Collections.synchronizedList(new ArrayList());下图是部分截图,可以看出很多函数也是通过加synchronized关键字实现的,所以效率也不理想。
而且使用Collections.synchronizedList还存在一些小问题,就是使用迭代器的时候需要手动同步,因为在整个迭代的过程中如果在循环外面不加同步代码,在一次次迭代之间,其他线程对于这个容器的add或者remove会影响整个迭代的预期效果,所以需要用户在整个循环外面加上synchronized(list);
- 使用CopyOnWriteArrayList(属于Java的并发包下面的工具),运用的是一种“写时复制”的思想。
通俗的理解就是当我们需要修改(增/删/改)列表中的元素时,不直接进行修改,而是先将列表Copy,然后在新的副本上进行修改,修改完成之后,在将引用从原列表指向新列表。
这样做的好处是读/写是不会冲突的,可以并发进行,读操作还是在原列表,写操作在新列表。仅仅当有多个线程同时进行写操作时,才会进行同步。
如上图的add操作,使用了 ReentrantLock 独占锁,保证同时只有一个线程对集合进行修改操作(支持重入)。
但是CopyOnWriteArrayList也是存在占用内存和数据一致性的问题的:占用内存指的是在进行写操作复制的时候,内存里会同时有两个对象(旧的和新的),如果数据太大可能造成频繁的GC的发生。支持读写分离,所以只能保证数据的最终一致性,不能保证实时一致性。
看上图的get操作,获取元素并没有加锁,这样做的好处是,在高并发情况下,读取元素时就不用加锁,写数据时才加锁,大大提升了读取性能。
CopyOnWriteArrayList 在使用迭代器时底层是一种安全失败机制,不过迭代器获取的数据取决于迭代器创建的时候,而不是迭代器迭代的时候,接下来我们来看看为何。
ArrayList中的elementData为何要加transient关键字修饰,为什么是 Object 而不是泛型 E?
先解释下为什么是Object吧,Java 中泛型运用的目的就是实现对象的重用,泛型T和Object类其实在编写时没有太大区别,只是JVM中没有T这个概念,T只是存在于编写时,进入虚拟机运行时,虚拟机会对泛型标志进行擦除,也就是替换T会限定类型替换(根据运行时类型),如果没有限定就会用Object替换。
同时Object可以new Object(),就是说可以实例化,而T则不能实例化。在反射方面来说,从运行时,返回一个T的实例时,不需要经过强制转换,然后Object则需要经过转换才能得到。
再来看下为何要加transient关键字修饰,transient关键字的作用来表示一个域不是对象序列化的一部分,当一个对象被序行化的时候,transient修饰的变量的值是不包括在序行化的表示中的。
但是ArrayList本身是可以序列化的,elementData是存放ArrayList中具体元素的成员数据,那是不是意味着反序列化之后ArrayList丢失了元素?玄机在writeObject和readObject两个方法中,我们一起看看:
ArrayList在序列化的时候会调用writeObject,直接将size和element写入ObjectOutputStream;反序列化时调用readObject,从ObjectInputStream获取size和element,再恢复到elementData。
为什么不直接用elementData来序列化,而采用上诉的方式来实现序列化呢?
原因在于elementData是一个缓存数组,它通常会预留一些容量,等容量不足时再扩充容量,那么有些空间可能就没有实际存储元素,采用上诉的方式来实现序列化时,就可以保证只序列化实际存储的那些元素,而不是整个数组,从而节省空间和时间。
ArrayList的遍历?迭代时不允许修改?
- for循环遍历:
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
for (int i = 0; i < list.size(); i++) {
if ("1".equals(list.get(i))){
list.add("4");
list.add("5");
list.remove("1");
}
System.out.println(list.get(i));
}
结果:不报错,打印2 3 4 5;
- 使用 foreach 遍历:
List<String> list2 = new ArrayList<>();
list2.add("1");
list2.add("2");
list2.add("3");
for (String s : list2){
if ("1".equals(s)){
list2.add("4");
list2.remove("1");
}
System.out.println(s);
}
结果:抛出 java.util.ConcurrentModificationException异常;
- 使用 Iterator 迭代器:
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
if ("1".equals(iterator.next())) {
//iterator.remove();
list.add("4");
list.remove("1");
}
}
结果:抛出 java.util.ConcurrentModificationException异常;
解释下:逻辑上讲,迭代时可以添加元素,但是一旦开放这个功能,很有可能造成很多意想不到的情况。
比如你在迭代一个 ArrayList,迭代器的工作方式是依次返回给你第0个元素,第1个元素,等等,假设当你迭代到第5个元素的时候,你突然在ArrayList的头部插入了一个元素,使得你所有的元素都往后移动,于是你当前访问的第5个元素就会被重复访问。
java 认为在迭代过程中,容器应当保持不变。因此,java 容器中通常保留了一个域称为 modCount,每次你对容器修改,这个值就会加1。当你调用 iterator 方法时,返回的迭代器会记住当前的 modCount,随后迭代过程中会检查这个值,一旦发现这个值发生变化,就说明你对容器做了修改,就会抛异常。接下来我们通过Iterator迭代器简单分析下:
先看AbstractList中的iterator():
看Itr对象中的next()的实现:
checkForComodification()方法的实现如下:
modCount表示集合的元素被修改的次数,每次增加或删除一个元素的时候,modCount都会加一,而expectedModCount用于记录在集合遍历之前的modCount,检查这两者是否相等就是为了检查集合在迭代遍历的过程中有没有被修改
如果被修改了,就会在运行时抛出ConcurrentModificationException这个RuntimeException,以提醒开发者集合已经被修改,这就说明了为什么在集合在使用Iterator进行遍历的时候不能使用集合的本身的add或者remove方法来增减元素。但是使用Iterator的remove方法是可以的,感兴趣的可以继续去研究源码;
为什么arraylist的最大数组大小设置成Integer.MAX_VALUE - 8
官方解释是:数组作为一个对象,需要一定的内存存储对象头信息,对象头信息最大占用内存不可超过8字节。数组的对象头信息相较于其他Object,多了一个表示数组长度的信息。
LinkedList
LinkedList定义:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable { }
LinkedList简介:
-
LinkedList是List接口的双向链表的实现,并允许包括null在内的所有元素。同时也实现了Deque接口,Deque是一个双向队列,那么也意味着LinkedList也是双端队列的一种实现,我们可以操作LinkedList像操作队列和栈一样。
-
双向链表是以Node节点为基础的实例,Node中包含成员变量:prev,next,item。其中,prev是该节点的上一个节点,next是该节点的下一个节点,item是该节点所包含的值,所以插入速度快(时间复杂度为O(1),但是涉及到先确定位置再插入的时间复杂度也会变为O(n)),只需要移动前后节点指针即可。
-
LinkedList是有序的并且可以包含重复元素,非线程安全的。
-
LinkedList的链式存储结构比数组的连续存储内存利用率更高,查询速度相对较慢,因为每次查询都需要从头节点或者尾节点遍历next指针。
LinkedList源码:
- 构造函数
/**
* Constructs an empty list.
*/
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
- 内部类Node就是实际的结点,用于存放实际元素的地方
- LinkedList的属性
- add函数:
接着我们看linkLast()函数:
下面看一个示例示例说明和其示意图:
List<Integer> lists = new LinkedList<Integer>();
lists.add(5);
lists.add(6);
说明:如上图所示,当执行完了添加元素5和元素后之后的状态是,5的pre指针是null(同时代表first),然后next指针指向6(同时代表last),然后尾节点的next指针也为null;执行添加元素的过程时,先将尾节点保存为final类型,再创建新节点,pre指针指向尾节点,再将尾节点last指针指向该新节点,重新赋值尾节点。
- addAll()函数-添加一个集合:如图所示,addAll函数有两个重载函数,不过底层都会转化为addAll(int, Collection<? extends E>)这个,所以我们来分析这个函数;
补充:其中第2步涉及到一个node()函数,根据索引定位找到元素并返回。其中会根据index < (size >> 1)先判断index属于LinkedList的前半段还是后半段,因为LinkedList是双向链表,结点在前半段则从头开始遍历,在后半段则从尾开始遍历,这样便于更快的索引找到元素。 - get()函数:利用上面的node函数定位,不再细说;
总结:LinkedList底层的数据结构是基于双向循环链表的,且头结点中不存放数据。在LinkedList中涉及到的增删改操作都是通过操作节点Node的指针,所以比较方便插入删除等操作,效率更高;在ArrayList的操作中需要将很多数组元素进行位置的移动,所以相较而言效率更低些;
重点关注问题:
在addAll()中,传入一个集合为何要先转变成一个数组再遍历数组,添加数组的元素,而不是直接遍历集合?
如上截图是官方解释,toArray的目的是保证传进来的这个集合不会被任何地方引用,也保证这个集合不会有任何机会被修改(即在addAll过程中Collection的内容发生变化),保证了数据的安全性。
分析一下LinkedList的遍历方式?
- 迭代器和链表迭代器
Iterator iterator=list.iterator();
//ListIterator<Student> iterator=list.listIterator();
while(iterator.hasNext()){
System.out.println(iterator.next());
}
- 快速随机(会存在严重效率问题,看后面分析,强烈不建议使用!!)
for(int i=0;i<list.size();i++){
System.out.println(list.get(i));
}
- 使用增强for遍历
for(String str:list){
System.out.println(str);
}
- list的函数pollFirst,pollLast,removeFirst,removeLast(但用它们遍历时,会删除原始数据):
while(list.pollFirst() != null)
//while(list.pollLast() != null)
//while(list.removeFirst() != null)
//while(list.removeLast() != null)
{ }
分析遍历:list的函数removeFist或removeLast遍历方式效率很高,但是遍历之后会删除原始数据。而list的快速随机(用size进行遍历循环)的遍历方式存在很多的问题,为什么呢?
接下来我们来分析下:
如果测试数据较少的时候可能会发现没什么问题,但是随着数据量的加大我们会发现数据有明显的卡顿现象,我们在前面分析了get()方法以及其内部的node()方法(用来定位该位置的元素),会判断当前元素是在链表前半段或者后半段然后决定从哪边遍历,然后取数据的时候无论目标元素在哪里都会从头部节点或者尾部节点遍历到目标节点再去除数据。
试想一下,如果链表中存在10个元素需要遍历,那么每次元素的查询次数为 (1,2,3,4,5,5,4,3,2,1)总次数为30,随着n趋向于无穷大时时间复杂度趋向于O(n^2)。当目标数量n越大时,时间复杂度的增长也就越快,从而导致了程序假死。
所以,强烈建议不要使用size循环遍历数据,使用遍历器或者foreach的效率都还不错。
LinkedList如何提供通过位置获取数据的功能的,它的查询效率真的非常低吗?
LinkedList实际上是通过双向链表去实现的。既然是双向链表,那么它的顺序访问会非常高效,而随机访问效率比较低。
LinkedList最大的好处在于头尾和已知节点的插入和删除时间复杂度都是o(1)。
但是涉及到先确定位置再操作的情况,则时间复杂度会变为o(n),因为对于LinkedList来说确定位置是需要从头节点或者尾节点循环。当然,每个节点都需要保留prev和next指针也是经常被吐槽是浪费了空间。
它是如何用作栈、队列或双端队列的?
如上图所示,实现了Deque接口,LinkedList可用作队列或双端队列就是因为实现了它。
LinkedList不是线程安全的,怎么办?
为何线程不安全?线程安全问题是由多个线程同时写或同时读写同一个资源造成的,不多解释,看下图,参考ArrayList;
解决方法:
-
List list = Collections.synchronizedList(new LinkedList());见上面在ArrayList中的分析,也会出现: 由iterator()和listIterator()返回的迭代器是fail-fast的,需要手动同步;
-
将LinkedList全部换成ConcurrentLinkedQueue或者LinkedBlockingQueue这种支持添加元素为原子操作的队列;LinkedBlockingQueue使用的是锁机制,ConcurrentLinkedQueue使用的是CAS算法,不过LBQ的底层获取锁也是使用的CAS算法。LinkedBlockingQueue的put等方法,是使用ReentrantLock来实现的添加元素原子操作。我们看一下add和offer()方法,方法中使用了CAS来实现的无锁的原子操作:
关于插入元素的性能,ConcurrentLinkedQueue肯定是最快的,在实际的使用过程中,尤其在多cpu的服务器上,有锁和无锁的差距便体现出来了,ConcurrentLinkedQueue会比LinkedBlockingQueue快很多。
分析ArrayList和LinkedList两者各适用于哪些场合
- 如果应用程序对数据有较多的随机访问,ArrayList对象要优于LinkedList对象;
- 如果应用程序有更多的插入或者删除操作,较少的数据读取,LinkedList对象要优于ArrayList对象;
- ArrayList的插入,删除操作也不一定比LinkedList慢,如果在List靠近末尾的地方插入,那么ArrayList只需要移动较少的数据,而LinkedList则需要一直查找到列表尾部,反而耗费较多时间,这时ArrayList就比LinkedList要快。
总结:其实使用场景还是要具体分析,根据ArrayList和LinkedList的特点来进行分析需要应用哪个。
Vector
Vector定义:
public class Vector<E>
extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{ }
Vector简介:
- Vector 继承了AbstractList,实现了List;所以,它是一个队列,支持相关的添加、删除、修改、遍历等功能。
- Vector是线程安全的,也可以看作是线程安全的ArrayList,因为其内部很多函数是加了Synchronized关键字的ArrayList函数,不过也因为这个导致Vector效率稍低些;
- 在Vector中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
Vector核心函数:
Vector内部很多源码和ArrayList的类似,不再赘述,感兴趣的可以自己去研究;
重点关注问题:
底层是数组,现在已少用,被ArrayList替代。
- Vector所有方法都是同步(synchronized),而这些从来都不是必须的,有性能损失。
- Vector初始length是10 超过length时 以100%比率增长,相比于ArrayList更多消耗内存。
Stack
Stack定义:
class Stack<E> extends Vector<E> {}
Stack简介:
- Stack是栈,继承于Vector。它的特性是:先进后出(FILO, First In Last Out)。
- 由于Vector是通过数组实现的,这就意味着,Stack也是通过数组实现的,而非链表。
- 类Stack是栈,但是实际这个类用的并不多,但是它实现的栈结构却是经常使用的,栈结构我们会在数据结构篇详细讲解。
唠叨
船长希望有一天能够靠写作养活自己,现在还在磨练,这个时间可能会持续很久,但是,请看我漂亮的坚持
感谢大家能够做我最初的读者和传播者,请大家相信,只要你给我一份爱,我终究会还你们一页情的。
船长会持续更新技术文章,和生活中的暴躁文章,欢迎大家关注【Java贼船】,成为船长的学习小伙伴,和船长一起乘千里风、破万里浪
哦,对了!后续的更新文章我都会及时放到Java成神之路,欢迎大家点击观看,都是干货文章啊,建议收藏,以后随时翻阅查看