第14章,构建自定义的同步工具
14.1.1 示例:将前提条件的失败传递给调用者
因此,客户代码必须要在二者之间进行选择:要么容忍自旋导致的CPU时钟周期浪费,要么容忍由于休眠而导致的低响应性。(除了忙等待与休眠之外,还有一种选择就是调用Thread.yield,这相当于给调度器一个提示:现在需要让出一定的时间使另一个线程运行。假如正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个CPU调度时间片,那么可以使整体的执行过程变快。)
while (true){ try{ V item= buffer. take(); //对于 item 执行 一些 操作 break; }catch(BufferEmptyException ){ Thread.sleep(SLEEP_ GRANULARITY ); } }
~以上代码采用休眠的方式,另外一种方式去掉休眠的代码,无限循环中调用take方法去获取元素。第三就是将休眠的代码行换成Thread.yield(),用来让出处理器使另一个线程运行。
14.1.3 条件队列#
“条件队列”这个名字来源于:它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且Object中的wait、notify和notifyAll方法就构成了内部条件队列的API。对象的内置锁与其内部条件队列是相互关联的,要调用对象X中条件队列的任何一个方法,必须持有对象X上的锁。这是因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须被紧密地绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。
~之前只知道Object中的wait、notify和notifyAll这个三个方法,但是并不知道条件队列这个东西。当知道了条件队列后,我对以上三个方法了解更加深入了。 #另外一个需要注意的就是调用这三个方法时,先要持有对象锁。
14.2.1 条件谓词
条件谓词是使某个操作成为状态依赖操作的前提条件。在有界缓存中,只有当缓存不为空时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“缓存不为空”,take方法在执行之前必须首先测试该条件谓词。同样,put方法的条件谓词是“缓存不满”。条件谓词是由类中各个状态变量构成的表达式。
在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件队列对象(即调用wait和notify等方法所在的对象)必须是同一个对象。
每一次wait调用都会隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护保护着构成条件谓词的状态变量。
~一是说明了什么是条件谓词,二是强调了调用wait方法一定需要持有条件队列相关的锁。
14.2.2 过早唤醒
void stateDependentMethod() throws InterruptedException { // condition predicate must be guarded by lock synchronized(lock) { while (!conditionPredicate()) lock.wait(); // object is now in desired state } }
当使用条件等待时(例如Object.wait或Condition.await): ·通常都有一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试。 ·在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。 ·在一个循环中调用wait。
·确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。 ·当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。 ·在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
~使用object.wait方法的注意事项。
14.2.4 通知
由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll,那么将是一种危险的操作,因为单一的通知很容易导致类似于信号丢失的问题。
在BoundedBuffer中很好地说明为什么在大多数情况下应该优先选择notifyAll而不是单个的notify。这里的条件队列用于两个不同的条件谓词:“非空”和“非满”。假设线程A在条件队列上等待条件谓词PA,同时线程B在同一个条件队列上等待条件谓词PB。现在,假设PB变成真,并且线程C执行一个notify:JVM将从它拥有的众多线程中选择一个并唤醒。如果选择了线程A,那么它被唤醒,并且看到PA尚未变成真,因此将继续等待。同时,线程B本可以开始执行,却没有被唤醒。这并不是严格意义上的“丢失信号”,而更像一种“被劫持的”信号,但导致的问题是相同的:线程正在等待一个已经(或者本应该)发生过的信号。
只有同时满足以下两个条件时,才能用单一的notify而不是notifyAll:所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
~可以说使用notify的两个条件比较苛刻,较难满足,所以在一般都都使用notifAll。另外可以使用Condition类来创建条件队列,这样就可以规避一个对象只能有一个条件队列的限制,你可以在Lock上构建多个条件队列,每个条件队列只有一个条件谓词,这样你就可以通过Condition.signal使用唤醒在某一个条件队列休眠的线程。
14.3 显式的Condition对象
内置条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列,因而在像BoundedBuffer这种类中,多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都使得无法满足在使用notifyAll时所有等待线程为同一类型的需求。如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显式的Lock和Condition而不是内置锁和条件队列,这是一种更灵活的选择。
与内置锁和条件队列一样,当使用显式的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由Lock来保护,并且在检查条件谓词以及调用await和signal时,必须持有Lock对象。
在使用显式的Condition和内置条件队列之间进行选择时,与在ReentrantLock和synchronized之间进行选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用Condition而不是内置条件队列。
~为什么要使用了Condition?解决了我心中这个疑惑。
14.5 AbstractQueuedSynchronizer
在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。在使用CountDownLatch时,“获取”操作意味着“等待并直到闭锁到达结束状态”,而在使用FutureTask时,则意味着“等待并直到任务已经完成”。“释放”并不是一个可阻塞的操作,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState,setState以及compareAndSetState等protected类型方法来进行操作。这个整数可以用于表示任意状态。例如,ReentrantLock用它来表示所有者线程已经重复获取该锁的次数,Semaphore用它来表示剩余的许可数量,FutureTask用它来表示任务的状态(尚未开始、正在运行、已完成以及已取消)。在同步器类中还可以自行管理一些额外的状态变量,例如,ReentrantLock保存了锁的当前所有者的信息,这样就能区分某个获取操作是重入的还是竞争的。
~因为不少同步器类都是通过AQS实现的,了解了这些内容,可以加深对同步器类的理解。
14.6.3 FutureTask
在FutureTask中,AQS同步状态被用来保存任务的状态,例如,正在运行、已完成或已取消。FutureTask还维护一些额外的状态变量,用来保存计算结果或者抛出的异常。此外,它还维护了一个引用,指向正在执行计算任务的线程(如果它当前处于运行状态),因而如果任务取消,该线程就会中断。
~了解一下FutureTask内部实现情况。