相对于Synchronized,我们还可以使用更加人性化的Lock关键字作为线程同步的实现方案。本篇文章将详细介绍Lock的相关用法以及使用它的优势。
1 使用Lock同步代码块
Java提供另外的机制用来同步代码块。它比synchronized关键字更加强大、灵活。它是基于Lock接口和实现它的类(如ReentrantLock)。这种机制有如下优势:
- 它允许以一种更灵活的方式来构建synchronized块。使用synchronized关键字,你必须以结构化方式得到释放synchronized代码块的控制权。Lock接口允许你获得更复杂的结构来实现你的临界区。
- Lock接口比synchronized关键字提供更多额外的功能,新功能之一是它提供的tryLock()方法。这种方法试图获取锁的控制权并且如果它不能获取该锁是由于其他线程在使用这个锁,它将返回false。使用synchronized关键字,当线程A试图执行synchronized代码块,如果线程B正在执行它,那么线程A将阻塞直到线程B执行完synchronized代码块。使用Lock,你可以执行tryLock()方法,这个方法返回一个Boolean值表示是否有其他线程正在运行这个锁所保护的代码。
- 当有多个读者和一个写者时,Lock接口允许读写操作分离。
- Lock接口提供了相比于wait更加灵活的await关键字用于线程通信。
- ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。
注意:
对于上面第5条优势中的提到的可以被Interrupt的同步是相对于进入同步块前的这个时间段来说的。另外在进入同步块后,如果调用了synchronized概念中的wait()或Lock概念中的await()或者sleep(),那么该线程在等待唤醒的过程中是可以响应打断的(抛出异常后继续执行,而不是线程终止);如果调用了Lock概念中的awaitUninterruptibly()或耗时业务逻辑导致的延迟操作,则不会响应打断。
在下面的案例中,你将学习如何通过锁来同步代码块和通过Lock接口及其实现者ReentrantLock类来创建临界区,实现一个程序来模拟打印队列。
1.创建PrintQueue类,来实现打印队列。
1 | public class PrintQueue { |
2.声明一个Lock对象,并且使用ReentrantLock类的一个新对象来初始化它。
1 | private final Lock queueLock=new ReentrantLock(); |
3.实现printJob()方法,它将接收Object对象作为参数,并且不会返回任何值。
1 | public void printJob(Object document){ |
4.在printJob()方法内部,通过调用lock()方法来获取Lock对象的控制权。
1 | queueLock.lock(); |
5.然后,包含以下代码来模拟文档的打印:
1 | try { |
6.最后,通过调用unlock()方法来释放Lock对象的控制。
1 | finally { |
7.创建一个Job类,并指定它实现Runnable接口。
1 | public class Job implements Runnable { |
8.声明一个PrintQueue类的对象,并通过实现类(Job类)的构造器来初始化这个对象。
1 | private PrintQueue printQueue; |
9.实现run()方法,它使用PrintQueue对象来发送一个打印任务。
1 | @Override |
10.通过创建类名为Main,且包括main()方法来实现这个示例的主类。
1 | public class Main { |
11.创建一个共享的PrintQueue对象。
1 | PrintQueue printQueue=new PrintQueue(); |
12.创建10个Job对象,并且使用10个线程来运行它们。
1 | Thread thread[]=new Thread[5]; |
13.启动这10个线程。
1 | for (int i=0; i<10; i++){ |
从以下截图,你可以看到执行这个示例的输出:
在 printJob()中,queueLock是这个示例的关键所在。当我们通过锁来实现一个临界区并且保证只有一个执行线程能运行一个代码块,我们必须创建一个ReentrantLock对象。在临界区的起始部分,我们必须通过使用lock()方法来获得锁的控制权。当一个线程A调用这个方法时,如果没有其他线程持有这个锁的控制权,那么这个方法就会给线程A分配这个锁的控制权并允许线程A执行这个临界区。否则,如果其他线程B正在执行由这个锁控制的临界区,lock()方法将会使线程A睡眠直到线程B完成这个临界区的执行。
在临界区的尾部,我们必须使用unlock()方法来释放锁的控制权,允许其他线程运行这个临界区。如果你在临界区的尾部没有调用unlock()方法,那么其他正在等待该代码块的线程将会永远等待,造成死锁情况。如果你在临界区使用try-catch代码块,别忘了在finally部分的内部包含unlock()方法的代码。
1.1 tryLock()
Lock接口(和ReentrantLock类,以及后面要讲的ReadWriteLock接口和ReentrantReadWriteLock类)包含其他方法来获取锁的控制权,那就是tryLock()方法。这个方法与lock()方法的最大区别是,如果一个线程调用这个方法不能获取Lock接口的控制权时,将会立即返回并且不会使这个线程进入睡眠。这个方法返回一个boolean值,true表示这个线程获取了锁的控制权,false则表示没有。
小知识:
预先考虑到tryLock()方法的结果,并采取相应的措施,这是程序员的责任。如果这个方法返回false值,预计你的程序不会执行这个临界区。如果是这样,你应该针对此错误结果做相应的处理。
ReentrantLock类也允许递归调用(锁的可重入性),当一个线程有锁的控制权并且使用递归调用,它延续了锁的控制权,所以调用lock()方法将会立即返回并且继续递归调用的执行。
1.2 lockInterruptibly()
lockInterruptibly()是Lock提供的另一个用来获取锁的控制权的方法。它可以立即响应线程的interrupt,不伦当前线程是在等待锁的期间或是在已经获取锁的控制权期间。lock()相比于lockInterruptibly()来说没有那么及时,它要在获取锁的那一刻才能响应线程的interrupt事件。
参考资料>>
2 使用读/写锁同步数据访问
锁所提供的最重要的改进之一就是ReadWriteLock接口和唯一一个实现它的ReentrantReadWriteLock类。这个类提供两把锁,一把用于读操作和一把用于写操作。同时可以有多个线程执行读操作,但只有一个线程可以执行写操作。当一个线程正在执行一个写操作,不可能有任何线程执行读操作。
在下面的例子中,你将会学习如何使用ReadWriteLock接口实现一个程序,使用它来控制访问一个存储两个产品价格的对象。
1.创建PricesInfo类,用它来存储两个产品价格的信息。
1 | public class PricesInfo { |
2.声明两个double类型的属性,分别命名为price1和price2。
1 | private double price1; |
3.声明一个名为lock的ReadWriteLock对象。
1 | private ReadWriteLock lock; |
4.实现类的构造器,初始化这三个属性。其中,对于lock属性,我们创建一个新的ReentrantReadWriteLock对象。
1 | public PricesInfo(){ |
5.实现getPrice1()方法,用它来返回price1属性的值。它使用读锁来控制这个属性值的访问。
1 | public double getPrice1() { |
6.实现getPrice2()方法,用它来返回price2属性的值。它使用读锁来控制这个属性值的访问。
1 | public double getPrice2() { |
7.实现setPrices()方法,用来建立这两个属性的值。它使用写锁来控制对它们的访问。
1 | public void setPrices(double price1, double price2) { |
8.创建Reader类,并指定它实现Runnable接口。这个类实现了PricesInfo类属性值的读者。
1 | public class Reader implements Runnable { |
9.声明一个PricesInfo对象,并且实现Reader类的构造器来初始化这个对象。
1 | private PricesInfo pricesInfo; |
10.实现Reader类的run()方法,它读取10次两个价格的值。
1 | @Override |
11.创建Writer类,并指定它实现Runnable接口。这个类实现了PricesInfo类属性值的修改者。
1 | public class Writer implements Runnable { |
12.声明一个PricesInfo对象,并且实现Writer类的构造器来初始化这个对象。
1 | private PricesInfo pricesInfo; |
13.实现run()方法,它修改了三次两个价格的值,并且在每次修改之后睡眠2秒。
1 | @Override |
14.通过创建类名为Main,且包括main()方法来实现这个示例的主类。
1 | public class Main { |
15.创建一个PricesInfo对象。
1 | PricesInfo pricesInfo=new PricesInfo(); |
16.创建5个Reader对象,并且用5个线程来执行它们。
1 | Reader readers[]=new Reader[5]; |
17.创建一个Writer对象,并且用线程来执行它。
1 | Writer writer=new Writer(pricesInfo); |
18.启动这些线程。
1 | for (int i=0; i<5; i++){ |
在以下截图中,你可以看到执行这个例子的一个部分输出:
3 修改Lock的公平性
在ReentrantLock类和ReentrantReadWriteLock类的构造器中,允许一个名为fair的boolean类型参数,它允许你来控制这些类的行为。默认值为 false,这将启用非公平模式。在这个模式中,当有多个线程正在等待一把锁(ReentrantLock或者 ReentrantReadWriteLock),这个锁必须选择它们中间的一个来获得进入临界区,选择任意一个是没有任何标准的。true值将开启公平模式。在这个模式中,当有多个线程正在等待一把锁(ReentrantLock或者ReentrantReadWriteLock),这个锁必须选择它们中间的一个来获得进入临界区,它将选择等待时间最长的线程。由于tryLock()方法并不会使线程进入睡眠,即使Lock接口正在被使用,这个公平属性并不会影响它的功能。更多关于公平模式的介绍,请点击查阅。