Java并发编程基础—锁
锁的7大分类
-
偏向锁/轻量级锁/重量级锁;特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态
1)如果锁没有竞争,就不需要上锁了,打个标记即可,这就是偏向锁
2)不存在实际竞争,或者竞争时间很短,用CAS就可以解决,这就是轻量级锁
3)开销较大,会让申请却拿不到锁的进程陷入阻塞状态
锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。
-
可重入锁/非可重入锁;
1)可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。
2)不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取
-
共享锁/独占锁;
1)共享锁指的是我们同一把锁可以被多个线程同时获得
2)独占锁指的就是,这把锁只能同时被一个线程获得
-
公平锁/非公平锁;
1)公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁
2)非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象
-
悲观锁/乐观锁;
1)悲观锁的概念是在获取资源之前,必须先拿到锁
2)乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源
-
自旋锁/非自旋锁;
1)自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁
2)非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等
-
可中断锁/不可中断锁。
1)在 Java 中,synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理
2)ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开
释放和获取monitor锁的时机
每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。
同步代码块
synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令
-
monitorenter
执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:
a. 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。
b. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
c. 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。
-
monitorexit
monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。
同步方法
同步代码块是使用 monitorenter 和 monitorexit 指令实现的。而对于 synchronized 方法,并不是依靠 monitorenter 和 monitorexit 指令实现的,被 javap 反汇编后可以看到,synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。
当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁
Synchronized和Lock的异同
相同点
- synchronized 和 Lock 都是用来保护资源线程安全的。
- 都可以保证可见性。
- synchronized 和 ReentrantLock 都拥有可重入的特点。
不同点
-
用法区别
synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this),也可以新建一个同步代码块并且自定义 monitor 锁对象;而 Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。
-
加解锁顺序不同
对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2,加解锁有一定的灵活度
-
synchronized 锁不够灵活
synchronized是重量级锁,获取不到锁的对象会陷入等待、阻塞。而Lock如果不想等待了可以放弃等待去做别的事
-
synchronized 锁只能同时被一个线程拥有, Lock 锁没有这个限制
-
原理区别
synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的
-
是否可以设置公平/非公平
ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置。
-
性能区别
在 Java 5 以及之前,synchronized 的性能比较低,但是到了 Java 6 以后,发生了变化,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。
Lock锁
Lock 接口是 Java 5 引入的,Lock 并不是用来代替 synchronized 的,而是当使用 synchronized 不合适或不足以满足要求的时候,Lock 可以用来提供更高级功能的。
方法纵览
public interface Lock {
//lock() 是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。并且不能忘记在 finally 中添加 unlock() 方法,以便保障锁的绝对释放。lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待,所以一般我们用 tryLock() 等其他更高级的方法来代替 lock()
void lock();
//除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。这个方法本身是会抛出 InterruptedException 的,所以使用的时候,如果不在方法签名声明抛出该异常,那么就要写两个 try 块
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true,否则返回 false,代表获取锁失败
boolean tryLock();
//tryLock() 的重载方法,区别在于 tryLock(long time, TimeUnit unit) 方法会有一个超时时间
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//unlock() 方法,是用于解锁的
void unlock();
Condition newCondition();
}
公平锁和非公平锁
公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。但需要注意这里的非公平并不是指完全的随机,不是说线程可以任意插队,而是仅仅“在合适的时机”插队(仅在持有锁线程释放锁的一瞬间)。
这样是因为唤醒等待线程的开销是比较大的,如果执行的代码较少,就可以先让未阻塞的线程执行,当阻塞的线程被完全唤醒时,该线程已经执行完了。这是一个双赢的“局面”。提高了整体运行效率。公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件hasQueuedPredecessors() 为 false, 这个方法就是判断在等待队列中是否已经有线程在排队了。
有一个特例需要我们注意,针对 tryLock() 方法,它不遵守设定的公平原则。因为它本身调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。
ReadWriteLock读写锁
读写锁分为读锁和写锁,要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)
ReentrantReadWriteLock 是 ReadWriteLock 的实现类,最主要的有两个方法:readLock() 和 writeLock() 用来获取读锁和写锁。
相比于 ReentrantLock 适用于一般场合,ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。
读写锁插队策略
如果是公平锁,我们就在构造函数的参数中传入 true,如果是非公平锁,就在构造函数的参数中传入 false,默认是非公平锁。在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。
公平锁实现
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
非公平锁实现
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
锁的升降级
public class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
//在获取写锁之前,必须首先释放读锁。
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
//这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
if (!cacheValid) {
data = new Object();
cacheValid = true;
}
//在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
rwl.readLock().lock();
} finally {
//释放了写锁,但是依然持有读锁
rwl.writeLock().unlock();
}
}
try {
System.out.println(data);
} finally {
//释放读锁
rwl.readLock().unlock();
}
}
}
这是一个非常典型的利用锁的降级功能的代码。你可能会想,我为什么要这么麻烦进行降级呢?我一直持有最高等级的写锁不就可以了吗?这样谁都没办法来影响到我自己的工作,永远是线程安全的。
如果我们在刚才的方法中,一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也是没有必要的,因为我们只有一处修改数据的代码。后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。
所以,可以知道,只能从写锁降级为读锁,不能从读锁升级为写锁。
自旋锁
自旋锁的好处
自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。
AtomicLong 的实现
在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
public final long getAndAddLong (Object var1,long var2, long var4){
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
适用场景
自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。
JVM对锁的优化
自适应的自旋锁
自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定
public final long getAndAddLong(Object var1, long var2, long var4) {
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
锁消除
一个锁消除的例子:经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。
锁粗化
如果我们释放了锁,紧接着什么都没做,又重新获取锁。其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除。不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。
这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。
偏向锁/轻量级锁/重量级锁
这三种锁是特指 synchronized 锁的状态的,通过在对象头中的 mark word 来表明锁的状态。在上面我们已经讲过。
锁的升级路径
从无锁到偏向锁,再到轻量级锁,最后到重量级锁。结合前面我们讲过的知识,偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。
JVM 默认会优先使用偏向锁,如果有必要的话才逐步升级,这大幅提高了锁的性能。