本文概括了当前java中部分锁的理解,包括乐观悲观锁,共享独占锁,可重入锁。
锁
七大锁
- 偏向锁/轻量级锁/重量级锁:将第一个尝试获取锁的线程设为偏向锁的
拥有着
,它在尝试获取该锁时可以直接得到;当该偏向锁被另一个线程访问时,它会升级为轻量级锁
,线程会通过自旋的方式尝试获取锁;当多个线程竞争时,会升级为重量级锁
,获取不到锁的线程会进入阻塞状态。 - 可重入锁/非可重入锁:可重入锁是指当前线程持有该锁的时候,可以不释放该锁就再次获取这把锁。
- 共享锁/独占锁:典型代表:读写锁
- 公平锁/非公平锁:公平锁即遵循排队原则,先入先出,非公平锁则可能发生“插队”。
- 悲观锁/乐观锁:悲观锁在操作资源之前必须先独占资源,乐观锁则可以利用CAS在不独占资源的情况下,操作资源。
- 自旋锁/非自旋锁:自旋锁拿不到锁时,不会阻塞,会自旋不断尝试获取锁。
- 可中断锁/不可中断锁:不可中断锁指开始竞争锁后,不拿到锁就无法继续后面的逻辑,如synchronized,可中断锁如ReentrantLock。
乐观锁的ABA问题
- 乐观锁大部分都是通过 CAS(比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。
- CAS 可能会造成 ABA 的问题,当一个位置被读取两次,两次读取具有相同的值,并且“值相同”用于指示“什么都没有改变”。但是,另一个线程可以在两次读取之间执行并更改值,执行其他工作,然后将值改回,因此,即使第二个线程的工作违反了该假设,也使第一个线程认为“什么都没有改变”。
- JDK 在 1.5 时提供了 AtomicStampedReference 类也可以解决 ABA 的问题,此类维护了一个“版本号” Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
final int stamp; // “版本号”
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
// 比较并设置
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp, // 原版本号
int newStamp) { // 新版本号
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
//.......省略其他源码
}
乐观锁有一个优点,它在提交的时候才进行锁定的,因此不会造成死锁.
公平锁
- 公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小,相反非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。
自旋锁
优势
阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。自旋锁用循环去不停地尝试获取锁,让线程始终处于Runnable状态,节省了线程状态切换带来的开销。
劣势
虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。
可重入锁
public class LockExample {
public static void main(String[] args) {
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");
}
}
main:执行 reentrantA
main:执行 reentrantB
ReentrantLock 和 synchronized 都是可重入锁,从结果可以看出 reentrantA 方法和 reentrantB 方法的执行线程都是“main” ,我们调用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的话,那么线程会被一直堵塞。可重入锁的实现原理,是在锁内部存储了一个线程标识,用于判断当前的锁属于哪个线程(getName()),并且锁的内部维护了一个计数器
Lock接口
方法
public interface Lock {
void lock();
//除非被中断,否则会一直尝试获取
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,获取不到也不会一直等待,可以避免死锁
boolean tryLock();非公平的
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
JVM的优化
自旋锁
1.6引入了自适应的自旋锁来解决长时间自旋的问题,自旋的时间将会根据自旋的成功率,锁的状态等因素来决定。
锁消除
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
如StringBuffer中被synchronized修饰的apppend()方法,大多数情况下只会在一个线程内被使用,则编译器会做出优化,将对应的synchronized消除
锁粗化
两种需要锁粗化的情况
1.释放锁到下一次重新获取锁几乎什么都没做,这时可以将同步区域扩大来优化
public void lockCoarsening() {
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
synchronized (this) {
//do something
}
}
2.在循环内部不断获取,释放锁,会导致其他线程长时间无法获取锁
for (int i = 0; i < 1000; i++) {
synchronized (this) {
//do something
}
}
优化
public void lockCoarsening() {
synchronized (this) {
//do something
}
}
synchronized (lock)
for (int i = 0; i < 1000; i++) {
{
//do something
}
}