锁 相关知识总结
1. 概述
Java 提供各种各样的锁实现方式,每种锁的特点都不太一样,在适当的场景下能够展现出非常高的效率。由于 Java 中的锁,是按照是否包含某一种特性来定义锁,所以按照锁的特性,可以将锁进行分组归类:
2. 悲观锁 vs 乐观锁
悲观锁和乐观锁,是两种不同的并发控制思想,体现了对线程同步的不同控制方式和强度。在 Java 和数据库中都有乐观锁和悲观锁的具体实现和使用。
2.1. 悲观锁的概念
悲观锁(Pessimistic Locking ),是一种悲观的并发控制思想。如果不同线程对同一个数据产生并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
所以,悲观锁具有强烈的独占性和排他性,因此,在悲观锁在持有数据的过程中,总会把资源或者数据处于锁定状态,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。
Java 中的 Synchronized 和 ReentrantLock 是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁。
// ------------------------------ 悲观锁的调用方式 ------------------------------
// synchronized
public synchronized void testMethod() {// 操作同步资源
}// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {lock.lock();// 操作同步资源lock.unlock();
}
2.2. 乐观锁的概念
乐观锁是一种乐观的并发控制思想,它认为自己在使用数据时不会有别的线程修改数据,所以使用时不会添加锁,只是在更新数据的时候,才会去判断之前有没有别的线程更新了这个数据。
如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试(自旋))。
乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法。
// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 可以保证多个线程使用的是同一个 AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
2.3. 区别
- 悲观锁适合写操作多的场景,先加锁,可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
- 悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。
3. 偏向锁 vs 轻量级锁 vs 重量级锁
3.1. synchronized 实现原理
Synchronized 能实现线程同步,主要是因为 Java 对象头” 和 “Monitor 监视器” 。
Java 对象头
synchronized 是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁的状态,就保存在 Java 对象头里。
以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的 HashCode,GC 分代年龄和锁标志位信息等,这些信息都是与对象自身定义无关的数据。所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor 监视器
Monitor 可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。
Monitor 是线程私有的数据结构,每一个代表锁的对象,都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
synchronized 通过 Monitor 来实现线程同步,Monitor 是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的线程同步。
3.2. 四种锁状态
JDK 6 中为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁”。
所以目前锁一共有 4 种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
锁状态 | 存储内容 | 存储内容 |
无锁 | 对象的 hashCode 、对象分代年龄、是否是偏向锁(0 ) | 01 |
偏向锁 | 偏向线程 ID 、偏向时间戳、对象分代年龄、是否是偏向锁(1 ) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向重量级锁(互斥锁)的指针 | 10 |
3.3. 偏向锁
偏向锁,是指同步代码被同一个线程所访问,那么该线程会自动获取锁(锁对象偏向于这个线程的获取),降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID 。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。
引入偏向锁是为了在没有出现多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为 “01” )或轻量级锁(标志位为 “00” )的状态。
偏向锁在 JDK 6 及以后的 JVM 里是默认启用的。
3.4. 轻量级锁
当锁是偏向锁的时候,被其它线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record )的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。
拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word 。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为 “00” ,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
3.5. 重量级锁
升级为重量级锁时,锁标志的状态变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
3.6. 总结
- 偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。
- 而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
- 重量级锁是将除了拥有锁的线程以外的线程都阻塞。
4. 公平锁 vs 非公平锁
public class Test01 {public static void main(String[] args) {//非公平锁ReentrantLock lock1=new ReentrantLock();//公平锁ReentrantLock lock2=new ReentrantLock(true);}
}
4.1. 公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会 “饿死”(不会产生饥饿线程)。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。
4.2. 非公平锁
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有机率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会产生饥饿线程,或者等很久才会获得锁。
4.3. 区别
4.4. ReentrantLock 中公平锁与非公平锁
ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer ),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。它有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。
ReentrantLock 默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
public class ReentrantLock implements Lock, java.io.Serializable {private static final long serialVersionUID = 73739848272414699L;/** 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 {...}/**...*/public ReentrantLock() { sync = new NonfairSync(); }/**...*/public ReentrantLock(boolean fair) { sync = fair? new FairSync() : new NonfairSync(); }
}
ReentrantLock 中公平锁与非公平锁的加锁方法的源码:
protected final boolean tryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();if (c == 0) {if (!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;
}
final boolean nonfairTryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();if (c == 0) {if (compareAndSetState(0, acquires)) {setExclusiveOwnerThread(current);return true;}}else if (current == getExclusiveOwnerThread()) {int nextc = c + acquires;if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");setState(nextc);return true;}return false;
}
可以看出公平锁与非公平锁的 lock () 方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors () 。
再进入 hasQueuedPredecessors () ,可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回 true,否则返回 false 。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。