02Java集合

一、概览

容器主要包括Collection 和 Map两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。

image-20200417095100798

1. Set

  • TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。Integer 和String 对象都可以进行默认的TreeSet 排序,而自定义类的对象是不可以的,自己定义的类必须实现Comparable 接口,并且覆写相应的compareTo()函数,才可以正常使用。
  • HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
  • LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。

2. List

  • ArrayList:基于数组实现,支持随机访问。

  • Vector:和 ArrayList 类似,但它支持线程同步,是线程安全的。

  • LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。

3. Queue

  • LinkedList:可以用它来实现双向队列。

  • PriorityQueue:基于堆结构实现,可以用它来实现优先队列。

Map

  • TreeMap:基于红黑树实现。

  • HashMap:基于哈希表实现。

  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。

  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

二、源码分析

1、ArrayList

1.1. 概览

因为 ArrayList 是基于数组实现的,所以支持快速随机访问。RandomAccess 接口标识着该类支持快速随机访问。

数组的默认大小为 10

1.2. 扩容

添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍

扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

1.3. 删除元素

需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,该操作的时间复杂度为 O(N),可以看到 ArrayList 删除元素的代价是非常高的。

2、Vector

2.1. 同步

它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。

2.2. 与 ArrayList 的比较

  • Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  • Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。

3、LinkedList

3.1. 概览

基于双向链表实现,使用 Node 存储链表节点信息。

每个链表存储了 first 和 last 指针:


3.2. 与 ArrayList 的比较

  • 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
  • 链表不支持随机访问,但插入删除只需要改变指针。

4、HashMap

4.1. 存储结构

内部包含了一个 Entry 类型的数组 table。Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。1.8之前,HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。

4.2. 拉链法的工作原理

  • 新建一个 HashMap,默认大小为 16;
  • 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。

应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。

查找需要分成两步进行:

  • 计算键值对所在的桶;
  • 在链表上顺序查找,时间复杂度显然和链表的长度成正比。

4.3. put 操作

  • 判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
  • 根据键值key计算hash值得到插入的数组索引i;如果table[i]==null,直接插入
  • 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value;(hashcode和equals)
  • .判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
  • 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
  • 插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容

4.4. 扩容

  • 基本原理:
    • resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
    • 每次扩展的时候,都是扩展2倍;默认大小是16
    • 在1.8中,根据在同一个桶的位置中进行判断(e.hash&&oldCap)是否为0,重新进行hash分配后,钙元素要么停留在原始位置,要么移动到(原始位置+增加的数组大小)位置
  • 重新计算桶下标:在1.7中,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度

4.5.解决哈希冲突

  • 链地址法来链接拥有相同hash值的数据
  • 使用二次扰动函数(hash函数)来降低哈希冲突的概率,使数据分布平均;
  • 引入红黑树,降低遍历的时间复杂度

我们如果只是单纯的用hashCode取余来获取对应的下标,参与运算的只有hashCode的低位,这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以要进行hash函数优化:

hash函数:与自己右移16位进行异或运算,让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,称为扰动

4.6.HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

  • hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
  • 解决办法:
    • HashMap自己实现了自己的hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;(在1.8中进行了一次位运算和一次异或运算共两次)
    • 在保证数组长度为2的幂次方的时候,使用hash()运算之后的值&(length - 1)来获取数组下标的方式进行存储,解决哈希值与数组大小范围不匹配的问题

4.7.HashMap 的长度为什么是2的幂次方

取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;而且采用二进制位操作 &,相对于%能够提高运算效率

4.8. 与 Hashtable和ConcurrentHashMap 的比较

  • 线程安全:HashMap不安全,Hashtable 使用 synchronized 来进行同步,线程安全。ConcurrentHashMap 在1.7使用分段锁机制,1.8后使用synchronized 和 CAS 来操作,只锁定当前链表或红黑二叉树的首节点,线程安全
  • HashMap 可以插入键为 null 的 Entry,且这样的KEY只能有一个。HashTable 和ConcurrentHashMap都不行
  • 效率:HashMap>HashTable

4.9.为什么HashMap是线程不安全的

​ HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。

5、ConcurrentHashMap

5.1. 存储结构

  • ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。Segment 继承自 ReentrantLock。默认的并发级别为 16,也就是说默认创建 16 个 Segment。

  • 在1.8后直接采用Node数组+链表(红黑树)的数据结构实现,并发控制使用synchronized和CAS操作,synchronized只锁定当前链表(红黑二叉树)的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

5.2. JDK 1.8 的改动

JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。

JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。

并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。

6、HashSet

  • 实现原理:基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT;
  • 如何检查重复:HashSet 首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals 方法 如果 equls 结果为true ,HashSet 就视为同一个元素。如果equals 为false 就不是同一个元素。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值