线程同步
Java语言的优点之一就是他在语言级别上对多线程的支持。这些支持多集中在于同步(synchronization):多线程之间的协作活动和数据访问。Java所使用的支持同步的机制是监控器(monitor)。本章描述这些监控器以及他们如何被JVM使用。并且从JVM指令集描述监控器的加锁和解锁。
监控器(Monitors)
Java监控器支持两种类型的线程同步:互斥(mutual exclusion)和协作(cooperation)。互斥,JVM通过对象锁(Object Locks)使得不同线程相互独立并且互不干扰的操作共享数据;协作,JVM通过类Object的wait和notify方法使得多个线程一起协作完成一个共同的目标。
监控器就像一座含有一个特殊房间大楼,这个房间一次仅仅能被一个线程占用,并且房间里通常含有一些数据。从一个线程进入房间到他离开,他对这个房间的任何数据都有单独排外的访问权限。进入监控大楼称为“entering the monitor”。进入特殊房间称为“acquiring the monitor”。占用房间称为“owning the monitor”。离开房间称为“releasing the monitor”。离开整栋大楼称为“exiting the monitor”。
监控器除了监控相关数据外,他还监控相关代码(我们称之为临界区(monitor regions))。临界区也就是我们需要同步的代码块。监控器强制使得一次只有一个线程可以执行临界区代码直到结束(也就是原子操作,相当于数据库中的事务transaction要么一次性做完,要么不做)。线程执行临界区代码的唯一方法是获得监控器(acquiring the monitor)。
当一个线程执行到临界区的开始位置,他会被放到和监控器关联的入口集合entry set。入口集合类似监控大楼里面的前走廊。如果没有线程在entry set里面等待且没有线程当前拥有监控器(owning the monitor),那么该线程获得监控器(acquiring the monitor)然后继续执行临界区代码。当线程执行完临界区代码,他就释放并退出监控器(release and exit the monitor);如果线程进入entry set时,监控器已经被其他线程占有,那么新抵达的线程就必须在entry set里等待,当占有监控器的线程执行完并退出监控器时,新抵达的线程必须和其他在entry set里等待的线程竞争被释放的监控器。有且仅有一个能获得。
互斥(mutual exclusion)一般用于多线程互斥的访问临界区。一般是在多个线程共享内存数据或其他资源时,互斥模型相当重要。
另一种同步模型是协作(cooperation)。互斥是让多个线程共享数据时互不干扰,而协作则是帮助多个线程一起工作完成一个共同的目标。
当一个线程需要达到某个状态的数据而另一个线程加好负责让这些数据达到这个状态时,协作就显得异常重要了。例如,一个读线程可能从一个缓冲区buffer读数据,另一个写线程负责往buffer中填充数据。读线程读的条件是buffer里面非空,如果读线程发现buffer是空,那他必须等待,写线程负责填充buffer前提是buffer未满。这样写线程写一点,读线程就读一点。
这种形式在JVM中称为Wait andNotify monitor。(也被叫做Signal and Continue)这种类型的监视器,当一个线程拥有监视器并正在执行时可以通过wait方法将自己挂起。当一个线程执行wait时,他会释放monitor并且进入wait set。自我挂起的线程被唤醒当且仅当其他线程执行notify之后。但是,线程执行notify会继续持有monitor直到他自己释放monitor(要么执行wait,要么执行完临界区monitor region)。在notify线程释放monitor之后,waiting线程将会重新获得monitor。
这种JVM监视器有时候也叫Signaland Continue 监视器,因为一个线程执行notify(the signal)后仍然会持有monitor继续执行临界区(Continue)。某段时间过后,notifying线程释放monitor同时waiting线程重新恢复。Waiting线程通常挂起自己是因为被监视器保护的数据还没有达到该线程继续工作的状态(state)。同样的,notifying线程会执行notify当他使得被保护的数据达到waiting线程想要的状态。但是因为notifying线程还会继续,他就可能会再次改变数据状态以至于waiting线程仍然无法工作。还有一种可能是,第三个线程在notify释放后获得monitor,第三个线程可能会改变被保护数据的状态。因此,notifying线程发出notify信号只能作为waiting线程的一种提示(想要的状态可能存在)而不是绝对存在。每一次waiting线程重生,他必须还要检查数据状态来决定是否向前工作。如果数据仍然不可用,他会再次执行wait或者直接放弃。也就是说检查和操作必须是一起的原子操作。
下图是对上述monitor的一个阐述。在中间的矩形只有一个线程,也就是monitor的owner。左边的小矩形含有入口集合entry set。右边的小矩形含有等待集合wait set。灰色的表示激活状态的线程,挂起(阻塞)状态的线程为灰色。
图片展示了几个标有序号的门,线程要想和监视器交互,必须先通过这些门。当一个线程抵达临界区的开始,他会通过门①进入monitor。找到自己在entry set中的位置。如果当前既没有拥有监视器的线程也没有其他在entry set等待的线程,那么该线程迅速通过门②占有监视器开始执行;如果当前还有其他线程正在声讨monitor,那么这个新抵达的线程就必须在entry set中等待而进入阻塞。
上图中有3个在entry set中和4个在wait set中挂起的线程。他们指导宿主线程释放monitor才能出来。当前宿主线程释放monitor的方式有两种:执行完临界区和调用wait命令。如果他执行完临界区,他将从底部的门⑤退出。如果他执行wait,他将从门③进入wait set。
如果前任宿主不执行notify,那么只有在entryset中的线程可以竞争monitor。如果执行notify,那么所有阻塞线程一起竞争monitor。
在JVM中,线程在执行wait时还可以选择一个超时设置。如果有设置,那么在时间到达之前没有其他线程会执行notify,waiting线程会接受来自JVM的notify信号。
说点外话,JVM从entry set 和wait set选择下一个线程取决于JVM的具体实现。不同的JVM实现不一样。也就是说是编程者不可知的。作为编程者,你不能依赖任何具体的选择算法或者优先级,你应该试着去编写平台无关的程序。例如,你不知道wait set中的线程是以何种顺序被JVM选择调度,那么只有当你完全确定只有一个线程在wait set中时,你才用notify命令。如果wait set中有超过一个线程,那么你就应该用notifyAll。否则,某些线程可能会被阻塞很长时间都得不到执行甚至总是都无法执行,因为你不知道JVM的调度算法是什么。
Object Locking
对象锁(锁就是我们所说的监视器模型的具体实现)
在JVM中,每个对象和类逻辑上关联着一个监视器monitor。对于对象,关联的monitor保护着对象实例变量。对于类,monitor保护着类的类成员变量。
为了实现monitor的互斥能力,JVM让每一个类和对象都关联着一把锁(lock)。一把锁某一时刻只有一个线程能拥有。线程访问对象实例或者类变量可以不加锁直接访问。但是一旦有一个线程获得锁,那么其他线程就无法访问加在相同数据上的锁直到这个线程释放锁。(锁住对象(lock an object)等同于获得关联该对象的监视器(acquiring the monitor))
类锁(Class Lock)本质上也是对象锁。JVM加载每一个类的时候,都会创建一个java.lang.Class的实例,当你锁住一个类时,实际上锁的就是这个类的Class对象。
单个线程允许多次锁住同一个对象。JVM为每一个对象维护一个对象被锁次数的计数器count。一个没有被锁的对象的count是0。当一个线程第一次获得锁,count自增1。
线程每次获得相同对象上的锁,count就自增1.(只有已经获得对象锁的线程允许再一次锁住该对象,及内部锁可重入;其他线程要想获得锁必须等到锁释放。)线程每释放一次锁,count就自减1,当count为0时,其他线程才可以获得该锁。
当线程执行到临界区开头就会请求一把锁。在JVM有两种类型的临界区:synchronized块和synchronized方法。每一个临界区和一个对象引用相关联。当线程执行临界区第一条指令时,他必须获得关联对象的锁。当线程离开同步块,不管是如何离开的,都要释放锁。