关于锁
锁在并发编程之中是必不可少的一部分,且由于 Synchronize 使用起来效率不高,而且缺乏灵活性,所以出现了各式各样的锁。但值得说明的是,如标题所示 “悲观锁、乐观锁、共享锁、公平锁、可重入锁、自旋锁” 大部分并不是代指某个锁的名字这样叫,我认为是指符合这一特性的一类锁。
使用 ReentrantLock.tryLock 解决死锁问题
、
关于死锁问题,简而言之便是两个线程所被阻塞的缘故是因为缺少对方所持有的锁,因此导致双方都无法正常运行。
而.tryLock 最大的作用就是当在指定时间内(或及时反馈)无法获取到锁的话,会直接返回false,这样我们把要执行的任务放在为true的条件下。当目前任务由于无法继续执行后需任务的时候,则要求此任务结束此次操作,因此该任务所持有的锁将会被释放。
当释放当前拥有的锁之后再进入睡眠,那么另外一个线程借此可能就成功获得该锁,以此死锁便被破除。
static class t1 implements Runnable {
@SneakyThrows
@Override
public void run() {
boolean isSuccess = false;
while (true) {
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("t1获取到lock1");
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("t1获取到lock2");
} finally {
lock2.unlock();
isSuccess = true;
}
} else {
System.out.println("t1未能获得lock2, 正在重试");
}
} finally {
lock1.unlock();
Thread.sleep(new Random().nextInt(1000)); //解锁后进行睡眠
}
} else {
System.out.println("t1未能获得lock1, 正在重试");
}
if (isSuccess) {
System.out.println("t1获取到了两把锁");
break;
}
}
}
}
悲观锁与公平锁
悲观锁
该锁认为,如果自己不去锁住自己要锁的资源的话,那么其他的线程就会来争抢,因此悲观锁为了确保线程安全,会将数据全部锁住使别人无法访问。其代表实现就是Synchronized和Lock相关类。
乐观锁
该锁认为自己在处理数据的时候一般不会有其他线程来争抢资源,因此不会锁住对应的对象,但当乐观锁更新的时候会去对比哪些数据有没有发生改变。如果没有发生改变,那就说明数据只有自己在操作,因此正常执行。但如果发现数据与一开始拿到的不一样的话,那么当前操作会被放弃或报错以及重试等策略。
可重入锁
主要思想为,如果当前要获取的锁已经被锁住了,且获取这把锁的线程是当前线程,那么此时也可以获取锁。
显然,如果该锁不允许可重入性质,那么f2方法将会与f1方法抢夺锁,以致于发生死锁问题。
而这正是 ReentrantLock之所以叫可重入锁的缘故。在其代码之中可以看到,如果当前锁的持有者是当前线程,那么可对当前线程共享这把锁。
公平和非公平锁
公平锁指的是按照线程的请求顺序来分配锁的所得权,而非公平锁则指不完全按照请求的顺序,在某种情况下可以插队。
那么在什么样的情况下非公平锁会允许某个线程插队呢?
假设线程B执行完毕后去归还锁,之后由A拿到锁,但A从睡眠中被唤醒是存在一定的时间的,假设这时线程C刚好处于活跃状态,那么则这把锁会让线程C插队拿到。
因为即使此时线程C并没有插队,此时线程A也是无法进行任务的,所以优先让线程C获得锁,以提升整体运行效率。
ReentrantLock 公平与非公平设置
显然,ReentrantLock 拥有一个参数表明当前锁是公平锁还是非公平锁,默认为非公平锁。
公平与非公平输出结果
根据上述概念可知,如果我们将锁设计为公平锁,那么我们得到的任务执行顺序一定是根据顺序而来,反之几乎一定存在插队现象。
代码如下。
public static ReentrantLock lock = new ReentrantLock(false);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> f()).start();
}
}
public static void f() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + ": 正在执行1");
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + ": 正在执行2");
} finally {
lock.unlock();
}
}
而其后分别构建公平锁以及非公平锁执行,得其结果如下。
显然,在非公平锁的情况下当第一部分代码执行完毕且释放锁后,随后就被后面一部分的代码直接运行(即插队到最前方)。而在公平锁的情况下则是根据队列依次按顺序运行。
共享锁和排他锁
排他锁,又称为独享锁,当其被使用之时,其他线程无法读亦无法写,这是前述锁的共识,即 ReentrantLock 就是排他锁。
共享锁,便是当它锁住某个资源的时候,资源虽然不允许修改或删除,但允许其他线程去读取操作。共享锁之所以存在,因为有时多个线程去获得资源时可能只是为了去读取资源,这时并不会出现线程安全问题。
在Java中的典型是读写锁 ReentrantReadWriteLock, 这把锁下有两把锁以它为载体。其一为读锁,即共享锁。其二为写锁,即排它锁。
public static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
public static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
总的来说,当多个线程申请读锁(进行读操作)的时候,是允许它们都持有锁的,显然这也就意味着读写锁适合于读多写少的并发场景。但如果有一个线程已经持有锁且要进行写操作,那么其他线程就无法拿到锁了,哪怕它们只是进行读操作。
public static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "已获得读锁");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "已释放读锁");
}
}
public static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "已获得写锁");
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "已释放写锁");
}
}
其运行结果为:
另外值得一说的是,读写锁中的读锁在公平锁下一般是不允许插队的。假设此时线程A和B进行读操作,而C和D在队列之中等待,C为写操作,D为读操作。按道理说D完全可以插队,因为它和A以及B是丝毫不干涉的,可以直接同时拥有锁,如果插队可以效率更高。
但如果C只有并不只有一个D,而有成千上万个读,那么线程C将很久不能得到被执行,此时便会出现很大的问题。
那么什么时候是允许插队的呢?即,假设线程A为写锁,其后有读锁B、C、D。当写锁A结束的一瞬间,此时进入了读锁E,那么E就可能插队到B、C、D之前。
自旋锁和阻塞锁
自旋锁一般适用于被锁的代码块内容非常简单,其执行的速度可能小于CP{切换线程状态(即阻塞状态和运行状态的切换)消耗的时间,那么这时被锁住的线程当被阻塞的时候并不会对线程状态进行切换,而是重复进行尝试获取锁(即自旋)。
而阻塞锁则相反,只要无法拿到锁那边直接把线程切换到阻塞状态,等待被唤醒。
降级策略
当线程A持有写锁时,此后有读锁B、C、D,如果想要在线程A不抛弃写锁的同时,使后面的B、C、D执行的话,可以通过再获得一把读锁从而实现降级操作,使后者得以执行。
本页的评论功能已关闭