售票案例,极有可能碰到“意外”情况,如一张票被打印多次,或者打印出的票号为0甚至负数。这些“意外”都是由多线程操作共享资源tickets所导致的线程安全问题,接下来对案例进行修改,模拟四个窗口出售10张票,并在售票的代码中每次售票时线程休眠100毫秒,如文件1所示。
1 // 定义SaleThread类实现Runnable接口
2 class SaleThread implements Runnable {
3 private int tickets = 10; // 10张票
4 public void run() {
5 while (true) {
6 if (tickets > 0) {
7 try {
8 Thread.sleep(100); // 模拟售票耗时过程
9 } catch (InterruptedException e) {
10 e.printStackTrace();
11 }
12 System.out.println(Thread.currentThread().getName()
13 + " 正在发售第 " + tickets-- + " 张票 ");
14 }
15 }
16 }
17 }
18 public class Example11 {
19 public static void main(String[] args) {
20 SaleThread saleThread = new SaleThread();
21 // 创建并开启四个线程,模拟4个售票窗口
22 new Thread(saleThread, "窗口1").start();
23 new Thread(saleThread, "窗口2").start();
24 new Thread(saleThread, "窗口3").start();
25 new Thread(saleThread, "窗口4").start();
26 }
27 }
运行结果如图1所示。
图1 运行结果
图1中,最后几行打印售出的票为0和负数,这种现象是不应该出现的,因为在售票程序中做了判断只有当票号大于0时才会进行售票。运行结果中之所以出现了负数的票号是因为多线程在售票时出现了安全问题。
在售票程序的while循环中添加了sleep()方法,这样就模拟了售票过程中线程的延迟。由于线程有延迟,当票号减为1时,假设窗口2线程此时出售1号票,对票号进行判断后,进入while循环,在售票之前通过sleep()方法模拟售票时耗时操作,这时窗口1线程会进行售票,由于此时票号仍为1,因此窗口1线程也会进入循环,同理,四个线程都会进入while循环,休眠结束后,四个线程都会进行售票,这样就相当于将票号减了四次,结果中出现了1、0、-1、-2这样的票号。
线程安全问题其实就是由多个线程同时处理共享资源所导致的。要想解决线程安全问题,必须得保证处理共享资源的代码在任意时刻只能有一个线程访问。为此,Java中提供了线程同步机制。当多个线程使用同一个共享资源时,可以将处理共享资源的代码放置在一个使用synchronized关键字来修饰的代码块中,这段代码块被称作同步代码块,其语法格式如下:
synchronized(lock){
// 操作共享资源代码块
...
}
上述代码中,lock是一个锁对象,它是同步代码块的关键。当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1,此时线程会执行同步代码块,同时将锁对象的标志位置为0。当一个新的线程执行到这段同步代码块时,由于锁对象的标志位为0,新线程会发生阻塞,等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进入同步代码块执行其中的代码,这样循环往复,直到共享资源被处理完为止。这个过程就好比一个公用电话亭,只有前一个人打完电话出来后,后面的人才可以打。
接下来将售票的代码放到synchronized区域中进行修改,如文件1所示。
文件1 Example12.java
1 // 定义SaleThread2类实现Runnable接口
2 class SaleThread2 implements Runnable {
3 private int tickets = 10; // 10张票
4 Object lock = new Object(); // 定义任意一个对象,用作同步代码块的锁
5 public void run() {
6 while (true) {
7 synchronized (lock) { // 定义同步代码块
8 if (tickets > 0) {
9 try {
10 Thread.sleep(100); // 模拟售票耗时过程
11 } catch (InterruptedException e) {
12 e.printStackTrace();
13 }
14 System.out.println(Thread.currentThread().getName()
15 + " 正在发售第 " + tickets-- + " 张票 ");
16 }
17 }
18 }
19 }
20 }
21 public class Example12 {
22 public static void main(String[] args) {
23 SaleThread2 saleThread = new SaleThread2();
24 // 创建并开启四个线程,模拟4个售票窗口
25 new Thread(saleThread, "窗口1").start();
26 new Thread(saleThread, "窗口2").start();
27 new Thread(saleThread, "窗口3").start();
28 new Thread(saleThread, "窗口4").start();
29 }
30 }
运行结果如图1所示。
图1 运行结果
文件1中,将有关tickets变量的操作全部都放到同步代码块中synchronized (lock) {},从图10-16可以看出,售出的票不再出现0和负数的情况,这是因为售票的代码实现了同步,之前出现的线程安全问题得以解决。
注意
同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是相同的。“任意”说的是共享锁对象的类型。所以,锁对象的创建代码不能放到run()方法中,否则每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位,线程之间便不能产生同步的效果。
同步代码块可以有效解决线程的安全问题,当把共享资源的操作放在synchronized定义的区域内时,便为这些操作加了同步锁。同样,在方法前面也可以使用synchronized关键字来修饰,被修饰的方法为同步方法,它能实现和同步代码块同样的功能,具体语法格式如下:
[修饰符] synchronized 返回值类型 方法名([参数1,……]){}
被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行。
接下来使用同步方法模拟售票系统,如文件1所示。
文件1 Example13.java
1 // 定义SaleThread3类实现Runnable接口
2 class SaleThread3 implements Runnable {
3 private int tickets = 10;
4 public void run() {
5 while (true) {
6 saleTicket(); // 调用售票方法
7 }
8 }
9 // 定义一个同步方法saleTicket()
10 private synchronized void saleTicket() {
11 if (tickets > 0) {
12 try {
13 Thread.sleep(100); // 模拟售票耗时过程
14 } catch (InterruptedException e) {
15 e.printStackTrace();
16 }
17 System.out.println(Thread.currentThread().getName()
18 + " 正在发售第 " + tickets-- + " 张票 ");
19 }
20 }
21 }
22 public class Example13 {
23 public static void main(String[] args) {
24 SaleThread3 saleThread = new SaleThread3();
25 // 创建并开启四个线程,模拟4个售票窗口
26 new Thread(saleThread, "窗口1").start();
27 new Thread(saleThread, "窗口2").start();
28 new Thread(saleThread, "窗口3").start();
29 new Thread(saleThread, "窗口4").start();
30 }
31 }
运行结果如图1所示。
图1 运行结果
文件1中,将售票代码抽取为售票方法saleTicket(),并用synchronized关键字把saleTicket()修饰为同步方法,然后在run()方法中调用该方法。从图1可以看出,同样没有出现0号和负数号的票,说明同步方法实现了和同步代码块一样的效果。
思考:
大家可能会有这样的疑问:同步代码块的锁是自己定义的任意类型的对象,那么同步方法是否也存在锁?如果有,它的锁是什么呢?答案是肯定的,同步方法也有锁,它的锁就是当前调用该方法的对象,也就是this指向的对象。这样做的好处是,同步方法被所有线程所共享,方法所在的对象相对于所有线程来说是唯一的,从而保证了锁的唯一性。当一个线程执行该方法时,其他的线程就不能进入该方法中,直到这个线程执行完该方法为止,从而达到了线程同步的效果。
有时候需要同步的方法是静态方法,静态方法不需要创建对象就可以直接用“类名.方法名()”的方式调用。这时候我们就会有一个疑问,如果不创建对象,静态同步方法的锁就不会是this,那么静态同步方法的锁是什么?Java中静态方法的锁是该方法所在类的class对象,该对象可以直接类名.class的方式获取。
同步代码块和同步方法解决多线程问题有好处也有弊端。同步解决了多个线程同时访问共享数据时的线程安全问题,只要加上同一个锁,在同一时间内只能有一条线程执行,但是线程在执行同步代码时每次都会判断锁的状态,非常消耗资源,效率较低。
synchronized同步代码块和同步方法使用一种封闭式的锁机制,使用起来非常简单,也能够解决线程同步过程中出现的线程安全问题,但也有一些限制,例如它无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。
从JDK 5开始,Java增加了一个功能更强大的Lock锁。Lock锁与synchronized隐式锁在功能上基本相同,其最大的优势在于Lock锁可以让某个线程在持续获取同步锁失败后返回,不再继续等待,另外Lock锁在使用时也更加灵活。
接下来将售票案例改为使用Lock锁进行演示,如文件1所示。
文件1 Example14.java
1 import java.util.concurrent.locks.*;
2 // 定义LockThread类实现Runnable接口
3 class LockThread implements Runnable {
4 private int tickets = 10; // 10张票
5 // 定义一个Lock锁对象
6 private final Lock lock = new ReentrantLock();
7 public void run() {
8 while (true) {
9 lock.lock(); // 对代码块进行加锁
10 if (tickets > 0) {
11 try {
12 Thread.sleep(100); // 模拟售票耗时过程
13 System.out.println(Thread.currentThread().getName()
14 + " 正在发售第 " + tickets-- + " 张票 ");
15 } catch (InterruptedException e) {
16 e.printStackTrace();
17 }finally{
18 lock.unlock(); // 执行完代码块后释放锁
19 }
20 }
21 }
22 }
23 }
24 public class Example14 {
25 public static void main(String[] args) {
26 LockThread lockThread = new LockThread();
27 // 创建并开启四个线程,模拟4个售票窗口
28 new Thread(lockThread, "窗口1").start();
29 new Thread(lockThread, "窗口2").start();
30 new Thread(lockThread, "窗口3").start();
31 new Thread(lockThread, "窗口4").start();
32 }
33 }
运行结果如图1所示。
图1 运行结果
文件1中,通过Lock接口的实现类ReentrantLock来创建一个Lock锁对象,并通过Lock锁对象的lock()方法和unlock()方法对核心代码块进行了上锁和解锁。从图1可以看出,使用Lock同步锁也可以实现正常售票,解决线程同步过程中的安全问题。
需要注意的是,ReentrantLock类是Lock锁接口的实现类,也是常用的同步锁,在该同步锁中除了lock()方法和unlock()方法外,还提供了一些其他同步锁操作的方法,例如tryLock()方法可以判断某个线程锁是否可用。另外,在使用Lock同步锁时,可以根据需要在不同代码位置灵活的上锁和解锁,为了保证所有情况下都能正常解锁以确保其他线程可以执行,通常情况下会在finally{}代码块中调用unlock()方法来解锁。