Android 面试系列(三)Java 集合类源码要点

源码解析

我们经常说,不要抱有目的的去学习,这其实是正确的,而且一味的去“背”一些东西的结果是,大概率只算是【纸上谈兵】。如果抛开“面试”这个目的,在有时间的前提下,多读一些源码其实也是有利于我们的技术成长的。但是在国内移动互联网的整体氛围就是如此的。嘛,毕竟我国最强的就是【应试教育】。。。下面是我总结的 Java 中常见的集合类的源码解析,那么,亮个相吧小宝贝!
在这里插入图片描述

HashMap

HashMap 的负载因子
  • 默认是 0.75,阈值默认是数组长度乘负载因子,都不指定则默认容量是 16,阈值是 12。扩容时涉及到 rehash、复制数据等,比较耗费性能。
  • 之所以大于阈值是就要扩容,是因为当元素大于阈值时,证明数组中已经有很多元素了,为了防止链表过长导致遍历检索性能影响,进行扩容
1.7
底层数据结构

数组加链表实现

怎么在链表上添加数据?

JDK1.7 版本的 HashMap 采用头插法,hash 冲突时,若 index 位置已经有元素,则新建一个 Entry 节点,将原来节点作为这个节点的 next

怎么预防和解决 hash 冲突的?
  • 二次 hash 和扰动函数预防 hash 冲突
  • 通过拉链法,key 冲突时,数据结构变成一个链表解决冲突
JDK1.7 的 HashMap 的默认容量是多少?

默认容量是 16,不可以是 15,必须是 2 的幂次方。HashMap 使用 key 的 hash 对数组长度求余的操作来求元素在数组中的 index。indexFor() 方法负责完成寻找 index。方法中使用 key 的 hash 值对数组 length - 1 求与运算(与用 hash 值跟数组长度求余是一样的)来求元素的 index,2 的幂次方 -1,得到的是后面的位数全是 1 的二进制数,hash(key)&(length-1),即可通过位运算实现求余操作得到元素 index,提高效率。

JDK1.7 的 HashMap 的数组是什么时候创建的

首次执行 put 方法的时候

JDK1.7 的 put 流程
  • 若数组为空,创建数组,同时实例化阈值
  • 如果 key 是空,则将其放在数组的 0 号位置,hash 值为 0
  • 求出 key 的 hash,根据 hash 结合数组 length 算出 index
  • 看是否能找到 table[index],找到则替换,找不到执行 addEntry
  • addEntry 中会先判断,如果 size 大于阈值,并且 table[index] 不等于 null,则 resize 操作扩容,重算 hash 值,调用 indexFor 找到新的 index,将原数组中的元素转移到扩容后的数组,将引用指向新数组
  • 新建 Entry,放到数组的 index 位置
JDK1.7 的 get 流程
  • 根据 key 的 hash,调用 indexFor 找到 key 在数组中的索引
  • 如果 table[index] 位置为空,则 return null
  • 不为空,取出对应位置的 Entry 对象,判断 hash 值、key 值是否等于我们传入进来的 key,若为链表,则依次遍历链表,直到找到为止
1.8
JDK1.8 的 HashMap 的数据结构?

数组加链表加红黑树

链表上数据的插入方式?

从链表后面插入

HashMap 什么时候会把链表转换为红黑树?

当链表长度超过 8,并且数组长度不小于 64 的时候

JDK1.8 的 put 流程
  • 先判断 table 是否为空,为空则创建 table
  • 根据当前 key 的 hashcode 定位到具体的桶中,并判断是否为空,空表明没有 hash 冲突,直接在当前位置创建一个新 Node
  • table[index] 不为空,则比较 key 和 key 的 hash,相等就赋值给 e
  • 如果当前 table[index] 位置元素为红黑树,则按照红黑树的规则写入数据
  • 如果是链表,遍历 table[index] 位置的链表,若找到一个 key 和 key 的 hash 都与插入元素相等的元素,则跳出循环,替换 value
  • 若没有重复元素,将 key、value 封装成一个新的节点写入到当前链表最后
  • 如果链表长度大于成树的阈值(8),并且数组 size 大于 64,转换为红黑树,否则只是做扩容处理
  • 最后判断是否需要扩容(数组容量和阈值都扩容为原来的两倍)
JDK1.8 的 get 流程
  • 根据 key 和 key 的 hash,找到元素 index,判断 table[index] 是否为空,不为空并且 hash 等于传入的 key 的 hash,key 也相等,则返回
  • table[index].next 不为空,且 table[index] 是 TreeNode 类型,则通过红黑树的查找方式找元素
  • 否则就是链表,遍历链表找元素
1.8 HashMap 扩容后链表中元素存储位置的计算方式

使用元素的 hash 与原数组的长度做按位与运算,通过两个高低位的桶,直接在链表尾部添加

HashMap 是线程安全的吗,不是的话会导致什么问题?
  • HashMap 不是线程安全的。JDK1.7 版本的 HashMap 的链表采用的是头插法,在并发场景时,如果遇到扩容的情况,容易造成死循环。
  • 1.7 中 hash 冲突采用的是头插法形成的链表,并发条件下会形成循环链表,一旦有查询落在这个链表上,获取不到值时就会死循环。

LinkedHashMap

LinkedHashMap 结构

LinkedHashMap 特点
  • LinkedHashMap 是继承于 HashMap 的,是基于 HashMap 和双向链表来实现的。
  • LinkedHashMap 是非线程安全的
  • HashMap 无序;LinkedHashMap 有序,可分为插入顺序和访问顺序。通过一个变量 accessOrder 来控制是否是访问顺序,默认是 false,可以通过三参数的构造设置为 true。如果是访问顺序,每次 put 和 get 操作已存在的 Entry 时,都会把 Entry 移动到双向链表的表尾(其实是先删除,再插入)
  • LinkedHashMap 存取数据与 HashMap 一样使用 Entry 数组的方式,双向链表是为了保证顺序
LinkedHashMap 与 HashMap 相比修改了哪些部分?
  • 继承 Hash.Entry,重写了 Entry,维护了一个双向链表结构。来保证顺序性。
  • 重写了 init 方法,在其中实例化了一个前后都指向自己的双端链表节点 header。
  • 重写了 createEntry 方法,在将 entry 放入数组中的同时,还会把新创建的 entry 放入双向链表中。
  • 重写了 transfer 方法,在需要扩容时,做数据迁移的时候会遍历双向链表,对所有 entry rehash,然后加入新的 table 中。与 HashMap 相比少了一次遍历数组的过程。
  • 重写了 Entry#recordAccess 方法,每次调用 put/get 方法时,都会调用这个方法,将当前访问的元素放到链表尾,实际是先删除这个节点,然后再在链表末尾 add。
  • 重写了内部类 EntryIterator,使其继承自 LinkedHashIterator,LinkedHashIterator 中 nextEntry 方法就是链表的遍历过程了。hasNext 方法就是判断当前 nextEntry 是否等于 header,是则证明链表空,或者遍历完了。
LinkedHashMap 在 Android 中的应用

在 Android 中一般用 LinkedHashMap 作为 LruCache 算法的内部结构。

  • 实例化 LinkedHashMap 时为其指定 accessOrder 为 true,表示 LinkedHashMap 为访问顺序,这样当对 LinkedHashMap 中已存在的 entry 进行 get 和 put 操作时,会将其移动到双向链表表尾,添加新元素时也是在表尾,符合 Lru 算法特性,最近有使用的,放在最后移除。
  • 当到达缓存阈值要移除元素时,通过得到 LinkedHashMap 的迭代器/EntrySet,取第一个元素,即最早加入的元素,移除即可,也是符合 Lru 算法特性,最早加入且未使用的删除

ConcurrentHashMap

  • ConcurrentHashMap 引入了 Segment 机制,把一个大的 Map 分成多个片段,Segment 对象继承了 ReentrantLock 类,执行 put 操作时,会根据 hash(paramK.hashCode()) 来决定具体存放进哪个 Segment。其同步机制是基于 lock 操作的,这样就可以对 Map 的某个 Segment 上锁。这样影响的只是将要放进同一个 Segment 的元素的 put 操作。这样既能保证同步性,又能保证并发时的性能。
  • 理论上,ConcurrentHashMap 支持 currencyLevel(segment 数组长度)的线程并发,每个线程占用锁访问一个 Segment 时,不会影响到其他 Segment。currencyLevel 默认为 DEFAULT_CONCURRENCY_LEVEL = 16
1.7

pic

由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。

put 方法
  • 先通过 hash(key) 获取到 Segment(是一个 CAS 过程),然后在对应的 Segment 中进行 put 操作。
  • HashEntry 中的 value 是 volatile 修饰的,不能保证原子性,Segment 中的 put 操作需要加锁。
  • 先尝试获取锁,如果失败,则利用 scanAndLockForPut() 自旋获取锁。
  • 如果自旋获取锁达到了 MAX_SCAN_RETRIES(64),则改为阻塞锁获取,保证可以获取成功。
  • 将当前 Segment 中的 table 通过 key 的 hashcode 定位到具体的 HashEntry
  • 遍历 HashEntry,不为空则判断 key,找到相等的则覆盖旧值。为空则新建 HashEntry 并加入 Segment,同时判断是否要扩容
  • 操作完时调用 unlock() 解除当前 Segment 的锁
get 方法

将 key 通过 hash 之后定位到具体的 Segment,在通过一次 hash 定位到具体元素上。get 方法非常高效,因为整个过程都不需要加锁。

1.8

pic

  • 1.8 中抛弃了 Segment 分段锁,采用 CAS + synchronized 保证并发安全。
  • 1.8 版本中的 ConcurrentHashMap 与 1.8 的 HashMap 的数据结构基本一致,也是数组 + 链表 + 红黑树。采用红黑树之后可以保证查询效率,甚至取消了 ReentrantLock 改为使用 synchronized,新版 jdk 对 synchronized 做了很多优化(偏向锁、轻量锁、重量锁)。
put 方法
  • 根据 key 计算出 hash
  • 判断是否需要初始化
  • 如果 key 定位到的 Node 为空,利用 CAS 尝试写入,失败则自旋,保证成功
  • 如果当前位置的 hashcode == MOVED == -1,则需要扩容
  • 都不满足,则利用 synchronized 锁写入数据
  • 如果数量大于转换为树的阈值(默认是 8),将链表转换为红黑树
get 方法
  • 根据 hash(key) 寻址,如果在桶上,直接返回
  • 如果是红黑树就按照数的方式获取值
  • 不满足就按照链表方式遍历获取值

CopyOnWriteArrayList

原理

CopyOnWriteArrayList 是 ArrayList 的线程安全的变体。CopyOnWriteArrayList 底层实现添加的原理是先使用 ReentrantLock 上锁,然后 copy 出一个副本,往新的副本里添加新数据,添加完成后把原来的变量指向新的内存地址。添加数据期间,其他线程访问数据,读取的是旧容器里的数据。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}

添加数据需要加锁,否则多线程写的时候会复制出 N 个副本;读的时候不需要加锁,因为读的是旧 Array 中的数据,旧的 Array 不会上锁

优缺点
优点
  • 保证数据一致完整性,因为加了锁,并发数据不会乱
  • 解决了 ArrayList 等集合多线程遍历迭代问题。
缺点
  • 内存占用问题,因为添加数据时又 copy 了一份原数据,如果数据比较多,比较大的情况下,内存占用会比较大,可以使用 ConcurrentHashMap 代替
  • 只能保证最终的数据一致性,不能保证数据的实时一致性。
使用场景
  • 读多写少,如白名单、黑名单、商品条目的访问和更新场景。
  • 集合不大
  • 实时性要求不高,只要求最终结果的准确性

ArrayList 和 LinkedList

ArrayList 是怎么扩容的?

ArrayList 内部使用数组实现,支持动态扩容。初始化大小是 10,如果新增的时候发现大小不够用了,就去扩容。扩容规则是,扩容后的大小 = 原始大小 + 原始大小/2 + 1。通过 Arrays.copyOf() 方法传入原数组和新的容量。

ArrayList 支持序列化吗?为什么承载元素的 elementData 要用 transient 修饰?

ArrayList 实现了 Serializable 接口,支持序列化。
用 transient 修饰是因为让数组不要参与序列化,ArrayList 自己提供了两个私有方法 writeObject 和 readObject 来完成序列化。因为 ArrayList 的扩容规则是当容量满了之后,扩充为原来的 1.5 倍。若数据非常大时,刚好达到容量阈值,则扩充为原来的 1.5 倍之后数组中还有很多无用的剩余空间,序列化时可能会非常浪费性能。

ArrayList 是怎么获取元素的?
按索引 get

直接返回数组对应下标的元素

通过元素获取索引

需要遍历整个数组,找到对应元素 index

ArrayList 是如何添加元素的?

ArrayList 添加元素时,分为几种情况:

  • 若在 ArrayList 队尾添加(调用 add(e) 方法)
    • 不需要扩容,只需要为数组 size++ 位置赋值
    • 需要扩容,先扩容,通过 Arrays.copyOf 方法复制到新数组中,然后再添加元素
  • 若在 ArrayList index 位置添加,则需要将 index + 1 到 length 的元素全部后移,需要调用 System.arraycopy 方法,插入元素的位置越靠近 List 头,性能损耗越大
ArrayList 是如何删除元素的?
  • 根据索引删除
    • 删除元素时,先从数组中取出对应元素,然后调用 System.arraycopy 将 index + 1 到 length 位置的元素全都向前移动一位(通过数组 copy 的方式),删除的元素越靠前,性能损耗越大。然后将 index 位置的元素置空,方便之后 GC
  • 根据 Object remove
    • 先遍历数组,找到要删除的元素的 index
    • 然后执行按索引删除时的操作
LinkedList 是如何实现的?

LinkedList 是基于双向链表结构实现的,内部定义了一个 Node 类,Node 类包含一个前引用 prev,一个后引用 next,和元素 item。定义了一个 first 和一个 last 节点,用来记录链表的第一个和最后一个 Node。

LinkedList 支持序列化吗?如何实现的?

LinkedList 实现了 Serializable 接口,支持序列化。LinkedList 中的 size、first、last 都用了 transient 修饰,证明 LinkedList 内部也用自己的方式实现了序列化和反序列化。序列化时,LinkedList 中只保留了 Node 的 item 属性,元素的前后节点属性没有被序列化。反序列化时,再将链表从头到尾重新连接起来,恢复之前的顺序。

LinkedList 怎么获取元素?
getFirst、getLast

这两种方式很快,直接返回队头、队尾元素即可

根据索引找

通过调用 node(index) 方法找到对应元素,node 方法中会判断传入的索引是链表长度前半段还是后半段,然后遍历前半段或后半段链表,找到对应元素返回

通过元素获取索引

若传入的 object 不为空,则遍历整个链表,找到对应元素返回

LinkedList 怎么添加元素?

LinkedList 添加元素时分为几种情况:

  • 在 LinkedList 队尾添加元素
    • 创建一个新的对象,添加到队尾(有一个新对象的创建过程)
  • 在 index 位置插入元素
    • 判断是否是队尾,是则调用上面的方法
    • 不是队尾,调用 node(index) 方法,找到 index 位置的 Node,将其插入到原来 Node 的位置,分别设置 pre 和 next
LinkedList 怎么删除元素?

LinkedList 的 remove 有四种情况:

  • removeFirst
    • 移除头结点,不需要遍历,只需要把第二个节点更新为头结点
  • removeLast
    • 移除尾节点,不需要遍历,只需要把倒数第二个元素更新为最后一个节点
  • 根据索引删除
    • 通过 node(index) 找到对应元素,然后把这个 Node 的前后节点关联到一起
  • 根据 Object 删除
    • 遍历链表,找到对应的 Node,将其前后节点关联上
ArrayList 和 LinkedList 的遍历
  • ArrayList 遍历使用 for 循环
  • LinkedList 遍历使用迭代器循环,因为如果使用 for 循环,每次去 get 取元素时,都会调用一次 node(index) 对链表前后半段进行遍历,使用迭代器只会用一次 node 方法,返回头结点,后边使用 hasNext 和 next 方法一次取链表下一个节点

ArrayMap

  • 基于两个数组实现,一个存放 hash,一个存放键值对
  • 存放 hash 的数组是有序的,使用二分查找法查找
  • 发生 hash 冲突时,在键值对数组里连续存放,相邻位置插入,查找时通过 key.equals 索引,找不到时先向后,再向前遍历相同 hash 值的键值对数组
  • 扩容时不向 HashMap 直接 double,内存利用率高,也不需要重建 hash 表,只需要调用 System.arraycopy 数组拷贝
  • 不适合存大量数据(1000 以下),因为数据量大的时候二分查找比红黑树慢

SparseArray

  • SparseArray 内部使用两个数组分别存储 key 和 value。当每次 put/remove 时,会先通过二分查找法找到元素 index,然后从数组中取出返回。
  • 可以替代 key 是 int 类型,value 是 Object 的 HashMap。
  • 相比 HashMap,它性能更高,因为免去了 int 和 integer 的拆箱装箱操作。并且扩容时,只需要数组 copy,不需要重建 hash 索引。每次 put 操作时,也不需要为 value 创建一个额外的 entry 对象
  • 初始默认容量是 10
  • 如果 key 是 long 类型,可以使用 LongSparseArray

参考文章

java面试整理(四)—— HashMap、LinkedHashMap、TreeMap、Hashtable、HashSet和ConcurrentHashMap区别

HashMap? ConcurrentHashMap? 相信看完这篇没人能难住你!

图解LinkedHashMap原理

面试题-Map之LinkedHashMap

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值