当前位置: > 教程 > 编程教程 >

高并发编程知识体系(上)
栏目分类:编程教程   发布日期:2020-06-10   浏览次数:

作者:林振华公众号:编程原理 1.问题 什么是线程的交互方式? 如何区分线程的同步/异步,阻塞/非阻塞? 什么是线程安全,如何做到线程安全? 如何区分并发模型? 何谓 响应式编程? 操作系统如何调度多线程? 2.关键词 同步,异步,阻塞,非阻塞,并行,并
作者:林振华
公众号:编程原理

1.问题

  • 什么是线程的交互方式?
  • 如何区分线程的同步/异步,阻塞/非阻塞?
  • 什么是线程安全,如何做到线程安全?
  • 如何区分并发模型?
  • 何谓响应式编程?
  • 操作系统如何调度多线程?

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类里面对线程状态的定义就知道

高并发编程知识体系(上)

 

高并发编程知识体系(上)

 

生命周期

线程的状态:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。注释也解释得很清楚各个状态的作用,而各个状态的转换也有一定的规则需要遵循的。

6.2动作

介绍完线程的状态和生命周期,接下来我了解的线程具备哪些常用的操作。首先线程也是一个普通的对象Thread,所有的线程都是Thread或者其子类的对象。那么这个内存对象被创建出来后就会放在JVM的堆内存空间,当我们执行start()方法的时候,对象的方法体在栈空间分配好对应的栈帧来往执行引擎输送指令(也即是方法体翻译成JVM的指令集)。

线程操作

1、新建线程:new Thread(),新建一个线程对象,内存为线程在栈上分配好内存空间

2、启动线程:start(),告诉系统系统准备就绪,只要资源允许随时可以执行我栈里面的指令了

3、执行线程:run(),分配了CPU等计算资源,正在执行栈里面的指令集

4、停止线程(过时):stop(),把CPU和内存资源回收,线程消亡,由于太过粗暴,已经被标记为过时

5、线程中断:

  • interrupt(),中断是对线程打上了中断标签,可供run()里面的方法体接收中断信号,至于线程要不要中断,全靠业务逻辑设计,而不是简单粗暴的把线程直接停掉
  • isInterrupt(),主要是run()方法体来判断当前线程是否被置为中断
  • interrupted(),静态方法,也是用户判断线程是否被置为中断状态,同时判断完将线程中断状态复位

6、线程休眠:sleep(),静态方法,线程休眠指定时间段,此间让出CPU资源给其他线程,但是线程依然持有对象锁,其他线程无法进入同步块,休眠完成后也未必立刻执行,需要等到资源允许才能执行

7、线程等待(对象方法):wait(),是Object的方法,也即是对象的内置方法,在同步块中线程执行到该方法时,也即让出了该对象的锁,所以无法继续执行

8、线程通知(对象方法):notify(),notifyAll(),此时该对象持有一个或者多个线程的wait,调用notify()随机的让一个线程恢复对象的锁,调用notifyAll()则让所有线程恢复对象锁

9、线程挂起(过时):suspend(),线程挂起并没有释放资源,而是只能等到resume()才能继续执行

10、线程恢复(过时):resume(),由于指令重排可能导致resume()先于suspend()执行,导致线程永远挂起,所以该方法被标为过时

11、线程加入:join(),在一个线程调用另外一个线程的join()方法表明当前线程阻塞知道被调用线程执行结束再进行,也即是被调用线程织入进来

12、线程让步:yield(),暂停当前线程进而执行别的线程,当前线程等待下一轮资源允许再进行,防止该线程一直霸占资源,而其他线程饿死

13、线程等待:park(),基于线程对象的操作,较对象锁更为精准

14、线程恢复:unpark(Thread thread),对应park()解锁,为不可重入锁

线程分组

为了管理线程,于是有了线程组的概念,业务上把类似的线程放在一个ThreadGroup里面统一管理。线程组表示一组线程,此外,线程组还可以包括其他线程组。线程组形成一个树,其中除了初始线程组以外的每个线程组都有一个父线程。线程被允许访问它自己的线程组信息,但不能访问线程组的父线程组或任何其他线程组的信息。

守护线程

通常情况下,线程运行到最后一条指令后则完成生命周期,结束线程,然后系统回收资源。或者单遇到异常或者return提前返回,但是如果我们想让线程常驻内存的话,比如一些监控类线程,需要24小时值班的,于是我们又创造了守护线程的概念。

setDaemon()传入true则会把线程一直保持在内存里面,除非JVM宕机否则不会退出。

线程优先级

线程优先级其实只是对线程打的一个标志,但并不意味这高优先级的一定比低优先级的先执行,具体还要看操作系统的资源调度情况。通常线程优先级为5,边界为[1,10]。

高并发编程知识体系(上)

 

本节介绍了线程单元的转态切换和常用的一些操作方法。如果只是单线程的话,其他都没必要研究这些,重头戏在于多线程直接的竞争配合操作,下一节则重点介绍多个线程的交互需要关注哪些问题。

7.线程交互

其实上一节介绍的线程状态切换和线程操作都是为线程交互做准备的。不然如果只是单线程完全没必要搞什么通知,恢复,让步之类的操作了。

7.1交互方式

线程交互也就是线程直接的通信,最直接的办法就是线程直接直接通信传值,而间接方式则是通过共享变量来达到彼此的交互。

  • 等待:释放对象锁,允许其他线程进入同步块
  • 通知:重新获取对象锁,继续执行
  • 中断:状态交互,通知其他线程进入中断
  • 织入:合并线程,多个线程合并为一个

7.2线程安全

我们最关注的还是通过共享变量来达到交互的方式。线程如果都各自干活互不搭理的话自然相安无事,但多数情况下线程直接需要打交道,而且需要分享共享资源,那么这个时候最核心的就是线程安全了。

什么是线程安全?

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。(摘自《深入Java虚拟机》)

如何保证线程安全?

我们最早接触线程安全可能是JDK提供的一些号称线程安全的容器,比如Vetor较ArrayList是线程安全,HashTable较HashMap是线程安全?其实线程安全类并不代表也不等同线程安全的程序,而线程不安全的类同样可以完成线程安全的程序。我们关注的也就是写出线程安全的程序,那么如何写出线程安全的代码呢?下面列举了线程安全的主要设计技术:

无状态

这个有点函数式编程的味道,下文并发模式会介绍到,总之就是线程只有入参和局部变量,如果变量是引用的话,确保变量的创建和调用生命周期都发生在线程栈内,就可以确保线程安全。

无共享状态

完全要求线程无状态比较难实现,必要的状态是无法避免的,那么我们就必须维护不同线程之间的不同状态,这可是个麻烦事。幸好我们有ThreadLocal这个神器,该对象跟当前线程绑定,而且只对当前线程可见,完美解决了无共享状态的问题。

不可变状态

最后实在没办法避免状态共享,在线程之间共享状态,最怕的就是无法确保能维护好正确的读写顺序,而且多线程确实也无法正确维护好这个共享变量。那么我们索性粗暴点,把共享的状态定位不可变,比如价格final修饰一下,这样就达到安全状态共享。

消息传递

一个线程通常也不是所有步骤都需要共享状态,而是部分环节才需要的,那么我们把共享状态的代码拆开,无共享状态的那部分自然不用关心,而共享状态的小段代码,则通过加入消息组件来传递状态。这个设计到并发模式的流水线编程模式,下文并发模式会重点介绍。

线程安全容器

JUC里面提供大量的并发容器,涉及到线程交互的时候,使用安全容器可以避免大部分的错误,而且大大降低了代码的复杂度。

  • 通过synchronized给方法加上内置锁来实现线程安全的类如Vector,HashTable,StringBuffer
  • AtomicXXX如AtomicInteger
  • ConcurrentXXX如ConcurrentHashMap
  • BlockingQueue/BlockingDeque
  • CopyOnWriteArrayList/CopyOnWriteArraySet
  • ThreadPoolExecutor

synchronized同步

该关键字确保代码块同一时间只被一个线程执行,在这个前提下再设计符合线程安全的逻辑

其作用域为

  • 对象:对象加锁,进入同步代码块之前获取对象锁
  • 实例方法:对象加锁,执行实例方法前获取对象实例锁
  • 类方法:类加锁,执行类方法前获取类锁

volatile约束

volatile确保每次操作都能强制同步CPU缓存和主存直接的变量。而且在编译期间能阻止指令重排。读写并发情况下volatile也不能确保线程安全,上文解析内存模型的时候有提到过。

这节我们论述了编写线程安全程序的指导思想,其中我们提到了JDK提供的JUC工具包,下一节将重点介绍并发编程常用的趁手工具。

相关热词:

js特效 教程 资源 资讯