多线程

作者: Ad大成 | 来源:发表于2021-05-06 17:45 被阅读0次

    学习前奏——从硬件层进程,线程与CPU之间是搭伙儿过小日子的!!!
    进程与线程的概念
    进程是程序执行时的一个实例,即它是程序已经执行到各种程度的数据结构的汇集。从内核的观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的基本单位。
    每个进程拥有完全不同的虚拟地址空间,操作系统内核通Address Translation技术映射到物理地址空间(X86处理器体系架构采用段表+页表进行映射,页表有2级和4级之分,32位系统采用2级页表,64位系统采用4级页表),这让进程有一种幻觉即独占整个内存空间。
    线程是进程的一个执行流,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个进程由几个线程组成(拥有很多相对独立的执行流的用户程序共享应用程序的大部分数据结构),线程与同属一个进程的其他的线程共享进程所拥有的全部资源。线程与线程之间是没有隔离的,虽说每个线程有自己的工作栈空间,但是线程A去访问线程B的工作栈空间也是可以做到的。某个线程的行为可能影响到进程内其他的线程。因此,一个线程的崩溃可能引起一系列连锁反应导致其他线程崩溃,最后甚至影响进程的崩溃。因此一些核心线程要尽量保证与其他易出问题的线程的耦合度要低。
    而耦合度降低也会带来一些其他问题。比如:某个线程抛出异常没有捕获,对应的线程会崩溃,但是对应进程(JVM虚拟机)并不会随之崩溃。这样既有好处也有坏处,好处是:其他线程不受影响,坏处是:若没有外围的监控,很难察觉到对应的线程是否已经崩溃。
    经过多方面概述后,我们知道多进程的程序一定要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
    总的来说就是:进程有独立的地址空间,线程没有单独的地址空间(同一进程内的线程共享进程的地址空间)。
    如果说,基础不够扎实的读者,第一次看到这些内容,或许对进程和线程的概念依旧不会很清晰。别急,待我将进程,线程与CPU之间的关系娓娓道来。
    并发和并行的概念
    身为程序员,我们常常含着属于我们的三高口号——高并发、高性能、高可用。一些新手看到这三个词儿就紧张不已,用有些紧张加崇拜的眼神望着老鸟们。作为学习多线程的第一步,以及后续内容势必多次提到的高频词汇,我们在这里先让大家理解什么是并发?以及另一个略微少提及的并行的基本概念。
    非并行非并发
    吃饭吃了一半,电话来了,你一直吃饭吃完了以后才去接,这说明你不支持并发也不支持并行。

    并发 图片
    吃饭吃了一半,电话来了,你停下来接了电话,接完继续吃饭,说明你支持并发。(此处不强调在时间点上同时进行,重点是一段时间内可以交替执行)。

    并行

    图片

    吃饭吃了一半,你一边接电话一边吃饭,这说明你支持并行。

    总结

    此时不难发现,并行概念可以归纳为并发概念的一个子集并发的关键在于你有处理多个任务的能力,不一定要同时,只要能交替执行。比如单CPU核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可察觉的速度不断地去切换这两个任务,已达到”同时执行的效果”。其实并没有同时执行,只是计算机执行速度太快我们无法察觉而已。

    并行的关键是你有同时处理多个任务的能力。比如依旧是上面的例子。如果此时多加一个CUP。CUP1执行线程1,CUP2执行线程2。那么此时程序就是真正意义的并行。

    所以我认为它们最关键的点在于:并发[交替],并行[同时]。

    CPU核心数与线程的关系

    首先先说一些基础的概念。

    一个计算机只有一个CPU,这是勿容置疑的。在不断地学习多线程的知识后,有些朋友已经脑袋里是一堆CPU在计算机里到处乱转了。实际上它只有一个。

    但是站在计算机的用户的角度来看,所有的程序确确实实是在同时运行,那么一个CPU是如何让所有程序在我们用户眼里保持同时运行的呢?

    CPU的影分身——CPU多核心

    多核心:也指单芯片多处理器(CMP)。CMP是由美国斯坦福大学提出的,其思想是将大规模并行处理器中的SMP(对称到处理器)集成和到同一芯片内,各个处理器并行执行不同的进程,这种依靠多个CPU同时并行地运行程序是实现超高速计算的一个重要方向,称为并行处理。

    简而言之,这个理念的核心,就是把多个处理器集中当一个芯片中,实现一个CPU多处理器的效果。而这个多处理器又被称为CPU核心。

    核心数,线程数:目前主流的CPU都是多核的。增加核心数目就是为了增加线程数。因为操作系统一般是通过线程来执行任务的,一般情况下它们是1:1的对应关系,也就是说四核CPU一般拥有四个线程。但Intel引入超线程技术后,使核心数与线程数形成1:2的关系。(这个8也就代表着多可并行8个线程)。

    图片

    因此,以后再提到线程数,一定要想到是与CPU的"影分身"——核心数挂钩的。

    如何理解资源分配

    在许多经典的操作系统教科书中,总是把进程定义为程序的执行实例,它并不执行什么, 只是维护应用程序所需的各种资源,而线程则是真正的执行实体。

    为了让进程完成一定的工作,进程必须至少包含一个线程。

    周所周知,CPU是非常宝贵的。那么如何将它的运算效率发展的淋漓尽致呢?答案是资源分配与处理任务进行解耦。

    我们的CPU代表着只会愣头干活的Java程序员,他的代码能力很强。即使他换工作,跳槽,但是经过短时间的适应后,就可以发挥其高效的完成任务能力。

    那么如何保证这个强大的程序员,到达一个新环境可以进行快速适应环境呢?答案就是提前进行资源的分配,也就是我们进程的概念。我们的操作系统如果是一个公司,那么一个进程好比是一个部门,它为了解决一类业务而存在。这时我们发现,部门这个名词儿压根儿不会干活儿,对应进程也是如此。但是部门存在的意义依旧是重中之重,有其中两个原因:

    1. 作为公司(操作系统)资源分配的单位。现在公司到了一批电脑,我们往往的分派目标是一个部门,很不会为一个程序员而去独立采购的只说。(站在操作系统分配进程资源的角度上说。你也别跟我说什么公司太子爷。程序的世界没有人情关系,只讲究效率!)。经过操作系统的分配后,进程就有了自己的地址空间。

    2. 让该部门(进程)中的任务(线程)有归属感。所有的线程都知道自己是这个部门的,可以使用该部门的资源。也就是说线程就会使用进程的地址空间。如果学习过JVM,那么就知道运行时数据区的概念。整个运行时数据区就是操作系统对该Java进程分配的一片工作区域。

    图片

    往往资源分配要提前搞好,也就是上下文环境要提前布置完善。我们可以想象,当一个线程如果获取到了CPU执行权,但是其上下文环境不清不楚,导致CPU无法立即投入战斗,就会产生性能的大幅损耗。

    如何理解处理器调度

    在刚才的介绍中,我们把操作系统比喻成公司,进程是部门。而一个部门起码得有一个或者多个业务支撑。此时的业务就代表着线程。CPU则是我们的Java开发人员,而我们的开发人员是如何执行这些业务的呢?

    Java中调度的基本单位是线程,也就是linux内核线程,也就说轻量级进程,基于抢占式调度。可以通过在新建线程时,获取其优先级而调整该线程在未来抢占CPU内核执行权的概率。但是本质依旧是抢占式调度。

    对应到我们的程序中,在线程(任务)多,CPU内核(开发人员)少的时候,站在线程的角度上来说:线程争抢CPU核心来处理我们的任务的。站在CPU的角度,则是随机选择线程进行处理。

    是不是脑子里有周星驰拍的唐伯虎点秋香里。饭点到了,一群下人一拥而上,把桶里的饭抢的干干净净的样子了?没抢到的CPU执行权的线程就只能像周星驰演的华安那样,一脸懵逼。

    CPU时间片轮转机制(RR调度,并发原理)

    时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。又称RR调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。

    如果时间片结束时,进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或者结束,则CPU当即进行切换。调度程序所要做的就是一张就绪进程列表,当进程用完它的时间片后,它被移动到队列的末尾。

    时间片轮转调度中唯一有趣的一点是时间片的长度。从一个进程切换到另一个进程是需要定时间的,包括保存和装入寄存器值及内存映像,更新各种表格和队列等待。假如进程切换上下文需要5ms,再假设时间片设为20ms,则在做完20ms有用的工作之后,CPU将花费5ms来进行进程切换。此时CPU时间的20%被浪费在了管理开销上。

    为了提高CPU效率,我们可以将时间片设为5000ms这时,浪费的时间只有0.1%。但考虑到在一个分时系统中,如果有10个交互用户几乎同时按下回车键,那么将发生什么情况?假设所有其他进程都用足它们的时间片的话(5000ms=1s),那么后两个用户起码得等上5s才能获取机会。多数用户无法忍受一条简短命令要5s才能做出响应,同样的问题在一台支持多道程序的个人计算机也会发生。

    结论可以总结如下:时间片设置太短会导致过多的进程切换,降低了CPU效率。而设计的太长有可能引起对短的交互请求响应变差。将时间片设置为100ms通常是一个比较合理的折中。

    进程调度

    在读概念的时候,我们逐渐发现了一些问题。就是时间片轮转调度的单位是进程。这与我们所说的线程是处理器调度的最小单位这一概念是否冲突呢?

    图片

    我们在平时开发时,感觉并没有受CPU核心数限制(明明最大并行线程只有8个,为什么3000多个线程同时跑我们却觉得很正常呢?)这是因为操作系统提供了一种CPU时间片轮转机制。

    我们现在为了处理这3000多个线程,有两种实现方案。

    1.直接把这随机把这8个核心分配给这3000多个线程,也就是让所有线程同时去抢占CPU执行权。

    假设是这种实现方法,会出现什么问题。那就是可能会造成线程饥饿。万一有一些线程运气不好,很长一段时间都无法抢到CPU执行权,那么这个进程反馈给我们的状态必然是频繁卡顿。

    2.就是以为们进程为单位,统一将CPU分配至这个进程中的线程,仅仅让这个进程中的线程进行争夺CPU执行权,那么就可以保证线程饥饿的概率大幅下降,让所有CPU核心集中去做一个进程下的任务。

    这样做有什么好处呢?

    1.大幅度减轻了线程饥饿带来的问题。

    2.实现一个进程中CPU核心数的可控性。因此CPU密集型与IO密集型业务,都可以根据CPU核心数来计算出更合理的线程池。

    基于这个问题的出现,我们引出了以进程为单位进行调度的概念。

    我们知道线程是抢占CPU核心进行处理任务的。那么进程是如何分配CPU资源的呢?

    系统会维护一张就绪进程列表,其实就是一个先进先出的队列,新来的进程就会被加到队列的末尾,然后每次执行进程调度的时候,都会选择队列的队首进程,让它在CPU上运行一个时间片的时间,不过如果分配的时间片已经消耗光了而进程还在运行,调度程序就会停止该进程的运行,同时把它移到队列的末尾,CPU会被剥夺并分配给队首进程,而如果进程在时间片结束前阻塞或者结束了,则CPU就会进行切换。

    如此一来,再次对比直接把这8个核心分配给这3000多个线程的方式。我们发现维护这个进程队列,就免不了要消耗更大的上下文切换成本。但与我们之前所说的那些优点相比,这也是相当值得的。

    总结

    最后再进行一下点题。回到我们最开始的问题:一个CPU是如何让所有程序在我们用户眼里保持同时运行的呢?

    首先,在进程层面,一段较长的时间被CPU被划分为多个时间碎片,然后在这一段时间内并发执行所有进程(来回切换干活),并且这里所有的进程都是排好队的。

    在CPU分配到一个进程后,因为超线程技术,使CPU具备并行执行的内置核心数2的线程数。如果程序中需要减少CPU核心在一个进程中的线程里频繁切换,那么该进程中的线程数要尽量设置的小于该机器的CPU核心数2。

    知识进阶——从软件方面认识Java里的线程,谈谈线程的启动与中止

    Java程序天生就是多线程的

    图片 图片

    一个Java程序从main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上Java程序天生就是多线程程序。因为执行main()方法的是一个名称为main的线程。

    [1] main //main线程,用户程序入口

    [2] Monitor Ctrl-Break //监控Ctrl-Break中断信号

    [3] AttachListener //内存dump,线程dump,类信息统计,获取系统属性等

    [4] SignalDispatcher//分发处理发送给JVM信号的线程

    [5] Finalizer//调用对象finalize方法的线程

    [6] ReferenceHandler//清除Reference的线程

    线程的启动与终止

    启动线程的方式

    继承Thread类

    图片 图片 图片

    实现Runnable接口:

    图片 图片 图片

    Thread和Runnable的区别

    从用法的角度来说

    Thread是对Java里对线程的唯一抽象。

    Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行。

    在实际开发中,我们通常采用Runnable接口来实现多线程。因为实现Runnable接口比继承Thread类有以下好处:

    1. 避免继承的局限性。一个类可以实现多个接口,但一个类只能继承一个类。如果业务代码已经继承了某各类,此时只能使用Runnable启动线程。

    2. Runnbale接口实现的线程便于资源共享。而通过继承Thread类,各自线程的资源是独立的,不方便共享。

    从二者本质上来讲

    图片

    其实这两个本身就是一回事。只是Thread类提供了更多的可用方法和成员而已。

    线程的中止

    自然中止

    要么run执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。

    Stop(已弃用)

    暂停,恢复和停止操作对应在线程的API是suspend(),resume()和stop()。但是这些API都已经过期了,也就是不建议使用的。不建议使用的原因主要有:stop会导致不安全,为啥呢,如果在同步块执行一半时,stop来了,后面还没执行完呢,锁没了,线程退出了,别的线程又可以操作你的数据了,所以就是线程不安全了。suspend会导致死锁,因为挂起后,是不释放锁的,别人也就阻塞着,如果没人唤醒,那就一直死锁。

    中断interrupted

    中断线程是会让线程立刻结束么?

    不,中断线程interrupted其实就是给一个标志位。不会让原先的线程立刻推出,而是适用于某个条件后,该线程继续执行。

    interrupt和isinterrupt的区别

    图片 图片

    安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作,中断操作,好比其他线程对该线程打了个招呼,“A线程,你要中断啦”,不代表线程A会立刻停止自己的工作。同样的A线程完全可以不理会这种中断请求。因为java里的线程是协作式的,不是抢占式的,线程通过检查自身的中断标志是否为true来进行响应。(也就是说interrupt一般在搭配循环检测场景中使用)

    再写一个interrupt的例子:

    图片 图片

    可以看到,当使用interrupt()的时候,状态位变为true一次。当使用完毕后继续该为了flase,这样可以让我们反复利用该状态位进行多次中断。而isinterrupt()则是使用一次后就永久为true了。一般适合使用一次中断的场景。

    如果interrupt的实现是根据状态位,那我自己写一个状态位替代么?

    首先,如果你了解JMM,那么你就会理解,自己写一个标志位在线程中循环判断,是很有可能无法通过外部线程修改状态位而通知到它的。例如我们在一个线程中有一个flage标志位,默认为flase。我们循环判断一个flag是否为true。当其他线程将flag修改为true时,我们该线程的flag是无法立即获取到这个true值的。初学者可以理解为,此时这个flag一直会从缓存中获取。因此需要加volatile来修饰。

    但是这样既会浪费声明变量的内存,也会或多或少影响读取数据的效率。因此不推荐,但是如果能思考到我们可以自己实现interrupt这一步,你也真正理解它了。

    如果中断线程时,线程正在sleep是如何处理的?

    我们可能平时没有注意到,就是写一个Thread.sleep();为啥还要捕获/抛出一个异常。我们点进sleep方法中一窥究竟。

    图片

    不看不知道,一看下一跳。感情这个异常就是专门为我们的interrupt而存在的。

    我们来看一个实际的例子。

    不在catch中中断

    图片

    通过观察可知,在线程休眠时使用interrupt对线程进行唤醒,我们的线程采取的方式是:捕获一个异常,然后不予理会!也就是说,这次我就无视了。等我啥时候睡醒了,你再喊我我再更新我的中断标志位等待更新。

    那我就是想在你睡觉的时候把你叫醒怎么办

    在catch中中断

    图片 图片

    这次我们在catch中再次调用interrupt,线程会从沉睡中被唤醒,并强制结束。

    Interrupt机制和wait/notify的区别

    顺便提一下,其实博主在第一次学习Interrupt,再学习wait/notify时,就会觉得很乱。为什么呢?因为这两个机制貌似都会让线程停止工作。

    很多不懂得原理的新手可能会在这个概念上犯浑。但其实了解了Interrupt只是一个标志位的概念后,发现其实和线程终止不终止没什么必然联系,不能被这个名词给弄晕了。当线程在每次做轮循任务时,会判断一下这个状态位,如果我们要求其退出(设置标志位状态)后,该线程会跳出循环从而结束该循环任务。

    而wait/notify机制则属于多线程下,多线程在抢占同一个锁资源时,成功抢到锁资源的线程执行任务,而其他未抢到锁的线程则进行等待。当执行任务的线程执行完毕后,会释放锁,并唤醒其他等待线程继续抢占锁资源。因此这两个概念其实完全没关系,望新手知晓。

    相关文章

      网友评论

          本文标题:多线程

          本文链接:https://www.haomeiwen.com/subject/mivddltx.html