锁
Java 中的锁而言,一把锁也有可能同时占有多个标准,符合多种分类,比如 ReentrantLock 既是可中断锁,又是可重入锁。
根据分类标准我们把锁分为以下 7 大类别,分别是:
- 偏向锁/轻量级锁/重量级锁;
- 可重入锁/非可重入锁;
- 共享锁/独占锁;
- 公平锁/非公平锁;
- 悲观锁/乐观锁;
- 自旋锁/非自旋锁;
- 可中断锁/不可中断锁
偏向锁/轻量级锁/重量级锁
第一种分类是偏向锁/轻量级锁/重量级锁,这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。
- 偏向锁
如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。
- 轻量级锁
JVM 开发者发现在很多情况下,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。
- 重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。
综上所述,偏向锁性能最好,可以避免执行 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差
可重入锁/非可重入锁
共享锁/独占锁
共享锁指的是我们同一把锁可以被多个线程同时获得,而独占锁指的就是,这把锁只能同时被一个线程获得。我们的读写锁,就最好地诠释了共享锁和独占锁的理念。读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有
公平锁/非公平锁
公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,有先来先得的意思。而非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象
悲观锁/乐观锁
悲观锁的概念是在获取资源之前,必须先拿到锁,以便达到“独占”的状态,当前线程在操作资源的时候,其他线程由于不能拿到锁,所以其他线程不能来影响我。而乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源;相反,乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改。
自旋锁/非自旋锁
自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,这个循环过程被形象地比喻为“自旋”,就像是线程在“自我旋转”。相反,非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等
可中断锁/不可中断锁
在 Java 中,synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理。而我们的 ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开
悲观锁/乐观锁本质
悲观锁比较悲观,它认为如果不锁住这个资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失
synchronized 背后的“monitor 锁”
获取和释放 monitor 锁的时机
最简单的同步方式就是利用 synchronized 关键字来修饰代码块或者修饰一个方法,那么这部分被保护的代码,在同一时刻就最多只有一个线程可以运行,而 synchronized 的背后正是利用 monitor 锁实现的。所以首先我们来看下获取和释放 monitor 锁的时机,每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
synchronized 和 Lock 如何选择?
相同点
synchronized 和 Lock 的相同点非常多,我们这里重点讲解 3 个比较大的相同点。
1.synchronized 和 Lock 都是用来保护资源线程安全的。
2.都可以保证可见性。
对于 synchronized 而言,线程 A 在进入 synchronized 块之前或在 synchronized 块内进行操作,对于后续的获得同一个 monitor 锁的线程 B 是可见的,也就是线程 B 是可以看到线程 A 之前的操作的,这也体现了 happens-before 针对 synchronized 的一个原则
而对于 Lock 而言,它和 synchronized 是一样,都可以保证可见性,如图所示,在解锁之前的所有操作对加锁之后的所有操作都是可见的
3.synchronized 和 ReentrantLock 都拥有可重入的特点
ReentrantLock 是 Lock 接口的一个最主要的实现类,在对比 synchronized 和 Lock 的时候,也会选择 Lock 的主要实现类来进行对比。可重入指的是某个线程如果已经获得了一个锁,现在试图再次请求这个它已经获得的锁,如果它无需提前释放这个锁,而是直接可以继续使用持有的这个锁,那么就是可重入的。如果必须释放锁后才能再次申请这个锁,就是不可重入的。而 synchronized 和 ReentrantLock 都具有可重入的特性
不同点
- 加解锁顺序不同
对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2,加解锁有一定的灵活度,如代码所示。
1 | java复制代码 |
但是 synchronized 无法做到,synchronized 解锁的顺序和加锁的顺序必须完全相反,例如:
1 | java复制代码 |
那么在这里,顺序就是先对 obj1 加锁,然后对 obj2 加锁,然后对 obj2 解锁,最后解锁 obj1。这是因为 synchronized 加解锁是由 JVM 实现的,在执行完 synchronized 块后会自动解锁,所以会按照 synchronized 的嵌套顺序加解锁,不能自行控制。
- synchronized 锁不够灵活
一旦 synchronized 锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。如果持有锁的线程持有很长时间才释放,那么整个程序的运行效率就会降低,而且如果持有锁的线程永远不释放锁,那么尝试获取锁的线程只能永远等下去。
相比之下,Lock 类在等锁的过程中,如果使用的是 lockInterruptibly 方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock() 等方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活。
- synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制
例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized 做不到。
如何选择
讲完了 synchronized 和 Lock 的相同点和区别,最后我们再来看下如何选择它们,在 Java 并发编程实战和 Java 核心技术里都认为:
如果能不用最好既不使用 Lock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全。
如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock
Lock常用方法
1 | java复制代码 |
接下来重点分析这 5 种方法的作用和用法,这 5 种方法分别是 lock()、tryLock()、tryLock(long time, TimeUnit unit) 和 lockInterruptibly()、unlock()。
lock() 方法
在 Lock 接口中声明了 4 种方法来获取锁(lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()),那么这 4 种方法具体有什么区别呢?
首先,lock() 是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。
对于 Lock 接口而言,获取锁和释放锁都是显式的,不像 synchronized 那样是隐式的,所以 Lock 不会像 synchronized 一样在异常时自动释放锁(synchronized 即使不写对应的代码也可以释放),lock 的加锁和释放锁都必须以代码的形式写出来,所以使用 lock() 时必须由我们自己主动去释放锁,因此最佳实践是执行 lock() 后,首先在 try{} 中操作同步资源,如果有必要就用 catch{} 块捕获异常,然后在 finally{} 中释放锁,以保证发生异常时锁一定被释放,示例代码如下所示。
公平锁与非公平锁 为什么需要非公平锁?
对比公平和非公平的优缺点
公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小,相反非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。
- 源码分析
在 ReentrantLock 类包含一个 Sync 类,这个类继承自AQS(AbstractQueuedSynchronizer),代码如下:
1 | java复制代码 |
Sync 类的代码:
1 | java复制代码 |
根据代码可知,Sync 有公平锁 FairSync 和非公平锁 NonfairSync两个子类:
1 | java复制代码 |
公平锁与非公平锁的加锁方法的源码
1 | java复制代码 |
非公平锁的锁获取源码如下:
1 | java复制代码 |
我们可以明显的看出公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors() 为 false,这个方法就是判断在等待队列中是否已经有线程在排队了。这也就是公平锁和非公平锁的核心区别,如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;对于非公平锁而言,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。
这里有一个特例需要我们注意,针对 tryLock() 方法,它不遵守设定的公平原则。
例如,当有线程执行 tryLock() 方法的时候,一旦有线程释放了锁,那么这个正在 tryLock 的线程就能获取到锁,即使设置的是公平锁模式,即使在它之前已经有其他正在等待队列中等待的线程,简单地说就是 tryLock 可以插队。
看它的源码就会发现:
1 | java复制代码 |
这里调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。
公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,所以存在后申请却先获得锁的情况,但由此也提高了整体的效率。
读写锁
在没有读写锁之前,我们假设使用普通的 ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了一定的资源,因为如果多个读操作同时进行,其实并没有线程安全问题,我们可以允许让多个读操作并行,以便提高程序效率。
但是写操作不是线程安全的,如果多个线程同时写,或者在写的同时进行读操作,便会造成线程安全问题。
整体思路是它有两把锁,第 1 把锁是写锁,获得写锁之后,既可以读数据又可以修改数据,而第 2 把锁是读锁,获得读锁之后,只能查看数据,不能修改数据。读锁可以被多个线程同时持有,所以多个线程可以同时查看数据。
读写锁的获取规则
我们在使用读写锁时遵守下面的获取规则:
1.如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
2.如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作。
3.如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写。
一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)
案例
ReentrantReadWriteLock 是 ReadWriteLock 的实现类,最主要的有两个方法:readLock() 和 writeLock() 用来获取读锁和写锁。
1 | java复制代码 |
总结ReentrantReadWriteLock
- 插队策略
- 公平策略下,只要队列里有线程已经在排队,就不允许插队。
2.非公平策略下:
1).如果允许读锁插队,那么由于读锁可以同时被多个线程持有,所以可能造成源源不断的后面的线程一直插队成功,导致读锁一直不能完全释放,从而导致写锁一直等待,为了防止“饥饿”,在等待队列的头结点是尝试获取写锁的线程的时候,不允许读锁插队
2).写锁可以随时插队,因为写锁并不容易插队成功,写锁只有在当前没有任何其他线程持有读锁和写锁的时候,才能插队成功,同时写锁一旦插队失败就会进入等待队列,所以很难造成“饥饿”的情况,允许写锁插队是为了提高效率。
- 升降级策略:
只能从写锁降级为读锁,不能从读锁升级为写锁。
自旋锁
自旋锁:
“自旋”可以理解为“自我旋转”,这里的“旋转”指“循环”,比如 while 循环或者 for 循环。“自旋”就是自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞
|-> 对比自旋和非自旋的获取锁的流程
|-> 自旋锁的好处
首先,阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。
在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。
那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销
案例: 可重入的自旋锁
1 | java复制代码 |
- 缺点
最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失。
- 适用场景
自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率
可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源
JVM 锁优化
相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。有了这些优化措施后,synchronized 锁的性能得到了大幅提高,下面我们分别介绍这些具体的优化
自适应自旋锁
在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变“聪明”了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率
锁消除
1 | java复制代码 |
从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。
但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。
粗化锁
如果我们释放了锁,紧接着什么都没做,又重新获取锁,例如下面这段代码所示:
1 | java复制代码 |
其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。
不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。如果在循环中我们也这样做,如代码所示:
1 | JAVA复制代码 |
也就是我们在第一次循环的开始,就开始扩大同步区域并持有锁,直到最后一次循环结束,才结束同步代码块释放锁的话,这就会导致其他线程长时间无法获得锁。所以,这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。
锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。
偏向锁/轻量级锁/重量级锁
这三种锁是特指 synchronized 锁的状态的,通过在对象头中的 mark word 来表明锁的状态
本文转载自: 掘金