Pxoolcm的并发编程第二期-ConcurrentHashMap解析
欢迎大家,在上一期深入理解JMM之后,我又马不停蹄开始更新第二期了,学习永无止境!
Java集合框架图
废话不多说,Java各种工具类在此,各种数据结构亦然。
其实他们都继承的Iterator,也就是我们常说的迭代器。在这里迭代器不是重点,详细的介绍看下面链接Java中为什么要使用迭代器?_java集合为啥要有一个迭代器-CSDN博客)
接下来介绍一些常用的工具类,ArrarList,LinkedList,HashSet(Set),HashMap(Map),ConcurrentHashMap(线程安全并发Map)
ArrayList
特点:
元素是有序插入的,元素可以重复
存储结构
底层用数组实现
public class ArrayList extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable
继承了AbstractList类,同时实现了List, RandomAccess, Cloneable, java.io.Serializable等接口
Cloneable
这个接口是用来实现数据的浅拷贝和深拷贝的,总的来说就是实现了拷贝。
浅拷贝
基础类型拷贝后不会随着原变量的改变进行改变,例如:
int a = 1;
int b = a;
int a = 2;
在这时,b通过a进行拷贝值得到了1,a再重新赋值变为2,那么这时候b并不会改变,为1。
扩展和复习:Java的基本数据类型有 整数型:
byte(8bit) short(16bit) int(32bit) long(64bit)
浮点型:
float(32bit) double(64bit)
字符型:
char(16bit)
布尔型:
boolean(1bit)
引用型对象引用地址不变,拷贝后的变量引用堆中同一个对象。
但是有一个特例String类,大家也知道他不是基本数据类型之一,但是他实现的浅拷贝还是和基本数据类型一样的,原理是String在Java中是不可变的。如果新建了一个String对象,值和原来相等,那么他会在堆内存中新建一个空间,实现浅拷贝。
深拷贝
变量的所有引用类型变量(除了String)都需要实现Cloneable(数组可以直接调用 clone方法),clone方法中,引用类型需要各自调用clone,重新赋值,这就是Cloneable接口的作用,可以实现深拷贝。 实现深拷贝可以使用以下代码:
public Object clone() throws CloneNotSupportedException {
Study s = (Study) super.clone();
s.setScore(this.score.clone());
return s;
}
调用this.score.clone()方法就可以实现深拷贝,让引用类型变量的拷贝也可以实现为基本数据类型的浅拷贝。在堆中开辟一个真正的其他空间装载新对象。
Serializable
序列化
什么是序列化呢,大家可以简单理解为,在计算机网络中各个程序要进行通信,必须要看懂程序传来的信息是什么,所以对象一定要变为可传输的对象,序列化就是将对象转化为可传输格式的过程。与序列化相反的是反序列化,就是将一个可传输的对象转换为真正的对象。这两个过程结合起来就可以做到对象的传输与获得。Java中的Serializable接口其实是给JVM看的,就是告诉JVM这个对象我不做序列化了,你来做吧。
AbstractList
大家看名字也知道是抽象List,主要是用来实现里面的增删查改方法,就是说继承了这个类实现了父类方法就具备了对这个类元素的增删查改功能。
List
这个就有点喜剧色彩了,当初写ArrayList的作者在StackOverflow上主动说这是他当年犯下的一个错误。
I’ve asked Josh Bloch, and he informs me that it was a mistake. He used to think, long ago, that there was some value in it, but he since “saw the light”. Clearly JDK maintainers haven’t considered this to be worth backing out later.
翻译:我问过约什・布洛赫(Josh Bloch),他告诉我那是个错误。很久以前,他曾认为它有一定价值,但后来他 “恍然大悟” 了。显然,JDK 的维护者们并不认为这件事值得在之后撤回
ArrayList基本属性
private static final long serialVersionUID = 8683452581122892189L; // 序列化版本号(类文件签名),如果不写会默认生成,类内容的改变会影响签名变化,导致反序列化失败
private static final int DEFAULT_CAPACITY = 10; // 如果实例化时未指定容量,则在初次添加元素时会进行扩容使用此容量作为数组长度
// static修饰,所有的未指定容量的实例(也未添加元素)共享此数组,两个空的数组有什么区别呢? 就是第一次添加元素时知道该elementData从空的构造函数还是有参构造函数被初始化的。以便确认如何扩容。空的构造器则初始化为10,有参构造器则按照扩容因子扩容
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData; // arrayList真正存放元素的地方,长度大于等于size
private int size; // arrayList中的元素个数
构造器
ArrayList主要有三个构造器,一个是空参的,一个是有参的,一个是将Collection构造,第一个是大家用脑子都想得出来,空参的构造器其实就是在创建对象时没有传入参数,ArrayList给的默认的大小,而有参的就是传入了参数,ArrayList创建和你传入参数一样的大小。 DEFAULTCAPACITY_EMPTY_ELEMENTDATA = 10; 在构造无参的ArrayList对象时传入这个值为10的对象完成ArrayList数组长度的确定。
// 无参构造器,构造一个容量大小为10的空的list集合,但构造函数只是给elementData赋值了一个空的数组,其实是在第一次添加元素时容量扩大至10的。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
initialCapacity是传入的参数,如果当他大于0,那么就会创建一个对象数组复制给elementData,如果传入的值为0,那么elementData赋值为EMPTY_ELEMENTDATA,除了这两种情况就是小于0,就会抛出一个IllegalArgumentException异常。
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);
}
}
第三种构造器
public ArrayList(Collection<? extends E> c) {
Object[] a = c.toArray();
if ((size = a.length)!= 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// 指向空数组
elementData = EMPTY_ELEMENTDATA;
}
}
添加元素
ArrayList默认添加元素在尾部添加,这样可以保证效率很高,大家知道如果在中间插入的话需要移动该元素以后的元素,所以时间复杂度是O(n),但是尾插可以做到O(1)。
数组扩容
在了解扩容之前,我们需要知道为什么扩容和哪时候扩容?为什么扩容呢,大家知道ArrayList我们可以一直执行add方法,但是他总不能保证第一次创建的数组能装下所有元素吧,这就是说当我们的元素个数要超过当前数组大小的时候,ArrayList就会自动进行扩容,注意,这边是快要超过当前数组大小而不是达到数组大小,因为做什么事都不能做的太满,填装元素亦是如此,在ArrayList中有个填装因子,默认是0.75。
填装因子
所谓填装因子,就是当数组实际个数是数组长度 * 填装因子时,数组就会触发扩容机制
扩容代码和逻辑
private void grow(int minCapacity) {
// 获取当前数组长度
int oldCapacity = elementData.length;
// 默认将扩容至原来容量的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果1.5倍太小的话,则将我们所需的容量大小赋值给newCapacity
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果1.5倍太大或者我们需要的容量太大,那就直接拿 newCapacity = (minCapacity > MAX_ARRAY_SIZE)? Integer.MAX_VALUE : MAX_ARRAY_SIZE 来扩容
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 然后将原数组中的数据复制到大小为newCapacity的新数组中,并将新数组赋值给elementData。
elementData = Arrays.copyOf(elementData, newCapacity);
}
迭代器
迭代器可以简单理解为一个指针,可以遍历整个对象中的元素。
本质上是通过判断是否有下一个元素的方法hasNext()来判断是否结束遍历,大家可以想到迭代器的变量是必须要遍历到结尾的。所以在实际中不会很常用这个方法。
扩展:如果在使用迭代器的时候,修改了迭代器中的元素,这时候迭代器会抛出异常,这也是迭代器一个比较鸡肋的地方
ArrayList迭代器代码实现:
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
// 代表下一个要访问的元素下标
int cursor;
// 代表上一个要访问的元素下标
int lastRet = -1;
// 代表对ArrayList修改次数的期望值,初始值为modCount
int expectedModCount = modCount;
// 如果下一个元素的下标等于集合的大小 ,就证明到最后了
public boolean hasNext() {
return cursor!= size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
// 获取当前下一个元素
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
lastRet = i;
return (E) elementData[i];
}
final void checkForComodification() {
if (expectedModCount!= modCount)
throw new ConcurrentModificationException();
}
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
LinkedList
大家在学习数据结构的时候应该都会接触到这个,就是链表,当然也可以做队列,栈。
大多都和ArrayList大差不差,就是一个大家应该没有注意到的点也是没看源码不会知道的点,就是其实LinkedList底层的每个节点其实是有next和rear指针的,就说明LinkedList是双向链表,因为大家使用这个工具类的时候都把它直接当作单向链表来使用,但是现在看了面试问到就不怕了哦。
LinkedList每个节点的数据结构
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
HashMap
接下来本期重点,HashMap。学习HashMap,不止要学会她的key,value结构,各种API还要知道他的更好的线程安全类ConcurrentHashMap是怎么做到线程安全的,将并发编程的知识融会到ConcurrentHashMap中,进行升华和理解。
特点
HashMap的存储是[key,value]形式,就是所谓的键值对形式,key不能重复,重复的话value会被覆盖,但是可以为null。
HashMap的数据结构
在JDK8之前,HashMap使用数组和链表存储的,在JDK8之后使用数组+链表+红黑树实现,为什么要用红黑树,哪时候使用红黑树,我们会在后面讲到。
HashMap存储的底层原理
大家都知道每个元素放进HashMap中的话,他们都会找到各自的位置,那么这种位置信息是怎么得出来的呢,其实底层使用了散列函数(哈希算法),简单来讲,就是把这个他的value通过一个散列函数来得到一个值,这个值再去MOD一个值,大多数情况都是MOD散列数组大小,这样就能保证每个元素都在这个数组中。常见的哈希算法有:MD5算法(密码加密),雪花算法。
下面以"lies"作为value来传入哈希数组
哈希冲突
大家可以看到"lies"字符串计算后存储到了数组9的位置,要是再来个字符串计算的和他的结果一样呢,那是不是产生了冲突,这个字符串就肯定不能在9的位置了,要去找别的位置住下来,这就是哈希冲突。
扩展:常见解决哈希冲突的方法:开发地址法,二次探测法,链地址法。
HashMap的红黑树
大家可以看到,当哈希表元素一旦多起来,那么查询的速度会非常慢,因为链表在里面,大家都知道链表的查询速度是很慢的但是增删的速度很快,所以我们就认定这样存储哈希表的数据结构是不够好的,这时候JDK8之后就引入了红黑树。
下面是HashMap要进行树化的元素达到的个数。
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* shrinkage.
* tree removal about conversion back to plain bins upon
*/
static final int TREEIFY_THRESHOLD = 8;
翻译:用于决定对某个桶(bin)采用树结构而非链表结构的元素数量阈值。当向一个桶中添加元素,且该桶中已有的节点数量至少达到这个阈值时,桶中的链表就会转换为树结构。该数值必须大于 2,并且最好至少为 8,以便与收缩(相关操作)时关于将树结构转换回普通链表的假设相契合。
大家看到会疑问为什么达到8个节点会进行树化,其实这是数学方面的知识,一些科学家进行一些概率统计的分布,比如泊松分布,得出来的一个重要结论,在这里我们就不过多探讨了。
HashMap的put方法源码解析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
大家可以看到put方法是调用了其中的putVal方法,在putVal中,传入的第一个值是key通过哈希函数得到的哈希值,第二个是传入的key,第三个是value,下面有两个布尔值,这两个布尔值参数是用于控制 putVal 方法内部的一些特定逻辑,比如是否允许覆盖已有的值、是否进行某些边界条件判断等,具体取决于 putVal 方法的实现细节。接来下我们看putVal方法.
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
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;
}
这一个实现代码里面这么多if,可读性十分差,你们在公司可不能这样写哦(bushi)。
源码解读
Node<K,V>[] tab; Node<K,V> p; int n, i;
大家可以看到tab是一个Node<k,v>类型的数组,是用于指向存储键值对数据的数组(可以看作所有键值对都存在这个数组中)。
Node<K,V> p是一个键值对的指针,指向数组中的节点,便于遍历。
int n表示数组大小
int i表述当前p所在的索引位置
哈希表为空或者不存在
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
table是HashMap类的成员对象,代表整个哈希表底层的数组结构。先把table赋值给tab,之后如果不存在或者长度为0,就创建一个数组。使用了resize方法
根据哈希值来确定在数组中的位置(无哈希冲突)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
上面说了p是该插入数据节点,i表示索引值,tab[i = (n - 1) & hash])通过数组大小和哈希函数进行位运算得到该元素在数组中的位置,再赋给i。如果得到后的数组位置为null,那么就会将我们要插入的元素进行在该位置上的赋值。大家再问为什么是p == null的时候才进行插入,如果不等于null,说明这个位置在进行哈希函数运算后已经有元素了,就产生了哈希冲突。这时候就需要特殊情况处理了,接下来的代码就是处理哈希冲突的几种情况。
哈希冲突情况一:已存在相同键
if (p.hash == hash &&
((k = p.key) == key || (key!= null && key.equals(k))))
e = p;
如果p.hash == hash是true,并且并且通过 equals 方法(前提是 key 不为 null)进一步判断键是否完全相等(因为哈希值相等不一定键就相等)。如果两者相等,就实现覆盖操作,将新传入的值覆盖原来的值。
哈希冲突情况二:节点是树节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
这个好理解,就像我们刚才介绍树化的一样,HashMap底层元素超过八个的时候就会进行树化,如果当前节点p实现了TreeNode接口,那么就要把p转换为树节点e =((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
哈希冲突情况三:节点是普通链表节点(采用链表处理冲突元素)
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key!= null && key.equals(k))))
break;
p = e;
}
}
这个也好理解,就是代码不好看,在HashMap进行数组和链表存储时,他不是单单一个数组,存储时,其实是数组每个元素的下面是一个链表,每个数组第一个元素就是头节点,之后再判断在这个节点位置的元素就在头节点下接入链表。
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
这个其实就是树化的逻辑,先计算binCount的值,如果超过了8,就进行树化。之后跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key!= null && key.equals(k))))
break;
p = e;
在循环过程中,如果发现链表中某个节点的键与传入的键相等(通过比较哈希值和 equals 方法判断),就直接跳出循环,此时 e 指向的就是这个已存在相同键的节点。
整体分析
介绍完HashMap,就是进行ConcurrentHashMap了。
ConcurrentHashMap
在之前就已经介绍了,HashMap是线程不安全的,什么是线程不安全,就是两个线程同时修改一个变量,A线程修改了,但是B线程不知道,这就会造成变量修改的不正确,那么ConcurrentHashMap应运而生。
特点
并发安全的HashMap ,比Hashtable效率更高,但是它保证安全的操作不是锁住整个方法,是通过原子操作和局部加锁的方法来保证线程安全。其余的都和HashMap差不多,主要就是put方法,ConcurrentHashMap进行了主要的改进。
源码解读
Node<K,V>[] table
transient volatile Node<K,V>[] table;
在介绍HashMap的时候已经介绍了,table是它的一个成员变量,在ConcurrentHashMap中亦是如此,区别是后者加上了volatile,在上一期已经详解了volatile,它可以保证可见性和程序不可重排序性,但是不能保证原子性,这使得ConcurrentHashMap更容易做到线程安全。
put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
和HashMap一样,他调用putVal方法。
putVal方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
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;
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) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
这代码太长了,太难看懂,我来为大家分步骤讲解
参数校验
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
if (key == null || value == null) throw new NullPointerException();
这里如果传进来的key或者value是null,那么直接抛出空指针异常。
int hash = spread(key.hashCode());
通过spread方法通过key的哈希值获得应该存储在table中的位置
int binCount = 0;
这里和HashMap一样,用来记录元素个数,如果超过八个就进行树化。
几个变量意义
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
这里进入循环,f表示指向数组中的节点,方便进行操作。
n表示数组的长度。
i表示键值对在数组中的索引位置。
fh表示获得节点的hash值,通同样也是方便操作。
处理空数组
if (tab == null || (n = tab.length) == 0)
tab = initTable();
在把成员变量赋值给tab之后,会进行对tab是否为null或者长度为空的判断。如果为两种情况其中之一,就会调用initTable方法初始化数组。
插入新节点到数组(无哈希冲突)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
(f = tabAt(tab, i = (n - 1) & hash) == null
进行哈希运算,把f赋给运算后的位置,如果这个位置为null,那么就进行新值的插入,如果不为空,说明计算后的位置上有值,那么就会产生哈希冲突,这时候就需要另外的逻辑进行处理。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
执行casTabAt方法,其实这是一个CAS操作,如果CAS操作成功,那么就直接跳出循环,表示操作顺利完成,这是ConcurrentHashMap做到线程安全的重要一步。CAS是一种乐观锁,也可以保证数据的线程安全,和悲观锁不一样的是,JVM在理解乐观锁其实是不用加锁的,CAS的详细操作会在后面的文章中写到。
拓展:CAS CAS(Compare-And-Swap):比较并交换CAS就是通过一个原子操作,用预期值去和实际值做对比,如果实际值和预期相同,则做更新操作。 如果预期值和实际不同,我们就认为,其他线程更新了这个值,此时不做更新操作。 而且这整个流程是原子性的,所以只要实际值和预期值相同,就能保证这次更新不会被其他线程影响。
协助扩容操作(遇到正在转移的节点情况)
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
MOVED表示的是一个节点正在移动的特殊哈希值,这就代表了数组正在进行扩容,使用helpTransfer来协助扩容
处理哈希冲突情况(链表和红黑树)
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;
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;
}
}
}
}
首先,大家可以看到它加了一个synchronized锁,这个是一个悲观锁,可以保证只有一个线程在对值进行操作,而其他线程只能等着。
处理链表的哈希冲突
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
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;
}
}
}
如果fh的哈希值是大于等于0的,说明现在底层还是用数组和来存储元素。
首先将 binCount 初始化为 1(表示已经开始遍历链表,当前桶中至少有一个节点了),然后从链表头节点 f 开始遍历,每次循环获取当前节点 e 的键与传入的键进行比较(通过比较哈希值以及 equals 方法判断是否相等),如果找到了相同的键,说明已经存在该键对应的键值对了,将当前节点的值赋给 oldVal 保存起来,并且根据 onlyIfAbsent 参数来决定是否更新值,当 onlyIfAbsent 为 false 时,就将当前节点 e 的值更新为传入的新值(e.val = value),之后跳出循环。
如果遍历到链表末尾(即 e.next 为 null),说明没有找到相同的键,此时创建一个新节点(通过 new Node<K,V>(hash, key, value, null))并将其插入到链表末尾(通过 pred.next = new Node<K,V>(hash, key, value, null),这里 pred 是上一个节点),然后跳出循环。
处理树结构的哈希冲突
if (binCount!= 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal!= null)
return oldVal;
break;
}
首先要判断现在要不要进行树化,树化的条件就不用多说了。
之后检查oldVal是否为null,如果不是null,说明找到了已存在并且可能更新了这个值,返回oldVal。如果是null,结束整个putVal。
维护
addCount(1L, binCount);
return null;
最后,调用 addCount 方法,传入 1L(表示增加了一个元素)和 binCount(前面统计的桶中节点数量相关信息,可能用于一些内部统计和调整逻辑),用于更新 ConcurrentHashMap 的元素个数等相关统计信息。由于前面没有返回已存在键值对的旧值(即执行到这里说明是新插入元素),所以最后返回 null。
总结
本期最重头戏的ConcurrentHashMap介绍完了,大家是不是觉得有点云里雾里呢?其实不难理解,它还是和HashMap差不多的,只不过它加了volatile,synchronized锁,CAS等来保证线程安全,大家多看看,注意各个if的条件,还是可以理解的。
最后
第二期Pxoolcm的并发编程结束了,主要介绍了ArrayList,LinkedList,HashMap,ConcurrentHashMap。花了大多篇幅来介绍ConcurrentHashMap的底层源码,这对理解并发编程有很大作用。大家仔细理解,对源码阅读能力和理解Java并发编程原理有很大的作用。
这篇博客花了我三天时间,再接再厉,写博客不仅是对知识的巩固,也是对自我的约束,同时也可以传播知识。我也知道博客中有些晦涩难懂,比如缺少图片来更直观的表示,或者是一些知识点的错误,也希望大家提出,我会改正的!希望大家和我一起进步。共勉!