锁
什么是锁
相关信息
锁可以用于解决并发时数据混乱的问题。 在 Java 中有许多类型的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
乐观锁 & 悲观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。
悲观锁是指在进行并发操作时,悲观的认为在使用数据时很有可能有其他线程来修改数据,因此在获取数据时先加锁,确保数据不会被别的线程修改。 synchronized 和 Lock 的实现就是悲观锁。适合写操作多的场景。
// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized 同步方法
public synchronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
// 需要保证多个线程使用的是同一个锁
private ReentrantLock lock = new ReentrantLock();
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}
乐观锁是指在进行并发操作时,乐观的认为在使用数据时大概率不会有其他线程修改数据,因此在获取数据时不添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果数据没有被更新,则将当前线程数据写入;如果数据已经被更新,则根据不同的实现方式执行不同的操作(报错或者重试)。适合读操作多的场景 。乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,Java 原子类中的递增操作就通过 CAS 自旋实现。
// ------------------------- 乐观锁的调用方式 -------------------------
// 需要保证多个线程使用的是同一个 AtomicInteger
// AtomicInteger 原子类 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作
private AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
自旋锁
Java 线程的阻塞或唤醒操作需要操作系统切换 CPU 状态,状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复的时间花费可能会让系统得不偿失。自旋锁就是为了让当前线程“稍等一下”,看看持有锁的线程是否很快就会释放锁,从而避免切换线程的开销。
自旋锁不能代替阻塞,虽然避免了线程切换的开销,但要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,自旋的线程只会浪费处理器资源。所以自旋等待的时间必须要有限度,当前超过了限定次数(默认是10次,可以使用 -XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。
CAS 中 AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
在 JDK 1.6 中引入了自适应自旋锁,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。相反,如果对于某个锁,自旋很少成功获取锁。那以后要获取锁时可能省略掉自旋过程,以避免浪费处理器资源。
无锁/偏向锁/轻量级锁/重量级锁
无锁/偏向锁/轻量级锁/重量级锁是指使用 synchronized 同步时锁的状态,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。
轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞。
公平锁 & 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
- 优点:等待锁的线程不会饿死。
- 缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
- 优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
- 缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
可重入锁 & 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。ReentrantLock 和 synchronized 都是可重入锁,可一定程度避免死锁。
以下类中的两个方法都是被 synchronized 修饰的,因为 synchronized 是可重入锁,所以同一个线程在调用 doOthers() 时可以直接获得当前对象的锁,进入操作。 如果是不可重入锁,那么当前线程在调用 doOthers() 之前需要将执行 doSomething() 时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放,此时会出现死锁。
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
共享锁 & 排他锁
排他锁(X锁)指该锁只能被一个线程所持有。一个线程加上共享锁排他锁后,其他线程不能再加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。synchronized 和 Lock 的实现就是排他锁。
共享锁(S锁)指该锁可被多个线程所持有。一个线程加上共享锁后,其他线程只能再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。