关于Java多线程的一些思考

最近在写毕业设计,涉及到多线程的一些技术,之前对于多线程的技术的了解仅限于书上的一些Demo,此次编写毕业设计对于多线程的技术有了一些更多的了解。

首先,我们来看这么一个例子:刷知乎偶然看见一个答主回答的关于多线程的问题,她在Main中下载远程服务器的图片,发现虚拟机的网络速度仅仅只跑到100多kb/s,使用多线程技术后网络速度达到了1-2MB/s的速度。

这个例子很有意思,为什么在Main线程中速度只有100多kb/s,而使用多线程技术后速度却可以达到1-2MB/s。

首先我们需要对这个例子进行分析:

Main,即public static void main(String[] args){}    这个通常是一个单线程,那么在下载图片的过程中,通常只会一步一步的进行下载。

单线程示例:

下载图片1-》下载完成-》下载图片2-》下载完成-》下载图片3-》下载完成-》下载图片n.....-》下载完成

而对于多线程技术,首先我们需要了解计算机基础中的一个概念,即“时间片”概念。

时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

---以上概念来源:百度百科

让我们来举一个简单的例子:CPU是一个人,他的职责就是根据系统的需要来干活。刚开始的时候,系统让他去搬砖,他就只能去搬砖,系统要是再发布一条命令让他去擦地板,他得搬完砖之后菜能去擦地板(这就是最初的计算机处理的方式,即一次只能干一个活儿)。后来啊,这个CPU学了一个技能,他可以开启分身模式,一个人可以变两个人(多核心CPU技术的发展),于是系统发布任务让他搬砖后又让他去擦地板时他就可以让他的分身去干擦地板的活儿(另一个核心处理任务)。再后来啊,CPU越来越厉害了,学的东西也越来越多了,这时候他发现:哎,系统让我搬砖的时候,我明明中途很闲啊,但是搬砖这个任务我还没完成,不能去处理擦地板的活儿。那么我应该怎么办呢?这就是时间片的概念,CPU将干活的时间分成一小段一小段的时间,比如10ms这样。在这个时间内,CPU会去擦地板,当这个时间片过了之后,根据调度CPU又会去搬砖,由于时间片的时间通常非常短,通常在几毫秒到几十毫秒之间,我们人类是感受不到CPU切换的,这样就造成了一个假象,即CPU又在擦地板,又在搬砖,还能抽出空闲时间接收QQ消息。

为什么要了解时间片这个概念呢?

现有的计算机CPU的处理速度已经非常快了,以超算“太湖之光”为例,其运算能力达到:9.3亿亿次/秒。而我们通常使用的个人计算机虽然没有这么恐怖的运算力,但对于人类来说,个人计算机的运算力依旧是非常恐怖的存在。

CPU对于单线程任务来说,其所有的时间片通常会分配给这个任务(此处忽视操作系统调度简单介绍),也就是说cpu就单纯的在处理搬砖这件事,并且是一块砖一块砖的搬。

而对于多线程来说,CPU则是将其时间片根据线程优先级来进行分配,线程优先级高的,分配的时间片就越多,线程优先级低的,分配的时间片就越少。以上方知乎的回答来说,对于图片下载,若是使用多线程技术的话,则CPU会将其时间片均匀的分配给每个线程,这样的话就不再是傻乎乎的一块砖一块砖的将砖头从A处搬到B处,而是根据时间片,执行线程A时搬一块砖,若是砖在时间片内还没搬完,ok,将砖扔在地上,记住这个位置,等到下次线程A的时间片,再回到这个位置来搬这块砖。

tips:此处将砖头扔在地上,并记住这个位置。在CPU中通常称为上下文切换开销,即CPU需要记住这个线程在此时间片之后的状态,以便在下个时间片载入这个状态。

那么现在我们来分析知乎回答的这个具体业务场景。

很明显,下载远程服务器图片的这个任务,是适合使用多线程技术的。为什么呢?一般的服务器,会对每个文件有一定的速度限制,这样做是为了保障其他用户的体验。而通常这个限制对于客户机的网络速度很明显是没有办法跑满带宽的,如果这时候需要提升效率的话,那么就需要同时下载多个文件,以避免客户机的网络带宽浪费。而同时下载多个文件,正好对应了多线程技术的应用场景,即同时执行多个任务。

那么再来具体分析以上所述,如果远程服务器是对每个IP限制网络速度而非对文件进行网络限速的话,那么使用单线程可能比使用多线程技术下载的速度更快。

再来进行分析,如果远程服务器的带宽仅有128kb/s的带宽速度,那么若是使用多线程技术,其线程切换造成的网络开销与cpu上下文开销,可能其下载的总时间比使用单线程下载更慢。

可以看出,此次分析并没有根据客户机的配置进行分析,而是对于远程服务器的配置进行分析,因为通常而言对于多线程技术来说,其考虑一般来说是对于服务端而非客户端而言。

那么我们再来对客户机进行简单的分析。

仍然以下载图片这个例子为例:

如果我们下载的图片数量在一定大小内比如几十张到几百张这样的话,那么可以非常简单的使用for循环创建线程即可,但是若我们需要下载的图片达到了几百万张,几千万张的时候,很明显就不能用for循环+直接创建线程的方式来创建线程了。为什么呢?这就涉及到一个配置问题,客户机的配置通常非常一般,若是同时创建几百万个线程,可能会导致宕机,而即使没有宕机,几百万个线程之间进行的上下文切换的线程开销与网络开销,以及操作系统对于线程的调度,导致了你使用了多线程技术,其效果甚至还不如单线程来的效率高。

那么通常如何解决这个问题呢?这里就涉及到一个技术:即“线程池”技术。

什么是线程池技术?线程池就是根据计算机配置来具体分析并创建一定量的线程,当你需要使用多线程技术时,从这些定量的线程中取出一个或多个并进行使用,使用完毕后线程恢复初始状态。

这样说或许不太明白,举一个更简单的例子:共享单车,相信各位一定听说过,用户缴纳一定押金后即可使用共享单车。而共享单车的数量,即对应线程池中的线程数量,即线程池大小。当用户需要使用单车时,直接扫码就可以使用单车了,使用完毕后关锁即可完成还车。即类似于你若需要使用多线程技术,即可向线程池申请线程(扫码用车),并使用这些线程(骑车),使用完毕后线程返回到线程池(关锁还车)。

线程池中的线程数量通常是恒定的,但有时候我们可能需要比线程池中的线程数量更多的线程,该怎么办呢?这就涉及一个队列的问题,即你向线程池并非是进行申请线程操作,而是你提交你的线程,线程池中有一个队列,当你的任务提交后添加到这个队列中,若是线程池中有空的线程,则根据队列的先进先出的原则将队列中的线程任务进行正式的任务提交,即开始执行线程。

简单的写一个流程:

提交线程任务-》线程任务进入线程池队列-》线程池中有空线程时-》获取线程池队列中第一个线程任务并开始执行任务

 

可以看出,这样的设计更加完善,可以完整利用主机的所有性能,也不会因为线程过多而导致主机宕机等问题,且因为使用了多线程技术,任务效率也有了一定的提升。

 

关于多线程技术,还有一点想说的就是关于死锁的问题。

死锁是怎样产生的呢?首先我们需要了解一个问题,什么是锁?很简单,根据字面意思理解即可,就是你需要这个东西,那么你就将这个东西锁起来暂时归你自己用。

在编程过程中,多线程技术通常会使用到锁技术,因为在多线程执行过程中,需要保证数据仅对当前线程可用。

举一个简单的例子,银行系统。你的卡上余额为0,于是你在atm机上存了100,与此同时有一个人在另外一台机器上给你打款了200,若是对你的余额这个变量没有进行加锁,则两个线程都可以访问你的余额变量,若你与另一个人存款与打款正好处于同一时间段上,那么你的余额可能变成200,也可能变成100.这是为什么呢?

具体来分析,你在ATM上存款,ATM获取到你的存款余额为0,另一个人在给你打款,那台机器上获取到你的余额也为0.此时你开始存款,你的余额变成100,ATM将这个余额变量返回到服务器并写入到数据库,此时你的余额是100.另一个人同时在给你打款200,此时你的余额在那台机器上变成了200,那台机器返回余额变量并返回到服务器上,你的余额正式变成200.

当然这个步骤可以进行调换,而对于你来说,即对于用户来说,平白无故损失一百元或两百元都是无法令人接受的。

那么我们就需要对余额这个变量进行加锁操作,即当你进行存款时,余额这个变量别人是没有办法访问的,只有等你访问完毕后,别人才能进行访问,这样才能有效避免上面的余额问题!

再以银行系统为例:此时你已经对余额变量进行加锁操作,那么在多线程中是这样进行的。

你在ATM机前,ATM获取到你的余额为0,并对余额变量进行加锁操作,你进行存款操作,余额变成100,atm机返回余额变量并写入到数据库且释放余额变量锁。与此同时另一个人在另一台机器上打款给你,由于余额变量被锁,机器需等待锁释放之后才能获取到余额变量,此时线程进入等待状态,你已经 完成存款操作,锁被释放,机器获取到你的余额为100,另一个人给你打款200,你的余额变成300,机器写入数据库并释放锁,你的余额正确。

这就是锁的意义。

那么为什么会有死锁呢?

在上面的银行示例中,我们仅有一个锁即余额变量的锁。若此时我们多加一个锁,即定期存款锁。

对于ATM机的线程A,其加锁方式为:先加锁余额变量,再加锁定期存款变量锁。

对于另一台机器的线程B,其加锁的方式为,先加锁定期存款变量锁,再加锁余额变量锁。

此时cpu执行操作,先执行线程A,线程A加锁余额变量,时间片过了,切换到线程B,线程B加锁了定期存款变量锁,时间片过了,切换到线程A,线程A准备加锁定期存款变量锁,发现该变量已被线程B锁定,于是等待线程B释放定期存款变量锁,时间片过了,线程B准备加锁余额变量,发现该变量已经被线程A加锁,于是等待线程A释放锁,时间片过了。。。

可以看到,在上面的这个示例中,由于线程A与线程B都已经锁了一个变量,且都在等待对方释放另一个变量,这就造成了一个死循环,即你在等我释放锁,我也在等你释放锁,这就是死锁的原理。

 

那么如何避免死锁呢?在之前的阿里巴巴Java开发手册中有明显的说明:

【强制】对多个资源、数据库表、对象同时加锁时,需要保持一致的加锁顺序,否则可能会造 成死锁。

说明:线程一需要对表 A、B、C 依次全部加锁后才可以进行更新操作,那么线程二的加锁顺序 也必须是 A、B、C,否则可能出现死锁。

 

以及你可以在代码中设置超时,当线程在指定时间内未完成操作,则返回超时并解锁变量重新创建线程。

 

关于饥饿锁,我是这样理解的。即线程因为其优先级过低(Java可以指定线程的优先级),导致其一直没有被分配到CPU时间片,所以一直无法有效执行。

简单的举例:你需要去买火车票,正在排队呢,前面来了个人说是铁路职工(线程优先级比你高),于是强行插队买票。ok,过了一会儿又来了一个旅游团,旅游团属于大客户优先购票,于是你前面又被插队,而且是一大群人,一直这样下去,由于你的优先级不高,一直被插队,所以你可能永远也没有办法买到票,这就是一个饥饿锁。

如何避免饥饿锁?   尽量保持所有线程处于同一优先级,(Java默认线程的优先级都是相同的),尽量避免修改线程的优先级。以及避免给过多的线程添加高优先级以避免其他线程无法进入cpu时间片。

 

关于多线程技术的更多资料,可以考虑买以下两本书,我觉得十分的不错:

1.《Java并发编程的艺术》

2.《Java并发编程核心方法与框架》

以及可以访问:并发编程网    查阅更多相关资讯。

发表评论

您的电子邮箱地址不会被公开。