多线程(五)可重入锁、读写锁
ReentrantLock、ReentrantReadWriteLock
本系列文章:
多线程(一)多线程基础
多线程(二)Java内存模型、同步关键字
多线程(三)线程池
多线程(四)显式锁、队列同步器
多线程(五)可重入锁、读写锁
多线程(六)线程间通信机制
多线程(七)原子操作、阻塞队列
多线程(八)并发容器
多线程(九)并发工具
多线程(十)多线程编程总结
一、ReentrantLock(可重入锁)
重入锁可以替代synchronized。在JDK5的早期版本中,重入锁的性能远远优于synchronized,但从JDK6开始,JDK在关键字synchronized上做了大量的优化,使得两者的性能差距并不大。
ReentrantLock(可重入锁),主要利用CAS+AQS队列来实现,支持公平锁和非公平锁。
ReentrantLock使用示例:
private Lock lock = new ReentrantLock();
public void test(){
lock.lock();
try{
doSomeThing();
}catch (Exception e){
}finally {
lock.unlock();
}
}
ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在队列队首的线程会被唤醒,然后CAS再次尝试获取锁。在这个时候,如果:
- 1、ReentrantLock对象是非公平锁
如果同时还有另一个线程进来尝试获取,那么有可能会让这个线程抢先获取; - 2、ReentrantLock对象是公平锁
如果同时还有另一个线程进来尝试获取,当它发现自己不是在队首的话,就会排到队尾,由队首的线程获取到锁。
1.1 ReentrantLock的特点(可重入/可响应中断/可实现公平锁/可设置超时时间)
1.1.1 可重入锁
可重入锁是指同一个线程可以多次获取同一把锁。ReentrantLock和synchronized都是可重入锁。
关于可重入性,示例:
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
method1();
}
public static void method1() {
lock.lock();
try {
log.debug("execute method1");
method2();
} finally {
lock.unlock();
}
}
public static void method2() {
lock.lock();
try {
log.debug("execute method2");
method3();
} finally {
lock.unlock();
}
}
public static void method3() {
lock.lock();
try {
log.debug("execute method3");
} finally {
lock.unlock();
}
}
1.1.2 可中断锁
可中断锁是指线程尝试获取锁的过程中,是否可以响应中断。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。
当使用synchronized实现锁时,阻塞在锁上的线程除非获得锁否则将一直等待下去,也就是说这种无限等待获取锁的行为无法被中断。而ReentrantLock给我们提供了一个可以响应中断的获取锁的方法lockInterruptibly()
,该方法可以用来解决死锁问题。
接下来看个例子:两个子线程,子线程在运行时会分别尝试获取两把锁。其中一个线程先获取锁1在获取锁2,另一个线程正好相反。如果没有外界中断,该程序将处于死锁状态永远无法停止。此时可以使其中一个线程中断,来结束线程间毫无意义的等待。被中断的线程将抛出异常,而另一个线程将能获取锁后正常结束。示例:
public class TestDemo {
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
//该线程先获取锁1,再获取锁2
Thread thread = new Thread(new ThreadDemo(lock1, lock2));
//该线程先获取锁2,再获取锁1
Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));
thread.start();
thread1.start();
thread.interrupt();//是第一个线程中断
}
static class ThreadDemo implements Runnable {
Lock firstLock;
Lock secondLock;
public ThreadDemo(Lock firstLock, Lock secondLock) {
this.firstLock = firstLock;
this.secondLock = secondLock;
}
@Override
public void run() {
try {
firstLock.lockInterruptibly();
TimeUnit.MILLISECONDS.sleep(10);//更好的触发死锁
secondLock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
firstLock.unlock();
secondLock.unlock();
System.out.println(Thread.currentThread().getName()+"正常结束!");
}
}
}
}
结果:
1.1.3 公平锁与非公平锁
公平锁是指多个线程同时尝试获取同一把锁时,锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO;而非公平锁则允许线程“插队”。synchronized是非公平锁,而ReentrantLock的默认实现是非公平锁,但是也可以设置为公平锁。公平锁会影响性能。
ReentrantLock提供了两个构造方法:
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
默认构造方法初始化为NonfairSync对象,即非公平锁,而带参数的构造器可以指定使用公平锁和非公平锁。
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
公平锁:加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得。
非公平锁:加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待。
- 非公平锁性能比公平锁高5~10倍,因为公平锁需要在多核的情况下维护一个队列。
- Java中的synchronized是非公平锁,ReentrantLock默认的lock()方法采用的是非公平锁。
- 公平策略与非公平策略
简单来说,如果一个线程先申请锁,先获得锁,就表示使用了公平策略。如果某个线程后申请锁,却先获得了锁,就表示使用了非公平策略。
一般来说,非公平调度策略的吞吐率较高
。它的缺点是:从申请者个体的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较大,即有的线程很快就能申请到资源,而有的线程则要经历若干次暂停与唤醒才能成功申请到资源,极端情况下可能导致饥饿现象
。
公平调度策略的吞吐率较低,这是其维护资源独占权的授予顺序的开销比较大(主要是线程的暂停与唤醒所导致的上下文切换)的结果。其优点是,从个体申请者的角度来看,这些申请者获得相应资源的独占权所需时间的偏差可能比较小,即每个资源申请者申请到资源所需的时间基本相同,并且不会导致饥饿现象。
synchronized是非公平锁,ReentrantLock可以设置公平锁或非公平锁,默认是非公平锁。其构造函数:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
当参数fair为true时,表示锁是公平的。公平锁要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能比较低下。因此,默认情况下,锁是非公平的。如果没有特别要求,则不需要使用公平锁。
公平锁与非公平锁的比较:
- 1、
公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序;而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象
。 - 2、公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
1.1.4 可以进行超时设置(避免了无限等待)
在ReetrantLock中,tryLock(long timeout, TimeUnit unit) 提供了超时获取锁的功能。它的语义是在指定的时间内如果获取到锁就返回true,获取不到则返回false。
超时机制避免了线程无限期的等待锁释放
。
tryLock方法可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,也不会产生死锁。
用超时机制解决死锁的例子:
public class TestDemo {
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
//该线程先获取锁1,再获取锁2
Thread thread = new Thread(new ThreadDemo(lock1, lock2));
//该线程先获取锁2,再获取锁1
Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));
thread.start();
thread1.start();
}
static class ThreadDemo implements Runnable {
Lock firstLock;
Lock secondLock;
public ThreadDemo(Lock firstLock, Lock secondLock) {
this.firstLock = firstLock;
this.secondLock = secondLock;
}
@Override
public void run() {
try {
while(!lock1.tryLock()){
TimeUnit.MILLISECONDS.sleep(10);
}
while(!lock2.tryLock()){
lock1.unlock();
TimeUnit.MILLISECONDS.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
firstLock.unlock();
secondLock.unlock();
System.out.println(Thread.currentThread().getName()+"正常结束!");
}
}
}
}
结果:
Thread-0正常结束!
Thread-1正常结束!
再看一个例子:
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(Thread.currentThread().getName()+" get lock fail");
}
}catch(InterruptedException e){
e.printStackTrace();
}finally {
// 查当前线程是否占用该锁
if(lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
public static void main(String[] args) {
TimeLock tl = new TimeLock();
Thread t1 = new Thread(tl);
Thread t2 = new Thread(tl);
t1.start();
t2.start();
}
}
上述代码中,由于t1、t2分配到CPU执行时间的不确定性,所以代码会输出“Thread-1 get lock fail”或“Thread-0 get lock fail”。
1.2 相关问题
1.2.1 tryLock(可以进行超时设置)、lock(没获取锁就一直等待/响应中断不抛异常)和lockInterruptibly(响应中断时抛异常)的区别
这3个方法都用来获取锁。
tryLock能获得锁就返回true,不能就立即返回false
,tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false。lock能获得锁就返回true,不能的话一直等待获得锁
。- lock和lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程,lock不会抛出异常,而lockInterruptibly会抛出异常。触发InterruptedException异常之后,线程的中断标志会被清空,即置为false。
1.2.2 ReentrantLock是如何实现可重入性的*
- 什么是可重入性
一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。 - ReentrantLock如何实现可重入性
ReentrantLock使用内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。Sync有两个实现,分别为NonfairSync(非公公平锁)和FairSync(公平锁)。Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计算重入次数(可以理解为计数器),避免频繁的持有释放操作带来的线程问题。
当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值+1,表示重入返回即可。
1.2.3 跟Synchronized相比,可重入锁ReentrantLock其实现原理有什么不同
其实,锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。
Synchronized通过在对象头中设置标记实现了这一目的,是一种JVM原生的锁实现方式,而ReentrantLock以及所有的基于Lock接口的实现类,都是通过用一个volitile修饰的int型变量,并保证每个线程都能拥有对该it的可见性和原子修改,其本质是基于所谓的AQS框架。
二、ReentrantReadWriteLock
有这样的场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了。
针对这种场景,可以用读写锁ReentrantReadWriteLock,它表示两个锁,一个是读操作相关的锁,称为共享锁;一个是写相关的锁,称为排他锁。
读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞(即读锁是共享锁,写锁是排他锁)
。即:
读-读不互斥:读读之间不阻塞。
读-写互斥:读阻塞写,写液会阻塞读。
写-写互斥:写写阻塞。
2.1 ReentrantReadWriteLock的特点(支持公平锁和非公平锁/可重入锁/锁可以降)
读写锁有以下三个重要的特性:
- 1、支持公平锁和非公平锁
支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。 - 2、可重入锁
读锁和写锁都支持线程重进入。 - 3、锁可以降级
遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
一个线程要想同时持有写锁和读锁,必须先获取写锁再获取读锁;写锁可以“降级”为读锁;读锁不能“升级”为写锁
。
2.2 读写锁的互斥性测试
- 1、基础代码
public class ReadWriteLockTest {
private ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock();
//获取写锁
public void getW(Thread thread) {
try {
rw1.writeLock().lock();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 10){
System.out.println(thread.getName() + "正在写操作");
}
System.out.println(thread.getName() + "写操作完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rw1.writeLock().unlock();
}
}
//获取读锁
public void getR(Thread thread) {
try {
rw1.readLock().lock();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 10){
System.out.println(thread.getName() + "正在读操作");
}
System.out.println(thread.getName() + "读操作完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rw1.readLock().unlock();
}
}
}
- 2、并发读
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.getR(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.getR(Thread.currentThread());
}
}.start();
}
结果:
Thread-1正在读操作
Thread-0正在读操作
Thread-1读操作完成
Thread-0读操作完成
可以看到读线程间是不用排队的。
- 3、并发写
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.getW(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.getW(Thread.currentThread());
}
}.start();
}
结果:
可以看出写线程获取锁是互斥的。
- 4、并发读写
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.getR(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.getW(Thread.currentThread());
}
}.start();
}
结果:
可以看出读写线程获取锁也是互斥的。
更多推荐
所有评论(0)