并发编程--锁--锁的理解及分类
谈谈你对锁的理解?
在并发编程中有两个重要的概念:线程和锁
多线程是一把双刃剑,它在提高程序性能的同时,也带来了编码的复杂性。
锁的出现就是为了保障多线程在同时操作一组资源时的数据一致性,当我们给资源加上锁之后,只有拥有此锁的线程才能操作此资源,而其他线程只能排队等待使用此锁。
你知道哪几种锁?分别有什么特点?
需要首先指出的是,这些多种多样的分类,是评价一个事物的多种标准,比如评价一个城市,标准有人口多少、经济发达与否、城市面积大小等。而一个城市可能同时占据多个标准,以北京而言,人口多,经济发达,同时城市面积还很大。同理,对于 Java 中的锁而言,一把锁也有可能同时占有多个标准,符合多种分类,比如 ReentrantLock 既是可中断锁,又是可重入锁。
根据分类标准我们把锁分为以下 7 大类别,分别是:
(1) 悲观锁/乐观锁;
(2) 公平锁/非公平锁;
(3) 共享锁/独占锁;
(4) 可重入锁/非可重入锁;
(5) 自旋锁/非自旋锁;
(6) 偏向锁/轻量级锁/重量级锁;
(7) 可中断锁/不可中断锁。
乐观锁/悲观锁
悲观锁和乐观锁并不是某个具体的“锁”而是一种并发编程的基本概念,是根据看待并发同步的角度。乐观锁和悲观锁最早出现在数据库的设计当中,后来逐渐被 Java 的并发包所引入。
悲观锁
悲观锁认为对于同一个数据的并发操作一定是会发生修改的,采取加锁的形式,悲观地认为,不加锁的并发操作一定会出问题。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中Synchronized和ReentrantLock等独占锁就是悲观锁思想实现的。
乐观锁
乐观锁正好和悲观锁相反,它获取数据的时候,并不担心数据被修改,每次获取数据的时候也不会加锁,只是在更新数据的时候,通过判断现有的数据是否和原数据一致来判断数据是否被其他线程操作,如果没被其他线程修改则进行数据更新,如果被其他线程修改则不进行数据更新。Lock 是乐观锁的典型实现案例。
补充:更详细的介绍请查看我的另一篇博文 -- 理解悲观锁和乐观锁及其实现方式
公平锁/非公平锁
根据线程获取锁的抢占机制,锁又可以分为公平锁和非公平锁。
公平锁:是指多个线程按照申请锁的顺序来获取锁。
非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁(允许“插队”的情况存在)。
举例说明:
ReentrantLock ,可通过构造函数设置一个 boolean 类型的值,来决定选择公平锁和非公平锁的实现。
公平锁:new ReentrantLock(true)
非公平锁:new ReentrantLock(false)
而公平锁由于有挂起和恢复所以存在一定的开销,因此性能不如非公平锁,所以构造函数不传任何参数的时候,默认提供的是非公平锁。
独占锁/共享锁
根据锁能否被多个线程持有,可以把锁分为独占锁和共享锁。
独占锁:是指任何时候都只能有一个线程能执行资源操作(只能被单线程持有的锁)。比如 synchronized 就是独占锁。
共享锁:是指可以同时被多个线程读取,但只能被一个线程修改。
我们的读写锁,就最好地诠释了共享锁和独占锁的理念。读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
可重入锁/非可重入锁
可重入锁也叫递归锁,指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁(同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁)。同理,不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁,最典型的就是 ReentrantLock 了,正如它的名字一样,reentrant 的意思就是可重入,它也是 Lock 接口最主要的一个实现类。
下面我们用 synchronized 来演示一下什么是可重入锁,代码如下:
/** * @author 佛大java程序员 * @since 1.0.0 */ public class LockExample { public static void main(String[] args) { //可重入锁A reentrantA(); } /** * 可重入锁A方法 */ private synchronized static void reentrantA(){ System.out.println(Thread.currentThread().getName() + ":执行 reentrantA"); reentrantB(); } /** * 可重入锁B方法 */ private synchronized static void reentrantB(){ System.out.println(Thread.currentThread().getName() + ":执行 reentrantB"); } }
运行结果:
从结果可以看出reentrantA方法和reentrantB方法的执行线程都是“main”,我们调用了reentrantA方法,它的方法中嵌套了reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。
可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程,并且锁的内部维护了一个计数器,当锁空闲时此计数器的值为0,当被线程占用和重入时分别加1,当锁被释放时计数器减1,直到减到 0 时表示此锁为空闲状态。
自旋锁/非自旋锁
自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是采用循环的方式去尝试获取锁,这个循环过程被形象地比喻为“自旋”,就像是线程在“自我旋转”,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。相反,非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。
偏向锁/轻量级锁/重量级锁
这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。
偏向锁:它会偏向于第一个获取锁的线程,如果一把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。(简洁版:偏向锁,它会标记第一个获取锁的线程,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,这样开销很小,性能最好)
轻量级锁:JVM 开发者发现在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
重量级锁:重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。
综上所述,偏向锁性能最好,可以避免执行 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
可中断锁/不可中断锁
在 Java 中,synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理。而我们的 ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开。
常见面试题
(1) 谈谈你对锁的理解?
(2) 谈谈你对乐观锁悲观锁的理解?
(3) 为什么非公平锁吞吐量大于公平锁?
答:比如 A 占用锁的时候,B 请求获取锁,发现被 A 占用之后,堵塞等待被唤醒,这个时候 C 同时来获取 A 占用的锁,如果是公平锁 C 后来者发现不可用之后一定排在 B 之后等待被唤醒,而非公平锁则可以让 C 先用,在 B 被唤醒之前 C 已经使用完成,从而节省了 C 等待和唤醒之间的性能消耗,这就是非公平锁比公平锁吞吐量大的原因。
(4) 以下说法错误的是?
A:独占锁是指任何时候都只有一个线程能执行资源操作
B:共享锁指定是可以同时被多个线程读取和修改
C:公平锁是指多个线程按照申请锁的顺序来获取锁
D:非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁
题目解析:答案是B,共享锁指定是可以同时被多个线程读取,但只能被一个线程修改。
(5) 你知道哪几种锁?分别有什么特点?
参考/好文
拉钩课程
-- java面试真题及源码 --谈谈你对锁的理解
--java并发编程 -- 你知道哪几种锁?分别有什么特点?