《面试补习》- Java 集合知识梳理
一、ArrayList
ArrayList 底层数据结构为 动态数组 ,所以我们可以将之称为数组队列。ArrayList 的依赖关系:
从依赖关系可以看出,ArrayList 首先是一个列表,其次,他具有列表的相关功能,支持快速(固定时间)定位资源位置。可以进行拷贝操作,同时支持序列化。这里我们需要重点关注的是 AbstractLit 以及 RandomAccess 。这个类,一个是定义了列表的基本属性,以及确定我们列表中的常规动作。而 RandomAccess 主要是提供了快速定位资源位置的功能。
1.1、ArrayList 成员变量
在 ArrayList 的源码中,主要有上述的几个成员变量:
elementData : 动态数组,也就是我们存储数据的核心数组
DEFAULT_CAPACITY:数组默认长度,在调用默认构造器的时候会有介绍
size:记录有效数据长度,size()方法直接返回该值
MAX_ARRAY_SIZE:数组最大长度,如果扩容超过该值,则设置长度为 Integer.MAX_VALUE
拓展思考:EMPTY_ELEMENTDATA 和 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 都是两个空的数组对象,他们到底有什么区别呢?我们在下一节讲解构造方法的时候,会做详细对比。
1.2、构造方法
ArrayList 中提供了三种构造方法:
ArrayList()
ArrayList(int initialCapacity)
ArrayList(Collection<T> c)
根据构造器的不同,构造方法会有所区别。我们在平常开发中,可能会出现在默认构造器内部调用了 ArrayList(int capacity) 这种方式,但是 ArrayList 中对于不同的构造器的内部实现都有所区别,主要跟上述提到的成员变量有关。
1.2.1 ArrayList()
在源码给出的注释中这样描述:构造一个初始容量为十的空列表
从源码可以看到,它只是将 elementData 指向了 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 的存储地址,而 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 其实是一个空的数组对象,那么它为什么说创建一个默认大小为 10 的列表呢?
或者我们从别的角度思考一下,如果这个空的数组,需要添加元素,会怎么样?
上述代码块比较长,这里做个简单的总结:
1、add(E e):添加元素,首先会判断 elementData 数组的长度,然后设置值
2、ensureCapacityInternal(int minCapacity):判断 element 是否为空,如果是,则设置默认数组长度
3、ensureExplicitCapacity(int minCapacity):判断预期增长数组长度是否超过当前容量,如果过超过,则调用 grow()
4、grow(int minCapacity):对数组进行扩展
1.2.2 ArrayList(int initialCapacity)
根据指定大小初始化 ArrayList 中的数组大小,如果默认值大于 0,根据参数进行初始化,如果等于 0,指向 EMPTY_ELEMENTDATA 内存地址(与上述默认构造器用法相似)。如果小于 0,则抛出 IllegalArgumentException 异常。
拓展思考:为什么这里是用 EMPTY_ELEMENTDATA 而不是跟默认构造器一样使用 DEFAULTCAPACITY_EMPTY_ELEMENTDATA ?有兴趣的童鞋可以自己县思考,经过思考的知识,才是你的~
1.2.3 ArrayList(Collection<T> c)
将 Collection<T> c 中保存的数据,首先转换成数组形式(toArray()方法),然后判断当前数组长度是否为 0,为 0 则只想默认数组(EMPTY_ELEMENTDATA);否则进行数据拷贝。
1.2.4 总结
上述的三个构造方法可以看出,其实每个构造器内部做的事情都不一样,特别是默认构造器与 ArrayList(int initialCapacity) 这两个构造器直接的区别 ,我们是需要做一些区别的。
ArrayList():指向 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,当列表使用的时候,才会进行初始化,会通过判断是不是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 这个对象而设置数组默认大小。
ArrayList(int initialCapacity):当 initialCapacity >0 的时候,设置该长度。如果 initialCapacity =0,则指向 EMPTY_ELEMENTDATA 在使用的时候,并不会设置默认数组长度 。
因此 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 与 EMPTY_ELEMENTDATA 的本质区别就在于,会不会设置默认的数组长度。
1.3、添加方法(Add)
ArrayList 添加了四种添加方法:
add(E element)
add(int i , E element)
addAll(Collection<? extends E> c)
addAll(int index, Collection<? extends E> c)
1.3.1 add(E element)
首先看 add(T t)的源码:
结合默认构造器或其他构造器中,如果默认数组为空,则会在 ensureCapacityInternal()方法调用的时候进行数组初始化。这就是为什么默认构造器调用的时候,我们创建的是一个空数组,但是在注释里却介绍为 长度为 10 的数组。
1.3.2 add(int i , T t)
这个方法其实和上面的 add 类似,该方法可以按照元素的位置,指定位置插入元素,具体的执行逻辑如下:
1)确保数插入的位置小于等于当前数组长度,并且不小于 0,否则抛出异常
2)确保数组已使用长度(size)加 1 之后足够存下 下一个数据
3)修改次数(modCount)标识自增 1,如果当前数组已使用长度(size)加 1 后的大于当前的数组长度,则调用 grow 方法,增长数组
4)grow 方法会将当前数组的长度变为原来容量的 1.5 倍。
5)确保有足够的容量之后,使用 System.arraycopy 将需要插入的位置(index)后面的元素统统往后移动一位。
6)将新的数据内容存放到数组的指定位置(index)上
1.3.3 addAll(Collection<? extends E> c)
addAll() 方法,通过将 collection 中的数据转换成 Array[] 然后添加到 elementData 数组,从而完成整个集合数据的添加。在整体上没有什么特别之初,这里的 collection 可能会抛出控制异常 NullPointerException 需要注意一下。
1.3.4 addAll(int index,Collection<? extends E> c)
与上述方法相比,这里主要多了两个步骤,判断添加数据的位置是不是在末尾,如果在中间,则需要先将数据向后移动 collection 长度 的位置。
1.4、删除方法(Remove)
ArrayList 中提供了 五种删除数据的方式:
remove(int i)
remove(E element)
removeRange(int start,int end)
clear()
removeAll(Collection<T> c)
1.4.1、remove(int i):
删除数据并不会更改数组的长度,只会将数据重数组种移除,如果目标没有其他有效引用,则在 GC 时会进行回收。
1.4.2、remove(E element):
这种方式,会在内部进行 AccessRandom 方式遍历数组,当匹配到数据跟 Object 相等,则调用 fastRemove() 进行删除
fastRemove( ):fastRemove 操作与上述的根据下标进行删除其实是一致的。
1.4.3、removeRange(int fromIndex, int toIndex)
该方法主要删除了在范围内的数据,通过 System.arraycopy 对整部分的数据进行覆盖即可。
1.4.4、clear()
直接将整个数组设置为 null ,这里不做细述。
1.4.5、removeAll(Collection<T> c)
主要通过调用:
在 retainAll(Collection<T> c)也有调用,主要作用分别为,删除这个集合中所包含的元素和留下这个集合中所包含的元素。
拓展思考
清楚 ArrayList 的删除方法后,再结合我们常用的删除方式,进行思考,到底哪些步骤会出问题,我们通常会选择变量列表,如果匹配,则删除。我们遍历的方式有以下几种:
foreach():主要出现 ConcurrentModificationException 异常
for(int i;**;i++):主要出现相同数据跳过,可参考:https://blog.csdn.net/sun_flower77/article/details/78008491
Iterator 遍历:主要出现 ConcurrentModificationException 可参考:https://www.cnblogs.com/dolphin0520/p/3933551.html
避免 ConcurrentModificationException 的有效办法是使用 Concurrent 包下面的 CopyOnWriteArrayList ,后续会进行详细分析
1.5、toArray()
ArrayList 提供了 2 个 toArray()函数:
Object[] toArray()
<T> T[] toArray(T[] contents)
调用 toArray() 函数会抛出“java.lang.ClassCastException”异常,但是调用 toArray(T[] contents) 能正常返回 T[]。
toArray() 会抛出异常是因为 toArray() 返回的是 Object[] 数组,将 Object[] 转换为其它类型(如如,将 Object[]转换为的 Integer[])则会抛出“java.lang.ClassCastException”异常,因为 Java 不支持向下转型。
toArray() 源码:
1.6、subList()
如果我们在开发过程中有需要获取集合中的某一部分的数据进行操作,我们可以通过使用 SubList() 方法来进行获取,这里会创建 ArrayList 的一个内部类 SubList()。
SubList 继承了 AbstractList,并且实现了大部分的 AbstractList 动作。
需要注意的是,SubList 返回的集合中的某一部分数据,是会与原集合相关联。即当我们对 Sublist 进行操作的时候,其实还是会影响到原始集合。我们来看一下 Sublist 中的 add 方法:
可以看到,Sublist 中的 加操作,其实还是调用了 parent(也就是原集合) 中的加操作。所以在使用 subList 方法时,一定要想清楚,是否需要对子集合进行修改元素而不影响原有的 list 集合。
总结
ArrayList 总体来说比较简单,不过 ArrayList 还有以下一些特点:
ArrayList 自己实现了序列化和反序列化的方法,因为它自己实现了 private void writeObject(java.io.ObjectOutputStream s)和 private void readObject(java.io.ObjectInputStream s) 方法
ArrayList 基于数组方式实现,无容量的限制(会扩容)
添加元素时可能要扩容(所以最好预判一下),删除元素时不会减少容量(若希望减少容量,trimToSize()),删除元素时,将删除掉的位置元素置为 null,下次 gc 就会回收这些元素所占的内存空间。
线程不安全
add(int index, E element):添加元素到数组中指定位置的时候,需要将该位置及其后边所有的元素都整块向后复制一位
get(int index):获取指定位置上的元素时,可以通过索引直接获取(O(1))
remove(Object o)需要遍历数组
remove(int index)不需要遍历数组,只需判断 index 是否符合条件即可,效率比 remove(Object o)高
contains(E)需要遍历数组
使用 iterator 遍历可能会引发多线程异常
拓展思考
拓展思考 1、RandomAccess 接口是如何实现快速定位资源的?
拓展思考 2、EMPTY_ELEMENTDATA 与 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 的作用?
拓展思考 3、remove 方法存在的坑?
拓展思考 4:、ArrayList 为什么不是线程安全?
二、Vector
在介绍 Vector 的时候,人们常说:
那么这句话定义的到底对不对呢?我们接下来结合上一篇文章进行分析:
Vector 是一个矢量队列,它的依赖关系跟 ArrayList 是一致的,因此它具有一下功能:
1、Serializable:支持对象实现序列化,虽然成员变量没有使用 transient 关键字修饰,Vector 还是实现了 writeObject() 方法进行序列化。
2、Cloneable:重写了 clone()方法,通过 Arrays.copyOf() 拷贝数组。
3、RandomAccess:提供了随机访问功能,我们可以通过元素的序号快速获取元素对象。
4、AbstractList:继承了 AbstractList ,说明它是一个列表,拥有相应的增,删,查,改等功能。
5、List:留一个疑问,为什么继承了 AbstractList 还需要 实现 List 接口?
*拓展思考:为什么 Vector 的序列化,只重写了 writeObject()方法?
细心的朋友如果在查看 vector 的源码后,可以发现,writeObject() 的注释中有这样的说法:
看完注释,可能会有一种恍然大悟的感觉,Vector 的核心思想不就是 线程安全吗?那么序列化过程肯定也要加锁进行操作,才能过说其是线程安全啊。因此,即使没有 elementData 没有使用 transient 进行修饰,还是需要重写 writeObject()。
*拓展思考:与 ArrayLit,以及大部分集合类相同,为什么继承了 AbstractList 还需要 实现 List 接口?
有两种说法,大家可以参考一下:
1、在 StackOverFlow 中:传送门得票最高的答案的回答者说他问了当初写这段代码的 Josh Bloch,得知这就是一个写法错误。
2、Class 类的 getInterfaces 可以获取到实现的接口,却不能获取到父类实现接口,但是这种操作无意义。
2、Vector 成员变量
与 ArrayList 中的成员变量相比,Vector 少了两个空数组对象:EMPTY_ELEMENTDATA 和 DEFAULTCAPACITY_EMPTY_ELEMENTDATA
因此,Vector 与 ArrayList 中的第一个不同点就是,成员变量不一致。
2.1、Vector 构造函数
Vector 提供了四种构造函数:
Vector():默认构造函数
Vector(int initialCapacity):capacity 是 Vector 的默认容量大小。当由于增加数据导致容量增加时,每次容量会增加一倍。
Vector(int initialCapacity, int capacityIncrement):capacity 是 Vector 的默认容量大小,capacityIncrement 是每次 Vector 容量增加时的增量值。
Vector(Collection<? extends E> c):创建一个包含 collection 的 Vector
乍一眼看上去,Vector 中提供的构造函数,与 ArrayList 中的一样丰富。但是在上一节内容 中分析过 ArrayList 的构造函数后,再来看 Vector 的构造函数,会觉得有一种索然无味的感觉。
代码看上去没有太多的问题,跟我们平时写的代码一样,只是与 ArrayList 中的构造函数相比 缺少了一种韵味。有兴趣的同学可以去看一下 ArrayList 中的构造函数实现。
JDK 1.2 之后提出了将 Collection 转换成 Vector 的构造函数,实际操作就是通过 Arrays.copyOf() 拷贝一份 Collection 数组中的内容到 Vector 对象中。这里会有可能抛出 NullPointerException。
在构造函数上面的对比:Vector 的构造函数的设计上输于 ArrayList。
2.2、添加方法(Add)
Vector 在添加元素的方法上面,比 ArrayList 中多了一个方法。Vector 支持的 add 方法有:
add(E)
addElement(E)
add(int i , E element)
addAll(Collection<? extends E> c)
addAll(int index, Collection<? extends E> c)
2.2.1 addElement(E)
我们看一下这个多出来的 addElement(E) 方法 有什么特殊之处:
从注释上面来看,这个方法就是 跟 add(E) 方法是有着一样的功能的。因此除了返回数据不同外,也没什么特殊之处了。
我们顺着上述代码来进行分析 Vector 中的添加方法。可以看到 Vector 对整个 add 方法都上锁了(添加了 synchronized 修饰),其实我们可以理解,在添加元素的过程主要包括以下几个操作:
ensureCapacityHelper():确认容器大小
grow():如果有需要,进行容器扩展
elementData[elementCount++] = obj:设值
为了避免多线程情况下,在 ensureCapacityHelper 容量不需要拓展的情况下,其他线程刚好将数组填满了,这时候就会出现 ArrayIndexOutOfBoundsException ,因此对整个方法上锁,就可以避免这种情况出现。
与 ArrayList 中对比,确认容器大小这一步骤中,少了 ArrayList#ensureCapacityInternal 这一步骤,主要也是源于 Vector 在构造时,以及创建好默认数组大小,不会出现数组为空的情况。
其次 grow() 方法中:
Vector 中支持自定义的增长系数,也是它在 add() 方法中为数不多的亮点了。
2.2.2 add(int index, E element)
这部分代码跟 ArrayList 中没有太多的差异,主要是抛出的异常有所不同,ArrayList 中抛出的是 IndexOutOfBoundsException。这里则是抛出 ArrayIndexOutOfBoundsException。至于为什么需要将操作抽取到 insertElementAt() 这个方法中呢?童鞋们可以进行相关思考。
在添加方法上面,Vector 与 ArrayList 大同小异。Vector 多了一个奇怪的 addElement(E)。
2.3、删除方法(Remove)
Vecotr 中提供了比较多的删除方法,但是只要查看一下源码,就可以发现其实大部分都是相同的方法。
remove(int location)
remove(Object object)
removeAll(Collection<?> collection)
removeAllElements()
removeElement(Object object)
removeElementAt(int location)
removeRange(int fromIndex, int toIndex)
clear()
2.3.1、remove(int location) & removeElementAt(int location)
对比一下 remove(int location) 和 removeElementAt(int location)
除了返回的数据类型不同,其他内部操作其实是一致的。remove 是重写了父类的操作,而 removeElement 则是 Vector 中自定义的方法。ArrayList 中提供了 fastRemove() 方法,与其有着同样的效果,不过 removeElement 作用范围为 public。
2.3.2、remove(Object object) & removeElement(Object object)
remove(Object object) 实际内部调用的就是 removeElement(Object object) 。删除操作首先找到 对象的索引(与 ArrayList 中的 remmove(E)一样),然后调用 removeElementAt(i)(ArrayList 中调用 fastRemove()方法)进行删除。
其余删除操作与 ArrayList 类似,这里不做详细解析。总体来说,在删除方法这一块的话,Vector 与 ArrayList 也是大同小异。
2.4、线程安全 Vector?
拓展思考,我们常说 Vector 是线程安全的数组列表,那么它到底是不是无时无刻都是线程安全的呢?在 StackOverFlow 中有这样一个问题:
Is there any danger, if im using one Vector(java.util.Vector) on my server program when im accessing it from multiple threads only for reading? (myvector .size() .get() ...) For writing im using synchronized methods. Thank you.
其中有一个答案解析的比较详细的:
Vector 中的每一个独立方法都是线程安全的,因为它有着 synchronized 进行修饰。但是如果遇到一些比较复杂的操作,并且多个线程需要依靠 vector 进行相关的判断,那么这种时候就不是线程安全的了。
如上述代码所示,Vector 判断完 size()>0 之后,另一线程如果同时清空 vector 对象,那么这时候就会出现异常。因此,在复合操作的情况下,Vector 并不是线程安全的。
总结
本篇文章标题是:百密一疏之 Vector,原因在于,如果我们没有详细去了解过 Vector,或者在面试中,常常会认为 Vector 是线程安全的。但是实际上 Vector 只是在每一个单一方法操作上是线程安全的。
总结一下与 ArrayList 之间的差异:
1、构造函数,ArrayList 比 Vector 稍有深度,Vector 默认数组长度为 10,创建是设置。
2、扩容方法 grow(),ArrayList 通过位运算进行扩容,而 Vector 则通过增长系数(创建是设置,如果过为空,则增长一倍)
3、Vector 方法调用是线程安全的。
4、成员变量有所不同
三、LinkedList
我们在前面的文章中已经介绍过 List 大家族中的 ArrayList 和Vector 这两位犹如孪生兄弟一般,从底层实现,功能都有着相似之处,除了一些个人行为不同(成员变量,构造函数和方法线程安全)。接下来,我们将会认识一下他们的另一位功能强大的兄弟:LinkedList
LinkedList 的依赖关系:
1、继承于 AbstractSequentialList ,本质上面与继承 AbstractList 没有什么区别,AbstractSequentialList 完善了 AbstractList 中没有实现的方法。
2、Serializable:成员变量 Node 使用 transient 修饰,通过重写 read/writeObject 方法实现序列化。
3、Cloneable:重写 clone()方法,通过创建新的 LinkedList 对象,遍历拷贝数据进行对象拷贝。
4、Deque:实现了 Collection 大家庭中的队列接口,说明他拥有作为双端队列的功能。
eng~从上述实现接口来看,LinkedList 与 ArrayList 之间在整体上面的区别在于,LinkedList 实现了 Collection 大家庭中的 Queue(Deque)接口,拥有作为双端队列的功能。(就好比一个小孩子,他不仅仅有父母的特性,他们有些人还会有舅舅的一些特性,好比 外甥长得像舅舅一般)。
3.1、LinkedList 成员变量
LinkedList 的成员变量主要由 size(数据量大小),first(头节点)和 last(尾节点)。结合数据结构中双端链表的思想,每个节点需要拥有,保存数据(E item),指向下一节点(Node next )和指向上一节点(Node prev)。
LinkedList 与 ArrayLit、Vector 的成员变量对比中,明显没有提供 MAX_ARRAY_SIZE 这一个最大值的限定,这是由于链表没有长度限制的原因,他的内存地址不需要分配固定长度进行存储,只需要记录下一个节点的存储地址即可完成整个链表的连续。
拓展思考: LinkedList 中 JDK 1.8 与 JDK 1.6 有哪些不同?
主要不同为,LinkedList 在 1.6 版本以及之前,只通过一个 header 头指针保存队列头和尾。这种操作可以说很有深度,但是从代码阅读性来说,却加深了阅读代码的难度。因此在后续的 JDK 更新中,将头节点和尾节点 区分开了。节点类也更名为 Node。
3.2、LinkedList 构造函数
LinkedList 只提供了两个构造函数:
LinkedList()
LinkedList(Collection<? extends E> c)
在 JDK1.8 中,LinkedList 的构造函数 LinkedList() 是一个空方法,并没有提供什么特殊操作。区别于 JDK1.6 中,会初始化 header 为一个空的指针对象。
3.2.1 LinkedList()
JDK 1.6
JDK 1.8 在使用的时候,才会创建第一个节点。
3.2.2 LinkedList(Collection<? extends E> c)
这一构造方法主要通过 调用 addAll 进行创建对象,在介绍 LinkedList 添加方法的时候再进行细述。
3.2.3 小结
LinkedList 在新版本的实现中,除了区分了头节点和尾节点外,更加注重在使用时进行内存分配,这里跟 ArrayList 类似(ArrayList 默认构造器是创建一个空的数组对象)。
4、添加方法(Add)
LinkedList 继承了 AbstractSequentialList(AbstractList),同时实现了 Deque 接口,因此,他在添加方法 这一块,包含了两者的操作:
AbstractSequentialList:
add(E e)
add(int index,E e)
addAll(Collection<? extends E> c)
addAll(int index, Collection<? extends E> c)
Deque
addFirst(E e)
addLast(E e)
offer(E e)
offerFirst(E e)
offerLast(E e)
4.1 add(E e) & addLast(E e) & offer(E e) & offerLast(E e)
虽然 LinkedList 分别实现了 List 和 Deque 的添加方法,但是在某种意义上,这些方法其实都是有共性的。例如,我们调用 add(E e) 方法,不管是 ArrayList 或 Vector 等列表,都是默认在数组末尾进行添加,因此与 队列中在末尾添加节点 addLast(E e) 是有着一样的韵味的。所以,从 LinkedList 的源码中,这几个方法,底层操作其实是一致的。
我们主要分析一下 linkLast 这个方法:
获取尾节点(last)
创建插入节点,并且设置上一节点为 last,下一节点为 null。
设置新节点为末尾节点(last)
如果 l(初始末尾节点)==null,说明这是第一次操作,新加入的为头节点
否则,设置 l(初始末尾节点)的下一节点为新加入的节点
size + 1,操作计数 + 1
拓展思考:为什么内部变量 Node l 需要使用 final 进行修饰?
4.2 addFirst(E e) & offerFirst(E e)
从上述代码可以看出,offerFirst 和 addFirst 其实都是一样的操作,只是返回的数据类型不同。而 linkFirst 方法,则与 linkLast 其实是一样的思想,这里也不做细述。
4.3 add(int index,E e)
这里我们主要讲一下,为什么 LinkedList 在添加、删除元素这一方面优于 ArrayList。
LinkedList 在 add(int index,Element e)方法的流程
判断下标有效性
如果插入位置为末尾,直接插入
否则,遍历 1/2 的链表找到 index 下标的节点
通过 succ 设置新节点的前,后节点
LinkedList 在插入数据之所以会优于 ArrayList,主要是由于在插入数据这一环节(linkBefore),插入计算只需要设置节点的前,后节点即可,而 ArrayList 则需要将整个数组的数据进行后移(
)
4.4 addAll(Collection<? extends E> c)
LinkedList 中提供的两个 addAll 方法中,其实内部实现也是一样的,主要通过:addAll(int index, Collection<? extends E> c)进行实现:
4.5 小结
LinkedList 在插入数据优于 ArrayList ,主要是因为他只需要修改指针的指向即可,而不需要将整个数组的数据进行转移。而 LinkedList 优于没有实现 RandomAccess,或者说 不支持索引搜索的原因,他在查找元素这一操作,需要消耗比较多的时间进行操作(n/2)。
5、删除方法(Remove)
AbstractSequentialList:
remove(int index)
remove(Object o)
Deque
remove()
removeFirst()
removeLast()
removeFirstOccurrence(Object o)
removeLastOccurrence(Object o)
5.1 remove(int index)&remove(Object o)
在 ArrayList 中,remove(Object o) 方法,是通过遍历数组,找到下标后,通过 fastRemove(与 remove(int i) 类似的操作)进行删除。而 LinkedList,则是遍历链表,找到目标节点(node),通过 unlink 进行删除:我们这里主要来看看 unlink 方法:
整个过程为:
获取目标节点的 next、prev
如果 prev 为空,说明目标节点为头节点
设置 first 为目标节点的下一节点(next)
否则设置 prev 节点的下一节点为 next(即将自己重链表中剔除)
如果 next 为空,说明目标节点为尾节点
设置 last 为目标节点的上一节点
否则,设置 next 节点的上一节点为 prev
将目标节点设置为 null
可以看到,删除方法与添加方法类似,只需要修改节点关系即可,避免了类似于 ArrayList 的数组平移情况,大大减少了时间损耗。
5.2 Deque 中的 Remove
Deque 中的 removeFirstOccurrence 和 removeLastOccurrence 主要过程为,首先从 first/last 节点开始遍历,当发现第一个目标对象,则低哦啊用 remove(Object o) 进行删除对象。总体上没有什么特别之处。
稍有不同的是 Deque 中的 removeFirst()和 removeLast()方法,在底层实现上面,由于明确知道删除的对象为 first/last 对象,因此在删除操作上面 会更加简单:
整体操作为,将 first 节点的 next 设置为新的头节点,然后将 f 清空。 removeLast 操作也类似。
结合队列的思想,removeFirst 和 removeLast 都会返回 数据 E,相当于我们的出列操作(pollFirst/pollLast)
6 LinkedList 双端链表
我们之所以说 LinkedList 为双端链表,是因为他实现了 Deque 接口,支持队列的一些操作,我们来看一下有哪些方法实现:
pop()
poll()
push()
peek()
offer()
可以看到 Deque 中提供的方法主要有上述的几个方法,接下来我们来看看在 LinkedList 中是如何实现这些方法的。
6.1 pop() & poll()
LinkedList#pop 的源码:
从上述代码可以看出,Pop() 的操作为,队列头部元素出队列,如果过 first 为空 会抛出异常。
LinkedList#poll 的源码:
对比 pop 和 poll 的源码可以看到,虽然同样是 first 出列,不同的是,如果 first 为 null, pop()方法会抛出异常。
6.2 push()
push() 方法的底层实现,其实就是调用了 addFirst(Object o):
push()方法的操作,主要跟 栈(Stack) 中的入栈操作类似。
6.3 peek()
LinkedList#peek 操作主要为,将取队列头部元素的值(根据队列的 FIFO,peek 为取头部数据)
6.4 offer()
offer()方法为直接调用添加方法。
7 LinkedList 遍历
LinkedList 由于没有实现 RandomAccess,因此,在以随机访问的形式进行遍历时效果会非常低下。除此之外,LinkedList 提供了类似于通过 Iterator 进行遍历,节点的 prev 或 next 进行遍历,还有 for 循环遍历,都有不错的效果。
四、HashMap
在接下来主要为大家介绍一下Java
集合家庭中另一小分队 Map
,我们先来看看 Map
家庭的整体架构:
我们主要介绍一下 HashMap:
HashMap 的依赖关系:
1、AbstractMap:表明它是一个散列表,基于 Key-Value 的存储方式
2、Cloneable:支持拷贝功能
3、Seriablizable:重写了 write/readObject,支持序列化
从依赖关系上面来看,HashMap
并没有 List
集合 那么的复杂,主要是因为在迭代上面,HashMap 区别 key-value 进行迭代,而他们的迭代又依赖与 keySet-valueSet 进行,因此,虽然依赖关系上面 HashMap 看似简单,但是内部的依赖关系更为复杂。
4.1、HashMap 成员变量
对比于 JDK8 之前的 HashMap ,成员变量主要的区别在于多了红黑树的相关变量,用于标示我们在什么时候进行 list
-> Tree
的转换。
附上 Jdk8 中 HashMap 的数据结构展示图:
4.2、HashMap 构造函数
HashMap 提供了四种构造函数:
HashMap():默认构造函数,参数均使用默认大小
HashMap(int initialCapacity):指定初始数组大小
HashMap(int initialCapacity, float loadFactor):指定初始数组大小,加载因子
HashMap(Map<? extends K, ? extends V> m):创建新的 HashMap,并将
m
中内容存入 HashMap 中
4.3、HashMap Put 过程
接下来我们主要讲解一下,HashMap 在 JDK8 中的添加数据过程(引用):
4.3.1、put(K key, V value)
上述方法是我们在开发过程中最常使用到的方法,但是却很少人知道,其实内部真正调用的方法是这个putVal(hash(key), key, value, false, true)
方法。这里稍微介绍一下这几个参数:
hash 值,用于确定存储位置
key:存入键值
value:存入数据
onlyIfAbsent:是否覆盖原本数据,如果为 true 则不覆盖
onlyIfAbsent:table 是否处于创建模式
4.3.1.1 hash(Object key)
这里的 Hash 算法本质上就是三步:取 key 的 hashCode 值、高位运算、取模运算。这里引用一张图,易于大家了解相关机制
这里可能会比较疑惑,为什么需要对自身的 hashCode 进行运算,这么做可以在数组 table 比较小的时候,让高位 bit 也能参与到 hash 运算中,同时不会又太大的开销。
4.3.2、putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
由于源码篇幅过长,这里我进行分开讲解,同学们可以对照源码进行阅读
4.3.2.1 声明成员变量(第一步)
第一部分主要县声明几个需要使用到的成员变量:
tab:对应 table 用于存储数据
p:我们需要存储的数据,将转化为该对象
n:数组(table) 长度
i:数组下标
4.3.2.2 Table 为 null,初始化 Table(第二步)
table 为空说明当前操作为第一次操作,通过上面构造函数的阅读,我们可以了解到,我们并没有对 table 进行初始化,因此在第一次 put 操作的时候,我们需要先将 table 进行初始化。
从上述代码可以看到,table 的初始化和扩容,都依赖于 resize()
方法,在后面我们会对该方法进行详细分析。
4.3.2.3 Hash 碰撞确认下标(True)
在上一步我们以及确认当前 table 不为空,然后我们需要计算我们对象需要存储的下标了。
如果该下标中并没有数据,我们只需创建一个新的节点,然后将其存入 tab[]
即可。
4.3.2.4 Hash 碰撞确认下标(False)
与上述过程相反,Hash 碰撞结果后,发现该下标有保存元素,将其保存到变量 p = tab[i = (n - 1) & hash]
,现在 p
保存的是目标数组下标中的元素。如上图所示(引用):
4.3.2.4.1 key 值相同覆盖
在获取到 p
后,我们首先判断它的 key 是否与我们这次插入的 key 相同,如果相同,我们将其引用传递给 e
4.3.2.4.2 红黑树节点处理
由于在 JDK 8 后,会对过长的链表进行处理,即 链表 -> 红黑树,因此对应的节点也会进行相关的处理。红黑树的节点则为 TreeNode,因此在获取到p
后,如果他跟首位元素不匹配,那么他就有可能为红黑树的内容。所以进行putTreeVal(this, tab, hash, key, value)
操作。该操作的源码,将会在后续进行细述。
4.3.2.4.3 链表节点处理
链表遍历处理,整个过程就是,遍历所有节点,当发现如果存在 key 与插入的 key 相同,那么退出遍历,否则在最后插入新的节点。判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
4.3.2.4.3 判断是否覆盖
如果 e
不为空,说明在校验 key 的 hash 值,发现存在相同的 key,那么将会在这里进行判断是否对其进行覆盖。
4.3.2.5 容量判断
如果 size
大于 threshold
则进行扩容处理。
4.4、Resize()扩容
在上面的构造函数,和 put
过程都有调用过 resize()
方法,那么,我们接下来将会分析一下 resize()
过程。由于JDK 8
引入了红黑树,我们先从JDK 7
开始阅读 resize()
过程。下面部分内容参考:传送门
4.4.1 JDK 7 resize()
在 JDK 7
中,扩容主要分为了两个步骤:
容器扩展
内容拷贝
4.4.1.1 容器扩展
4.4.1.2 内容拷贝
4.4.1.3 扩容过程展示(引用)
下面举个例子说明下扩容过程。假设了我们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组 table 的 size=2, 所以 key = 3、7、5,put 顺序依次为 5、7、3。在 mod 2 以后都冲突在 table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于 table 的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize 成 4,然后所有的 Node 重新 rehash 的过程。
4.4.2 JDK 8 resize()
由于扩容部分代码篇幅比较长,童鞋们可以对比着博客与源码进行阅读。与上述流程相似,JDK 8
中扩容过程主要分成两个部分:
容器扩展
内容拷贝
4.4.2.1 容器扩展
从上面的流程分析,我们可以看到在 JDK 8 HashMap
中,开始使用位运算进行扩容计算,主要优点将会在后续数据拷贝中具体表现。
4.4.2.2 内容拷贝
在上述容器扩容结束后,如果发现 oldTab
不为空,那么接下来将会进行内容拷贝:
内容拷贝,在JDK 8
中优化,主要是:
通过高位与运算确认存储地址
链表不会出现导致,JDK 8 通过创建新链表方式进行转移
我们来看一下 JDK 8
是如何通过高位与运算确认存储位置的:
4.5、小结
HashMap 中,如果 key 经过 hash 算法得出的数组索引位置全部不相同,即 Hash 算法非常好,那样的话,getKey 方法的时间复杂度就是 O(1),如果 Hash 算法技术的结果碰撞非常多,假如 Hash 算极其差,所有的 Hash 算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为 O(n)和 O(lgn)。
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用 HashMap 的时候,估算 map 的大小,初始化的时候给一个大致的数值,避免 map 进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于 1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap 是线程不安全的,不要在并发的环境中同时操作 HashMap,建议使用 ConcurrentHashMap。
(4) JDK1.8 引入红黑树大程度优化了 HashMap 的性能。
(5) 还没升级 JDK1.8 的,现在开始升级吧。HashMap 的性能提升仅仅是 JDK1.8 的冰山一角。
参考
https://tech.meituan.com/java-hashmap.html
https://www.2cto.com/kf/201505/401433.html
五、总结
没有太多的拓展思考,脑子不够清晰,总体来说,List 接口下面的小家庭的源码以及分析完了。对每一个成员都有了进一步的了解,面试的时候,也不会再简单的回答,linkedList 插入删除性能比较好,ArrayList 能过快速定位元素,Vector 是线程安全。只有在充分了解其实现,你才会发现,你回答的虽然没错,但是也就 60 分而已,如果你想要将每一个问题回答的完美,那么请认真思考,认真去了解它。
最后贴一个新生的公众号 (Java 补习课
),欢迎各位关注,主要会分享一下面试的内容(参考之前博主的文章),阿里的开源技术之类和阿里生活相关。 最近在组建一个阿里内推&技术交流群
,感兴趣的童鞋,可以扫描二维码添加我的微信!
版权声明: 本文为 InfoQ 作者【JayceKong】的原创文章。
原文链接:【http://xie.infoq.cn/article/4765eb8103e68db7144d8f94e】。文章转载请联系作者。
评论