锁的概念
在Java中,锁是一种同步机制,用于控制并发线程对共享资源的访问。
锁的分类
锁的分类还可以根据其实现方式、作用范围、线程数量等不同的特性进行划分。以下是锁的一些常见分类:
-
根据实现方式:
- 显示锁(Explicit Lock):需要通过显式地调用锁对象的方法来获取和释放锁,如
ReentrantLock。 - 隐式锁/内部锁(Intrinsic Lock):由编程语言或运行时环境提供的隐式锁机制,如 Java 中的
synchronized关键字。
- 显示锁(Explicit Lock):需要通过显式地调用锁对象的方法来获取和释放锁,如
-
根据作用范围:
- 全局锁(Global Lock):作用于整个系统或者整个应用程序,控制对全局资源的访问。
- 对象锁(Object Lock):作用于对象实例,控制对对象实例的访问。
- 类锁(Class Lock):作用于类对象,控制对类的静态资源的访问。
-
根据线程数量:
- 互斥锁/排他锁(Mutex Lock):一次只允许一个线程访问共享资源的锁。
- 共享锁/读写锁(Read-Write Lock):允许多个线程同时读取共享资源,但在写入资源时需要独占访问。
-
根据公平性:
- 公平锁(Fair Lock):保证锁的获取按照请求的顺序进行,避免饥饿现象。
- 非公平锁(Non-fair Lock):对锁的获取顺序不做保证,可能存在线程饥饿现象。
-
根据获取方式:
- 可重入锁(Reentrant Lock):允许同一个线程多次获取同一个锁,避免死锁。
- 不可重入锁(Non-reentrant Lock):不允许同一个线程多次获取同一个锁,会导致死锁。
-
根据等待策略:
- 自旋锁(Spin Lock):线程在获取锁时不会被阻塞,而是通过自旋重试的方式获取锁。
- 阻塞锁(Blocking Lock):线程在获取锁时如果失败会被阻塞,直到获取到锁为止。
Java中具体的锁实现
1. synchronized 关键字
这是Java最基础的互斥锁机制,每个Java对象都可以作为一把锁。通过synchronized修饰方法或代码块,当一个线程进入synchronized区域时会自动获取对象锁,并在离开时释放锁。同一时间只有一个线程可以获得该锁,其他线程必须等待锁的释放才能进入同步代码块或方法。
锁升级和锁粗化:
synchronized 锁升级和锁粗化是Java中用于提升并发性能的两种重要锁优化技术,它们都是HotSpot虚拟机在处理同步代码块或方法时采用的策略。以下是关于这两者的详细解释:
synchronized锁升级
锁升级是指当Java程序中使用synchronized关键字对某个对象进行同步时,HotSpot JVM根据锁竞争的实际情况,自动将锁从低开销的形式逐步升级到高开销但更稳定、适合高竞争场景的形式。具体的升级路径如下:
-
无锁状态:
- 对象尚未被任何线程锁定,其对象头中的锁标志位为0。
-
偏向锁:
- 当一个线程首次获得对象锁时,JVM会将锁设置为偏向模式,即在对象头的Mark Word中记录下该线程的ID,使得该线程在后续进入同步代码块时无需进行任何同步操作,只需验证当前线程ID是否与Mark Word中的线程ID一致即可。
- 偏向锁适用于只有一个线程访问同步代码块的场景,几乎无额外开销。
-
轻量级锁:
- 当有第二个线程尝试获取已被偏向的锁时,偏向锁失效,JVM会将锁升级为轻量级锁。
- 轻量级锁通过CAS操作(Compare-and-Swap)尝试在当前线程栈中创建一个锁记录(Lock Record),并将对象头的Mark Word替换为指向锁记录的指针。如果CAS成功,线程获得锁;若失败,说明存在其他线程的竞争,进入自旋状态。
- 自旋过程中,线程会循环尝试获取锁,避免立即挂起。自旋次数可由JVM根据历史统计数据动态调整,若在一定次数内仍未获得锁,则升级为重量级锁。
-
重量级锁(也称互斥锁或独占锁) :
- 当竞争激烈,自旋无法快速获得锁或者超过自旋阈值时,JVM会将锁升级为重量级锁。
- 在此状态下,竞争锁的线程会被操作系统挂起并放入阻塞队列,等待锁持有者释放锁后,由操作系统唤醒并重新竞争锁。虽然上下文切换成本较高,但能有效避免大量线程因长时间自旋而导致的CPU资源浪费。
锁升级机制的目标是在保证数据同步的前提下,尽可能减少锁的开销,尤其是在低竞争或无竞争的情况下避免不必要的系统调用和上下文切换。
锁粗化
锁粗化则是另一种优化手段,它关注的是如何减少琐碎的加锁操作,避免频繁地进行加锁和解锁,特别是在一系列连续的操作中。具体表现为:
如果一系列连续的对同一对象的加锁操作(如在一个循环中对同一个对象反复加锁和解锁),可能会导致不必要的性能损耗,因为每次加锁和解锁都需要一定的开销,而且频繁的锁操作可能导致更多的锁竞争。
锁粗化的做法是将这些原本分散的、琐碎的加锁范围合并成一个更大的锁范围,即将多个连续的同步代码块合并成一个大的同步代码块,这样只需要在进入这个大范围时一次性加锁,执行完所有相关操作后再一次性解锁。这样减少了加锁和解锁的次数,降低了系统开销,同时也减少了因频繁加锁解锁可能导致的锁竞争。
例如,原本的代码可能是这样的:
Java
1synchronized (obj) {
2 // 操作1
3}
4// 非同步代码...
5synchronized (obj) {
6 // 操作2
7}
8// 非同步代码...
9synchronized (obj) {
10 // 操作3
11}
经过锁粗化优化后,代码可能变为:
Java
1synchronized (obj) {
2 // 操作1
3 // 非同步代码...
4 // 操作2
5 // 非同步代码...
6 // 操作3
7}
综上所述,锁升级和锁粗化都是Java HotSpot虚拟机为了提升并发环境下synchronized同步机制的性能而采取的优化手段。前者动态调整锁的状态以适应不同级别的竞争,后者则通过合并锁的使用范围来减少琐碎的加锁解锁操作,降低系统开销和锁竞争。这些优化有助于提高多线程应用程序的执行效率。
2. ReentrantLock 类
java.util.concurrent.locks.ReentrantLock 是Java并发包(JUC)提供的可重入锁,相比于内置锁提供了更多的功能和更高的灵活性。它支持公平锁和非公平锁的选择,以及可打断的获取锁操作。ReentrantLock同样具有可重入性,也就是说,已经获取了锁的线程可以再次获取相同的锁而不受阻塞。
问题:ReentrantLock的公平策略怎么实现的?
ReentrantLock 在Java中通过AbstractQueuedSynchronizer(AQS)框架来实现公平和非公平锁。在ReentrantLock中,内部有FairSync(公平锁)和NonfairSync(非公平锁)两个类,都间接继承自AQS。两者的区别为NonfairSync类的tryAcquire方法会多一步逻辑:检查同步队列的头节点是否为空以及当前线程是否是队列中的第一个等待线程,只有两者都满足时才会尝试获取锁。如果不是,则当前线程会调用AQS的acquireQueued方法将自己加入到等待队列中,并进入等待状态。
public final boolean hasQueuedPredecessors() {
Thread first = null; Node h, s;
if ((h = head) != null && ((s = h.next) == null ||
(first = s.waiter) == null ||
s.prev == null))
first = getFirstQueuedThread(); // retry via getFirstQueuedThread
return first != null && first != Thread.currentThread();
}
3. ReadWriteLock 接口
java.util.concurrent.locks.ReadWriteLock 接口提供了读写锁,典型实现是 ReentrantReadWriteLock。读写锁允许多个读取线程同时访问,但在写入线程访问时,所有读取线程和其他写入线程都必须等待。这样在读多写少的场景下,可以提高系统的并发性
4. StampedLock 类
java.util.concurrent.locks.StampedLock 是Java 8引入的一个高级锁,它提供了乐观读写锁和悲观读写锁的功能。使用stamp作为锁状态的表示,可以进行读模式、写模式和乐观读模式的锁定。
5. 乐观锁
乐观锁并不是Java中的一个具体锁实现,而是一种并发控制的思想。在Java中,乐观锁往往通过CAS(Compare and Swap)原子操作实现,如 AtomicInteger、AtomicLong 或 java.util.concurrent.atomic 包下的其他原子类。乐观锁假设并发冲突的概率较低,因此在读取时不锁定资源,但在更新时检查数据是否在读取后被其他线程改变,如果没有改变则更新成功,否则重试。
6. 自旋锁
自旋锁不是Java标准库提供的锁实现,但在JVM级别或某些第三方库中可能存在类似的概念。自旋锁会让尝试获取锁的线程在原地循环等待(自旋),而不是立即挂起,这样在锁很快就能释放的情况下可以减少线程上下文切换的开销,但如果锁竞争激烈或持有时间较长,自旋反而会浪费CPU资源。