由于最近准备面试特总结相关面试题、所以文章会不断更新维护、文中如有错误请下方留言。
Java集合常用数据结构简要类图
iterable 可迭代接口是所有集合框架顶级接口、实现此接口Java类能够使用 迭代器进行访问集合,JDK 1.8添加了 forEach()
所以实现此接口也能够实现 for-each表达式
/**
* Performs the given action for each element of the {@code Iterable}
* until all elements have been processed or the action throws an
* exception. Unless otherwise specified by the implementing class,
* actions are performed in the order of iteration (if an iteration order
* is specified). Exceptions thrown by the action are relayed to the
* caller.
*
* @implSpec
* The default implementation behaves as if:
*
{@code
* for (T t : this)
* action.accept(t);
* }
*
* @param action The action to be performed for each element
* @throws NullPointerException if the specified action is null
* @since 1.8
*/
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
类图:
HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素
HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。
HashSet的添加和删除源码如下:
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
HashSet 中的add ()方法会使用HashMap 的put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。
HashMap | HashSet |
---|---|
实现了Map接口 | 实现Set接口 |
存储键值对 | 仅存储对象 |
调用put()向map中添加元素 | 调用add()方法向Set中添加元素 |
HashMap使用键(Key)计算Hashcode | HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回false |
HashMap相对于HashSet较快,因为它是使用唯一的键获取对象 | HashSet较HashMap来说比较慢 |
HashSet 是用一个 hash 表来实现的,因此,它的元素是无序的。添加,删除和 HashSet 包括的方法的持续时间复杂度是 O(1) 。
TreeSet 是用一个树形结构实现的,因此,它是有序的。添加,删除和 TreeSet 包含的方法的持续时间复杂度是 O(logn) 。
类图:
LinkedHashSet 继承于 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 Hashmap 实现一样,不过还是有一点点区别的。
LinkedHashSet 部分源码如下:
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
}
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true);
}
public LinkedHashSet() {
super(16, .75f, true);
}
public LinkedHashSet(Collection<? extends E> c) {
super(Math.max(2*c.size(), 11), .75f, true);
addAll(c);
}
}
可以看出LinkedHashSet公共构造器内部均是调用 HashSet中三个参数构造器,HashSet 三个参数构造器源码如下:
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
可以看出内部是使用 LinkedHashMap 来实现的。
类图:
TreeSet 内部基于TreeMap实现
(有序,唯一): 红黑树(自平衡的排序二叉树。)
TreeSet 部分源码如下:
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable{
private transient NavigableMap<E,Object> m;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
/**
* Constructs a set backed by the specified navigable map.
*/
TreeSet(NavigableMap<E,Object> m) {
this.m = m;
}
public TreeSet() {
this(new TreeMap<E,Object>());
}
public TreeSet(Comparator<? super E> comparator) {
this(new TreeMap<>(comparator));
}
public TreeSet(Collection<? extends E> c) {
this();
addAll(c);
}
public TreeSet(SortedSet<E> s) {
this(s.comparator());
addAll(s);
}
}
从源码中我们看到TreeSet有如下功能:
List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
类图:
ArrayList的优点如下:
ArrayList 的缺点如下:
ArrayList 比较适合顺序添加、随机访问的场景。
ArrayList 不是线程安全的,如果遇到多线程场景,可以通过 Collections 的 synchronizedList 方法将其转换成线程安全的容器后再使用。
代码示例:
List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("aaa");
synchronizedList.add("bbb");
for (int i = 0; i < synchronizedList.size(); i++) {
System.out.println(synchronizedList.get(i));
}
ArrayList 中的数组定义如下:
private transient Object[] elementData;
再看一下 ArrayList 的定义:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
可以看到 ArrayList 实现了 Serializable 接口,这意味着 ArrayList 支持序列化。transient 的作用是说不希望 elementData 数组被序列化,重写了 writeObject 实现:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{
*// Write out element count, and any hidden stuff*
int expectedModCount = modCount;
s.defaultWriteObject();
*// Write out array length*
s.writeInt(elementData.length);
*// Write out all elements in the proper order.*
for (int i=0; i<size; i++)
s.writeObject(elementData[i]);
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大小。
数组的特点是:寻址容易,插入和删除困难; 链表的特点是:寻址困难,插入和删除容易。
这两个类都实现了 List 接口,他们都是有序集合
ArrayList不是同步的,所以在不需要保证线程安全时时建议使用Arraylist。
遍历方式有以下几种:
for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。
迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java 在 Collections 中支持了 Iterator 模式。
foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换。
最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access。
如果一个数据集合实现了该接口,就意味着它支持 RandomAccess,按位置读取元素的平均时间复杂度为 O(1),如ArrayList。
如果没有实现该接口,表示不支持 Random Access,如LinkedList。
推荐的做法就是,支持 RandomAccess 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历。
类图:
要想理解HashMap
首先看一下HashMap的一些配置参数:
/**
* 默认HashMap容量大小 16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* HashMap 最大容量 1073741824
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表长度大于此值且 容量大于 64 时会将链表转换成红黑树表示
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转换成链表时阈值 如果红黑树长度小于6 会转换成 链表表示
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
*红黑树最小容量
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 第一次初始化的数据表,在扩容时长度总是保证 2的幂
*/
transient Node<K, V>[] table;
/**
* 转换成set集合
*/
transient Set<Map.Entry<K, V>> entrySet;
/**
* map集合的大小
*/
transient int size;
/**
* 此哈希映射在结构上被修改的次数
* 哈希映射或以其他方式修改其内部结构(例如。,
* 重新整理)。此字段用于对的集合视图生成迭代器遍历 fail-fast 机制 否则会抛出 ConcurrentModificationException
*/
transient int modCount;
/**
* 下一次需要调整的大小 (容量 * 负载因子)
*/
int threshold;
/**
* 负载因子
*/
final float loadFactor;
我们一般成HashMap中数组存储是哈希桶那是为什么呢?看一下内部定义:
static class Node<K, V> implements Map.Entry<K, V> {
// hash 值
final int hash;
final K key;
V value;
// 后继指针
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final String toString() {
return key + "=" + value;
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
可以看出每个哈希桶中包含了四个字段:hash、key、value、next,其中 next 表示链表的下一个节点。
JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。
那我们看一下HashMap中红黑树的定义:
static final class TreeNode<K, V> extends LinkedHashMap.Entry<K, V> {
TreeNode<K, V> parent; // red-black tree links
TreeNode<K, V> left;
TreeNode<K, V> right;
TreeNode<K, V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K, V> next) {
super(hash, key, val, next);
}
//省略部分代码
}
至此我们知道了HashMap底层的数据存储和相关配置参数等。
所以HashMap的底层存储结构如下:
上图比较清晰的说明了HashMap的底层结构,下面我们就看几个比较重要的方法实现
put()
put()方法是往HashMap中存入数据的函数,相关源码如下:
/**
* 添加元素
* 首先对key 进行hash() 操作
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* 添加元素具体实现方法
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 临时数组
Node<K, V>[] tab;
Node<K, V> p;
//数组长度、
int n, i;
// 如果此时table数组为null或长度为0 则进行一次扩容
if ((tab = table) == null || (n = tab.length) == 0)
//进行一次扩容
n = (tab = resize()).length;
//根据key的hash值 确定要插入的数组索引
if ((p = tab[i = (n - 1) & hash]) == null)
//如果tab[i] == null 则直接在尾部插入一个新节点
tab[i] = newNode(hash, key, value, null);
else {
Node<K, V> e;
K k;
//如果key值已经存在则直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//key 不存在则判断是否是 红黑树 直接插入红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
else {
//链表结构 循环准备插入
for (int binCount = 0; ; ++binCount) {
//找到next 后继指针为 null 的链表节点 准备插入
if ((e = p.next) == null) {
//添加链表节点
p.next = newNode(hash, key, value, null);
// 链表长度大于 8 将链表转换成 红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//key值存在覆盖 value
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//存在相同的 key 使用新值替代旧值 返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//操作次数增加
++modCount;
//如果大小 大于扩容阈值 进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从上面代码可以看到put()方法实际实现逻辑都在内部函数putVal()方法中实现的,也看到在进行添加操作时:
put()方法的流程图如下:
在进行put()中有一个比较重要的方法是扩容方法resize()
我们看一下resize()
具体是怎么做的
resize()
resize()
源码如下:
/**
* 初始化或扩容表大小。如果为空,则分配与初始容量目标保持一致。否则,因为我们使用的是2的幂,所以
* 每个bin中的元素必须保持在同一索引中,或者移动在新表中使用两个偏移量的幂。
*/
final Node<K, V>[] resize() {
// 旧数组
Node<K, V>[] oldTab = table;
// 旧的数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧 容器达到扩容阈值
int oldThr = threshold;
// 新的数组大小、新下一次达到扩容阈值大小
int newCap, newThr = 0;
if (oldCap > 0) {
// 如果大于最大的容量 不进行扩容 直接返回旧 table
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
} else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 扩容为原来 2倍
} else if (oldThr > 0) // initial capacity was placed in threshold 当前数组没有数据,使用初始化的值
newCap = oldThr; // 将下一次扩容阈值 和 新数组大小设置为一样值
else { // zero initial threshold signifies using defaults 初始化大小
newCap = DEFAULT_INITIAL_CAPACITY; // 16 默认数组大小
newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12 = 0.75*16 默认下次达到扩容的阈值
}
// 如果新的达到扩容阈值为 0 则重新计算下次扩容阈值
if (newThr == 0) {
float ft = (float) newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
(int) ft : Integer.MAX_VALUE);
}
// 将局部数据 同步至全局数据
threshold = newThr;
//直接创建一个新的数组
@SuppressWarnings({"rawtypes", "unchecked"})
Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
//将局部数据同步至全局数据
table = newTab;
//从旧数组copy数据到 新数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K, V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) //如果链表只有一个 则直接插入
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //处理红黑树数据
((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
else { // preserve order
//链表复制 JDK 1.8 优化
Node<K, V> loHead = null, loTail = null;
Node<K, V> hiHead = null, hiTail = null;
Node<K, V> next;
do {
next = e.next;
//原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
} else { //原索引 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//将原索引直接放入hash桶中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//将原索引+oldCap 放入哈希桶中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新数组
return newTab;
}
从以上源码可以看出,JDK 1.8 在扩容时并没有像 JDK 1.7 那样,重新计算每个元素的哈希值,而是通过高位运算(e.hash & oldCap)来确定元素是否需要移动,比如 key1 的信息如下:
使用 e.hash & oldCap 得到的结果,高一位为 0,当结果为 0 时表示元素在扩容时位置不会发生任何变化,而 key 2 信息如下:
这时候得到的结果,高一位为 1,当结果为 1 时,表示元素在扩容时位置发生了变化,新的下标位置等于原下标位置 + 原数组长度,如下图所示:
其中红色的虚线图代表了扩容时元素移动的位置。
get()方法
get()方法是获取HashMap中存储Value数据,相关源码如下:
/**
* 先对key 进行 hash() 的hash值进行查找桶的位置
*/
public V get(Object key) {
Node<K, V> e;
// 得到一个节点,之后从节点中查找value
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 获取HashMap上节点数据
*/
final Node<K, V> getNode(int hash, Object key) {
// 临时数组
Node<K, V>[] tab;
// 第一个节点 、要查找的节点
Node<K, V> first, e;
// 数组长度
int n;
K k;
// 当前数组 != null 且 数组长度大于 0 且 第一个节点不等于null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 要查找的值 正好是第一个节点 一次就命中
//最好情况时间复杂度是 O(1)
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 要查找的值 不是第一个节点
if ((e = first.next) != null) {
// 如果是红黑树节点 则调用 getTreeNode() 来获取
// 时间复杂度O(log n)
if (first instanceof TreeNode)
return ((TreeNode<K, V>) first).getTreeNode(hash, key);
// 链表表示 循环匹配
// 最坏情况下 时间复杂度O(n)
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 不满足上述条件 直接返回null
return null;
}
从以上源码可以看出,当哈希冲突时我们需要通过判断 key 值是否相等,才能确认此元素是不是我们想要的元素。
经典回答:
在 JDK 1.7 中 HashMap 是以数组加链表的形式组成的,JDK 1.8 之后新增了红黑树的组成结构,当链表大于 8 并且容量大于 64 时,链表结构会转换成红黑树结构
JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。查询时间复杂度从原来的O(n)到O(logn)
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
JDK1.8主要解决或优化了一下问题:
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数 resize()中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树 |
插入数据方式 | 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量) |
当我们put的时候,首先计算 key的hash值,这里调用了 hash()
方法,hash()
方法实际是让key.hashCode()
与key.hashCode()>>>16
进行异或操作,高16bit补0,一个数和0异或不变,所以 hash()
函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。
putVal方法执行流程图:
putVal()
源码参考上述简要源码分析中。
resize()
扩容函数请参考上文中HashMap简要源码分析中。
加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。
那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?
这其实是出于容量和性能之间平衡的结果:
当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
还有一个必要条件是那就是HashMap的大小一定是2的幂。所以,如果默认负载因子是3/4的话,那么和capacity的乘积结果就可以是一个整数
所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。
答:在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行;
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性**:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同**。
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:
这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化
上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn)
简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。
答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;
以 JDK 1.7 为例,假设 HashMap 默认大小为 2,原本 HashMap 中有一个元素 key(5),我们再使用两个线程:t1 添加元素 key(3),t2 添加元素 key(7),当元素 key(3) 和 key(7) 都添加到 HashMap 中之后,线程 t1 在执行到 Entry
void transfer(Map.Entry[] newTable,boolean rehash){
int newCapacity = newTable.length;
for (Entry<K,V> e:table){
while(null!=e){
Map.Entry<K,V>next=e.next;//线程一执行此处
if(rehash){
e.hash=null==e.key ?0:hash(e.key);
}
int i=indexFor(e.hash,newCapacity);
e.next=newTable[i];
newTable[i]=e;
e=next;
}
}
}
那么此时线程 t1 中的 e 指向了 key(3),而 next 指向了 key(7) ;之后线程 t2 重新 rehash 之后链表的顺序被反转,链表的位置变成了 key(5) → key(7) → key(3),其中 “→” 用来表示下一个元素。
当 t1 重新获得执行权之后,先执行 newTalbe[i] = e 把 key(3) 的 next 设置为 key(7),而下次循环时查询到 key(7) 的 next 元素为 key(3),于是就形成了 key(3) 和 key(7) 的循环引用,因此就导致了死循环的发生,如下图所示:
当然发生死循环的原因是 JDK 1.7 链表插入方式为首部倒序插入,这个问题在 JDK 1.8 得到了改善,变成了尾部正序插入。
有人曾经把这个问题反馈给了 Sun 公司,但 Sun 公司认为这不是一个问题,因为 HashMap 本身就是非线程安全的,如果要在多线程下,建议使用 ConcurrentHashMap 替代,但这个问题在面试中被问到的几率依然很大,所以在这里需要特别说明一下。
类图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-myKNwJzM-1593957124184)(https://note.youdao.com/yws/res/14990/WEBRESOURCE2670d4f483b2eed710b1ad6218d2b7ee)]
先看一下ConcurrentHashMap的定义和相关配置参数:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
/**
* 最大容量
*/
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认大小
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 加载因子
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 转换成红黑树的阈值
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 将红黑树转换成链表的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 红黑树最小存储容量
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* The bit shift for recording size stamp in sizeCtl.
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
/*
* Encodings for Node hash fields. See above for explanation.
*节点上的hash编码
*/
static final int MOVED = -1; // hash for forwarding nodes 正在移动节点
static final int TREEBIN = -2; // hash for roots of trees 树的root节点
static final int RESERVED = -3; // hash for transient reservations 重置
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
可以看到很多配置和HashMap是一致的。接下来看一下put()
是如何实现的。
put()方法
put()
方法源码如下:
/**
* 存入数据
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
//key和value 不允许为null
if (key == null || value == null) throw new NullPointerException();
//计算hash
int hash = spread(key.hashCode());
int binCount = 0;
//无限循环保证插入成功
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
//如果table为空则创建数组
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//从 (n - 1) & hash) 位置查找节点 如果为null 说明第一次插入数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//cas操作插入数据
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
break; // no lock when adding to empty bin
} else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//其他情况下对当前节点进行加锁
synchronized (f) {
//确认节点位置
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
//在链表中插入数据
for (Node<K, V> e = f; ; ++binCount) {
K ek;
//如果hash和key一致 则使用新值替换旧值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
//插入新的节点数据
Node<K, V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
} else if (f instanceof TreeBin) { //红黑树执行其插入逻辑
Node<K, V> p;
binCount = 2;
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//判断链表长度是否大于转换成红黑树阈值 TREEIFY_THRESHOLD 默认是 8 如果满足则将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
可以看到put()
方法实际上是由putVal()
方法实现的,而且内部很多逻辑除了为了保证线程安全其他的和HashMap高度相似,putVal()
大致步骤如下:
initTable()
方法。该方法通过一个变量 + CAS 来控制并发Node
节点 对象用CAS方式添加到容器,并跳出循环。transfer()
方法进行移动和重新散列,该方法中,如果是槽中只有单个节点,则使用CAS直接插入,如果不是,则使用 synchronized 进行同步,防止并发成环。putVal()
方法中会调用initTable()
方法进行初始化容器我们看一下具体代码:
initTable()初始化容器
/**
* 初始化table
*/
private final Node<K, V>[] initTable() {
Node<K, V>[] tab;
int sc;
while ((tab = table) == null || tab.length == 0) {
//如果一个线程发现sizeCtl<0,意味着另外的线程执行CAS操作成功,当前线程只需要让出cpu时间片,
//由于sizeCtl是volatile的,保证了顺序性和可见性
if ((sc = sizeCtl) < 0)
//调用线程的yield()进行线程让步
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { //cas操作判断并置为-1
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; //确认数组大小 DEFAULT_CAPACITY 默认为16
//创建一个新的数组
@SuppressWarnings("unchecked")
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
该方法为了在并发环境下的安全,加入了一个 sizeCtl 变量来进行判断,只有当一个线程通过CAS修改该变量成功后(默认为0,改成 -1),该线程才能初始化数组。保证了初始化数组时的安全性。
addCount()
在putVal()
方法完成插入操作之后会调用addCount()
方法,这个方法主要进行两步操作:
对 table 的长度加一。无论是通过修改 baseCount,还是通过使用 CounterCell。当 CounterCell 被初始化了,就优先使用他,不再使用 baseCount。
检查是否需要扩容,或者是否正在扩容。如果需要扩容,就调用扩容方法,如果正在扩容,就帮助其扩容。
具体请参考这篇文章:
https://www.jianshu.com/p/749d1b8db066
transfer()
transfer()
扩容函数比起HashMap复杂不少,可以查看这篇文章:https://www.jianshu.com/p/2829fe36a8dd
helpTransfer()
helpTransfer()
方法帮助扩容即可以实现多线程扩容,提升速度具体可查看此文章:https://www.jianshu.com/p/39b747c99d32
JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
JDK1.8
JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。
结构如下:
类图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SXy569tj-1593957124189)(https://note.youdao.com/yws/res/14993/WEBRESOURCE28d796131a1da84fd7dfa9e5055d7a8b)]
类图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f55XSKaG-1593957124190)(https://note.youdao.com/yws/res/14996/WEBRESOURCEb5d3c975680d6ddc7c42e1c2fa65ef09)]
线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!)
效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它
对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException。
初始容量大小和每次扩充容量大小的不同: ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小。
底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构:
JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本
② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
两者的对比图:
HashTable:
JDK1.7的ConcurrentHashMap:
JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):
总结:
ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的。
List , Set 都是继承自Collection 接口
List 特点:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
Set 特点:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
Set和List对比
示例代码:
// list to array
List<String> list = new ArrayList<String>();
list.add("123");
list.add("456");
list.toArray();
// array to list
String[] array = new String[]{"123","456"};
Arrays.asList(array);
java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List与Set。
Collections则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。
是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。
例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。
原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
解决办法:
在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
使用CopyOnWriteArrayList来替换ArrayList
可以使用 Collections.unmodifiableCollection(Collection c) 方法来创建一个只读集合,这样改变集合的任何操作都会抛出 Java.lang.UnsupportedOperationException 异常。
示例代码如下:
List<String> list = new ArrayList<>();
list. add("x");
Collection<String> clist = Collections. unmodifiableCollection(list);
clist. add("y"); // 运行时此行报错
System. out. println(list. size());
Iterator 可以遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。
Iterator 只能单向遍历,而 ListIterator 可以双向遍历(向前/后遍历)。
ListIterator 实现 Iterator 接口,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。
https://blog.csdn.net/ThinkWon/article/details/104588551
https://blog.csdn.net/a303549861/article/details/93619042
JDK 1.7HashMap源码分析:(https://blog.csdn.net/woshimaxiao1/article/details/83661464)
JDK1.8ConcurrentHashMap源码分析:(https://www.jianshu.com/p/29d8e66bc3bf)
https://www.jianshu.com/p/d0b37b927c48