1. Lock简介、地位、作用
- 锁是一种工具,用于控制对共享资源的访问;
- Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同;
- Lock并不是用来代替synchronized的,而是当使用synchronized不合适或者不满足要求的时候,来提供高级功能的;
- Lock接口中最常见的实现类是ReentrantLock;
- 通常情况下,Lock只允许一个线程来访问这个共享资源,不过有的时候,一些特殊的实现也可允许并发访问,比如ReadWriteLock里面的ReadLock;
2. 为什么synchronized不够用?
- 锁的释放情况少:1、 代码执行完毕;2、发生异常;一旦发生阻塞,其他线程只能干等;
- 不够灵活:加锁和释放锁的时机单一;
- 无法知道是否成功获取到了锁;
3. Lock主要方法介绍
-
在Lock中声明了四个方法来获取锁:
1. lock():最普通的锁,Lock不会像synchronized一样在异常时自动释放锁,必须自己try/finally; 2. tryLock():尝试获取锁,如果当前锁没有被其他线程占用,则获取成功; 3. tryLock(long time, TimeUnit unit):超时就放弃; 4. lockInterruptibly():相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限,在等待锁的过程中,线程可以被中断;
-
用lock演示死锁:
public class LockDeadLock {
private static Lock lockA = new ReentrantLock();
private static Lock lockB = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
lockA.lock();
try {
System.out.println("获取到lockA");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("正在尝试获取lockB...");
lockB.lock();
try {
System.out.println("获取到lockB");
} finally {
lockB.unlock();
}
} finally {
lockA.unlock();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
lockB.lock();
try {
System.out.println("获取到lockB");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("正在尝试获取lockA...");
lockA.lock();
try {
System.out.println("获取到lockA");
} finally {
lockB.unlock();
}
} finally {
lockB.unlock();
}
}
});
t1.start();
t2.start();
}
}
- 用tryLock避免死锁
public class TryLockDeadLock {
private static Lock lockA = new ReentrantLock();
private static Lock lockB = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("获取到lockA");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("正在尝试获取lockB...");
try {
if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("获取到lockB");
} finally {
lockB.unlock();
}
} else {
System.out.println("没有获取到lockB");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lockA.unlock();
}
} else {
System.out.println("没有获取到lockA");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
if (lockB.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("获取到lockB");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("正在尝试获取lockA...");
try {
if (lockA.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("获取到lockA");
} finally {
lockA.unlock();
}
} else {
System.out.println("没有获取到lockA");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lockB.unlock();
}
} else {
System.out.println("没有获取到lockB");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
获取到lockA
获取到lockB
正在尝试获取lockB...
正在尝试获取lockA...
没有获取到lockB
没有获取到lockA
- 用lockInterruptibly演示等待锁的过程中响应中断:
public class LockInterruptibly implements Runnable {
private static Lock lockA = new ReentrantLock();
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "正在尝试获取lockA...");
lockA.lockInterruptibly();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "在休眠中被中断了");
} finally {
lockA.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "在尝试获取锁时被中断了");
}
}
public static void main(String[] args) throws InterruptedException {
LockInterruptibly lockInterruptibly = new LockInterruptibly();
Thread t1 = new Thread(lockInterruptibly);
Thread t2 = new Thread(lockInterruptibly);
t1.start();
Thread.sleep(500);
t2.start();
Thread.sleep(2000);
t2.interrupt();
}
}
Thread-0正在尝试获取lockA...
Thread-1正在尝试获取lockA...
Thread-1在尝试获取锁时被中断了
4. 锁的分类
- 线程要不要锁住(互斥同步)同步资源:
1. 锁住:悲观锁,不能让其他线程在我操作的时候去操作这个对象
2. 不锁住:乐观锁 - 多线程能否共享一把锁:
1. 共享:共享锁,比如:读锁
2. 不共享:独占锁,比如:写锁 - 多线程竞争时,是否排队:
1. 排队:公平锁
2. 先尝试插队,插队失败再排队:非公平锁 - 同一个线程是否可以重复获取同一把锁:
1. 可以:可重入锁
2. 不可以:不可重入锁 - 是否可中断:
1. 可以:可中断锁
2. 不可以:不可中断锁 - 等锁的过程:
1. 自旋:自旋锁,自旋:不停的尝试,而不是进入阻塞,比如:原子类;
2. 阻塞:非自旋锁
5. 乐观锁和悲观锁
悲观锁(互斥同步锁):如果不锁住这个资源,别人就来争抢,会造成数据结果错误,为了保证结果的正确性,会在每次修改数据时把数据锁住,让别人无法访问该数据,确保万无一失。Java中悲观锁的实现是synchronized和Lock。
- 锁住之后就是独占的,其他线程想获得资源必须等待,有阻塞和唤醒带来的性能劣势;
- 可能永久阻塞:如果持有锁的线程永久阻塞了,比如遇到了死锁等问题;
- 优先级反转:一旦优先级低的线程不释放,即便优先级高的线程也拿不到锁;
乐观锁(非互斥同步锁):认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象,在更新的时候,去对比在我修改期间数据有没有被其他人改变过:如果没被改变过,那就说明真的是只有我自己在操作,拿我就正常去修改数据;如果数据改变了,拿我就不继续更新了,我会选择放弃、报错、重试等策略。乐观锁的实现一般都是利用CAS算法实现的。Java中乐观锁的实现就是原子类、并发容器等。
乐观锁和悲观锁的对比:
- 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸;
- 乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多;
各自的使用场景:
- 悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,比如:有IO操作、代码复杂、抢占锁竞争激烈;
- 乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高;
6. 可重入锁和非可重入锁,以ReentrantLock为例
public class Recursion {
private ReentrantLock lock = new ReentrantLock();
public void go() {
lock.lock();
try {
if (lock.getHoldCount() < 5) {
System.out.println("第" + lock.getHoldCount() + "次获取到锁");
go();
}
} finally {
System.out.println("已释放锁");
lock.unlock();
}
}
public static void main(String[] args) {
new Recursion().go();
}
}
第1次获取到锁
第2次获取到锁
第3次获取到锁
第4次获取到锁
已释放锁
已释放锁
已释放锁
已释放锁
已释放锁
可以看出,ReentrantLock具有可重入的性质,等方法执行完毕统一释放锁。
- 可重入的好处是:避免死锁,提高封装性;
性质如下:
7. 公平锁和非公平锁
- 公平指的是按照线程请求的顺序来分配锁;
- 非公平是指不完全按照请求的顺序,在一定情况下,可以插队,使得线程总体执行更快,吞吐量更大,但是有可能产生线程饥饿,也就是某些线程在长时间内始终得不到执行;
- 为什么要有非公平锁:由于唤醒的开销比较大,避免唤醒带来的空档期,提高效率;
- 公平的情况:ReentrantLock本身是非公平锁,填写参数为true会改为公平锁;
8. 共享锁和排它锁(独占锁)
- 排它锁:比如synchronized;
- 共享锁:又称为读锁,获取共享锁后,只能查看但是无法修改和删除数据,其他线程也可以获取共享锁;
- Java中的实现是ReentrantReadWriteLock,其中读锁是共享锁,写锁是独占锁。
- 在没有读写锁之前,我们假设只用ReentrantLock,那么虽然保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题。
- 在读的地方只用读锁,在写的地方只用写锁。
- 读写锁规则:要么是一个或多个线程同时有读锁,要么就是一个线程有写锁,读和写不同时出现,要么是读锁定,要么是写锁定。
- ReentrantReadWriteLock的具体用法:
public class ReadWriteLock {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("释放了读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("释放了写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(() -> read()).start();
new Thread(() -> read()).start();
new Thread(() -> write()).start();
new Thread(() -> write()).start();
}
}
Thread-0得到了读锁,正在读取...
Thread-1得到了读锁,正在读取...
释放了读锁
释放了读锁
Thread-2得到了写锁,正在写入...
释放了写锁
Thread-3得到了写锁,正在写入...
释放了写锁
读写锁采用的策略:
- 公平锁:不允许插队;
- 非公平锁(默认):写锁可以随时插队,因为写锁不容易获取到锁;读锁仅仅在等待队列头结点不是写锁的时候可以插队;
9. 锁的升降级
- 为什么需要升降级:比如读写过程中,已经没有写操作了,此时不需要写锁,但是线程又不想释放写锁,那么就可以将写锁降级成读锁。
- 读写锁支持降级不支持升级,因为降级可以提高效率,降级成读锁不会修改数据;
public class UpDownLock {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static void updateLock() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到读锁");
System.out.println(Thread.currentThread().getName() + "正在升级成写锁...");
writeLock.lock();
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了读锁");
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了写锁");
}
}
private static void downLock() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到写锁");
readLock.lock();
System.out.println(Thread.currentThread().getName() + "降级成读锁成功");
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了读锁");
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了写锁");
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> downLock());
t1.start();
Thread.sleep(1000);
System.out.println("-----------------------------");
Thread t2 = new Thread(() -> updateLock());
t2.start();
}
}
Thread-0获取到写锁
Thread-0降级成读锁成功
Thread-0释放了读锁
Thread-0释放了写锁
-----------------------------
Thread-1获取到读锁
Thread-1正在升级成写锁...
因为读写锁,只能多读或一写,如果其中一个线程想要升级成写锁,那么其他线程必须放弃读锁,如果所有的读线程都想升级成写锁,那么就必须都得相互等待对方释放读锁,而两者都想升级就都不释放读锁,这就陷入了死锁。
总结:
- 锁申请和释放策略:要么多读,要么一写;
- 插队策略:写锁可以插队,读锁仅仅在等待队列头结点不是写锁的时候可以插队;
- 升降级策略:只能降级,提高效率;不能升级,会导致死锁;
- 使用场景:适合读多写少情况,可以提高并发效率;
10. 可中断锁
- synchronized不是可中断锁;
- Lock是可中断锁,因为tryLock(设置超时时间)和lockInterruptibly(等待获取锁期间)都能响应中断;
11. 自旋锁和阻塞锁
- 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间;
- 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长;
- 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失;
- 如果物理机有多个处理器,能够让两个以上的线程同时并行执行,我们就可以让后的那个请求锁的线程不放弃CPU的执行时间(不阻塞),看看持有锁的线程是否很快就会释放锁;
- 而为了让当前线程等待一下,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
- 阻塞锁和自旋锁相反,阻塞所如果没有拿到锁,会直接把线程阻塞,直到被唤醒;
自旋锁的缺点:
- 如果锁被占用时间过长,那么自旋的线程只会白白浪费处理器资源;
- 在自旋的过程中,一直消耗CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的;
原子类是自旋锁实现的,AtomicInteger的实现:自旋锁的实现原理是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直到修改成功。
- 自己实现一个简单的自旋锁:
public class SpinLock implements Runnable {
private AtomicReference<Thread> sign = new AtomicReference<>();
@Override
public void run() {
lock();
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
unlock();
}
public void lock() {
Thread current = Thread.currentThread();
while (!sign.compareAndSet(null, current)) {
// System.out.println(Thread.currentThread().getName() + "正在尝试获取自旋锁...");
}
}
public void unlock() {
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
System.out.println(current.getName() + "释放了自旋锁");
}
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Thread t1 = new Thread(spinLock);
Thread t2 = new Thread(spinLock);
t1.start();
t2.start();
}
}
Thread-0获取到了自旋锁
Thread-0释放了自旋锁
Thread-1获取到了自旋锁
Thread-1释放了自旋锁
自旋锁的使用场景:
- 使用于并发度不高的情况;
- 适合临界区比较小的情况;
12. 锁优化
- 自旋锁和自适应:提高效率,尝试自旋的时候,如果尝试不到就转为阻塞锁;
- 锁消除:有一些场景下不必要加锁,JVM会分析出来直接消除了;
- 锁粗化:消除了加锁解锁的过程,把前后相邻的synchronized代码块合并一起;
13. 写代码时候如何优化锁和提高并发性能
- 缩小同步代码块;
- 尽量不要锁住方法;
- 减少请求锁的次数;
- 锁中尽量不要包含锁,不要嵌套锁;
- 选择合适的锁的类型以及合适的工具类;