Java高并发学习(八)-------多线程的团队协作:同步控制
同步控制是并发程序必不可少的重要手段。之前介绍的synchronized关键字就是一种最简单的控制方法。同时,wait()和notify()方法起到了线程等待和通知的作用。这些工具对于实现复杂的多线程协作起到了重要的作用。接下来将介绍synchronized,wait,notify方法的代替品(或者说是增强版)——重入锁。
1. synchronized的功能扩展: 重入锁
重入锁完全可以代替synchronized关键字。在早期JDK版本,重入锁的性能远远优于synchronized关键字,在JDK后期版本,对synchronized关键字做了大量的优化,使得两者的性能差不多。
下面展示一段简单的synchronized的使用案例:public class fist{
public static ReentrantLock Lock = new ReentrantLock();
public static int Count = 0;
public static class MyThread extends Thread{
@Override
public void run(){
for(int i=0;i<10000;i++){
Lock.lock();
Count++;
Lock.unlock();
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start();t2.start();
t1.join();t2.join();
System.out.println(Count);
}
}
上述代码创建了一个全局的ReentrantLock对象,这个对象就是重入锁对象,该对象的lock()和unlock()方法之间的代码区域就是重入锁的保护零界区,确保了多线程对Count变量的操作安全性。
从这段代码可以看到,与synchronized相比,重入锁有着显示操作的过程。开发人员必须手动指定何时加锁 ,何时释放锁。也正是因为这样,重入锁逻辑控制远远要好于synchronized。但值得注意的是,在退出零界区时,必须记得要释放锁,否者永远没有机会再访问零界区了,会造成其线程的饥饿甚至是死锁。
重入锁之所以被称作重入锁是因为重入锁是可以反复进入的。当然,这里的反复进入仅仅局限于一个线程。上诉代码还可以这样写:
for(int i=0;i<10000;i++){
Lock.lock();
Lock.lock();
Count++;
Lock.unlock();
Lock.unlock();
}
在这种情况下,一个线程连续两次获得同一把锁。这是允许的!但要注意的是,如果一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。
·中断响应
重入锁除了提供上述的基本功能外,还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。这是一个非常重要的功能,synchronized是没有中断功能的。在等待锁的过程中,程序可以根据需要取消对锁的请求。这是synchronized办不到的。也就是说,重入锁具有解除死锁的功能。
下面的代码产生了一个死锁,得益于锁的中断,我们可以轻易的解决这个死锁:
public class fist{
public static ReentrantLock Lock1 = new ReentrantLock();
public static ReentrantLock Lock2 = new ReentrantLock();
public static class MyThread extends Thread{
int flag;
MyThread(int flag){
this.flag = flag;
}
@Override
public void run(){
try{
if(flag == 1){
try {
Lock1.lockInterruptibly();
Thread.sleep(1000);
Lock2.lockInterruptibly();
System.out.println(flag+"号线程:完成工作");
} catch (InterruptedException e) {}
}
else if(flag == 2){
try {
Lock2.lockInterruptibly();
Thread.sleep(1000);
Lock1.lockInterruptibly();
System.out.println(flag+"号线程:完成工作");
} catch (InterruptedException e) {}
}
}finally{
//中断响应
if(Lock1.isHeldByCurrentThread()){
Lock1.unlock();
System.out.println(flag+":Lock1 interrupted unlock");
}
if(Lock2.isHeldByCurrentThread()){
Lock2.unlock();
System.out.println(flag+":Lock2 interrupted unlock");
}
System.out.println(flag+"号线程退出");
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread(1);
MyThread t2 = new MyThread(2);
t1.start();
t2.start();
Thread.sleep(3000);
t2.interrupt();
}
}
线程t1和线程t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这样很容易形成t1和t2之间的互相等待,造成死锁。在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中可以响应中断。
在t1和t2线程start后,主线程main进入休眠,此时t1和t2线程处于死锁状态,然后主线程main中断t2线程,故t2会放弃对lock1的请求,同时释放lock2。这个操作使得t1可以获得lock2从而继续执行下去。
执行上诉代码,将输出:
可以看到,中断后,两个线程双双退出。但真正完成工作的只有t1。而t2放弃任务直接退出,释放资源。
·锁申请等待限时
除了等待外部通知之外,还有一种避免死锁的方法,就是限时等待。通常,我们不会预料到系统在什么时候会产生死锁,就无法主动的解除死锁,最好的系统设计方式是,这个系统根本就不会产生死锁。我们可以用tryLock()方法进行限时等待。
下面这段代码展示了限时等待锁的使用:
public class fist{
public static ReentrantLock Lock = new ReentrantLock();
public static class MyThread extends Thread{
@Override
public void run(){
try {
if(Lock.tryLock(5,TimeUnit.SECONDS)){
Thread.sleep(6000);
}
else{
System.out.println("get lock failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
if(Lock.isHeldByCurrentThread()){
Lock.unlock();
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start();
t2.start();
}
}
输出结果:
在这里,trylock()接收两个参数,一个表示等待时长,另一个表示计时单位。这里设置为秒,时长为5,表示线程在这个锁的请求中,最多等待5秒。如果超过5秒还没有得到锁就返回false。如果成功就返回true。
在本例中,由于占用锁的线程会持有锁长达6秒,故另外一个线程无法在5秒内获得锁,因此,对锁的请求会失败。
tryLock()方法也可以不带参数直接运行,在这种情况下,当前进程会尝试获得锁,如果锁并未被其他进程占用,则申请就会成功,立即返回true。如果锁被其他线程占用,会立即返回false。这种模式不会引起线程的等待,因此不会造成死锁。下面演示了这种使用方式:
public class fist{
public static ReentrantLock Lock1 = new ReentrantLock();
public static ReentrantLock Lock2 = new ReentrantLock();
public static class MyThread extends Thread{
int flag;
MyThread(int flag){
this.flag = flag;
}
@Override
public void run(){
if(flag == 1){
while(true){
if(Lock1.tryLock()){
try {
Thread.sleep(500);
if(Lock2.tryLock()){
System.out.println(flag+"号线程完成工作");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(Lock2.isHeldByCurrentThread()){
Lock2.unlock();
}
if(Lock1.isHeldByCurrentThread()){
Lock1.unlock();
}
}
}
else if(flag == 2){
while(true){
if(Lock2.tryLock()){
try {
Thread.sleep(300);
if(Lock1.tryLock()){
System.out.println(flag+"号线程完成工作");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(Lock1.isHeldByCurrentThread()){
Lock1.unlock();
}
if(Lock2.isHeldByCurrentThread()){
Lock2.unlock();
}
}
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread(1);
MyThread t2 = new MyThread(2);
t1.start();
t2.start();
}
}
上述代码中,采用了非常容易死锁的加锁顺序。也就是先让线程t1请求lock1,在请求lock2,而让t2先请求lock2,在请求lock1。在一般情况下,这样会导致t1,t2互相等待,从而引起死锁。
但是采用trylock后,这种情况得到了改善。由于线程不会傻傻的等待,而是不停的尝试,因此, 只要执行足够长的时间,线程总是会获得所需要的资源,从而正常执行(这里以线程能同时获得lock1和lock2两把锁视为正常执行)。
代码执行结果如下:
可以看到,1号线程和2号线程都是有机会被执行到的。但是不能保证,谁先被执行和被执行的次数是平均的。不知道各位有没有注意上述的一句话:“只要执行足够长的时间,线程总是会获得所需要的资源”,这句话给我们提供了两个信息。第一,两个线程都会有被执行到的几乎,第二,不能保证这两个线程被公平的执行。实际上这两个线程也是在互相争夺同一个资源,这两个线程到底谁会被执行,这依靠的是操作系统对线程的调度策略。
在上述程序执行一段时间后:
我们可以看到1号线程被执行次数比较多。这表示操作系统的调度不能满足资源平均分配这一需求。这里在多说一句,根据目前大多数操作系统的调度,一个线程倾向于在次获得持有的锁,这种分配方法是高效的,但毫无公平性可言。
·公平锁
在大多数情况下,如上述情况,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么锁A可用时,线程1可以获得锁还是线程2可以获得锁呢?这是不一定的,系统只会从这个锁的等待队列中随机挑取一个。因此不能保证公平性。
而接下来要讲的公平锁,他会按照时间的先后顺序,保证先到者先得,后到者后得。所以,公平锁的最大特点就是,他不会产生饥饿现象。
注意:如果线程采用synchronized进行互斥,那么产生的锁是非公平的。而重入锁允许我们进行公平性设置。他有一个如下的构造函数:
public ReentranLock(boolean fair);
当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁,必然要求系统维护一个有序队列,因此对公平锁得到实现成本比较高,意味着公平锁的效率非常低下,因此,在默认情况下,锁是非公平的。如果没有什么特别的需求,尽量别用公平锁。
下面代码能很好的凸显公平锁的特点:
public class fist{
public static ReentrantLock Lock = new ReentrantLock(true);
public static class MyThread extends Thread{
@Override
public void run(){
while(true){
Lock.lock();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getId() + ": 获得锁");
Lock.unlock();
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start();
t2.start();
}
}
代码执行结果:
可以看到,线程的调度是公平的。
2. 从入锁的好搭档: Condition条件
如果大家了解object.wait()方法和object.notify()方法的HIA,那么就能很容易理解condition对象了。他和wait()和notify()方法的作用是基本相同的。但是wait()和notify()方法是与synchronized关键字组合使用的,而condition是与重入锁相关联的。Condition接口提供的基本方法如下:
Void await() throws InterrupteException;
Void awaitUninterruptibly();
Long awaitNanos(long nanosTimeout) throws InterrupteException;
Boolean await(long time, TimeUnit unit) throws InterrupteException;
Boolean awaitUntil(Data deadline) throws InterrupteException;
Void signal();
Void signalAll();
以上方法含义如下:
·await()方法会使当前线程等待,同时释放当前锁,当其他线程使用signal()或signalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和object.wait()方法很相似。
·awaitUninterruptibly()方法与wait()方法相同,唯一的不同点是,该方法不会再等待的过程中响应中断。
·signal()方法用于唤醒一个在等待中的线程。signalAll()会唤醒所有正在等待的线程。这和object.notify()方法很相似。
下面代码简单的演示了Condition的作用:
public class fist{
public static ReentrantLock Lock = new ReentrantLock(true);
//生成Lock对应的condition对象
public static Condition condition = Lock.newCondition();
public static class MyThread extends Thread{
@Override
public void run(){
try {
Lock.lock();
condition.await();
System.out.println("Thread is going on");
Lock.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String args[]) throws InterruptedException{
MyThread t1 = new MyThread();
t1.start();
//两秒后通知线程继续执行
Thread.sleep(2000);
Lock.lock();
condition.signal();
Lock.unlock();
}
}
上述代码先通过lock生成一个与之绑定的condition对象。后要求线程在condition对象上进行等待。主线程main在两秒后发出signal通知,告知等待在condition上的线程可以继续执行了。
和object.wait()和object.notify()一样,当线程使用Condition.wait()时,要求线程持有相关的从入锁,在condition.wait()调用后,这个线程会主动释放这把锁。并且,在condition.signal()方法调用时,也要求线程获取相关的锁。注意,在signal()方法调用之后,一定要释放相关的锁,把锁让给其他线程。
当主线程调用signal()方法之后,会从等待队列中随机唤醒一个wait中的线程,这个线程会重新进行锁的争夺。这里很容易让读者产生一个疑问,在线程唤醒后是重新执行零界区代码,还是继续执行condition.wait()方法后的代码?这里给出答复,是继续执行condition.wait()方法后的代码(JDK会像中断一样保存线程断点)。
3. 允许多个线程同时访问:信号量(Semaphore)
信号量为多线程协作提供了更为强大的控制方法。广义上说,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentranLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问一个资源。信号量主要提供了一下的构造函数:
Public Semaphore(int permits);
Public Semaphore(int permits, boolean fair); //第二个参数可以指定是否公平
在构造信号量时,必须指定信号量的准入数,即同时能申请几个许可。当每个线程只申请一个许可时,这就相当于指定了同时能有多少个线程可以访问某个资源。信号量的主要逻辑方法有:
Public void acquire();
Public void acquireUninterruptibly();
Public boolean tryAcquire();
Public boolean tryAcquire(long timeout, TimeUnit unit);
Public void release();
acquire()方法尝试获得一个准入的许可。若无法获得,则线程会等待,直到申请到许可或者当前线程被中断。acquireUninterruptibly()方法与acquire()方法类似,但不响应中断。tryAcquire()尝试获得一个许可,成功返回true失败返回false,它不会进行阻塞等待,立即返回。release()用于在线程访问资源结束后,释放一个许可,以使其他等待许可的线程可以进行资源访问。
下面是Semaphore的简单使用:
public class fist{
public static Semaphore semp = new Semaphore(5);
public static class MyThread extends Thread{
@Override
public void run(){
try {
semp.acquire();
//模拟耗时操作
Thread.sleep(2000);
System.out.println(Thread.currentThread().getId()+": done!");
semp.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String args[]) throws InterruptedException{
ExecutorService exec = Executors.newFixedThreadPool(20);
MyThread t1 = new MyThread();
for(int i=0;i<20;i++){
exec.submit(t1);
}
}
}
在本例中同时开启了20个线程。观察上述程序的输出,你会发现线程以5个线程为一组依次输出。
4. ReadWriteLock 读写锁
ReadWriteLock 是JDK5中提供的读写分离锁。读写锁能有效的帮助减少锁竞争,以提升系统性能。
·读-读不互斥:读读之间不阻塞。
·读-写互斥:读阻塞写,写也会阻塞读。
·写-写互斥:写写阻塞。
如果系统中,读操作次数远远大于写操作,则读写锁可以发挥最大的功效,提升系统性能。这里给出一个稍微夸张的案例,来说明读写锁对性能的帮助。
public class fist{
//普通锁
private static Lock lock = new ReentrantLock();
//读写锁
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Lock readLock = readWriteLock.readLock();
private static Lock writeLock = readWriteLock.writeLock();
private static int value;
private static int runtime = 0;
//模拟读操作
public static int handleRead(Lock lock) throws InterruptedException{
try {
lock.lock();
Thread.sleep(1000);
return value;
}finally{
lock.unlock();
}
}
//模拟写操作
public static void handleWrite(Lock lock, int index) throws InterruptedException{
try {
lock.lock();
Thread.sleep(1000);
value = index;
}finally{
lock.unlock();
}
}
public static class Mythread_Read extends Thread{
@Override
public void run(){
try {
//handleRead(readLock); //使用读写锁
handleRead(lock); //使用普通锁
} catch (InterruptedException e) {}
}
}
public static class Mythread_Write extends Thread{
@Override
public void run(){
try {
//handleWrite(writeLock, 0); //使用读写锁
handleWrite(lock, 0); //使用普通锁
} catch (InterruptedException e) {}
}
}
//守护线程,用来记录运行时间
public static class Deamon extends Thread{
@Override
public void run(){
try {
while(true){
System.out.println("use time: " + runtime);
runtime++;
Thread.sleep(1000);
}
} catch (InterruptedException e) {}
}
}
public static void main(String args[]) throws InterruptedException{
Deamon deamon = new Deamon();
deamon.setDaemon(true);
deamon.start();
for(int i=0;i<18;i++){
Mythread_Read read = new Mythread_Read();
read.start();
}
for(int i=0;i<2;i++){
Mythread_Write write = new Mythread_Write();
write.start();
}
}
}
上述代码中,比较了使用读写锁和普通锁时,系统完成读写任务所需要的时间,这里设置读任务要比写任务多得多。设置一个守护线程来记录整个读写操作完成所需要的时间。
执行结果(不用读写锁):
可以看到,不用读写锁,程序花费了20秒的时间才完成读写任务。
执行结果(采用读写锁):
可以看到,采用读写锁,程序需要3秒就完成读写任务了。
所以,当一个系统中读者数量明显多于写者时,使用读写锁能大大减小系统的开销,这一点非常重要。