解析JVM线程同步机制
2005 年 12 月 26 日
摘要
对多线程的支持一般是在OS级的,而Java将其做在了语言级别,这其中最吸引人的莫过于Java对线程同步(互斥与协作)的支持。本文分析了JVM(Java Virtual Machine)内部实现的监视器同步机制,并结合经典的生产者消费者同步问题,阐述Java语言级别上对此机制的支持。
关键词:同步,互斥,协作,监视器,锁
Keyword: Synchronization, Mutual Exclusion (Mutex), Cooperation, Monitor, Lock
目 录
摘要
目 录
知识准备
一、线程同步:互斥和协作
二、解决同步的方案
三、线程同步模型——监视器(Monitor)机制
3.1 监视器模型
3.2 监视器实现互斥
3.3 监视器实现协作
四、JVM线程同步的实现
4.1 JVM线程模型
4.2 对象锁
4.3 Java语言对线程同步的支持
4.3.1 同步方法与同步语句
4.3.2 协作——wait & notify/notifyAll
五、利用JVM线程同步解决生产者消费者问题
5.1 生产者消费者问题相关类层次
5.2 生产者线程——threadSynch.ProducerConsumer.Producer
5.3 消费者线程——threadSynch.ProducerConsumer.Consumer
5.4 主程序——threadSynch.ProducerConsumer. ProducerConsumer
5.5 程序运行时的一个快照
六、总结
参考资料及进一步阅读
关于作者
知识准备
阅读本文前,你应该具有操作系统的基本知识,知道Java的基本运行模式,最好还有过多线程的编程经验。这些知识准备,可以阅读本文后面所附的参考资料。
一、线程同步:互斥和协作
早期,顺序程序设计(Sequential Programming)的模式一般是串行的执行“输入——处理——输出”,执行过程中可能还有用户的交互或者执行其它I/O操作,而这一切直到最后的输出,系统最珍贵的CPU资源都由这个程序对应的进程(Process)占有。为了提高CPU的利用率和任务的并行化,引入了并发程序设计(Concurrent Programming),也就相应的在OS内部有了并发进程的概念。在支持线程(Thread)的系统中,进程的实现和思考方法也适用,只是在OS调度的最小单元和资源的分配单元上有所区别。但是也要看对线程的支持实现在哪一级上,所以也就有了三种线程实现方式:内核级线程、用户级线程和混和式线程。(关于这方面的知识,可阅读参考文献中的1或2)
支持多线程的系统中,并发线程在运行过程时,会有同步(Synchronization)的需求,同步包括了互斥(Mutual Exclusion,简记为Mutex)与协作(Cooperation)两个方面。多个线程交叉访问临界资源的时候,如果不把临界区进行互斥管理,执行的结果可能就不是你预想的(这种没有对临界资源进行互斥访问而出现的情况,你几乎可以在任何讲述并发编程的书中找到例子,这里不再赘述),这就需要将多个线程对临界区的执行串行化。两个或多个线程运行中,可能要协作完成一整件事,但是它们是相对独立运行的,相互之间并不影响,所以需要某种机制使某个线程知道它下一步执行所依赖的条件是不是满足,而这个条件是否满足是要看另外一个线程的执行情况的,也就是通过某种协作机制使线程在单独执行并行化的基础上,实现多个线程的协作串行执行。
二、解决同步的方案
解决线程同步问题,早期的科学家提出了一些对于临界区管理的方法,后来在操作系统中实现了信号量机制来解决经典的同步问题,在IEEE的POSIX(Portable Operating Systems Interface)标准中就进一步把线程进行了标准化,简称pThread标准,这其中也标准化了线程同步机制——互斥量等,目前很多类UNIX(UNIX-like)操作系统都已经实现了pThread。
实现临界区的管理可以采用软件方法,也可以用硬件方法。T.Dekker和G.L.Perterson分别提出了Dekker算法和Perterson算法,用软件方法实现了对临界区管理。硬件方法可以采用关中断来解决,但是这种方式使系统效率大大的降低,甚至如果关中断处理不当,还会使系统无法正常调度,而且也不适用于多处理器情况。现在几乎所有的操作系统都实现了信号量(Semaphore)机制。信号量(Semaphore)通过一个记录当前使用情况的标记value,等待该信号量的线程队列queue和相应的PV操作原语实现同步。P操作相当于线程要使用该信号量所标志的资源,通过P操作来获得,如果当前不可用,该线程就要被挂起;V操作相当于线程使用完该信号量所标志的资源,通过该操作来释放它,如果有线程在等待这个资源并且资源当前可用,就采取某种策略选择一个等待的线程,让它拥有该资源,并继续执行。根据value的初始值,信号量可以用来实现线程的互斥与协作。POSIX pThread的互斥量(Mutex)其实是信号量的一种特殊形式,但是使用互斥量要比用通用的信号量的同步机制来得容易。
Java利用JVM对线程执行期的完全控制,实现了监视器(Monitor)机制的线程同步,下面章节先介绍监视器机制,然后分析监视器机制在JVM中的实现。
三、线程同步模型——监视器(Monitor)机制
监视器支持上文所述的两种线程同步:互斥与协作,而JVM通过对象锁实现了监视器机制。本节就来阐述监视器模型,并分析它如何实现线程的互斥与协作。
3.1 监视器模型
图一、监视器模型
图一是监视器模型,监视器包括了三个部分,入口区、拥有区和等待区,入口区和等待区内可能有多个线程,但是任何时刻最多只有一个线程拥有该监视器。
线程对监视器的操作原语如下:
-“进入”监视器指线程进入入口区,准备获取监视器,此时如果没有别的线程拥有该监视器,则这个线程拥有此监视器,否则它要在入口区等待;
-“获取”监视器指在入口区和等待区的线程按照某种策略机制被选择可拥有该监视器时的操作;
-“拥有”监视器的线程在它拥有该监视器的时候排他地占有它,从而阻止其它线程的进入;
-“释放”监视器 拥有监视器的线程执行完监视器范围内的代码或异常退出之后,要释放掉它所拥有的此监视器。
监视器实现的是对临界区的管理,对临界区调度原则有16字要求——无空等待,有空让进,择一而入,算法可行。展开来说就是:
- 一次至多一个线程能够在临界区内;
- 不能让一个线程无限地留在临界区;
- 不能强迫一个线程无限地等待进入临界区;
- 不能因所选的调度策略而造成线程的饥饿(Starving),甚至死锁(Dead Lock)。
下面以经典的“生产者-消费者问题”为例,来分析利用监视器如何实现生产者进程与消费者进程之间的互斥与协作。简化后的该问题的描述如下:生产者进程和消费者进程都对同一个缓冲区操作,生产者生产产品放到缓冲区,消费者消费缓冲区内的产品;如果缓冲区非空,则消费者读取缓冲区的产品,消费掉产品的同时将缓冲区清空,否则消费者等待;如果缓冲区为空,则生产者生产产品放到缓冲区,否则生产者等待。
3.2 监视器实现互斥
利用监视器实现进程之间的互斥理解起来非常简单。生产者消费者问题中的缓冲区是临界资源,需要互斥访问,可以用一个监视器来保护。生产者或消费者要访问该共享的缓冲区首先必须拥有这个监视器,在监视器被别的线程占有时,该访问线程必须在入口区或等待区等待。生产者和消费者对缓冲区的互斥访问关系如图二所示。
图二、生产者-消费者之间的互斥
生产者与消费者各自独立地执行,只有当它们需要访问缓冲区(存放生产的产品或消费产品)的时候,才需要获得与该缓冲区关联的监视器,如果当前该监视器不能获得,它们就在监视器的入口区等待。它们在离开临界区的时候,释放监视器,以允许其它竞争该监视器的线程进入。如果一个线程已经获得了监视器,但是缓冲区的内容却还不满足自己的需要,它就必须等待并释放掉监视器,从而允许其它线程进入,关于线程协作的问题参考下面的小节。
3.3 监视器实现协作
上面小节主要说明了生产者和消费者对缓冲区的互斥访问关系,但是没有详细谈到“wait & Release the Monitor”这个活动,这个主要牵涉了监视器实现的线程间的协作关系。
生产者和消费者通过监视器协作完成生产者消费者问题的活动图如图三所示。
图三、生产者-消费者之间的协作
重点关注图中的绿色部分,拥有监视器的线程检测到当前缓冲区不符合自己要求的情况下“wait & Release the Monitor”,这样这个线程就释放掉了该监视器,并且进入到等待区。假设这个线程是生产者Thread_p,此时无论消费者线程Thread_c处于入口区还是等待区,Thread_c都可能获得该监视器并继续执行。Thread_c消费完产品并设置好缓冲区之后,它通知(notify/notifyAll)等待区的线程,正在等待(wait)的线程要求的条件满足后可以竞争获取监视器,并继续执行。
注意,等待线程并不是收到notify消息立即就能获取监视器,还要等发送notify消息的线程离开临界区(此时,已经释放监视器)时它才能竞争获取。这里的“竞争获取”指的是处于入口区和等待区的线程按照某种调度策略被选择进入监视器,这种策略可以是用先入先出(FIFO)队列实现的先来先服务管理,但采用哪种实现要看虚拟机的具体实现。因为这种等待线程的竞争获取监视器现象的存在,等待线程在拥有监视器之后要判断当前条件是否真的满足需要——状态有可能被先于它获得监视器的其它线程改变。
四、JVM线程同步的实现
JVM通过对象锁实现监视器的模型的线程同步机制。其实现是通过在JVM内部为每个对象和类都关联一个锁;语言层次上用同步方法或同步语句标识临界区,每个对象都实现等待/通知方法等方式来通过实现线程同步的。
4.1 JVM线程模型
Java中的线程是在用户级实现的,即,在操作系统看来JVM是一个进程,而Java线程是JVM内部实现的,对OS内核来说是透明的。这种实现可以利用JVM对Java线程执行期的完全控制在JVM和Java语言上实现线程的同步。其实现也就是图四中的2)用户级线程(User Level Thread/ULT)。
图四中,用户级线程(User Level Threads/ULT),对OS内核来说是透明的;内核级线程(Kernel Level Threads/KLT)在用户空间和内核空间有相应线程的对应关系。JVM的一个实例,在内核空间只有一个OS进程与其对应,而Java内部实现的线程对OS来说都是不可见的,是实现在JVM内部的用户级线程。
4.2 对象锁
对JVM内部非私有数据的保护,JVM采用的是为每一个这样的数据对象都关联一个对象锁,这些数据主要有堆中的对象实例和方法区中的类变量。这也就是为每一个对象关联一个对象锁,来实现监视器机制的对某个对象的互斥访问和基于某个条件的协作工作。类锁的实现采用的是对象锁,不同的是,对象锁是针对java.lang.Object对象,而类锁是针对java.lang.Class对象,也就是类的实例。
JVM内部对每一个对象锁都有一个相应的计数,
- 如果该计数为0,则它没有被锁,可以由访问它的线程来加锁;
- 如果该对象锁被别的线程锁定,则现在访问它的线程被挂起等待,直到锁定它的线程释放该对象锁,并且计数为0;
- 一个线程已经拥有了一个对象的对象锁,如果再次访问这个对象,则可以重入,并且这个对象锁对应的计数加一;
- 拥有对象锁的线程释放对象锁的时候,对象锁对应的计数减一;当减到计数为零时,该锁可被等待的线程竞争拥有。
由对象锁的特性可以看出,利用JVM的对象锁就实现了对被对象锁保护对象的互斥访问,是监视器模型的线程的互斥实现。当前锁定一个对象的线程也可以因等待(wait)某个条件而释放该对象锁;拥有对象锁的线程也可以在别的线程等待的某个条件满足之后通知它(notify)或它们(notifyAll),这也就实现了线程间的协作。
目前已经讨论了JVM如何实现线程同步的,但是还不知道如何进入/退出临界区,以及如何通知等待的线程实现线程协作的,这些都在Java语言上给予了支持,本文在下面章节具体介绍。
4.3 Java语言对线程同步的支持
Java语言上对线程同步的支持主要有对临界区的标识,和线程协作的支持。
4.3.1 同步方法与同步语句
Java语言对临界区的标识是通过同步方法(Synchronized Method)和同步语句(Synchronized Statements)实现的。Java线程在进入这些同步方法或同步语句标识的临界区开始的地方申请被保护对象的对象锁;离开临界区的时候(包括出现异常而离开的时候)释放掉该对象锁;如果该对象锁已经被别的线程锁定,则当前进入的线程被挂起等待。这一切是在JVM内部实现的,Java程序中要做的是用同步方法或同步语句标识临界区,并指名被保护对象,也就是对象锁所对应的对象。
同步方法是在一个类的方法的前面用synchronized关键字声明,这样标识了一个临界区,在线程访问这个类的对象的该方法的时候,就遵从锁对象的管理机制。同步语句是把某条或某几条语句用synchronized关键字标识出并指名同步语句所针对的对象。
同步方法和同步语句实现的机理是一样的,所不同的只是它们所标识区域的粒度不同,同步方法的标识的锁的粒度大于同步语句的,线程等待该锁的时间也就比较久,但是实现会比较容易,;同步方法可以指定其所管理的对象,比较灵活。所以对于同步方法或是同步语句的选择,一般原则是,对性能要求不是很高的应用层程序采用同步方法,而调度性能要求较高的底层应用,宜采用同步语句,并尽量减小其所保护的范围,当然这在提高性能的同时增加了设计的复杂度。所以这要根据你所具体应用场景的各项因素来平衡选择。
4.3.2 协作——wait & notify/notifyAll
Java语言的每个对象(都是java.lang.Object的子类)都实现了线程协作的方法,只是这些方法只有在同步方法或同步语句所标识的临界区内才能被调用,也就是调用这些方法的时候,相对应的对象已经被加锁。这种协作方式,也就如前文所述,
- 拥有对象锁(监视器)的线程调用wait释放该对象锁并等待再次进入;
- 拥有对象锁的线程执行过程中,别的线程等待的条件满足,则通知(notify)等待的线程或通知所有(notifyAll)等待的线程。
wait & notify/notifyAll的原型声明如下:
public final void wait() throws InterruptedException
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException
public final native void notify();
public final native void notifyAll();
等待某个对象锁的时候,可以指定等待的时间,超时的话,自动退出等待。
当明确知道等待区内只有一个等待线程的时候,才应该使用notify,否则就应该使用notifyAll,让JVM采用相应的调度策略来决定选择哪个等待该对象锁的线程被唤起。这样就可由JVM来保证避免某个线程无限制等待的饥饿现象,而不需要用户来关注。
五、利用JVM线程同步解决生产者消费者问题
本节以生产者消费者的实例说明JVM线程同步的设计。
5.1 生产者消费者问题相关类层次
生产者消费者问题相关类层次如下图所示
图五、生产者消费者问题相关类层次图
生产者(Producer)和消费者(Consumer)线程引用同一个缓冲区(Buffer)实例。缓冲区可以判断当前是否为空(isNull);非空情况下,可以从中得到产品并将其清空(getProduct);Buffer为空的情况下,可以放置产品进去(setProduct)。
5.2 生产者线程——threadSynch.ProducerConsumer.Producer
生产者线程不停地生产产品,如果缓冲区为空,放置产品到缓冲区。Producer Override Thread的run()方法,这样在调用线程的start()方法的时候,JVM线程调度机制会自动调用线程的run()方法。下面程序段是Producer.run()方法。
public void run() {
super.run();
while (true) {
execute();
}
}
execute()是生产者的循环执行体,实现的是图三的生产者的活动,代码如下:
private void execute() {
generateNewProduct();
System.out.println("[Producer] produced a new product: " + product);
synchronized (buffer) {
System.out.println("[Producer] Owned the buffer's monitor!");
while (!buffer.isNull()) {
System.out.println("[Producer] release the monitor and wait!");
try {
buffer.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("[Producer] re-owned the monitor!");
}
System.out.println("[Producer] put the product (" + product
+ ") into the buffer!");
buffer.setProduct(product);
System.out.println("[Producer] notify other threads waiting on the monitor!");
buffer.notifyAll();
System.out.println("[Producer] to release the monitor!");
}
}
generateNewProduct()利用java.util.Random产生一个随机数代码生产者生产的产品。
private void generateNewProduct() {
int prd = product;
while ((prd = (new Random()).nextInt(100)) == product)
;
product = prd;
}
5.3 消费者线程——threadSynch.ProducerConsumer.Consumer
消费者线程不停地查询缓冲区内是否有产品,如果缓冲区内有产品,则从中取出产品并清缓冲区。Consumer Override Thread.run()方法,这样在调用线程的start()方法的时候,JVM线程调度机制会自动调用线程的run()方法。Run()的实现同生产者线程,不同的是循环执行体execute(),其实现的是图三所示的消费者的活动。
private void execute() {
synchronized (buff) {
System.out.println("[Consumer] Owned the monitor!");
while (buff.isNull()) {
System.out.println("[Consumer] release the monitor and wait!");
try {
buff.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("[Consumer] re-owned the monitor!");
}
product = buff.getProduct();
System.out.println("[Consumer] get a product (" + product
+ ") from the buffer!");
System.out.println("[Consumer] notify others waiting on the monitor!");
buff.notifyAll();
System.out.println("[Consumer] to release the monitor!");
}
System.out.println("[Consumer] consume the product: " + product);
}
5.4 主程序——threadSynch.ProducerConsumer. ProducerConsumer
主程序ProducerConsumer创建Buffer以及生产者和消费者线程的实例,并启动生产者和消费者线程。
package threadSynch.ProducerConsumer;
public class ProducerConsumer {
private static Producer producer;
private static Consumer consumer;
private static Buffer buffer;
public static void main(String[] args) {
buffer = new Buffer(0);
producer = new Producer(buffer);
consumer = new Consumer(buffer);
consumer.start();
producer.start();
}
}
5.5 程序运行时的一个快照
下面是上述程序运行时的一个快照,并分别以绿色和蓝色标识生产者和消费者的输出:
[Consumer] Owned the monitor! (01)
[Consumer] release the monitor and wait! (02)
[Producer] produced a new product: 43 (03)
[Producer] Owned the buffer's monitor! (04)
[Producer] put the product (43) into the buffer! (05)
[Producer] notify other threads waiting on the monitor! (06)
[Producer] to release the monitor! (07)
[Consumer] re-owned the monitor! (08)
[Consumer] get a product (43) from the buffer! (09)
[Consumer] notify others waiting on the monitor! (10)
[Consumer] to release the monitor! (11)
[Consumer] consume the product: 43 (12)
[Consumer] Owned the monitor! (13)
[Producer] produced a new product: 70 (14)
[Consumer] release the monitor and wait! (15)
[Producer] Owned the buffer's monitor! (16)
[Producer] put the product (70) into the buffer! (17)
[Producer] notify other threads waiting on the monitor! (18)
[Producer] to release the monitor! (19)
[Producer] produced a new product: 43 (20)
[Producer] Owned the buffer's monitor! (21)
[Producer] release the monitor and wait! (22)
[Consumer] re-owned the monitor! (23)
[Consumer] get a product (70) from the buffer! (24)
[Consumer] notify others waiting on the monitor! (25)
[Consumer] to release the monitor! (26)
[Consumer] consume the product: 70 (27)
[Consumer] Owned the monitor! (28)
[Consumer] release the monitor and wait! (29)
[Producer] re-owned the monitor! (30)
[Producer] put the product (43) into the buffer! (31)
[Producer] notify other threads waiting on the monitor! (32)
[Producer] to release the monitor! (33)
[Producer] produced a new product: 34 (34)
[Producer] Owned the buffer's monitor! (35)
[Producer] release the monitor and wait! (36)
[Consumer] re-owned the monitor! (37)
[Consumer] ―――――― (38)
[Producer] ―――――― (39)
生产者消费者相关联的对象的简写表示:Per-生产者;Cer-消费者;B-缓冲区;M-缓冲区相关联的监视器;Px(x = 1, 2, …)-产品。结合图三的生产者和消费者的活动图,可以解释上面的输出快照。
- 消费者Cer首先运行,获得监视器M,因缓冲区B内当前还没有产品P,所以Cer释放M并等待[Line 1, 2]。
- 生产者Per生产了一个产品P1,在获得M之后,放置P1到B并通知Cer,释放M[Line 3-7]。
- Cer重新获得M之后,从B中得到P1并清空B,通知其它线程当前B可用,释放M,消费产品P1(因为P1已经被Cer获得,所以消费P1不需要在临界区内完成),重新获得M(线程调度的作用)[Line 8-13]。
- Per生产一个新产品P2(虽然Per当前未获得M,但生产产品是不需要在临界区的,当JVM线程调度运行Per的时候,它仍然可以生产产品)[Line 14]。
- P2并未被放到B,所以Cer释放M并等待[Line 15]。
- Per现在可获得M[16],并放置P2到B[17],然后唤醒等待M的线程[18]并释放M[19];当前Cer并未获得调度运行,所以Per继续生产P3[20];Per获得M并试图放置P3到B[21],但此时Cer还未取走P2,所以Per释放M并等待[22];
- Cer…[23, …]
- Per…[line…]
六、总结
多线程有有效的利用了计算机的最珍贵的CPU资源,但在计算性能提高的基础上的同时,也增加了设计的复杂度。Java利用JVM对运行期的控制,实现了JVM内的线程模型,并简化了实现了线程间的同步问题,对编写Java程序的人员来说,开发的效率显著提高。
任何设计决策都不能用绝对的孤立的评价角度来看,它应该是各种因素的综合。JVM线程同步模型也是一样,Java开发者实现简单的代价也就丧失了灵活性。比如要在同一个类的多个方法之间实现同步,用同步方法或同步语句来实现就显得力不从心了,如果增加监视器来实现,处理不当就又可能引起死锁(Dead Lock)。
关于其它同步方法,Doug Lea实现了一个Java语言工具包。现在这个包已经加入处于JCP控制下的JSR标准。SUN Java SE 1.5实现中也已经加入了这部分代码,有兴趣的读者可以参阅。
文中所描述的生产者消费者问题的实例代码可与本文作者联系索取。
参考资料及进一步阅读
1) 孙钟秀,费翔林,骆斌,谢立. 操作系统教程,第三版. 高等教育出版社,2003.8
2) Abraham Silberschatz, Peter Baer Galvin, Greg Gagne. Operating System Concepts, 6th Edition. John Wiley & Sons, Inc/高等教育出版社影印, 2002.5
3) David R. Butenhof/于磊,曾刚. Programming with POSIX Threads. Addison Wesley/中国电力出版社, 2003
4) Bill Venners著/曹晓钢,蒋靖译. Inside the Java Virtual Machine, 2nd edition. McGraw-Hill/机械工业出版社, 2003
5) Ken Arnold, James Gosling, David Holmes. The Java Programming Language, 3rd Edition. Addison Wesley/中国电力出版社影印, 2003.5
6) Michael L. Scott著/裘宗燕译. Programming Language Pragmatics. Elsevier/电子工业出版社, 2005.3
关于作者
田海立,硕士,国家系统分析师,CSAI专业顾问。您可以通过 haili.tian@csai.cn 或 haili.tian@gmail.com 与他联系,到 http://blog.csdn.net/thl789/ 看他最新的文章。
(本文可自由转载,但请给出原文链接: http://blog.csdn.net/thl789/archive/2005/12/30/566494.aspx)。