Java集合
Java集合
Java 集合概览
Java 集合, 也叫作容器,主要是由两大接口派生而来:
-
一个是
Collection
接口,主要用于存放单一元素;对于Collection
接口,下面又有三个主要的子接口:List
、Set
和Queue
。 -
另一个是
Map
接口,主要用于存放键值对。
说说 List, Set, Queue, Map 四者的区别?
List
(对付顺序的好帮手): 存储的元素是有序的、可重复的。Set
(注重独一无二的性质): 存储的元素是无序的、不可重复的。Queue
(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。Map
(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
集合框架底层数据结构总结
先来看一下 Collection
接口下面的集合。
-
List
-
Arraylist
:Object[]
数组 -
Vector
:Object[]
数组 -
LinkedList
: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
-
-
Set
-
HashSet
(无序,唯一): 基于HashMap
实现的,底层采用HashMap
来保存元素 -
LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。有点类似于我们之前说的LinkedHashMap
其内部是基于HashMap
实现一样,不过还是有一点点区别的 -
TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)
-
-
Queue
-
PriorityQueue
:Object[]
数组来实现二叉堆 -
ArrayQueue
:Object[]
数组 + 双指针
-
再来看看 Map
接口下面的集合
-
Map
-
HashMap
: JDK1.8 之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间 -
LinkedHashMap
:LinkedHashMap
继承自HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》 (opens new window) -
Hashtable
: 数组+链表组成的,数组是Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的 -
TreeMap
: 红黑树(自平衡的排序二叉树)
-
Collection 子接口之 List
Arraylist 和 Vector 的区别?
ArrayList
是List
的主要实现类,底层使用Object[ ]
存储,适用于频繁的查找工作,线程不安全 ;Vector
是List
的古老实现类,底层使用Object[ ]
存储,线程安全的。
Arraylist 与 LinkedList 区别?
-
是否保证线程安全:
ArrayList
和LinkedList
都是不同步的,也就是不保证线程安全; -
底层数据结构:
Arraylist
底层使用的是Object
数组;LinkedList
底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环) -
插入和删除是否受元素位置的影响:
①
ArrayList
采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。②
LinkedList
采用链表存储,所以对于add(E e)
方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i
插入和删除元素的话((add(int index, E element)
) 时间复杂度近似为o(n),因为需要先移动到指定位置再插入。 -
是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。 -
内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而LinkedList
的空间花费则体现在它的每一个元素都需要消耗比ArrayList
更多的空间(因为要存放直接后继和直接前驱以及数据)。
说一说 ArrayList 的扩容机制吧
-
jdk 7
-
以无参数构造方法创建
ArrayList
时,底层创建了长度是10的Object[]数组elementData。 -
当添加元素导致底层elementData数组容量不够,则扩容。
-
默认情况下,扩容为原来的容量的1.5倍,然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,如果新容量大于
MAX_ARRAY_SIZE
,进入hugeCapacity()
方法来比较minCapacity
和MAX_ARRAY_SIZE
,如果minCapacity
大于最大容量,则新容量则为Integer.MAX_VALUE
,否则,新容量大小则为MAX_ARRAY_SIZE
即为Integer.MAX_VALUE - 8
,最后调用Arrays.copyOf(elementData, newCapacity)
将原有数组中的数据复制到新的数组中。
-
建议开发中使用带参的构造器:
ArrayList list = new ArrayList(int capacity)
- jdk 8
- 以无参数构造方法创建
ArrayList
时,底层Object[] elementData初始化为空数组{}。 - 当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
- 后续的添加和扩容操作与jdk 7 无异。
- 以无参数构造方法创建
小结:jdk7中的ArrayList的对象的创建类似于单例的饿汉式,而jdk8中的ArrayList的对象的创建类似于单例的懒汉式,延迟了数组的创建,节省内存。
Collection 子接口之 Set
Set接口:存储无序的、不可重复的数据
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
HashSet
、LinkedHashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。
无序性和不可重复性的含义是什么
1、什么是无序性?无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
2、什么是不可重复性?不可重复性是指添加的元素按照 equals()判断是否相同 ,需要同时重写 equals()
方法和 HashCode()
方法。
HashSet中,equals与hashCode之间的关系?
equals和hashCode这两个方法都是从object类中继承过来的,equals主要用于判断对象的内存地址引用是否是同一个地址;hashCode根据定义的哈希规则将对象的内存地址转换为一个哈希码。HashSet中存储的元素是不能重复的,主要通过hashCode与equals两个方法来判断存储的对象是否相同:
- 如果两个对象的hashCode值不同,说明两个对象不相同。
- 如果两个对象的hashCode值相同,接着会调用对象的equals方法,如果equlas方法的返回结果为 true,那么说明两个对象相同,否则不相同。
Collection 子接口之 Queue
Queue 与 Deque 的区别
Queue
是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue
扩展了 Collection
的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队尾 | add(E e) | offer(E e) |
删除队首 | remove() | poll() |
查询队首元素 | element() | peek() |
Deque
是双端队列,在队列的两端均可以插入或删除元素。
Deque
扩展了 Queue
的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 | 抛出异常 | 返回特殊值 |
---|---|---|
插入队首 | addFirst(E e) | offerFirst(E e) |
插入队尾 | addLast(E e) | offerLast(E e) |
删除队首 | removeFirst() | pollFirst() |
删除队尾 | removeLast() | pollLast() |
查询队首元素 | getFirst() | peekFirst() |
查询队尾元素 | getLast() | peekLast() |
事实上,Deque
还提供有 push()
和 pop()
等其他方法,可用于模拟栈。
说一说 PriorityQueue
PriorityQueue
是在 JDK1.5 中被引入的, 其与 Queue
的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的一些要点:
PriorityQueue
利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue
通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue
是非线程安全的,且不支持存储NULL
和non-comparable
的对象。PriorityQueue
默认是小顶堆,但可以接收一个Comparator
作为构造参数,从而来自定义元素优先级的先后。
Map 接口
HashMap 和 Hashtable 的区别
-
线程是否安全:
HashMap
是非线程安全的,Hashtable
是线程安全的,因为Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap
); -
效率: 因为线程安全的问题,
HashMap
要比Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它; -
对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。 -
初始容量大小和每次扩充容量大小的不同 :
① 创建时如果不指定容量初始值,
Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么
Hashtable
会直接使用给定的大小,而HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证)。也就是说HashMap
总是使用 2 的幂作为哈希表的大小。 -
底层数据结构: JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
-
底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; -
实现线程安全的方式(重要):
① 在 JDK1.7 的时候,
ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段(Segment
),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后 对synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本;②
Hashtable
(同一把锁):使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
HashMap 和 HashSet 区别
如果你看过 HashSet
源码的话就应该知道:HashSet
底层就是基于 HashMap
实现的。(HashSet
的源码非常非常少,因为除了 clone()
、writeObject()
、readObject()
是 HashSet
自己不得不实现之外,其他方法都是直接调用 HashMap
中的方法)。
HashMap | HashSet |
---|---|
实现了 Map 接口 | 实现 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put() 向 map 中添加元素 | 调用 add() 方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 hashcode | HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals() 方法用来判断对象的相等性 |
HashMap的底层实现原理
JDK 7及以前版本:HashMap是数组+链表实现
JDK 8版本发布以后:HashMap是数组+链表/红黑树实现。
key的设计要求
- HashMap的key可以为null,但Map的其他实现则不然
- 作为key的对象,必须实现
hashCode
和equals
,并且**key的内容不能修改 **(不可变) - key 的
hashCode
应该有良好的散列性
索引计算
- 计算对象的
hashCode()
- 再调用
HashMap
的hash()
方法进行二次哈希- 二次
hash()
是为了综合高位数据,让哈希分布更为均匀
- 二次
- 最后,hash & (capacity - 1) 得到索引,其中
capacity
必须是2的n次幂,这里相当于mod( capacity )
数组容量为何是2的n次幂
- 计算索引时效率更高:如果是2的n次幂可以使用位与运算代替取模。
- 扩容时重新计算索引效率更高:hash & oldCap == 0的元素留在原来位置,否则新位置 = 旧位置 + oldCap
put与扩容
put流程
- HashMap是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)。计算hashCode、二次hash、& (capacity - 1)
- 如果桶下标还没人占用,创建Node占位返回
- 如果桶下标已经有人占用
- 已经是TreeNode,走红黑树的添加或更新逻辑
- 是普通Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容(添加元素之后检查,然后再扩容)
1.7与1.8的区别
- 1.7 在实例化以后,底层创建了长度是16的一维数组
Entry[] table
。1.8new HashMap()
底层没有创建一个长度为16的数组,首次使用才创建Node[] table
数组。 - 链表插入节点时,1.7 是头插法,1.8 是尾插法
- 1.7 是大于等于阈值(
loadFactor * 数组总长度
)且没有空位时才扩容(容量×2);而1.8是大于阈值就扩容,并且链表长度超过8时,会先尝试扩容来减少链表长度,如果数组容量已经>=64,会转化成红黑树。 - 1.8在扩容计算Node索引时,会优化
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yiSkcy5k-1646466040175)(E:\Typora\imgs\image-20220225164756916.png)]
扩容(加载)因子为何默认是0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长,影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
树化与退化
树化规则
- 当链表长度超过树化阈值 8时,先尝试扩容来减少链表长度,如果数组容量已经>=64,才会进行树化
退化规则
- 情况1:在扩容时如果拆分树时,树元素个数<= 6则会退化链表
- 情况2:remove树节点时,若root、 root.left、 root.right、 root.left.left有一 个为null,也会退
化为链表
为何HashMap线程不安全
- 在JDK1.7中,HashMap采用头插法插入元素,因此并发情况下会导致环形链表,产生死循环。 (扩容死链)
- 虽然JDK1.8采用了尾插法解决了这个问题,但是并发下的put操作也会使前一个key被后一个key覆盖。 由于HashMap有扩容机制存在,也存在A线程进行扩容后,B线程执行
get
方法出现失误的情况。(数据错乱)
那么 hashMap 线程不安全怎么解决?
- 给 hashMap 「直接加锁」,来保证线程安全
- 使用 「hashTable」,比方法一效率高,其实就是在其方法上加了
synchronized
锁。 - 使用 「concurrentHashMap」 , 不管是其 1.7 还是 1.8 版本,本质都是**「减小了锁的粒度,减少线程竞争」**来保证高效。
String对象的hashCode()设计 (为什么每次乘的是31)
- 目标是达到较为均匀的散列效果, 每个字符串的 hashCode足够独特
- 31是一个素数,素数作用就是如果我用一个数字来乘以这个素数,那么最终出来的结果只能被素数本身和被乘数还有1来整除。(减少冲突)
- 31代入公式有较好的散列特性,并且
31 * h
可以被优化为位运算,即h<<5 - h
Iterator
什么是fail fast?
fast-fail是Java集合的一种错误机制,遍历的同时不能修改,尽快失败。当多个线程对同一个集合进行操作时,就有可能会产生fast-fail
事件。例如:当线程a正通过iterator遍历集合时,另一个线程b修改了集合的内容,此时modCount
(记录集合操作过程的修改次数)会加1,不等于expectedModCount
,那么线程a访问集合的时候,就会抛出ConcurrentModificationException
,产生fast-fail
事件。边遍历边修改集合也会产生fast-fail
事件。
解决方法:
- 使用
Colletions.synchronizedList()
方法或在修改集合内容的地方加上synchronized
。这样的话,增删集合内容的同步锁会阻塞遍历操作,影响性能。 - 使用
CopyOnWriteArrayList
来替换ArrayList
。在对CopyOnWriteArrayLis
t进行修改操作的时候,会拷贝一个新的数组,对新的数组进行操作,操作完成后再把引用移到新的数组。
什么是fail safe?
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception
。(读写分离)
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception
,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。(牺牲了一定的一致性)。