目录
0.前言
个人学习、整理和记录Java容器相关知识点用。其中大部分内容来自以下地址,表示感谢。
CS-Notes
InfoQ-java集合之ArrayList源码解读
美团技术团队-Java 8系列之重新认识HashMap
1.概览
容器主要包括Collection和Map两种,Collection存储着对象的集合,而Map存储这键值对(两个对象)的映射表。
1.1 Set
TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如HashSet,HashSet查找时间复杂度为O(1)a,TreeSet复杂度为O(logN)。
HashSet:基于哈希表实现,内部实现就是HashMap,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用Iterator遍历HashSet得到的结果是不确定的。
LinkedHashSet:具有HashSet的查找效率,并且内部使用双向链表维护元素的插入顺序。
1.2 List
ArrayList:基于动态数组实现,支持随机访问。适用于查询多,删除插入少的场景。
Vector:和ArrayList类似,但是它是线程安全的。
CopyOnWriteArrayList:读写分离。
LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList还可以用作栈、队列和双向队列。适用于查询少,删除插入多的场景。
1.3 Queue
LinkedList:可以用它来实现双向队列。
PriorityQueue:基于堆结构实现,可以用来实现优先队列。
1.4 Map
TreeMap:基于红黑树实现。
HashMap:基于哈希表实现。
ConcurrentHashMap:和HashMap类似,但是是线程安全的。
LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用顺序(LRU)。
2 常用容器分析
默认基于Jdk1.8。
2.1 ArrayList
继承关系
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{ }
实现了RandomAccess接口,这是一个标记接口,官方解释是只要List实现了这个接口,就能支持随机访问,随机访问指的是可以使用索引遍历,即用for循环遍历数据,比使用iterator遍历数据更高效。
实现了Cloneable接口,这也是一个标记接口,表示该对象能被clone,即可以使用Object.clone()方法。如果没有实现Cloneable的类对象调用clone()方法会抛出CloneNotSupportException。且是深拷贝。
实现了Serializable接口,这也是一个标记接口,用于标记实现类可以序列化。
字段
ArrayList的字段以及说明如下:
/**
* 序列化版本标识,序列化和反序列化时使用
*/
private static final long serialVersionUID = 8683452581122892189L;
/**
* 默认的数据容量
*/
private static final int DEFAULT_CAPACITY = 10;
/**
* 用于ArrayList空实例的共享空数组实例
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* 用于默认大小空实例的共享空数组实例。
* 我们将DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA区别开来
* 以便在添加第一个元素时知道要膨胀多少。
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* 存放元素的数组
* 备注:字段不设置为私有,是为了方便内部类的访问
* 思考:为什么不是E[]呢?
*/
transient Object[] elementData;
/**
* 数组元素个数
*/
private int size;
构造方法
有三个构造方法
//1
// 创建一个容量为10的ArrayList
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//2
/**
* 1、创建ArrayList强制使用范型,避免使用原生类型引起类型不安全的问题
* 2、java7之后的jdk增强了类型推导,建议使用new ArrayList<>(),最好不使用new ArrayList<E>
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
//3
// 使用Collection的实现比如Set,List创建一个ArrayList
// 通常是Collection的实现进行相互转换
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
添加
有两种添加方式:单个添加(添加特定元素)和批量添加(添加集合)。
单个添加的流程如图:
相关源码如下:
/**
* 在ArrayList结尾添加元素
*/
public boolean add(E e) {
// 根据size处理容量
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
// 如果使用ArrayList()创建,默认容量=DEFAULT_CAPACITY=10
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
// minCapacity为此时elementData必须的最小长度
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
// 修改次数+1,用于fail-fast处理
modCount++;
// 如果minCapacity大于elementData的长度,则进行扩容处理
if (minCapacity - elementData.length > 0)
// 扩容,可能会引起溢出问题
grow(minCapacity);
}
// ArrayList动态扩容机制的核心
private void grow(int minCapacity) {
// 可能存在整型溢出
int oldCapacity = elementData.length;
// 容量默认扩大1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
// 可能1:newCapacity<0整型溢出
// 可能2:newCapacity<minCapacity
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 数组深拷贝
elementData = Arrays.copyOf(elementData, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0)
// 说明已经整型溢出
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
/**
* 在ArrayList特定位置添加单个元素
* 思考:add(E e)没有调用add(int index, E element),个人猜测是出于性能的考虑
* 毕竟基于数组进行插入操作可能存在性能问题
*/
public void add(int index, E element) {
// 检查位置是否合法
rangeCheckForAdd(index);
// 跟add(E e)中处理方式类似
ensureCapacityInternal(size + 1);
// 将elementData中位置为index位置及其后面的元素都向后移动一个下标(底层是native方法,使用cpp直接操作内存。)
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
添加元素时,先使用ensureCapacityInternal()方法来保证容量足够,如果不够,使用grow()方法进行扩容,新容量的大小约为老容量的1.5倍。扩容操作需要调用Arrays.copyOf()把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建ArrayList对象时就指定大概的容量大小,减少扩容操作的次数。
批量添加的相关代码如下:
//
public boolean addAll(Collection<? extends E> c) {
// 集合转化成数组
Object[] a = c.toArray();
int numNew = a.length;
// 跟add(E e)中处理方式类似
ensureCapacityInternal(size + numNew);
// 将集合内的元素复制到elementData中,覆盖[size, size+numNew)的元素
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
public boolean addAll(int index, Collection<? extends E> c) {
// 检查位置是否合法
rangeCheckForAdd(index);
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew);
int numMoved = size - index;
if (numMoved > 0)
// 将elementData中位置为index及其以后的元素都向后移动numNew个位置
System.arraycopy(elementData, index, elementData, index + numNew, numMoved);
// 将集合内的元素复制到elementData中,覆盖[index, index+numNew)的元素
System.arraycopy(a, 0, elementData, index, numNew);
size += numNew;
return numNew != 0;
}
删除
删除可以分为四种:单个删除(删除特定元素、特定下标)、批量删除(删除集合中的元素)、批量保留(批量删除除集合外的元素)、清空。
单个删除,相关代码如下:
// 删除ArrayList中第一次出现的特定元素
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
// 比较对象时依赖equals方法
// 因此类型变量E对应的类注意重写equlas方法
// 重写时注意遵守规范,具体参考effective java第三版的第10、11两条规则
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
// 根据下标删除元素
private void fastRemove(int index) {
modCount++;
int numMoved = size - index - 1;
if (numMoved > 0)
// 将elemenData中index+1及其后面的元素都向前移动一个下标
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 根据上一步的操作, size-1位置的对象向前移动了一个下标
// 如果没有elementData[--size]==null,可能会导致内存泄漏
// 试想,ArrayList被add了100个对象,然后被remove了100次。按照GC的机制来说,100个对象应该可以被GC掉(假设没有对象对象),但是由于还存在ArrayList的实例引用,对应的100个对象就无法删除
elementData[--size] = null;
}
// 根据下标删除元素
// 注意:java5后引入自动装箱、拆箱的机制,因此产生了一个有趣的问题:
// 当类型变量为Integer的ArrayList调用remove时,可能调用remove(Object),也可能调用remove(Index)
// 一定要注意测试是否符合自己的预期
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
// 如果被删除元素不是ArrayList的最后一个元素
if (numMoved > 0)
// 对应下标之后的元素向前移动一个下标
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 最后一个元素只为null,方便GC
elementData[--size] = null;
return oldValue;
}
需要调用System.arraycopy()将index+1后面的元素都复制到index位置上,该操作的时间复杂度为O(N),可以看到ArrayList删除元素的代价是比较高的。
批量删除和清空
// 批量删除ArrayList和集合c都存在的元素
public boolean removeAll(Collection<?> c) {
// 非空校验
Objects.requireNonNull(c);
// 批量删除
return batchRemove(c, false);
}
private boolean batchRemove(Collection<?> c, boolean complement){
final Object[] elementData = this.elementData;
int r = 0, w = 0;
boolean modified = false;
try {
for (; r < size; r++)
if (c.contains(elementData[r]) == complement)
// 把需要保留的元素前置
elementData[w++] = elementData[r];
} finally {
// 即使c.contains抛异常,也要保持跟AbstractCollection行为的兼容性
// 备注:ArrayList重写了AbstractCollection中的removeAll方法,removeAll调用了batchRemove
if (r != size) {
// 备注1:可能是上面的for循环出现了异常
// 备注2:可能是其它线程添加了元素。
System.arraycopy(elementData, r,
elementData, w,
size - r);
w += size - r;
}
if (w != size) {
for (int i = w; i < size; i++)
// 跟fastRemove(int index)里面的操作类似,防止内存泄漏
elementData[i] = null;
// 思考:为什么addAll的modCount+1,而removeAll的modCoun+size-w
// 个人以为modCount只是做标记做了结构的修改并且用来做校验。
// 因此+1,+2 +size-w并没有本质区别
modCount += size - w;
size = w;
modified = true;
}
}
return modified;
}
// 思考:上面是按照元素进行批量删除,如何按照下标区间进行批量删除呢?
批量保留
public boolean retainAll(Collection<?> c) {
Objects.requireNonNull(c);
// 批量保留
return batchRemove(c, true);
}
public void clear() {
modCount++;
// 清空ArrayList里面所有的元素
for (int i = 0; i < size; i++)
elementData[i] = null;
size = 0;
}
查找
// 返回元素第一次出现的下标
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
// 返回最后一次出现的位置
public int lastIndexOf(Object o) {
if (o == null) {
for (int i = size-1; i >= 0; i--)
if (elementData[i]==null)
return i;
} else {
for (int i = size-1; i >= 0; i--)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
序列化
ArrayList基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,所以不用直接序列化elementData,而是根据size序列化包含的元素,忽略数组中的其他位置,提高效率并节省空间,通过使用writeObject() 和 readObject()方法来实现的。
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// fail-fast,后续判断是否有并发处理
int expectedModCount = modCount;
// 序列化没有标记为static、transient的字段,包括size等。
s.defaultWriteObject();
// 没有意义,可以忽略
s.writeInt(size);
// 序列化元素
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
// ArrayList被并发处理,发生结构性修改
throw new ConcurrentModificationException();
}
}
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
// 反序列化没有标记为static、transient的字段,包括size等
s.defaultReadObject();
// 可以忽略,跟writeObject里面的方法对应
s.readInt();
if (size > 0) {
// 数组扩容
ensureCapacityInternal(size);
Object[] a = elementData;
// 反序列化元素并填充到数组中
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
保存元素的数组elementData使用transient修饰,该关键字声明数组默认不会被序列化。
transient Object[] elementData; // non-private to simplify nested class access
Fail-Fast
modCount用来记录ArrayList结构发生变化的次数。结构发生变化是指添加或删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构变化。
在进行序列化或者迭代等操作时,需要比较操作前后modCount是否改变,如果改变了需要抛出ConcurrentModificationException。
2.2 CopyOnWriteArrayList
是一个并发安全的ArrayList,可以做到读写分离。写操作在一个复制的数组上进行,读操作在原始数组上进行,所以能做到读写分离,互不影响。
添加
@Override
public boolean add(E e) {
/**
* 增加元素 e 到数组的末尾
* 操作步骤:
* 1. 获取全局的 reentrantLock
* 2. 将原来的 array1 copy 到一个 array.length + 1 的数组 array2 里面
* 3. 将 先添加的元素e添加到新数组 array2 的最后一个空间里面 (array2[array2.length - 1] = e)
* 4. 将 新数组 array2 赋值给 CopyOnWriteArrayList 中的 array
*/
final ReentrantLock lock = this.lock;
lock.lock(); // 1. 获取 全局 lock
try{
Object[] elements = getArray(); // 2. 获取原来的数组
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1); // 3. 新建一个 array2 将原来的数据赋值到这个新建的数组里面
newElements[len] = e; // 4. 将 e 赋值给 array2的最后一个空间里面
setArray(newElements); // 5. 将新数组 array2 赋值给 CopyOnWriteArrayList 中的 array
return true;
}finally {
lock.unlock(); // 6. 释放锁
}
}
@Override
public void add(int index, E element) {
/**
* 将元素 e 插入到数组 指定的索引下标 index 下
* 操作步骤:
* 1. 获取全局的锁
* 2. 获取 CopyOnWriteArrayList 的 array, 及 array.length
* 3. 进行参数校验 (index > len || index < 0) 则直接抛异常 -> 说明元素的插入只能在 0 - array.length 之间(包含两个端点)
* 4. 获取插入点 index 与 array.length 之间的步长, 进行分类讨论
* 1) 插入的数据正好在 原array数组的后一个节点 (numMoved = len), 则直接新建一个 array, 将原来的 array copy 过来
* 2) 插入的 index 满足 0 <= index <= len - 1, 则新建一个数组, 原来 o -> index(index不包含) 拷贝来, index后面的数据拷贝到新数组的 index + 1 的空间
* 5. 将 e 设置到 新 array 的 index 位置
* 6. 将 新 array 设置到 CopyOnWriteArrayList 里面
*/
final ReentrantLock lock = this.lock;
lock.lock(); // 1. 获取全局的锁
try{
Object[] elements = getArray();
int len = elements.length;
if(index > len || index < 0){
throw new IndexOutOfBoundsException("Index: " + index + ", Size:" + len);
}
Object[] newElements;
int numMoved = len - index;
if(numMoved == 0){ // 走到这一步, 说明 数据是插入到 oldArray.length(这个值是指下标) 位置上的元素
newElements = Arrays.copyOf(elements, len + 1); // 直接拷贝原数组到一个新的 array 数组中, 这个数组的长度是 len + 1
}else{
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index); // 将原数组 index 前的数组都拷贝到新的数组里面
System.arraycopy(elements, index, newElements, index + 1, numMoved); // 将原数组 index 以后的元素都 copy到新的数组里面(包括index位置的元素)
}
newElements[index] = element; // 将 index 赋值 element
setArray(newElements); // 将 新的 array set到 CopyOnWriteArrayList 上
}finally {
lock.unlock();
}
}
删除
@Override
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try{
Object[] elements = getArray();
int len = elements.length;
E oldValue = get(elements, index);
int numMoved = len - index - 1;
if(numMoved == 0){ // 说明删除的元素的位置在 len - 1 上, 直接拷贝原数组的前 len - 1 个元素
setArray(Arrays.copyOf(elements, len - 1));
}else{
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index); // 拷贝原数组 0 - index之间的元素 (index 不拷贝)
System.arraycopy(elements, index + 1, newElements, index, numMoved); // 拷贝原数组 index+1 到末尾之间的元素 (index+1也进行拷贝)
setArray(newElements);
}
}finally {
lock.unlock();
}
return null;
}
public boolean remove(Object o){
Object[] snapshot = getArray();
// 获取 index 在 snapshot 中的位置, -1 表示不存在
int index = indexOf(o, snapshot, 0, snapshot.length);
return (index < 0) ? false : remove(o, snapshot, index);
}
/**
* A version of remove(Object) using the strong hint that given
* recent snapshot contains o at the given index
*/
private boolean remove(Object o, Object[] snapshot, int index){
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] current = getArray();
int len = current.length;
// findIndex: <- 这个用法平时用的比较少, 在这里, 只要 break findIndex, 那 if(snapshot != current) 这里括号里面的其他代码就不执行了, 直接跳到括号外面, 建议写个小demo试一下
if(snapshot != current) findIndex:{ // snapshot != current 表示数组被另外一个线程操作过, 有变化
/**
* 下面的操作是发生在 调用方法 "remove(Object o)" 中的 "indexOf"后 , 数组 array 发生变化而做的查询修正工作
* 主要分 下面 4 中情况:
* 1. 从 index,len 中取出一个较小的值 prefix, 从 current的prefix前个元素中寻找元素 o, 找到后, 直接 break, 执行下面的操作
* 2. 若 index >= len, 则说明 元素 o 在另外的线程中已经被删除, 直接 return
* 3. current[index] = o, 则说明, index 位置上的元素 o 还在那边, 直接 break
* 4. 最后 在 index 与 len 之间寻找元素, 找到位置直接接下来的代码, 没找到 直接 return
*/
int prefix = Math.min(index, len);
for(int i = 0; i < prefix; i++){
// 找出 current 数组里面 元素 o 所在的位置 i, 并且赋值给 index
if(current[i] != snapshot[i] && eq(o, current[i])){
index = i;
break findIndex;
}
}
if(index >= len){ // index >= len 表示元素 o 已经被删除掉
return false;
}
if(current[index] == o){ // 元素 o 也在数组 current 的 index 位置
break findIndex;
}
index = indexOf(o, current, index, len); // 在 current 中寻找元素 o 所在的位置 (这里不会出现 index > len 的情况, 上面的代码中已经做了判断)
if(index < 0){ // 要删除的元素 在另外的线程中被删除掉了, 直接 return false
return false;
}
}
Object[] newElements = new Object[len - 1]; // 新建一个 len - 1 长度的数组
System.arraycopy(current, 0, newElements, 0, index); // 拷贝老数组前 index 个元素
System.arraycopy(current, index + 1, newElements, index, len - index - 1); // 拷贝 老数组 index + 1 后的元素 ( index + 1 包含)
setArray(newElements);
return true;
}finally {
lock.unlock();
}
}
使用场景
CopyOnWriteArrayList在写操作的同时允许读操作,大大提升了读操作的性能,因此很适合读多写少的应用场景。
但是缺陷也比较明显:
1.内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
2.数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还没有同步到读数组中。
所以CopyInWriteArrayList不适用内存敏感以及对实时性要求很高的场景。
2.3 HashMap
继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{ }
实现了Cloneable接口,这也是一个标记接口,表示该对象能被clone,即可以使用Object.clone()方法。如果没有实现Cloneable的类对象调用clone()方法会抛出CloneNotSupportException。且是深拷贝。
实现了Serializable接口,这也是一个标记接口,用于标记实现类可以序列化。
存储结构
HashMap是数组+链表+红黑树(JDK8增加的红黑树)实现,如下图:
存储数据的是数组Node[] table,数组里面的元素是链表或者红黑树。Node是HashMap的一个内部类,实现了Map.Entry接口,本质上就是一个映射(键值对),上图的每个黑色圆点就是一个Node对象。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //用来定位数组索引位置
final K key;
V value;
Node<K,V> next; //链表的下一个node
Node(int hash, K key, V value, Node<K,V> next) { ... }
public final K getKey(){ ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
JDK8增加红黑树,是为了当出现拉链过长(哈希值集中在某几个桶位置)时,也能够利用红黑树快速增删改查的特点,提升HashMap的性能
字段
列举几个关键字段
//默认值
//数组table的默认大小
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组的最大大小
static final int MAXIMUM_CAPACITY = 1 << 30;
//装载因子的默认大小
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转红黑树的阈值,JDK8新加
static final int TREEIFY_THRESHOLD = 8;
//实际使用的值
//存储数据的数组,数组的元素是链表或者是红黑树,长度是2的n次方
transient Node<K,V>[] table;
//键值对的数量
transient int size;
//size的临界值,当size大于等于这个值时,需要进行扩容操作
int threshold;
//装载因子,threshold= loadFactor*table的大小
final float loadFactor;
//修改次数,用来记录内部结构发生变化,主要用于迭代的快速失败,同ArrayList
transient int modCount;
功能实现
主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个点展开。
1.确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。HashMap的数据结构是数组和链表的结合,所以我们希望这个HashMap里面的元素位置尽量均匀些,尽量每个位置上的元素数量只有一个,这种情况下,当我们用hash算法求得这个位置的时候,就马上可以知道对应位置的元素,不用遍历链表,大大优化了查询效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。下面是java源码:
方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
hash算法本质上是三步:取key的HashCode值、高位运算、取模运算。
对于任意给定的对象,只要他的hashCode()返回值相同,那么程序调用方法一所计算得到的hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算(h%len),这样一来,元素的分布相对来说是比较均匀的。但是,取模运算的消耗是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。这个方法非常巧妙,它通过h&(table.len - 1)来得到该对象的保存位,而hashMap规定数组的长度总是2的n次方,当len总是2的n次方时,h&(len-1)等价于取模运算h%len,但是具有更高的效率。
在JDK8的实现中,优化了高位运算的算法,通过hashCode的高16位异或低16位实现:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑,这么做可以在数组table的len比较小的时候,也能保证高低bit都参与到hash的计算中,同时不会有太大的开销。
2.分析HashMap的put方法流程
流程图如下:
①判断键值对数组table是否为空或null,如果是执行resize进行扩容;
②根据键key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果不为空,转向③;
③判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④判断table[i]是否是treeBode红黑树,如果是,则直接在树中插入键值对,否则转向⑤;
⑤遍历table[i],判断链表长度是否大于8,大于8就把链表转换成红黑树,在树中执行插入操作,否则在链表中执行插入,遍历过程中若发现key已经存在,直接覆盖value即可;
⑥插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过,进行扩容。
JDK8中的HashMap的put方法源码如下:
1 public V put(K key, V value) {
2 // 对key的hashCode()做hash
3 return putVal(hash(key), key, value, false, true);
4 }
5
6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
7 boolean evict) {
8 Node<K,V>[] tab; Node<K,V> p; int n, i;
9 // 步骤①:tab为空则创建
10 if ((tab = table) == null || (n = tab.length) == 0)
11 n = (tab = resize()).length;
12 // 步骤②:计算index,并对null做处理
13 if ((p = tab[i = (n - 1) & hash]) == null)
14 tab[i] = newNode(hash, key, value, null);
15 else {
16 Node<K,V> e; K k;
17 // 步骤③:节点key存在,直接覆盖value
18 if (p.hash == hash &&
19 ((k = p.key) == key || (key != null && key.equals(k))))
20 e = p;
21 // 步骤④:判断该链为红黑树
22 else if (p instanceof TreeNode)
23 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24 // 步骤⑤:该链为链表
25 else {
26 for (int binCount = 0; ; ++binCount) {
27 if ((e = p.next) == null) {
28 p.next = newNode(hash, key,value,null);
//链表长度大于8转换为红黑树进行处理
29 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
30 treeifyBin(tab, hash);
31 break;
32 }
// key已经存在直接覆盖value
33 if (e.hash == hash &&
34 ((k = e.key) == key || (key != null && key.equals(k))))
35 break;
36 p = e;
37 }
38 }
39
40 if (e != null) { // existing mapping for key
41 V oldValue = e.value;
42 if (!onlyIfAbsent || oldValue == null)
43 e.value = value;
44 afterNodeAccess(e);
45 return oldValue;
46 }
47 }
48 ++modCount;
49 // 步骤⑥:超过最大容量 就扩容
50 if (++size > threshold)
51 resize();
52 afterNodeInsertion(evict);
53 return null;
54 }
扩容机制
扩容(resize)就是重新计算容量,Java的数组是无法自动扩容的,所以使用一个新的数组代替已有的数组,就像是用小桶装水,如果要装更多的水,就需要换大桶。
由于JDK的resize融入了红黑树相关的逻辑,比较复杂,为了好理解使用JDK7的代码,本质上区别不大。
1 void resize(int newCapacity) { //传入新的容量
2 Entry[] oldTable = table; //引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; //HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }
这里使用一个容量更大的数组来代替已有的小容量数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }
newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突),这一点和JDK8有点区别。在就数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能放到了新数组不同的位置。
说说JDK8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(长度是原来的2倍),所以,元素的新位置要么是原位置,要么是原位置再移动2次幂的位置,具体看下图。n为table的长度,图a表示扩容前的key1和key2两种key确定索引位置的示例,图b表示扩容后key1和key2两种key确定索引位置的示例,hash1表示key1对应的哈希与高位运算结果。
元素在重新计算hash后,因为n变成了2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此在扩充HashMap时,不需要想JDK7那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0,0索引不变,1索引变成“原索引+oldCap”
小结
1.扩容特别耗性能,所以使用HashMap时可以先估算map大小,初始化时给一个大致的数值,避免频繁扩容;
2.负载因子0.75是平衡时间和空间后的取值,可以修改,也可以大于1,但是一般不建议修改,除非情况特殊且你明确知道你的修改代表什么(如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值);
3.HashMap是线程不安全的,并发情况使用ConcurrentHashMap;
4.JDK8引入的红黑树,很大程度上优化了HashMap的性能。
为什么要求table的长度为2的n次方:
主要有以下原因,1.确定索引的时候,当len=2的幂次,h%len等价h & (len - 1),但是&运行效率高于%运算;2.当len=2的幂次时,让数据分布更均匀,减少冲突。3.扩容的时候,可以快速确定元素的新索引位置(原位置 或者 原位置+oldCap)。
2.4 ConcurrentHashMap
比较HashMap、HashTable、ConcurrentHashMap。
HashMap不是线程安全的,不能在并发的情况下使用。
HashTable是线程安全的,但是是通过整个加锁的方式,当一个线程在写操作时,其他的线程不能读写。效率不够。
ConcurrentHashMap线程安全,支持了并发读写,效率得到提高。Java1.7及其之前,使用的是分段锁实现,每一个锁维护多个桶,当写数据时,其他桶位置依然可以进行读写;Java1.8之后,采用CAS(无锁算法)+synchronized实现。此外,HashMap的键值对允许有null,但是ConcurrentHashMap都不允许。