基本线程同步(四)在同步代码中使用条件
在并发编程中的一个经典问题是生产者与消费者问题,我们有一个数据缓冲区,一个或多个数据的生产者在缓冲区存储数据,而一个或多个数据的消费者,把数据从缓冲区取出。
由于缓冲区是一个共享的数据结构,我们必须采用同步机制,比如synchronized关键字来控制对它的访问。但是我们有更多的限制因素,如果缓冲区是满的,生产者不能存储数据,如果缓冲区是空的,消费者不能取出数据。
对于这些类型的情况,Java在Object对象中提供wait(),notify(),和notifyAll() 方法的实现。一个线程可以在synchronized代码块中调用wait()方法。如果在synchronized代码块外部调用wait()方法,JVM会抛出IllegalMonitorStateException异常。当线程调用wait()方法,JVM让这个线程睡眠,并且释放控制 synchronized代码块的对象,这样,虽然它正在执行但允许其他线程执行由该对象保护的其他synchronized代码块。为了唤醒线程,你必 须在由相同对象保护的synchronized代码块中调用notify()或notifyAll()方法。
1.创建EventStorage类,包括一个名为maxSize,类型为int的属性和一个名为storage,类型为LinkedList<Date>的属性。指南中,你将学习如何通过使用synchronized关键字和wait()和notify(),notifyAll()方法实现生产者消费者问题。
2.实现这个类的构造器,初始化所有属性
3. 实现synchronized方法set(),用来在storage上存储一个事件。首先,检查storage是否已满。如果满了,调用wait()方 法,直到storage有空的空间。在方法的尾部,我们调用notifyAll()方法来唤醒,所有在wait()方法上睡眠的线程。
4. 实现synchronized方法get(),用来在storage上获取一个事件。首先,检查storage是否有事件。如果没有,调用wait()方 法直到,storage有一些事件,在方法的尾部,我们调用notifyAll()方法来唤醒,所有在wait()方法上睡眠的线程。
5.创建Producer类,并指定它实现Runnable接口,它将实现这个示例的生产者。
6.声明一个EventStore对象,并实现(Producer类)构造器,初始化该对象
7.实现run()方法,该方法调用EventStorage对象的set()方法100次。
8.创建Consumer类,并指定它实现Runnable接口,它将实现这个示例的消费者。
9.声明一个EventStore对象,并实现(Consumer类)构造器,初始化该对象。
10.实现run()方法,该方法调用EventStorage对象的get()方法100次。
11.通过创建类名为Main,且包括main()方法来实现这个示例的主类。
12.创建一个EventStorage对象。
13.创建一个Producer对象,并且用线程运行它。
14.创建一个Consumer对象,并且用线程运行它。
15.启动这两个线程。
package concurrency.study.chap02;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
public class EventStorage {
private int maxSize;
private List<Date> storage;
public EventStorage() {
maxSize = 10;
storage = new LinkedList<>();
}
public synchronized void set() {
while (storage.size() == maxSize) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
((LinkedList<Date>) storage).offer(new Date());
System.out.printf("Set: %d \n", storage.size());
notifyAll();
}
public synchronized void get() {
while (storage.size() == 0) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.printf("Get: %d: %s \n", storage.size(), ((LinkedList<?>) storage).poll());
notifyAll();
}
}
package concurrency.study.chap02;
public class Consumer implements Runnable {
private EventStorage storage;
public Consumer(EventStorage storage) {
this.storage = storage;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
storage.get();
}
}
}
package concurrency.study.chap02;
public class Producer implements Runnable {
private EventStorage storage;
public Producer(EventStorage storage) {
this.storage = storage;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
storage.set();
}
}
}
package concurrency.study.chap02;
public class EventStorageMain {
public static void main(String[] args) {
EventStorage storage = new EventStorage();
Producer producer = new Producer(storage);
Thread thread1 = new Thread(producer);
Consumer consumer = new Consumer(storage);
Thread thread2 = new Thread(consumer);
thread2.start();
thread1.start();
}
}
EventStorage 类的set()方法和get()方法是这个示例的关键。首先,set()方法检查storage属性是否有空闲空间。如果它满了,调用wait()方法等 待有空闲的空间。当其他线程调用notifyAll()方法,这个线程将被唤醒并且再次检查这个条件。这个notifyAll()方法并不保证线程会醒 来。这个过程是重复,直到storage有空闲空间,然后它可以生成一个新的事件并存储它。
get()方法的行为是相似的。首先,它检查storage是否有事件。如果EventStorage类是空的,调用wait()方法等待事件。当其他线程调用notifyAll()方法,这个线程将被唤醒并且再次检查这个条件直到storage有一些事件。
基本线程同步(五)使用Lock同步代码块
Java提供另外的机制用来同步代码块。它比synchronized关键字更加强大、灵活。它是基于Lock接口和实现它的类(如ReentrantLock)。这种机制有如下优势:
- 它允许以一种更灵活的方式来构建synchronized块。使用synchronized关键字,你必须以结构化方式得到释放synchronized代码块的控制权。Lock接口允许你获得更复杂的结构来实现你的临界区。
- Lock 接口比synchronized关键字提供更多额外的功能。新功能之一是实现的tryLock()方法。这种方法试图获取锁的控制权并且如果它不能获取该锁,是因为其他线程在使用这个锁,它将返回这个锁。使用synchronized关键字,当线程A试图执行synchronized代码块,如果线程B正在执行它,那么线程A将阻塞直到线程B执行完synchronized代码块。使用锁,你可以执行tryLock()方法,这个方法返回一个 Boolean值表示,是否有其他线程正在运行这个锁所保护的代码。
- 当有多个读者和一个写者时,Lock接口允许读写操作分离。
- Lock接口比synchronized关键字提供更好的性能。
在这个指南中,你将学习如何通过锁来同步代码块和通过Lock接口及其实现者ReentrantLock类来创建临界区,实现一个程序来模拟打印队列。
1.创建PrintQueue类,来实现打印队列。
2.声明一个Lock对象,并且使用ReentrantLock类的一个新对象来初始化它。
3.实现printJob()方法,它将接收Object对象作为参数,并且不会返回任何值。
4.在printJob()方法内部,通过调用lock()方法来获取Lock对象的控制权。
5.然后,包含以下代码来模拟文档的打印:
6.最后,通过调用unlock()方法来释放Lock对象的控制。
7.创建一个Job类,并指定它实现Runnable接口。
8.声明一个PrintQueue类的对象,并通过实现类(Job类)的构造器来初始化这个对象。
9.实现run()方法,它使用PrintQueue对象来发送一个打印任务。
10.通过创建类名为Main,且包括main()方法来实现这个示例的主类。
11.创建一个共享的PrintQueue对象
12.创建10个Job对象,并且使用10个线程来运行它们。
13.启动这10个线程。
package concurrency.study.chap02;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class PrintQueue {
private final Lock queueLock = new ReentrantLock();
public void printJob(Object document) {
queueLock.lock();
try {
Long duration = (long) (Math.random() * 10000);
System.out.println(Thread.currentThread().getName() + ":PrintQueue: Printing a Job during "
+ (duration / 1000) + " seconds");
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
}
}
package concurrency.study.chap02;
public class Job implements Runnable {
private PrintQueue printQueue;
public Job(PrintQueue printQueue) {
this.printQueue = 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());
}
}
package concurrency.study.chap02;
public class PrintQueueMain {
public static void main(String[] args) {
PrintQueue printQueue = new PrintQueue();
Thread thread[] = new Thread[10];
for (int i = 0; i < 10; i++) {
thread[i] = new Thread(new Job(printQueue), "Thread " + i);
}
for (int i = 0; i < 10; i++) {
thread[i].start();
}
}
}
在 printJob()中,PrintQueue类是这个示例的关键所在。当我们通过锁来实现一个临界区并且保证只有一个执行线程能运行一个代码块,我们必 须创建一个ReentrantLock对象。在临界区的起始部分,我们必须通过使用lock()方法来获得锁的控制权。当一个线程A调用这个方法时,如果 没有其他线程持有这个锁的控制权,那么这个方法就会给线程A分配这个锁的控制权并且立即返回允许线程A执行这个临界区。否则,如果其他线程B正在执行由这 个锁控制的临界区,lock()方法将会使线程A睡眠直到线程B完成这个临界区的执行。
在临界区的尾部,我们必须使用unlock()方法来释放锁的控制权,允许其他线程运行这个临界区。如果你在临界区的尾部没有调用unlock()方法,那么其他正在等待该代码块的线程将会永远等待,造成 死锁情况。如果你在临界区使用try-catch代码块,别忘了在finally部分的内部包含unlock()方法的代码。
不止这些…
Lock 接口(和ReentrantLock类)包含其他方法来获取锁的控制权,那就是tryLock()方法。这个方法与lock()方法的最大区别是,如果一 个线程调用这个方法不能获取Lock接口的控制权时,将会立即返回并且不会使这个线程进入睡眠。这个方法返回一个boolean值,true表示这个线程 获取了锁的控制权,false则表示没有。
注释:考虑到这个方法的结果,并采取相应的措施,这是程序员的责任。如果这个方法返回false值,预计你的程序不会执行这个临界区。如果是这样,你可能会在你的应用程序中得到错误的结果。
ReentrantLock类也允许递归调用(锁的可重入性,译者注),当一个线程有锁的控制权并且使用递归调用,它延续了锁的控制权,所以调用lock()方法将会立即返回并且继续递归调用的执行。此外,我们也可以调用其他方法。
更多信息
你必须要非常小心使用锁来避免死锁,这种情况发生在,当两个或两个以上的线程被阻塞等待将永远不会解开的锁。比如,线程A锁定Lock(X)而线程B锁定 Lock(Y)。如果现在,线程A试图锁住Lock(Y)而线程B同时也试图锁住Lock(X),这两个线程将无限期地被阻塞,因为它们等待的锁将不会被解开。请注意,这个问题的发生是因为这两个线程尝试以相反的顺序获取锁(译者注:锁顺序死锁)。在附录中,提供了一些很好的并发编程设计的建议,适当的设计并发应用程序,来避免这些死锁问题。