本文主要来自李刚的《疯狂Java讲义》第三版
线程通信
当线程在系统内运行时,线程的调度具有一定的透明性,程序通常无法准确控制线程的轮换执行,但 Java 也提供了一些机制来保证线程协调运行。
我们假设有一个存取款系统,存款跟取款是不同的线程,现在有一个比较特殊的要求,存款与取款的操作一直在进行,一旦执行了存款操作,就立刻执行取款操作,但不能连续进行存款取款操作,其中每次取款都会将所有的钱取出。
1. synchronized
对于 synchronized 修饰的同步方法或同步代码块,可以通过调用 Object 类中的 wait()、notify()、notifyAll()这三个方法来实现线程通信,这三个方法必须由同步监视器对象调用,可以分为以下两种情况:
- 对于 synchronized 修饰的方法,因为该类的默认实例(this)就是同步监视器,所以可以在同步方法中直接调用这三个方法。
- 对于 synchronized 修饰的代码块,同步监视器是 synchronized 后括号里的对象,所以必须使用该对象调用这三个方法。
关于这三个方法的解释如下:
- wait():导致当前线程等待,直到其他线程调用该同步监视器的 notify()方法或 notifyAll()方法来唤醒该线程。该方法有三种形式:无时间参数代表无限期等待直到其他线程通知;有时间参数(带毫秒参数、带毫秒微秒参数)代表等待指定时间后自动苏醒。调用 wait()后当前线程会释放对该同步监视器的锁。
- notify():唤醒在此同步监视器上等待的单个线程。如果多个线程在等待,则会随机唤醒其中一个线程。只有当前线程放弃对该同步监视器的锁定后,才能执行被唤醒的线程。
- notifyAll():唤醒在此同步监视器上等待的所有线程,只有当前线程放弃对该同步监视器的锁定后,才能执行被唤醒的线程。
对于系统所要求的功能,可以设置一个 flag 标志,如果有存款,则 flag 为 true,否则为 false,根据此来调用等待唤醒线程的操作,从而实现系统所要求的方法:
账号类 Account:
public class Account {
//账号编号、帐号余额、存款标记
private String accountNo;
private double balance;
private boolean flag = false;
public Account(){}
public Account(String accountNo, double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
//省略accountNo的getset方法
//因为账户余额不能随便修改,所以只为该参数提供get方法
public double getBalance() {
return balance;
}
public synchronized void draw(double drawAmount){
try{
//如果flag为假,表明账户中还没有人存钱进去,线程等待
if (!flag){
wait();
}else{
System.out.println(Thread.currentThread().getName() + "取钱:" + drawAmount);
balance -= drawAmount;
System.out.println("账户余额为:" + balance);
//修改flag标记
flag = false;
//等待本线程执行完释放锁后唤醒其他线程
notifyAll();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void deposit(double depositAmount){
try{
//如果flag为真,表明账户中已经有人存钱进去,线程等待
if (flag){
wait();
}else{
System.out.println(Thread.currentThread().getName() + "存钱:" + depositAmount);
balance += depositAmount;
System.out.println("账户余额为:" + balance);
//修改flag标记
flag = true;
//等待本线程执行完释放锁后唤醒其他线程
notifyAll();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
取钱线程类 DrawThread:
public class DrawThread extends Thread {
//模拟用户账户
private Account account;
//取钱数
private double drawAmount;
public DrawThread(String name, Account account, double drawAmount) {
super(name);
this.account = account;
this.drawAmount = drawAmount;
}
//10次取钱操作
public void run(){
for (int i = 0; i < 10; i++) {
account.draw(drawAmount);
}
}
}
存钱线程类 DepositThread:
public class DepositThread extends Thread {
//模拟用户账户
private Account account;
//存钱数
private double depositAmount;
public DepositThread(String name, Account account, double depositAmount) {
super(name);
this.account = account;
this.depositAmount = depositAmount;
}
//10次存钱操作
public void run(){
for (int i = 0; i < 10; i++) {
account.deposit(depositAmount);
}
}
}
主函数:
public static void main(String[] args) {
Account account = new Account("123",0);
new DrawThread("取钱者",account,800).start();
new DepositThread("存钱者甲",account,800).start();
new DepositThread("存钱者乙",account,800).start();
new DepositThread("存钱者丙",account,800).start();
}
输出结果:
存钱者甲存钱:800.0
账户余额为:800.0
取钱者取钱:800.0
账户余额为:0.0
存钱者丙存钱:800.0
账户余额为:800.0
取钱者取钱:800.0
账户余额为:0.0
存钱者甲存钱:800.0
账户余额为:800.0
取钱者取钱:800.0
账户余额为:0.0
存钱者丙存钱:800.0
账户余额为:800.0
取钱者取钱:800.0
账户余额为:0.0
存钱者甲存钱:800.0
账户余额为:800.0
取钱者取钱:800.0
账户余额为:0.0
存钱者丙存钱:800.0
账户余额为:800.0
其中总共取了十次钱,取款线程执行完毕,存款线程可以看到甲丙交替执行,但如果将循环次数扩大,就会发现甲乙丙三个线程会随机的执行。因为存款线程次数比取款次数多,因此最后程序会因为线程阻塞而不会自动结束,一直在等待取款的操作,
2. Lock
如果使用 Lock 进行同步,因为不存在隐式同步监视器,就不能使用上面的三种方法进行通信了。因此 Java 提供了一个 Condition 类来保持协调,线程可以通过使用 Lock 产生的 Condition 对象,来使线程等待或唤醒其他线程。
Condition 中有三个方法,分别对应着上面介绍的三个方法:
- await():对应之前的 wait()方法,导致当前线程暂停,等待下面两种方法唤醒。对比 wait()方法,该方法有更多的变种,能实现更多的等待操作。
- signal():唤醒在此 Lock 对象上等待的单个线程,与 notify()方法类似。
- signalAll():唤醒在此 Lock 对象上等待的所有线程,与 notifyAll()方法类似。
实现方式:
账号 Account 类
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Account {
//账号编号、帐号余额、存款标记
private String accountNo;
private double balance;
private boolean flag = false;
//获取锁对象,和Condition对象
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
public Account(){}
public Account(String accountNo, double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
//省略accountNo的getset方法
//因为账户余额不能随便修改,所以只为该参数提供get方法
public double getBalance() {
return balance;
}
public void draw(double drawAmount){
lock.lock();
try{
//如果flag为假,表明账户中还没有人存钱进去,线程等待
if (!flag){
condition.await();
}else{
System.out.println(Thread.currentThread().getName() + "取钱:" + drawAmount);
balance -= drawAmount;
System.out.println("账户余额为:" + balance);
//修改flag标记
flag = false;
//等待本线程执行完释放锁后唤醒其他线程
condition.signalAll();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void deposit(double depositAmount){
lock.lock();
try{
//如果flag为真,表明账户中已经有人存钱进去,线程等待
if (flag){
condition.await();
}else{
System.out.println(Thread.currentThread().getName() + "存钱:" + depositAmount);
balance += depositAmount;
System.out.println("账户余额为:" + balance);
//修改flag标记
flag = true;
//等待本线程执行完释放锁后唤醒其他线程
condition.signalAll();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
3. 阻塞队列 BlockingQueue
BlockingQueue 有一个特征:当生产者线程试图向 BlockingQueue 中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从 BlockingQueue 中取出元素时,如果该队列已空,则该线程被阻塞。
应用在线程池中时,我们是生产者,将线程放入 BlockingQueue 中,线程池是消费者,通过执行 BlockingQueue 中的线程进行消费。
BlockingQueue 提供了下面两个支持阻塞的方法:
- put(E e):尝试把 E 元素放入 BlockingQueue 中,如果队列满,则阻塞该线程。
- take():尝试从 BlockingQueue 中取出元素,如果队列空,则阻塞该线程。
BlockingQueue 继承了 Queue 接口,因此也能使用 Queue 中的方法对元素进行操作做,下表是操作的对比:
抛出异常 | 不同返回值 | 阻塞线程 | 指定超时时长 | |
---|---|---|---|---|
队尾插入元素 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
队头删除元素 | remove() | poll() | take() | poll(time, unit) |
获取、不删除元素 | element() | peek() | 无 | 无 |
JDK1.7 中,BlockingQueue 有五个实现类
- ArrayBlockingQueue:基于数组实现的 BlockingQueue 队列,需要指定队列大小
- LinkedBlockingQueue:基于链表实现的 BlockingQueue 队列,不指定大小的话默认大小为 Integer.MAX_VALUE
- PriorityBlockingQueue:与前两种不同,该队列不是先进先出队列,在取出元素时,会根据优先级选择优先级最高的元素
- SynchronousQueue:同步队列,对该队列的存、取操作必须交替进行
- DelayQueue:基于 PriorityQueue,一种延时阻塞队列,DelayQueue 中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。