码迷,mamicode.com
首页 > 编程语言 > 详细

第20章 线程同步

时间:2019-12-03 23:01:31      阅读:80      评论:0      收藏:0      [点我收藏+]

标签:order   排他性   检查   ret   编程   引用   field   oid   获得   

不过,大多数情况下,监视器保护那些通过监视区域代码来访问的数据,在这种情况下一即要求数据仅可以由监视区域访问,监视器可以确保线程会互斥地访问这些数据。

另一种我们提到的被监视器所支持的同步是协作。互斥帮助线程在访问共享数据时不被其他线程干扰,而协作帮助线程与其他线程共同工作。

当一个线程需要一些特别状态的数据,而由另一个线程负责改变这些数据的状态时,同步就显得特别重要。举个例子,一个“读线程”会从缓冲区中读数据,而另一个“写线程”会向缓冲区中填充数据。“读线程”需要缓冲区处于一个“非空”的状态,这样它才可以从中读数据, 如果“读线程”发现缓冲区是空的,它就必须等待。“写线程”就负责向缓冲区中写数据,只有 “写线程”完成了一些数据的写入,“读线程”才能做相应的读取动作。

Java虚拟机所使用的这种监视器被称作"等待并唤醒”监视器(有时也被称作“发信号并继续”监视器)。在这种监视器中,一个已经持有监视器的线程,可以通过执行一个等待命令,暂停自身的执行。当线程执行了等待命令后,它就会释放监视器,并进入一个等待区,这个线程会在那里一直持续暂停状态,直到一段时间后,这个监视器中的其他线程执行了唤醒命令。当一个线程执行了唤醒命令后,它会继续持有监视器,直到它主动释放监视器,如执行了—个等待命令或者执行完监视区域。当执行唤醒的线程释放了监视器后,等待线程会苏醒,并重新获得监视器。

Java虚拟机中的这种监视器有时也被称作“发信号并继续”监视器的原因是,在一个线程做了唤醒操作(发信号)后,它还会继续持有监视器并继续执行监视区域(继续),过了一些时间之后,唤醒线程释放监视器,等待线程才会苏醒。推断起来,等待线程将自身挂起是因为监视器保护数据并不处于它想要继续执行正确操作的状态。同样,唤醒线程在它将监视器保护数据置成等待线程想要的状态后执行唤醒命令。但是因为唤醒线程会继续执行,它可能会在执行唤醒后又修改了数据的状态,让等待线程不能够继续工作。另一种情况是,第三个线程可能在唤醒线程释放了监视器,而等待线程还没有获得监视器之前抢先获得了监视器,而且这个线程可能会修改监视器保护数据的状态。因为以上事实,一次唤醒往往会被等待线程看作是一次提醒, 告诉它“数据已经是你想要的状态了”。每次等待线程苏醒的时候,它都需要再次检查状态,以 确定是否可以继续完成工作。如果数据不是它所需要的状态,这个线程可能会再次执行等待命令或者放弃等待退出监视器。

例如,再考虑我们上面提到过的场景:一个缓冲区,一个读线程,一个写线程。假定缓冲区是由某个监视器所保护的,当读线程进入这个监视器时,它会检查缓冲区是否是空的,如果缓冲区不是空的,读线程会从中读取(并且删除)一些数据,然后退出了监视器。而如果这个缓冲区是空的,读线程会执行一个等待命令,同时它会暂停执行并被放入等待区。这样,读线程释放了监视器,让它变得对于其他线程来说是“可用“的。稍后,写线程进人了监视器,向缓冲区中写了一些数据,然后执行唤醒,并退出监视器。当写线程执行了唤醒后,读线程被标志为”可能苏醒“,当写线程退出监视器后,读线程被唤醒并成为监视器的持有者。如果存在其 他线程先进入了监视器并“消耗”完写线程留下的数据的情况,这个读线程必须做明确检查,确保缓冲区不是空的。如果不存在这种情况,读线程可以假定数据已经存在,这样读线程会从缓冲区中读取(并删除)一些数据,然后退出监视器。

Java虚拟机中的这种监视器模型如图20-1所示,将监视器分成了三个区域。中间的大方框包括一个单独的线程,是监视器的持有者;左边小的方框中是入口区;右边另一个小方框是等待区。活动线程用深灰色圆画出,暂停的线程用浅灰色圆画出。

图20-1也显示了线程与监视器交互所必须“通过”的几道门。当一个线程到达监视区域的开始处时,它会通过最左边的1号门进入监视器,发现自己身处在那个叫入口区的方框中。如果没有任何线程正持有监视器,也没有其他线程正在入口区中等待,这个线程就会立刻通过下一道门-2号门,并持有监视器。作为这个监视器的持有者,它将继续执行监视区域中的代码。或者也可能出现另一种情况,已经有另一个线程正持有监视器,这个新到达的线程就必须在入口区中等待,很可能那里已经有一些线程在等待了,这个线程会被阻塞,所以不能执行监视区域中的代码。

图20-1中有3个线程在人口区中暂停,有4个线程在等待区中暂停,这些线程会一直在那儿, 直到监视器当前的持有者(即活动线程)释放监视器。活动线程会通过两条途径释放监视器: 完成它正在执行的监视区域或者执行一个等待命令。如果它执行完监视区域,它会通过中间方框下方的5号门退出监视器。如果它执行了等待命令,它会通过3号门进人等待区,从而释放监 视器。

如果上一个监视器的持有者在它释放监视器前没有执行唤醒命令(同时在此之前也没有任何等待线程被唤醒并等待苏醒),那么位于入口区中的那些线程将会竞争获得监视器。如果上一个持有者执行了唤醒命令,入口区中的线程就不得不与一个或多个等待区中的线程来竞争。如果入口区中的一个线程在竞争中获胜,它就会通过2号门,从而成为监视器的新的持有者。而如果是等待区中的某个线程贏了,它会通过4号门退出等待区并重新获得监视器。注意,一个线程只有通过3号和4号门才能进人或退出等待区。一个线程只有在它正持有监视器时才能执行等待命令,而且它只能通过再次成为监视器的持有者才能离开等待区。
在Java虚拟机中,线程在执行等待命令时可以随意指定一个暂停时间。如果一个线程指定了暂停时间,而且在暂停时间截止之前没有其他线程执行唤醒命令,这个等待线程会从虚拟机得到一个自动唤醒的命令。也就是说,在暂停时间到了之后,即使没有来自其他线程的明确的唤醒命令,它也会自动苏醒。

Java虚拟机提供了两种唤醒命令:“notify"和“notify all”。notify命令随意从等待区中选择一个线程并将其标志为“可能苏醒”,而notify all命令会将等待区中的所有线程都标志成“可能苏醒”。

Java虚拟机如何从等待区以及入口区选择下—个线程来执行,在很大程度上取决于java虚拟机的设计者。比如说,设计者可以在以下方面做出选择:

•哪一个等待区中的线程将获得notify命令。

•使用notify all命令后,位于等待区的线程依次苏醒的顺序。

•允许入口区的线程获得监视器的顺序。

•在得到一个notify命令后,在人口区(与等待区对比)中如何选择线程。

可以设想使用先入先出(FIFO)队列来管理入口区和等待区比较合理,那么在等待区排队时间最长的那个线程将首先获得监视器。或者,也可以选择实现10个先入先出队列,分别为Java虚拟机中每一种线程优先级服务。虚拟机会选其中有等待线程的、优先级最高的队列中等待时间最长的那一个。实现可能采取这些方法,但是你不能假设它釆取某种方法。实现可以自由选择, 它可能是按照后进先出(LIFO)来管理队列,或者选择更低优先级的线程,而不是选择更高优先级的线程,也可能做出任何其他没有意义的决定。总之,实现可以任意自由选择哪一个线程。

程序员必须不依赖任何特定的有关优先级的算法或者安排,至少在编写平台无关的java程序时应该这样。比如说,因为不知道notify命令将会导致等待区中的哪一个线程苏醒,只有当绝对确认只会有一个线程在等待区中挂起的时候,才应该使用notify (相对notify all而言)。只要存在同时有多个线程在等待区中被挂起的可能性,就应该使用notify all。否则,在某些Java虚拟机实现中可能导致某个特定的线程在等待区中等待非常长的时间。如果虚拟机采取最后到达等待区的 线程总是被notify优先选择的话,早就开始在等待区等待的那些线程可能永远不会有机会苏醒。

20.2对象锁

在前面的章节中提到过,Java虚拟机的一些运行时数据区会被所有的线程共享,其他的数据是各个线程私有的。因为堆和方法区是被所有线程共享的,Java程序需要为两种多线程访问数据进行协调:

•保存在堆中的实例变量。

•保存在方法区中的类变量。

程序不需要协调保存在Java栈中的局部变量,因为Java栈中的数据是属于拥有该栈的线程私有的。

在Java虚拟机中,每个对象和类在逻辑上都是和一个监视器相关联的。对于对象来说,相关联的监视器保护对象的实例变量。对于类来说,监视器保护类的类变量。如果一个对象没有实例变量,或者一个类没有类变量,相关联的监视器就什么都不监视。

为了实现监视器的排他性监视能力,Java虚拟机为每一个对象和类都关联一个锁(有时候被称为互斥体(rmitex))。一个锁就像一种任何时候只允许一个线程“拥有”的特权。线程访问实例变量或者类变量不需要获取锁。但是如果线程获取了锁,那么在它释放这个锁之前,就没有其他线程可以获取同样数据的锁了。(“锁住一个对象”就是获取对象相关联的监视器。)

类锁实际上用对象锁实现。前几章提到过,当java虚拟机装载一个class文件的时候,它会创建一个java.lang.Class类的实例。当锁住一个类的时候,实际上锁住的是那个类的Class对象。

一个线程可以允许多次对同一个对象上锁。对于每一个对象来说,Java虚拟机维护一个计数器,记录对象被加了多少次锁。没有被锁的对象的计数器是0。但一个线程第一次获得锁的时候, 计数器跳到1。线程每加锁一次,计数器就加1。(只有已经拥有了这个对象的锁的线程才能对该 对象再次加锁。在它释放锁之前,其他的线程不能对这个对象加锁。)每当线程释放锁一次,计数器就减1。当计数器跳到0的时候,锁就被完全释放了,其他的线程才可以使用它。

Java虚拟机中的一个线程在它到达监视区域开始处的时候请求一个锁。在java中,有两种监视区域:同步语句和同步方法(这些在本章后面会详细描述)。Java程序中每一个监视区域都和 一个对象引用相关联。当一个线程到达监视区域的第一条指令的时候,线程必须对该引用对象加锁,否则线程不允许执行其中的代码。一旦它获得了锁,线程就进入了被保护的代码。当线程离开这块代码的时候,不管它是如何离开的,它都会释放相关对象上的锁。

注意,Java编程人员不需要自己动手加锁,对象锁是在Java虚拟机内部使用的。在Java程序中,你只需要编写同步语句或者同步方法就可以标志一个监视区域。当Java虚拟机运行你的程序的时候,每一次进人一个监视区域的时候,它每次都会自动锁上对象或者类。

20.3指令集中对同步的支持

如前所述,语言提供了两种内置方式来标志监视区域:同步语句和同步方法。这两个机制实现了同步的互斥,是被Java虚拟机的指令集支持的。

20.3.1同步语句

要建立一个同步语句,在一个计算对象引用的表达式中加上synchronized关键字就可以了, 就像下面的reverseOrder ()方法这样:

在上面的例子中,如果没有获得对当前对象(this)的锁,在同步语句块内的语句是不会被执行的。如果使用的不是this引用,而是用一个表达式获得对另一个对象的引用,在线程执行语句体之前,需要获得那个对象的锁。如果用表达式获得对Class类实例的引用,就需要锁住那个类。 方法内的同步语句块会使用monitorenter和monitorexit这两个操作码。这些操作码在表20-1中。
表20-1监视器
操作码 操作数 描述
monitorenter 无 弹出objectref(对象引用),获得和objectref相关联的锁
monitorexit 无 弹出objectref,释放和objectref相关联的锁

当Java虚拟机遇到monitorenter的时候,它获得栈中objectref所引用的对象的锁。如果线程已 经拥有了那个对象的锁,锁的计数器会加1。线程中每条monitorexit指令都会引起计数器减1。当计数器变成0的时候,监视器就被释放了。

下面是KitchenSync类的reverseOrder ()方法生成的字节码序列:

// How acquire the lock on the referenced object
// Push local var 1 (the this reference; the
2 aload_l // object to lock)
// Pop reference, acquire the lock
3 monitorenter // on referenced object

// The code of the synchronized block begins here. A thread will not
// execute the next instruction, aload_0, until a lock has been
// successfully acquired on the this reference above.

4 aload_0 // Push the object ref at loc var 0 (the this ref)
// Pop object ref, push ref to instance variable
// intArray

5 getfield #4 <Field int intArray[]>

// The code of the synchronized block ends here
// The next two instructions unlock the object, making it available
// for other threads.The reference to the locked object was stored
// in local variable 1 above.

71 aload_l // Push local var 1 (the this reference)
72 monitorexit // Pop ref,unlock object
73 return // return normally from method

// This is a catch clause for any exception thrown (and not caught
// from within the synchronized block. If an exception is thrown,
// the locked object is unlocked, making it available for other
// threads.
74 aload_l // Push local var 1 (the this reference)
75 monitorexit // Pop ref, unlock object
76 athrow // rethrow the same exception

// Thd exception table shows the "catch all" clause covers the
// entire synchronized block, from just after the lock is acquired
// to just before the lock is released.

Exception table:
from to target type
4 71 74 any

注意,catch子句用来确保被加锁的对象将被释放(解锁),即使从同步语句块中抛出异常。 不管被同步的语句块是如何退出的,线程进人这个块时获得的锁总是一定会被释放的。

20.3.2同步方法

要同步整个方法,只需要在方法修饰符中加上synchronized关键宇,如同下例所示:
...
Java虚拟机调用同步方法或者从同步方法中返回没有使用仟何特别的操作码。当虚拟机解析对方法的符号引用时,它判断这个方法是否是同步的。如果是同步的,虚拟机就在调用方法之前获取一个锁。对于实例方法来说,虚拟机在方法将要被调用的时候获取对象相关联的锁。对于类方法来说,它获取方法所属的类的锁(其实是对Class对象上锁)。当同步方法执行完毕的时候,不管是正常结束还是抛出异常,虚拟机都会释放这个锁。

下面是javac生成的HeatSync的reverseOrder ()方法的字节码:
...
如果把这段字节码和上面KitchenSync的reverseOrder ()方法的相比较,会发现这些字节码 更加高效,因为这段代码没有进入和离开监视器的代码。HeatSync字节码中偏移量0到56对应 KitchenSync字节码中偏移量4到68的指令。因为HeatSync的reverseOrder ()方法不包含用于保存加锁对象的局部变量,方法每一次使用的局部变董的位置都是不固定的。但是这两段这些指令的功能是完全一致的。

两个reverseOrder ()方法的另外一个区别是编译器没有为HeatSync的reverseOrder ()方法创建异常表。在HeatSync的例子中,异常表不是必须的。当方法被调用的时候,Java虚拟机自动获取这个对象的锁。如果这个方法异常退出,如同正常退出一样,虚拟机会自动释放这个对象的锁。

同步类方法(静态方法)和上面例子中的同步实例方法以同样的方式操作。不同之处是类方法不使用this(类方法没有this),线程必须获得对应的Class实例的锁。

20.4 Object类中的协调支持

Object类声明了5个方法,程序员可以用来访问Java虚拟机同步的协调支持。这些方法都是被声明成公开的(public)和最终的(final),所以它们被所有的类继承。只有在同步方法或者同步语句中才能调用这些方法。换句话说,在这些方法被调用的时候,相关联的对象必须已经被加锁了。这些方法在表20-2中列出。

表20-2 Object类的wait和notify方法

方法 描述
void wait () 进人监视器的等待区,直到被其他线程唤醒
void wait (long timeout);
进人监视器的等待区,直到被其他线程唤醒,或者经过timeout所指定的

毫秒数后自动苏醒
void wait (long timeout, int nanos)

进入监视器的等待区,直到被其他线程唤醒,或者经过timeout所指定的毫

秒数加上nanos所指定的纳秒数后自动苏醒
void notify(); 唤醒监视器的等待区中的一个等待线程(如果没有线程在等待,就什么都不干)
void notifyAll(); 唤醒监视器的等待区中的所有线程(如果没有线程在等待,就什么都不干)

 

 

第20章 线程同步

标签:order   排他性   检查   ret   编程   引用   field   oid   获得   

原文地址:https://www.cnblogs.com/mongotea/p/11980092.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!