解决并发环境下线程安全问题的最基本策略就是使用“锁”。本节将详细地介绍如何用各种方式的“锁”来实现同步机制,从而保障共享资源在高并发环境中的线程安全性。
当多个线程同时访问同一个资源(对象、方法或代码块)时,经常会出现一些“不安全”的情况。例如,假设有100张火车票,同时被t1和t2两个站点售卖,就可能会出现火车票数据“不安全”的情况,代码如下。
范例3-4 并发售票
【 源码: demo/ch03/ThreadDemo01.java】
运行此程序,一种可能的结果如图3—8所示。
图3—8 并发售票的运行结果
可以发现,t1和t2两个线程同时销售了编号为100的车票,显然是不对的。造成这种错误的原因是t1和t2在争夺资源(即变量tickets)时,同时执行了sellTickets()方法,代码如下。
初始tickets=100,假设t2刚刚执行完第12行但还没有执行第13行(即还没有tickets--)时,t1也去执行了第12行,就会出现重复打印ticket=100的情况。
“非线程安全”就是指这种由于线程的异步特性而造成的并发问题。为了解决这种问题,可以使用synchronized关键字给共享的资源加锁。
具体地讲,可以使用synchronized来给方法或代码块加锁,语法如下所示。
给代码块加锁时,传入的“任意对象”该如何理解?例如,有多个人(多线程)去使用卫生间,如果某一个人(线程)已经占用了卫生间,那么他就可以在卫生间门口挂个牌子表示有人,也可以把卫生间门口的提示灯打开表示有人。因此,无论是“牌子”“提示灯”还是其他物体都没关系,只要能告知其他人(其他线程)该卫生间已被占用就可以了。同样地,在多线程看来,无论用什么对象,都可以实现加锁的目的。
对共享资源加锁或解锁的时机如下所述。
加锁。当某一个线程开始访问某个资源时,该线程就会对这个资源加锁,之后就会独占该资源。
解锁。当满足以下任一条件时,独占该资源的线程就会对该资源进行解锁:
①线程将资源访问完毕时;
②线程访问资源出现了异常时。
以给方法methodX()加锁为例,methodX()被加了锁之后,只能允许锁的拥有者去执行methodX()。具体地讲,当有多个线程同时访问methodX()时,多个线程之间会去争夺methodX()的访问权(即争夺锁)。同一时间只能有一个线程(称为A)立刻执行methodX(),其他线程只能等待(A在执行该methodX()时,会给该方法加锁)。当A执行完methodX()后,会给methodX()解锁;其他线程发现methodX()被解锁后,就会再次去争夺methodX()的访问权,获胜者再去加锁并单独访问methodX()……
因此,只需要给sellTickets()方法加上synchronized,就可以保证“线程安全”,代码如下所示。
注意
(1)什么时候需要加synchronized ?当某一个资源被共享时,就可以考虑给该资源加上synchronized,确保线程安全;但如果某资源不是共享资源(不会被多个线程共用),就不需要加synchronized。
(2)当被加了锁的资源在执行过程中出现异常时,锁也会被释放。因此,在并发程序中一定要将异常及时处理,否则会影响并发的逻辑。
(3)如果给某个资源加了锁,在多线程共享时要注意避免产生死锁。例如,有2个共享资源resource1和resource2,如果某一时刻线程1给resource1加了锁并同时等待使用resource2,而与此同时,线程2也给resource2加了锁并在等待使用resource1,这样便形成了死锁,两个线程会一直处于等待状态(都在等待对方释放资源),如图3—9所示。
图3—9 死锁
范例3-5 死锁
【 源码: demo/ch03/DeadLock.java】
鉴于篇幅有限,读者可以在本书赠送的配套资源中查看本例源码。
产生死锁的根本原因有两个:一是系统资源有限,如果本例中有多个resource1,那么线程1和线程2各自就能够获取一个resource1,就不会出现死锁;二是多个线程(或进程)之间的执行顺序不合理。可以通过“打破死锁的4个必要条件”“银行家算法”等方式来避免死锁的产生,读者可以自行查阅操作系统的相关知识进行学习。
多个线程在争夺同一个资源时,为了让这些线程协同工作、提高CPU利用率,可以让线程之间进行通信,具体可以通过wait()和notify()(或notifyAll())实现,这些方法的含义如下所述。
(1)wait():使当前线程处于等待状态(阻塞),直到其他线程调用此对象的notify()方法或notifyAll()方法。
(2)notify(): 唤醒在此对象监视器上等待的单个线程;如果有多个线程同时在监视器上等待,则随机唤醒一个。
(3)notifyAll(): 唤醒在对象监视器上等待的所有线程。
简言之,wait()会使线程阻塞,而notify()或notifyAll()可以唤醒线程,使之成为就绪状态。
在实际使用这些方法时,还要注意以下几点。
(1)这3个方法都是在Object类中定义的native方法,而不是Thread类提供的。这是因为Java提供的锁是对象级的,而不是线程级的。
(2)这3个方法都必须在synchronized修饰的方法(或代码块)中使用,否则会抛出异常java.lang.IllegalMonitorStateException。
(3)在使用wait()时,为了避免并发带来的问题,通常建议将wait()方法写在循环的内部。JDK在定义此方法时,也对此增加了注释说明,以下是Object类的部分源码。
【 源码: java.lang.Object】
下面,通过一个生产者与消费者的案例,强化对线程通信的理解。本案例的执行逻辑如下所述。
(1)生产者(CarProducter)不断地向共享缓冲区中增加数据(本例用cars++模拟)。
(2)同时,消费者不断地从共享缓冲区中消费数据(cars--)。
(3)共享缓冲区有固定大小的容量(本例为20)。
(4)当产量达到最大值(20)时,生产者无法再继续生产,生产者的线程就会通过wait()使自己处于阻塞状态;直到有消费者减少了产量后(<20),再通过notify()或notifyAll()唤醒生产者去继续生产。
(5)当产量为0时,消费者无法再继续消费,消费者线程就通过wait()使自己处于阻塞状态;直到有生产者增加了产量后(>0),再通过notify()或notifyAll()唤醒消费者去继续消费。
这样一来,生产者和消费者就会在共享缓冲区0~20的范围内,达成一种动态平衡,如图3—10所示。
图3—10 生产者与消费者
范例3-6 生产者与消费者
【 源码: demo/ch03/ProducerAndConsumer.java】
运行程序,某一时刻的运行截图如图3—11所示。
图3—11 生产者与消费者共享变量程序运行截图
以上范例,是一个非常简单的生产者与消费者共享变量程序,生产者和消费者之间仅仅共享了一个int变量,如图3—12所示。
图3—12 生产者与消费者之间共享变量
接下来,使用队列、线程池等技术对本程序进行改进,并且此次共享的数据是一个BlockingQueue队列,该队列中最多可以保存100个CarData对象,如图3—13所示。
图3—13 生产者与消费者之间共享队列
【 源码: demo/ch03/producerconsumer/CarData.java】
【 源码: demo/ch03/producerconsumer/CarStock.java】
【 源码: demo/ch03/producerconsumer/CarProducter.java】
【 源码: demo/ch03/producerconsumer/CarConsumer.java】
【 源码: demo/ch03/producerconsumer/TestProducerAndConsumer.java】
某一时刻的运行截图如图3—14所示。
图3—14 生产者与消费者的运行截图
在前面两个生产者与消费者程序中,都是使用synchronized(给生产或消费)的方法加锁,然后通过wait()和notifyAll()进行线程通信。除此以外,还可以使用Lock给方法加锁,然后使用Condition接口提供的await()和signalAll()进行线程通信。二者的对应关系如表3—1所示。
表3—1 两种加锁方式的对比
下面使用Lock和Condition重构之前的第一个生产者消费者程序。
范例3-8 使用 Lock+Condition 实现生产者与消费者
汽车库存类CarStock
【 源码: demo/ch03/lock/CarStock.java】
其余代码,与之前的完全相同。
范例3-9 线程交替打印
再来完成一道线程通信的例题:建立3个线程,第一个线程打印1、第二个线程打印2、第三个线程打印3;要求3个线程交替打印,即按照123123123123…的顺序打印。
做本题的思路:先让第一个线程打印1,然后通知第二个线程打印2;第二个线程打印完2后,再通知第三个线程打印3;第三个线程打印完3后,再通知第一个线程打印1,如此循环。此外,每个线程在打印前,需要先判断,如果还没轮到自己打印,则等待;如果轮到自己,就立刻打印,并在打印完毕后通知下一个线程,代码如下所示。
【 源码: demo/ch03/print/LoopPrint123.java】
【 源码: demo/ch03/print/TestLoopPrint123.java】
某一时刻的运行截图,如图3—15所示。
图3—15 线程交替打印的运行截图
本例使用的是Lock和Condition的通信方式,读者可以使用synchronized中的wait()和notify()进行尝试。
Lock和synchronized两种加锁方式的主要区别如表3—2所示。
表3—2 两种加锁方式的区别
通过表3—2可知,如果使用synchronized加锁,当发生死锁时,相互争夺资源的线程就会一直等待。而如果使用Lock加锁,就可以避免这种情况。
范例3-10 尝试加锁
当一个线程加锁后,另一个线程不会一直等待,而会持续尝试5ms,如果一直失败再放弃加锁。
【 源码: demo/ch03/lock/TestTryLock.java】
运行结果如图3—16所示。
图3—16 使用Lock避免死锁
本程序使用tryLock()对资源进行加锁,各种加锁方法的简介如下。
(1)lock():立刻获取锁,如果锁已被占用则等待。
(2)tryLock():尝试获取锁,如果锁已被占用则返回false,且不再等待;如果锁处于空闲,则立刻获取锁,并返回true。
(3)tryLock(long time, TimeUnit unit):与tryLock()的区别是,该锁会在time时间段内不断地尝试,unit是time的时间单位。
范例3-11 中断等待
如果线程A使用Lock方式加了锁,并且长时间独占使用,那么线程B就会因为长时间都无法获取锁而一直处于等待的状态,但是这种等待的状态可能被其他线程中断。
【 源码: demo/ch03/lock/TestInterruptibly.java】
运行结果如图3—17所示。
图3—17 中断等待的运行结果
本程序中使用lockInterruptibly()进行加锁,与使用lock()的区别如下。
lock(): 一个线程加了锁之后,其他线程只能等待、不能被中断。
lockInterruptibly():一个线程加了锁之后,其他线程因为无法获取锁而导致的等待状态可以被中断。
通过以上示例可以发现,Lock比synchronized拥有更加强大的加锁功能。
为了保证共享的资源在并发访问时的数据安全,是否必须对共享的资源加锁(synchronized,Lock)?并非如此。还可以使用CAS(Compare and Swap)无锁算法。
实际上,加锁是处理并发最简单的方式,但对系统性能的损耗也是巨大的。例如,加锁、释放锁会导致系统多次进行上下文切换(内核态与用户态之间的切换),造成调度延迟等情况。因此,为了减少加锁对性能的损耗,还可以通过CAS算法来保证数据的安全。
加锁的方式可以理解为一种悲观的策略,该策略总是假设对数据的访问是不安全的(在一个线程访问数据的同时,其他线程也会修改此数据),因此总是会对要访问的数据加锁,然后独占式访问。
与之相反,CAS算法是一种乐观的策略,该策略总是假设对数据的访问是安全的(在一个线程访问数据的同时,其他线程不会操作此数据),因此每次会直接访问数据,访问时可能出现以下两种情况。
(1)如果要访问的数据已被其他线程修改(即数据不安全),就会放弃此次访问,再重新获取最新的数据(即被其他线程修改后的数据);如果重新获取最新数据时,又被另外的线程修改了刚刚“最新”的数据,就再次放弃此次访问,再重新获取最新的数据……
(2)如果要访问的数据没有冲突(从上次访问以后,没有其他线程对该数据进行修改),就直接访问该数据。
不难发现,CAS算法没有加锁操作,因此不会出现死锁。
Semaphore称为信号量,是引自操作系统中的概念。在Java中,Semaphore可以通过permits属性控制线程的并发数。
在使用了Semaphore的编程中,默认情况下所有线程都处于阻塞状态。可以用Semaphore的构造方法设置可执行线程的并发数(即permits的值,如3),然后通过acquire()方法允许同一时间只能有3个线程同时执行;并且在这3个线程中,如果某个线程执行完毕,就可以调用release()释放一次执行的机会,然后从其他等待的线程中随机选取一个来执行。
范例3-12 线程控制
同一时间内最多允许3个线程同时执行,但却有8个线程在尝试并发执行。
【 源码: demo/ch03/TestSemaphore.java】
运行结果如图3—18所示。
图3—18 使用Semaphor实现线程通信
范例3-13 线程交替打印
前面,我们使用Lock+Condition的方式做过“三个线程循环打印123…”的题目,读者可以使用Semaphore重做此题。
【 源码: demo/ch03/print/LoopPrint123WithSemaphore.java】
鉴于篇幅有限,读者可以在本书赠送的配套资源中查看本例源码。