如果这个例子还不能帮助你理解如何解决多线程的问题,那么下面再来看一个更加实际的例子——卫生间问题。
例 如火车上车厢的卫生间,为了简单,这里只模拟一个卫生间,这个卫生间会被多个人同时使用,在实际使用时,当一个人进入卫生间时则会把卫生间锁上,等出来时 打开门,下一个人进去把门锁上,如果有一个人在卫生间内部则别人的人发现门是锁的则只能在外面等待。从编程的角度来看,这里的每个人都可以看作是一个线程 对象,而这个卫生间对象由于被多个线程访问,则就是临界资源,在一个线程实际使用时,使用 synchronized 关键将临界资源锁定,当结束时,释放锁定。实现的代码如下:
package syn3;
/**
* 测试类
*/
public class TestHuman {
public static void main(String[] args) {
Toilet t = new Toilet(); // 卫生间对象
Human h1 = new Human("1",t);
Human h2 = new Human("2",t);
Human h3 = new Human("3",t);
}
}
package syn3;
/**
* 人线程类,演示互斥
*/
public class Human extends Thread {
Toilet t;
String name;
public Human(String name,Toilet t){
this.name = name;
this.t = t;
start(); // 启动线程
}
public void run(){
// 进入卫生间
t.enter(name);
}
}
package syn3;
/**
* 卫生间,互斥的演示
*/
public class Toilet {
public synchronized void enter(String name){
System.out.println(name + " 已进入! ");
try{
Thread.sleep(2000);
}catch(Exception e){}
System.out.println(name + " 离开! ");
}
}
该示例的执行结果为,不同次数下执行结果会有所不同:
1 已进入!
1 离开!
3 已进入!
3 离开!
2 已进入!
2 离开!
在该示例代码中, Toilet 类表示卫生间类, Human 类模拟人,是该示例中的线程类, TestHuman 类是测试类,用于启动线程。在 TestHuman 中,首先创建一个 Toilet 类型的对象 t ,并将该对象传递到后续创建的线程对象中,这样后续的线程对象就使用同一个 Toilet 对象,该对象就成为了临界资源。下面创建了三个 Human 类型的线程对象,每个线程具有自己的名称 name 参数,模拟 3 个线程,在每个线程对象中,只是调用对象 t 中的 enter 方法,模拟进入卫生间的动作,在 enter 方法中,在进入时输出调用该方法的线程进入,然后延迟 2 秒,输出该线程离开,然后后续的一个线程进入,直到三个线程都完成 enter 方法则程序结束。
在该示例中,同一个 Toilet 类的对象 t 的 enter 方法由于具有 synchronized 修饰符修饰,则在多个线程同时调用该方法时,如果一个线程进入到 enter 方法内部,则为对象 t 上锁,直到 enter 方法结束以后释放对该对象的锁定,通过这种方式实现无论多少个 Human 类型的线程,对于同一个对象 t ,任何时候只能有一个线程执行 enter 方法,这就是解决多线程问题的第一种思路——互斥的解决原理。
12.4.2 同步
使用互斥解决多线程问题是一种简单有效的解决办法,但是由于该方法比较简单,所以只能解决一些基本的问题,对于复杂的问题就无法解决了。
解 决多线程问题的另外一种思路是同步。同步是另外一种解决问题的思路,结合前面卫生间的示例,互斥方式解决多线程的原理是,当一个人进入到卫生间内部时,别 的人只能在外部时刻等待,这样就相当于别的人虽然没有事情做,但是还是要占用别的人的时间,浪费系统的执行资源。而同步解决问题的原理是,如果一个人进入 到卫生间内部时,则别的人可以去睡觉,不占用系统资源,而当这个人从卫生间出来以后,把这个睡觉的人叫醒,则它就可以使用临界资源了。所以使用同步的思路 解决多线程问题更加有效,更加节约系统的资源。
在常见的多线程问题解决中,同步问题的典型示例是“生产者 - 消费者”模型,也就是生产者线程只负责生产,消费者线程只负责消费,在消费者发现无内容可消费时则睡觉。下面举一个比较实际的例子——生活费问题。
生 活费问题是这样的:学生每月都需要生活费,家长一次预存一段时间的生活费,家长和学生使用统一的一个帐号,在学生每次取帐号中一部分钱,直到帐号中没钱时 通知家长存钱,而家长看到帐户还有钱则不存钱,直到帐户没钱时才存钱。在这个例子中,这个帐号被学生和家长两个线程同时访问,则帐号就是临界资源,两个线 程是同时执行的,当每个线程发现不符合要求时则等待,并释放分配给自己的 CPU 执行时间,也就是不占用系统资源。实现该示例的代码为:
package syn4;
/**
* 测试类
*/
public class TestAccount {
public static void main(String[] args) {
Accout a = new Accout();
StudentThread s = new StudentThread(a);
GenearchThread g = new GenearchThread(a);
}
}
package syn4;
/**
* 模拟学生线程
*/
public class StudentThread extends Thread {
Accout a;
public StudentThread(Accout a){
this.a = a;
start();
}
public void run(){
try{
while(true){
Thread.sleep(2000);
a.getMoney(); // 取钱
}
}catch(Exception e){}
}
}
package syn4;
/**
* 家长线程
*/
public class GenearchThread extends Thread {
Accout a;
public GenearchThread(Accout a){
this.a = a;
start();
}
public void run(){
try{
while(true){
Thread.sleep(12000);
a.saveMoney(); // 存钱
}
}catch(Exception e){}
}
}
package syn4;
/**
* 银行账户
*/
public class Accout {
int money = 0;
/**
* 取钱
* 如果账户没钱则等待,否则取出所有钱提醒存钱
*/
public synchronized void getMoney(){
System.out.println(" 准备取钱! ");
try{
if(money == 0){
wait(); // 等待
}
// 取所有钱
System.out.println(" 剩余 :" + money);
money -= 50;
// 提醒存钱
notify();
}catch(Exception e){}
}
/**
* 存钱
* 如果有钱则等待,否则存入 200 提醒取钱
*/
public synchronized void saveMoney(){
System.out.println(" 准备存钱! ");
try{
if(money != 0){
wait(); // 等待
}
// 取所有钱
money = 200;
System.out.println(" 存入 :" + money);
// 提醒存钱
notify();
}catch(Exception e){}
}
}
该程序的一部分执行结果为:
准备取钱!
准备存钱!
存入 :200
剩余 :200
准备取钱!
剩余 :150
准备取钱!
剩余 :100
准备取钱!
剩余 :50
准备取钱!
准备存钱!
存入 :200
剩余 :200
准备取钱!
剩余 :150
准备取钱!
剩余 :100
准备取钱!
剩余 :50
准备取钱!
在该示例代码中, TestAccount 类是测试类,主要实现创建帐户 Account 类的对象,以及启动学生线程 StudentThread 和启动家长线程 GenearchThread 。在 StudentThread 线程中,执行的功能是每隔 2 秒中取一次钱,每次取 50 元。在 GenearchThread 线程中,执行的功能是每隔 12 秒存一次钱,每次存 200 。这样存款和取款之间不仅时间间隔存在差异,而且数量上也会出现交叉。而该示例中,最核心的代码是 Account 类的实现。
在 Account 类中,实现了同步控制功能,在该类中包含一个关键的属性 money ,该属性的作用是存储帐户金额。在介绍该类的实现前,首先介绍一下两个同步方法—— wait 和 notify 方法的使用,这两个方法都是 Object 类中的方法,也就是说每个类都包含这两个方法,换句话说,就是 Java 天生就支持同步处理。这两个方法都只能在 synchronized 修饰的方法或语句块内部采用被调用。其中 wait 方法的作用是使调用该方法的线程休眠,也就是使该线程退出 CPU 的等待队列,处于冬眠状态,不执行动作,也不占用 CPU 排队的时间, notify 方法的作用是唤醒一个因为该对象的线程,该线程当前处于休眠状态,至于唤醒的具体是那个则不保证。在 Account 类中,被 StudentThread 调用的 getMoney 方法的功能是判断当前金额是否是 0 ,如果是则使 StudentThread 线程处于休眠状态,如果金额不是 0 ,则取出 50 元,同时唤醒使用该帐户对象的其它一个线程,而被 GenearchThread 线程调用的 saveMoney 方法的功能是判断当前是否不为 0 ,如果是则使 GenearchThread 线程处于休眠状态,如果金额是 0 ,则存入 200 元,同时唤醒使用该帐户对象的其它一个线程。
如果还是不清楚,那就结合前面的程序执行结果来解释一下程序执行的过程:在程序开始执行时,学生线程和家长线程都启动起来,所以输出“准备取钱”和“准备存钱”,然后学生线程按照该线程 run 方法的逻辑执行,先延迟 2 秒,然后调用帐户对象 a 中的 getMoney 方法,但是由于初始情况下帐户对象 a 中的 money 数值为 0 ,所以学生线程就休眠了。在学生线程执行的同时,家长线程也按照该线程的 run 方法的逻辑执行,先延迟 12 秒,然后调用帐户对象 a 中的 saveMoney 方法,由于帐户 a 对象中的 money 为零,条件不成立,所以执行存入 200 元,同时唤醒线程,由于使用对象 a 的线程现在只有学生线程,所以学生线程被唤醒,开始执行逻辑,取出 50 元,然后唤醒线程,由于当前没有线程处于休眠状态,所以没有线程被唤醒。同时家长线程继续执行,先延迟 12 秒,这个时候学生线程执行了 4 次,耗时 4X2 秒 =8 秒,就取光了帐户中的钱,接着由于帐户为 0 则学生线程又休眠了,一直到家长线程延迟 12 秒结束以后,判断帐户为 0 ,又存入了 200 元,程序继续执行下去。
在解决多线程问题是,互斥和同步都是解决问题的思路,如果需要形象的比较这两种方式的区别的话,就看一下下面的示例。一个比较忙的老总,桌子上有 2 部电话,在一部处于通话状态时,另一部响了,老总拿其这部电话说我在接电话,你等一下,而没有挂电话,这种处理的方式就是互斥。而如果老总拿其另一部电话说,我在接电话,等会我打给你,然后挂了电话,这种处理的方式就是同步。两者相比,互斥明显占用系统资源 ( 浪费电话费,浪费别人的时间 ) ,而同步则是一种更加好的解决问题的思路。