Hi,朋友们,你们很久不见。这两个月来发生了不少的事情,疫情爆发,不知道有多少的家庭深受其害,濒临破碎,也不知道有多少中小企业面临着复工难,无力发放工资的困局。在此国难之际,咱们更应该信任咱们的国家,积极配合工做,祈祷疫情早日结束,人民生活早日回归正轨。武汉加油,中国加油!
java
不过咱们的学习仍是要继续。在家不能外出的日子,看些文章深化一下知识也是个不错的选择呢。今天我想分享的主题是java并发编程。以前对并发编程有一些了解,可是感受就没有造成体系。因此这段时间我又从新学习了一下并发编程,但愿能把本身所学东西天然的造成体系并分享出来。本文较长,建议电脑上观看。程序员
下面是java并发编程系列的大纲图: 编程
Java并发编程一直算是比较进阶的知识,相信不少的java程序员也曾经学习过一些零碎的知识,也都知道诸如volatile是轻量版的锁,会用synchronize来保证代码的同步。可是可能也有一些朋友并不知道该在什么场景下去使用对应的工具包,也就是不知道每种工具究竟是解决了什么问题。咱们先来聊聊为何并发程序可能会致使各类诡异的bug。segmentfault
随着技术的发展,CPU,内存和IO设备都有了巨大的进步。CPU核数从单核变化到多核,同一时刻CPU可能执行着不少个任务,或者一个任务也可能同时被多个CPU在运行着。内存和IO设备的速度和容量也突飞猛进。不过再怎么变化,有一个核心矛盾即三者的速度差别都一直存在着,而且差距之大如同天上一日,人间十年。咱们的程序大部分都须要访问内存,有些还要访问IO,这样的话,总体性能都会被访问内存和IO的速度给限制着,CPU可能大部分时候都在苦巴巴的空等。数组
为了平衡三者的速度差别,提升CPU的利用率,计算机结构,操做系统,编译器都作出了一些优化,主要是:缓存
不过正是由于这些优化项,咱们在写并发程序的时候才容易出那么多诡异的bug。bash
上面咱们说到,CPU会增长缓存,来均衡CPU和内存的速度差别。在单核时候,全部线程都是在一个CPU上面运行,这个优化并不会带来问题。由于只有一个缓存,线程A在缓存中的写,等到CPU运行线程B的时候,线程B必定能看到这个写以后的结果。多线程
一个线程对共享变量的修改,另外一个线程能马上看到,咱们就称为可见性。并发
可是到了多核时代,多个CPU,多份缓存,线程A在CPU A的缓存中的写操做,线程B在CPU B中却不必定能看到,由于他们是操做的不一样的缓存,这个时候线程A的写操做对B而言就不具备可见性了。下面用个例子展现一下。app
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 建立两个线程,执行add()操做
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
复制代码
咱们用了两个线程在同一个线程里面分别把count加一了10000次,要是在单核CPU上面,结果就应该是20000,不过在多核CPU上面,结果可能就是个随机数,为何呢?假设线程A和线程B同时开始运行,那么开始都是0,而后都增长1之后都同时写入内存,这时候内存中就是1而不是咱们指望的2了。由于这样读取和写入内存都不肯定,最终的结果可能就是个随机数。
这就是多个缓存带来的可见性问题,一个线程都数据的修改不能及时被别的线程看到。
为了提升CPU的使用效率,操做系统发明了多进程和线程。操做系统容许某个线程执行一个时间片的时间,而后就会从新选择一个线程来执行。这样在等待IO的时候,操做系统就能够把时间片让给别的线程,让别的线程去执行,提升CPU利用率。
这个设计看起来很是的天然,可是由于任务切换的时机大多数是在时间片结束的时候,可是现代的高级语言一条语句每每须要多条CPU指令,好比咱们熟知的 count += 1 这个操做,就须要三条指令:
时间片的切换,可能发生在任何一个CPU指令执行完的时候,这样两个线程切换的时候可能会致使一些奇怪的问题。这样就比较坑啦。好比这张图显示的,两个线程都执行了+1的操做,最终获得的结果不是咱们指望的2,而是1.
咱们把一个或多个操做在CPU执行的过程当中不被中断的特性称为原子性。CPU只能保证指令级别的原子操做,而不是高级语言的操做符。因此咱们须要经过别的方法保证操做的原子性。
编译器为了优化性能,有时候会改变程序中语句的执行顺序。例如程序中:“a=6;b=7;”,编译器优化后可能变成“b=7;a=6;”。在这个例子中,编译器的调整并无影响程序的最终成果。可是有些时候也会产生意想不到的bug。
java领域的一个比较经典的案例就是双重检查的单例模式,好比下面的代码。
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
复制代码
上面的代码乍看之下好像没啥问题,可能有些同窗看了上面原子性问题的部分会有所想法,会不会是这里Singleton的初始化语句的问题呢?没错,问题确实出在这里。由于new操做并非个原子性操做。它实际上包含了三步:
操做系统可能会优化为1-->3-->2的操做顺序。这样的话,假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时刚好发生了线程切换,切换到了线程 B 上;若是此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,因此直接返回 instance,而此时的 instance 是没有初始化过的,若是咱们这个时候访问 instance 的成员变量就可能触发空指针异常。
解决办法是啥呢?加volatile修饰变量,或者改成
并发程序一般会出现各类诡异问题,可能乍看之下很是的无厘头,不知从何查起,可是只要咱们深入的理解了可见性,原子性,有序性,咱们就能知道在某些场景下可能会出现什么问题,而且知道java提供的并发工具各自都是在解决什么问题。后面的并发理论基础,咱们会介绍Java是如何解决这些问题的。
上面咱们讲到了并发程序常见的可见性,原子性和有序性问题,接下来咱们就介绍java为了解决这些问题都作了什么努力。我将其分为java内存模型(JMM, Java Memory Model)和并发编程基础(主要是线程相关的并发知识)。这两项都是至关重要的并发编程背景知识,能够帮助咱们更好的理解和编码。
在并发编程中,须要处理两个关键问题:线程之间如何通讯及线程之间如何同步。这里的同步指的是程序中用于控制不一样线程间操做发生相对顺序的机制。常见的线程的通讯机制有两种,共享内存和消息传递。这两种机制在两个关键问题的处理上有一些区别:
1.共享内存:在共享内存的并发模型中,线程之间共享程序的公共状态,经过写-读内存中的公共状态进行隐式通讯
。之因此是说隐式通讯,是由于两个线程没有直接联系,可是经过共享内存又拿到了对方的相关结果。可是同步是显式
的,程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。好比下图
2.消息传递:在消息传递的并发模型中,线程之间没有公共状态,线程之间须要互相发送消息,因此通讯是显式的。同时因为发送消息和接收消息有前后顺序,因此两个线程之间相对顺序就已经隐式的指定了。好比下图
Java使用的是共享内存模型。程序员须要了解隐式通讯的工做机制,不然就可能遇到各类奇怪的内存可见性问题。
1.抽象结构
java线程之间的通讯由JMM控制,JMM决定了一个线程对共享变量的写入什么时候对另外一个线程可见。同时它定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个私有的本地内存,存储着该线程已读/写共享变量的副本。固然这里的本地内存是抽象概念,包括了上面讲到的缓存,寄存器等等。表明着线程存储本数据的地方。
注意:这里提到的共享变量指的是实例域,静态域和数组元素之类的存储于堆内存中的变量。只有堆内存才能被线程之间共享。不太清楚的能够参考JVM的堆和栈
Java内存模型的抽象示意如图所示。正如咱们前面所讲,主存中存储着共享数据,每一个线程中的私有内存存储着共享变量的副本。
2.重排序
第一节中,咱们也讲到了为了提升性能,编译器和处理器常常会对指令作重排序。重排序分为三种:
上面三种,第一种属于编译器重排序,2和3属于处理器重排序。为了解决重排序带来的可见性和有序性问题,JMM会禁止特定类型的编译器重排序,而且经过插入内存屏障的方式来精致特定类型的处理器重排序。内存屏障咱们会在后面讲到。
这里朋友们可能就有疑问了,编译器和处理器会在什么状况下禁止重排序呢,有没有什么判断依据?这里主要的依据是两个操做间有没有数据依赖性。
数据依赖性:若是两个操做访问同一个变量
,且这两个操做中有一个为写操做,那么这两个操做间就存在数据依赖性。这么一看是否是很好判别,只要有写操做就能够认为是有数据依赖性了。好比写后读(a=1, b=a),写后写(a=1, a=2),读后写(a=b, b=1)。这三种类型只要有了重排序,执行结果就会被改变,编译器和处理器会禁止有数据依赖性的两个操做的执行顺序。
介绍到这里,咱们就能够引出as-if-serial语义
了。as-if-serial语义指的是无论编译器和处理器怎么重排序,单线程程序的执行结果不能被改变。编译器和处理器都必须遵照as-if-serial语义。这是咱们的程序能稳定且符合预期运行的保障。举个栗子,假设咱们有个程序
a = 1
b = 2
c = a * b
复制代码
那么a和b能够重排序,可是a和c,b和c就不能重排序,由于他们有数据依赖性
3.总线工做机制
上面咱们讲到了,缓存会致使可见性的问题是由于缓存刷入内存不及时,那么问题来了,若是很及时,两个线程同时写入缓存,而后两个缓存同时写入内存,这样内存中的数据会冲突吗?答案是不会,这与总线工做机制密切相关。
在计算机中,数据经过总线在处理器和内存之间传递,每次处理器和内存之间的数据传递都是经过一系列步骤完成的,称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据处处理器,写事务从处理器传送数据到内存。而且,总线会同步试图并发使用总线的事务
。也就是说同一时刻只有一个处理器在执行总线事务。其他的处理器须要等待前一个处理器完成事务后才能执行操做。
4.总结
本小节咱们讲到了JMM的抽象结构,让你们看到了缓存是怎么样影响到数据同步的,同时也介绍了重排序的类型和判别重排序的依据-数据依赖性,最后介绍了总线的工做机制,旨在于让你们造成对JMM工做原理的初步认识,能认识到这些机制是保障程序稳定运行的前提。
Happens-before是JMM最核心的概念,做为Java程序员,理解了Happens-before规则,就理解了JMM的关键,至关于打通了任督二脉。对于这一块,你们务必提起干劲。
1.设计思路
JMM设计之初,设计者们须要考虑到两方的重要需求:
设计者们为了平衡二者的需求,想了个好办法,对上层的程序员,提供一套happens-before规则,程序员基于这套规则提供的内存可见性保障来编程。而对下层的编译器等,要禁止掉会改变程序执行结果的重排序。除此以外,编译器等想怎么优化都行,好比把没用的加锁和volatile变量处理给去掉等。
2.定义
happens-before概念用于指定两个操做之间的执行顺序,这两个操做能够是一个线程的,也能够是不一样线程之中。它的定义是:若是一个操做happens-before另外一个操做,那么第一个操做的执行结果将对第二个操做可见。
好比操做A读写了a和b变量,同时操做A是happens before操做B的,那么在操做B执行的时候,它是能看到操做A完成之后的a和b变量的结果的。
下面咱们仍是用以前的例子。
a = 1 // 操做1
b = 2 // 操做2
c = a * b // 操做3
复制代码
对于这个例子, 操做1 happens-before 操做2 操做1 happens-before 操做3 操做2 happens-before 操做3
可是要注意的是,happens-before规则不必定对应程序执行顺序
,这也是设计者们对于编译器和处理器的“放水”。就数据依赖性而言,3个happens-before关系中,2和3是必须的,可是1不是必要的。因此操做1和操做2的执行顺序是能够颠倒的。编译器和处理器在这种状况下就会尽量的进行优化。
3.happens-before规则
java中一共定义了六条happens-before规则:
因为编译器和处理器都必须知足as-if-serial语义,as-if-serial语义进一步保证了程序顺序规则。所以程序顺序规则至关于咱们前面提到的as-if-serial语义的封装。 这里的start和join规则主要是提供了线程切换时候的可见性保证,而前面四条规则提供了咱们平常使用到的各类工具的可见性保证。
happens-before很是重要,对于后面咱们理解锁和工具集的实现原理十分关键。你们先记着,等后面会常常性的用到。
大多数java学习者在一开始学习volatile的时候,都是记得:volatile能够看作是轻量级的锁,其修饰的变量的变动可以保证多线程的可见性。我刚学volatile的时候,觉得volatile只是java提供的一个小小的工具,可是看到java的happens-before规则中专门有volatile的一项,就感受真的不是那么简单。
咱们能够把对volatile变量的单个读/写,当作是使用同一个锁对这些单个读/写操做作了同步。因此volatile变量具备下列特性:
介绍完了volatile变量的特性,咱们结合上面提到的JMM内存抽象结构介绍下JMM对于volatile变量读写的操做。当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中,读的时候,volatile变量强制从主内存中读取。这样就至关于禁用了CPU缓存。这里须要注意到的是JMM不止会把volatile变量刷新到主内存,而是把本地内存中的全部共享变量。这个特性被用到了java concurrent包的各类工具类中。下面用个小栗子来讲明。
public class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
System.out.println("x = " + x);
}
}
}
复制代码
好比这个类,一个线程执行writer,而后另外一个线程再执行reader,就会输出x=42,这就是由于共享变量在volatile变量写的时候都被刷入进去主内存了。注意,volatile变量必定要最后写,最早读。
接下来能够总结volatile写和volatile读的内存语义:
看到这里,你们对JMM的共享内存模型隐式通讯
的特色是否是认识更深入些了?
上面介绍完了volatile的内存语义,接下来看看JMM如何实现volatile的内存语义。以前咱们提到太重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会限制这两种重排序类型。
能够看出来
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止处理器重排序。内存屏障的做用有两个:1是阻止屏障两侧的的指令重排,2是强制把高速缓存中的数据更新或者写入到主存中。Load Barrier负责更新高速缓存, Store Barrier负责将高速缓冲区的内容写回主存
JMM会采起保守策略,保证任何状况下都能获得正确的volatile语义。
这里以volatile写为例,讲解下屏障的意义。以下图所示,volatile写的先后分别被插入了StoreStore和StoreLoad屏障,插入以后,处理器就不能对指定类型进行重排序了。你能够把内存屏障看作一个栏杆,代码想越也越不了,只能按照固定顺序执行。
可能看到这里你就会有疑问了,上面咱们讲到第二个操做是volatile写时候,无论第一个操做是啥,都不能重排序,可是这里只给volatile写前面加了个StoreStore,为啥没有LoadStore呢?其实这里我也没有想明白,欢迎理解了的同窗指点一下。
锁是java中最重要的同步机制。你们可能从一开始学java就会把synchronized用上。接下来咱们就介绍下锁的内存语义。
当线程释放锁的时候,JMM会把线程对应的本地内存中的共享变量刷新到主内存上。当另外一个线程获取锁的时候,JMM会把该线程对应的本地内存置为无效,从而被监视器保护的临界区代码必须从主内存中读取共享变量。这点咱们也能够从happens-before规则中推导出来。
按照程序顺序规则,1 happens-before 2 happens-before 3, 4 happens-before 5 happens-before 6。 按照锁规则,3 happens-before 4。 因此按照传递规则,2 happens-before 5。也就是说,前一个线程在临界区进行的修改,都会后续得到锁的线程可见。
看到这里,你们是否是以为加锁和释放锁,和上面的volatile读和写操做是分别对应的,具备同样的内存语义。总结以下:
下面咱们借助于ReentrantLock来分析锁内存语义的具体实现机制。ReentrantLock依赖于AbstractQueuedSynchronizer(后面简称AQS)。AQS使用整型的volatile变量state来维护同步状态。ReentrantLock调用方式分为公平锁和非公平锁:
这里咱们就引入了compareAndSet
方法,这个方法也常被缩写为CAS
。它的做用是,若是当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。能够看到CAS操做知足了原子性和可见性。处理器在处理CAS方法时候,会给交换指令加上lock前缀。而lock前缀的特征以下:
后面两点就使得CAS彻底具备了volatile读和写的内存语义。
所以,锁的内存语义的实现实际上能够有两种方式:
固然还能够利用volatile和CAS的自由组合。咱们在分析concurrent包的源代码实现时候,就会发现通用化的实现模式:
能够说,volatile和CAS就是java并发编程的基石
上面讲完了内存模型,下面咱们来说讲偏java实现的概念,即并发编程中的线程是如何工做的。这里我主要是讲到线程的监视器
和等待/通知机制
。
任何一个对象都有本身的监视器
。你们能够看看Object的几个方法,wait(), notify(), notifyAll(), 这是都是跟监视器相关的。当这个对象由同步块或者同步方法调用时,执行方法的线程必须先获取到监视器才能进入同步区域。咱们能够把监视器看作房间的大门。只有拿到门锁才能进入房间。而没有取到监视器的线程则会阻塞在入口处,线程会进入BLOCK状态。咱们就会说线程被阻塞了。
你们都知道,咱们常使用的synchronized就是一个单线程监视器锁,只有一个线程能得到监视器并进入临界区执行代码,而其余的线程则会进入到同步队列中,等待线程释放了监视器之后,收到Monitor.Exit通知才能继续去尝试得到监视器。
那么问题来了,这里不是只要等着线程A释放监视器,线程B就能去获取锁了吗,那么Object的wait和notify操做是拿来干吗的呢?
其实现实状态并不会像想的那么理想,线程B获取到了锁,可是这时候可能条件并不知足,线程B并不能往下执行。好比线程B只能在flag=true的状况下执行,可是当它获取到监视器的时候,flag=false,那么若是线程B实在是想执行后面的操做的话,就有两种办法:
可想而知,第二种效率更高,更及时。这也是咱们说到的等待/通知机制
。
这里咱们看到,当条件不知足的时候,能够经过条件变量的wait()方法将当前正在执行的线程放入条件变量的等待队列,当条件知足的时候,调用条件变量的notify或者notifyAll方法将线程从等待队列中移出。那么有些同窗就会犯迷糊了。这里的等待队列和上面说到的由于没获取到监视器的线程的阻塞队列有区别吗?
固然有区别!上面的阻塞队列是没获取到监视器,这里的等待队列是获取到监视器,可是继续运行的条件没有知足,所以本身陷入等待状态的队列。二者是不一样的概念。以追妹子为例,咱们能够理解为阻塞队列是排队进妹子家的大门,进了大门才能和妹子聊天,可是聊完以后就要进入到备胎池等着妹子选择佳婿了。等妹子选好之后接到妹子电话,就能够从新排队登门和妹子谈恋爱了。
使用wait(),notify()和notifyAll()有些须要注意的细节:
本文讲到了java内存模型和线程的同步机制。java内存模型的重点是happens-before规则,volatile和锁。只要这些东西能了解,concurrent包里的实现范式咱们就能看懂了。了解了线程的等待/通知模型,咱们就能对锁的使用更加的了然于心。
本文就到这里啦。后续可能会学习下java的并发工具的设计和实现,若是有值得分享的东西,会另外再写文章分享出来,敬请期待。
《java并发编程的艺术》
《java并发编程实战》王宝令
我是Android笨鸟之旅,笨鸟也要有向上飞的心,我在这里陪你一块儿慢慢变强。期待你的关注