本文版权归作者所有,如有转载请与作者联系并注明出处http://blog.csdn.net/Iangao/archive/2008/10/09/3041265.aspx。
1.1 同步机制(synchronize mechanism)
多线程开发过程中,我们经常会提到同步这个词,那么什么是同步呢?为什么会存在同步问题呢?我们知道一个多线程应用系统在操作系统的进程(线程)机制下可以同时有多个进程(线程)并发运行,这此进程(线程)要完成的任务可能是互不相关的,但也可能是有联系的。那么当一个进程(线程)要和另一个进程(线程)交流信息时同步就有可能发生了。为什么呢?如果您不清,看了下面这个例子也许就会明白了。
同步示例: | 进程同步:// http://www.csdn.net/blog/Iangao | ||||||
在日常生活中我们经常会遇到定蛋糕的事,过程是什么样的呢?让我们一起来回顾一下。 首先是我们做我们的事,蛋糕师傅也做道自已的事,当我们想要买蛋糕时,我们就要和蛋糕师打交道了。 我们选好蛋糕样式,交款完款,把定单交给蛋糕师后由于时间制作时间很长,所以出去逛20分钟的街,然后回来拿着小票等待蛋糕被做好了。 蛋糕师则是一上班就在等待定单,当他拿到定单后,开始按照要求进行制作,做好后把这蛋糕放到货架上,然后根据定单上的连系方式通知我们来取货。 接到通知单后,我们便可以拿着蛋糕回家了。而蛋糕师还将继续做着他自已的事情。 |
|
我们分析一下上面的例子,在例子中,我们可以看出由于没有定单,师傅进程在“同步1”处不得不停下来等待一个定单,直到有定单时它才可以制作蛋糕。同样的,由于蛋糕尚未做好,因此顾客线程也在“同步2”处停下来等待,直到接到取货通知单后才取蛋糕回家。现在我们可以总结一下:同步(synchronize)实际上就是让一个线程停下脚步处于等待状态直到另一个线程向它发出继续执行的信号后,两个线程再继续并发执行的一种线程间的通信机制。而通过上面的例子我们可以总结出“要想实现这种机制起码应该具备如下三个基本要素”:
- 一个用于通信的信号(signal)
- 一个用于等待信号的操作(wait)
- 一个用于发送信号的操作(notify)
这种机制在Solaris或POSIX线程中,它们通常被看做是条件变量(condition variable), 而在Windows系统中它们往往被看成是事件变量(event variable),不过两者还是有一些区别的,我们将在后面的章节中继续深入讨论。
在同步问题中,有一类是由于对共享资源并发使用而引起的。众所周知一组资源(数据)被多个进程(线程)同时使用,往往会造成逻辑紊乱(有的在读,有的在改),为了避免这一情况,就要求对资源的使用加以控制,使得进程(线程)在访问资源期间,不允许被其他进程(线程)干扰。有干扰的线程必须处于等待状态中。通常我们把这段不受干扰的使用某一特定资源的段码段称为该资源的临界区(critical section)。而临界区就象对资源加了一把锁(Lock),进的时候加锁(lock),出的时解锁(unlock),这就可以把所有有干扰的进程(线程)锁都到临界区之外。下面我们分析一下用于定义临界区的锁操作都完成了什么功能,请看下面的一个最简单的功能性说明代码清单(下图参考自《操作系统》一书):
shared double balance, account; //共享变量 shared int lock=FALSE; //同步变量(Mutex): 用于实现控制对balance和account资源访问的锁 | |
Program for P1 [ Credit(贷) ] // http://www.csdn.net/blog/Iangao ... enter(lock); // 1. 进入临界区(加锁) balance=balance+account; // 2. <临界区A> leave(lock); // 3. 离开临界区(解锁) ----------------------------------------------------------> ... // 4. 继续P1任务 | Program for P1 [ Debit(借) ] // http://www.csdn.net/blog/Iangao ... enter(lock); // 2. 等待P1解锁 // http://www.csdn.net/blog/Iangao balance=balance-account; // 4. <临界区B> (获取了P1释放的锁) leave(lock); // 5. 解锁 ... |
随着后面不断深入的讨论,我们会发现有些临界区的锁是可以有限度的,它只阻止一部分可以引起相互干扰的进程(线程)进入临界区,而不会阻止互不干扰的进程(线程)进入,这时可能会有几个进程(线程)并发的使用。不过本节我们只讨论其中一种最简单的锁,一种会阻隔一切想要进入临界区内线程的锁,由于它具有极强的排它性,因此我们称它为互斥量(Mutex lock)。同样由于它具有排它性,我们一般会用它来定义一个原子(atomic)操作,因为它可以很好的保证对某一资源操作的不可分割性(individed)。下面我们简单定义了一个mutex的基本语义:
// 加锁:原子操作 enter(mutex){ while(mutex) wait(); // 等待 mutex=TRUE; // 标识资源忙 } | // 解锁:原子操作 leave(mutex){ mutex=FALSE; // 标识资源可用 notify(); } |
考虎到mutex是用于定义原子操作的,因此我们在实现时会对加锁和解锁操作进行一定的约束,约束如下:
最后,为了保证临界的原子性(atomic),通常对一个互斥量Mutex做解锁操作的进程(线程)一定是前面对其进行加锁操作的进程。 而不允许被其他进程(线程)解锁。下面的代码清单演示了临界区与锁的工作原理:如果P1运行到balance=balance+account时P2运行到了enter(lock),那么由于P1对"lock资源"已经加锁了,此时P2只处于等待状态。直到P1执行完exit(lock)后才释放了对"lock资源"的控制权,这时P2使可以执行enter(lock)操作,继而在独占lock资源的情况下完成临界区中的代码示意图。
1 定义临界区:
Java为了实现同步机制提供了synchronized关键字,我们可以使用它来定义被同步的对象以及临界区,临界区的范围是由一组大括号来标识的。而进出临界区时必要的加锁和解锁操作则是由Java内置支持的。从功能上来说,我们可以认为左大括号起到enter(lock)的作用,而右大括号起到了exit(lock)的作用。下面清单中演示了synchronized与互斥锁理论功能上的的对应关系。
private double balance, account; //共享变量 private Object lock=new Object(); //同步对象 Program for P1 ... synchronized(lock){ // 获取资源并加锁 balance=balance+account; // <临界区> } // 释放资源并解锁 ... | shared double balance, account; //共享变量 shared int lock=FALSE; //同步变量 Program for P1 ... enter(lock); // 获取资源并加锁 balance=balance+account; // <临界区> exit(lock); // 释放资源并解锁 ... |
2 定义同步对象:
我们知道了如何定义临界区,但这还不够,我们还需要知道如何定义这段临界区要同步的对象(数据).在Java中有下面的两种使用synchronized的方式,我们分别看一下它们是如何定义被同步的对象的:
- 一种是直接“声明同步(synchronized)对象”: 这种方式的同步对象被明确的指定在了sychronized后的小括号,而其后的一组大括号内定义的正是临界区。这种方式使用起来很灵活,可以只对方法中的一段不可分割的代码做同步,因此临界区比较小,所以效率也就可以比较高了。下面清单中演示了这一用法的使用。
/** * 声明同步对象示例,参看上节示例 * @author iangao */// http://www.csdn.net/blog/Iangao public class Foo { private double balance, amount; //共享资源 private Object synObject=new Object(); //同步对象 /** * 参看上节: [Program for P1] */ public void f(){ ... // 进入 临界区,锁定对synObj的访问 synchronized(synObject){ // 临界区A balance=balance+amount; ...// http://www.csdn.net/blog/Iangao doSomethingInCriticalSectionA(); } // 退出 临界区,释放对synObj的锁定// http://www.csdn.net/blog/Iangao ... }// http://www.csdn.net/blog/Iangao /** * 参看上节: [Program for P2] */ public void g(){// http://www.csdn.net/blog/Iangao ... synchronized(synObject){ // 临界区B balance=balance-amount; ... doSomethingInCriticalSectionB(); } ....// http://www.csdn.net/blog/Iangao } } |
- 另一种是“声明同步(synchronized)方法”:这种方式是把整个方法的实现都划入了临界区.但它同步的是哪个对象呢?实际上这种方式是隐式的对this对象做同步的,相当于一进入方法就对this对象做同步操作[即synchronized(this){}],直到方法结束 再解除同步。不过因为是对整个方法 做同步,所以有时会显得效率不高,只是它用起来很方便。以下清单中描述了synchronized的这种用法,右边演示了重构成第一种使用方式时的样子。
/** * 声明同步方法示例 (管程的Java实现) * @author iangao */ public class Foo2 {// http://www.csdn.net/blog/Iangao private double balance, amount; //共享资源 public synchronized void f(){ // 监界区C balance=balance+amount; }// http://www.csdn.net/blog/Iangao public synchronized void g(){ // 监界区D balance=balance-amount; } }// http://www.csdn.net/blog/Iangao | /** * 按照同步对象的方法重构Foo2后 * @author iangao */ public class Foo2 { private double balance, amount; //共享资源 public void f(){ synchronized(this){ // 监界区C balance=balance+amount; } } public void g(){ synchronized(this){ // 监界区D balance=balance-amount; } } } |
其实,通过sychronized关键字定义的同步对象,我们一般称它为管程对象(Monitor),在后面我们还会对管程进行详细讨论。在此不再多述。
3 同步测试
测试1.1.3.2中synchronized同步示例,为了达到测试的目的可以分别在临界区A,B,C,D中加入延时操作Thread.sleep(3000)并输出一些调试信息。这样就可以得到可见的同步效果显示了。测试代码可以参看下面的清单:
/** * 同步对象测试 * @author iangao */ public class SynchronizedObjectTest { public static void main(String[] args){ // 定义要同步的两段代码 new ThreadsTest(){ private Foo foo=new Foo(); void runInThread1() { // <反射> foo.f(); // 在线程1中执行foo.f() }// http://www.csdn.net/blog/Iangao void runInThread2() { // <反射> foo.g(); // 在线程2中执行foo.g() }// http://www.csdn.net/blog/Iangao }.execute(2); // 执行同步测试(启动2个线程) }// http://www.csdn.net/blog/Iangao } | 测试结果: [T1]: 准备进入: synObject临界区A |
4. 对象锁与类锁
在Java中,用synchronized定义的都有两种锁,一个种是对象锁(每个对象有一把锁), 一种是类锁(每个class有一把锁),前面讨论的主要是对象锁,下我们用一个例子来研究对象锁与类锁的区别:
/** public void runInThread1(){ g(); } | 执行结果: [T1]: enter g 结论: static方法定义的是类锁, 而非static方法定义的是对象锁. 因为两个方法同步的不是同一个锁,所以同步失效. |
执行结果2(把g改成static后的): [T1]: enter g 结论: 此时两个方法都是同步的class锁, 同步成功! |
1.2.2.1 wait、notify简介
Java语言为我们提供了一套用于线程间同步的通信机制即wait-nofity机制。前面我们知道了,sychronized关键字可以让我们把任何一个Object对象做为同步对象来看待,而Java为每个Object都实现了wait()和notify()方法。它们必须用在被sychronized同步的Object的临界区内。通过的wait()我们可以使得处于临界区内的线程进入阻塞状态,同时释放被同步对象的控制权,而notify操作可以唤醒一个因调用了wait操作而处于阻塞状态中的线程,使其进入就绪状态。被重新换醒的线程会试图重新获得临界区的控制权,并继续执行临界区内wait之后面的代码。如果发出notify操作时没有处于阻塞状态中的线程,那么该信号会被忽略.
/** | execute(2)执行结果: [T1]: <try enter> | execute(4)执行结果: [T1]: <try enter> |
分析: | 分析: |
通过上面一系列的测试与分析,我们可以得出一个得要结论,那就是“wait()对notify()的响应很有可能是不及时的”,之所以强调这一点是因为它往往会由于响应不及时而造成响应时的系统状态与发出notify时的不一致,所以在实际应用中我们要多加注意这一点,尤其是在那些与状态有关的同步应用中这点尤为重要。
1.2.2.2 notify 与 notifyAll
notify的只能唤醒一个通过调用wait而等待的线程, notifyAll可以唤醒所有调用wait或因synchronized关键字而等待的线程. 不过通过notifyAll唤醒的所有线程也必须按照上节分析的过程,依次进入临界区.
// http://www.csdn.net/blog/Iangao | wait() | sleep() |
所属对象 | Object | Thread |
阻塞当前线程 | 可以 | 可以 |
使用条件 | 必须在管程(Monitor)内使用,即所属Object必须已被sychronized同步。 | 随时 |
管程(Monitor)内使用效果 (synchronized临界区内) | 释放对管程对象(Monitor)的控制权 | 不释放对管程对象(Monitor)控制权 |
唤醒 | notify() | 无 |