Java Core「18」JCF 及常见问题
01-概述
JCF,Java Collection Framework,是 Java 提供的集合表示和操作框架。一般我们讨论 JCF 时,主要关注的有两部分内容:1. Collection 接口及其实现;2. Map 接口及其实现。
图 1. Collection 接口及其常用实现
图 2. Map 接口及其常用实现
02-Collection
如图 1. 所示,Collection 接口包含了三个常用的子接口:
List,有序列表,元素可重复
Set,无序列表,元素不可重复
Queue,有序列表,一端进,一端出
02.1-ArrayList vs. LinkedList
数据结构不同,ArrayList 基于数组实现,LinkedList 基于双向链表实现,且实现了双端队列接口。
ArrayList 支持随机访问,LinkedList 不支持。
取决于数据结构,导致适应的操作也不同。ArrayList 适合查找、追加操作,不适合插入、删除操作。LinkedList 适合插入、删除,不适合查找操作。
内存占用不同。ArrayList 占用的所有内存空间都用来存储其元素,LinkedList 占用的所有空间除了用来存储其元素外,还需存储元素的前驱和后继。所以,存储同样数量的元素,LinkedList 所需的空间更多。
02.2-ArrayList 扩容原理
当数组满时,即列表容量不足以容纳新元素时,ArrayList 会自动扩容。扩容逻辑在java.util.ArrayList#grow
方法中:
02.3-ArrayList 序列化
ArrayList 基于数组实现,列表中的数据被存储在elementData
数组中:
ransient
关键字表明,序列化时会忽略该属性。那么为什么要这样实现呢,直接将列表中的元素全部序列化,反序列化时再将全部元素反序列化,岂不是更简单?
我们知道,ArrayList 的容量和当前保存的元素数并不是一直相等的,也就是说,elementData
数组并不是一直都是满的。所以,如果直接对数组进行序列化,需要对数组中的空元素进行处理,会降低序列化和反序列化的效率。
ArrayList 对象的序列化和反序列化逻辑在java.util.ArrayList#writeObject
和java.util.ArrayList#readObject
方法中。
02.4-ArrayList 实现线程安全的几种方式
通过
java.util.Collections#synchronizedList
包装,并使用包装后的对象。通过 CopyOnWriteArrayList(较推荐)或 Vector(不推荐,历史问题)等线程安全的类,而非 ArrayList。
在使用 ArrayList 时,使用
synchronized
同步机制。
02.5-CopyOnWriteArrayList 原理
图 3. CopyOnWriteArrayList 类图
CopyOnWriteArrayList 是线程安全版本的 ArrayList,它采用了读写分离的并发策略:并发读时,无序加锁;并发写时,先拷贝一份副本,再在副本上执行操作,结束后将原容器的引用指向新副本。
参考:
图 4. 面渣逆袭:Java集合连环三十问
03-Map
不同于 Collection 接口,Map 接口定义的是二维结构。HashMap 是最常用的其实现类之一。
03.1-HashMap 的数据结构
自 Java 8 开始,HashMap 采用”数组 + 链表 / 红黑数“的数据结构。
其底层是一个一维数组:
数组中的元素 Node 实现了 Map.Entry<K,V>
接口,用来存储键值对。
初始时,table[i] 是一个链表,当其长度超过一定值时(默认为 8)且 table 长度不小于 64,链表会被重构成红黑树。
若红黑树中的节点小于 6 时,红黑树变为链表。
table[i] 一般被称作”桶“。
03.2-HashMap#put 方法源码分析
HashMap#put
方法的具体实现逻辑在HashMap#putVal
中:
3.3-HashMap 扩容机制
HashMap 的容量是 2 的 n 次幂,如果创建 HashMap 时,传入的初始容量不是 2 的 n 次幂,会自动扩大为比它大的、最近的 2 的 n 次幂。
触发扩容时,容量也会扩大为原来的 2 倍。
扩容后,需要将容器中的元素重新插入到新的数组中。假设,扩容前容量为 2n2^n2n,扩容后容量为 2n+12^{n+1}2n+1。扩容前,键的 hash 值的最后 n 位用来计算其所属桶的索引;扩容后,键的 hash 值的最后 n+1n+1n+1 位用来计算其索引。若新增的第 n+1n+1n+1 位是 0,则计算出的索引值不变;若是 1,则新的索引为旧索引+2n2^n2n。
03.4-HashMap 的线程安全问题
Java 7 之前,往桶中列表插入元素时,采用的是头插法。多线程情景下,扩容时可能会出现循环列表。详情分析,参考[1]。Java 8 之后,采用尾插法,保持链表的顺序,不会出现问题。
多线程情景下,同时执行 put 方法时,若 key 计算出的 hash 值相同,则会出现值被覆盖的情况。Java 7 和 Java 8 中都存在这个问题。
多线程情景下,put 方法和 get 方法同时执行时,若遇到扩容,则有可能会出现 get 的结果为 null 的情况。
有什么办法可以避免线程安全问题呢?与 ArrayList 线程安全类似:
使用
java.util.Collections#synchronizedMap
方法,在包装后的对象上执行操作。使用线程安全版本来替代 HashMap,例如 HashTable 和 ConcurrentHashMap。前者不推荐使用,因为所有的操作都使用
synchronized
关键字修饰,效率很低。
03.5-ConcurrentHashmap 的实现
Java 7 之前,ConcurrentHashmap 基于分段锁实现,Java 8 之后,基于 CAS + synchronized
关键字实现。
基于分段锁实现 (Java 7)
内部结构与 HashMap 不同,内部包含一个 Segment 数组,每个 Segment 包含 HashEntry 数组,用来存储键值对。换个角度理解,每个 Segment 都相当于一个 HashMap,(默认情况下,Segment 数组的长度为 16),对每个 Segment 的读写都是独立的,避免了对整个哈希表进行加锁。
基于 CAS + synchronized
关键字实现 (Java 8)
当表为空时,通过 CAS 初始化
通过前面的分析,可以知道,多线程场景下,HashMap 执行 put 方法时,当某个桶中无任何元素时,两个线程同时插入会出现元素覆盖的情况(代码如下):
在 ConcurrentHashmap 中被修改为了:
当需要扩容时:
不需要扩容时,使 synchronized 关键字,确保多线程执行 put 方法能够同步进行:
03.6-实现一个简单的 HashMap
数据结构采用:桶数组 + 链表。更具体点讲,桶数组长度为 2 的整数次幂。因此,根据键对象的哈希值确定桶数组下标就相对简单:hash ^ (capacity - 1)
。散列到同一个桶中的不同对象(即哈希值碰撞),采用链表的方式,且插入方式使用尾插法。考虑到实现的复杂度,暂时不考虑链表超过指定长度后转化为红黑树。
使用size
记录映射表中的键值对数量,当其超过负载因子0.75
之后,自动对桶数组进行扩容。扩容后的桶数组是原来的 2 倍。
关键部分 put 方法的代码贴在下面:
完整的代码提交到 gitee 上,感兴趣的可以参考下。如果实现上有任何的缺陷,也欢迎大家指出,互相交流提高。
04-总结
本文整理了 Java Collection Framework, JCF 中的关键接口及其常用实现,还有相关的一些问题。权作为学习笔记,以便在需要时快速的回看。
JCF 中包含两部分重要的接口,Collection 和 Map,前者是线性的数据结构,例如列表、集合、队列;后者是二维的映射表,记录的是键值对。
历史文章推荐
Java Core 「16」J.U.C Executor 框架之 ScheduledThreadPoolExecutor
Java Core 「15」J.U.C Executor 框架
Java Core 「14」J.U.C 线程池 -Future & FutureTask
Java Core 「13」ReentrantReadWriteLock 再探析
Java Core 「12」ReentrantLock 再探析
版权声明: 本文为 InfoQ 作者【Samson】的原创文章。
原文链接:【http://xie.infoq.cn/article/f359c6bf02e42fe24fc24ef0c】。文章转载请联系作者。
评论