Java - 集合
一、集合概述
集合是java中提供的一种容器,可以用来存储多个数据
集合和数组的区别
- 数组的长度是固定的,集合的长度是可变的
- 数组中可以存储基本数据类型值,集合中只能存储对象
集合主要分为两大系列:Collection和Map,Collection表示一组对象,Map表示一组映射关系或键值对
二、Collection
1、Iterator迭代器
(1)Iterator接口
JDK专门提供了一个接口
java.util.Iterator
,主要用于迭代访问(即遍历)Collection
中的元素,因此Iterator
对象也被称为迭代器- 迭代:即Collection集合元素的通用获取方式。在取元素之前先判断集合中是否有元素,如果有,则取出元素,然后继续判断,直到将集合中的元素全部取出
集合获取迭代器的方法:
public Iterator iterator()
Iterator接口的常用方法:
public E next()
:返回迭代的下一个元素public boolean hasNext()
:如果仍有元素可以迭代,则返回 truevoid remove()
:删除元素
(2)迭代器的实现原理
Iterator迭代器对象在遍历集合时,内部采用指针的方式来跟踪集合中的元素
- 在调用Iterator的next方法之前,迭代器指向第一个元素
- 当第一次调用迭代器的next方法时,返回第一个元素,然后迭代器的索引会向后移动一位,指向第二个元素
- 当再次调用next方法时,返回第二个元素,然后迭代器的索引会再向后移动一位,指向第三个元素,依此类推
- 直到hasNext方法返回false,表示到达了集合的末尾,终止对元素的遍历
(3)Iterator迭代器的快速失败(fail-fast)机制
Iterator迭代器的快速失败(fail-fast)机制:面对并发的修改,迭代器很快就完全失败(如果在Iterator、ListIterator迭代器创建后的任意时间从结构上修改了集合(通过迭代器自身的 remove 或 add 方法之外的任何其他方式),则迭代器将抛出 ConcurrentModificationException)
快速失败(fail-fast)机制设计原因:迭代器代表集合中某个元素的位置,内部会存储某些能够代表该位置的信息。当集合发生改变时,该信息的含义可能会发生变化,这时操作迭代器就可能会造成不可预料的事情。因此,果断抛异常阻止,是最好的方法
实现快速失败(fail-fast)机制原理:
- 在ArrayList等集合类中都有一个modCount变量,用来记录集合的结构被修改的次数
- 当给集合添加和删除操作时,会导致modCount++
- 当用Iterator迭代器遍历集合,创建集合迭代器的对象时,用一个变量记录当前集合的modCount,并且在迭代器每次next()迭代元素时,都要检查变量与modCount是否相等,如果不相等,那么说明调用了Iterator迭代器以外的Collection方法修改了集合的结构,使得modCount++,此时迭代器就会抛出ConcurrentModificationException异常
2、Collection接口
Collection表示一组对象,这些对象也称为collection的元素
- 有的collection允许有重复的元素,有的不允许
- 有的collection是有序的,有的是无序的
JDK不提供此接口的任何直接实现,它提供更具体的子接口实现。此接口通常用来传递collection,并在需要最大普遍性的地方操作这些collection
Collection是所有单列集合的父接口,因此在Collection中定义了单列集合(List和Set)通用的方法,这些方法可用于操作所有的单列集合
3、List接口
(1)List接口
List接口:
java.util.List
接口继承自Collection
接口,是单列集合的一个重要分支,习惯性地会将实现了List
接口的对象称为List集合List接口特点:
- 线性存储:List集合所有的元素是以一种线性方式进行存储的
- 元素有序:即元素的存入顺序和取出顺序有保证
- 有索引:通过索引就可以精确的操作集合中的元素(与数组的索引是一个道理)
- 可重复:集合中可以有重复的元素,通过元素的equals方法,来比较是否为重复的元素。
List作为Collection集合的子接口,不但继承了Collection接口中的全部方法,而且还增加了一些根据元素索引来操作集合的特有方法
(2)List接口的实现类
List接口的实现类有很多,常见的有:
ArrayList
:动态数组(线程不安全,效率高)Vector
:动态数组(线程安全,效率低)LinkedList
:双向链表
动态数组的特点
- 逻辑结构:线性结构
- 物理结构特点:连续的存储空间,一次申请一大段连续的空间,一旦申请到,内存就固定了
- 存储特点:所有数据存储在这个连续的空间中,数组中的每一个元素都是一个具体的数据(或对象),所有数据都紧密排布,不能有间隔。
- ArrayList与Vector的区别
ArrayList | Vector | |
---|---|---|
扩容机制 | 1.5倍 | 2倍 |
初始化容量 | JDK1.6及之前:默认10JDK1.7之后:默认0,添加后为10 | 默认10 |
迭代器 | Iterator和ListIterator迭代器,支持快速失败 | 支持Enumeration迭代器,不支持快速失败 |
链表的特点
- 逻辑结构:线性结构
- 物理结构:不要求连续的存储空间
- 存储特点:数据必须封装到“结点”中,结点包含多个数据项,数据值只是其中的一个数据项,其他的数据项用来记录与之有关的结点的地址
链表与动态数组的区别
- 动态数组访问元素的效率高,操作元素效率低
- 链表访问元素效率低,操作元素效率高
① Vector部分源码分析
1 | public Vector() { |
② ArrayList部分源码分析
1 | public ArrayList() { |
③ LinkedList源码分析
1 | int size = 0; |
4、Set接口
(1)Set接口
Set接口是Collection的子接口,set接口没有提供额外的方法,但是比
Collection
接口更加严格了Set接口特点:
- 不可重复:如果试把两个相同的元素加入同一个 Set 集合中,则添加操作失败
(2)Set接口的实现类
Set的常用实现类
HashSet
:HashSet 是 Set 接口的典型实现
- 存储结构:哈希表(底层的实现是
java.util.HashMap
支持,HashMap的底层物理实现是一个哈希表) - HashSet 按 Hash 算法来存储集合中的元素,因此具有很好的存取和查找性能
- 存储到HashSet的元素要重写hashCode和equals方法(HashSet 集合判断两个元素相等的标准:两个对象通过 hashCode() 方法比较相等,并且两个对象的 equals() 方法返回值也相等)
- 存储结构:哈希表(底层的实现是
LinkedHashSet
:java.util.LinkedHashSet
,是HashSet的子类,它在HashSet的基础上,在结点中增加before和after两个属性,维护了结点的前后添加顺序
- 存储结构:链表和哈希表组合
- LinkedHashSet插入性能略低于 HashSet,但在迭代访问Set里的全部元素时有很好的性能
TreeSet
- TreeSet里面维护了一个TreeMap,底层是基于红黑树实现的
- 特点
- 不允许重复
- 实现排序:
- 自然排序(实现Comparable接口,重写compareTo)
- 定制排序(实现Comparator接口,重写compare)
Set底层实现原理:Set的内部实现其实是一个Map。即HashSet的内部实现是一个HashMap,TreeSet的内部实现是一个TreeMap,LinkedHashSet的内部实现是一个LinkedHashMap。把添加到Set中的元素作为内部实现map的key,然后用一个常量对象PRESENT对象,作为value
① HashSet源码
1 | public HashSet() { |
② LinkedHashSet源码
1 | public LinkedHashSet(int initialCapacity, float loadFactor) { |
③ TreeSet源码
1 | public TreeSet() { |
三、Map
1、Map接口
Java提供了专门的集合类用来存放映射对象关系的对象,即
java.util.Map<K,V>
接口Collection
中的集合称为单列集合,Map
中的集合称为双列集合Map
中的集合不能包含重复的键,值可以重复;每个键只能对应一个值
2、Map集合的遍历
(1)分开遍历
- 单独遍历所有key
- 单独遍历所有value
(2)成对遍历
- 遍历的是映射关系Map.Entry类型的对象,Map.Entry是Map接口的内部接口。每一种Map内部有自己的Map.Entry的实现类。在Map中存储数据,实际上是将Key—->value的数据存储在Map.Entry接口的实例中,再在Map集合中插入Map.Entry的实例化对象
3、Map的实现类
Map接口的常用实现类
HashMap
:使用频率最高的实现类
- 底层原理:哈希表
- 存储到HashSet的元素要重写hashCode和equals方法(判断两个 key 相等的标准:两个key的hashCode值相等,并且equals() 方法也返回true)
Hashtable
:同HashMap(线程安全,且不允许使用null值和null键)LinkedHashMap
:HashMap的子类,与HashMap的不同之处在于,后者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是将键插入到映射中的顺序(插入顺序)TreeMap
:基于红黑树(Red-Black tree)的 NavigableMap 实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序,具体取决于使用的构造方法
附录1:二叉树
1、二叉树的基本概念
- 二叉树(Binary tree)是树形结构的一个重要类型。二叉树特点是每个结点最多只能有两棵子树,且有左右之分。许多实际问题抽象出来的数据结构往往是二叉树形式,二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要
2、二叉树的遍历
- 前序遍历:中左右(根左右)ABDHIECFG
- 中序遍历:左中右(左根右)HDIBEAFCG
- 后序遍历:左右中(左右根)HIDEBFGCA
3、经典二叉树
1、满二叉树: 除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树。 第n层的结点数是2的n-1次方,总的结点个数是2的n次方-1
2、完全二叉树: 叶结点只能出现在最底层的两层,且最底层叶结点均处于次底层叶结点的左侧
3、平衡二叉树:平衡二叉树(Self-balancing binary search tree)又被称为AVL树(有别于AVL算法),且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树,但不要求非叶节点都有两个子结点 。平衡二叉树的常用实现方法有红黑树、AVL、替罪羊树、Treap、伸展树等
例如红黑树的要求:
- 节点是红色或者黑色
- 根节点是黑色
- 每个叶子的节点都是黑色的空节点(NULL)
- 每个红色节点的两个子节点都是黑色的
- 从任意节点到其每个叶子的所有路径都包含相同的黑色节点数量。
当我们插入或删除节点时,可能会破坏已有的红黑树,使得它不满足以上5个要求,那么此时就需要进行处理:
- recolor :将某个节点变红或变黑
- rotation :将红黑树某些结点分支进行旋转(左旋或右旋)
4、二叉树的实现
1 | public class BinaryTree<E>{ |
附录2:哈希表(了解)
1、hashCode值
- hash算法是一种可以从任何数据中提取出其“指纹”的数据摘要算法,它将任意大小的数据映射到一个固定大小的序列上,这个序列被称为hash code、数据摘要或者指纹。
- 比较出名的hash算法有MD5、SHA。hash是具有唯一性且不可逆的,唯一性是指相同的“对象”产生的hash code永远是一样的
2、哈希表的物理结构
HashMap和Hashtable是散列表,其中维护了一个长度为2的幂次方的Entry类型的数组table,数组的每一个元素被称为一个桶(bucket),你添加的映射关系(key,value)最终都被封装为一个Map.Entry类型的对象,放到了某个table[index]桶中。使用数组的目的是查询和添加的效率高,可以根据索引直接定位到某个table[index]
(1)数组元素类型:Map.Entry
JDK1.7:
- 映射关系被封装为HashMap.Entry类型,而这个类型实现了Map.Entry接口
- 观察HashMap.Entry类型是个结点类型,即table[index]下的映射关系可能串起来一个链表。因此我们把table[index]称为“桶bucket”
1 | public class HashMap<K,V>{ |
JDK1.8:
- 映射关系被封装为HashMap.Node类型或HashMap.TreeNode类型,它俩都直接或间接的实现了Map.Entry接口
- 存储到table数组的可能是Node结点对象,也可能是TreeNode结点对象,即table[index]下的映射关系可能串起来一个链表或一棵红黑树
1 | public class HashMap<K,V>{ |
(2)HashMap决定某个映射关系存在的桶
因为hash值是一个整数,而数组的长度也是一个整数,有两种思路:
① hash 值 % table.length
会得到一个[0,table.length-1]范围的值,正好是下标范围,但是用%运算效率没有位运算符&高
② hash 值 & (table.length-1)
,任何数 & (table.length-1)的结果也一定在[0, table.length-1]范围
1 | hashCode值是 ? |
JDK1.7:
1 | static int indexFor(int h, int length) { |
JDK1.8:
1 | final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { |
(3)数组的长度始终是2的n次幂
- table数组的默认初始化长度:
1 | static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; |
- 如果手动指定的table长度不是2的n次幂,会通过如下方法纠正为2的n次幂
1 | private static int roundUpToPowerOf2(int number) { |
- 如果数组不够,扩容了还是2的n次幂,因为每次数组扩容为原来的2倍
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
- 保持2的n次幂原因:如果数组的长度为2的n次幂,那么table.length-1的二进制就是一个高位全是0,低位全是1的数字,这样才能保证每一个下标位置都有机会被用到
1 | hashCode值是 ? |
(4)hash是hashCode的再运算
- 不管是JDK1.7还是JDK1.8中,都不是直接用key的hashCode值直接与table.length-1计算求下标的,而是先对key的hashCode值进行了一个运算,JDK1.7和JDK1.8关于hash()的实现代码不一样,但是不管怎么样都是为了提高hash code值与 (table.length-1)的按位与完的结果,尽量的均匀分布。虽然算法不同,但是思路都是将hashCode值的高位二进制与低位二进制值进行了异或,然高位二进制参与到index的计算中
1 | final int hash(Object k) { |
- 将hashCode值的二进制的高位参与到index计算的原因:因为一个HashMap的table数组一般不会特别大,至少在不断扩容之前,那么table.length-1的大部分高位都是0,直接用hashCode和table.length-1进行&运算的话,就会导致总是只有最低的几位是有效的,那么就算hashCode()实现的再好也难以避免发生碰撞,这时让高位参与进来的意义就体现出来了。它对hashcode的低位添加了随机性并且混合了高位的部分特征,显著减少了碰撞冲突的发生
(5)解决[index]冲突问题
虽然从设计hashCode()到上面HashMap的hash()函数,都尽量减少冲突,但是仍然存在两个不同的对象返回的hashCode值相同,或者hashCode值就算不同,通过hash()函数计算后,得到的index也会存在大量的相同,因此key分布完全均匀的情况是不存在的
解决碰撞冲突
- JDK1.8之前使用:数组+链表的结构
- JDK1.8之后使用:数组+链表/红黑树的结构
- 即hash相同或hash&(table.lengt-1)的值相同,那么就存入同一个“桶”table[index]中,使用链表或红黑树连接起来
JDK1.8红黑树和链表共存原因
- 当冲突比较严重时,table[index]下面的链表就会很长,那么会导致查找效率大大降低,而如果此时选用二叉树可以大大提高查询效率
- 但是二叉树的结构又过于复杂,如果结点个数比较少的时候,那么选择链表反而更简单
(6)树化和反树化
1 | static final int TREEIFY_THRESHOLD = 8;//树化阈值 |
当某table[index]下的链表的结点个数达到8,并且table.length>=64,那么如果新Entry对象还添加到该table[index]中,那么就会将table[index]的链表进行树化
当某table[index]下的红黑树结点个数少于6个,此时,
- 当继续删除table[index]下的树结点,最后这个根结点的左右结点有null,或根结点的左结点的左结点为null,会反树化
- 当重新添加新的映射关系到map中,导致了map重新扩容了,这个时候如果table[index]下面还是小于等于6的个数,那么会反树化
1 | package com.atguigu.map; |
3、JDK1.7的put方法源码分析
(1)关键常量和变量
1 | int DEFAULT_INITIAL_CAPACITY = 1 << 4;//16 目的是体现2的n次方 |
负载因子的值
- 如果太大,threshold就会很大,那么如果冲突比较严重的话,就会导致table[index]下面的结点个数很多,影响效率
- 如果太小,threshold就会很小,那么数组扩容的频率就会提高,数组的使用率也会降低,那么会造成空间的浪费
1 | public HashMap() { |
(2)put(key,value)实现步骤
- 当第一次添加映射关系时,数组初始化为一个长度为16的HashMap$Entry的数组,这个HashMap$Entry类型是实现了java.util.Map.Entry接口
- 特殊考虑:如果key为null,index直接是[0],hash也是0
- 如果key不为null,在计算index之前,会对key的hashCode()值,做一个hash(key)再次哈希的运算,这样可以使得Entry对象更加散列的存储到table中
- 计算index = table.length-1 & hash;
- 如果table[index]下面,已经有映射关系的key与我要添加的新的映射关系的key相同了,会用新的value替换旧的value
- 如果没有相同的,会把新的映射关系添加到链表的头,原来table[index]下面的Entry对象连接到新的映射关系的next中
- 添加之前先判断if(size >= threshold && table[index]!=null)如果该条件为true,会扩容
1 | if(size >= threshold && table[index]!=null){ |
- size++
(3)get(key)实现步骤
- 计算key的hash值,用这个方法hash(key)
- 找index = table.length-1 & hash;
- 如果table[index]不为空,那么就挨个比较哪个Entry的key与它相同,就返回它的value
(4)remove(key)实现步骤
- 计算key的hash值,用这个方法hash(key)
- 找index = table.length-1 & hash;
- 如果table[index]不为空,那么就挨个比较哪个Entry的key与它相同,就删除它,把它前面的Entry的next的值修改为被删除Entry的next
4、JDK1.8的put方法源码分析
(1)关键常量和变量
1 | 几个常量和变量: |
(2)添加实现步骤
先计算key的hash值,如果key是null,hash值就是0,如果为null,使用(h = key.hashCode()) ^ (h >>> 16)得到hash值
如果table是空的,先初始化table数组
通过hash值计算存储的索引位置index = hash & (table.length-1)
如果table[index]==null,那么直接创建一个Node结点存储到table[index]中即可
如果table[index]!=null
- 判断table[index]的根结点的key是否与新的key“相同”(hash值相同并且(满足key的地址相同或key的equals返回true)),如果是那么用e记录这个根结点
- 如果table[index]的根结点的key与新的key“不相同”,而且table[index]是一个TreeNode结点,说明table[index]下是一棵红黑树,如果该树的某个结点的key与新的key“相同”(hash值相同并且(满足key的地址相同或key的equals返回true)),那么用e记录这个相同的结点,否则将(key,value)封装为一个TreeNode结点,连接到红黑树中
- 如果table[index]的根结点的key与新的key“不相同”,并且table[index]不是一个TreeNode结点,说明table[index]下是一个链表,如果该链表中的某个结点的key与新的key“相同”,那么用e记录这个相同的结点,否则将新的映射关系封装为一个Node结点直接链接到链表尾部,并且判断table[index]下结点个数达到TREEIFY_THRESHOLD(8)个,如果table[index]下结点个数已经达到,那么再判断table.length是否达到MIN_TREEIFY_CAPACITY(64),如果没达到,那么先扩容,扩容会导致所有元素重新计算index,并调整位置,如果table[index]下结点个数已经达到TREEIFY_THRESHOLD(8)个并table.length也已经达到MIN_TREEIFY_CAPACITY(64),那么会将该链表转成一棵自平衡的红黑树
如果在table[index]下找到了新的key“相同”的结点,即e不为空,那么用新的value替换原来的value,并返回旧的value,结束put方法
如果新增结点而不是替换,那么size++,并且还要重新判断size是否达到threshold阈值,如果达到,还要扩容
1 | if(size > threshold ){ |
(3)remove(key)实现步骤
- 计算key的hash值,用这个方法hash(key)
- 找index = table.length-1 & hash;
- 如果table[index]不为空,那么就挨个比较哪个Entry的key与它相同,就删除它,把它前面的Entry的next的值修改为被删除Entry的next
- 如果table[index]下面原来是红黑树,结点删除后,个数小于等于6,会把红黑树变为链表
5、映射关系的key是否可以被修改
映射关系存储到HashMap中会存储key的hash值,这样就不用在每次查找时重新计算每一个Entry或Node(TreeNode)的hash值了,因此如果已经put到Map中的映射关系,再修改key的属性,而这个属性又参与hashcode值的计算,那么会导致匹配不上