缘起
之前在知乎看到一个问题,京东一面:为什么 HashMap 是线程不安全的?看到这个问题我的第一个反应是,什么是线程安全,我还是饶有兴致的向我自己提问,然后期待一个像是数学定义一样的答案。那么什么是线程? 从操作系统的角度来看,为了实现任务并发,提高CPU的使用率,我们引入了进程,一个执行的程序在等待资源的时候,我们完全可以让CPU干点别的活,于是进程出现了,进程是动态的,程序是静态的,执行中的程序就叫进程,当一个程序开始执行,操作系统需要向他分配内存资源,这么一看进程可以看做是执行逻辑和资源的结合体,像是下面这样:
但是随着发展,程序开始集成越来越多的功能,像是一个音乐播放器,在最开始只具备打开本地文件播放的能力,慢慢的可以实现在线播放,在线播放,在线播放事实上分成三个动作,下载,解压,播放,由于我们目前只实现了进程级别的并发,那将这三个动作拆分到不同的进程里面,那不同的进程共享资源其实是一个麻烦的问题,那能不能简单点,这三个功能本身就算在这个进程里面,实现进程内部执行单元的切换。
当然可以,那么该如何实现呢,已知是现在有进程,那么一种选择就是将原来的进程改造的轻量级一些, 然后还能共享地址空间和其他资源,以前的进程概念用于刻画执行中的程序,那么改造之后的轻量级进程则用于刻画程序内部的执行流,所以这就是线程嘛,某种程度上我们可以将其当做内核级别的线程实现,但与最初的线程有着一点区别,早期的Linux事实上只支持用户态的线程,也就是说进程内部线程的切换是由进程内部来控制的,这对于早期的开发者来说是一个挑战,说起这个,你想到了什么,有没有想到Java的线程模型,早期Java的线程被称为绿色线程,线程的切换依赖于JVM,采用这一模型的原因在于Java出生于1996年,1997年,JDK 1.1 发布,而在工业界的第一款双核CPU于2001年发布,这款CPU来自于IBM,名叫 Power 4,我们可以认为在Java刚出生的年代还是单核处理器的天下,而在单核处理器下面绿色线程的性能是要更好的,虽然这对开发者不大友好,自己实现一套线程切换调度。所以早期的线程就是用户态的线程,只是为了更加强大的并发,更加友好的体验,更方便的共享资源,但是很快单核心的处理器遇到了瓶颈,处理器晶体管越多、频率越高,越无法稳定运行,同时发热量越大,高频产生的高温需要更极端的散热系统。所以,频率提升不上去了。所以欢迎光临多核时代,所以我们需要更加充分的利用多个核心,由此Linux就推出了线程的轻量级进程实现,这也就是原生线程,原先归虚拟环境调度的多线程被移动到了操作系统身上,对于开发者来说多线程切换更加容易,也能够更加充分的利用多核心,这也是Java改动线程模型的原因,适应多核时代。
当我问我自己什么是线程的时候,我当即给出的答案是一个独立的执行单元,我的回答想来是从Linux线程的实现来说的,这么说似乎也没什么问题,从调度程序的角度来说也只考虑进程,但是从用户角度来说,线程通常是要依附于进程,这么说线程是独立的执行单元也似乎有些问题。那一个目前可以接受的答案是线程是进程内部的一个执行流,进程是正在执行中的程序,有了线程之后,我们就可以将进程看成资源与若干执行流的结合体。我们的目标就是给出线程安全的一个定义,给出一个充分必要条件,像下面这样:
线性方程组有解的充分必要条件是其系数矩阵的秩等于其增广矩阵的秩
但也许这是一厢情愿,一切问题并非是数学问题, 我想起数学的公理化,那什么是公理化,粗略的说就是给定一组最基础的公理,在这个基础上推导出这个领域的定理,像是欧几里得几何,严谨一点的说法是:
公理化 是数学中一种思想方法, 即对某个 (可能来源于直观或者其它理论的) 研究对象, 找出其中的一些基本对象并列举出想让这些对象满足的一些关系. 这些关系称为公理 , 对象和关系全体称为公理系统. 之后在建立理论时, 不去管这些公理为何成立, 且只使用这些公理而不借助于其它理论来推导.
我想起杨振宁公理化的论述:
到20世纪初,有很多人试图将热力学公理化。我认为这不是一个很有用的东西。物理学是关于现实的,而现实是非常复杂的,它不像欧几里德几何那样可以被公理化。如果你真的想要绝对合乎逻辑,你必须在完全确定之前陈述很多很多事情。所以,在我看来,公理化物理学的想法是没有用的。之所以有公理化热力学的讨论,是由于在数学发展到了十九世纪末二十世纪初的时候,出现了将数学公理化的想法,大数学家希尔伯特的厥功至伟。希尔伯特在1900年就23个重要的数学问题发表了著名的演讲,对20世纪的数学产生了巨大的影响。这23个问题中,有一个是将物理学公理化。在我看来,这不是正确的方向。大约在1930年,一位名叫哥德尔的年轻数学家证明,将整个数学公理化的想法并不是完全正确的事情,这导致了数学、数理逻辑和哲学的伟大进步。然后,在没有实现物理公理化的情况下,人们对这个希尔伯特问题失去了兴趣,直到1950年代。20世纪50年代,基本粒子物理学进入了一个探索未来的时期,一些人,尤其是德国的很多人,在某种意义上复兴了这种公理化物理学的想法。当时国际上有许多人去跟风,发展出来一个叫作公理化场论的领域,但是到了1970年代,这个领域也走到了尽头。今天,在这样的方向上进行这种努力并不被认为是推动物理学向前发展的有用尝试。我们在讨论物理学时应当努力成为物理学家而不是逻辑学家。
所以我们本篇的目标,对什么是线程安全做出回答,给出一个数学定义一样的答案,也许根本就做不到,原因在于这个问题也许就没有答案,我们不该用这样的方式对这个问题作出解答,问题的答案可能是另一种形式,但这不意味着探索失去了意义,我们可以给出一个近似的答案,可以让我们在考察线程安全问题时更加得心应手,毕竟造火箭的时候,可能还是让你写一个线程安全的集合的,要回答这个问题,我们至少心里有一个线程安全的定义,定义是充分必要条件的另一种陈述。
那什么是安全,其实提到这个词,我想到的是交通安全,所谓交通安全我想指的就是行动的人正常到达目的地,没有因为违法规则,而出事故。那什么是安全? 在现代汉语词典中,安全的语义为:没有危险;不受威胁;不出事故。 在剑桥英文词典中safe的语义也类似:
not in danger or likely to be harmed
也就是说这个词的语义是借助其他词的否定来定义的,那线程安全呢,我们首先要考察是在什么情况下会出现安全问题? 然后通过这些现象给线程安全一个定义。
什么情况会出现线程安全问题?
之前在微信群里面看聊天,有个朋友说单核心就不会出现线程安全问题了,当时觉得这个结论蛮有意思的,因为按照我的理解从CPU调度上来说,CPU调度器都是从就绪队列中选择一个进程(在Linux下并不区分线程与进程,线程的真正实现是轻量级进程,所以这里用了线程这个词)进行执行,多个核心也就是可以理解为多个执行者,回想一下在《 Java多线程学习笔记(一) 初遇篇》中我们引出线程安全的例子:
java
public class TicketSell implements Runnable {
// 总共一百张票
private int total = 2000;
@Override
public void run() {
while (total > 0) {
System.out.println(Thread.currentThread().getName() + "正在售卖:" + total--);
}
}
}
public static void main(String[] args) {
TicketSell ticketSell = new TicketSell();
Thread a = new Thread(ticketSell, "a");
Thread b = new Thread(ticketSell, "b");
a.start();
b.start();
}
当时引出这里有线程安全问题说的是出现两个线程卖了同一张票,那这是为什么呢? 让我们回忆一下《JMM学习笔记(二) 规则和volatile》中的内容:
Java内存模型给出了一组规则或规范, 定义了程序中各个变量(包括示例字段,静态字段和构成数组对象的元素)的访问方式,规范了Java虚拟机与计算机内存是如何协同工作的,JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些文献也称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都必须存储在内存,主内存是共享内存区域,所有线程可以访问,但线程对遍历的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对遍历进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。工作内存是每个线程的私有数据区域,因此不同的线程无法访问对方的工作内存,线程间的通信(传值) 必读通过主内存来完成
所以出现一张票重复卖的原因是自减操作并不是一个原子操作,不是原子操作的原因就是可以被打断,total--涉及两个操作首先从主内存中读取total,然后自减,那么某线程时间片耗尽,没来得及完成自减操作,也就是没来得及将操作之后的值刷新会主内存,然后另一个线程时间片获得执行,从主内存中读取到total的值,然后做自减操作,这样的情况下就会导致会卖出一张相同的票。
当时引出这个例子的时候还做了声明,不能保证相同的结果,今天再看这个例子的时候,感觉还是有些不直观于是我将TicketSell做了改造:
java
public class TicketSell implements Runnable{
private Integer total = 2000;
private Map<Integer,String> map = new ConcurrentHashMap<>();
@Override
public void run() {
while (total > 0) {
Integer result = total--;
String absentThreadName = map.putIfAbsent(result, Thread.currentThread().getName());
if (absentThreadName != null){
System.out.println(absentThreadName + "和" + Thread.currentThread().getName() + "卖了同一张票");
}
System.out.println(Thread.currentThread().getName() + "正在售卖:" + result);
}
}
然后还是没跑出来,似乎验证多线程的行为破有些不可测的感觉,但是还记得《JMM测试利器-JCStress学习笔记》吗,我们就JCStress来测试原子性,工欲善其事,必先利其器。
java
// These are the test outcomes.
@Outcome(id = "1, 1", expect = ACCEPTABLE_INTERESTING, desc = "Both actors came up with the same value: atomicity failure.")
@Outcome(id = "1, 2", expect = ACCEPTABLE, desc = "actor1 incremented, then actor2.")
@Outcome(id = "2, 1", expect = ACCEPTABLE, desc = "actor2 incremented, then actor1.")
// This is a state object
@State
public class API_01_Simple {
int v;
@Actor
public void actor1(II_Result r) {
r.r1 = ++v; // record result from actor1 to field r1
}
@Actor
public void actor2(II_Result r) {
r.r2 = ++v; // record result from actor2 to field r2
}
}
跑出来的结果:
很轻松就跑出来了递增相同的值,这个例子不安全的原因在于对共享变量的操作并非是原子性的,导致更新的结果可能不能及时刷新回主存,导致其他线程可能读到的是相对旧值,那么解决的方式比较简单,一个是加锁,等操作完成再让其他线程对共享变量进行操作,这在某种程度上就是将并行变成排队执行的感觉,所以这会影响性能,所以我们在使用锁的时候就需要尽可能的缩小锁的粒度,尽可能的保证不是排队执行。另一种方式是将对共享变量的操作变成原子的,同时使用CAS策略,也就是说再将更新后的结果刷新到主内存的时候,比较提供的变量值和主存的变量是否相等,如果相等代表主存的变量还没有被更新,然后将更新过后工作内存的变量更新到主存里面:
这也就是Java中原子类做的事情,我们以AtomicInteger为例进行解读:
java
public final int getAndDecrement() {
return U.getAndAddInt(this, VALUE, -1);
}
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 读取这个值,比较并交换.这个方法返回的是操作之前的值
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
那么单看TicketSell,其实多线程跑也没什么问题,并没有造成线程死锁,我们认为线程不安全的原因仅仅是因为递减操作不是原子的,导致卖出了同一张票,或者再本质一些就是由于线程对共享变量的操作没有及时被刷新到工作内存中,或者说线程读到了旧值。所以从这个例子中我们提炼出来的一个线程安全要以就是线程操作的变量应当是最新的,其他线程对共享的变量应当及时的能够刷新到其他线程上。上面的例子中我们提到用悲观锁可以解决一张票被售出多次这个问题,因为悲观锁就像是排队对共享变量进行操作一样,那么是否用了悲观锁就能保证线程安全, 答案当然不是,一个有效的反驳例子是单例模式的DCL写法:
java
public class SimpleSingleTon {
private static SimpleSingleTon singleton = null;
private SimpleSingleTon(){
}
public static SimpleSingleTon getInstance(){
if (singleton == null){
synchronized(SimpleSingleTon.class){
singleton = new SimpleSingleTon();
}
}
return singleton;
}
public static void main(String[] args){
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
}
}
这个例子基本运行都没有问题,但从理论上来说确实有问题的,原因在于new 对象这个步骤,并不是原子的,可以大致分成三个步骤:
- 分配SimpleSingleTon所需的内存空间,并获得一个对该内存空间的引用,
- 调用SimpleSingleTon类构造器初始化1获得的内存空间
- 将1获得对内存空的引用赋值给singleton
如果操作有依赖关系,编译器不会乱序执行操作, 但是上面的三个步骤,1=>3=>2和1=>2=>3的效果是相同的,所以JIT编译器在一些情况下就会进行重排,那么在这个情况下,如果是1=>3=>2,给外部调用者拿到的就是一个未初始化完成的对象,就会产生意想不到的后果,为了避免这种情况的产生,也就是禁止指令重排,我们可以在变量上面加上volatile 来告诉编译器。那么来看就算是将任务让线程排队执行,也无法保证线程安全,那么接着需要补充线程安全的语义,为我们的线程安全定义打上补丁,除了保证线程对共享变量的操作能够及时的被其他线程看到,还要保证其他线程拿到的共享变量是完整的。这里我们总结的一些共性就是所有的线程安全问题都牵扯到共享变量。
我们接着考察线程安全的集合HashTable、ConcurrentHashMap、CopyOnWriteArrayList,对比这三个集合是如何实现线程安全的,我们首先分析多线程操纵集合会有什么问题,我们希望达到的,再来考察这些集合采用的手段。让我们从HashTable入手,为什么先从HashTable入手,因为HashTable相对比较简单,那你是从哪里知道这个集合是线程安全的,当然是从HashTable上的注释看到的:
As of the Java 2 platform v1.2, this class was retrofitted to implement the Map interface, making it a member of the Java Collections Framework.
自JDK 1.2,这个类被改造为Map接口的实现者,使他称为Java集合框架的一员。
Unlike the new collection implementations, Hashtable is synchronized. If a thread-safe implementation is not needed, it is recommended to use HashMap in place of Hashtable.
与新的集合实现不同,HashTable是同步的,如果不需要线程安全的实现,推荐使用HashMap。
If a thread-safe highly-concurrent implementation is desired, then it is recommended to use java.util.concurrent.ConcurrentHashMap in place of Hashtable. 如果需要线程安全的高性能实现,推荐使用ConcurrentHashMap 。
对于动态集合,我认为要实现线程安全首要保证的问题就是存(put)、取(get)、扩容不出现问题,这应该是基本的要求,所谓不出现问题就是多个线程在操作集合的时候,不会出现死锁导致线程执行卡顿。
HashTable是如何实现线程安全的
那HashTable想来在JDK的开发人员中一定是线程安全的了, 我们来观察一下这个类的实现,首先HashTable是一个动态集合,所谓动态集合就是当集合装不下的时候会自动扩容,对于集合,我们首先关注的就是存(put)和取(get):
java
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
ini
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
看起来平平无奇,Hashtable内部维护了一个Entry数组,这个Entry的数据结构为:
java
private static class Entry<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Entry<K,V> next;
protected Entry(int hash, K key, V value, Entry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
所以看起来put操作就是求key的hash,然后根据hash算在Entry数组的位置,然后遍历整个Entry数组,如果Entry数组里面存在一个值hash值和传入key的hash相等,且调用equals方法相等,则覆盖这个位置的value。如果不存在就将这个值加入到Entry数组里面,然后addEntry方法里面也只是朴实无华的判断是否需要扩容,如果需要扩容,重新计算key在Entry数组的位置。所以HashTable给我们的启示就是实现线程安全就是将所有对Hashtable的操作都变成串行的,排队执行。这个性能就比较弱,难怪说,不追求线程安全用HashMap,追求线程安全的高性能实现使用ConcurrentHashMap。
ConcurrentHashMap 是如何实现线程安全的
回到我们刚开始的问题,HashMap为什么是线程不安全的,一种比较取巧的回答是人家压根就不是为线程安全的集合,你硬要用,那就后果自负。稍微进阶一点的回答是,这个线程类未做任何同步措施,所以他不是线程安全的,那么更为完备的回答是什么,我们检验一下HashMap有没有实现我们对线程安全集合的三个要求, 首先读取方法不会产生副作用,上面我们讨论的线程安全都是发生了读、写操作才出现了线程安全问题,如果多个线程只是对HashMap进行读,其实不会有任何问题,那么问题就发生在读写交替,我们就来看看在写入HashMap的时候发生了什么:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 这个Node节点的结构和Hashtable Entry的结构一致
// 这个table 也是一个Node节点数组,
// 刚开始new HashMap的时候,这个table为空
// 所以这里给这个Node数组进行初始化,所以多个线程同时写,我们可以做出假定就是同时扩容
// 也就是说线程A执行resize还没走完的时候,线程B读取table然后重复执行扩容
// 重复进行扩容的话,副作用也就是浪费空间了,问题还不是那么大
if ((tab = table) == null || (n = tab.length) == 0) // 语句一
n = (tab = resize()).length; // 语句二
if ((p = tab[i = (n - 1) & hash]) == null) // 语句三
// 走到这里说明key对应的hash值没有值存放,然后将当前key value初始化一个Node
// 放入到Node数组的位置,线程交替执行的话,其实面临的问题也就是被覆盖的问题,
// 也就是丢失数据问题
tab[i] = newNode(hash, key, value, null); // 语句四
else {
// 走到这里说明要放入key计算出来的位置已经有值了,判断key是否相同
Node<K,V> e; K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 这个判断走到这里说明发生了hash冲突
e = p;
else if (p instanceof TreeNode)
// 说明是发生了hash冲突,但是key不相等或者,然后判断这个是否是树节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 这里整体执行链表化
for (int binCount = 0; ; ++binCount) {
// 这里读取了放入key对应位置的下一个节点
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值发生了hash冲突
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;
// 这个是个空实现交给LinkedHashMap来实现
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
// 扩容
resize();
// 这个是个空实现交给LinkedHashMap来实现
afterNodeInsertion(evict);
return null;
}
我们这里只是粗略的看,也就是说还可能有其他脆弱点隐藏在子方法中,这里我们只粗略的讲一下,也就是在树化的过程中,也就是treeifyBin调用treeify这里面可能会导致出现异常,或者无限循环,参见参考资料[18]。我们考察了HashMap是不满足我们提出的线程安全要求的,一是在多线程写入的时候会有数据丢失,二是在使用的过程会出现异常导致后续流程中断。除此之外控制不当,还可能导致重复扩容,那避开这些问题,应该就是线程安全的集合了吧,我们观察一下ConcurrentHashMap是如何实现put方法的:
java
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;
}
我们主要看我们标记的语句一、二、三、四,我们看语句一是如何避免重复扩容的:
ini
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@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这个值的时候,首先获取sizeCtl这个变量的值,如果小于0,调用Thread.yield,复习一下yield:
A hint to the scheduler that the current thread is willing to yield its current use of a processor. The scheduler is free to ignore this hint. Yield is a heuristic attempt to improve relative progression between threads that would otherwise over-utilise a CPU. Its use should be combined with detailed profiling and benchmarking to ensure that it actually has the desired effect.
向调度器提示当前线程愿意放弃对当前处理器的使用,调度器也可以忽略此提示。
yield
是一种启发式的尝试,用于改善那些可能会过度使用 CPU 的线程之间的相对进度。使用yield
应该结合详细的性能分析和基准测试,以确保它实际上达到了预期的效果。It is rarely appropriate to use this method. It may be useful for debugging or testing purposes, where it may help to reproduce bugs due to race conditions. It may also be useful when designing concurrency control constructs such as the ones in the java.util.concurrent.locks package.
很少有合适的场合使用这个方法。这个方法可能对于调试或测试有用,它可能帮助复现由于竞态条件产生的错误。在设计并发控制结构时,例如 java.util.concurrent.locks 包中的一些结构,使用这个方法可能也会有所帮助
看源码的过程也是我们将学习过的知识点连接起来的过程是的,是的,就像神经元一样,连接起来的才是有价值的。我们来大致看一下sizeCtl这个关键字:
java
private transient volatile int sizeCtl;
也就是说默认值为0,这个成员变量还用volatile来修饰,还记得volatile嘛,上面单例模式的一种实现中,我们用volatile来禁止指令重排,那么在这里他的作用是什么呢? 我们回忆一下《JMM学习笔记(二) 规则和volatile》的内容:
- volatile变量规则:对于同一个volatile变量,如果对于这个变量的写操作先行发生于这个变量的读操作,那么对于这个变量的写操作所产的影响对于这个变量的读操作是可见的。
下面调用了CAS将sizeCtl设置为-1,设置成功执行初始化,设置失败代表,有其他线程已经执行了初始化返回,volatile应该不是给这个方法用的。然后我们接着来看语句二, 首先是判断指定位置是否为空,为空通过CAS放值,放置成功执行break,如果放置失败代表出现了冲突,然后判断是否在执行扩容,如果是,也执行helpTransfer一起执行扩容,如果当前节点并没有在扩容,则在进入锁决定将其放入到捅对应的链表上还是树上。写到这里,我想起ConcurrentHashMap的computeIfAbsent方法:
java
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
if (key == null || mappingFunction == null)
throw new NullPointerException();
int h = spread(key.hashCode());
V val = null;
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) & h)) == null) {
Node<K,V> r = new ReservationNode<K,V>();
// 没值加锁向里面放入
synchronized (r) {
//省略逻辑
}
if (binCount != 0)
break;
}
else if ((fh = f.hash) == MOVED)
// 帮忙扩容
tab = helpTransfer(tab, f);
else {
boolean added = false;
// 走到这一步,表示对应的位置已经有值了,还是进了synchronized
synchronized (f) {
// 省略逻辑
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (!added)
return val;
break;
}
}
}
if (val != null)
addCount(1L, binCount);
return val;
}
我们可以看到这段代码是有性能问题的,问题就在于明明对应的位置已经有值了,还是进了锁,这个问题,已经有人提给Java官方了,见参考文档20。到现在我们可以基本总结一下ConcurrentHashMap实现线程安全的几个处理策略:
- 借助CAS来避免重复初始化
- 借助CAS来向指定的桶放入节点,放置失败,进入锁,形成链表或者树
- 扩容的时候,可以多线程协助扩容
CopyOnWriteArrayList 是如何实现线程安全的
CopyOnWriteArrayList是CopyOnWrite家族的一个代表,这个家族还有其他成员:CopyOnWriteArraySet。
我们还是看他的add方法:
java
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
哇塞,真的有点朴实无华诶,并发写会出现问题,那直接排队执行吧,但为什么把旧有复制了一份,然后再赋值呢,写多的场景,不会出现大量的对象嘛?这里我们瞄一下CopyOnWriteArrayList 的注释,这里直接放我翻译过后的:
这是 java.util.ArrayList 的线程安全版本,其中所有的变动操作(如添加、设置等)都是通过制作底层数组的新副本来实现的。
通常来说,这种方法代价过高,但是当遍历操作远大于变动操作时,这种方法可能比其他替代方法更高效,并且在你不能或者不想同步遍历,但需要防止并发线程之间的干扰时,这种方法很有用。"快照"风格的迭代器方法使用的是迭代器创建时数组的状态的引用。在迭代器的生命周期中,这个数组从不改变,所以干扰是不可能的,并且这个迭代器保证不会抛出 ConcurrentModificationException。迭代器不会反映出迭代器创建之后对列表的添加、删除或改变。迭代器本身的元素改变操作(移除、设置和添加)不被支持。这些方法会抛出 UnsupportedOperationException。所有元素都是允许的,包括 null。
内存一致性效果:As with other concurrent collections, actions in a thread prior to placing an object into a CopyOnWriteArrayList happen-before actions subsequent to the access or removal of that element from the CopyOnWriteArrayList in another thread.
就像其他并发集合一样 , 一个线程对
CopyOnWriteArrayList
的写入操作,对于在其他线程中进行的后续读取或移除操作是可见的。
这里提到了快照机制,也就是说你在遍历CopyOnWriteArrayList 的时候拿到的是CopyOnWriteArrayList 当时的一个快照:
java
List<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();
copyOnWriteArrayList.add("hello");
copyOnWriteArrayList.add("world");
Iterator<String> iterator = copyOnWriteArrayList.iterator();
while (iterator.hasNext()){
String s = iterator.next();
copyOnWriteArrayList.add("111");
System.out.println(s);
}
不会输出111, 如果用ArrayList就会抛异常。每次读的时候是一个快照,读多写少,这让我想到了MVCC,每次查询都声场一个read view,但好像又不大一样。让我一直想不通的就是,为什么要写时复制,一个答案是为了避免幻读,假设每次不复制,读和写操纵都是一块数据,某线程拿到CopyOnWriteArrayList开始遍历,遍历的时候,拿到的数据还只有十条,遍历的过程中,其他线程对CopyOnWriteArrayList执行了写入,这跟幻读的场景是类似的:
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读.
但是我感到有些割裂,这像是举了个场景,来说明这种设计的必要性,但是我的想法是这些线程安全的集合能否用一种理念来统一他的设计呢,于是我开始搜索,开始翻阅其他并发集合的注释,最终在ConcurrentLinkedQueue的注释上找到了一些关键线索:
这是一个基于链表节点的无界线程安全队列。该队列按照 FIFO(先进先出)的顺序排列元素。队列的头部是在队列中停留时间最长的元素,而尾部则是停留时间最短的元素。新元素被插入到队列的尾部,队列的检索操作在队列头部获取元素。当许多线程需要共享访问一个公共集合时,
ConcurrentLinkedQueue
是一个合适的选择。和大多数其他的并发集合实现一样,这个类不允许使用null
元素。这个实现使用了一种高效的非阻塞算法,基于 Maged M. Michael 和 Michael L. Scott 在 "Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms" 中描述的算法。
迭代器是弱一致性的,返回的元素反映了队列在迭代器创建时或之后某一时刻的状态。它们不会抛出
java.util.ConcurrentModificationException
,并且可以与其他操作并发进行。在迭代器创建后包含在队列中的元素将被返回一次。请注意,与大多数集合不同,
size
方法不是一个常数时间操作。由于这些队列的异步性质,确定当前元素数量需要遍历元素,因此如果在遍历过程中修改了此集合,可能会报告不准确的结果。此外,批量操作addAll
,removeAll
,retainAll
,containsAll
,equals
,和toArray
并不能保证以原子方式执行。例如,与addAll
操作并发运行的迭代器可能只能查看到部分新增的元素
我看到了弱一致性,返回的元素反应了队列在迭代器创建时或之后的某一时刻的状态,看到了弱一致性就让我想到了跟谁保持一致性,这让我想到了CopyOnWriteArrayList 适用的场景,读多写少,如果多个线程在某线程开始写入之间开始读取那么这些读线程看到的数据是一致的,是的,那就应该用一致性概念来统一所有并发集合的设计,读写都上锁是非常强的一致,读只允许一个线程读,就不用刻意的维持一致性,写的时候上锁,则每次读取都是相对即时的数据,但这性能很弱,这让我想起了MySQL的事务,在《数据库事务概论》引出事务:
让T1,T2排队执行牺牲性能这并不是我们想要的方案,我们想要的是既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时尽量高些,舍弃一部分的隔离性来提升性能。
那么引申到并发安全集合的设计,我们也可以做一定的让步,牺牲一部分一致性来换取性能的提升。ConcurrentHashMap 、CopyOnWriteArrayList、ConcurrentLinkedQueue、ConcurrentSkipListSet、ConcurrentSkipListMap,都是这一思路的实现,放松了一致性的要求来换取性能的提升,在单线程下,不允许一边遍历,一边对集合进行操作,但在多线程下面这是不可避免的事情,单线程下的数据总是一致的,在遍历的过程,集合的数据是不发生变化的,走到多线程的时候我们就要面临这个问题,多个线程读取在某个线程写入之前读取,该如何维护多个线程之间的数据一致性。这让我想起我在写这篇文章的时候,去搜索线程安全是定义:
A class is thread-safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of the execution of those threads by the runtime environment, and with no additional synchronization or other coordination on the part of the calling code.
如果一个类在被多线程访问时能正确地执行,无论运行时环境如何调度或交错这些线程的执行,而且调用代码不需要额外的同步或其他协调操作,那么这个类就是线程安全的。 《Java Concurrency in Practice》
老实说,这个论述你不能说错,但对于我来说不具备指导意义,反倒是翻oracle 的文档有了意外的收获:
Thread safety is the avoidance of data races--situations in which data are set to either correct or incorrect values, depending upon the order in which multiple threads access and modify the data.
线程安全性是为了避免数据竞争------这种情况下,数据被设置为正确或错误的值,取决于多个线程访问和修改数据的顺序。
When no sharing is intended, give each thread a private copy of the data. When sharing is important, provide explicit synchronization to make certain that the program behaves in a deterministic manner.
当不打算共享数据时,给每个线程提供数据的私有副本。当共享变得重要时,提供明确的同步以确保程序以确定性的方式运行。
A procedure is thread safe when it is logically correct when executed simultaneously by several threads. At a practical level, it is convenient to recognize three levels of safety.
当一个过程在被多个线程同时执行时仍逻辑上正确,我们就说这个过程是线程安全的。在实际层面,我们可以识别出三个安全级别。
- Unsafe 不安全
- Thread safe - Serializable 串行化
- Thread safe - MT-Safe 多线程安全
线程安全之要义
到这里我们就可以大致总结出线程安全的要义了,对于某个方法来说,我们称之为线程安全的时候,首要保证的时候,我们调用这个方法不会出现异常(抛出异常,无限循环、死锁),这个方法向我们返回了在单线程执行不会返回值,或者不会出现的情况,上面我们提到DCL单例模式适用于这个方法,让我们转到线程安全的集合,那么在设计一个线程安全的集合的时候,还是一样的这个集合应当做出承诺,这个集合的任何方法都应当有副作用即发生异常、死锁、死循环等导致调用者无法继续执行下去的情况,其次这个集合应当对数据一致性做出取舍,选择强一致性还是弱一致性,即线程读取的时候看到是最新值就是强一致性,那么为了维持这一种强一致性,读取的时候就要用悲观锁,写入的时候也不允许读取,避免发生幻读,也要加锁,但这性能很弱,我们可以放松对一致性的要求来获得性能的提升,一种设计理念是CopyOnWriteArrayList为代表,每次写入的时候都在新的副本上写入,一种设计理念为ConcurrentHashMap为代表,在写入的时候通过CAS+synchronized来提升性能,降低内存空间的占用。但是CopyOnWriteArrayList和ConcurrentHashMap又是两类集合,对于Map来说我们访问的时候我们比较关注的是某个key存在不存在,但是对于List这种顺序集合,我们关注的就是遍历,原则上我们可以依据CopyOnWriteArrayList的思想造出来CopyOnWriteHashMap, 但前提条件是我们希望有一个读多写少的场景,如果读写均衡,会创造出大量对象,什么你用的是ZGC、什么你用的值类型,那好像也没事,简单皮一下。
写在最后
这里再次重新理了一下线程发展史,现在看起来是理所当然的多核,在上世纪是罕见的,硬件在变软件也在变,我总有些贪心,总是想一门心思的收拢所有的内容,因为聊聊这个系列是一个不断发散的过程,前段时间看了小米澎湃os的发布会,其中提到了一种新的调度技术,我对其什么感兴趣,但是揉进来总是有些突兀,有种披萨里放草莓的感觉,所以就将其移除了,其实在上面描述的并发集合里面还有许多魔法设计,但是这里没有细讲,这里讲述的是一种宏观设计思路。
参考资料
[1] 论文学习之 Linux 调度器 juejin.cn/post/722888...
[2] 《Understanding the LINUX KERNEL》 third edition www.cs.utexas.edu/~rossbach/c...
[3] Light-weight process en.wikipedia.org/wiki/Light-...
[4] What is the difference between lightweight process and thread? stackoverflow.com/questions/1...
[5] What are Linux Processes, Threads, Light Weight Processes, and Process State www.thegeekstuff.com/2013/11/lin...
[6] Why aren't Java threads both lightweight (like green threads) and multi-core capable? (backed by an internal native fixed size native thread pool) stackoverflow.com/questions/6...
[7] linux 线程 lishiwen4.github.io/linux/linux...
[8] 计算机体系结构基础 foxsen.github.io/archbase/%E...
[9] 为什么十多年前的奔腾4处理器主频就已经3G了,现在处理器还是3G,为什么处理器频率这么多年都没有提高?
[10] 物理公理化 m.thepaper.cn/newsDetail_...
[11] 公理化 www.bananaspace.org/wiki/%E5%85...
[12] CPU Scheduling www.cs.uic.edu/~jbell/Cour...
[13] Java多线程学习笔记(一) 初遇篇 segmentfault.com/a/119000003...
[14] JMM学习笔记(二) 规则和volatile juejin.cn/post/724291...
[15] Why is i++ not atomic? stackoverflow.com/questions/2...
[16] What is the difference between ++i and i++? stackoverflow.com/questions/2...
[17] 疫苗:JAVA HASHMAP的死循环 coolshell.cn/articles/96...
[18] HashMap在jdk1.8中也会死循环 www.xuetimes.com/archives/29...
[19] In what situations is the CopyOnWriteArrayList suitable? stackoverflow.com/questions/1...
[20] ConcurrentHashMap.computeIfAbsent(k,f) locks bin when k present bugs.openjdk.org/browse/JDK-...
[21] Why setArray() method call required in CopyOnWriteArrayList stackoverflow.com/questions/2...
[22] In what situations is the CopyOnWriteArrayList suitable? stackoverflow.com/questions/1...
[23] CopyOnWriteArrayList: Why do we have to make a full copy of source data? softwareengineering.stackexchange.com/questions/2...
[24] 数据库事务概论 juejin.cn/post/706633...