(Java 高并发程序设计) 一篇文章带你深入了解关键字 synchronized 的功能扩展:重入锁

一、重入锁

重入锁可以完全替代关键字synchronized。在JDK 5.0的早期版本中,重入锁的性能远远优于关键字synchronized,但从JDK 6.0开始,JDK在关键字synchronized上做了大量的优化,使得两者的性能差距并不大。重入锁使用java.util.concurrent.locks.ReentrantLock类来实现。下面是一段最简单的重入锁使用案例。

import java.util.concurrent.locks.ReentrantLock;

public class ReenterLock implements Runnable {
    public static ReentrantLock lock = new ReentrantLock();
    public static int i=0;

    public void run() {
        for(int j=0;j<10000000;j++){
            lock.lock();
            try {
                i++;
            }finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReenterLock r1 = new ReenterLock();
        Thread t1 = new Thread(r1);
        Thread t2 = new Thread(r1);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

上述代码第8~13行使用重入锁保护临界区资源i,确保多线程对i操作的安全性。从这段代码可以看到,与关键字synchronized相比,重入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。也正因为这样,重入锁对逻辑控制的灵活性要远远优于关键字synchronized。但值得注意的是,在退出临界区时,必须记得释放锁(代码第13行),否则,其他线程就没有机会再访问临界区了。

你可能会对重入锁的名字感到奇怪。锁为什么要加上“重入”两个字呢?从类的命名上看,Re- Entrant-Lock翻译成重入锁非常贴切。之所以这么叫,是因为这种锁是可以反复进入的。当然,这里的反复仅仅局限于一个线程。上述代码的第8~13行,可以写成下面的形式:

在这里插入图片描述
在这种情况下,一个线程连续两次获得同一把锁是允许的。如果不允许这么操作,那么同一个线程在第2次获得锁时,将会和自己产生死锁。程序就会“卡死”在第2次申请锁的过程中。但需要注意的是,如果同一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放锁的次数多了,那么会得到一个java.lang.IllegalMonitorStateException异常,反之,如果释放锁的次数少了,那么相当于线程还持有这个锁,因此,其他线程也无法进入临界区。

除使用上的灵活性以外,重入锁还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。

二、中断响应

对于关键字synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,那就是线程可以被中断。
也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。

比如,你和朋友约好一起去打球,如果你等了半个小时朋友还没有到,你突然接到一个电话,说由于突发情况,朋友不能如约前来了,那么你一定扫兴地打道回府了。中断正是提供了一套类似的机制。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的

下面的代码产生了一个死锁,但得益于锁中断,我们可以很轻易地解决这个死锁。

import java.util.concurrent.locks.ReentrantLock;

public class IntLock implements Runnable {
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    public IntLock(int lock) {
        this.lock = lock;
    }

    @Override
    public void run() {
        try {
            if (lock == 1) {
                lock1.lockInterruptibly();
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                }
                lock2.lockInterruptibly();
            } else {
                lock2.lockInterruptibly();
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                }
                lock1.lockInterruptibly();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (lock1.isHeldByCurrentThread()) {
                lock1.unlock();
            }
            if (lock2.isHeldByCurrentThread()) {
                lock2.unlock();
            }
            System.out.println(Thread.currentThread().getName() + " ---> 线程退出");
            System.out.println(Thread.currentThread().getId() + " ---> 线程退出");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        IntLock r1 = new IntLock(1);
        IntLock r2 = new IntLock(2);
        Thread t1 = new Thread(r1,"线程 r1");
        Thread t2 = new Thread(r2,"线程 r2");
        t1.start();
        t2.start();
        Thread.sleep(1000);
        t2.interrupt();
    }
}

线程t1和t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。因此,很容易形成t1和t2之间的相互等待。在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断。

在代码第51行,主线程main处于休眠状态,此时,这两个线程处于死锁的状态。在代码第49行,由于t2线程被中断,故t2会放弃对lock1的申请,同时释放已获得的lock2。这个操作导致t1线程可以顺利得到lock2而继续执行下去。

在这里插入图片描述

可以看到,中断后两个线程双双退出,但真正完成工作的只有t1,而t2线程则放弃其任务直接退出,释放资源。

三、锁申请等待限时

除了等待外部通知之外,要避免死锁还有另外一种方法,那就是限时等待。依然以约朋友打球为例,如果朋友迟迟不来,又无法联系到他,那么在等待1到2个小时后,我想大部分人都会扫兴离去。对线程来说也是这样。通常,我们无法判断为什么一个线程迟迟拿不到锁。

也许是因为死锁了,也许是因为产生了饥饿。如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。我们可以使用tryLock()方法进行一次限时的等待。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TimeLock implements Runnable {
    public static ReentrantLock lock = new ReentrantLock();

    @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();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        TimeLock lock1 = new TimeLock();
        Thread t1 = new Thread(lock1);
        Thread t2 = new Thread(lock1);
        t1.start();
        t2.start();
    }
}

在这里,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。这里的单位设置为秒,时长为5,表示线程在这个锁请求中最多等待5秒。如果超过5秒还没有得到锁,就会返回false。如果成功获得锁,则返回true。在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获得锁,因此请求锁会失败。

ReentrantLock.tryLock()方法也可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,因此也不会产生死锁。下面演示了这种使用方式:

import java.util.concurrent.locks.ReentrantLock;

public class TryLock implements Runnable {
    // 这里书中没有提到,这儿使用static,所以创建两个TryLock对象才会共用lock1和lock2
    // 如果没有static会很快结束程序,因为使用的是自己的lock1和lock2不会互相锁住
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;

    public TryLock(int lock) {
        this.lock = lock;
    }


    @Override
    public void run() {
        if (lock == 1) {
            while (true) {
                if (lock1.tryLock()) {
                    try {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        if (lock2.tryLock()) {
                            try {
                                System.out.println(Thread.currentThread().getId() + " my job done");
                                return;
                            } finally {
                                lock2.unlock();
                            }
                        }
                    } finally {
                        lock1.unlock();
                    }
                }
            }
        } else {
            while (true) {
                if (lock2.tryLock()) {
                    try {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        if (lock1.tryLock()) {
                            try {
                                System.out.println(Thread.currentThread().getId() + " my job done");
                                return;
                            } finally {
                                lock1.unlock();
                            }
                        }
                    } finally {
                        lock2.unlock();
                    }
                }
            }
        }
    }

    public static void main(String[] args) {
        TryLock lock1 = new TryLock(1);
        TryLock lock2 = new TryLock(2);
        Thread t1 = new Thread(lock1);
        Thread t2 = new Thread(lock2);
        t1.start();
        t2.start();
    }
}

上述代码采用了非常容易死锁的加锁顺序。也就是先让t1获得lock1,再让t2获得lock2,接着做反向请求,让t1申请lock2,t2申请lock1。在一般情况下,这会导致t1和t2相互等待,从而引起死锁。

但是使用tryLock()方法后,这种情况就大大改善了。由于线程不会傻傻地等待,而是不停地尝试,因此,只要执行足够长的时间,线程总是会得到所有需要的资源,从而正常执行(这里以线程同时获得lock1和lock2两把锁,作为其可以正常执行的条件)。在同时获得lock1和lock2后,线程就打印出标志着任务完成的信息“MyJob done”。

执行上述代码,等待一会儿(由于线程中包含休眠500毫秒的代码)。最终你还是可以欣喜地看到程序执行完毕,并产生如下输出,表示两个线程双双正常执行。

在这里插入图片描述

四、公平锁

在大多数情况下,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1可以获得锁还是线程2可以获得锁呢?
这是不一定的,系统只是会从这个锁的等待队列中随机挑选一个。因此不能保证其公平性。这就好比买票不排队,大家都围在售票窗口前,售票员忙得焦头烂额,也顾不及谁先谁后,随便找个人出票就完事了。而公平的锁,则不是这样,它会按照时间的先后顺序,保证先到者先得,后到者后得。
公平锁的一大特点是:它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。如果我们使用synchronized关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它的构造函数如下:

在这里插入图片描述
当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下,因此,在默认情况下,锁是非公平的。如果没有特别的需求,则不需要使用公平锁。公平锁和非公平锁在线程调度表现上也是非常不一样的。下面的代码可以很好地突出公平锁的特点:

public class FairLock implements Runnable {
    //指定锁是公平的
    public static ReentrantLock fairLock = new ReentrantLock(true);

    @Override
    public void run() {
        while (true) {
            try {
                fairLock.lock();
                System.out.println(Thread.currentThread().getName());
            } finally {
                fairLock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        FairLock r1 = new FairLock();
        Thread t1 = new Thread(r1, "Thread_t1");
        Thread t2 = new Thread(r1, "Thread_t2");
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
在这个输出中,很明显可以看到,两个线程基本上是交替获得锁的,几乎不会发生同一个线程连续多次获得锁的可能,从而保证了公平性。如果不使用公平锁,那么情况会完全不一样,下面是使用非公平锁时的部分输出:

在这里插入图片描述
可以看到,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的,但是无公平性可言。

五、总结

对上面ReentrantLock的几个重要方法整理如下:

  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,则返回true,失败返回false。该方法不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。
  • unlock():释放锁。

就重入锁的实现来看,它主要集中在Java层面。在重入锁的实现中,主要包含三个要素。

第一,原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有了。

第二,等待队列。所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统就能从等待队列中唤醒一个线程,继续工作。

第三,阻塞原语park()和unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值