一、前言
大家好!集合是在以后工作面试中出现的必考题,本章我们将一起来学习集合的使用以及深入剖析它的底层原理。我们知道,在没有学习和接触到集合这一概念之前,当我们遇到要存储两个以上数据时,一般都是利用数组进行存储,虽然解决了多数据存储这一个问题,但是同时面临的是利用数组存储所带来的缺点。
- 数组长度在定义时就已经确定,而一旦确定无法更改,这就造成容量拓容问题
- 数组里面保存的元素必须是同一类型,造成类型单一化问题
- 最后是使用数组进行增加/删除元素比较麻烦(低效)
基于以上问题,我们引出了集合这一概念,集合在实现存储多个数据的基础上,又可以动态保存多个对象,再者提供了一系列方便操作对象的方法:add、remove、set、get等,最后就是使用集合添加和删除新元素能简化代码,便于阅读及操作。
二、集合的框架体系
JAVA集合类很多,主要分为两大类及单列集合(collectio)和双列集合(map),下面整理出集合的框架体系图(罗列出部分常用实现类),请大家在学习的过程中务必对这个体系图十分熟悉。
(图为单列集合的类接口继承图)
(图为双列集合类接口继承图)
三、Collection接口和常用方法
3.1Collection接口实现类的特点
- collection实现子类可以存放多个元素,每个元素的类型可以是Object(底层是List的实现类的字段中有Object[] elementData这个数组)详细后面会在后面的源码剖析中进一步说明,这里知道多个元素是维护在一个Object类型的数组中的就好
- 有些Collection的实现类可以存放重复的元素,有些不可以
- 有些Collection的实现类,有些是有序的(List),有些是无序的(Set)
- Collection接口没有直接的实现子类,是通过它的子接口Set和list来实现的
下面是list集合的常用方法使用案例
public class CollectionMethod {
public static void main(String[] args) {
List list = new ArrayList();
// add:添加单个元素
list.add("jack");
list.add(10);//list.add(new Integer(10))
list.add(true);
System.out.println("list=" + list);
// remove:删除指定元素
//list.remove(0);//删除第一个元素
list.remove(true);//指定删除某个元素
System.out.println("list=" + list);
// contains:查找元素是否存在
System.out.println(list.contains("jack"));//T
// size:获取元素个数
System.out.println(list.size());//2
// isEmpty:判断是否为空
System.out.println(list.isEmpty());//F
// clear:清空
list.clear();
System.out.println("list=" + list);
// addAll:添加多个元素
ArrayList list2 = new ArrayList();
list2.add("红楼梦");
list2.add("三国演义");
list.addAll(list2);
System.out.println("list=" + list);
// containsAll:查找多个元素是否都存在
System.out.println(list.containsAll(list2));//T
// removeAll:删除多个元素
list.add("聊斋");
list.removeAll(list2);
System.out.println("list=" + list);//[聊斋]
}
}
3.2Collection接口遍历元素方式
3.2.1使用Iterator(迭代器)遍历
由集合类和接口继承图我们知道,Collection接口的父接口是Iterable,接口里面定义了一个Iterator()方法,用于获取一个Iterator对象(迭代器),我们可以利用获得的迭代器对集合进行遍历。注意:Iterator仅用于遍历集合,它本身并不存放对象。
3.2.2迭代器的执行原理说明
下面给出Iterator的结构图。
Iterator iterator = collection.iterator();//得到一个集合的迭代器
while(iterator.hasNext()){ //判断是否还有下一个元素
iterator.next(); //指针下移,并将下移后的元素返回
}
注意事项:在使用iterator.next()方法之前必须要调用iterator.hasNext()进行检测。若不调用,下一条记录无效,直接调用it.next()会抛出NoSuchElementException异常。 当遍历结束后,由于此时迭代器中遍历的指针已到达底部,若想重新遍历,需要重置迭代器(即重新调用一下iterator()方法)
3.2.3 增强for循环遍历
增强for循环,可以代替iterator迭代器,其本质就是简化的iterator,只能用于遍历集合或数组。基本语法如下:
for(元素类型 元素名:集合或者数组名){
//获得元素进行操作......
}
for(Object o : collection){
System.out.println(o);
}
四、List接口和常用方法
4.1List接口基本介绍
- List接口是Collection接口的子接口
- List集合类中的元素是有序的(即添加顺序和取出的顺序一致)、且元素可以重复存入
- List集合中的元素都有其对应的索引,即支持索引查找
- List接口常用的实现类有:Vector、ArrayList、LinkedList
4.2List接口常用方法
public class ListMethod {
public static void main(String[] args) {
List list = new ArrayList();
list.add("xixixi");
list.add("xixi");
// void add(int index, Object ele):在 index 位置插入 ele 元素
//在 index = 1 的位置插入一个对象
list.add(1, "xixi");
System.out.println("list=" + list);
// boolean addAll(int index, Collection eles):从 index 位置开始将 eles 中的所有元素添加进来
List list2 = new ArrayList();
list2.add("hhh");
list2.add("hh");
list.addAll(1, list2);
System.out.println("list=" + list);
// Object get(int index):获取指定 index 位置的元素
// int indexOf(Object obj):返回 obj 在集合中首次出现的位置
System.out.println(list.indexOf("kkk"));//2
// int lastIndexOf(Object obj):返回 obj 在当前集合中末次出现的位置
list.add("kk");
System.out.println("list=" + list);
System.out.println(list.lastIndexOf("kk"));
// Object remove(int index):移除指定 index 位置的元素,并返回此元素
list.remove(0);
System.out.println("list=" + list);
// Object set(int index, Object ele):设置指定 index 位置的元素为 ele , 相当于是替换. list.set(1, "uuu");
System.out.println("list=" + list);
// List subList(int fromIndex, int toIndex):返回从 fromIndex 到 toIndex 位置的子集合
List returnlist = list.subList(0, 2);
System.out.println("returnlist=" + returnlist);
}
}
五、ArrayList底层结构和源码剖析
5.1注意事项
- ArrayList集合中可以添加多个null(String)
- ArrayList是由数组来实现数据的存储的
- ArrayList基本等同于Vector,除了ArrayList是线程不安全的(执行效率高),在多线程的情况下,建议不使用
5.2底层源码剖析
为了能够降低理解和学习的难度,这一小结利用(总-分-总)的顺序来进行学习,即先总结结论,再深入剖析。
- ArrayList中维护了一个Object类型的数组elementData --> Object[] elementData
- 当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData的容量为0,第一次添加,则扩容到10个容量,如果需要再次扩容,则扩容为原来的1.5倍
- 如果使用的是制定大小的构造器,则初始elementData容量为指定大小,如果需要扩容,则直接扩容到原来的1.5倍
public class ArrayListSource {
public static void main(String[] args) {
//ArrayList list = new ArrayList();
ArrayList list = new ArrayList(8);
//使用 for 给 list 集合添加 1-10 数据
for (int i = 1; i <= 10; i++) {
list.add(i);
}
//使用 for 给 list 集合添加 11-15 数据
for (int i = 11; i <= 15; i++) {
list.add(i);
}
}
}
调用无参构造器情况:
调用有参构造器情况:
六、Vector底层结构和源码剖析
6.1Vector的基本介绍
- vector集合底层也是一个对象数组,protected Object[] elementData
- vector集合是线程同步的,即线程安全,Vector类的操作方法中都带有synchronized关键字,表示支持多线程操作
- 在开发中若考虑到线程同步安全时,优先使用Vector
6.2底层源码剖析
import java.util.Vector;
public class Test {
public static void main(String[] args) {
Vector vector = new Vector();
for (int i = 0; i < 10; i++) {
vector.add(i);
}
vector.add(100);
System.out.println("vector=" + vector);
}
}
由上面剖析可大概清楚,ArrayList和Vector的底层算法其实大同小异
七、Vector和ArrayList的比较
底层结构 | 版本 | 线程安全(同步)效率 | 扩容倍数 | |
---|---|---|---|---|
ArrayList | 可变数组 Object[] | JDK1.2 | 不安全,效率高 | 如果有参构造1.5倍,如果无参,第一次10,第二次还是按1.5扩 |
Vector | 可变数组Object[] | JDK1.0 | 安全,效率低 | 无参,默认10,满后按2倍,若指定大小,则每次按原来2倍扩 |
八、LinkedList底层结构和源码剖析
8.1LinkedList的介绍
- LinkedList底层实现了双向链表和双端队列
- 可以添加任何元素(可重复),包含null
- 线程不安全
8.2LinkedList的底层操作机制
- LinkedList底层维护了一条双向链表
- LinkedList中维护了两个字段first和last分别指向首结点和尾结点
- 每个结点(Node对象-->内部类),里面又维护了prev、next、item三个字段,其中通过prev指向前一个、通过next指向后一个结点,实现双向链表
- 因为LinkedList的元素删除添加不是通过数组来实现的,所以相对效率比较高
8.3底层源码剖析
public class Test {
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
linkedList.add(1);
linkedList.add(2);
linkedList.add(3);
System.out.println("linkedList=" + linkedList);
linkedList.remove(); // 这里默认删除的是第一个结点
}
}
九、ArrayList和LinkedList的比较
底层结构 | 增删效率 | 改查效率 | |
---|---|---|---|
ArrayList | 可变数组 | 效率低 | 较高 |
LinkedList | 双向链表 | 较高,通过链表追加 | 较低 |
十、Set接口和常用方法
10.1使用Set接口注意事项:
- 添加在Set集合中的元素是无序的(即存入和取出顺序不一致)
- Set集合中不允许存在相同的元素,同理也最多能包含一个null
- JDK API中Set接口的实现类有(标记的为常用实现类):
10.2Set接口常用的方法
由于Set接口为Collection接口的子接口,所以常用的方法基本和Collection一样,API中接口方法如下:
以上就是Set接口的方法,详情请大家自行查阅API文档即可,就不再占用大篇幅进行示例了。
10.3 Set接口的遍历方式
同样由于Set接口是Iterable的子接口,同等会拥有Iterator()方法获得一个迭代器进行集合的遍历,与他拥有同等本质的增强For循环也是可以使用的,但要注意的一点是,List接口下的集合类都是支持利用索引方式来获取元素的,相反Set接口是不允许的。
十一*、HashSet底层结构和源码剖析(重难点)
11.1HashSet介绍
- 首先HashSet实现了Set接口
- HashSet实际上是HashMap
- 可以存放null值,但仅能有一个
- HashSet不保证元素是有序的,取决于Hash后,再确定再底层table数组的索引(即不保证存放元素的顺序于取出一致)
- 由于为Set接口实现类,所以同样不能存放相同元素
11.2HashSet底层源码剖析
HashSet底层其实是HashMap,而HashMap底层其实是利用数组+链表+红黑树构成
- 添加一个元素时,先会获取元素的哈希值(hashCode())
- 进而对获取的哈希值进行特殊运算处理(异或运算和位运算)得到一个索引值,该值即为要存放在哈希表中的位置号
- 如果发现该索引位上没有存放其他元素,则直接放入该位置,如果该位置已经有其他元素,则需要进一步通过equals判断,如果相等,则不添加,否则以链表的方式进行添加在尾部
- 在JDK8版本中,如果一条链表的元素个数到达8个,并且table数组的大小大于等于64,就会对链表进行树化(红黑树)
public class Test {
public static void main(String[] args) {
Set hashSet = new HashSet();
hashSet.add(null);
hashSet.add(null);
System.out.println("hashSet=" + hashSet);
}
}
下面是扩容和添加的核心源码,我们一步步来剖析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
11.3LinkedHashSet底层结构和源码剖析
11.3.1LinkedHashSet说明
- LinkedHashSet是HashSet的子类
- LinkedHashSet底层是一个LinkedHashMap,底层维护的是一个数组+双向链表
- LinkedHashSet根据元素的hashCode值来决定元素在数组的索引,同时使用双向链表来连接节点,使得元素看起来是以插入的顺序保存的
- 不允许添加重复元素
下面是LinkedHashSet的底层存储结构图
- 在LinkedHashSet中维护了一个hash表和双向链表(LinkedHashSet中有head和tail字段)
- 每一个节点(Node)对象里面都含有字段before和after,这样可以形成双向链表
- 同样在添加一个元素时,先求hash值,在求索引值,确定该元素在table数组中的位置,然后将添加的元素加入到双向链表中(同样要经过hashCode和equals)的检验
在一个案例中我们创建一个LinkedHashSet,并加入元素后,实现了有序的存储,对象的字段如下:
十二、Map接口实现类的特点
12.1Map底层存储说明
- Map与Collection并列存在。用于保存具有映射关系的数据(Key-Value)
- Map中的key和Value可以是任意的数据类型,这一对K-V会封装到HashMap$Node对象中去
- Map中的Key不允许重复,从HashSet的本质就同理可看出
- Map中Value可以重复
- 常用String类作为Key
- K-V存在单向一对一的关系,通过K可以找到V
Map存放数据的K-V的图解如下:
(图解来自韩顺平老师)
由上图可以看出,一对K-V是放在一个HashMap$Node中的,而Node这一HashMap的内部类是实现了Entry接口的。
12.2Map接口的遍历方法
public static void main(String[] args) {
Map map = new HashMap();
map.put();
map.put();
map.put();
map.put();
map.put();
map.put();
//第一组: 先取出 所有的 Key , 通过 Key 取出对应的 Value
Set keyset = map.keySet();
//(1) 增强 for
System.out.println("-----第一种方式-------");
for (Object key : keyset) {
System.out.println(key + "-" + map.get(key));
}
//(2) 迭代器
System.out.println("----第二种方式--------");
Iterator iterator = keyset.iterator();
while (iterator.hasNext()) {
Object key = iterator.next();
System.out.println(key + "-" + map.get(key));
}
//第二组: 把所有的 values 取出
Collection values = map.values();
//这里可以使用所有的 Collections 使用的遍历方法
//(1) 增强 for
System.out.println("---取出所有的 value 增强 for----");
for (Object value : values) {
System.out.println(value);
}
//(2) 迭代器
System.out.println("---取出所有的 value 迭代器----");
Iterator iterator2 = values.iterator();
while (iterator2.hasNext()) {
Object value = iterator2.next();
System.out.println(value);
}
//第三组: 通过 EntrySet 来获取 k-v
Set entrySet = map.entrySet();// EntrySet<Map.Entry<K,V>>
//(1) 增强 for
System.out.println("----使用 EntrySet 的 for 增强(第 3 种)----");
for (Object entry : entrySet) {
//将 entry 转成 Map.Entry
Map.Entry m = (Map.Entry) entry;
System.out.println(m.getKey() + "-" + m.getValue());
}
//(2) 迭代器
System.out.println("----使用 EntrySet 的 迭代器(第 4 种)----");
Iterator iterator3 = entrySet.iterator();
while (iterator3.hasNext()) {
Object entry = iterator3.next();
//System.out.println(next.getClass());//HashMap$Node -实现-> Map.Entry (getKey,getValue)
//向下转型 Map.Entry
Map.Entry m = (Map.Entry) entry;
System.out.println(m.getKey() + "-" + m.getValue());
}
十三、HashMap底层结构和源码剖析
13.1结论
- Map接口的常用实现类:HashMap、HashTable、Properties
- HashMap是以key-value对的方式来存储数据的
- 如果添加相同的Key,会覆盖原来的Val
- 与HashSet一样,数据存储是无序的
- HashMap没有实现线程同步,不安全,没有互斥处理
13.2底层源码剖析
(图解来自韩顺平老师)
- HashMap底层维护了Node类型的数组table,默认null
- 当创建对象时,将加载因子(loadFactor)初始化了0.75
- 当添加k-v时,通过Key的哈希值得到在table处对应的索引,然后判断该索引处是否有元素,如果没有则直接添加,如果有,则继续通过判断该元素的Key与准备加入的Key是否相等,如果相等,则会替换旧Key,不相等则需要判断是利用树的方式添加还是链表方式添加,当添加时发现到达扩容量临界值时,则需要扩容
- 在JDK8中,如果一条链表的元素超过8个并且table容量大于等于64就会进行树化(红黑树)
下面是源码剖析,由于HashSet的底层是HashMap,所以在这一小节就不重复赘述,只例举异同点和重点
if (e.hash == hash && //如果在循环比较过程中,发现有相同,就 break,就只是替换 value
((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; //替换,key 对应 value
afterNodeAccess(e);
return oldValue;
}
十四、HashTable介绍
14.1注意事项:
- 存放的元素是K-V
- hashtable的键值都不能为null,否则会抛出空指针异常
- 用法于HashMap一致
- 线程安全
14.2 HashMap与HashTable比较
Version | 线程安全 | 效率 | 允许null键null值 | |
HashMap | 1.2 | 不安全 | 高 | 允许 |
HashTable | 1.0 | 安全 | 低 | 不允许 |
十五、Properties介绍
- Properties类继承自HashTable类并且实现了Map接口,也是使用K-V来存储数据
- 与HashTable相似
- (常用)Properties常用于xxx.properties配置文件中,进行读取或者修改
十六、集合用法和选择总结
在日常开发中,对于不同集合的选用主要取决于不同的业务逻辑需要,具体选择思路如下:
- 先判断存储的类型(单列或者双列)
- 单列:Collection接口(重复:list(增删多:LinkedList 改查多: ArrayList) 不重复: Set(无序: HashSet 排序:TreeSet 插入和取出顺序一致:LinkedHashSet) )
- 双列:Map接口(键无序:HashMap 键排序:TreeMap 键插入和取出顺序一致:LinkedHashMap 读取文件:Properties)
十七、Collection工具类
(部分)
自己查询API即可
十八、总结
天啊!!!!终于结束了,麻了吧,本章单纯文本就超过了一万字,我也算重复学习了一遍,真的从学习到写帖子花了我将近两个星期的时间,终于熬过来了。集合这一章真的太重要了,太重要了,太重要了,重要的话说三遍,有工作经验的小伙伴可能知道,集合是工作面试中基本必问的知识点,使用它固然没有多大难度,但是底层原理才是面试中会遇到的,我们不仅仅是为了通过考核而去学习它的底层,更是在阅读高质量代码的同时提升自己,认识自己的不足,在以后遇到使用集合方面出现错误的时候,能够更加快速解决问题以及高效地使用集合,本章部分图有所借鉴。
我亦无他,唯手熟尔