“同步”实际上是相对于“异步”而言的。异步是指多个线程的运行相互独立,彼此间无依赖性。例如,在输出2的倍数时,不会影响3的倍数的输出。从某种意义上说,并发设计就是从顺序程序中分离出可异步执行的代码段,让其并发执行,以提高整体的执行效率。
同步则是指线程间执行需要遵循某种约定。例如组团外出旅游,约定:各人先自由赶赴某地集合,然后集体观光。这样,团友之间的行动实际上是相关的。
Java实现同步的策略是:互斥+通信。互斥,需要线程间有临界资源,由该资源的状态决定线程是否能执行。当然,各线程要以互斥方式更改资源的状态。例如,集合地就是团友间的临界资源,团友到达集合地后,集合地的状态发生改变(即人数增加)。通信,实际上就是对临界资源的wait()、notify()/notifyAll()的调用。如张三到达集合地后,若人尚未来齐,则执行集合地的wait()方法,让张三等待;若来齐,则执行集合地的notifyAll(),以唤醒所有处在等待状态的人(notify()只能唤醒一个线程),开始执行下一步动作。
注意
wait()、notify()、notifyAll()均为Object类的final方法。这些方法必须直接或间接地用于临界区中,否则,将会产生非法监控锁状态异常(IllegalMonitorStateException)。另外,执行wait()方法的线程会释放锁(这点与sleep()不同)。
01、线程的同步机制-示例
【例1】 有一生产者P、消费者C和缓冲区D,D中只能存放一个产品(int型数据),P、C每次只能生产/消费一个产品,见图2。利用线程同步机制,模拟实现多轮生产-消费。
■ 图2 生产者-消费者问题
目的:掌握同步机制的实现框架,掌握对线程同步执行过程的分析方法。
设计:本例主要设计了三个类:缓冲区、生产者、消费者
BufferArea:包含属性d和状态标记isEmpty,以及放/取产品的方法put(i)/get()方法。这两个方法是本例的设计重点,设计相似,要考虑三项重要操作何时执行:wait()、notify()、修改缓冲区状态(即isEmpty的值)。另外就是放/取(即返回)数据。
put(i):若缓冲区不空则wait(),否则执行{ d=i; isEmpty=false; notify(); }。
get() :若缓冲区空则wait(),否则执行{ x=d; isEmpty=true; notify(); return x; }。
Producer:生产者类,线程体run()会依次产生数据1~6,执行put(i)--输出i;
Consumer:消费者类,线程体run()会执行6次:输出get()信息。
本例展示了两种互斥实现策略,策略1:put/get为synchronized方法,生产者/消费者直接调用put/get,未达到预期效果;策略2:put/get为普通方法,生产者/消费者采用synchronized块方式,此策略完美实现生产-消费同步。注意对两种策略的执行分析。
class BufferArea{ private int d; //用于存放产品 private boolean isEmpty=true; //缓冲区状态。注:此处定要有初值true,why? public synchronized void put(int i){ //策略1:将put方法体设为原子操作序列 //public void put(int i){ //策略2:在run中设置原子操作序列 //注:因线程可能会伪唤醒(即自动醒来、小概率事件),故用循环 while(!isEmpty) try{ wait();}catch(InterruptedException e){;} d=i; isEmpty=false; notify();//注意要修改状态标记,并发通知 } public synchronized int get(){ //策略1:将get方法体设为原子操作序列 //public int get(){ // 策略2:在run中设置原子操作序列 while(isEmpty)try{ wait(); }catch(InterruptedException e){;} isEmpty=true; notify(); return d; //注意要修改状态标记,并发通知 } } class Producer extends Thread{ private BufferArea ba; public Producer(BufferArea b){ ba=b; } public void run(){ //生产过程 for (int i = 1; i<6; i++){ //synchronized(ba){ //使用策略1时需注释此句 ba.put(i); System.out.print("Producer put: "+i); try{sleep(1);}catch(InterruptedException e){;} //} //end synchronized:使用策略1时需注释此句 } //end for } } class Consumer extends Thread{ private BufferArea ba; public Consumer(BufferArea b){ ba=b; } public void run(){ //消费过程 for (int i = 1; i<6; i++){ //synchronized(ba){ //使用策略1时需注释此句 System.out.print("\t Consumer get: "+ba.get()+"\n"); try{sleep(1);}catch(InterruptedException e){;} //}//end synchronized:使用策略1时需注释此句 } } } public class Ch_5_8{ public static void main (String[] args) { BufferArea b=new BufferArea(); (new Consumer(b)).start(); (new Producer(b)).start(); } }
复制
02、输出结果
(为便于对比,下面列出了两种策略的运行结果。)
按策略1方式的运行结果:
Consumer get: 1 Producer put: 1Producer put: 2 Consumer get: 2 Producer put: 3 Consumer get: 3 Consumer get: 4 Producer put: 4 Consumer get: 5 Producer put: 5
复制
按策略2方式的运行结果:
Producer put: 1 Consumer get: 1 Producer put: 2 Consumer get: 2 Producer put: 3 Consumer get: 3 Producer put: 4 Consumer get: 4 Producer put: 5 Consumer get: 5
复制
03、示例剖析
实现框架:互斥+通信,互斥,是指生产者、消费者线程要有缓冲区作为临界资源,作用为:1. 传递产品数据;2. 借助缓冲区状态决定生产者/消费者能否执行;通信,是指互斥操作中调用wait()或是notify(),从而避免发生“死等”。
JDK说明文档中提醒,线程有时会被“伪唤醒”,即线程未用notify()或notifyAll()也会苏醒(小概率事件)。因此为安全起见,put/get中用while对isEmpty实施检测,这样即使伪唤醒,也会被wait语句再次阻塞。
对两种策略执行过程的分析。其中生产者线程执行6次“put(i)--打印”,消费者线程执行6次“打印get()的数据”。注意,因缓冲区初始为空,决定生产者定会先执行(若消费者先执行会因缓冲区状态而wait)。
策略1执行结果分析:
put(i)/get()方法体(粗线框定部分)以原子方式执行,见图2。生产者put(1)执行①后更改了缓冲区状态,继而执行②,消费者线程被唤醒,put(1)执行结束,生产者释放资源。若此时生产者线程的时间片恰好用完,get()操作将执行(注:此时生产信息尚未输出)。get()执行到③,更改缓冲区状态。get()操作结束,释放资源。若此时消费者时间片尚未用完,则执行④,输出。消费线程继续执行下一轮get()。执行到⑤,因缓冲区状态不符而wait(),切换到生产者线程,执行第一轮的输出,即⑥。之后开始消费者的第二轮生产(注:此时消费者已wait()),执行put(2),至⑦,改缓冲区状态,并唤醒消费者,由于时间片未用完,继续执行⑧。之后若开启第三轮生产,执行put(3),会因状态不符而wait()。故此后必定会转至消费者线程,接续⑤之后的操作,执行⑨,改状态并唤醒生产者,此后因时间片尚未用完,执行⑩。持续上述过程,直至结束。当然,策略1的执行结果是不唯一的。如生产者执行⑧之前,若时间片用完,会切换到消费者线程,输出“get 2”,之后才会输出“put 2”。
■ 图2 对策略1的执行分析
策略2执行结果分析
见图3,临界区是run中的方法体(粗线框定部分),生产者put(1)执行①后更改了缓冲区状态,继而唤醒消费者线程(注:此时尚未出临界区),并执行②,输出“put -1”。之后有两种可能。(1)生产者时间片用完,消费者执行,即get执行到③,继而执行④,输出“get-1”;(2)生产者时间片未用完,继续执行(3-a),会因状态不符而wait();之后消费者依次执行③、④,输出“get-1”。显然,无论哪种情形,都是先输出“put-1”,再输出“get-1”。
■ 图3 对策略2的执行分析
思考
若将run()写成:run(){ synchronized(ba){ for(…){…} } },输出结果会怎样?
小结:同步框架=互斥+通信,要点有三:(1)临界资源的状态决定线程执行的总体次序;(2)线程通过对临界资源的互斥存取,更改临界资源的状态;(3)通过临界资源的发出wait()/notify()等消息,阻塞/唤醒线程,即确保不该执行时不执行(即wait()),该执行时不会死等(即被notify()唤醒)。注意,wait()消息由临界资源X发出,相应地,被阻塞的线程会加到X的等待队列中;类似地,X发出的notify()/notifyAll()消息,仅会唤醒自己的等待队列中的线程。
思考
若缓冲区中用容量为n的数组存储数据,生产者每次生产x个产品(x<n),若当前缓冲区空余不足x个则停止生产;消费者每次消费至多为y个,若产品数为0则停止消费,若产品数大于等于y,则消费y个,否则将消费所有剩余产品。如何模拟上述生产-消费过程?如果存在多个生产者、多个消费者呢?
编辑