首页 > 基础资料 博客日记
Java多线程 - Java各种同步锁详解
2023-07-31 20:18:59基础资料围观312次
1 锁分类概述
1.1 乐观锁 & 悲观锁
根据对同步资源处理策略不同,锁在宏观上分为乐观锁与悲观锁,这只是概念上的一种称呼,Java中并没有具体的实现类叫做乐观锁或者悲观锁。
- 乐观锁:所谓乐观锁(Optimistic Lock),总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间这个数据是否被其他线程更新过,根据对比结果做出以下处理:
- 如果这个数据没有被更新,当前线程将自己修改的数据成功写入。
- 如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作,例如报错或者重试。
- 悲观锁:与乐观锁相反,悲观锁(Pessimistic Lock)总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
悲观锁阻塞事务,乐观锁回滚重试,它们各有优缺点,适应场景的不同区别,比如:
- 实现方式不同:
- 乐观锁:在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
- 悲观锁:依赖Java的synchronized和ReentrantLock等锁去实现。
- 适用场景不同:
- 乐观锁:适合用于写操作比较少的场景,因为冲突很少发生,这样可以省去锁的开销,加大了系统的整个吞吐量。
- 悲观锁:适用于写操作比较多的场景。如果经常产生冲突,上层 应用会不断的进行重试,乐观锁反而会降低性能,悲观锁则更加合适。
前面提到乐观锁得实现是依赖于CAS,那么何为CAS呢? CAS,即Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。 CAS通过以下机制实现变量同步:
- 比较:读取到一个值 A,在将其更新为 B 之前,检查原值是否为 A(未被其它线程修改过,这里忽略 ABA 问题)。
- 替换:如果是,更新 A 为 B,结束。如果不是,则不会更新。
上面两个步骤都是原子操作,可以理解为瞬间完成,在 CPU 看来就是一步操作。在 Java 中也是通过 native 方法实现的 CAS。更多教程请访问码农之家
public final class Unsafe {
...
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
...
}
CAS虽然很高效,但是它也存在三大问题:
- ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。 JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
- 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。 Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
1.2 自旋锁 & 适应性自旋锁
线程状态切换的开销是非常大的,其原因在于:
- 线程状态切换会使CPU运行状态从用户态转换到内核态。
- 每个线程在运行时的指令是被放在CPU的寄存器中的,如果切换内存状态,需要先把本线程的代码和变量写入内存,这样经常切换会耗费时间。
因此,线程因为无法获取到锁而进行挂起以及后续的恢复操作,这个时间消耗很可能大于同步资源的锁定时间,这种情况对系统而言是得不偿失的。
那么,能不能让获取锁失败的线程先不挂起,而是“稍等一下”,如果锁被释放,这个线程便可以直接获取锁,从而避免线程切换。这个“稍等一下”依赖于自旋实现,所谓自旋,即在一个循环中不停判断是否可以获取锁。获取锁的操作,就是通过 CAS 操作修改对象头里的锁标志位。先比较当前锁标志位是否为释放状态,如果是,将其设置为锁定状态,比较并设置是原子性操作,这个是 JVM 层面保证的。当前线程就算持有了锁,然后线程将当前锁的持有者信息改为自己。
这是一种折中的思想,用短时间的忙等来换取线程切换的开销。
自旋不是尽善尽美的,它有以下的弊端:
- 假如占有锁的线程操作时间很长,那么等待锁的线程会长时间处于自旋状态(称为“忙等”),耗费大量cpu资源。
- Java实现的自旋锁也是一种非公平锁,等待时间最长的线程并不能优先获取锁,可能会产生“线程饥饿”问题。
因此,自旋应该是有限度的。比如说,自旋10次后,自旋线程放弃等待进入挂起状态,同时锁升级为重量级锁,其他线程获取锁失败后直接挂起,不再自旋。
JVM团队后来又推出了自适应自旋,自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
1.3 公平锁 & 非公平锁
公平锁是指,如果多个线程争夺一把公平锁,这些线程会进入一个按申请顺序排序的队列(队列中的线程都处于阻塞状态,等待唤醒),当锁释放的时候,队列中第一个线程获取锁。 与之相对应,非公平锁机制下,试图获取锁的线程会尝试直接去获取锁,获取不到再进入等待队列。
在Java中,synchronized 锁只能是一种非公平锁,而 ReentrantLock 锁则可以通过构造函数指定使用公平锁还是非公平锁(默认是非公平锁)。 下面从源码来看一下,ReentrantLock 公平锁和非公平锁的大概实现机制。
首先,ReentrantLock类无参构造函数指定了使用非公平锁 NonfairSync,另外又提供了有参构造方法,允许调用者自己指定使用公平锁 FairSync 还是非公平锁 NonfairSync(FairSync和NonfairSync是ReentrantLock 内部类 Sync 的两个子类,而添加锁和释放锁的大部分操作是 Sync 实现的)。
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
下面是公平锁和非公平锁加锁方法的对比:
通过对比可见,公平锁相对于非公平锁多了一个限制条件:hasQueuedPredecessors(),这个方法是判断当前线程是否位于队列的首位。
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
公平锁与非公平锁的优劣势比较:
- 非公平锁通过插队直接获取锁,减少了一次线程阻塞与唤醒过程,系统整体吞吐量提升。
- 非公平锁不能保证等待时间最长的线程优先获取锁,可能导致线程饥饿。
1.4 可重入锁 & 不可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
重入锁与不可重入锁实现机制的差异
我们先通过源码对比一下两种锁的实现机制:
通过观察上面的源码,我们得知:
首先,ReentrantLock和NonReentrantLock都继承AQS,AQS中维护了一个同步状态 state 来计数,state 初始值为0,随着占有锁的线程的子流程占据和释放锁,state进行相应增减操作。getState() 方法能获取最新的 state 值。
当线程获取锁时:
- 可重入锁先查询当前 state 值,如果status 是 0,表示没有其他线程在占有锁,则该线程获取锁并将 state 执行+1操作。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行state+1操作,如此循环,当前线程便可以重复获得锁。
- 非可重入锁是直接去判断当前 state 的值是否是 0 ,如果是则将其置为1,并返回 true,从而获取锁,如果不是 0 ,则返回 false,获取锁失败,当前线程阻塞。
当线程释放锁时:
- 可重入锁先获取当前 state 的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。
- 非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
非重入锁容易导致死锁问题:
Java中的ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。为什么非重入锁容易导致死锁问题呢?先看下面这段代码:
public class Widget {
synchronized void methodA() throws Exception{
Thread.sleep(1000);
methodB();
}
synchronized void methodB() throws Exception{
Thread.sleep(1000);
}
}
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰,methodA()方法调用methodB()方法。因为内置锁是可重入的,所以同一个线程在调用 methodB() 时可以直接获得当前对象的锁,进入methodB()进行操作。
如果是一个不可重入锁,那么当前线程在调用 methodB() 之前需要将执行 methodA() 时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放,此时会出现死锁。
下面用一个网上的生动例子来描述一下可重入锁和非重入锁的异同。
有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
Java 中以 Reentrant 开头命名的锁都是可重入锁,而且 JDK 提供的所有现成 Lock 的实现类,包括 synchronized 关键字锁都是可重入的。不可重入锁需要自己去实现。不可重入锁的使用场景非常非常少。
1.5 共享锁 & 独享锁 & 读写锁
共享锁是指该锁可被多个线程所持有。独享锁,也叫排他锁,是指该锁一次只能被一个线程所持有。共享锁与独享锁互斥,独享锁与独享锁互斥。
- 对同步资源施加共享锁后,其他线程只能对此资源再添加共享锁而不能再添加独享锁。
- 对同步资源施加独享锁后,其他线程不能对此资源添加任何锁。
- 获得共享锁的线程只能读数据,不能修改数据。
Java中,Synchronized和ReentrantLock就是一种排它锁,CountDownLatch是一种共享锁,它们都是纯粹的共享锁或独享锁,不能转换形态。 而 ReentrantReadWriteLock(读写锁)就是一种特殊的锁,它既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。
写锁源码:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
// 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
if (writerShouldBlock() ||!compareAndSetState(c, c + acquires))
return false;
// 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者
setExclusiveOwnerThread(current);
return true;
}
- 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
- 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
- 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
- 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
- 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
2 锁升级
锁升级只针对synchronized 锁,synchronized 刚出现时性能较差,Java 6对 synchronized 进行了一系列优化,其性能也有大幅提升。 Java 6的优化主要在于引入 “偏向锁”和“轻量级锁”的概念,减少了获得锁和释放锁的消耗。
目前,锁一共有4种状态,分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁只能按照下面的顺序升级,锁一旦升级,就不能降级。
各种锁特点及适用场景:
锁类型 |
优点 |
缺点 |
适用场景 |
---|---|---|---|
偏向锁 |
加锁和解锁不需要额外的消耗,与执行非同步方法仅存在纳秒级的差距 |
如果线程间存在竞争,会带来额外的锁撤销的消耗 |
适用于只有一个线程访问同步块的情况 |
轻量级锁 |
竞争的线程不会堵塞,提高了程序的响应速度 |
始终得不到锁的线程,使用自旋会消耗CPU |
追求响应时间,同步块执行速度非常块 |
重量级锁 |
线程竞争不使用自旋,不会消耗CPU |
线程堵塞,响应时间缓慢 |
追求吞吐量,同步块执行速度比较慢,竞争锁的线程大于2个 |
2.1 偏向锁
偏向锁源自Java6,顾名思义,偏向锁是指偏向一个线程的锁,更具体点说就是,在偏向锁机制下,一个线程一旦持有了锁,那么JVM默认该线程持续持有锁,直到另一个线程加入竞争。
偏向锁获取锁的过程:
- 首先检查 Mark Word 是否为可偏向锁的状态,为1即表示支持可偏向锁,为0表示不支持可偏向锁。
- 如果是可偏向锁,则检查Mark Word储存的线程ID是否为当前线程ID,如果是则执行同步块,否则执行步骤3。
- 如果检查到Mark Word的ID不是本线程的ID,则通过CAS操作去修改线程ID修改成本线程的ID,如果修改成功则执行同步代码块,否则执行步骤4。
- 当拥有该锁的线程到达安全点之后,挂起这个线程,升级为轻量级锁。
偏向锁释放的过程:
- 偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。
- 等待全局安全点(在这个是时间点上没有字节码正在执行)。
- 暂停拥有偏向锁的线程,检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态,否则设置为被锁定状态。如果锁对象处于无锁状态,则恢复到无锁状态(01),以允许其他线程竞争,如果锁对象处于锁定状态,则挂起持有偏向锁的线程,并将对象头Mark Word的锁记录指针改成当前线程的锁记录,锁升级为轻量级锁状态。
偏向锁产生自Java 6,并且是jdk默认启动的选项,可以通过-XX:-UseBiasedLocking 来关闭偏向锁。另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令 -XX:BiasedLockingStartupDelay=0来关闭延迟。
2.2 轻量级锁
轻量级锁从偏向锁升级而来,在轻量级锁状态下,当锁被一个线程占有时,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
轻量级锁获取锁过程:
- 在线程进入同步代码的时候,先判断如果同步对象锁状态为无锁状态(锁标志位为"01"状态,是否为偏向锁为"0"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Recored)的空间,用于储存锁对象目前的Mark Word的拷贝(官方把这份拷贝加了个Displaced前缀,即Displaced Mark Word)。
- 将对象头的Mark Word 复制到线程的锁记录(Lock Recored)中。
- 复制成功后,虚拟机将使用 CAS 操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新成功了,则执行步骤4,否则执行步骤5。
- 更新成功,这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为"00",即表示此对象处于轻量级锁的状态。
- 更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其其它线程抢占了。进行自旋执行步骤3,如果自旋结束仍然没有获得锁,轻量级锁就需要膨胀为重量级锁,锁标志位状态值变为"10",Mark Word中储存就是指向monitor对象的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
轻量级锁释放锁的过程:
- 使用CAS操作将对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来(依据Mark Word中锁记录指针是否还指向本线程的锁记录),如果替换成功,则执行步骤2,否则执行步骤3。
- 如果替换成功,整个同步过程就完成了,恢复到无锁的状态(01)。
- 如果替换失败,说明有其他线程尝试获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
2.3 重量级锁
重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。
3 其他锁优化
3.1 锁消除
锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。
根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
看下面这段程序:
public class SynchronizedTest {
public static void main(String[] args) {
SynchronizedTest test = new SynchronizedTest();
for (int i = 0; i < 100000000; i++) {
test.append("abc", "def");
}
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去(即StringBuffer sb的引用没有传递到该方法外,不可能被其他线程拿到该引用),所以其实这过程是线程安全的,可以将锁消除。
3.2 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
举个例子:
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签: