Lock
内容纲要
Lock接口
Lock简介 地位 作用
- 锁是一种工具,用于控制对共享资源的访问
- Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同。
- Lock并不是用来代替synchronized的,而是当使用synchronized不合适或不足以满足要求的时候,来提供高级功能的。
- Lock接口最常见的实现类是ReentrantLock
- 通常情况下,Lock只允许一个线程来访问这个共享资源。不过有的时候,一些特殊的实现也可允许并发访问,比如ReadWriteLock里面的ReadLock。
synchronized缺陷
- 效率低: 锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程
- 不够灵活(读写锁更灵活): 加锁和释放的时间单一,每个锁仅有单一的条件(某个对象),可能是不够的
- 无法知道是否成功获取到锁
Lock主要方法
- 在Lock中申明了四个方法来获取锁
- lock(),tryLock(),tryLock(long time,TimeUnit unit)和lockInterruptibly()
lock()
- lock()就是最普通的获取锁。如果锁已被其他线程获取,则进行等待
- Lock不会像synchronized一样在异常时自动释放锁
- 因此最佳实践是,在finally中释放锁,以保证发生异常时锁一定被释放
- lock0方法不能被中断,这会带来很大的隐患:一旦陷入死锁lock()就会陷入永久等待
tryLock()
- tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,则返回true,否则返回false,代表获取锁失败
- 相比于lock,这样的方法显然功能更强大了,我们可以根据是否能获取到锁来决定后续程序的行为
- 该方法会立即返回,即便在拿不到锁时不会一直在那等
- tryLock(long time, TimeUnit unit) : 超时就放弃
lockInterruptibly()
- 相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限。在等待锁的过程中,线程可以被中断
unlock()
- 必须写在finally中,代码规范如下
if (lock.tryLock()) {
try {
} finally {
lock.lock();
}
}
可见性保证
- 可见性
- happens-before
- Lock的加解锁和synchronized有同样的内存语义,也就是说个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
锁的分类
乐观锁(非互斥同步锁)和悲观锁(互斥同步锁)
- 悲观锁:如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以每次悲观锁为了确保结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据内容万无一失,Java中悲观锁的实现就是synchronized和Lock相关类
- 乐观锁:
- 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象
- 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过:如果没被改变过,就说明真的是只有我自己在操作,那我就正常去修数据
- 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内改过数据,那我就不能继续才的更新数据过程了,我会选择放弃报错、重试等策略
- 乐观锁的实现一般都是利用CAS算法来实现的
- 乐观锁的典型例子就是原子类、并发容器等
互斥同步锁的劣势(悲观锁)
- 阻塞和唤醒带来的性能劣势,cpu用户态核心态,上下文切换,检查是否有阻塞线程被唤醒等等
- 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催的线程,将永远也得不到执行
- 优先级反转(被阻塞的线程优先级高,等待的线程优先级比较低)
乐观锁例子
- Git: Git就是乐观锁的典型例子,当我们往远端仓库push的时候,git会检查远端仓库的版本是不是领先于我们现在的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远端代码了,我们的这次提交就失败:如果远端和本地版本号一致,我们就可以顺利提交版本到远端仓库
- Git不适合用悲观锁,否则公司倒闭
数据库
- select for update就是悲观锁
- 用version控制数据库就是乐观锁
- 添加一个字段lock_version
- 先查询这个更新语句的version : select * from table
- 然后update set num = 2version = version +1 where version = 1 and id = 5
- 如果version被更新了等于2,不一样就会更新出错,这就是乐观锁的原理
开销对比
- 悲观锁的原始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影D
- 相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多
两种锁各自的使用场景
- 悲观锁: 适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 乐观锁:适合并发写入少,大部分是读取的场景,不加锁的能让读取性能大幅提高。
可重入锁和非可重入锁,ReentrantLock
- 场景,递归中需要获取到锁,但是我又不想放弃锁,可以重复调用lock()方法
方法:
- isHeldByCurrentThread可以看出锁是否被当前线程持有
- getQueueLength可以返回当前正在等待这把锁的队列有多长,一般这两个方法是开发和调试时候使用,上线后用到的不多
公平锁和非公平锁
- 公平指的是按照线程请求的顺序,来分配锁,非公平指的是不完全按照请求的顺序,在一定情况下,可以插队。
- 注意:非公平也同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队。
- Java设计者这样设计的目的,是为了提高效率,避免唤醒带来的空档期。
公平的情况:
- 后续等待的线程会到wait queue里,按照顺序依次执行
- 在线程1执行unlock0释放锁之后,由于此时线程2的等待时间最久,所以线程2先得到执行,然后是线程3和线程4。
不公平的情况(以ReentrantLock为例): - 如果在线程1释放锁的时候,线程5恰好去执行lock0
- 由于ReentrantLock发现此时并没有线程持有lock这把锁(线程2还没来得及获取到,因为获取需要时间 )
- 线程5可以插队,直接拿到这把锁,这也是ReentrantLock默认的公平策略,也就是“不公平
共享锁和排他锁
- 排它锁,又称为独占锁,独享锁
- 共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看但无法修改和删除数据
- 共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁
读写锁的作用(ReentrantReadWriteLock)
- 在没有读写锁之前,我们假设使用ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题
- 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率
- 读写锁的规则:读写锁只是一把锁,可以通过两种方式锁定:读锁定和写锁定。读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁进行读锁定和写锁定
- 多个线程只申请读锁,都可以申请到
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
- 一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现( 要么多读,要多一写 )
ReentrantReadWriteLock的实现
插队策略
- 公平锁:不允许插队
- 非公平锁
- 写锁可以随时插队
- 读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队
锁升降级: 写锁可以降级为读锁
自旋锁和阻塞锁
- 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间
- 如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长
- 如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁
- 而为了让当前线程“稍等一下”我们需让当前线程进行自旋如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁.
- 阻塞锁和自旋锁相反,阻塞锁如果遇到没拿到锁的情况,会直接把线程阻寨,直到被唤醒
- 如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源
- 在自旋的过程中,一直消耗cpu,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增长的
- 在java1.5版本及以上的并发框架java.util.concurrent 的atmoic包下的类基本都是自旋锁的实现
- AtomicInteger的实现:自旋锁的实现原理是CASAtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直至修改成功
/**
* @author Clay
* @date 2023-04-24
*/
public class SpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while (!sign.compareAndSet(null,current)){
System.out.println("再次尝试获取锁");
}
}
public void unlock() {
Thread current = Thread.currentThread();
sign.compareAndSet(current,null);
}
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Runnable runnable = () -> {
System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
spinLock.lock();
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
spinLock.unlock();
System.out.println("释放了自旋锁");
}
};
new Thread(runnable).start();
new Thread(runnable).start();
}
}
自旋锁的适用场景
- 自旋锁一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
- 另外,自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久以后才会释放),那也是不合适的
可中断锁
- 在Java中,synchronized就不是可中断锁,而Lock是可中断锁因为tryLock(time) 和lockInterruptibly都能响应中断。
- 如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁
Java虚拟机对锁的优化
- 自旋锁和自适应: 当自旋了一定次数之后都还是没有获取到锁则会转为阻塞状态, 进而优化性能
- 锁消除: jvm会识别当前变量是否是私有变量,如果是则不需要加锁
- 锁粗化: 前后相邻的代码同步块使用的是一个锁,jvm则会将他们合并成为一个
写代码时如何优化锁和提高并发性能
- 缩小同步代码块,保证基本的原子操作
- 尽量不要锁住方法
- 减少锁的次数
- 避免人为制造"热点"
- 锁中尽量不要包含锁
- 选择合适的锁类型或合适的工具类
共有 0 条评论