常识图谱
前言
网上并发以及JMM局部的内容大部分都特地的乱,也不好整顿。花了三四天工夫才整顿了一篇,有些概念的货色,是须要理解的,也标注进去了。
标注:在学习中须要批改的内容以及笔记全在这里 www.javanode.cn,谢谢!有任何不妥的中央望纠正
并发编程的优缺点
1. 为什么要用到并发
多核的CPU的背景下,催生了并发编程的趋势,通过并发编程的模式能够将多核CPU的计算能力施展到极致,性能失去晋升
面对简单业务模型,并行程序会比串行程序更适应业务需要,而并发编程更能吻合这种业务拆分
2. 并发编程有哪些毛病
2.1 频繁的上下文切换
工夫片是CPU调配给各个线程的工夫,因为工夫十分短,所以CPU一直通过切换线程,让咱们感觉多个线程是同时执行的,工夫片个别是几十毫秒。而每次切换时,须要保留以后的状态起来,以便可能进行复原先前状态,而这个切换时十分损耗性能,过于频繁反而无奈施展出多线程编程的劣势。通常缩小上下文切换能够采纳无锁并发编程,CAS算法,应用起码的线程和应用协程。
2.2 线程平安
多线程编程中最难以把握的就是临界区线程平安问题,略微不留神就会呈现死锁的状况,一旦产生死锁就会造成零碎性能不可用。
public class DeadLockDemo { private static String resource_a = "A"; private static String resource_b = "B"; public static void main(String[] args) { deadLock(); } public static void deadLock() { Thread threadA = new Thread(new Runnable() { @Override public void run() { synchronized (resource_a) { System.out.println("get resource a"); try { Thread.sleep(3000); synchronized (resource_b) { System.out.println("get resource b"); } } catch (InterruptedException e) { e.printStackTrace(); } } } }); Thread threadB = new Thread(new Runnable() { @Override public void run() { synchronized (resource_b) { System.out.println("get resource b"); synchronized (resource_a) { System.out.println("get resource a"); } } } }); threadA.start(); threadB.start(); }}
通常能够用如下形式防止死锁的状况
- 防止一个线程同时取得多个锁;
- 防止一个线程在锁外部占有多个资源,尽量保障每个锁只占用一个资源
- 尝试应用定时锁,应用lock.tryLock(timeOut),当超时期待时以后线程不会阻塞;
- 对于数据库锁,加锁和解锁必须在一个数据库连贯里,否则会呈现解锁失败的状况
并发三要素(理解)
可见性: CPU缓存引起
可见性:当多个线程拜访同一个变量时,如果其中一个线程对其作了批改,其余线程能立刻获取到最新的值。
原子性: 分时复用引起
原子性:即一个操作或者多个操作 要么全副执行并且执行的过程不会被任何因素打断,要么就都不执行
有序性: 重排序引起
程序执行的程序依照代码的先后顺序执行。(处理器可能会对指令进行重排序)
在执行程序时为了进步性能,编译器和处理器经常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不扭转单线程程序语义的前提下,能够重新安排语句的执行程序。
- 指令级并行的重排序。古代处理器采纳了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器能够扭转语句对应机器指令的执行程序。
- 内存零碎的重排序。因为处理器应用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
并发外围概念(理解)
并发与并行(重要)
第一种
- 在单CPU零碎中,系统调度在某一时刻只能让一个线程运行,尽管这种调试机制有多种形式(大多数是工夫片轮巡为主),但无论如何,要通过一直切换须要运行的线程让其运行的形式就叫并发(concurrent)。
- 而在多CPU零碎中,能够让两个以上的线程同时运行,这种能够同时让两个以上线程同时运行的形式叫做并行
- 第二种
你吃饭吃到一半,电话来了,你始终到吃完了当前才去接,这就阐明你不反对并发也不反对并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后持续吃饭,这阐明你反对并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这阐明你反对并行。
并发的要害是你有解决多个工作的能力,不肯定要同时。
并行的要害是你有同时解决多个工作的能力。
要害的点就是:是否是『同时』。
同步(重要)
在并发中,咱们能够将同步定义为一种协调两个或更多任务以取得预期后果的机制。同步的形式有两种:
- 管制同步:例如,当一个工作的开始依赖于另一个工作的完结时,第二个工作不能在第一个工作实现之前开始。
- 数据拜访同步:当两个或更多任务访问共享变量时,在任意工夫里,只有一个工作能够拜访该变量。
与同步密切相关的一个概念是临界段。临界段是一段代码,因为它能够访问共享资源,因而在任何给定工夫内,只能被一个工作执行。互斥是用来保障这一要求的机制,而且能够采纳不同的形式来实现。
并发零碎中有不同的同步机制。从实践角度看,最风行的机制如下:
- 信号量(semaphore):一种用于管制对一个或多个单位资源进行拜访的机制。它有一个用于寄存可用资源数量的变量,而且能够采纳两种原子操作来治理该变量。互斥(mutex,mutual exclusion的简写模式)是一种非凡类型的信号量,它只能取两个值(即资源闲暇和资源忙),而且只有将互斥设置为忙的那个过程才能够开释它。互斥能够通过爱护临界段来帮忙你避免出现竞争条件。
- 监视器:一种在共享资源上实现互斥的机制。它有一个互斥、一个条件变量、两种操作(期待条件和通报条件)。一旦你通报了该条件,在期待它的工作中只有一个会继续执行。如果共享数据的所有用户都受到同步机制的爱护,那么代码(或办法、对象)就是线程平安的。数据的非阻塞的CAS(compare-and-swap,比拟和替换)原语是不可变的,这样就能够在并发应用程序中应用该代码而不会出任何问题。
不可变对象
不可变对象是一种十分非凡的对象。在其初始化后,不能批改其可视状态(其属性值)。如果想批改一个不可变对象,那么你就必须创立一个新的对象。
不可变对象的次要长处在于它是线程平安
的。你能够在并发应用程序中应用它而不会呈现任何问题。
不可变对象的一个例子就是java中的String类。当你给一个String对象赋新值时,会创立一个新的String对象。
原子操作和原子变量
与应用程序的其余工作相比,原子操作是一种产生在霎时的操作。在并发应用程序中,能够通过一个临界段来实现原子操作,以便对整个操作采纳同步机制。
原子变量是一种通过原子操作来设置和获取其值的变量。能够应用某种同步机制来实现一个原子变量,或者也能够应用CAS以无锁形式来实现一个原子变量,而这种形式并不需要任何同步机制。
共享内存与消息传递(重要)
工作能够通过两种不同的形式来互相通信。
- 共享内存,通常用于在同一台计算机上运行多任务的状况。工作在读取和写入值的时候应用雷同的内存区域。为了避免出现问题,对该共享内存的拜访必须在一个由同步机制爱护的临界段内实现。
- 消息传递,通常用于在不同计算机上运行多任务的情景。当一个工作须要与另一个工作通信时,它会发送一个遵循预约义协定的音讯。如果发送方放弃阻塞并期待响应,那么该通信就是同步的;如果发送方在发送音讯后继续执行本人的流程,那么该通信就是异步的。
并发的问题(理解)
数据竞争
如果有两个或者多个工作在临界段之外对一个共享变量进行写入操作,也就是说没有应用任何同步机制,那么应用程序可能存在数据竞争(也叫做竞争条件)。
在这些状况下,应用程序的最终后果可能取决于工作的执行程序。
public class ConcurrentDemo { private float myFloat; public void modify(float difference) { float value = this.myFloat; this.myFloat = value + difference; } public static void main(String[] args) { } }
死锁
当两个(或多个)工作正在期待必须由另一线程开释的某个共享资源,而该线程又正在期待必须由前述工作之一开释的另一共享资源时,并发应用程序就呈现了死锁。当零碎中同时呈现如下四种条件时,就会导致这种情景。咱们将其称为Coffman 条件。
- 互斥: 死锁中波及的资源、必须是不可共享的。一次只有一个工作能够应用该资源。
- 占有并期待条件: 一个工作在占有某一互斥的资源时又申请另一互斥的资源。当它在期待时,不会开释任何资源。
- 不可剥夺:资源只能被那些持有它们的工作开释。
- 循环期待:工作1正等待工作2 所占有的资源, 而工作2 又正在期待工作3 所占有的资源,以此类推,最终工作n又在期待由工作1所占有的资源,这样就呈现了循环期待。
有一些机制能够用来防止死锁。
- 疏忽它们:这是最罕用的机制。你能够假如本人的零碎绝不会呈现死锁,而如果产生死锁,后果就是你能够进行应用程序并且从新执行它。
- 检测:零碎中有一项专门剖析零碎状态的工作,能够检测是否产生了死锁。如果它检测到了死锁,能够采取一些措施来修复该问题,例如,完结某个工作或者强制开释某一资源。
- 预防:如果你想避免零碎呈现死锁,就必须预防Coffman 条件中的一条或多条呈现
- 躲避:如果你能够在某一工作执行之前失去该工作所应用资源的相干信息,那么死锁是能够躲避的。当一个工作要开始执行时,你能够对系统中闲暇的资源和工作所需的资源进行剖析,这样就能够判断工作是否可能开始执行。
活锁
如果零碎中有两个工作,它们总是因对方的行为而扭转本人的状态, 那么就呈现了活锁。最终后果是它们陷入了状态变更的循环而无奈持续向下执行。
例如,有两个工作:工作1和工作2 ,它们都须要用到两个资源:资源1和资源2 。假如工作1对资源1加了一个锁,而工作2 对资源2 加了一个锁。当它们无法访问所需的资源时,就会开释本人的资源并且从新开始循环。这种状况能够有限地继续上来,所以这两个工作都不会完结本人的执行过程。
资源有余
当某个工作在零碎中无奈获取维持其继续执行所需的资源时,就会呈现资源有余。当有多个工作在期待某一资源且该资源被开释时,零碎须要抉择下一个能够应用该资源的工作。如果你的零碎中没有设计良好的算法,那么零碎中有些线程很可能要为获取该资源而期待很长时间。
要解决这一问题就要确保偏心准则。所有期待某一资源的工作必须在某一给定工夫之内占有该资源。可选计划之一就是实现一个算法,在抉择下一个将占有某一资源的工作时,对工作已期待该资源的工夫因素加以思考。然而,实现锁的偏心须要减少额定的开销,这可能会升高程序的吞吐量。
优先权反转
当一个低优先权的工作持有了一个高优先级工作所需的资源时,就会产生优先权反转。这样的话,低优先权的工作就会在高优先权的工作之前执行。
java内存模型(JMM) 重要
JMM概述
呈现线程平安的问题个别是因为主内存和工作内存数据不一致性和重排序导致的,而解决线程平安的问题最重要的就是了解这两种问题是怎么来的,那么,了解它们的外围在于了解java内存模型(JMM)。
Java 的并发采纳的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员齐全通明。如果编写多线程程序的 Java 程序员不了解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。咱们须要解决两个关键问题:线程之间如何通信及线程之间如何同步
(这里的线程是指并发执行的流动实体)。通信是指线程之间以何种机制来替换信息。紧接着咱们须要晓得java中那些是共享内存
共享变量与局部变量
- 共享变量:在 java 中,所有
实例域、动态域和数组元素存储在堆内存中,堆内存在线程之间共享
。 - 局部变量(Local variables), 办法定义参数(java 语言标准称之为 formal method parameters)和异样处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
JMM内存模型形象
Java 线程之间的通信由 Java 内存模型(JMM java method model)管制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
从形象的角度来看,JMM 定义了线程和主内存之间的形象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个公有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的正本。本地内存是 JMM 的一个抽象概念,并不实在存在。它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化。
Java 内存模型的形象示意图如下:
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经验上面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 而后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
线程A和线程B通过共享变量在进行隐式通信
。如果线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就呈现了“脏读”
景象。能够通过同步机制(管制不同线程间操作产生的绝对程序)来解决或者通过volatile关键字使得每次volatile变量都可能强制刷新到主存,从而对每个线程都是可见的。
重排序(重要)
一个好的内存模型实际上会放松对处理器和编译器规定的解放,也就是说软件技术和硬件技术都为同一个指标而进行奋斗:在不扭转程序执行后果的前提下,尽可能进步并行度。
JMM对底层尽量减少束缚,使其可能施展本身劣势。因而,在执行程序时,为了进步性能,编译器和处理器经常会对指令进行重排序。
Store Buffer的提早写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和CPU的指令重排序。
- 编译器重排序。
对于没有先后依赖关系的语句,编译器能够从新调整语句的执行程序。
- CPU指令重排序。
在指令级别,让没有依赖关系的多条指令并行。
- CPU内存重排序。
CPU有本人的缓存,指令的执行程序和写入主内存的程序不完全一致。
1属于编译器重排序,而2和3统称为CPU处理器重排序。这些重排序会导致线程平安的问题,一个很经典的例子就是DCL问题.
假如:X、Y是两个全局变量,初始的时候,X,Y是全局变量并 X=0,Y=0。 线程A,B 别离执行各自的值。线程1和线程2的执行先后顺序是不确定的,可能程序执行,也可能穿插执行
,这就造成内存可见性问题。可能会呈现后果可能是:
- a=0,b=1
- a=1,b=0
- a=1,b=1
对于编译器,JMM 的编译器重排序规定会禁止特定类型的编译器重排序
(不是所有的编译器重排序都要禁止)。对于CPU处理器重排序,JMM 的处理器重排序规定会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障指令,
通过内存屏障指令来禁止特定类型的处理器重排序`(不是所有的处理器重排序都要禁止)。
内存屏障(理解)
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障
(Memory Barrier)。这也正是JMM和happen-before规定的底层实现原理。
编译器的内存屏障,只是为了通知编译器不要对指令进行重排序。当编译实现之后,这种内存屏障就隐没了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,能够由开发者显示调用。内存屏障是很底层的概念,对于 Java 开发者来说,个别用 volatile 关键字
就足够了。但从JDK 8开始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe { // ... public native void loadFence(); public native void storeFence(); public native void fullFence(); // ...}
在实践层面,能够把根本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Unsafe中的办法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
as-if-serial语义(理解)
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行后果不能被扭转。
重排序的准则是什么?什么场景下能够重排序,什么场景下不能重排序呢?
- 单线程程序的重排序规定
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行后果不能扭转,这就是单线程程序的重排序规定
。
即只有操作之间没有数据依赖性,编译器和CPU都能够任意重排序,因为执行后果不会扭转,代码看起来就像是齐全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问题。
- 多线程程序的重排序规定
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太简单,编译器和CPU没有方法齐全了解这种依赖性并据此做出最正当的优化。
编译器和CPU只能保障每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,须要编译器和CPU的下层来确定。
下层要告知编译器和CPU在多线程场景下什么时候能够重排序,什么时候不能重排序。
happens-before定义
JMM能够通过happens-before关系向程序员提供跨线程的内存可见性保障这两个操作既能够是在一个线程之内,也能够是在不同线程之间。
- 如果一个操作happens-before另一个操作,那么第一个操作的执行后果将对第二个操作可见,而且第一个操作的执行程序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要依照happens-before关系指定的程序来执行。如果重排序之后的执行后果,与按happens-before关系来执行的后果统一,那么这种重排序并不非法(也就是说,JMM容许这种重排序)。
下面的1)是JMM对程序员的承诺。从程序员的角度来说,能够这样了解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保障——A操作的后果将对B可见,且A的执行程序排在B之前。留神,这只是Java内存模型向程序员做出的保障!
下面的2)是JMM对编译器和处理器重排序的束缚准则。正如后面所言,JMM其实是在遵循一个根本准则:只有不改变程序的执行后果(指的是单线程程序和正确同步的多线程程序
),编译器和处理器怎么优化都行。JMM这么做的起因是:程序员对于这两个操作是否真的被重排序并不关怀,程序员关怀的是程序执行时的语义不能被扭转(即执行后果不能被扭转)。因而,happens-before关系实质上和as-if-serial语义是一回事。
as-if-serial和happens-before的区别
- as-if-serial语义保障单线程内程序的执行后果不被扭转,happens-before关系保障正确同步的多线程程序的执行后果不被扭转。
- as-if-serial语义给编写单线程程序的程序员发明了一个幻境:单线程程序是按程序的程序来执行的。happens-before关系给编写正确同步的多线程程序的程序员发明了一个幻境:正确同步的多线程程序是按happens-before指定的程序来执行的。
- as-if-serial语义和happens-before这么做的目标,
都是为了在不扭转程序执行后果的前提下,尽可能地进步程序执行的并行度
。
happens-before规定(理解)
- 程序程序规定:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规定:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规定:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规定:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规定:如果线程A执行操作ThreadB.join()并胜利返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作胜利返回。
- 程序中断规定:对线程interrupted()办法的调用后行于被中断线程的代码检测到中断工夫的产生。
- 对象finalize规定:一个对象的初始化实现(构造函数执行完结)后行于产生它的finalize()办法的开始。
happens-before值传递(理解)
这些根本的happen-before规定,happen-before还具备传递性,即若A happen-before B,Bhappen-before C,则A happen-before C。
举例:
- volatile
如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多线程程序中,咱们要么加锁,要么必须把所有变量都申明为volatile变量?这显然不可能,而这就得归功于happen-before的传递性。
class A { private int a = 0; private volatile int c = 0; public void set() { a = 5; // 操作1 c = 1; // 操作2 } public int get() { int d = c; // 操作3 return a; // 操作4 } }
操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happen,before操作4。又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen,before操作3。利用happen-before的传递性,就失去:
操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。
- synchronized
因为与volatile一样,synchronized同样具备happen-before语义。开展下面的代码可失去相似于上面的伪代码:
class A { private int a = 0; private int c = 0; public synchronized void set() { a = 5; // 操作1 c = 1; // 操作2 } public synchronized int get() { return a; } }
JMM的设计(重要)
下面曾经聊了对于JMM的两个方面:1. JMM的形象构造(主内存和线程工作内存);2. 重排序以及happens-before规定。
- 下层会有基于JMM的关键字和J.U.C包下的一些具体类用来不便程序员可能迅速高效率的进行并发编程。
- JMM处于中间层,蕴含了两个方面:1. 内存模型;2.重排序以及happens-before规定。为了禁止特定类型的重排序会对编译器和处理器指令序列加以控制。
在设计JMM时须要思考两个关键因素
:
- 程序员对内存模型的应用 程序员心愿内存模型易于了解、易于编程。程序员心愿基于一个强内存模型来编写代码
- 编译器和处理器对内存模型的实现 编译器和处理器心愿内存模型对它们的解放越少越好,这样它们就能够做尽可能多的优化来进步性能。编译器和处理器心愿实现一个弱内存模型。
JMM 把 happens- before 要求禁止的重排序分为了上面两类:
- 会扭转程序执行后果的重排序。
- 不会扭转程序执行后果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略:
- 对于会扭转程序执行后果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会扭转程序执行后果的重排序,JMM 对编译器和处理器不作要求(JMM 容许这种重排序)
从上图能够看出两点:
JMM 向程序员提供的 happens- before 规定能满足程序员的需要
。JMM 的 happens- before 规定岂但简略易懂,而且也向程序员提供了足够强的内存可见性保障(有些内存可见性保障其实并不一定实在存在,比方下面的 A happens- before B)。JMM 对编译器和处理器的解放曾经尽可能的少
。从下面的剖析咱们能够看出,JMM 其实是在遵循一个根本准则:只有不改变程序的执行后果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比方,如果编译器通过粗疏的剖析后,认定一个锁只会被单个线程拜访,那么这个锁能够被打消。再比方,如果编译器通过粗疏的剖析后,认定一个 volatile 变量仅仅只会被单个线程拜访,那么编译器能够把这个 volatile 变量当作一个一般变量来看待。这些优化既不会改变程序的执行后果,又能进步程序的执行效率。
JMM 的内存可见性保障(重要)
Java 程序的内存可见性保障按程序类型能够分为下列三类:
- 单线程程序。单线程程序不会呈现内存可见性问题。编译器,runtime 和处理器会独特确保单线程程序的执行后果与该程序在程序一致性模型中的执行后果雷同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具备程序一致性(程序的执行后果与该程序在程序一致性内存模型中的执行后果雷同)。这是 JMM 关注的重点,JMM 通过限度编译器和处理器的重排序来为程序员提供内存可见性保障。
- 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
下图展现了这三类程序在 JMM 中与在程序一致性内存模型中的执行后果的异同:
标注:在学习中须要批改的内容以及笔记全在这里 www.javanode.cn,谢谢!有任何不妥的中央望纠正