Synchronized
能够保证一个同一时刻只有一个线程执行该段代码,以达到保证并发安全的效果
原理
加锁和释放锁
进入同步代码块加锁对应字节码指令monitorenter,释放锁对应字节码指令monitoreexit;在执行时会将存储在对象头中的对象锁计数+一或-一
获取锁时,数据是从主内存中读取;释放锁时,会将数据回写到主内存
Lock接口
简介、地位、作用
锁是一种工具,用于控制对共享资源的访问
Lock和synchronized是两个最常见的锁,他们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同
Lock并不是用来替代synchronized的,而是当synchronized不合适或不足以满足要求的时候,来提供高级功能的
为什么synchronized不够用?为什么需要Lock?
- 效率低:锁的释放情况少、试图获取锁时不能设置超时、不能中断一个正在试图获取锁的线程
- 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能不够用
- 无法知道是否成功获取到锁
方法介绍
lock()
就是普通的获取锁,如果已被其他线程获取则等待
lock不会像synchronized一样在异常时自动释放锁
最佳实践,在finally中释放锁,以保证异常发生时,锁一定被释放
lock方法不能被中断,这带来很大隐患:一旦陷入死锁,lock就会陷入永久等待
trylock()
用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回true,否则返回false,代表获取锁失败
我们可以根据是否获取到锁,来决定后续程序的行为
该方法会立即返回,即使在拿不到锁时不会一直在那里等
trylock(long time,TimeUnit unit)
lockInterruptibly()
相当于trylock把超时时间设置为无限。在等锁的过程中线程可以被中断
可见性保证
可见性
happens-before
lock的加解锁和synchronized有同样的内存语义,也就是说,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
乐观锁和悲观锁
为什么会诞生非互斥同步锁
互斥同步锁劣势
阻塞和唤醒带来的性能劣势
永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲观线程,将永远得不到执行
优先级反转
悲观锁
- 如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,每次获取和修改数据时,把数据锁住让别人无法访问该数据,这样就可以确保数据内容万无一失
java悲观锁的实现synchronized和lock相关类
乐观锁
认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作的对象
- 在更新的时候,去对比在我修改期间数据有没有被其他人修改过,如果没被修改过,就说名真的只有我自己在操作,那我就正常的去修改数据
- 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内改过数据,那我就不能继续刚才的更新数据过程了,我就选择放弃、报错、重试等策略
- 乐观锁的实现一般都是利用CAS算法实现
- 乐观锁的典型例子就是原子类、并发容器等
开销对比
悲观锁的原始开销要高于乐观锁,但特点是一劳永逸
乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多
两种锁各自的使用场景
悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
乐观锁:适用于写入少,大部分是读取的场景,不加锁能让读取的性能大幅提高
可重入锁和非可重入锁
好处
可以避免死锁
提升封装性
公平锁和非公平锁
什么是公平和非公平
- 公平是指按照线程请求的顺序,来分配锁;而非公平指的是,不完全按照请求的顺序,在一定的情况下,可以插队
注意:非公平也不提倡“插队”行为,这里的非公平指的是“在合适的时机”插队,而不是盲目的插队
为什么要有非公平锁
Java设计者是为了提高效率
- 避免线程唤醒带来的空档期
例子:按照公平应该唤醒线程A,给它分配锁,但是唤醒锁是需要时间的,当这时候正在运行的线程B,来获取锁,线程B就会获取到锁
不公平的情况(以ReentrantLock为例)
如果线程1在释放锁时,线程5恰好去执行lock()
- 由于ReentrantLock发现此时并没有线程持有lock这把锁(线程2还未来得及获取到,因为获取需要时间)
- 线程5可以插队,直接拿到这把锁,这就是ReentrantLock默认的公平策略,也就是“不公平”
特例
- 针对tryLock()方法,它是很猛的,它不遵守设计的公平规则
- 例如:当有线程执行tryLock()的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使它之前已经有其他现在在等待队列里了
优缺点
共享锁和排它锁
什么是共享锁和排它锁
排它锁,又称为独占锁、独享锁
共享锁,又称为读锁,获得共享锁之后,可以查看但是无法修改和删除数据,其他线程也可以获得该锁
共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁
读写锁的作用
- 在没有读写锁之前,我们假设使用ReentrantLock,那么我们虽然保证了线程安全,但是也浪费了一定资源:多个读操作同时进行,并没有线程安全问题
在读的地方用读锁,写的地方用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率
读写锁的规则
多个线程申请读锁,都可以申请到
- 如果有一个线程占用了读锁,其他线程如果想申请写锁,需要等待读锁释放
- 如果有一个线程占用了写锁,此时其他线程申请写锁、读锁,则申请的线程会一直等待写锁释放
- 一句话总结:要么是一个线程或多个线程有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要么一写)
换一种思路
读写锁只是一把锁,可以通过两种方式锁定,读锁定和写锁定。读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁读锁定和写锁定
demo
public class CinemaReadWrite {
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(Thread.currentThread().getName() + "释放读锁");
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(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(()->read(),"Thread1").start();
new Thread(()->read(),"Thread2").start();
new Thread(()->write(),"Thread3").start();
new Thread(()->write(),"Thread4").start();
}
}
读锁插队策略
ReentrantReadWriteLock采用策略2,不允许读任务插队,损失了一定效率,但避免了写任务的饥饿
- 公平锁:不允许插队
- 非公平锁:
1)写锁可以随时插队,但是插队会失败
2)读锁仅在等待队列头是读锁时可以插队,等待队列头是写锁时不允许插队
锁的升降级
目的:为了提高效率,当你释放写锁后,再去获取读锁,不知道什么时候才能拿到读锁,如果在写锁中去降级为读锁,可以提高执行效率
只能降级,不能升级
写锁过程中可以降级为读锁
读锁过程中不能升级为写锁,是为了防止死锁
自旋锁和阻塞锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比代码执行的时间还要长
在许多场景中,同步资源锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就释放锁
而为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果自旋完成后前面锁定同步资源的线程已经放弃了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁
阻塞锁和自旋锁相反,阻塞锁如果没有拿到锁的情况下,会直接把线程阻塞,知道被唤醒
自旋锁的缺点
如果锁被占用的时间很长,那么自旋线程只会白白浪费CPU资源
在自旋过程中,一直消耗CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增加的
原理和源码分析
在java1.5版本及以上的并发框架java.util.concurrent的atmoic包下的类基本都是自旋的实现
AtomicInteger的实现:自旋的实现原理是CAS
AtomicInteger中调用unsafe进行自增操作的源码中的do_while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直到修改成功
自旋锁适用场景
自旋锁一般用于多核服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
自旋锁用于临界区比较小的情况下,否则如果临界去很大(线程一旦拿到锁,很久以下才会释放),那也是不合适的
可中断锁
在java中synchronized是不可中断锁,lock是可中断锁,因为tryLock(time)和lockInterruptibly都能响应中断
如果某一个线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁
锁优化
Java虚拟机对锁的优化
自旋锁和自适应
锁消除
锁粗化
代码优化
- 缩小同步代码块
- 尽量不要锁住方法
- 减少锁的次数
- 避免人为制造“热点”
- 锁中尽量不要包含锁
- 选则合适的锁类型和工具类