一、锁的种类
-
偏向锁 / 轻量级锁 / 重量级锁
- 偏向锁
如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好 - 轻量级锁
JVM 开发者发现在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞 - 重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态
- 偏向锁
-
可重入锁 / 非可重入锁
- 可重入锁
指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁
最典型的就是 ReentrantLock 了 reentrant 的意思就是可重入,它也是 Lock 接口最主要的一个实现类 - 非可重入锁
指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取
- 可重入锁
-
共享锁 / 独占锁
- 共享锁
指的是同一把锁,可以被多个线程同时获得,例如:读 - 独占锁
指的是这把锁只能同时被一个线程获得,例如:写
- 共享锁
-
公平锁 / 非公平锁
- 公平锁
公平的含义在于如果线程现在拿不到这把锁 那么线程就都会进入等待,开始排队 在等待队列里等待时间长的线程会优先拿到 这把锁,有先来先得的意思 - 非公平锁
会在一定情况下忽略掉已经在排队的线程,发生插队现象
- 公平锁
-
悲观锁 / 乐观锁
-
悲观锁
- 解释:在获取资源之前必须先拿到锁,以便达到“独占”的状态。当前线程在操作资源的时候,其他线程由于不能拿到锁,所以其他线程不能来影响我
- 案例:
- synchronized 关键字和 Lock 接口
Java 中悲观锁的实现包括 synchronized 关键字和 Lock 相关类等,例如 Lock 的实现类 ReentrantLock,类中的 lock() 等方法就是执行加锁,而 unlock() 方法是执行解锁处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁,这就是非常典型的悲观锁思想 - 在 MySQL 选择 select for update 语句
在提交之前不允许第三方来修 改该数据,这当然会造成一定的性能损耗,在高并发的情况下是不可取的
- synchronized 关键字和 Lock 接口
-
乐观锁
- 解释:并不要求在获取资源前拿到锁,也不会锁住资源,相反,乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改
- 案例:
- 原子类
乐观锁的典型案例就是原子类 例如 AtomicInteger 在更新数据时,就使用了乐观锁的思想,多个线程可以同时操作同一个原子变量 - 在 MySQL 获取及修改数据时都不需要加锁,但是我们在获取完数据并计算完毕,准备更新数据时,会检查版本号和获取数据时的版本号是否 一致,如果一致就直接更新,如果不一致,说明计算期间已经有其他线程修改过这个数据了,那 我就可以选择重新获取数据,重新计算,然后再次尝试更新数据
- 原子类
-
注意
有一种说法认为:悲观锁由于它的操作比较重量级,不能多个线程并行执行,而且还会有上下文切换等动作,所以悲观锁的性能不如乐观锁好,应该尽量避免用悲观锁
这种说法是不正确的,因为虽然悲观锁确实会让得不到锁的线程阻塞,但是这种开销是固定的,悲观锁的原始开销确实要高于乐观锁,但是特点是一劳永逸,就算一直拿不到锁,也不会对开销造成额外的影响。
反观乐观锁虽然一开始的开销比悲观锁小,但是如果一直拿不到锁,或者并发量大,竞争激烈,导致不停重试,那么消耗的资源也会越来越多,甚至开销会超过悲观锁。所以,同样是悲观锁,在不同的场景下,效果可能完全不同 -
两种锁各自的使用场景
- 悲观锁
适用于并发写入多,临界区代码复杂,竞争激烈等场景。这种场景下悲观锁可以避免大量的无用的反复尝试等消耗 - 乐观锁
适用于大部分是读取,少部分是修改的场景。也适合虽然读写都很多 但是并发并不激烈的场景,在这些场景下,乐观锁不加锁的特点能让性能大幅提高
- 悲观锁
-
-
自旋锁 / 非自旋锁
- 自旋锁
如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,这个循环过程被形象地比喻为 “自旋”,就像是线程在“自我旋转” - 非自旋锁
没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等
- 自旋锁
-
可中断锁 / 不可中断锁
- 可中断锁
ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开 - 不可中断锁
在 Java 中 synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路,只能等到拿到锁以后才能进行其他的逻辑处理
- 可中断锁
二、获取和释放 monitor 锁的时机
线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁
看一组伪代码
public synchronized void method() {
method body
}
等同于
public void method() {
this.intrinsicLock.lock();
try {
method body
} finally {
this.intrinsicLock.unlock();
}
}
进入 method 方法后,立刻添加内置锁并 且用 try 代码块把方法保护起来,最后用 finally 释放这把锁
-
用 javap 命令查看反汇编的结果
public class SynTest { public void synBlock() { synchronized (this) { System.out.println("test"); } } }
- 首先用 cd 命令切换到 SynTest.java 类所在的路径
- 然后执行 javac SynTest.java,于是就会产生一 个名为 SynTest.class 的字节码文件
- 然后我们执行 javap -verbose SynTest.class就 可以看到对应的反汇编内容
-
1个monitorenter,2个monitorexit
因为JVM必须保证一个 monitorenter 必须对应一个 monitorexit
这里的 monitorenter 插入的是方法的开始处,而 monitorexit 需要插入的是方法结束处以及异常处,这样才能保证程序即便抛了异常,也能释放锁 -
monitorenter
执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:- 如果该 monitor 的计数器为 0,则线程获得该 monitor 并将其计数设置为 1,然后该线程就是 这个 monitor 的所有者
- 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数
- 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变 成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor
-
monitorexit
monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止,代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁。所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权
三、synchronized 和 Lock 异同
-
相同点
- synchronized 和 Lock 都是用来保护资源线程安全的
- 都可以保证可见性
- synchronized 和 ReentrantLock 都拥有可重入的特点
-
不同点
- 用法不同
- synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this) 也可以新建一个同步代码块并且自定义 monitor 锁对象
- Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁
- 解锁顺序不同
- Lock 接口可以不按照加锁顺序,来反向解锁,比如
先加锁1再加锁2,然后先解锁1再解锁2lock1.lock(); lock2.lock(); ... lock1.unlock(); lock2.unlock();
- synchronized 解锁顺序必须和加锁顺序完全相反
synchronized(obj1) { synchronized(obj2) { ... } }
- Lock 接口可以不按照加锁顺序,来反向解锁,比如
- 类型不同
- synchronized 是 【不可中断锁】【独占锁】
- ReentrantLock 是 【可中断锁】【共享锁】
- 原理区别
- synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁
- Lock根据实现不同会有不同的原理,比如 ReentrantLock 是根据AQS来实现获取和释放锁的
- 是否可以设置公平/非公平
ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置
- 用法不同
四、Lock 中的常用方法
-
look()方法
在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法,lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等 待,所以一般我们用 tryLock() 等其他更高级的方法来代替 lock()Lock lock = ...; lock.lock(); try { //获取到了被本锁保护的资源,处理任务 //捕获异常 } finally { lock.unlock(); //释放锁 }
- 创建了一个 Lock,并且用 lock 方法加 锁
- 然后立刻在 try 代码块中进行相关业务 逻辑的处理
- 如果有需要还可以进行 catch 来捕获异常
- 最重要的是 finally,一定不要忘记在 finally 中添加 unlock() 方法,以便保 障锁的绝对释放
-
tryLock()
tryLock() 用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true 否则返回 false,代表获取锁失败。相比于 lock(),这样的方法显然功能更强大,我们可以根据是否能获取到锁来决定后续程序的行为Lock lock = ...; if (lock.tryLock()) { try { //处理任务 } finally { lock.unlock(); //释放锁 } } else { //如果不能获取锁,则做其他事情 }
- 创建 lock() 方法之后使用 tryLock() 方法并用 if 语句判断它的结果
- 如果 if 语句返回 true,就使用 try finally 完成相关业务逻辑的处理
- 如果 if 语句返回 false 就会进入 else 语句代表它暂时不能获取到锁,可以先去做一些其他事情
-
tryLock(long time, TimeUnit unit)
tryLock() 的重载方法是 tryLock(long time, TimeUnit unit) ,和 tryLock() 很类似,区别在于 tryLock(long time, TimeUnit unit) 方法会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false 如果一开始就获取锁或者等待期间内获取到锁,则返回 true -
lockInterruptibly()
一句话总结:除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止
lockInterruptibly() 是可以响应中断的,相比于不能响应中断的 synchronized 锁 lockInterruptibly() 可以让程序更灵活,可以在获取锁的同时,保持对中断的响应,可以把这个方法理解为超时时间是无穷长的 tryLock(long time, TimeUnit unit),因为 tryLock(long time, TimeUnit unit) 和 lockInterruptibly() 都能响应中断,只不过 lockInterruptibly() 永远不会超时。lockInterruptibly() 响应中断的之后,也会抛出InterruptedException异常public void lockInterruptibly() { try { lock.lockInterruptibly(); try { System.out.println("操作资源"); } finally { lock.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } }
- 首先执行了 lockInterruptibly 方法, 并且对它进行了 try catch 包装
- 然后同样假设能够获取到这把锁,和之前 一样,就必须要使用 try finall 来保障 锁的绝对释放
-
unlock()
用于解锁,方法比较简单 对于 ReentrantLock 而言,执行 unlock() 的时候,内部会把锁的“被持有计数器”减 1,直到减到 0,就代表当前这把锁已经完全释放了。如果减 1 后计数器不为 0,说明这把锁之前被“重入”了 那么锁并没有真正释放,仅仅是减少了持有的次数
五、为何要选择非公平锁
ReentrantLock默认就是非公平的,如果要让它变成公平锁,则在new构造器的时候需要传入参数true
注意:这里的非公平并不是指完全的随机,不是说线程可以任意插队,而是仅仅“在合适的时机”插队
-
合适的时机
- 假设当前线程在请求获取锁的时候,恰巧前一个持有锁的线程释放了这把锁 那么当前申请锁的线程就可以不顾已经等待的线程而选择立刻插队 但是如果当前线程请求的时候,前一个线程并没有在那一时刻释放锁 那么当前线程还是一样会进入等待队列。
- 假设线程 A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态,那么当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这 把锁,那么根据非公平的策略,会把这把锁给线程 C 这是因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完 任务释放了这把锁。
- 相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,如果在锁代码中执行的内容不多的话,线程 C 就可以很快完成任务,并且在线程 B 被完全唤醒之前就把这个锁交出去,这样是一个双赢的局面,对于线程 C 而言,不需要等待提高了它的效率 而对于线程 B 而言,它获得锁的时间并没有推迟,因为等它被唤醒的时候,线程 C 早就释放锁了,因为线程 C 的执行速度相比于线程 B 的唤醒速度,是很快的。所以 Java 设计者设计非公平锁,是为了提高整体的运行效率。
-
对比公平和非公平的优缺点
- 公平锁
- 优势:各线程公平平等 每个线程在等待一段时间后 总有执行的机会
- 劣势:更慢,吞吐量更小
- 非公平锁
- 优势:更快,吞吐量更大
- 劣势:有可能产生线程饥饿 也就是某些线程在长时间内 始终得不到执行
- 公平锁
-
源码分析
ReentrantLock 内部有个Sync继承自AQS,他自己又有两个子类分别是
公平锁 FairSync 和非公平锁 NonfairSync,公平锁会去先判断等待队列里面是否有任务,而非公平锁不会public class ReentrantLock implements Lock, java.io.Serializable { private static final long serialVersionUID = 7373984872572414699L; /** Synchronizer providing all implementation mechanics */ private final Sync sync; }
abstract static class Sync extends AbstractQueuedSynchronizer { ... }
static final class NonfairSync extends Sync {...} static final class FairSync extends Sync {...}
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && //这里判断了 hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) { throw new Error("Maximum lock count exceeded"); } setState(nextc); return true; } return false; }
有一个特例需要注意,针对 tryLock() 方法,它不遵守设定的公平原则 例如当有线程执行 tryLock() 方法的时候,一旦有线程释放了锁,那么这个正在 tryLock 的线程 就能获取到锁,即使设置的是公平锁模式,即使在它之前已经有其他正在等待队列中等待的线程,简单地说就是 tryLock 可以插队final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { //这里 没有判断 hasQueuedPredecessors() if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; // overflow if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
这里调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关public boolean tryLock() { return sync.nonfairTryAcquire(1); }
六、读写锁 ReadWriteLock
在没有读写锁之前,我们假设使用普通的 ReentrantLock,那么虽然我们保证了线程安全 但是也浪费了一定的资源,因为如果多个读操作同时进行,其实并没有线程安全问题 我们可以允许让多个读操作并行,以便提高程序效率。
-
读写锁的获取规则
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写
-
读写锁适用场合
相比于 ReentrantLock 适用于一般场合
ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率 -
读锁插队策略
- 公平锁
只要等待队列中有线程在等待,也就是 hasQueuedPredecessors() 返回 true 的时候,那么 writer 和 reader 都会 block,也就是一律不允许插队ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true); final boolean writerShouldBlock() { return hasQueuedPredecessors(); } final boolean readerShouldBlock() { return hasQueuedPredecessors(); }
- 非公平锁
在 writerShouldBlock() 这个方法中始终返回 false,可以看出对于想获取写锁的线程而言,由于返回值是 false,所以它是随时可以插队的。读锁插队的条件必须是等待队列头结点不是"写"任务。ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false); final boolean writerShouldBlock() { return false; } final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); }
-
锁的升降级
只能从写锁降级为读锁,不能从读锁升级为写锁- 为什么不能升级
假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁,而线程 B 也需要等待所有的线程,包括线程 A 释放读锁,至此就造成了死锁 - 为什么需要降级
我们对于写锁写任务之后的数据如果仅仅是读取,还一直使用写锁的话,就不能让多个线 程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能
- 为什么不能升级
七、自旋锁
-
自旋锁的好处
自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。 -
AtomicLong 的实现
可以看到它调用了一个 unsafe.getAndAddLong 所以我们再来看这个方法:public finallong getAndlncrement() { return unsafe.getAndAddLong(this, valueOffset,iL); }
public final long getAndAddLong(Objectvarl,longvar2,longvar4) { long var6; do { Var6 = this.getLongVolatile(varl.var2); } while (!this.compareAndSwapLong(varl,var2,var6,var6+var4)); return var6; }
-
缺点
- 在避免线程切换开销的同时也带来了新的开销
- 随着时间的增加,后期甚至会超过线程切换的开销
-
适用场景
并发度不是特别高的场景;临界区比较短小的情况
八、JVM 对锁进行了哪些优化?
-
自适应的自旋锁
在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题 自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率 以及当前锁的拥有者的状态等多种因素来共同决定 自旋的持续时间是变化的,自旋锁变“聪明”了
比如:如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间。但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率 -
锁消除
这个方法是被 synchronized 修饰的同步方法因为它可能会被多个线程同时使用,但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个StringBuffer 对象只会在一个线程内被使用就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率@Override public synchronized StringBuffer append(Object obj) { toStringCache = null; super.append(String.valueOf(obj)); return this; }
-
锁粗化
释放了锁,紧接着什么都没做,又重新获取锁,这种情况下JVM会帮我们将锁扩大化,但是如果外面是一层for循环的话,则不会帮我们进行锁粗话,因为这就会导致其他线程长时间无法获得锁,所以这里的锁粗化不适用于循环的场景,仅 适用于非循环的场景public void lockCoarsening() { synchronized (this) { //do something } synchronized (this) { //do something } synchronized (this) { //do something } }
网友评论