java并发编程之四、互斥
前面说了并发任务之间的分工和协作,现在说并发任务之间同样很重要,甚至更重要的一个方面,互斥。因为分工、协作和互斥这三个方面,从重要性上来讲,或许可以三分天下,但从复杂性和可探讨性来讲,互斥显然更胜一筹,对互斥的深入使用,更加体现了一个人的并发编程能力。
互斥,即同一时间只能有一个并发任务可以对数据的进行访问。大多数编程语言在这里都使用的锁机制,java自然也不例外,当然java中提供了多种互斥机制,不过是锁的不同实现。
一、Synchronized
synchronized可以说是java中所最为人知的互斥机制,synchronized关键字既可以用来修饰方法,也可以用来修饰代码块,通过synchronized关键字,编译器会相应的在方法或代码块的前后自动加上加锁lock()和解锁unlock(),这样无需手工加锁,也保证了加解锁的成对出现。
另外,需要注意的是,加锁,总是要锁在一个对象上,那么synchronized关键字所加锁的对象是什么了?
* 修饰静态方法,这时候加锁的对象是当前类的Class对象,也可以用synchronized(A.class)来显示实现,不过这样就要小心,所有对这个类的访问可能都需要加解锁,是否你真的是想这样。
* 修饰非静态方法,这时候加锁的对象是当前类的实例对象,也可以用synchronized(this)来显式实现,同样要小心,你是否希望对这个实例的所有同步方法之间都进行互斥。
* 修饰对象,这时候加锁的对象自然是所修饰的这个对象,所有对这个对象加锁的代码之间是互斥的,这里要确保所加锁的对象是不变的,比如synchronized(new Object()),这自然也是加锁的对象,但由于每次都创建新的对象,则完全达不到数据访问互斥的目的。一般而言,对不同的数据资源的访问,可以创建不同的锁对象,这种机制可以称为细粒度锁。
虽然总体上看,synchronized的使用还是比较简单的,如果保护一个资源,直接用就可以,如果分析出有多少个资源需要保护,但资源之间没有关系,那么每个资源一把锁也可以,但如果多个资源之间有关系,则需要多个资源使用同一把锁。也别忘了确认资源的访问路径,所有的访问路径都需要加锁。即便是synchronized的这么简单,要做到锁的范围恰到好处,也不是那么容易。既不因为锁的范围少了导致互斥失效,也不因为锁的范围太大,导致程序效率太差。
二、Lock、Condition、ReentrantLock
由于synchronized关键字已经提供了对共享资源的互斥访问,为什么又要提供一个Lock/Condition机制了?
其实看看Lock的接口就知道了。
1 void lock() 2 Acquires the lock. 3 void lockInterruptibly() 4 Acquires the lock unless the current thread is interrupted. 5 Condition newCondition() 6 Returns a new Condition instance that is bound to this Lock instance. 7 boolean tryLock() 8 Acquires the lock only if it is free at the time of invocation. 9 boolean tryLock(long time, TimeUnit unit) 10 Acquires the lock if it is free within the given waiting time and the current thread has not been interrupted. 11 void unlock() 12 Releases the lock.
除了与synchronized相同的lock()和unlock()方法外,Lock也提供了可中断的获取锁的lockInterruptibly()方法,以及非阻塞获取锁的tryLock()方法,和支持超时的获取锁的tryLock(long,TimeUnit)方法,而这三个方法都是在提高性能、灵活性和解决死锁方面有非常大的帮助,这也是Lock接口在synchronized关键字之后依然出现的原因。
ReentrantLock是Lock的一个实现类,一般在使用Lock的时候,实例化的是ReentrantLock类型的对象。ReentrantLock是可重入锁,亦即当已经持有一个锁实例之后,当再次申请加锁时,依然可以加锁成功。如果是不可重入锁,则会在再次获取锁时阻塞。但其实java并没有提供不可重入锁,synchronized关键字本身也是可重入的。
在使用ReentrantLock的过程中,就不得不涉及到公平锁和非公平锁的概念,所谓公平锁,即在唤醒等待线程时选择等待时间最长的线程,体现了先到先得的概念,而非公平锁,则在唤醒时不按照等待时间优先,可能随机选择一个线程,而这个线程可能是等待时间最短的线程。但是否一定就要选择公平锁了,也不一定,也要对照具体情况分析,因为公平锁虽然公平,但是会影响效率,反映到业务上就是会影响容量,但选择非公平锁,效率是上来的,但有可能有的线程会一直得不到服务,出现饥饿现象。
在使用synchronized的过程中,只要你加上了这个关键字,java会在后台帮你加上加锁和解锁的语句,但是使用Lock,则需要自己来实现加锁和解锁,如果没有合适的解锁,可能会导致意想不到的的问题,所以在Lock的使用中,也有一个要遵循的最佳实践:try{}finally{}法则,即在finally中进行锁的释放。
其实无论使用synchronized关键字来实现互斥,还是用Lock来实现,影响程序性能的另一个问题就是“当且仅当”在必要的时候对共享对象加锁,有大师总结了加锁的三个原则:
* 永远只在更新对象的成员变量时加锁
* 永远只在访问可变的成员变量时加锁
* 永远不在调用其他对象的方法时加锁
其中第1项和第2项都很好理解,为何有第三个原则?这里的其他对象可以是自己写的其他对象,也可能是调用的第三方的对象,这里的重点在于其他对象的方法对于你而言是个黑盒,你不知道在其他对象的方法中做了什么,是否有耗时操作,是否有加锁操作,如果你根本就不了解其他对象的方法中做了什么是怎么做的,贸然加锁,则很大可能会造成性能损坏或者死锁。
Condition的出现,犹如synchronized关键字使用时的object,Condition中最常用的方法即是await,signal和signalAll,犹如Object类型的wait,notify和notifyAll,但千万不要用混了。Condition的优势在于,它可以在一个Lock对象中衍生出很多个Condition对象,而不是像synchronized只能对一个对象进行wait和notify。具体可以参加java源码中对阻塞队列的实现。
三、ReentrantReadWriteLock
面向业务编程要求总是根据实际的需求来决定技术路线,虽然ReentrantReadWriteLock的父类是ReadWriteLock,而并非Lock,但是在ReentrantReadWriteLock在实际使用中涉及的ReentrantReadWriteLock.ReadLock和ReentrantReadWriteLock.WriteLock却都是Lock的子类,这个类型面向的业务场景是读多写少的业务场景,比如缓存系统。
ReentrantReadWriteLock也具有一些特性:
* 公平锁和非公平锁,基本的语义和上面提及的类似,但是涉及到写锁和读锁,则有不同。如果被构造为公平锁,则会按照请求到底的顺序来分配锁,如果只有一个等待的写锁,则分配给它写锁,如果有一组读锁等待的时间比写锁更长,则分配读锁。在公平锁下,一个线程如果想要获取读锁,则必须满足持有的写锁或者等待的写锁已经释放;一个线程如果获取写锁,则必须等待所有的读锁和写锁都释放。
* 可重入锁,读锁和写锁都支持可重入。
* 锁降级,当持有写锁的时候,可以获取读锁,这种操作称为锁降级;但相反的,没有锁升级,即不允许在持有读锁的时候获取写锁,这时候获取写锁,只会造成永久阻塞,需要避免。
* 锁打断,读锁和写锁都支持获取时打断,即比较好的避免死锁。
* 支持Condition,只有写锁支持Condition,读锁不支持。
四、StampedLock
StampedLock类不是Lock类的一个实现,也不是ReadWriteLock类的实现,而是一个单独的类,虽然功能上和ReadWriteLock比较像,另外,StampedLock是不可重入的,也不支持Condition变量。
前面说了StampedLock和ReadWriteLock比较像,那么StampedLock依然还能够被作为java的工具类,自然有其必要性。
StampedLock的三个主要的接口方法是,writeLock()、
readLock()
和tryOptimisticRead()。前面两个会获取写锁和读锁,类似ReadWriteLock,区别即在于最后的“乐观读”,请注意,这里是“乐观读”,而不是“乐观读锁”,表明这里是没有锁的,所以显而易见,“乐观读”以一种无锁的方式效率自然比读锁的效率要高。
StampedLock出现的初衷即在于编写线程安全的内部组件,而不是用来进行大量的业务编码,所以在使用上,一般而言,ReadWriteLock可能就足够了。
StampedLock的最佳实践可以参考官方给出的例子,如何使用写锁,如何使用读锁,如何使用乐观读,乐观读如何升级为读锁:
1 class Point { 2 private double x, y; 3 private final StampedLock sl = new StampedLock(); 4 5 void move(double deltaX, double deltaY) { // an exclusively locked method 6 long stamp = sl.writeLock(); 7 try { 8 x += deltaX; 9 y += deltaY; 10 } finally { 11 sl.unlockWrite(stamp); 12 } 13 } 14 15 double distanceFromOrigin() { // A read-only method 16 long stamp = sl.tryOptimisticRead(); 17 double currentX = x, currentY = y; 18 if (!sl.validate(stamp)) { 19 stamp = sl.readLock(); 20 try { 21 currentX = x; 22 currentY = y; 23 } finally { 24 sl.unlockRead(stamp); 25 } 26 } 27 return Math.sqrt(currentX * currentX + currentY * currentY); 28 } 29 30 void moveIfAtOrigin(double newX, double newY) { // upgrade 31 // Could instead start with optimistic, not read mode 32 long stamp = sl.readLock(); 33 try { 34 while (x == 0.0 && y == 0.0) { 35 long ws = sl.tryConvertToWriteLock(stamp); 36 if (ws != 0L) { 37 stamp = ws; 38 x = newX; 39 y = newY; 40 break; 41 } 42 else { 43 sl.unlockRead(stamp); 44 stamp = sl.writeLock(); 45 } 46 } 47 } finally { 48 sl.unlock(stamp); 49 } 50 } 51 }
五、Semaphore
Semaphore也是用来实现线程互斥访问共享变量的类,类似于计算机操作系统课程中学校的PV操作。Semaphore在构建的时候需要传入一个数值,表述此实例所持有的信号量的多少。
在开始使用信号量的时候,要先获取信号量,其主要的接口方法为acquire()/acquire(int permits),这两个方法都是阻塞方法,如果对象中已有的信号量不满足要求,则会阻塞,另外有非阻塞的tryAcquire()/tryAcquire(int permits),以及支持超时的tryAcquire(long timeout, TimeUnit unit)/tryAcquire(int permits, long timeout, TimeUnit unit)。
在使用完毕,则需要归还信号量给对象,对应的是release()/release(int permits)操作。
使用信号量Semaphore,可以很容易的实现一个限流器,比如一个教室只允许五个学生进行,多于五个,则必须等待,直到有其他人退出教室。
六、死锁 活锁 饥饿
加锁如果加成了死锁,简直就完美了,估计这个情况也是加锁情况下最不愿意见到的情形之一,给死锁一个定义:一组相互竞争资源的线程因为相互等待,导致“永久”阻塞的现象。那么,什么情况下会发生死锁了?Coffman给出了一组条件,满足这四个条件,就可能会发生死锁:1.互斥,共享资源只能被一个线程占用;2.占有且等待,线程在占有共享资源A的并等待共享资源B的时候,不释放共享资源A;3.不可抢占,其他线程不能抢占别的线程占有的共享资源;4.循环等待,既线程1占有资源A等待资源B,线程2占有资源B等待资源A。
要解决死锁问题,当然也可以从上面这四方面入手,破坏了任何一个条件,则就不会发生死锁:
1.“互斥”本身就是锁所存在的基础,所以可以从其他三个条件出发。
2.破坏“占有且等待”,简单办法就是一次性申请所有的资源,如果不能申请到所有的资源,则不持有任何资源。
3.破坏“不可抢占”,虽然不能让别的线程抢占当前线程的资源,但是当前线程在持有资源一段时间之后,如果没有申请到所有的资源,可以主动释放掉持有的资源。这种破坏,通过synchronized显然无法实现,但是通过lock则可以轻松实现。
4.破坏“循环等待”,意即线程1申请资源A和B,线程2也是申请资源A和B,而不是线程1申请资源A和B,线程2申请B和A。将原先循环申请,改变为所有的参与方都按照一个顺序来申请资源。最简单的实现,比如多个资源进行排序并保证每次排序的结果一致,然后每个线程在申请资源的时候就先对资源进行排序,然后按照排序结果对多个资源进行申请。
现实中,除了“死锁”,还可能存在“活锁”,“活锁”指的是线程没有发生阻塞,但存在执行不下去的情况。比如线程1占有资源A,尝试获取资源B,获取失败,放弃资源A,然后重试;线程2占有资源B,尝试获取资源A,获取失败,放弃资源B,然后重启;有可能线程1和2都在不断的尝试,但一直无法成功。破解之法也简单,即在重试之前等待随机时间,这样,再次碰撞的几率就会降低。
另外,还存在“饥饿”,指线程无法获得所需资源而无法执行下去,比如CPU繁忙的情况下,优先级低的线程可能会得不到执行的机会;在比如,持有锁的线程如果执行的时间很长,也可能导致等待线程发生“饥饿”。要破解饥饿,可以增加资源,保证供应充足,也可以避免持有锁的线程执行时间长,但这两种方案在实际环境中往往不可实施,那么就有一种不是很厉害但是有效的方案,即使用公平锁,根据线程的等待时间来调度。