Java集合


Java集合

在这里插入图片描述

Java 集合概览

Java 集合, 也叫作容器,主要是由两大接口派生而来:

  • 一个是 Collection接口,主要用于存放单一元素;对于Collection 接口,下面又有三个主要的子接口:ListSetQueue

  • 另一个是 Map 接口,主要用于存放键值对

说说 List, Set, Queue, Map 四者的区别?

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
  • Set(注重独一无二的性质): 存储的元素是无序的、不可重复的。
  • Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
  • Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

集合框架底层数据结构总结

先来看一下 Collection 接口下面的集合。

  • List

    • ArraylistObject[] 数组

    • VectorObject[] 数组

    • LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

  • Set

    • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素

    • LinkedHashSet: LinkedHashSetHashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的

    • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

  • Queue

    • PriorityQueue: Object[] 数组来实现二叉堆

    • ArrayQueue: Object[] 数组 + 双指针

再来看看 Map 接口下面的集合

  • Map

    • HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间

    • LinkedHashMapLinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》 (opens new window)

    • Hashtable: 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的

    • TreeMap: 红黑树(自平衡的排序二叉树)


Collection 子接口之 List

Arraylist 和 Vector 的区别?

  1. ArrayListList 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全
  2. VectorList 的古老实现类,底层使用 Object[ ]存储,线程安全的。

Arraylist 与 LinkedList 区别?

  1. 是否保证线程安全: ArrayListLinkedList 都是不同步的,也就是不保证线程安全

  2. 底层数据结构: Arraylist 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

  3. 插入和删除是否受元素位置的影响:

    ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。

    LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n),因为需要先移动到指定位置再插入。

  4. 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持

  5. 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

说一说 ArrayList 的扩容机制吧

  • jdk 7

    • 无参数构造方法创建 ArrayList 时,底层创建了长度是10的Object[]数组elementData

    • 当添加元素导致底层elementData数组容量不够,则扩容

    • 默认情况下,扩容为原来的容量的1.5倍,然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,如果新容量大于 MAX_ARRAY_SIZE,进入hugeCapacity() 方法来比较 minCapacityMAX_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 三者的异同

  • HashSetLinkedHashSetTreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的
  • HashSetLinkedHashSetTreeSet主要区别在于底层数据结构不同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两个方法来判断存储的对象是否相同:

  1. 如果两个对象的hashCode值不同,说明两个对象不相同
  2. 如果两个对象的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非线程安全的,且不支持存储 NULLnon-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

Map 接口

img

HashMap 和 Hashtable 的区别

  1. 线程是否安全: HashMap非线程安全的Hashtable线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap );

  2. 效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它;

  3. 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 valueHashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException

  4. 初始容量大小和每次扩充容量大小的不同 :

    ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍

    ② 创建时如果给定了容量初始值,那么 Hashtable直接使用给定的大小,而 HashMap将其扩充为 2 的幂次方大小HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小

  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: 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 中的方法)。

HashMapHashSet
实现了 Map 接口实现 Set 接口
存储键值对仅存储对象
调用 put()向 map 中添加元素调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcodeHashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

HashMap的底层实现原理

JDK 7及以前版本:HashMap是数组+链表实现

JDK 8版本发布以后:HashMap是数组+链表/红黑树实现。

key的设计要求
  1. HashMap的key可以为null,但Map的其他实现则不然
  2. 作为key的对象,必须实现hashCodeequals,并且**key的内容不能修改 **(不可变)
  3. key 的 hashCode应该有良好的散列性

索引计算
  • 计算对象的 hashCode()
  • 再调用HashMaphash()方法进行二次哈希
    • 二次hash()是为了综合高位数据,让哈希分布更为均匀
  • 最后,hash & (capacity - 1) 得到索引,其中capacity必须是2的n次幂,这里相当于mod( capacity )

数组容量为何是2的n次幂
  • 计算索引时效率更高:如果是2的n次幂可以使用位与运算代替取模。
  • 扩容时重新计算索引效率更高:hash & oldCap == 0的元素留在原来位置,否则新位置 = 旧位置 + oldCap

put与扩容

put流程

  1. HashMap是懒惰创建数组的,首次使用才创建数组
  2. 计算索引(桶下标)。计算hashCode、二次hash、& (capacity - 1)
  3. 如果桶下标还没人占用,创建Node占位返回
  4. 如果桶下标已经有人占用
  • 已经是TreeNode,走红黑树的添加或更新逻辑
  • 是普通Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  1. 返回前检查容量是否超过阈值,一旦超过进行扩容(添加元素之后检查,然后再扩容)

1.7与1.8的区别

  1. 1.7 在实例化以后,底层创建了长度是16的一维数组Entry[] table。1.8 new HashMap()底层没有创建一个长度为16的数组,首次使用才创建Node[] table数组。
  2. 链表插入节点时,1.7 是头插法,1.8 是尾插法
  3. 1.7 是大于等于阈值(loadFactor * 数组总长度)且没有空位时才扩容(容量×2);而1.8是大于阈值就扩容,并且链表长度超过8时,会先尝试扩容来减少链表长度,如果数组容量已经>=64,会转化成红黑树。
  4. 1.8在扩容计算Node索引时,会优化

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yiSkcy5k-1646466040175)(E:\Typora\imgs\image-20220225164756916.png)]


扩容(加载)因子为何默认是0.75f
  1. 空间占用查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长,影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

树化与退化

树化规则

  • 当链表长度超过树化阈值 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。在对CopyOnWriteArrayList进行修改操作的时候,会拷贝一个新的数组,对新的数组进行操作,操作完成后再把引用移到新的数组。

什么是fail safe?

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。(读写分离

缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。(牺牲了一定的一致性)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值