总结1:
ArrayList:
- 线程不安全
- 底层以数组的形式实现,内存模型为一整块连续内存
- 查找速度较快,增删改速度较慢
- 遍历推荐使用for循环
LinkedList:
- 线程不安全
- 底层以双向链表结构实现,内存模型为不连续分块内存
- 查找速度较慢,增删改速度快
List
关于并发List的选择
- Collections.synchronizedList() & Vector
优:SynchronizedList有兼容功能,可以将List的子类转成线程安全的类
劣:SynchronizedList,进行遍历时要手动进行同步处理
- CopyOnWriteArrayList
适合多读少写的场景,读弱一致性,读用volatile无锁方式,写用 ReentrantLock加锁 copy 新的数组
List常用方法:
List接口常用方法:
-
add(Object element): 向列表的尾部添加指定的元素。 -
size(): 返回列表中的元素个数。 -
get(int index): 返回列表中指定位置的元素,index从0开始。 -
add(int index, Object element): 在列表的指定位置插入指定元素。 -
set(int i, Object element): 将索引i位置元素替换为元素element并返回被替换的元素。 -
clear(): 从列表中移除所有元素。 -
isEmpty(): 判断列表是否包含元素,不包含元素则返回 true,否则返回false。 -
contains(Object o): 如果列表包含指定的元素,则返回 true。 -
remove(int index): 移除列表中指定位置的元素,并返回被删元素。 -
remove(Object o): 移除集合中第一次出现的指定元素,移除成功返回true,否则返回false。 -
iterator(): 返回按适当顺序在列表的元素上进行迭代的迭代器。
-
ArrayList
ArrayList(); 不指定大小,默认为0,调用add后定义大小为10,扩容默认1.5呗扩容
ArrayList为Collectior下的List实现的子类 且ArrayList还实现了接口:RandomAccess(空实现标志接口),Clonable(深度克隆标志接口)
-
由于实现了
RandomAccess(空实现标志接口),List中子类ArrayList遍历最好采用for循环,如LinkedList等未实现RandomAccess接口的子类采用迭代器进行循环遍历 -
由于实现了Clonable(深度克隆标志接口),所以在调用clone()方法的时候,为深度克隆
实现结构
ArrayList中采用数组作为底层数据存储结构,这也就导致了其查询速度优越但是增删改缓慢的特点。
初始化
-
一般使用无参构造函数进行初始化,默认为空数组,调用add函数增加元素的时候,再使用grow扩容函数进行初始化,默认初始化大小为
DEFAULT_CAPACITY=10 -
初始容量进行初始化
public ArrayList(int initialCapacity),数组长度为传入的参数值
扩容策略
以下为ArryList的grow扩容函数源码解析:
private void grow(int minCapacity) {
// 计算现有数组长度
int oldCapacity = elementData.length;
// 现有数组长度增加1/2
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 扩容以后的数组容量是否满足本次元素插入最小容量需要
if (newCapacity - minCapacity < 0)
// 不满足就将本次扩容数组容量设置为插入所需最小
newCapacity = minCapacity;
// 判断数组容量超过限制最大值
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 初始化新容量数组并将原数据复制
elementData = Arrays.copyOf(elementData, newCapacity);
}
由上可知ArrayList扩容策略为:当集合大小为0时,首次扩容容量为10,集合大小不为0时候,扩容为当前大小的1.5倍,检测当前容量是否满足本次元素插入的需求,不满足则设定为插入所需最小容量
ArrayList插入操作
- add(E e)函数直接加入数组尾端
- add(int index,E e)按照index序列插入
- set(int index,E e)index位置元素覆盖
ArrayList遍历
- 由于
ArrayList实现了RandomAccess接口,建议使用for遍历 - 其父类接口也提供了
Iterator迭代器方案,自身对迭代器也有优化,可以使用迭代器遍历
错误应用场景:频繁结构修改
由于ArrayList数据结构变更会涉及到变更本次index后的所有元素
结构,会导致效率过低
应用场景
需要对数据进行查询,而非增删改时,使用ArrayList效率高
正常使用方式
public class ArrayListDemo {
public static void main(String[] args) {
ArrayList<String> arrayList=new ArrayList<>();
arrayList.add("1");
arrayList.add("2");
arrayList.add("3");
arrayList.add("4");
for (String s : arrayList) {
System.out.println(s);
}
Iterator<String> iterator = arrayList.iterator();
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
关于ArrayList深度解析
1. 数据结构
源码:
transient Object[] elementData; //存放数据
private int size; //记录已存放的元素个数
2. 初始化
源码:
//static 关键字标识,所有对象公用,省内存
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
//初始化一个空的数组,在第一次add()时才初始化 elementData
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//预知大数据时指定初始化容量,减少扩容次数
public ArrayList(int initialCapacity) {
...
this.elementData = new Object[initialCapacity];
}
- 延迟初始化的思想,大部分有数组的数据结构,在第一次添加操作时才初始化,分配内存
- 避免频繁扩容影响性能,知道预期数量则可以指定 例如:
//构造函数如下
public ArrayList(int initialCapacity) //LinkedList不是数组就没有
public HashMap(int initialCapacity)
public StringBuffer(int capacity)
3. 扩容数据
源码:
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
//1.5倍扩容 10->15->22->33
int newCapacity = oldCapacity + (oldCapacity >> 1);
...
elementData = Arrays.copyOf(elementData, newCapacity);
}
//最终copyOf调用 System 类底层方法,浅拷贝数据
public static native void arraycopy(Object src, int srcPos,Object dest, int destPos,int length);
- 扩容机制为1.5倍扩容
- Arrays.copyOf 性能比for循环逐个赋值高
- 最终copyOf调用 System 类 arraycopy() C实现方法,浅拷贝数据
4. 批量删除
源码:
private boolean batchRemove(Collection<?> c, boolean complement) {
...
try {
for (; r < size; r++)
//内层循环性能关键点,基于Hash查找的HashSet contains() 方法比 List快
if (c.contains(elementData[r]) == complement)
//这段算法,真会玩
elementData[w++] = elementData[r];
} finally {
...
}
return modified;
}
- 这里
elementData[w++] = elementData[r];的逻辑是:首先前面的if判断当前的集合的r位置与对照集合c内是否有匹配字符,如果有匹配字符,将原集合当前r位置的对象移动到w下个位置,且w自增,r也自增,如果没有匹配字符r自增,w不变,这样下来,可以将不要的对象移动到数组后半部分,而最终的到w长度的数组是筛选完成后的数组 - 推荐查看该文章理解:blog.csdn.net/weixin_4084… 源码:
//1.8 lambda 删除方式
public boolean removeIf(Predicate<? super E> filter) {
final BitSet removeSet = new BitSet(size);
for (int i=0; i < size; i++)
if (filter.test(element)) {
removeSet.set(i);
}
}
示例:
Collection<Person> collection = new ArrayList();
collection.add(new Person("张三", 22, "男"));
collection.add(new Person("李四", 19, "女"));
collection.add(new Person("王五", 34, "男"));
collection.add(new Person("赵六", 30, "男"));
collection.add(new Person("田七", 25, "女"));
collection.removeIf(
person -> person.getAge() >= 30
);//过滤30岁以上的求职者
System.out.println(collection.toString());//查看结果
5.并发异常发现方式
//涉及遍历的方法校验 modCount 值
public void forEach(Consumer<? super E> action) {
//遍历前记录修改次数
final int expectedModCount = modCount;
for (int i=0; modCount == expectedModCount && i < size; i++) {
...
}
//发现和预期值不同则抛出异常
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
public boolean removeIf(Predicate<? super E> filter)
Itr.next()
- 由于每次修改集合,都会记录下修改次数,在foreach的时候会核验修改次数,如果与预期不匹配,就直接抛出异常
6. 结语
- 本质是实现动态数组的 CRUD
- 在实际使用中,注意初始化指定容量大小提升性能,是线程不安全即可
参考文章: juejin.cn/post/684490…
-
LinkedList
LinkedList(); 不指定大小,默认为0,LinkedList没有扩容机制,新增多少加多少。
关于LinkedList的构造函数
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
- 空构造方法构造了一个空的List 其中size为0,first和last都为null ,没有任何元素
LinkedList(Collection<? extends E> c)构造一个包含指定Collection中所有元素的列表 该方法先调用空构造器 然后addAll()把Collection中所有元素添加进去
特点:
LinkedList的实现是基于双向链表的,且头结点中不存放数据
LinkedList是基于链表实现的,因此不存在容量不足的问题,所以这里没有扩容的方法 不同与数组实现的ArrayList
LinkedList是基于链表实现的,插入删除效率高,查找效率低
深度解析
继承
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
LinkedList继承了AbstractSequentialList,实现了List接口、Deque接口和java.io.Serializable接口,从它的继承机构就能看出它是一种队列的实现方式。
主要方法
add(E e)
一个参数方法,默认添加到list最后面
add(int index, E element)
把元素element添加到下标index位置
- 以及实现的Deque接口的addFirst(E e)和addLast(E e)方法
addAll(Collection<? extends E> c)
addAll(int index, Collection<? extends E> c)
addFirst(E e) linkFirst插入到最前面
addLast(E e) 和add(E e)一样
- get
get(int index) //获取下标index上的元素
getFirst() //获取第一个元素(first节点的值)
getLast() //获取最后一个元素(last节点的值)
- remove方法
remove()//删除第一个元素,直接调用的removeFirst()方法
remove(int index)//删除下标index上的元素,如果下边越界,会抛IndexOutOfBoundsException异常
remove(Object o)//删除第一个匹配到的元素o
removeFirst()//删除第一个元素(如果list为空,会抛出NoSuchElementException异常)
removeFirstOccurrence(Object o)//直接调用remove(o)
removeLast()//删除最后一个元素(如果list为空,会抛出NoSuchElementException异常)
removeLastOccurrence(Object o)//删除最后一个匹配到的元素
- peek方法 返回list中的第一个元素,不删除,如果list为空,返回null
peekFirst()//同peek()
peekLast()//返回最后一个元素,不删除
pollFirst()//同poll()
pollLast()//返回最后一个元素,并删除
- poll方法
返回list中的第一个元素,并删除,如果list为空,返回null
- pop()方法
删除list中的第一个元素,如果list为空,抛出NoSuchElementException异常
- clear()方法
删除list中的所有元素
- clone()方法
复制一个相同的list
- contains(Object o)方法
判断list中是否包含元素o
- indexOf(Object o)方法
返回元素o的下标
- set(int index, E element)方法
给下标index上的元素赋值,返回原有的值
- toArray()方法
把list中的元素复制到新数组中并返回该数组
- toArray(T[] a)方法
把list中的元素复制到指定的数组中
迭代器
可以有种迭代器listIterator()与iterator(),区别:
1、使用范围不同,iterator可以应用于所有的集合,Set、List和Map以及这些集合的子类型。而ListIterator只能用于List及其子类型。
2、ListIterator有add方法,可以向List中添加对象,而Iterator不能。
3、ListIterator和Iterator都有hasNext()和next()方法,可以实现顺序向后遍历,但是ListIterator有hasPrevious()和previous()方法,可以实现逆向遍历,但是iterator不可以。
4、ListIterator可以定位当前索引的位置,nextIndex()和previousIndex()可以实现。Iterator没有此功能。
5、都可以实现删除操作,但是ListIterator可以实现对象的修改,set()方法可以实现。Iterator仅能遍历,不能实现修改。
参考文章: juejin.cn/post/684490…
#总结2: HashSet:
- HashSet具有很好的对象检索性能
- 不能保证排列的顺序
- 非线程安全
- 集合元素可以是null
Set
HashSet:
HashSet实现Set接口,由哈希表支持。它不保证set 的迭代顺序;特别是它不保证该顺序恒久不变。此类允许使用null元素。
HashSet的实现:
对于HashSet而言,它是基于HashMap实现的,HashSet底层使用HashMap来保存所有元素,因此HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成
特点:
- 非线程安全
- 允许null值
- 添加值得时候会先获取对象的hashCode方法,如果hashCode 方法返回的值一致,则再调用equals方法判断是否一致,如果不一致才add元素。
注意: 对于HashSet中保存的对象,请注意正确重写其equals和hashCode方法,以保证放入的对象的唯一性。
深度解析
HashSet是一个HashMap的一个实例,它不保证它的元素们的相对顺序始终是一样的。它也允许null元素的存在。和其他的集合一样,它也是线程不安全,具有fail-fast机制的。
public Iterator<E> iterator() {
return map.keySet().iterator();
}
HashSet的迭代器也是来自HashMap。从这里可以看出HashSet中的元素其实是HashMap中的key的集合,因为该迭代器就是遍历的HashMap的key集合。
它的add方法其实就是HashMap的put方法实现的,所以它的元素不重复也是由这个方法来保证的。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
从上面这段代码可以看出先是比较集合中的元素的hash值是否与要添加的元素的hash值相等,以及他们的key是否相等(此处是用==来判断的);然后用元素的equals方法比较key值是否相等。如果条件成立则把p节点赋给e节点,此时说明要加入的元素的hash值与集合中的一个元素的hash值相等了,也就是说对于key有了相同的映射。今儿判断e是否为null,若不为null,就把集合中的这个元素的value值赋给oldValue作为返回值,然后把要添加的元素的value值赋给e节点
参考文章: juejin.cn/post/684490…
#总结3:
HashMap:
- 线程不安全
- 结构为:数组+链表+红黑树(jdk1.8)
- 无序的
- key可以为null
LinkHashMap:
- 线程不安全
- 结构为:数组+链表+红黑树(jdk1.8),外部还维护了一个双向链表
- 无序的
- key可以为null
HashTable:
- 线程安全
- 结构为:数组+链表+红黑树(jdk1.8)
- 无序的
- key不可以为null
- 速度较慢
ConcurrentHashMap:
- 线程安全
- 结构为:数组+链表+红黑树(jdk1.8)
- 无序的
- key不可以为null
- 速度较快
Map
-
HashMap
应用场景
基于键(key)/值(value),键可以是任何引用数据类型的值,不可重复;值可以是任何引用数据类型的值,可以重复;键值对存放无序。
常用方法
-
put(K key, V value)将键(key)/值(value)映射存放到Map集合中。 -
get(Object key)返回指定键所映射的值,没有该key对应的值则返回 null。 -
size()返回Map集合中数据数量
4.clear()清空Map集合
-
isEmpty ()判断Map集合中是否有数据,如果没有则返回true,否则返回false -
remove(Object key)删除Map集合中键为key的数据并返回其所对应value值 -
values()返回Map集合中所有value组成的以Collection数据类型格式数据 -
keySet()返回Map集合中所有key组成的Set集合 -
containsKey(Object key)判断集合中是否包含指定键,包含返回 true,否则返回false -
containsValue(Object value)判断集合中是否包含指定值,包含返回 true,否则返回false
遍历集合
HashMap用entrySet() 遍历集合
entrySet() 将Map集合每个key-value转换为一个Entry对象并返回由所有的Entry对象组成的Set集合
例:
HashMap<Integer,String> hashMap = new HashMap<>();
hashMap.put(1,"1");
hashMap.put(4,"1");
hashMap.put(3,"1");
hashMap.put(2,"1");
Collection<String> values = hashMap.values();
Iterator<String> iterator = values.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
for (Map.Entry<Integer, String> integerStringEntry : hashMap.entrySet()) {
Integer key = integerStringEntry.getKey();
String value = integerStringEntry.getValue();
System.out.println(key+":"+value);
}
底层数据结构
HashMap在JDK1.7结构为数组+链表,JDK1.8演变为数组+链表+红黑树
-
链表 链表节点储存在数组之中,储存规则由链表key的hash值所定,为单向链表
-
红黑树 红黑树为特殊的平衡二叉树,当链表下长度超过默认值
TREEIFY_THRESHOLD=8时会转换为红黑树,如果红黑树的节点减少到默认值UNTREEIFY_THRESHOLD=6时,红黑树会转换为链表
红黑树与链表转换
红黑树所占空间是链表两倍,当链表节点数量过小此时遍历性能还不是太差,牺牲两倍空间换一些时间不明智所以规定了当链表节点数量为8时转红黑树,8是根据泊松分布计算出到达此条件概率为千万分之一
HashMap的扩容机制
HashMap的扩容算法在resize()函数中,总结起来如果排除超过最大值特殊情况的话就是将现有哈希桶容量翻倍
关于HashMap深度解析
1.7jdk的HashMap
size,就是HashMap的存储大小。threshold是c。loadFactor是负载因子, 默认为75%。阀值 = 当前数组长度✖负载因子。modCount指的是HashMap被修改或者删除的次数总数。
//就是HashMap的存储大小
transient int size;
//HashMap临界值,也叫阀值,如果HashMap到达了临界值,需要重新分配大小
int threshold;
//负载因子, 默认为75%。阀值 = 当前数组长度*负载因子
final float loadFactor;
//HashMap被修改或者删除的次数总数
transient int modCount;
关于HashMap实现逻辑
- 首先判断Key是否为Null,如果为null,直接查找Enrty[0],如果不是Null,先计算Key的HashCode,然后经过二次Hash,得到Hash值。
- 根据Hash值,对Entry[]的长度length求余,得到的就是Entry数组的index。
- 根据对应的索引找到对应的数组,就是找到了其所在的链表,然后按照链表的操作对Value进行插入、删除和查询操作。
Hash碰撞
HashMap是怎么通过Hash查找数组的索引的呢,调用indexFor,其中h是hash值,length是数组的长度,这个按位与的算法其实就是h%length求余。
static int indexFor(int h, int length) {
return h & (length-1);
}
在做按位与的时候,始终是低位在做计算,高位不参与计算,因为高位都是0。这样导致的结果就是只要是低位是一样的,高位无论是什么,最后结果是一样的,如果这样依赖,hash碰撞始终在一个数组上,导致这个数组开始的链表无限长,那么在查询的时候就速度很慢
构造函数初始容量
源码:
public HashMap(int initialCapacity, float loadFactor) {
// initialCapacity代表初始化HashMap的容量,它的最大容量是MAXIMUM_CAPACITY = 1 << 30。
// loadFactor代表它的负载因子,默认是是DEFAULT_LOAD_FACTOR=0.75,用来计算threshold临界值的。 if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 使用默认的初始容量(16)构造一个空的`HashMap`
* 设定初始的负载因子(0.75)
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
put操作
源码:
public V put(K key, V value) {
//数组为空时创建数组
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//①key为空单独对待
if (key == null)
return putForNullKey(value);
//②根据key计算hash值
int hash = hash(key);
//②根据hash值和当前数组的长度计算在数组中的索引
int i = indexFor(hash, table.length);
//遍历整条链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//③hash值和key值都相同的情况,替换之前的值
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
//返回被替换的值
return oldValue;
}
}
modCount++;
//③如果没有找到key的hash相同的节点,直接存值或发生hash碰撞都走这
addEntry(hash, key, value, i);
return null;
}
扩容方法resize
源码:
void resize(int newCapacity) { //传入新的容量
//获取旧数组的引用
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//极端情况,当前键值对数量已经达到最大
if (oldCapacity == MAXIMUM_CAPACITY) {
//修改阀值为最大直接返回
threshold = Integer.MAX_VALUE;
return;
}
//步骤①根据容量创建新的数组
Entry[] newTable = new Entry[newCapacity];
//步骤②将键值对转移到新的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//步骤③将新数组的引用赋给table
table = newTable;
//步骤④修改阀值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
如果大小超过最大容量就返回。否则就new 一个新的Entry数组,长度为旧的Entry数组长度的两倍。然后将旧的Entry[]复制到新的Entry[] transfer复制Entry[]方法:
void transfer(Entry[] newTable, boolean rehash) {
//获取新数组的长度
int newCapacity = newTable.length;
//遍历旧数组中的键值对
for (Entry<K,V> e : table) {
while(null != e) {
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;
}
}
}
参考文章: juejin.cn/post/684490…
1.8jdk的HashMap
1.8的HashMap相比于1.7有了很多变化
Entry结构变成了Node结构,hash变量加上了final声明,即不可以进行rehash了- 插入节点的方式从头插法变成了尾插法
- 引入了红黑树
- tableSizeFor方法、hash算法等等
成员变量
//默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转红黑树的阈值 8
static final int TREEIFY_THRESHOLD = 8;
//红黑树转链表的阈值 6
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树所需要的最小表容量64,即当链表的长度达到转红黑树的临界值8的时候,如果表容量小于64,此时并不会把链表转成红黑树,而会对表进行扩容操作,减小链表的长度
static final int MIN_TREEIFY_CAPACITY = 64;
//table,Node数组
transient Node<K,V>[] table;
//保存缓存entrySet()
transient Set<Map.Entry<K,V>> entrySet;
//节点总数
transient int size;
//修改次数
transient int modCount;
//扩容阈值
int threshold;
//负载因子
final float loadFactor;
Node结构
Node结构,实现了Entry,hash值声明为final,不再可变,即1.7中的rehash操作不存在了
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
....
}
构造函数
参数为Map的构造方法,先计算需要的容量大小,然后调用putVal方法插入节点
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
hash算法
hash算法进行了简化,直接把hashCode()值的高16位移下来进行异或运算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
多线程安全问题
- JDK1.7中,当两个线程同时进行插入操作时,同时执行到createEntry方法时,获取到了同一个头节点e,第二个线程会覆盖掉第一个线程的插入操作,使第一个线程插入的数据丢失。
- JDK1.8中的尾插法同样会有这样的问题,两个线程获取到相同的节点,然后把新键值对赋值给这个节点的next,后面的赋值操作覆盖掉前面的。
- JDK1.7和JDK1.8中对map进行扩容时,由于节点的next会变化,造成实际有key值,但是读操作返回null的情况。
链表死循环
1.7中,当两个线程同时进行扩容操作时,可能会造成链表的死循环
并发死循环指的发生在进行哈希桶扩容时需要迁移旧数据,因为JDK1.7采用头插法插入数据,在并发条件下如果相同哈希桶上的链表在新的哈希桶中存放位置还是在相同的哈希桶位置,就有可能会产生死循环。
JDK1.8中已经将元素插入方式修改为尾插法,并发死循环问题得到解决
参考文章: juejin.cn/post/684490…
-
HashTable
HashTable同样是基于哈希表实现的,类似HashMap,两者的区别是:
- 关于null,HashMap允许key和value都可以为null,而Hashtable则不接受key为null或value为null的键值对。
- 关于线程安全,HashMap是线程不安全的,Hashtable是线程安全的,因为Hashtable的许多操作函数都用synchronized修饰。
- Hashtable与HashMap实现的接口一致,但Hashtable继承Dictionary,而HashMap继承自AbstractMap,即父类不同。
- 默认初始容量不同,扩容大小不同。HashMap的hash数组的默认大小是16,而且一定是2 的指数,增加方式old2;Hashtable中hash数组默认大小是11,增加的方式是old2+1。
注: Hashtable之所以初始容量为11(质数)和扩容方式保证为奇数,是为了散列得更均匀,也就是减少碰撞发生的几率。
关于put方法添加键值对
put方法的流程是:计算key的hash值,根据hash值获得key在table数组中的索引位置,然后迭代该key处的Entry链表,若该链表中存在一个这个的key对象,那么就直接替换其value值即可,否则在将该key-value节点插入该index索引位置处。
关于get方法获取
计算key的hash值,判断在table数组中的索引位置,然后迭代链表,匹配直到找到相对应key的value,若没有找到返回null。
关于HashTable的扩容方法rehash方法
rehash()方法扩容是容量扩大两倍+1,同时需要将原来HashTable中的元素一一复制到新的HashTable中,这个过程是比较消耗时间的,同时还需要重新计算hashSeed的,毕竟容量已经变了。:比如初始值11、加载因子默认0.75,那么这个时候阀值threshold=8,当容器中的元素达到8时,HashTable进行一次扩容操作,容量 = 8 * 2 + 1 =17,而阀值threshold=17*0.75 = 13,当容器元素再一次达到阀值时,HashTable还会进行扩容操作,以次类推。
参考文章: juejin.cn/post/684490…
参考文章: juejin.cn/post/684490…
-
LinkedHashMap
LinkedHashMap继承了HashMap,它相比较于HashMap增加了排序功能
关于排序
LinkedHashMap基于HashMap的实现,只不过在table之外,额外维护了一个双向链表。实现newNode,afterNodeRemoval等方法方法,put及remove时候维护链表的增减,其内部子类实现Iterator接口,返回链表数据其排序顺序是根据插入的顺序
例:
public static void main(String[] args) {
Map<Integer,String> map = new LinkedHashMap<>();
map.put(1,"1");
map.put(3,"3");
map.put(2,"2");
Set<Map.Entry<Integer, String>> entries = map.entrySet();
Iterator<Map.Entry<Integer, String>> iterator = entries.iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> next = iterator.next();
System.out.println(next.getKey());
System.out.println(next.getValue());
}
}
关于LinkHashMap深度解析
LintHashMap的节点对象继承HashMap的节点对象,并增加了前后指针 before after:
源码:
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
构造函数
- accessOrder,简单说就是这个用来控制元素的顺序,
- accessOrder为true:表示按照访问的顺序来,也就是谁最先访问,就排在第一位
- accessOrder为false:表示按照存放顺序来,就是你put元素的时候的顺序。
源码:
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
/**
* 生成一个空的LinkedHashMap,并指定其容量大小,负载因子使用默认的0.75,
*/
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
/**
* 生成一个空的HashMap,容量大小使用默认值16,负载因子使用默认值0.75
* 默认将accessOrder设为false,按插入顺序排序.
*/
public LinkedHashMap() {
super();
accessOrder = false;
}
/**
* 根据指定的map生成一个新的HashMap,负载因子使用默认值,初始容量大小为Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,DEFAULT_INITIAL_CAPACITY)
* 默认将accessOrder设为false,按插入顺序排序.
*/
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
/**
* 生成一个空的LinkedHashMap,并指定其容量大小和负载因子,
* accessOrder为false表示按照存放顺序来,就是你put元素的时候的顺序
* accessOrder为true: 表示按照访问的顺序来,也就是谁最先访问,就排在第一位
*/
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
存储:
put调用的HashMap的put方法,调用两个空方法,由LinkedHashMap实现
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
读取:
e不为空,则获取e的value值并返回。
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
LinkedHashMap的几个迭代器:
- LinkedKeyIterator 继承自LinkedHashIterator,实现了Iterator接口,对LinkedHashMap中的key进行迭代。
- LinkedValueIterator 继承自LinkedHashIterator,实现了Iterator接口,对LinkedHashMap中的Value进行迭代
- LinkedEntryIterator 继承自LinkedHashIterator,实现了Iterator接口,对LinkedHashMap中的结点进行迭代
参考文章: juejin.cn/entry/68449…
-
ConcurrentHashMap
问题:
- 在并发编程中使用
HashMap可能造成死循环(jdk1.7,jdk1.8 中会造成数据丢失) HashTable效率非常低下
使用场景:
HashMap在并发执行put操作时会引起死循环,因为多线程导致HashMap的 Entry 链表形成环形数据结构,则 Entry 的 next 节点永远不为空,会死循环获取 Entry。HashTable使用synchronized来保证线程安全,但是在线程竞争激烈的情况下,效率非常低。其原因是所有访问该容器的线程都必须竞争一把锁。
ConcurrentHashMap解决方法:
ConcurrentHashMap 使用锁分段技术,容器里有多把锁,每一把锁用于其中一部分数据,当多线程访问不同数据段的数据时,线程间就不会存在锁的竞争。
ConcurrentHashMap深度解析
1.7jdk版本
- jdk 1.7 中,ConcurrentHashMap 是由 Segment 数据结构和 HashEntry 数组结构构成。采取分段锁来保证安全性。Segment 是 ReentrantLock 重入锁,在 ConcurrentHashMap 中扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,Segment 的结构和 HashMap 类似,是一个数组和链表结构。
get 操作
Segment 的 get 操作实现非常简单和高效,先经过一次再散列,然后使用这个散列值通过散列运算定位到 Segment,再通过散列算法定位到元素。 get 操作的高效之处在于整个 get 过程都不需要加锁,除非读到空的值才会加锁重读。原因就是将使用的共享变量定义成 volatile 类型。
transient volatile int count;
volatile V value;
put 操作
当执行put操作时,会经历两个步骤:
判断是否需要扩容 定位到添加元素的位置,将其放入 HashEntry 数组中
插入过程会进行第一次 key 的 hash 来定位 Segment 的位置,如果该 Segment 还没有初始化,即通过 CAS 操作进行赋值,然后进行第二次 hash 操作,找到相应的 HashEntry 的位置,这里会利用继承过来的锁的特性,在将数据插入指定的 HashEntry 位置时(尾插法),会通过继承 ReentrantLock 的 tryLock() 方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用 tryLock() 方法去获取锁,超过指定次数就挂起,等待唤醒。
size操作
计算 ConcurrentHashMap 的元素大小是并发操作的,就是在你计算 size 的时候,他还在并发的插入数据,这就可能会导致你计算出来的 size 和你实际的 size 有相差。
ConcurrentHashMap 采取的解决方法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,统计过程中如果 count 发生变化,则再采用加锁的方式来统计所有 Segment 的大小。
1.8jdk版本
- JDK1.8 的实现已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 Synchronized 和 CAS 来操作,整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。
基本属性
//node数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认初始值,必须是2的幂数
private static final int DEFAULT_CAPACITY = 16;
//数组可能最大值,需要与toArray()相关方法关联
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//并发级别,遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//链表转红黑树阀值,> 8 链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
//树转链表阀值,小于等于6(tranfer时,lc、hc=0两个计数器分别++记录原bin、新binTreeNode数量,<=UNTREEIFY_THRESHOLD 则untreeify(lo))
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MIN_TRANSFER_STRIDE = 16;
private static int RESIZE_STAMP_BITS = 16;
//2^15-1,help resize的最大线程数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//32-16=16,sizeCtl中记录size大小的偏移量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//forwarding nodes的hash值
static final int MOVED = -1;
//树根节点的hash值
static final int TREEBIN = -2;
//ReservationNode的hash值
static final int RESERVED = -3;
//可用处理器数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
//存放node的数组
transient volatile Node<K,V>[] table;
/*控制标识符,用来控制table的初始化和扩容的操作,不同的值有不同的含义
*当为负数时:-1代表正在初始化,-N代表有N-1个线程正在 进行扩容
*当为0时:代表当时的table还没有被初始化
*当为正数时:表示初始化或者下一次进行扩容的大小
*/
private transient volatile int sizeCtl;
- table: 默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂次方。
- nextTable: 默认为null,扩容时新生成的数组,其大小为原数组的两倍
- Node :保存 key,value 及 key 的 hash 值的数据结构。
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
//省略部分代码
}
注:JDK1.8 版本的 ConcurrentHashMap 的数据结构已经接近 HashMap,相对而言,ConcurrentHashMap 只是增加了同步的操作来控制并发
JDK 1.8 中为什么要摒弃分段锁
- jdk1.8中锁的粒度更细了。jdk1.7中ConcurrentHashMap 的concurrentLevel(并发数)基本上是固定的。jdk1.8中的concurrentLevel是和数组大小保持一致的,每次扩容,并发度扩大一倍.
- 红黑树的引入,对链表的优化使得 hash 冲突时的 put 和 get 效率更高
- 获得JVM的支持 ,ReentrantLock 毕竟是 API 这个级别的,后续的性能优化空间很小。 synchronized 则是 JVM 直接支持的, JVM 能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得 synchronized 能够随着 JDK 版本的升级而不改动代码的前提下获得性能上的提升。
参考文章: juejin.cn/post/688193…
参考文章: juejin.cn/post/684490… juejin.cn/post/684490… juejin.cn/post/684490… juejin.cn/post/684490… blog.csdn.net/diweikang/a… blog.csdn.net/zuochao_201… www.iteye.com/blog/zhangs…