Skip to content

集合框架

## 1. 总体结构 img

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

Map 接口和 Collection 接口是所有集合框架的父接口。

Map 接口实现:

  • HashMap:线程不安全,键值可为 null
  • LinkedHashMap:双向链表,元素默认按插入顺序排序
  • TreeMap:实现 SortedMap 接口,默认按键值升序排序,可指定排序的比较器
  • Hashtable:键值都不可为 null,线程安全,Synchronized 关键字实现
  • ConcurrentHashMap:1.7 和 1.8 的实现不同
    • 1.7 Segment 数组 + HashEntry + Unsafe 实现
    • 1.8 移除 Segment,Synchronized + CAS + Node + Unsafe 实现,使锁粒度更小
  • Properties 持久数据集

Collection 接口的子接口包括 Set 接口和 List 接口。

List接口实现:

  • ArrayList 初始容量10,扩容为原来的1.5倍。非线程安全,随机查询快
  • Vector 线程安全,使用了 sychronized
  • LinkedList 链表结构,新增和删除快

Set接口实现:

  • HashSet 无序不重合集合
  • TreeSet 基于红黑树的可排序集合
  • LinkedHashSet 基于 hash 实现的有序集合

2. 集合中的设计模式

参考 容器中的设计模式

2.1. 迭代器模式

Collection 继承了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素。

从 JDK 1.5 之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象。

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
    System.out.println(item);
}

2.2. 适配器模式

java.util.Arrays#asList() 可以把数组类型转换为 List 类型。

@SafeVarargs
public static <T> List<T> asList(T... a)
应该注意的是 asList() 的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组。
Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);
也可以使用以下方式调用 asList():
List list = Arrays.asList(1, 2, 3);

3. 主要类具体分析

3.1. 基本类型不能做为 HashMap 的键值

  • Java中是使用泛型来约束 HashMap 中的 key 和 value 的类型的,HashMap
  • 泛型在 Java 的规定中必须是对象 Object 类型的,基本数据类型不是 Object 类型,不能作为键值
  • map.put(0, "test") 中编译器已将 key 值 0 进行了自动装箱,变为了 Integer 类型

3.2. ArrayList 和 Vector 的联系和区别

相同点:

  • 底层都使用数组实现
  • 功能相同,实现增删改查等操作的方法相似
  • 长度可变的数组结构

不同点:

  • Vector 是早期 JDK 版本提供,ArrayList 是新版本替代 Vector 的
  • Vector 的方法都是同步的,线程安全;ArrayList 非线程安全,但性能比 Vector 好
  • 默认初始化容量都是10,Vector 扩容默认会翻倍,可指定扩容的大小;ArrayList 只增加 50%

3.3. HashMap 详解

3.3.1. 内部数据结构

Jdk1.8之前,内部使用数组 + 链表,链表扩容时使用头插法,可能造成环状列表的问题

Jdk1.8,内部使用数组 + 链表红黑树,链表插入使用尾插法

3.3.2. 插入原理

1.先判断数组是否为空,为空初始化;

2.不为空,计算 key 的 hash 值,通过 (n-1) & hash 计算应当存放在数组中下标 index;

3.查看 table[index] 是否存在数据,没有数据就构造一个 Node 节点放在 table[index] 中;

4.若存在数据,说明 hash 冲突,判断 key 是否相等。相等的话用新的 value 替换原数据;

5.若 key 不相等判断当前节点类型是不是树型节点,如果是树节点,创造节点插入红黑树中(判断是树型节点表示当前已是红黑树了);

6.节点如果不是树型节点,创建普通 Node 加入链表中;判断链表长度是否大于8并且数组长度大于64,条件成立的话链表转为红黑树;

7.插入完成之后判断当前节点是否大于阈值,如果大于就开始扩容为原数组的二倍。

3.3.3. HashMap初始容量

HashMap 默认大小是16,负载因子是0.75,如果传入初始值n,初始化大小为大于n的数字,满足2的整数次方。比如传的是10,小大为16.

/**
* The default initial capacity - MUST be a power of two.
* 初始容量16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
* 最小容量2,最大容量2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* The load factor used when none specified in constructor.
* 负载因子,使用容量大于75%就要扩容
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
* 哈希桶中的链表元素转换成树结构
* 必须大于 2 并且应该至少为 8
*/
static final int TREEIFY_THRESHOLD = 8;

/**
* 取消树结构的阈值,应小于6,最多为6
*/
static final int UNTREEIFY_THRESHOLD = 6;

/**
* 转化为树结构还必须哈希表容量至少是64,避免频繁在链表和树之间转换
*/
static final int MIN_TREEIFY_CAPACITY = 64;

3.3.4. HashMap的hash函数如何设计

hash 函数先是拿到 key 的 hashcode,是一个32位 int 值,然后让 hashcode 的高16位和低16位进行异或操作

//1.8版本,比1.7觉得扰动做一次就够了
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//确定数组 table 下标
/*tab[i = (n - 1) & hash]
其中 (n - 1) & hash 的 n 是 tab 长度,此操作相当于 hash % (n-1) 取余数,位运算计算比取余数快
*/

//1.7版本,做了四次移位和四次异或
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

3.3.5. 为什么这样做异或操作

1.尽可能降低 hash 碰撞,越分散越好;

2.算法尽可能高效,高频操作,采用位运算。

3.3.6. 为什么采用 hashcode 的高16位和低16位异或能降低 hash 碰撞?hash 函数能不能直接用 key 的 hashcode?

将 hashcode 的高半区和低半区做异或,混合哈希码的高位和低位,加大随机性,混合后低位掺杂了高位的部分特征,高位信息也被变相保留下来。

3.3.8. JDK1.8 版本除了 hash 函数做优化,还有其他哪些优化

1.为防止发生 hash 冲突时,链表长度过长,数据结构由数组+链表改成了数组+链表或红黑树,时间复杂度由链表 O(n) 降低为红黑树 O(logn) 。

2.链表的插入方式从头插法(头插法扩容时,会使链表发生反转,多线程环境下会产生环)改成了尾插法,即在插入时,如果数组位置上已经有元素,1.7是将新元素放到数组中,原始节点作为新节点的后继节点,而1.8是遍历链表,将元素放置到链表的最后。

3.扩容的时候1.7需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小。

由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1。

扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为 0000 1111,扩容为32后的二进制就高位多了1,为 0001 1111,如果第四位高位为0,重新 hash 数值不变,如果第四位为1,重新hash数值比原来大16(旧数组的容量)

4.在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容。

3.3.9. HashMap是线程安全的吗?

不是,在多线程环境下,1.7 会产生死循环(环形链)、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题。

3.3.10. 解决HashMap是线程不安全问题

Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map。

HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个数组,粒度比较大,Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;ConcurrentHashMap 使用分段锁,降低了锁粒度,让并发度大大提高。

3.3.11. 链表转红黑树是链表长度达到阈值,这个阈值是多少?

阈值是8,红黑树转链表阈值为6。经过概率计算,在 hash 函数设计合理的情况下,发生 hash 碰撞8次的几率为百万分之6。至于为什么转回来是6,因为如果 hash 碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化。

3.4. ConcurrentHashMap

3.4.1. 数据结构

  • 1.7:Segment 数组 + HashEntry 数组 + 链表
    分段锁对整个桶数组进行了分割分段,每一把锁只能锁住容器中一部分数据,多线程访问操作就没有问题了

  • 1.8:Node 数组 + 链表/红黑树 摒弃了 Segment 分段锁概念,使用 synchronized 和 CAS 来操作

3.4.2. ConcurrentHashMap 的分段锁的实现原理

ConcurrentHashMap 成员变量使用 volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用 CAS 操作和 synchronized 结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。