- 更灵活的方式构造同步代码块。使用sychronized关键字只能以一种结构方式来控制同步代码块,但是,Lock接口允许更加复杂的结构来实现临界区。
- 相比synchronized关键字,Lock接口还提供附加的功能。其中一个新功能是通过tryLock()方法实现,这个方法尝试去控制锁,如果此方法被其它线程使用而无法控制的话,返回false。使用synchronized关键字,如果线程A尝试运行一段线程B正在执行的同步代码块,线程A将暂停直到线程B结束同步块的执行。使用锁机制,即可运行tryLock()方法,这个方法在判断是否有其它线程正在运行被锁保护的代码时返回布尔值。
- ReadWriteLock接口允许多个访问者和一个修改者进行读写分离操作。
- Lock接口性能优于synchronized关键字。
ReentrantLock类的构造函数包含一个名为fair的boolean型参数,用来控制其行为。此参数默认值为false,称为非公允模式,在此模式下,如果一些线程在等待需要选择其中一个线程来访问临界区的锁时,它会随机选择一个线程。参数值为true时称为公允模式,在此模式下,如果一些线程在等待需要选择其中一个线程来访问临界区的锁时,它将选择等待时间最长的线程。考虑到之前解释的特性只用到lock()和unlock()方法,因为tryLock()方法在Lock接口被使用时不会让线程休眠,公允属性就不会影响到此方法的功能。
在本节中,学习如何使用锁来同步代码块,以及使用ReentrantLock类和其实现的Lock接口创建临界区,来模拟打印队列。还会学习公允参数如何影响Lock的行为。
准备工作
本范例通过Eclipse开发工具实现。如果使用诸如NetBeans的开发工具,打开并创建一个新的Java项目。
实现过程
通过如下步骤完成范例:
-
创建名为PrintQueue的类,用来实现打印队列:
public class PrintQueue {
-
定义Lock对象,在构造函数中用ReentrantLock类的新对象进行初始化。构造函数会接收一个Boolean参数,此参数将用于指定Lock的公允模式:
private Lock queueLock; public PrintQueue(boolean fairMode){ queueLock = new ReentrantLock(fairMode); }
-
实现printJob()方法,接收Object为参数且不会返回任何值:
public void printJob(Object document){
-
在printJob()方法内,调用lock()方法控制Lock对象:
queueLock.lock();
-
然后,包括如下代码来模拟打印一个文件的流程:
try { Long duration = (long)(Math.random() * 10000); System.out.println(Thread.currentThread().getName()+ ": PrintQueue: Printing the first Job during "+(duration/1000)+" seconds"); Thread.sleep(duration); } catch (InterruptedException e) { e.printStackTrace(); }
-
最后使用unlock()方法取消对Lock对象的控制:
finally{ queueLock.unlock(); }
-
然后,重复执行打印流程。printJob()方法将两次使用和释放锁。这种诡异的操作行为以一种更好的方式展现公允模式与非公允模式的区别。在printJob()方法中加入如下代码:
queueLock.lock(); try { Long duration = (long)(Math.random() * 10000); System.out.printf("%s: PrintQueue: Printing the second Job during %d seconds\n", Thread.currentThread().getName(), (duration/1000)); Thread.sleep(duration); } catch (InterruptedException e) { e.printStackTrace(); } finally{ queueLock.unlock(); }
-
创建名为Job的类,指定其实现Runnable接口:
public class Job implements Runnable {
-
定义PrintQueue类的对象,实现初始化此对象的类构造函数:
private PrintQueue printQueue; public Job(PrintQueue printQueue){ this.printQueue = printQueue; }
-
实现run()方法,使用PrintQueue对象发送打印操作:
@Override public void run() { System.out.printf("%s: Going to print a document\n", Thread.currentThread().getName()); printQueue.printJob(new Object()); System.out.printf("%s: The document has been printed\n", Thread.currentThread().getName()); }
-
创建本范例中的主类,实现一个包含main()方法的Main类:
public class Main { public static void main(String[] args) {
-
使用一个锁测试PrintQueue类,此锁的公允模式分别返回true和false。我们使用一个辅助方法来实现两个测试,以便于main()方法的代码简单化:
System.out.printf("Running example with fair-mode = false\n"); testPrintQueue(false); System.out.printf("Running example with fair-mode = true\n"); testPrintQueue(true); }
-
创建辅助方法testPrintQueue(),在方法内创建一个共享的PrintQueue对象:
private static void testPrintQueue(Boolean fairMode) { PrintQueue printQueue = new PrintQueue(fairMode);
-
创建10个Job对象以及10个执行对象的线程:
Thread thread[] = new Thread[10]; for (int i = 0 ; i < 10 ; i++){ thread[i] = new Thread(new Job(printQueue), "Thread "+ i); }
-
执行这10个线程:
for (int i = 0 ; i < 10 ; i++){ thread[i].start(); }
-
最后,等待这10个线程运行结束:
for (int i = 0 ; i < 10 ; i++){ try { thread[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } }
工作原理
下图显示执行本范例输出的部分内容:
范例的关键之处在PrintQueue类的printJob()方法中。当想要使用锁创建临界区以及确保只有一个执行线程运行一段代码块时,就必须创建ReentrantLock对象。在临界区初始阶段,需要使用lock()方法控制锁。当线程A调用lock()方法时,如果没有线程控制这个锁,它允许线程A控制锁并且立即返回,以便线程A进入临界区运行。否则,如果线程B正在被锁控制的临界区里执行,lock()方法则让线程A休眠直到线程B在临界区里运行结束。
在临界区结尾,使用unlock()方法释放锁控制,并允许其它线程进入临界区运行。如果在临界区结尾不调用unlock()方法的话,其它等待运行的线程将会一直等待下去,导致死锁局面。如果在临界区中使用try-catch程序块,切记在finally部分里加入unlock()方法。
范例中测试的另一个特性时公允模式。每次打印操作中有两个临界区。如上图所示,会看到所有操作中,第二部分紧随第一个执行。这是正常情况,但非公允模式发生时就会有异常,也就是说,给ReentrantLock类构造函数传false值。
与之相反,当通过给Lock类构造函数传递true值建立公允模式时,就具有不同的行为。第一个请求控制锁的线程是Thread0,然后是Thread1,以此类推。当Thread0正在运行被锁保护的第一个代码块时,还有九个线程等待执行同一个代码块。当Thread0释放锁时,它会立刻再次请求控制锁,所以就是有10个线程同时尝试控制锁。当公允模式生效后,Lock接口将选择Thread1,因为它已经等待更多的时间。然后,Lock接口选择Thread2,然后Thread3,以此类推。在所有的线程通过锁保护的第一个代码块之前,没有线程去执行锁保护的第二个代码块。一旦所有线程已经执行完锁保护的第一个代码块,然后重新排队,Thread0,Thread1,以此类推。如下图所示:
扩展学习
tryLock()方法,是Lock接口(ReentrantLock类)中另一个控制锁的方法。与lock()方法最大的不同是,如果使用此方法的线程无法得到Lock接口的控制,tryLock()会立即返回并且不会让线程休眠。如果线程控制锁的话则返回boolean类型值true,否则返回false。也可以传递时间值和TimeUnit对象来指明线程等待锁的最长持续时间。如果时间过去后线程依然没有得到锁,tryLock()方法将返回false值。TimeUnit是一个枚举类型的类,包含如下常量:DAYS、HOURS、MICROSECONDS、MILLISECONDS、MINUTES、NANOSECONDS、SECONDS,表明传递给方法的时间单位。
考虑到开发人员的职责是关注方法结果以及相应地表现。如果tryLock()方法返回false,很显然程序无法在临界区里执行。即便运行通过,程序也可能得到错误的结果。
ReentrantLock类允许递归调用。当一个线程控制锁并且进行一次递归调用时,它将继续控制这个锁,所以调用的lock()方法将立即返回,而线程将继续执行递归调用。此外,也可以调用其它方法。在代码中,调用unlock()方法的次数与调用lock()方法的次数相同。
避免死锁
为了避免死锁,需要非常小心的使用锁机制。当两个或多个线程同时等待锁时被阻塞的话,这种情况会导致永远不会解锁。例如,线程A控制了锁X,线程B控制了锁Y。如果线程A尝试控制锁Y,同时线程B尝试控制锁X,两个线程将被无限期的阻塞,因为它们都在等待永远不会被释放的锁。切记这种问题发生是因为两个线程尝试逆序控制锁。第十一章“并发编程设计”的目录提供一些好的建议来设计合适的并发应用,同时避免死锁问题。
更多关注
- 本章中”同步方法“和“同步程序中使用状态”小节。
- 第九章“测试并发应用”中的“监控锁接口”小节。