vlambda博客
学习文章列表

JVM是如何进行多线程并行编程的

灵魂画风小剧场镇楼!








我们在日常项目中会经会遇到希望用多线程来并行执行任务的场景。一方面,多线程可以提升执行效率,但同时它也增加了排查问题的难度。

其实,HotSpotVM里就提供了管理多线程的框架,并通过这个框架来实现并行GC和并发GC。

学习HotSpotVM的设计,从中获得灵感,能够帮助我们更好地完成自己的项目。

今天,我们就来看看HotSpotVM是如何以多个线程并行执行任务的,然后再通过剖析源码,看看并行GC具体的执行示例。



背景知识






Java语言自带监视器(monitor)这种同步结构。而HotSpotVM内部的互斥处理一般都会使用监视器。


我们借助滑雪板租赁商店的情景讲解Java监视器。


假设租赁商店中滑雪板的尺寸和样式都相同,而且商店非常窄小,一次只能进入一位顾客。


如果前面已经有顾客进入商店,那么其他顾客就只能在店外排队等待。当店内没有顾客后,排在第一位的顾客就可以进入店内。进入店内的顾客可以租滑雪板。如果没有多余的滑雪板了,那么顾客就要在店内的等候室里等待。


返还滑雪板的顾客同样必须在店外排队等待。将滑雪板返还后,顾客可以呼叫一位在等候室里等待的顾客或所有顾客。被呼叫的顾客只有在店里没有其他顾客的情况下才可以进入店内。如果店外有人排队,那么他必须排到队尾等待。


如果再次进入商店时滑雪板恰巧又没有了,那么他还是必须进入等候室等待。

JVM是如何进行多线程并行编程的

以上是关于监视器的比喻。这时,共享资源是滑雪板,监视器是租赁商店。如果将顾客看作线程,那么同时只能有一个线程进入监视器。当租赁商店中有顾客时,商店处于被加锁的状态。顾客离开后商店被解锁,其他顾客就可以进入商店了。就Java语言而言,在等候室内等待就是wait方法,通知等候室内的一位顾客就是notify方法,通知所有顾客就是notifyAll方法。


1.  并行执行的流程

HotSpotVM中多线程并行执行的机制主要由以下角色来完成。

●AbstractWorkGang:工人集合
●AbstractGangTask:让工人执行的任务

●GangWorker:执行指定任务的工人

这些角色并行执行的流程如下。

先,如图1所示,AbstractWorkGang只有一个监视器,它会让属于AbstractWorkGang的GangWorker在监视器的等候室中等待。



图1 步骤①

JVM是如何进行多线程并行编程的

(AbstractWorkGang只有一个监视器,它会让GangWorker在等候室中等待)




由监视器负责进行互斥处理的共享资源是任务信息的布告板。布告板上有以下信息。

●任务的地址
●任务的编号
●执行任务的工人总数

●完成任务的工人总数

接下来,客户会在布告板上写下希望并发执行的任务的信息(图2)。


图2 步骤②

JVM是如何进行多线程并行编程的

(客户获取监视器的锁并在布告板上写下任务信息)




客户带来的实际任务可以是继承自AbstractGangTask类的任何实例。

客户会在布告板上写下该实例的地址作为任务的地址,而任务的编号则是上一次任务的编号加1。

在本例中,这个值是1。执行任务的工人总数与完成任务的工人总数分别被初始化为0

接下来,客户会通知所有正在等待的工人,然后自己进入等候室(图3)。  


图3 步骤③

JVM是如何进行多线程并行编程的

(工人们一个接一个地进入监视器,将布告板上的信息记在自己的笔记本上,然后离开监视器。)




被通知到的工人们一个接一个地进入监视器,确认布告板上的信息。

工人会记录自己上次执行过的任务编号,如果布告板上的编号与记录的编号相同,那么为了避免重复执行任务,他们会忽略这个任务并进入等候室等待。

如果是新的任务编号,那么他们会在笔记本上记录下布告板上的信息(任务的地址和编号),并将布告板上执行任务的工人总数加1,然后离开监视器去执行任务。

执行完任务后,工人会再次进入监视器。这时,为了告诉大家自己完成了一项任务,他会将布告板上“完成任务的工人总数”加1(图4)。  


图4 步骤④

JVM是如何进行多线程并行编程的

(在任务完成后,工人再次进入监视器,更新布告板上的信息并进入等候室等待。)



接着,这个工人会将等候室中的所有人(包括客户)都叫出来,然后自己进入等候室。

所有工人的任务都执行完成后,执行任务的工人总数应当与完成任务的工人总数相同。 

客户进入监视器后会确认布告板上的信息,看看是否所有的任务都完成了(图5)。


图5 步骤⑤

JVM是如何进行多线程并行编程的

(客户进入监视器,在看到所有工人都执行完任务后退出监视器)



如果还有尚未完成的任务,那么客户就会在等候室里等待工人完成任务。所有任务都完成之后,客户才会满意地离开监视器。

以上就是并行执行的流程。  


JVM是如何进行多线程并行编程的



1.1  AbstractWorkGang类
接下来我们详细地讲解一下并行执行流程中的出场角色。AbstractWorkGang类的继承关系如图6所示。 

图6 AbstractWorkGang类的继承关系

JVM是如何进行多线程并行编程的

AbstractWorkGang类中定义了WorkGang所需的接口。

JVM是如何进行多线程并行编程的

第127行代码定义的虚函数run_task()负责将任务交给worker并让它们执行任务。run_task()的实体是在子类 WorkGang类中定义的。 
第139行至第156行代码定义了WorkGang所需的属性。这部分相当于1.中讲过的“任务信息的布告板”中的数据。
图6中展示的FlexibleWorkGang类能够在之后灵活(flexible)地改变可以执行任务的工人数量。并行GC会经常用到这个类。

1.2 AbstractGangTask类

AbstractGangTask类的继承关系如图7所示。

图7 AbstractGangTask类的继承关系

JVM是如何进行多线程并行编程的

AbstractGangTask类定义了并行执行任务所需的接口。 

JVM是如何进行多线程并行编程的

其中最重要的成员函数就是第68行代码所定义的work()。work()是负责执行任务的函数,它接收工人的编号作为参数。 
任务的详细处理是在G1ParTask等子类的work()方法中定义的。客户将AbstractGangTask的子类的实例传递给AbstractWorkGang,然后让他们并行执行任务。

1.3 GangWorker类
GangWorker类是负责实际执行任务的类,它的一个祖先类是Thread类(图8)。
图8 GangWorker类的继承关系

JVM是如何进行多线程并行编程的

由于一个GangWoker的实例对应一个线程,所以GangWoker也被称为工人线程。 

JVM是如何进行多线程并行编程的

GangWorker类中定义有一个成员变量_gang,其中存放着自身所属的AbstractWorkGang。

JVM是如何进行多线程并行编程的


2.
 并行GC的执行示例


下面,我们来一边阅读实际代码,一边回顾上一节中的内容。
代码清单1.1展示了作为客户的主线程执行并行GC的示例代码。

代码清单1.1 并行GC的示例代码

JVM是如何进行多线程并行编程的


JVM是如何进行多线程并行编程的



2.1  ①准备工人


首先,通过代码清单1.1的①中所示部分创建和初始化FlexibleWorkGang的实例,使之变为前面出现过的图1的状态。
创建和初始化FlexibleWorkGang的时序图如图9所示。
图9 创建和初始化WorkGang的时序图

JVM是如何进行多线程并行编程的

让我们从上往下看一看这个流程。首先是AbstractWorkGang的构造函数。

JVM是如何进行多线程并行编程的

上面是初始化监视器和数据的代码,大家只看懂这一点即可。其他代码没有太多关系,可以忽略。
在创建出AbstractWorkGang类的实例后,要通过成员函数initialize_workers()初始化工人。

JVM是如何进行多线程并行编程的

第81行代码用来按照客户希望的工人数量创建一个工人数组,第92行至第103行代码则用来创建工人。
第93行代码是调用allocate_worker()创建GangWorker,而第96行代码和第101行代码分别是创建工人线程和让工人线程开始执行处理。
第93行代码中的allocate_worker()的源码如下。 JVM是如何进行多线程并行编程的
allocate_worker()函数以this(自己所属的AbstractWorkerGang)和工人的编号为参数创建GangWorker的实例。
initialize_workers()是通过内部调用os::start_thread()来让线程开始执行处理。由于GangWorker继承自Thread类,所以os::start_thread()实际上会调用让线程开始执行处理的run()函数。
让我们看一看GangWorker类的run()函数。

JVM是如何进行多线程并行编程的

run()函数调用了loop()函数。这里我们只看loop()函数中进入监视器等候室等待的部分。 

JVM是如何进行多线程并行编程的

首先在第243行获取自己所属的AbstractWorkGang的监视器。 
在第249行给监视器加锁并进入监视器。 
然后,在第268行中的循环开始处检查是否有任务。 
由于线程启动时多数情况下是没有任务的,所以这时基本上都会执行第284行代码调用wait()。

2.2 ②创建任务
准备好工人后,接下来要创建让工人执行的任务。请参考代码清单1.1中②的部分。这里以继承自AbstractGangTask的G1GC标记任务CMConcurrentMarkingTask为例进行讲解。

JVM是如何进行多线程并行编程的

在第1155行至第1157行定义的CMConcurrentMarkingTask的构造函数接收用来执行work()的变量作为参数。由于work()的参数是确定的,所以任务类的实例必须将执行各个任务时所需的信息作为成员变量保存起来。
第1095行至第1153行代码是CMConcurrentMarkingTask要执行的任务的内容。创建出的各个GangWorker会调用这个work()方法。 

2.3 ③并行执行任务

最后是将任务交给工人。

代码清单1.1中③的部分会调用FlexibleWorkGang的run_task()。

JVM是如何进行多线程并行编程的

以任务为参数的run_task()首先会通过第132行代码获取监视器的锁。
然后,在第139行写好任务信息,在第140行至第142行更新其他信息。 
这一部分与图2相对应。

JVM是如何进行多线程并行编程的

然后,在第144行通知在等候室中等待的工人。
第146行至第153行的while循环的退出条件是“所有的工人都完成任务”。 
如果不满足条件,那么在第152行的客户会继续等待。这一部分与图5相对应。 
各个工人在GangWorker的loop()函数中调用wait(),等待被给予可以执行的任务。 
下面我们稍微详细地看一看loop()。

JVM是如何进行多线程并行编程的

顾名思义,第242行的previous_sequence_number是用来记录上一个任务编号的局部变量。

从第244行开始的for循环每循环一次,工人就会执行一个任务。 

第245行代码中的WorkData是记录WorkerGang中任务信息(布告板信息)的局部变量。

此外,第246行代码中的part是记录工人顺序的局部变量。这些局部变量都是在执行任务的循环的作用域(scope)中定义的,因此执行任务的循环每循环一次,它们就会被清空一次。

从第268行开始的for循环是从WorkerGang获取任务的循环。 

通常工人是在第284行处于等待状态,直到接收到notify_all()的通知才会开始工作。

工人开始工作后,第285行代码中的internal_worker_poll()会将任务信息复制到局部变量中。

在获取了这些信息后,第276行和第277行的条件分支代码会检查当前是否有应该执行的任务。如果有,则在第278行将自己已经启动的信息记录到GangWorker中,然后在第279行调用notify_all(),将工人的顺序保存在part中并退出循环。请注意,这里在退出循环的同时还解除了监视器的锁。 

然后,第308行以part为参数调用了任务的work()函数。这里会实际地执行任务。

到目前为止的这一部分与图3相对应。

JVM是如何进行多线程并行编程的

任务完成后,工人会再次获取锁,并将任务完成的信息写入到GangWorker中。
接下来,工人会调用notify_all(),将完成的任务的编号复制到previous_sequence_number中,然后 返回到for循环的开始处。 
这一部分与图4相对应。 
到此,工人就完成了一个任务。当所有的工人都执行完任务后,客户会检查GangWorker中的信息,确认所有任务全部完成。这样run_task()函数的执行也就结束了。
以上就是HotSpotVM中多线程并行执行GC任务的流程和源码实现。 


那么,HotSpotVM又是如何控制线程,与mutator并发执行GC的?

并发GC由哪些类实现?各类有什么作用,继承关系又如何?

HotSpotVM的“安全点”和“VM线程”又是什么意思呢?


欲知后事如何,没有下回分解!

一切尽在此书中!


JVM是如何进行多线程并行编程的


推 荐 阅 读


图 灵 社 群
JVM是如何进行多线程并行编程的

点个「赞」或「在看」嘛!