一、Lock接口
1 Lock简介&地位&作用
- 锁是一种工具,用于控制对
共享资源
的访问 - Lock和synchronized是最常见的两个锁,他们都能够达到线程安全的目录,但是使用和功能上又有较大的不同
- Lock接口最常见的实现类就是
ReentrantLock
- 通常情况下Lock只允许一个线程访问共享资源,特殊情况也允许多个线程并发访问,如ReadWriteLock的ReadLock
2 为什么需要Lock?
(1)因为sychronized在某些场合不适用,存在如下缺点:
效率低
:
锁的释放情况少(只有执行结束或者抛异常才能释放锁)、试图获取锁时不能设定超时、不能中断一个正在试图获取锁的线程
不够灵活
:
加锁和释放的时机单一(不像读写锁那样针对不同场景而选择使用读锁或写锁),每个锁仅有单一的条件(某个对象),适用场景可能是不够的
- 无法知道是否
成功获取到锁
(2)Lock接口拥有而synchronized关键字不具备的主要特性:
特性 | 描述 |
---|---|
尝试非阻塞地获取锁:tryLock() | 当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁,否则立即返回false |
能被中断地获取锁:lockInterruptibly() | 在等待获取锁的线程能够响应中断,等待锁的线程被中断时,中断异常将会被抛出,不再等待 |
超时获取锁:tryLock(long time, TimeUnit unit) | 在指定的截止时间之前获取锁, 超过截止时间后仍旧无法获取则返回 |
3 Lock接口基本的方法:
方法名称 | 描述 |
---|---|
void lock() | 获得锁。如果锁已经被其他线程获取,则进行等待 |
boolean tryLock() | 只有在调用时才可以获得锁。如果可用,则获取锁定,并立即返回值为true;如果锁不可用,则此方法将立即返回值为false 。 |
boolean tryLock(long time, TimeUnit unit) | 超时获取锁,当前线程在一下三种情况下会返回: 1. 当前线程在超时时间内获得了锁;2.当前线程在超时时间内被中断;3.超时时间结束,返回false. |
void lockInterruptibly() | 获取锁,如果可用并立即返回。如果锁不可用,那么等待,和 tryLock(long time, TimeUnit unit) 方法不同的是等待时间无限长,但是在等待中可以中断当前线程(响应中断)。 |
Condition newCondition() | 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁。 |
void unlock() | 释放锁。 |
下面对以上接口的使用进行演示:
二、Lock 接口方法演示
1. lock()
- 最普通的获取锁,如果所被其他线程获得了,进行等待
- Lock不会像synchronized一样在异常时自动释放锁
- 使用时,一定要在finally中释放锁
- lock()方法 不能被中断,一旦死锁,lock() 就会永久等待
代码演示:
/**
* 描述: Lock不会像synchronized一样,异常的时候自动释放锁,所以最佳实践是,finally中释放锁,以便保证发生异常的时候锁一定被释放
*/
public class MustUnlock {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
lock.lock();
try{
//获取本锁保护的资源
System.out.println(Thread.currentThread().getName()+"开始执行任务");
}finally {
lock.unlock();
}
}
}
2. tryLock()
- tryLock()用来
尝试获取锁
,如果当前线程没有被其他线程占用,则获取成功,则返回true,否则返回false,代表获取锁失败 - 相比上面的lock(),他可以返回一个值,让我们知道是否成功获取到锁;进而
决定后续程序的行为
- 它会
立刻返回
,即便在拿不到锁时,不会一直等待
3. tryLock(long time,TimeUnit unit)
可以设定超时时间的尝试获取锁
,一段时间内等待锁,超时就放弃。
使用 tryLock(long time,TimeUnit unit) 来避免死锁的代码演示:
/**
* 〈用trylock避免死锁〉
*
* @author Chkl
* @create 2020/3/11
* @since 1.0.0
*/
public class TryLockDeadLock implements Runnable {
int flag = 1;
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) {
TryLockDeadLock r1 = new TryLockDeadLock();
TryLockDeadLock r2 = new TryLockDeadLock();
r1.flag = 1;
r2.flag = 0;
new Thread(r1).start();
new Thread(r2).start();
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (flag == 1) {
try {
if (lock1.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程1获取到了锁1");
Thread.sleep(new Random().nextInt(1000));
if (lock2.tryLock(800,TimeUnit.MILLISECONDS)){
try {
System.out.println("线程1获取到了锁2");
System.out.println("线程1获取到了两把锁");
break;
}finally {
lock2.unlock();
}
}else {
System.out.println("线程1获取锁2失败");
}
} finally {
lock1.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程1获取锁1失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (flag == 0) {
try {
if (lock2.tryLock(800, TimeUnit.MILLISECONDS)) {
try {
System.out.println("线程2获取到了锁2");
Thread.sleep(new Random().nextInt(1000));
if (lock1.tryLock(800,TimeUnit.MILLISECONDS)){
try {
System.out.println("线程2获取到了锁1");
System.out.println("线程2获取到了两把锁");
break;
}finally {
lock1.unlock();
}
}else {
System.out.println("线程2获取锁1失败,已重试");
}
} finally {
lock2.unlock();
Thread.sleep(new Random().nextInt(1000));
}
} else {
System.out.println("线程2获取锁2失败,已重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
打印结果、
4. LockInterruptibly
相当于把tryLock(long time,TimeUnit unit)的超时时间设置为无限长,在等待锁的过程中,线程可以中断
public class LockInterruptibly implements Runnable{
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptibly l = new LockInterruptibly();
Thread thread0 = new Thread(l);
Thread thread1 = new Thread(l);
thread0.start();
thread1.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread0.interrupt();
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "尝试获取锁");
try {
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName() + "获取到了锁");
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "睡眠期间被中断了");
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "等待锁期间被中断了");
}
}
}
打印结果:
5. 锁的可见性保证
- lock符合happens-before规则,具有可见性
- 当线程解锁,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
三、锁的分类
根据不同的划分标准,常见的锁的划分如思维导图所示
1、乐观锁&悲观锁
(1)为什么会诞生非互斥同步锁(乐观锁)
主要由于互斥同步锁(悲观锁)存在一些劣势,如下:
- 阻塞和唤醒带来的性能劣势
- 永久阻塞:如果持有锁的线程被永久阻塞,如无限循环,死锁等活跃性问题,那么等待该线程释放锁的线程永远得不到执行
- 优先级反转:阻塞的那个线程A优先级高,持有锁的B优先级低,如果B不释放,A就需要一直等待,导致优先级反转
(2)什么是乐观锁&悲观锁
悲观锁:
- 悲观锁认为:如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以为了结果的正确性,悲观锁会在每次获取并修改结果时把数据锁住,让别人无法访问
- Java中悲观锁典型的实现就是synchronized和lock相关类
乐观锁:
- 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住操作对象
- 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过。
- 如果没有被改变过,就说明只有自己在操作,就正常修改数据
- 如果数据与最初拿到的不一致,说明其他人在这段时间内修改过数据,就会执行放弃、报错或重试等策略
- 乐观锁的实现通常是利用
CAS
算法,典型例子是:原子类,并发容器等
代码演示:实现累加器
public class PessimismOptimismLock {
int a;
//悲观锁
public synchronized void testMethod(){
a++;
}
public static void main(String[] args) {
//乐观锁
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
//悲观锁
new PessimismOptimismLock().testMethod();
}
}
典型例子
-
Git:Git是乐观锁的典型应用,当我们向远程仓库push的时候,git会检查远程仓库的版本是不是领先我们现在的版本,
- 如果远端版本和本地版本不一致,表明远端代码被人修改过了,提交就失败
- 如果版本一直,才能顺利提交到远程仓库
-
数据库:
- select for update就是悲观锁
- 用version控制就是乐观锁
- 添加一个字段lock_version
- 更新操作前先查出这条数据的version 记为mversion
- 进行更新操作时:
update set num = 2 , version = vsersion+1 where version = mversion and id = 5
- 如果version更新了不等于查询出来的值了,更新就无效
开销对比
- 悲观锁的原始开销要高于乐观锁,但是一劳永逸,临界区(加锁区)持锁时间就算越来越差,也不会对互斥锁的开销造成影响
- 乐观锁一开始的开销比悲观锁小,如果自旋时间很长或者不停重试,name消耗的资源也会越来越多
使用场景
- 悲观锁:适合于并发写入多的情况,适合于临界区(可以理解为加锁区)持锁时间较长的情况,悲观锁可以避免大量的无用自旋锁等消耗
- 临界区有IO操作
- 临界区代码复杂或者循环量大
- 临界区竞争非常激烈
- 乐观锁:适合并发写入少,大部分都是读取的场景,不加锁能让读取性能大幅提高
2、可重入&非可重入锁
非可重入锁就是最常见的锁,一旦锁被使用,如果没有释放,就不能再使用这个锁了
可重入锁,是指同一线程获取到一把锁之后,可在不释放该锁的条件下再次获取该锁,以ReentrantLock为例进行演示,如下:
(1)重复调用演示:
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.lock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
lock.unlock();
System.out.println(lock.getHoldCount());
}
(2)递归调用演示:
public class RecursionDemo {
private static ReentrantLock lock = new ReentrantLock();
private static void accessResource(){
lock.lock();
try {
System.out.println("已经对资源进行处理");
if (lock.getHoldCount()<5){
//递归调用
System.out.println(lock.getHoldCount());
accessResource();
System.out.println(lock.getHoldCount());
}
}finally {
lock.unlock();
}
}
public static void main(String[] args) {
accessResource();
}
}
从结果可以看出获得锁之后是可以重复获得锁再最后释放的,这就是可重入锁
- 可重入锁的好处
- 避免了死锁
- 使用灵活,提高了封装性
(3)源码分析
可重入锁 & 非可重入锁的获取锁和释放锁的方法源码对比:
3、公平锁&非公平锁
(1) 什么是公平和非公平
- 公平:指按照线程请求的顺序来分配锁
- 非公平:不完全按照请求的顺序,在合适的时机下,可以插队
- 非公平也同样不
提倡"插队"行为
,这里的非公平,指的是在合适的时机
插队,而不是盲目插队 - 什么是合适的时机?
- 比如你排队买票,现在在第二位,当你成为第一位的时候,你脑子懵了2秒;此时有个人,突然插在你前面,问售票员:XXX点的车几点发车,问完就走,只花了3秒,此时你也刚清醒,也就没太在乎。这个例子主要是说,插队的时机,就算他没插队,中间懵逼的2秒就白白浪费了,为了更高效的运作,就可以把这个线程唤醒时的空窗期也能利用到,提高吞吐量。
- 非公平也同样不
(2)为什么要有非公平锁
- 为了提高效率(大多数都默认采用非公平锁)
- 避免唤醒带来的空档期
(3)公平的情况(以ReentrantLock 为例)
- 如果创建ReentrantLock 对象时,参数填写为true,那么这个锁就是公平锁
演示案例:模拟打印机打印任务,有两个类,一个是打印作业Job类,一个是打印队列PrintQueeue 类,一个打印任务包含两次打印,两次获得锁。在main方法中创建10个线程执行Job,当锁使用公平锁时:
/**
* 〈演示公平锁和不公平锁〉
*
*/
public class FairLock {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
PrintQueue printQueue = new PrintQueue();
Thread thread[] = new Thread[10];
for (int i = 0; i < 10; i++) {
thread[i] = new Thread(new Job(printQueue), "线程"+i);
}
for (int i = 0; i < 10; i++) {
thread[i].start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Job implements Runnable {
PrintQueue printQueue;
public Job(PrintQueue printQueue) {
this.printQueue = printQueue;
}
@Override
public void run() {
System.out.println(
Thread.currentThread().getName() + "开始打印");
printQueue.printJob(new Object());
System.out.println(
Thread.currentThread().getName() + "打印结束");
}
}
class PrintQueue {
//公平锁
private Lock queueLock = new ReentrantLock(true);
//非公平锁
// private Lock queueLock = new ReentrantLock();
public void printJob(Object document) {
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要" + duration);
Thread.sleep(duration * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
queueLock.unlock();
}
queueLock.lock();
try {
int duration = new Random().nextInt(10) + 1;
System.out.println(Thread.currentThread().getName() + "正在打印,需要" + duration);
} finally {
queueLock.unlock();
}
}
}
使用公平锁进行打印操作,会按照请求锁的顺序依次执行,按照请求顺序,不会出现插队。一次运行结果如下,因为每次打印后需要休眠n秒模拟打印耗时,休眠时间足够所有的线程依次启动,所以执行顺序一定是线程0-9按顺序请求第一把锁,之后线程0-9再按顺序请求第二把锁,顺序一定不会变
线程0开始打印
线程0正在打印,需要8
线程1开始打印
线程2开始打印
线程3开始打印
线程4开始打印
线程5开始打印
线程6开始打印
线程7开始打印
线程8开始打印
线程9开始打印
线程1正在打印,需要3
线程2正在打印,需要3
线程3正在打印,需要5
线程4正在打印,需要4
线程5正在打印,需要2
线程6正在打印,需要2
线程7正在打印,需要2
线程8正在打印,需要10
线程9正在打印,需要7
线程0正在打印,需要9秒
线程1正在打印,需要5秒
线程0打印完毕
线程2正在打印,需要8秒
线程1打印完毕
线程2打印完毕
线程3正在打印,需要2秒
线程3打印完毕
线程4正在打印,需要8秒
线程4打印完毕
线程5正在打印,需要5秒
线程5打印完毕
线程6正在打印,需要9秒
线程7正在打印,需要1秒
线程6打印完毕
线程7打印完毕
线程8正在打印,需要10秒
线程8打印完毕
线程9正在打印,需要10秒
线程9打印完毕
(4)不公平的情况(以ReentrantLock 为例)
修改PrintQueue 中的锁为非公平锁
//非公平锁
private Lock queueLock = new ReentrantLock(); // 或者 new ReentrantLock(false)
打印结果:
运行结果如上,从结果可以看到,打印顺序并没有再按照0-9、0-9执行了,线程2的第一次打印结束后马上又开始了第二次打印,这就是非公平锁的好处了,线程2执行完第一个打印之后,线程3准备打印,但是在准备的空窗期,线程2干脆一次性把第二次打印也完成了,不影响线程3打印的正常运行,同理下面的线程56789都是这种情况,提高了效率,充分利用了空窗期
(5)特例
- trylock()方法不准守公平规则,自带插队属性
- 当trylock()执行时,一旦有线程释放了锁,就一定被使用trylock()的线程获得,即使现在这个锁的等待队列里有线程在等待
(6)对比非公平和公平的优缺点
(7)公平锁和非公平锁的获取锁的方法源码分析
4、共享锁&排它锁
以ReetrantReadWriteLock读写锁为例
(1)什么是共享锁和排它锁
- 排它锁:又称独占锁,独享锁
- 共享锁:又称为读锁,获得共享锁后,可以查看但是无法修改和删除数据,其他线程此时也可以蝴蝶共享锁,同样无法修改和删除数据
- 共享锁和排它锁的典型就是读写锁ReetrantReadWriteLock,其中读锁是共享锁,写锁是排它锁
(2)读写锁的作用
- 在没有读写锁之前,假设我们使用ReetrantLock,虽然保证了线程安全,但是也浪费了一定的资源:多个读操作同时进行,并没有线程安全问题
- 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率
(3)读写锁的规则
- 当一个线程占用读锁时,其他线程可以申请读锁,不能申请写锁
- 当一个线程占用写锁时,其他线程读锁写锁都不可以申请
- 总结:
要么多读,要么一写
(4)ReetrantReadWriteLock的具体使用
创建4个线程,前两个获取读锁,后两个获取写锁,运行后可以看到读锁可以同时获取,写锁必须等前面的线程释放了才能再获取,写锁获取期间,不允许其它线程的读写操作。
public class CinemaReadWrite {
private static ReentrantReadWriteLock
reentrantReadWriteLock = new ReentrantReadWriteLock();
//读锁
private static ReentrantReadWriteLock.ReadLock
readLock = reentrantReadWriteLock.readLock();
//写锁
private static ReentrantReadWriteLock.WriteLock
writeLock = reentrantReadWriteLock.writeLock();
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();
}
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName()
+ "得到了读锁,正在读取ing");
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()
+ "得到了写锁,正在读取ing");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()
+"释放读锁");
writeLock.unlock();
}
}
}
运行结果:
(5) 读锁插队策略
- 公平锁:不允许插队
- 非公平锁:
- 写锁可以随时插队
- 读锁仅在等待队列头节点不是想要获取写锁的线程的时候可以插队(即头结点是写锁不可以插队,是读锁就可以插队)
源码分析
- 公平读写锁
- 非公平读写锁
(6)演示头结点是读操作,后面的读操作插队的情况
public class NonfairBargeDemo {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(false);//非公平读写锁
private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();//读锁
private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();//写锁
//读操作
private static void read(){
System.out.println(Thread.currentThread().getName()+" : 开始尝试获取读锁");
readLock.lock();
try{
System.out.println(Thread.currentThread().getName()+" : 得到读锁,正在读取");
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+" : 释放读锁");
readLock.unlock();
}
}
//写操作
private static void write(){
System.out.println(Thread.currentThread().getName()+" : 开始尝试获取写锁");
writeLock.lock();
try{
System.out.println(Thread.currentThread().getName()+" : 得到读锁,正在写入");
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName()+" : 释放写锁");
writeLock.unlock();
}
}
//主函数
public static void main(String[] args) {
new Thread(()->write(),"Thread1").start();
new Thread(()->read(),"Thread2").start();
new Thread(()->read(),"Thread3").start();
new Thread(()->write(),"Thread4").start();
new Thread(()->read(),"Thread5").start();
new Thread(new Runnable() {
@Override
public void run() {
Thread thread[] = new Thread[1000];
for (int i = 0; i < thread.length; i++) {
thread[i] = new Thread(()->read(),"子线程创建的Thread"+i);
thread[i].start();
}
}
}).start();
}
}
线程2刚拿到锁时,此时等待队列中的头结点是线程3,是读操作,所以后面的读操作可以进行插队,Thread330就没有排队,直接去抢到了锁;当线程3也获取到锁之后,由于队列的头节点是线程4,写操作,所以后面的读操作都不再插队,按部就班的排队。
(7)升降级策略
为什么需要升降级?
-
提高效率:
某个线程执行过程中不同时间段的操作不同,一开始执行写操作,之后都进行读;一直使用写锁的话,后面的读操作不能和其他线程进行共享,就会浪费资源;如果将写锁释放掉然后去抢占读锁,不一定能抢到。所有就有了写锁降级,然后让其他线程也能获取到读锁。
支持锁的降级,不支持升级
-
为什么不支持读锁的升级?
因为读锁升级需要等所有的读锁都释放了才能升级,容易造成死锁,比如两个线程都在等待升级的话,就会互相等待对方释放读锁,就成了死锁
public class NonfairBargeDemo {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(false);//公平读写锁
private static ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();//读锁
private static ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();//写锁
//读升级
private static void readUpgrading(){
System.out.println(Thread.currentThread().getName()+" : 开始尝试获取读锁");
readLock.lock();
try{
System.out.println(Thread.currentThread().getName()+" : 得到读锁,正在读取");
Thread.sleep(1000);
System.out.println("升级会带来阻塞");
writeLock.lock();
System.out.println(Thread.currentThread().getName()+"升级成功");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
System.out.println(Thread.currentThread().getName()+" : 释放读锁");
readLock.unlock();
}
}
//写降级
private static void writeDownGrading(){
System.out.println(Thread.currentThread().getName()+" : 开始尝试获取写锁");
readLock.lock();
try{
System.out.println(Thread.currentThread().getName()+" : 得到读锁,正在写入");
Thread.sleep(1000);
readLock.lock();
System.out.println(Thread.currentThread().getName()+":在不释放写锁的情况下,直接获取读锁,成功降级");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
System.out.println(Thread.currentThread().getName()+" : 释放写锁");
readLock.unlock();
}
}
//主函数
public static void main(String[] args) throws InterruptedException {
System.out.println("降级是可以的");
Thread thread1 =new Thread(()->writeDownGrading(),"thread1");
thread1.start();
thread1.join();
System.out.println("=========================");
System.out.println("升级是不行的");
Thread thread2 = new Thread(() -> readUpgrading(), "thread2");
thread2.start();
}
}
(8)适用场合
相比于 ReentrantLock 适用于一般场合,ReentrantReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。
5、自旋锁&阻塞锁
(1)为什么需要自旋锁
-
阻塞或者唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态装换需要耗费处理器时间
-
如果同步代码块中的内容过于简单,
状态转换消耗的时间
可能比用户代码执行的时间
还长 -
同步资源锁定时间很短的场景,线程挂起和恢复现场的花费可能会让系统得不偿失
-
如果物理机有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就释放锁
自旋锁:为了让当前线程“稍微等一下”
,需要让当前线程自旋
,如果自旋完成后
前面锁定同步资源的线程已经释放锁了,那么当前线程可以不必阻塞而是直接获取同步资源
,从而避免线程切换的开销,这就是自旋锁
阻塞锁和自旋锁相反,阻塞锁如果没有拿到锁,会直接把线程阻塞,直到被唤醒
(2) 自旋锁缺点
如果锁被占用时间很长,那么自旋的线程只会白白浪费CPU资源
(3)自旋锁的应用
- 在java1.5版本及以上的并发框架java.util.concurrent 的atmoic包下的类基本都是自旋锁的实现;
- AtomicInteger 的实现:自旋锁的实现原理是CAS。AtomicInteger 源码中调用 unsafe进行
自增操作的的do-while循环
就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直至修改成功.
AtomicInteger的getAndIncrement源码
(4) 自己实现一个简单的自旋锁
/**
* 自旋锁演示
*/
public class SpinLock {
private AtomicReference<Thread> sign = new AtomicReference<>();
//加锁操作
public void lock(){
Thread current = Thread.currentThread();
//只有在 null 的时候,current 才能执行通过,否则就是循环
while (!sign.compareAndSet(null,current)){
System.out.println("获取锁失败,已重试");
}
}
//解锁操作
public void unlock(){
Thread current = Thread.currentThread();
// 将 sign 的线程设为null,就意味着其他线程可以再次进行设值,就相当于解锁了
sign.compareAndSet(current,null);
}
public static void main(String[] args) {
SpinLock spinLock = new SpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ":开始尝试获取自旋锁");
spinLock.lock();
System.out.println(Thread.currentThread().getName() + ":获取到了自旋锁");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + ":释放了自旋锁");
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
(5) 自旋锁的使用场景
- 自旋锁一般用于多核的服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
- 自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久以后才会释放),那也是不合适的
6. 可中断锁和不可中断锁
- Java中,synchronized 就是不可中断锁,而 Lock 是可中断锁,因为 trylock(time) 和 lockInterruptibly() 都可以响应中断
- 如果某个线程A正在执行锁中的代码,另一个线程B正在等待获取该锁,可能由于等待时间太长了,线程B不相等了,可以去处理其他事情,把B中断,这就是可中断锁
public class LockInterruptibly implements Runnable{
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
LockInterruptibly l = new LockInterruptibly();
Thread thread0 = new Thread(l);
Thread thread1 = new Thread(l);
thread0.start();
thread1.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread0.interrupt();
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+": 尝试获取锁");
try {
lock.lockInterruptibly();
try {
System.out.println(Thread.currentThread().getName()+": 拿到了锁");
Thread.sleep(5000);
}catch (InterruptedException e){
System.out.println(Thread.currentThread().getName()+"【睡眠期间】被中断");
} finally{
lock.unlock();
System.out.println(Thread.currentThread().getName()+": 释放锁");
}
} catch (InterruptedException e) {
System.out.println("【等锁期间】被中断");
e.printStackTrace();
}
}
}
四、锁优化
1. Java虚拟机对锁的优化
(1) 自旋锁&自适应
- 自适应是对自旋锁的优化,比如当自旋锁尝试自旋了很多次,都没有获取到锁,那他就会阻塞;
- 如果这次自旋100次都没拿到锁,并进入了阻塞,那下次就可能没有自旋了,直接进入阻塞
(2)锁消除
在一些不会出现线程不安全的地方,jvm 会对这里的锁消除
(3)锁粗化
理论上来说,让同步代码块的范围越小越好;但是如果一系列的操作都是对一个对象进行反复的加锁解锁操作,那他就会优化,只在这一系列操作的开始加锁,在结束时进行解锁,就减少了锁的数量。避免了反复加锁解锁带来的资源浪费。
2. 程序员对锁的优化
-
缩小同步代码块
-
尽量不要锁住方法
-
减少请求锁的次数
例如,在日志框架中,多个线程去执行日志记录操作,那可以加一个中间件,将多个操作合成一个操作,然后去用一个线程去执行这合成的一个操作
-
避免人为制造"热点"
例如,在使用size()方法获取hashmap的大小时,如果遍历整个hashmap来获取大小时,为了准确性就会加锁,从而使得其他线程进入阻塞;为了避免这里成为加锁"热点",我们可以维护一个计数器,每次put()操作就给计数器加1,每次删除操作就减1,就无需遍历获取hashmap的大小,而是直接去取map中的一个元素,大大减小开销。
-
锁中尽量不要再包含锁
因为容易出现
死锁
-
选择合适锁的类型&合适的工具类