高并发编程知识体系
1.问题
1、什么是线程的交互方式?
2、如何区分线程的同步/异步,阻塞/非阻塞?
3、什么是线程安全,如何做到线程安全?
4、如何区分并发模型?
5、何谓响应式编程?
6、操作系统如何调度多线程?
2.关键词
同步,异步,阻塞,非阻塞,并行,并发,临界区,竞争条件,指令重排,锁,amdahl,gustafson
3.全文概要
由于单机的性能上限原因我们才不得不发展分布式技术。那么话说回来,如果单机的性能没能最大限度的榨取出来,就盲目的就建设分布式系统,那就有点本末倒置了。如果单机性能满足的话,就不要折腾复杂的分布式架构。如果说分布式架构是宏观上的性能扩展,那么高并发则是微观上的性能调优。本文将从线程的基础理论谈起,逐步探究线程的内存模型,线程的交互,线程工具和并发模型的发展。扫除关于并发编程的诸多模糊概念,从新构建并发编程的层次结构。
4.基础理论
4.1基本概念
开始学习并发编程前,我们需要熟悉一些理论概念。既然我们要研究的是并发编程,那首先应该对并发这个概念有所理解才是,而说到并发我们肯定要要讨论一些并行。
并发:一个处理器同时处理多个任务
并行:多个处理器或者是多核的处理器同时处理多个不同的任务
然后我们需要再了解一下同步和异步的区别:
同步:执行某个操作开始后就一直等着按部就班的直到操作结束
异步:执行某个操作后立即离开,后面有响应的话再来通知执行者
接着我们再了解一个重要的概念:
临界区:公共资源或者共享数据
由于共享数据的出现,必然会导致竞争,所以我们需要再了解一下:
阻塞:某个操作需要的共享资源被占用了,只能等待,称为阻塞
非阻塞:某个操作需要的共享资源被占用了,不等待立即返回,并携带错误信息回去,期待重试
如果两个操作都在等待某个共享资源而且都互不退让就会造成死锁:
死锁:参考著名的哲学家吃饭问题
饥饿:饥饿的哲学家等不齐筷子吃饭
活锁:相互谦让而导致阻塞无法进入下一步操作,跟死锁相反,死锁是相互竞争而导致的阻塞
4.2并发级别
理想情况下我们希望所有线程都一起并行飞起来。但是CPU数量有限,线程源源不断,总得有个先来后到,不同场景需要的并发需求也不一样,比如秒杀系统我们需要很高的并发程度,但是对于一些下载服务,我们需要的是更快的响应,并发反而是其次的。所以我们也定义了并发的级别,来应对不同的需求场景。
阻塞:阻塞是指一个线程进入临界区后,其它线程就必须在临界区外等待,待进去的线程执行完任务离开临界区后,其它线程才能再进去。
无饥饿:线程排队先来后到,不管优先级大小,先来先执行,就不会产生饥饿等待资源,也即公平锁;相反非公平锁则是根据优先级来执行,有可能排在前面的低优先级线程被后面的高优先级线程插队,就形成饥饿
无障碍:共享资源不加锁,每个线程都可以自有读写,单监测到被其他线程修改过则回滚操作,重试直到单独操作成功;风险就是如果多个线程发现彼此修改了,所有线程都需要回滚,就会导致死循环的回滚中,造成死锁
无锁:无锁是无障碍的加强版,无锁级别保证至少有一个线程在有限操作步骤内成功退出,不管是否修改成功,这样保证了多个线程回滚不至于导致死循环
无等待:无等待是无锁的升级版,并发编程的最高境界,无锁只保证有线程能成功退出,但存在低级别的线程一直处于饥饿状态,无等待则要求所有线程必须在有限步骤内完成退出,让低级别的线程有机会执行,从而保证所有线程都能运行,提高并发度。
4.3量化模型
首先,多线程不意味着并发,但并发肯定是多线程或者多进程。我们知道多线程存在的优势是能够更好的利用资源,有更快的请求响应。但是我们也深知一旦进入多线程,附带而来的是更高的编码复杂度,线程设计不当反而会带来更高的切换成本和资源开销。但是总体上我们肯定知道利大于弊,这不是废话吗,不然谁还愿意去搞多线程并发程序,但是如何衡量多线程带来的效率提升呢,我们需要借助两个定律来衡量。
Amdahl
S=1/(1-a+a/n)
其中,a为并行计算部分所占比例,n为并行处理结点个数。这样,当1-a=0时,(即没有串行,只有并行)最大加速比s=n;当a=0时(即只有串行,没有并行),最小加速比s=1;当n→∞时,极限加速比s→ 1/(1-a),这也就是加速比的上限。
Gustafson
系统优化某部件所获得的系统性能的改善程度,取决于该部件被使用的频率,或所占总执行时间的比例。
两面列举了这两个定律来衡量系统改善后提升效率的量化指标,具体的应用我们在下文的线程调优会再详细介绍。
5.内存模型
宏观上分布式系统需要解决的首要问题是数据一致性,同样,微观上并发编程要解决的首要问题也是数据一致性。貌似我们搞了这么多年的斗争都是在公关一致性这个世界性难题。既然并发编程要从微观开始,那么我们肯定要对CPU和内存的工作机理有所了解,尤其是数据在CPU和内存直接的传输机制。
5.1整体原则
探究内存模型之前我们要抛出三个概念:
原子性
在32位的系统中,对于4个字节32位的Integer的操作对应的JVM指令集映射到汇编指令为一个原子操作,所以对Integer类型的数据操作是原子性,但是Long类型为8个字节64位,32位系统要分为两条指令来操作,所以不是原子操作。
对于32位操作系统来说,单次次操作能处理的最长长度为32bit,而long类型8字节64bit,所以对long的读写都要两条指令才能完成(即每次读写64bit中的32bit)
可见性
线程修改变量对其他线程即时可见
有序性
串行指令顺序唯一,并行线程直接指令可能出现不一致,也即是指令被重排了
而指令重排也是有一定原则(摘自《深入理解Java虚拟机第12章》):
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
5.2逻辑内存
我们谈的逻辑内存也即是JVM的内存格局。JVM将操作系统提供的物理内存和CPU缓存在逻辑分为堆,栈,方法区,和程序计数器。在《从宏观微观角度浅析JVM虚拟机》 一文我们详细介绍了JVM的内存模型分布,并发编程我们主要关注的是堆栈的分配,因为线程都是寄生在栈里面的内存段,把栈里面的方法逻辑读取到CPU进行运算。
5.3物理内存
而实际的物理内存包含了主存和CPU的各级缓存还有寄存器,而为了计算效率,CPU往往回就近从缓存里面读取数据。在并发的情况下就会造成多个线程之间对共享数据的错误使用。
5.4内存映射
由于可能发生对象的变量同时出现在主存和CPU缓存中,就可能导致了如下问题:
线程修改的变量对外可见
读写共享变量时出现竞争资源
由于线程内的变量对栈外是不可见的,但是成员变量等共享资源是竞争条件,所有线程可见,就会出现如下当一个线程从主存拿了一个变量1修改后变成2存放在CPU缓存,还没来得及同步回主存时,另外一个线程又直接从主存读取变量为1,这样就出现了脏读。
现在我们弄清楚了线程同步过程数据不一致的原因,接下来要解决的目标就是如何避免这种情况的发生,经过大量的探索和实践,我们从概念上不断的革新比如并发模型的流水线化和无状态函数式化,而且也提供了大量的实用工具。接下来我们从无到有,先了解最简单的单个线程的一些特点,弄清楚一个线程有多少能耐后,才能深刻认识多个线程一起打交道会出现什么幺蛾子。
6.线程单元
6.1状态
我们知道应用启动体现的就是静态指令加载进内存,进而进入CPU运算,操作系统在内存开辟了一段栈内存用来存放指令和变量值,从而形成了进程。而其实我们的JVM也就是一个进程而且,而线程是进程的最小单位,也就是说进程是由很多个线程组成的。而由于进程的上下文关联的变量,引用,计数器等现场数据占用了打段的内存空间,所以频繁切换进程需要整理一大段内存空间来保存未执行完的进程现场,等下次轮到CPU时间片再恢复现场进行运算。这样既耗费时间又浪费空间,所以我们才要研究多线程。毕竟由于线程干的活毕竟少,工作现场数据毕竟少,所以切换起来比较快而且暂用少量空间。而线程切换直接也需要遵守一定的法则,不然到时候把工作现场破坏了就无法恢复工作了。
线程状态
我们先来研究线程的生命周期,看看Thread类里面对线程状态的定义就知道
public enum State { /**
* Thread state for a thread which has not yet started.
*/
NEW, /**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE, /**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED, /**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
*
*
*
*
*
*
*
A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called Object.wait()
* on an object is waiting for another thread to call
* Object.notify() or Object.notifyAll() on
* that object. A thread that has called Thread.join()
* is waiting for a specified thread to terminate.
*/
WAITING, /**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
*
*
*
*
*
*
*
*/
TIMED_WAITING, /**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
生命周期
线程的状态:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。注释也解释得很清楚各个状态的作用,而各个状态的转换也有一定的规则需要遵循的。
6.2动作
介绍完线程的状态和生命周期,接下来我了解的线程具备哪些常用的操作。首先线程也是一个普通的对象Thread,所有的线程都是Thread或者其子类的对象。那么这个内存对象被创建出来后就会放在JVM的堆内存空间,当我们执行start()方法的时候,对象的方法体在栈空间分配好对应的栈帧来往执行引擎输送指令(也即是方法体翻译成JVM的指令集)。
线程操作
新建线程:new Thread(),新建一个线程对象,内存为线程在栈上分配好内存空间
启动线程:start(),告诉系统系统准备就绪,只要资源允许随时可以执行我栈里面的指令了
执行线程:run(),分配了CPU等计算资源,正在执行栈里面的指令集
停止线程(过时):stop(),把CPU和内存资源回收,线程消亡,由于太过粗暴,已经被标记为过时
线程中断:
interrupt(),中断是对线程打上了中断标签,可供run()里面的方法体接收中断信号,至于线程要不要中断,全靠业务逻辑设计,而不是简单粗暴的把线程直接停掉
isInterrupt(),主要是run()方法体来判断当前线程是否被置为中断
interrupted(),静态方法,也是用户判断线程是否被置为中断状态,同时判断完将线程中断状态复位
线程休眠:sleep(),静态方法,线程休眠指定时间段,此间让出CPU资源给其他线程,但是线程依然持有对象锁,其他线程无法进入同步块,休眠完成后也未必立刻执行,需要等到资源允许才能执行
线程等待(对象方法):wait(),是Object的方法,也即是对象的内置方法,在同步块中线程执行到该方法时,也即让出了该对象的锁,所以无法继续执行
线程通知(对象方法):notify(),notifyAll(),此时该对象持有一个或者多个线程的wait,调用notify()随机的让一个线程恢复对象的锁,调用notifyAll()则让所有线程恢复对象锁
线程挂起(过时):suspend(),线程挂起并没有释放资源,而是只能等到resume()才能继续执行
线程恢复(过时):resume(),由于指令重排可能导致resume()先于suspend()执行,导致线程永远挂起,所以该方法被标为过时
线程加入:join(),在一个线程调用另外一个线程的join()方法表明当前线程阻塞知道被调用线程执行结束再进行,也即是被调用线程织入进来
线程让步:yield(),暂停当前线程进而执行别的线程,当前线程等待下一轮资源允许再进行,防止该线程一直霸占资源,而其他线程饿死
线程等待:park(),基于线程对象的操作,较对象锁更为精准
线程恢复:unpark(Thread thread),对应park()解锁,为不可重入锁
线程分组
为了管理线程,于是有了线程组的概念,业务上把类似的线程放在一个ThreadGroup里面统一管理。线程组表示一组线程,此外,线程组还可以包括其他线程组。线程组形成一个树,其中除了初始线程组以外的每个线程组都有一个父线程。线程被允许访问它自己的线程组信息,但不能访问线程组的父线程组或任何其他线程组的信息。
守护线程
通常情况下,线程运行到最后一条指令后则完成生命周期,结束线程,然后系统回收资源。或者单遇到异常或者return提前返回,但是如果我们想让线程常驻内存的话,比如一些监控类线程,需要24小时值班的,于是我们又创造了守护线程的概念。
setDaemon()传入true则会把线程一直保持在内存里面,除非JVM宕机否则不会退出。
线程优先级
线程优先级其实只是对线程打的一个标志,但并不意味这高优先级的一定比低优先级的先执行,具体还要看操作系统的资源调度情况。通常线程优先级为5,边界为[1,10]。
/**
* The minimum priority that a thread can have.
*/
public final static int MIN_PRIORITY = 1;/**
* The default priority that is assigned to a thread.
*/
public final static int NORM_PRIORITY = 5; /**
* The maximum priority that a thread can have.
*/
public final static int MAX_PRIORITY = 10;
本节介绍了线程单元的转态切换和常用的一些操作方法。如果只是单线程的话,其他都没必要研究这些,重头戏在于多线程直接的竞争配合操作,下一节则重点介绍多个线程的交互需要关注哪些问题。
本文主要将的数基于JAVA的传统多线程并发模型,下面例牌给出知识体系图。
视频资料领取☟☟☟ 微信:Nancy007001
原创: 编程原理林振华