目录
Collection接口的子接口(1)——List接口
List接口的实现类(1)——ArrayList
ArrayList的基本介绍:
ArrayList的底层结构和源码分析(全程截图,手把手带你进行Debug分析)
List接口的实现类(2)——Vector
Vector的基本介绍:
Vector创建和扩容源码分析:
List接口的实现类(3)——LinkedList
LinkedList基本介绍:
LinkedList底层结构和源码分析
Collection接口的子接口(2)——Set接口
Set接口的实现类(1)——HashSet
HashSet的基本介绍:
HashSet扩容机制源码分析
HashSet底层结构和源码分析
HashSet扩容和转成红黑树机制
Map
Map的实现类(1)——HashMap
HashMap底层机制
Map的实现类(2)——Hashtable
Hashtable的基本介绍
Hashtable底层结构
Java中的集合主要分为两大类:单列集合和双列集合
1.Collection接口有两个重要的子接口List和Set,他们的实现子类都是单列集合
2.Map接口的实现子类为双列集合,存放的是键值对 (Key-Value);
两大类的继承图(记住图对后面理解事半功倍)分别如下
Collection
Set
List接口的基本介绍:
1.List集合类中的元素有序,且可重复;
2.List集合中的每一个元素都有其对应的顺序索引,即支持索引;
3.List容器中的元素都对应一个整数型的序号记载在容器的位置,可以根据序号存取容器中的元素;
4.List接口下重要的实现类有ArrayList,Vector,LinkedList(后面会依次详细讲解);
1.ArrayList集合的底层是由数组实现数据存储的,因此其搜索效率高,但是修改效率低;
2.ArrayList集合中可以加入null,并且可以存储多个null;
3.ArrayList基本等同于Vector,但是ArrayList相较于Vector来说是线程不安全的
从源码中我们可以看到
Vector中基本所有方法都使用了synchronized关键字修饰以保证线程安全,而ArrayList且并没有synchronized修饰。
因此ArrayList集合的执行效率更高但是线程不安全,Vector线程安全,但相较于ArrayList来说执行效率较低。
1.ArrayList集合中维护了一个Object类型的数组elementData
2.当创建ArrayList对象时,如果使用的时无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需再次扩容,则扩容elementData为1.5倍
3.如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,则直接扩容elementData为1.5倍
源码分析:
1.我们先来看一下调用无参构造器创建ArrayList对象是如何完成的
2.进入内部后我们发现,ArrayList的无参构造器其实就是给其成员属性elementData赋值
3.为了弄明白elementData的属性是干嘛的,以及这个无参构造函数给elementData赋值了什么内容,我们分别通过Ctrl+鼠标左键单击找到elementData和DEFAULTCAPACITY_EMPTY_ELEMENTDATA所表示的内容如下:
可以看到 DEFAULTCAPACITY_EMPTY_ELEMENTDATA实际上是一个空的Object数组,
因此可以得出第一个结论:
1.当我们调用ArrayList的无参构造函数创建对象时,其内部实际上是创建了一个空的elementData数组
后面的源码我们暂时不去探究,先点击步出退回到初始状态
接着点击步过往后走,进行for循环查看添加元素时内部如何运行
再次点击步入查看内部源码
这里是先对我们所添加的元素进行装箱处理,我们仍然暂时先不去探索这类知识点,点击步出回到起始位置,然后再次点击步入发现他来到了我们ArrayList中的add方法
从源码中我们可以看到add方法并没有直接将传入的值加入到elementData集合中,而是先执行了一个ensureCapacityInternal()方法,我们继续点击步入查看ensureCapacityInternal()的内部执行内容;
在 ensureCapacityInternal()中又调用了两个方法
我们接着点击步入并鼠标左键点击calculateCapacity进入方法内部
在这里我们可以看到,当elementData为空数组时,calculateCapacity会让我们传入的mincapacity与DEFAULT_CAPACITY(也就是10)进行对比,并返回其中最大的那个数返回。
也就是说calculateCapacity的目的是为了确定这个数组的最小容量至少得有多大,初始扩容为10;
接着点击步过回到 ensureCapacityInternal(),然后点击步入去查看ensureExplicitCapacity()
这里的modCount是用来记录集合被修改次数的参数,目的是为了防止有多个线程同时对其进行修改,了解即可。
接着点击步过来到核心代码
这里的意思是:当我们的实际上需要的最小容量minCapacity比数组elementData的容量要大时,即数组的容量不足以塞下所有元素时,便会执行grow对数组elementData进行扩容。
点击步过再点击步入进入grow方法内部
这里比较复杂,我们通过代码注释进行分析
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;//首先将elementData数组当前的容量赋值给oldCapacity
int newCapacity = oldCapacity + (oldCapacity >> 1);//通过位运算将oldCapacity的1.5倍赋值给newCapacity
if (newCapacity - minCapacity < 0)//这里的判断十分巧妙,它使得我们能初始的数组容量并不是直接按照1.5倍进行扩容,而是先直接将我们前面确定的最小容量10又重新赋值给了newCapacity然后在后面进行扩容
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);
}
我们一直点击步过,直到执行完Arrays.copyOf(),则我们此次的扩容完成。
后面的元素添加源码流程也是如此,大家可以自己试一试
通过对这些流程阅读我们得出第二个结论:
2.当创建ArrayList对象时,如果使用的时无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需再次扩容,则扩容elementData为1.5倍
下面我们再接着来看看ArrayList的有参构造的内部是如何完成的(有参构造除了初始创建时候内部源码不同,其他的基本和无参构造区别不大,快速过一遍)
点击步入
在这里进行初始化,如果有参构造传入参数为0,则和无参构造一模一样,否则就按照传入的参数大小对elementData数组的容量进行初始化,后续扩容机制与无参构造区别不大。
结论:
3.如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,则直接扩容elementData为1.5倍
1.Vector底层也是一个对象数组,protected Object[] elementData;
2.Vector是线程同步的,即Vector线程安全,因为Vector的操作方法都带有synchronized关键字修饰,开发中如果需亚奥线程同步安全时可以考虑使用Vector
点击步入
这里通过无参构造中的this方法调用了有参构造方法且有参构造的参数为10。
点击步入来到有参构造
再点击步入进入另一个有参构造
在这里实现了对于elementData的容量初始化,且初次容量大小为10
下面步出回到创建Vector的这里,然后点步过来到给数组增加元素这里
点击步入查看添加元素时的内部操作
又是先进行装箱,不去管他,点击步出回来再点击步入 观察添加细节
步过到ensureCapacityHelper方法后步入去观察方法内部代码
我们发现由于我们的数组大小已经扩容至10,而我们现在添加的元素个数还达不到需要数组继续扩容的地步,因此无法观察grow方法内部。
下面我们直接查看扩容的内部源码:
我们给Vector添加10个元素之后再去下断点查看添加第11一个元素会怎样
点击步入
步过到ensureCapacityHelper方法后步入去观察方法内部代码
继续步入到grow方法中
可以看到,这里的Vector是对数组容量进行2倍扩容处理,其他的内容和ArrayList源码基本一致。
总结:
实现类 | 底层结构 | 线程安全,效率 | 扩容方式 |
ArrayList | 可变数组 | 不安全,效率高 | 如果是有参构造则按1.5倍扩容 如果是无参构造 则第一次扩容为10, 后面开始按1.5倍扩容 |
Vector | 可变数组 | 安全,效率较低 | 如果是无参,默认10,满后,按照2倍扩容 如果是有参指定大小,则每次都按2倍扩容 |
1.LinkedList底层实现了双向链表和双端队列特点;
2.可以添加任意元素(元素可重复),包括null;
3.线程不安全,没有实现同步;
4.LinkedList的添加和删除是通过链表来完成的,效率会比数组更高
我们先来通过无参构造创建一个LinkedList对象来查看其创建过程
点击步入
我们发现LinkedList的无参构造中没有任何操作,单纯创建了一个LinkedList对象
因此我们再点击几次步过就回到了初始代码部分。并且此时就创建好了一个LinkedList对象
下面我们给LinkedList添加元素来查看其添加元素时的源码
点击步入
这时我们发现系统调用了LinkedList的add方法来完成元素的添加操作
继续步入查看linkLast()内部源码
这里的last和frist均为LinkedList里面的成员属性,且类型为Node
Node
当我们添加第一个元素时,由于LinkedList中此前并没有元素,因此他的frist指针和last指针实际上都指向null;
所以final Node
然后执行final Node
我们步入进去
可以看到就是将创建了一个值为e,prev节点为l ,next节点为null的新Node节点
然后将执行last = newNode;
继续往下执行,由于此时的l确实为null,因此first也指向了newNode这个新节点
此时的数据结构是一个这样的
全都指向了新添加的这个节点,这样此次添加过程就结束了
我们再来看在LinkedList中有数据的情况下再添加元素是怎样操作的
我们来到linkedList.add(2);这里点击步入
这里先让节点 l 指向last所指向的节点
然后新创建一个节点newNode,这个新节点的prev指向的是之前已经创建好的那个节点
next指向为空
然后让last指向这个新节点
然后因为 l 此时并不指向空,而是指向着以前创建好的节点,因此让 l 的next指向这个新节点,完成连接
此时的结构是这样的
后面的LinkedList的remove操作都是链表操作,大家学好数据结构即可看懂源码
Set接口的基本介绍:
1.Set接口的实现类无序(即添加和取出的顺序不一致),没有索引
2.Set接口的实现类中不允许有重复元素,所以最多包好一个null
3.Set接口的重要实现类有:HashSet和TreeSet
注意:Set接口的遍历方式中,由于其没有索引,因此只能通过迭代器和增强for循环来遍历获取元素,无法通过索引遍历获取
1.HashSet实际上是一个HashMap,源码如下
2.HashSet可以存放null值,但是只能有一个null
3.HashSet不保证元素是有序的,取决于hash后,在确定索引的结果
4.不能有重复元素/对象
1.HashSet底层是HashMap
2.添加一个元素时,先得到hash值,然后转换成索引值
3.找到存储数据表table,看索引位置是否已经存放有元素
4.如果没有则直接加入
5.如果索引位置已经有元素,则调用equals比较,如果相同,就放弃添加,如果不同,则将元素添加到最后
6.在Java8中,如果一条链表的元素个数超过TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树)
我们步入构造器中发现其无参构造实际上是构建了一个HashMap
继续步入HashMap中,我们能发现在HashMap的构造器中构造了一个具有默认初始容量(16)和默认负载因子(0.75)的空HashMap
然后跟着顺序在步过几步回到原点往下走,去观察其添加元素时的源码
可以看到在add方法中调用了HashMap的一个put方法,我们步入进去看一看
这里的key就是我们添加进集合的元素,value是我们在上一步调用put方法时传入的PREESENT,这个PRESENT其实是一个静态的Object对象,主要是是为了起到占位的作用,让单列集合Hashset使用到双列HashMap
我们先去看hash(key)这个方法做了什么,Ctrl+左键点击hash进入
可以看到 hash(key)方法是为了得到key对应的hash值,但是hash(key)得到的hash值并不与key的hashCode相同,因为它还做了一个位运算的处理以避免hashCode的碰撞
下面我们接着步入去观察最核心的部分,即putVal()方法干了什么事情
代码太长截图不完整,直接复制下来
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i; //定义了辅助变量
//table 就是 HashMap 的一个数组,类型是 Node[]
//if 语句表示如果当前table 是null, 或者 大小=0
//就是第一次扩容,到16个空间.
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(1)根据key,得到hash 去计算该key应该存放到table表的哪个索引位置
//并把这个位置的对象,赋给 p
//(2)判断p 是否为null
//(2.1) 如果p 为null, 表示还没有存放元素, 就创建一个Node (key="java",value=PRESENT)
//(2.2) 就放在该位置 tab[i] = newNode(hash, key, value, null)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//一个开发技巧提示: 在需要局部变量(辅助变量)时候,在创建
Node e; K k; //
//如果当前索引位置对应的链表的第一个元素和准备添加的key的hash值一样
//并且满足 下面两个条件之一:
//(1) 准备加入的key 和 p 指向的Node 结点的 key 是同一个对象
//(2) p 指向的Node 结点的 key 的equals() 和准备加入的key比较后相同
//就不能加入
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//再判断 p 是不是一颗红黑树,
//如果是一颗红黑树,就调用 putTreeVal , 来进行添加
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {//如果table对应索引位置,已经是一个链表, 就使用for循环比较
//(1) 依次和该链表的每一个元素比较后,都不相同, 则加入到该链表的最后
// 注意在把元素添加到链表后,立即判断 该链表是否已经达到8个结点
// , 就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树)
// 注意,在转成红黑树时,要进行判断, 判断条件
// if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY(64))
// resize();
// 如果上面条件成立,先table扩容.
// 只有上面条件不成立时,才进行转成红黑树
//(2) 依次和该链表的每一个元素比较过程中,如果有相同情况,就直接break
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD(8) - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//size 就是我们每加入一个结点Node(k,v,h,next), size++
if (++size > threshold)
resize();//扩容
afterNodeInsertion(evict);
return null;
}
由于我们添加第一个元素时table还为空,因此执行如下语句
可以看到,系统执行了resize方法并将其赋值给了tab后求了长度再赋值给了n
我们点进resize看看resize做了什么
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
我们往下执行,会发现系统最后会执行这部分代码
在这里,系统将新集合的容量扩容为了16,并且紧接着就定义了一个临界值,并且赋值为
DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY
这样做的意义是为了保证在集合容量达到0.75倍时,就要开始扩容,以避免当突然有大量线程来添加时,发生阻塞
然后执行完这三段代码后,就完成了加载因子的初始化和数组table的扩容
我们的resize方法就结束了,即resize主要的作用就是扩容我们的集合容量
接着往下走,执行下面这个if语句
这段代码的目的如下:
(1)根据key,得到hash去计算该key应该存放到table表的哪个索引位置并把这个位置的对象,赋给 p
(2)判断p 是否为null
(2.1) 如果p 为null, 表示还没有存放元素, 就创建一个Node (key="java",value=PRESENT)就放在该位置 tab[i] = newNode(hash, key, value, null)
(2.2)如果不为空就执行else语句所包含的代码,后面讲
继续向下执行
如果++过后的size大于12,则进入resize方法继续扩容
最后返回null
说明:
(1)这里的afterNodeInsertion(evict);方法其实运用的是模板方法思想,是HashMap为了留给子类实现的用来增加功能的
(2)这里返回null才能保证最后我们在这里操作成功,即返回null就说明添加成功了
接着看添加第二个元素时的源码,前面部分基本相同,不同的在于putVal方法执行的代码不一样
这里代码比较简单就不做分析,我们接着看当添加相同元素时的源码
步入进去会发现,系统执行这部分源码
Node e; K k; //
//如果当前索引位置对应的链表的第一个元素和准备添加的key的hash值一样
//并且满足 下面两个条件之一:
//(1) 准备加入的key 和 p 指向的Node 结点的 key 是同一个对象
//(2) p 指向的Node 结点的 key 的equals() 和准备加入的key比较后相同
//就不能加入
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//再判断 p 是不是一颗红黑树,
//如果是一颗红黑树,就调用 putTreeVal , 来进行添加
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {//如果table对应索引位置,已经是一个链表, 就使用for循环比较
//(1) 依次和该链表的每一个元素比较后,都不相同, 则加入到该链表的最后
// 注意在把元素添加到链表后,立即判断 该链表是否已经达到8个结点
// , 就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树)
// 注意,在转成红黑树时,要进行判断, 判断条件
// if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY(64))
// resize();
// 如果上面条件成立,先table扩容.
// 只有上面条件不成立时,才进行转成红黑树
//(2) 依次和该链表的每一个元素比较过程中,如果有相同情况,就直接break
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD(8) - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
1.HashSet底层是HashMap, 第一次添加时,table 数组扩容到 16, 临界值(threshold)是 16*加载因子(loadFactor)是0.75 = 12
2.如果table 数组使用到了临界值 12,就会扩容到 16 * 2 = 32, 新的临界值就是 32*0.75 = 24, 依次类推
3.在Java8中, 如果一条链表的元素个数到达 TREEIFY_THRESHOLD(默认是 8 ), 并且table的大小 >= MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树), 否则仍然采用数组扩容机制
Map接口实现类的特点一(JDK8中的Map接口特点)
1.Map和Collection并列存在,用于保存具有映射关系的数据:Key-Value
2.Map中的key和value可以是任何引用类型的数据,会封装到HashMap$Node对象中
3.Map中的key不允许重复,原因和HashSet一样
4.Map中的value可以重复
5.Map的key可以为null,value也可以为null,注意key只能有一个为null,value可以多个为null
6.常用String类作为Map的key
7.key和value之间存在单向一对一关系,即通过指定的key总能找到对应的value
8.Map存放数据的每对key-value是放在一个HashMAp$Node中的,又因为Node实现了Entry接口,因此也可以说一对key-value就是一个Entry
Map接口实现类的特点二(JDK8中的Map接口特点)
1. k-v 最后是 HashMap$Node node = newNode(hash, key, value, null)
2. k-v 为了方便程序员的遍历,还会 创建 EntrySet 集合 ,该集合存放的元素的类型 Entry, 而一个Entry对象就有k,v EntrySet
3. entrySet 中, 定义的类型是 Map.Entry ,但是实际上存放的还是 HashMap$Node,这是因为 static class Node
4. 当把 HashMap$Node 对象 存放到 entrySet (entrySet指向Node中的key-value)就方便我们的遍历, 因为 Map.Entry 提供了重要方法 K getKey(); V getValue();
1.HashMap底层维护了Node类型的数组table,默认为null
2.当创建对象时,将加载因子初始化为0.75
3.当添加key-value时,通过key的hash值得到在table的索引。然后判断该索引处是否有元素,如果没有元素直接添加。如果该索引处有元素,继续判断该元素的key和准备加入的key相比较是否相等,如果相等,则直接替换value;如果不相等需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容
4.第1次添加时,需要扩容table容量为16,临界值为12
5.以后再扩容,则需要扩容table容量为原来的2倍,临界值也为原来的2倍,依此类推
6.在Java8中,如果一条链表的元素个数超过TREEIFY_THRESHOLD(默认时8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认是64),就会进行树化(红黑树)
1.HashMap是Map接口使用频率最高的实现类
2.HashMap是以key-value对的方式来存储数据
3.key不能重复,但是值可以重复,允许使用null键和null值
4.如果添加相同的key,则会覆盖原来的key-value,等同于修改(只会修改value)
5.与HashSet一样,不保证映射的顺序,因为底层是以hash表的方式来存储的。
6.HashMap没有实现同步,因此线程不安全
1.Hashtable存放的也是键值对
2.Hashtable的键和值都不能为null,否则会抛出空指针异常
3.Hashtable使用方法基本上和HashMap一样
4.Hashtable是线程安全的,HashMap是线程不安全的
1.底层有数组 Hashtable$Entry[] 初始化大小为 11
2. 临界值 threshold 8 = 11 * 0.75
3. 扩容: 按照自己的扩容机制来进行即可
4. 执行 方法 addEntry(hash, key, value, index); 添加K-V 封装到Entry
5. 当 if (count >= threshold) 满足时,就进行扩容(执行rehash()方法)
6. 按照 int newCapacity = (oldCapacity << 1) + 1; 的大小扩容