前言
Java集合框架主要由两个接口及其下面的实现类构成,这两个接口分别是Map接口
和Collection接口
,下面先通过其对应的UML类图看下这两个接口的具体实现,如下
1、Map接口
Map接口的主要实现有我们熟悉的HashMap
、HashTable
以及TreeMap
、ConcurrentHashMap
等,其中,AbstractMap
提供了Map接口的大部分方法的实现,最大限度的减少实现Map接口的工作量,这在AbstractMap
的开篇写道this class provides a skeletal implementation of the Map interface, to minimize the effort required to implement this interface.
,下面,我们将详细介绍Map接口的几个实现类。
2、Collection接口
Collection接口实现了Iterator接口,它下面有三个主要接口继承其本身,List、Queue和Set
,这三个接口是我们常见的实现类的接口,比如ArrayList、LinkedList、HashSet
等等,其中的AbstractCollection、AbstractList等
同上面的AbstractMap
的作用。这里的Queue
接口的实现我画出的是阻塞队列ArrayBlockingQueue和LinkedBlockingQueue
,这两个队列我自己的应用是在线程池中设置阻塞队列,后面简单介绍一下。
至此,集合框架的两个大接口的常见实现有了一个大体的了解,下面,针对常用的集合进行具体介绍。
一、Map接口
1、HashMap
提到Map接口,第一个想到的就是HashMap
了,对于其组成,有两个版本,jdk8之前,由数组+链表
组成,数组是存储的主要位置,链表是为了解决哈希冲突;在jdk8之后,有了一个很大的变化,由数组+链表/红黑树
组成,当链表长度大于树化阈值时,会转变成红黑树,从而减少搜索时间,下面基于jdk8做一个分析。
结构
DEFAULT_INITIAL_CAPACITY,默认初始容量(数组大小),16(1<<4)
MAXIMUM_CAPACITY,最大容量,1<<30
DEFAULT_LOAD_FACTOR,默认负载因子,0.75f
TREEIFY_THRESHOLD,树化阈值,8
UNTREEIFY_THRESHOLD,链表化阈值,6
MIN_TREEIFY_CAPACITY,最小树化节点数量,树化的另一个参数,当哈希表中的所有桶个数超过64时,才允许树化,64
threshold,扩容阈值,`capacity * loadFactory`(容量*加载因子)
size,存储的元素个数,即k-v键值对的个数
modCount,散列表结构变化的次数,例如增删节点(修改值不算),用于快速失败机制
前面说过,HashMap由数组+链表/红黑树
组成,其中,数组存储的是node链表,transient Node<K,V>[] table;
,node节点如下组成
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //元素的hash值
final K key; //键
V value; //值
Node<K,V> next; //指向下一个节点
}
因此,我们先大概画一下HashMap的结构
数组的查询速度快,链表的插入速度快,下面,开始分析数据的插入、删除、扩容等相关内容,当某个链表的长度超过树化阈值(8)同时节点个数超过最小树化节点数(64)时,才转变成红黑树;如果没有达到最小树化节点数,那么考虑进行数组扩容(容量变成原来的2倍),而不是转变红黑树,从而减少搜索时间
传入初始化容量
当我们传入容量initialCapacity时,构造函数会调用一个tableSizeFor
方法,进行数组的容量初始化,通过这个方法返回一个大于等于initialCapacity的2的幂次方的数,例如传入7,返回的是8
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这也引申出,数组的容量是2的幂次方数,这是因为通过哈希值(32位int类型数)计算数组下标时候,做法是hash&(n.length-1),因此,只有是2的n次方,进行-1操作才能保证2进制数每位都是1,这样按位与才能计算出非0的数,保证数据分散均匀
特点
HashMap存储是无序的;键和值都可以为null,但是只能有一个null键;线程不安全
hash算法&hash碰撞
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash算法:让key的hashcode异或hashcode无符号右移16位,这样做是为了让hashcode的高16位也能参与运算,从而使得散列均匀,减少hash碰撞
hash碰撞:两个不同的key,a.hashcode == b.hashcode
,这样会去比较a.equals(b)
,如果true,那么覆盖值,否则,将节点插入链表尾部(jdk7插入头部)
put添加流程
注意,put方法是有返回值的,返回的是之前存储的value(即返回老值),hashmap是懒加载,在第一次put的时候才会创建数组
判断当前数组是否为空,若为空,调用resize方法初始化数组
根据key计算hash值,根据hash值找到数组下标位置(数组长度-1 & hash 计算)
如果当前下标没有节点,将节点e放入当前位置
当前位置存在节点p,如果p的hash值等于e的hash值(发生hash冲突)
如果key存在,覆盖value值
key不存在,要插入新节点,判断p是不是树节点
如果是树节点,挂到树上
否则,判断是否树化,若不树化插入链表的尾部,树化则挂到树节点
完成这些,根据元素个数是否超过扩容阈值,是否扩容
resize扩容流程
扩容是在hashmap容量 > 初始容量*负载因子 时发生的,扩容是将数据长度放大到原来的2倍,扩容后节点的存储位置要么是原位置,要么是原位置+原数组长度的位置,扩容分为两大步,计算新的数组容量和迁移旧数据
计算数组容量:
旧容量大于0
旧容量大于等于最大容量
扩容因子赋值为Int最大值,返回老容量
否则新容量 = 2*旧容量,新容量小于最大容量且旧容量大于等于默认容量,新扩容阈值 = 2*旧阈值
旧容量小于0且旧扩容阈值大于0
新容量 = 旧扩容阈值
旧容量和旧阈值都小于0(还未初始化)
默认容量和默认阈值
转移元素:
创建新数组,容量为之前的2倍
遍历旧数组
如果只有头节点,直接计算新的位置并赋值
如果是树结构,单独处理
如果是链表,计算节点下标,链接到新数组的链表上
存放自定义对象时,需要注意什么
存放自定义对象时,重写对象的hashcode和equals方法。
首先我们知道hashmap的key是不允许重复,如果没有重写equals方法,我们看下如下代码
public static void main(String[] args) {
user user1 = new user("张三");
user user2 = new user("张三");
HashMap<user,String>hashMap = new HashMap<>();
hashMap.put(user1,"1");
hashMap.put(user2,"2");
System.out.println(hashMap.size());
}
上面代码的运行结果是2,这里先引申下== 和 equals的区别
==比较的是两个对象地址是不是相等
对于基本数据类型,比较的是值
对于引用数据类型,比较的是内存地址
equals比较的是两个对象是否相等
若未重写equals方法,那么作用等同于==,比较的是对象地址
若重写equals方法,那么一般情况是重写比较两个对象的内容是否相等
因此,这里的user1和user2是两个对象,所以结果是2,那么我们重写了equals方法之后是否就输出1了呢?答案是否定的,因为hashmap在放入对象的时候是先通过key计算hash值的,再没重写hashcode方法时,调用的是Object类的一个native方法,返回的是不同值,因此计算数组下标不同,所以可以存进去。那么我们两个方法都重写之后,才能确定是同一个key
public class user {
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
private String name;
public user(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
user user = (user) o;
return Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
是否线程安全?怎么做到线程安全?
HashMap是非线程安全的,可以通过Collections.synchronizedMap(hashMap);
方法将HashMap转变成线程安全,这是因为SynchronizedMap
内部维护了一个互斥锁mutex,通过synchronized
对HashMap的s方法做了修饰;同时我们可以考虑使用ConcurrentHashMap保证线程安全
为什么String、Integer等包装类适合作为HashMap的key?
包装类的特性能够保证hash值的不可更改性和计算准确性,能够有效减少hash碰撞;包装类都是final类型,即保证key的不可更改性,不会存在获取hash值不同的情况;
包装类内部重写了hashCode()方法和equals()方法;
⑩如何实现HashMap的有序性
可以考虑使用LinkedHashMap或者TreeMap。以LinkedHashMap为例,LinkedHashMap的结构大体上和HashMap没什么两样,其Entry对象增加了两个属性,Entry<K,V> before, after;
即增加了一个标识签后节点的before和after,形成了一个双向链表,这样,在存储数据的时候,就已经做到了有序存储
fail-fast机制
在遍历HashMap时删除元素或多线程下修改集合结构,会有一个ConcurrentModificationException
报错,在前文中说到HashMap有一个modCount来记录结构修改次数,有一个expectedModCount是迭代器期望的修改次数,只有迭代器能修改这个次数,在每次迭代过程中,都会去判断modCount的值是否和expectedModCount相等,如果在迭代过程中add/remove元素,由于add/remove方法不是迭代器的方法,所以不会修改expectedModCount,这就导致了报错。这个机制适用于java.util包下的所有集合。那么,怎么做到一边遍历一遍删除呢?使用迭代器Iterator.remove方法或者倒序遍历删除。对应fail-fast机制,还有fail-safe机制,用在JUC下的容器中
如何确定一个较为合适的容量
在使用HashMap时,传入一个合适的初始容量可以有效避免扩容,可以参考putAll方法中的一个公式float ft = ((float)s / loadFactor) + 1.0F
,只要这个ft不超过容量最大值就返回(int)ft,否则使用最大值。例如,我们期望存储7个元素,那么7/0.75+1最终得到10,然后又因为2的幂次方,因此找大于等于10且最接近的值,就是16,因此,传入16;当然,我们也可以使用Guava中提供的Maps.newHashMapWithExpectedSize(7)
方法,直接初始化期望容量的HashMap
2、ConcurrentHashMap
结构
ConcurrentHashMap底层也是由数组+链表组成,在jdk7中ConcurrentHashMap由segment数组(即将hashEntry数组分成几个小段得到的数组)+hashEntry数组构成,每个segment加一把锁(继承ReentrantLock,可重入锁,默认并发度16),互不影响;在jdk8中,与HashMap构成相同,采用Node数组+链表/红黑树组成,采用cas+synchronized,锁的是链表的头节点/红黑树的根节点,最大限度的保证并发性
put流程
计算key的hash值,开始自旋,判断是否初始化数组
定位到Node,去拿首节点f
如果f为null,f = tabAt(tab, i = (n - 1) & hash)
,通过cas插入节点
如果f.hash==MOVED(-1),参与扩容
synchronized锁住f节点(头节点),判断是链表还是红黑树,插入节点
判断是否达到树化条件,树化操作
get流程&是否需要加锁
计算key的hash值,判断数组是否为空,取到对应位置节点e = tabAt(tab, (n - 1) & h)
如果是头节点直接返回
如果是红黑树,就搜索树节点返回
如果是链表,就遍历链表返回
get是不需要加锁的,这是因为Node节点的value和next指针都是通过volatile
关键字修饰的,这个关键字保证了多线程下变量修改的可见性
是否允许key、value为null
答案是不允许,if (key == null || value == null) throw new NullPointerException();
直接抛出空指针异常。因为concurrentHashMap是线程安全的,假设允许value为null,那么就会产生歧义(这个key存储的是null,这个key不存在),比如我A线程获取到了null,假设是没有这个key,那么contains(key)应该返回false,但是在A线程判断key是否存在时,B线程插入了key-null,那么A线程contains(key)返回的就是true了,线程不安全。因此,为了避免歧义,value不允许为null
3、HashTable
结构
HashTable底层是由Entry数组构成的,每个Entry对象中都有next属性指向下一个对象,因此形成了一个链表,即数组+链表
初始化&扩容
默认初始化容量为11,默认的负载因子为0.75f,如果传入容量为0,则设置为1。扩容后的新容量=旧容量*2+1newCapacity = (oldCapacity << 1) + 1
,前提是计算出来的新容量不超过MAX_ARRAY_SIZE(最大数组容量,Integer.MAX_VALUE - 8)
get流程
计算key的hash值
根据hash值和数组长度计算key的数组下标
根据数组下标获取头节点,遍历链表返回对应节点
put流程
判断value是否为null,为null抛出空指针异常
获取key的hash值
根据hash值和数组长度计算下标
获取下标位置节点,逐个遍历
如果有相同key,覆盖值
否则添加节点
线程安全&是否允许key-value为null
HashTable是线程安全的,它的操作方法,例如put、get、containsKey、remove等都是添加了synchronized关键字修饰;key和value都不允许为null,这里的原因和上面ConcurrentHashMap同理
HashTable和HashMap的区别
HashMap线程不安全;HashTable线程安全
HashMap允许key和value为null;HashTable不允许
HashMap默认初始容量16,扩容后容量为之前的2倍;HashTable默认初始容量为11,扩容后容量为之前的2倍+1
HashMap在jdk8结构调整为数组+链表/红黑树;HashTable结构为数组+链表
HashMap效率比HashTable高,若要保证线程安全,考虑使用ConcurrentHashMap而非HashTable,HashTable是jdk1.0提出,HashMap是jdk1.2提出,因此,日常开发不要使用HashTable,基本被抛弃
二、Collection接口
1、ArrayList
结构
ArrayList底层是一个Object类型的数组,这也就导致了ArrayList增删慢,查询快的特性,默认数组长度为10,支持传入初始化容量,若传入值为0,则返回ArrayList内部定义的空数组Object[] EMPTY_ELEMENTDATA
,如果不传入数组容量,实际上也是初始化一个空数组Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA
,在第一次添加元素的时候进行容量的计算和初始化,先判断是否是DEFAULTCAPACITY_EMPTY_ELEMENTDATA数组,如果是,初始化默认容量和传入最小容量(数组长度+1)的最大值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
可以添加null元素,可以重复
扩容
ArrayList的扩容相对来说较为容易,首先计算新容量oldCapacity + (oldCapacity >> 1)
新容量 = 旧容量+旧容量的一半(旧容量右移一位),即新容量=1.5*旧容量
判断新容量是否小于最小容量(即扩容时传入的容量),如果小于,则保持当前容量
判断新容量是否大于最大容量(MAX_ARRAY_SIZE,值为Integer.MAX_VALUE - 8),如果大于,判断当前容量>最大容量(MAX_ARRAY_SIZE)?Integer.MAX_VALUE:MAX_ARRAY_SIZE
确定新容量后,通过Arrays.copyOf
将旧数据复制到新数组
线程安全
ArrayList是非线程安全的,例如两个线程向同一个ArrayList添加元素,那么ArrayList可能最终处于不一致的状态,两个线程添加的元素之一可能无法添加到列表中。如果想要保证线程安全,那么考虑使用CopyOnWriteArrayList
,在jdk1.0中还有vector,也实现了动态数组,使用了大量的synchronized保证线程安全,但是效率极低,目前很少使用,当然也可以使用Collections.synchronizedList
方法处理,它也是通过synchronized
关键字保证线程安全
补充:Vector
vector是线程安全的,操作方法通过synchronized关键字修饰,因此效率极低;vector默认初始容量是10,默认扩容为原来的2倍;vector底层也是Object类型的数组
2、CopyOnWriteArrayList
为什么线程安全
首先,CopyOnWriteArrayList和ArrayList在结构上没有太大的区别,我们注意到源码中有一个可重入锁的定义final transient ReentrantLock lock = new ReentrantLock();
,而且数组是通过volatile
关键字修饰的,保证了可见性,copyOnWrite翻译过来是写时复制,也就是说在操作写数据(增删改)操作时,是先通过重入锁加锁,然后复制一个新数组,对新数组进行操作,最后指向新数组。
3、LinkedList
结构
LinkedList底层是由node节点组成的双向链表,每个节点包含两部分:数据和对序列中前后节点的引用。添加元素可以为null,元素可以重复,添加时,从链表尾部添加;同时,LinkedList还实现了Deque接口,因此具有双端队列功能;非线程安全
ArrayList和LinkedList的区别
- 底层结构:ArrayList底层是Object类型的数组;LinkedList底层是Node节点组成的双向链表
- 增删元素:ArrayList受元素位置影响,默认是增加元素到数组尾部,时间复杂度O(1),如果固定位置,那就是O(n-i),需要移动其他元素;LinkedList删除元素的时间复杂度是O(1),只需要断开该元素的连接即可,增加元素默认到链表尾部,如果指定位置那时间复杂度接近O(n)
- 快速随机访问:ArrayList由于底层是数组,通过数组下标可以实现快速访问;LinkedList不能快速随机访问
4、HashSet&LinkedHashSet
结构
HashSet底层定义了一个HashMap,它的无参构造器就是实现一个HashMap,代码如下public HashSet() {map = new HashMap<>()}
,因此,HashSet基本上就是靠HashMap的结构和接口实现的,存储的是HashMap中的key,不允许重复,可以为null,无序;HashSet的有参构造可以有三个参数,除了初始容量和加载因子这两个HashMap中的参数外,还有一个布尔类型的dummy参数,通过它来控制实现的map是HashMap还是LinkedHashMap,同理,LinkedHashSet底层就是实现的LinkedHashMap
为什么数据不重复
HashSet 内部使用 HashMap 存储元素,对应的键值对的键为 Set 的存储元素,值为一个默认的 Object 对象PRESENT
,通过比较key的hash值和value来确定是否是同一个元素,也就是hashCode方法和equals方法
补充,hashCode和equals的关系:
两个对象相等,那么hashCode一定相等,equals方法返回true
两个对象的hashCode相等,这两个对象也不一定相等
如果重写了equals方法,那么一定要重写hashCode方法