一 概述
锁可以理解成一种用于控制对共享资源访问的工具,它在在Java5之前,在协调对共享对象的访问可以使用的内置锁机制是借助关键字synchronized和volatile实现,Java5之后增加了Lock接口,并提供了相应是实现类,如ReentrantLock类等。它是弥补内置加锁机制不适合时的一种高级功能。
二 Lock接口
Lock接口定义了一组抽象的加锁操作。与内置加锁机制不同的是,Lock提供了一种无条件,可轮询,定时的以及可中断的锁获取操作,所有加锁和解锁的方法都是显示的,故可以称这些锁为显式锁。在Lock的实现中必须提供与内部锁相同的内存可见性语义,但在加锁语义,调度算法,顺序保证以及性能等方面有所不同。
public interface Lock {
//获取锁,如果锁被其他线程获取了就会进入等待状态
void lock();
//中断锁
void lockInterruptibly() throws InterruptedException;
//获取锁
boolean tryLock();
//尝试在time时间范围内获取锁,如未获取则会返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//释放锁
void unlock();
//条件
Condition newCondition();
}
Lock接口的实现步骤
* Lock lock = ...;
* if (lock.tryLock()) {
* try {
* // manipulate protected state
* } finally {
* lock.unlock();//必须在finally中做释放锁操作
* }
* } else {
* // perform alternative actions
* }}
为什么需要Lock,synchronized关键字为什么不够用
synchronized关键字定义的内置锁的释放情况少,试图获取锁是不能设置超时,不能中断一个正在试图获得锁的线程。只有synchronized锁定的代码全部执行完后锁才会被释放,或者是执行过程中发生异常后,JVM会自动将该锁释放。
如果某个线程任务执行到一半的时候需要释放锁时,synchronized关键字是无法满足要求。
如果某线程想在一定时间段内尝试获取某锁,如果获取到锁就会进行任务执行,否则返回false,synchronized关键字也是无法满足要求的。
此外,通过synchronized关键字是无法判断当前尝试获取锁的线程是否真正获取到锁。
基于synchronized关键字这些不足,Lock的出现能够很好的解决这些问题。
值得注意的是Lock无法像synchronized一样在异常时自动释放锁,需要在finally代码块中主动使用unlock()方法进行锁的释放,以保证锁在发生异常时一定会被释放。
当然如果线程任务中出现死循环的时候,synchronized设置的内部锁会出现死锁现象。
二 锁的可见性
一般情况下某一线程是无法实时查看另一线程中数据的动态。由于happens-before原则的存在,在当某一线程中的数据发生修改完成后,其他线程一定能够发现同一数据的变化,则其他线程也一定存在happens-before原则。
Lock加锁和synchronized声明的内部锁有同样的内存语义,所以下一个线程加锁后可以看到前一个线程解锁前发生的所有操作,即存在happens_before原则。
对于某一线程需要获取synchronized关键字定义的锁时,需要其他线程先释放当前锁,而且synchronized关键字在释放锁之前会将自己对数据的修改写回内存中。
三 锁的分类
锁的分类并不是互斥的,可以多个类型并存,即一个锁可能属于多个类型的锁。如ReentrantLock既是互斥锁,又是可重入锁。
三 乐观锁和悲观锁
乐观锁也称作非互斥同步锁,悲观锁称为互斥同步锁。
悲观锁(互斥同步锁)
互斥同步锁存在线程的阻塞和唤醒所以会带来性能问题,因为悲观锁为线程独占的,其他线程想要获取相同资源时就需要进行等待。
存在永久阻塞问题:如果持有锁的线程出现死循环,死锁等活跃性问题,那么等待线程释放锁的线程将处于永久等待状态,将永远得不到执行。
优先级反转:如果获取锁的线程优先级比较低而等待锁的线程优先级比较高,则会出现线程优先级的反转。
Java中的悲观锁的实现为synchronized关键字修饰和Lock相关类的实现。
乐观锁(非互斥同步锁)
允许多个线程同时获取锁,当某一线程获取的数据同起初获取的数据不同,说明其他人在这段时间内修改过数据,所以不能继续刚才的更新数据过程,我会选择放弃,报错,重试等策略。
乐观锁的实现一般都是利用CAS算法实现,即获取锁的线程可以在一个原子操作内完成数据的改变,所以其他线程是无法影响到当前线程的对数据的修改。这种实现在java中的原子类和并发容器中都有使用。
Git是乐观锁实现的典型例子,当我们往远端仓库push的时候,git会检查远端仓库的版本是否与本地库版本一致,如果一致我们可以顺利的提交代码到远程仓库,并产生要给新的版本号。如果不一致,说明在此期间存在以他的代码提交,所以无法进行本次代码的提交。
数据库的select for update就是悲观锁,我们通过version控制数据库就是通过乐观锁实现,在表中添加一个字段lock_version在我们先查询该更新语句的版本version,select versionOne from table;然后update table set version = version+1 where version = versionOne;这种操作类似于CAS操作,实现了乐观锁。
悲观锁与乐观锁的比较
悲观锁的起始开销要比乐观锁高,但是某一线程获取悲观锁之后就会一直持有锁,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响。
悲观锁适合并发写入较多的情况,使用于临界区持有锁时间比较长的情况,悲观锁可以避免当量的无用自旋消耗。如临界区存在IO操作,临界区代码复杂或者循环量大,临界区资源竞争激烈。
乐观锁的起始开销要比悲观锁低,但是可能存在很长时间的自旋和不停的重试,那么消耗的资源也会越来越多。
乐观锁适合并发写入少,大部分为度读操作的场景,不加锁的能让读取性能提高。
四 可重入锁和非可重入
可重入理解:可以理解为在北京买车上牌照,需要摇号,如果我摇到号就相当于我获取锁。当我需要为第二辆车进行摇号上牌照时候,被通知为我已经拥有一个牌照不能在参加摇号,除非放弃之前的牌照,这种情况为非重入。而我可以为第二辆车摇号的同时无需放弃之间拥有的牌照的情况表示为可重入。
Synchronized,ReentrantLock均为可重入锁,重入锁也是一种递归锁。重入锁的存在可以避免死锁,同时提高了封装性,减少加锁时必须释放锁的操作。
ReentrantLock方法
//查看锁是否被当前线程所持有
public boolean isHeldByCurrentThread() {
return sync.isHeldExclusively();
}
protected final boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
//查看这把锁的等待队列有多长
public final int getQueueLength() {
return sync.getQueueLength();
}
public final int getQueueLength() {
int n = 0;
for (Node p = tail; p != null; p = p.prev) {
if (p.thread != null)
++n;
}
return n;
}
可重入锁源码
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//获取锁时先判断,如果当前线程为已经占有锁的线程,则state值加1并返回true.
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
//释放锁时也是先判断当前线程是否时已占有锁的线程,然后判断state是否为0,如果是则释放锁
protected final boolean tryRelease(int releases) {
//c表示当前线程重入的次数
int c = getState() - releases;
//判断当前线程是持有锁的线程,如果不是持有锁的线程就会抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
//当可重入的次数大于0的时候,free会一直为false
boolean free = false;
//当前c为0时就会释放锁
if (c == 0) {
free = true;
//将持有当前锁的线程设置为null,表示没有任何线程持有这把锁
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
非可重入锁源码
public class ThreadPoolExecutor extends AbstractExecutorService {
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
//获取锁
protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true; //非重入锁直接尝试获取锁
}
return false;
}
//释放锁
protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);//释放锁是直接将state设置为0
return true;
}
}
}
五 公平锁和非公平锁
公平是指按照线程请求的顺序来分配锁,而非公平锁指的是,不完全按照请求的顺序,在一定合适的情况下,可以进行插队。
非公平锁的存在是为了提高效率,因为它可以避免线程释放锁后唤醒其他线程时的空档期,
ReentrantLock中的公平锁源码
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
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;
}
}
ReentrantLock中的非公平锁源码
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
//进行CAS操作
if (compareAndSetState(0, 1))
//使得当前线程获取锁
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
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) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
锁升级与插队策略:
升级
ReentrantReadWriteLock是不允许读锁插队操作,在升降级的时候是只允许写锁将为读锁(降级)而不允许读锁升级为写锁(升级)。
插队策略
为了防止写锁进程进入饥饿状态,读锁不能进行插队。
1. 在锁为公平锁的情况下是不允许插队的,在非公平状态下,当存在多个线程获取读锁后处于同时读取状态,当此时一个线程想要写入时,会因为无法获取锁而进入等待队列,如果增加一个新的读线程时,该线程是可以插队获取读锁,当存在多个读线程进入时,这样写锁会处于饥饿状态。
2.在非公平状态下,当存在多个线程获取读锁后处于同时读取状态,当此时一个线程想要写入时,会因为无法获取锁而进入等待队列,如果增加一个新的读线程时,直接将该线程放入线程队列中,等到写线程完成后才让其获取读锁,这样避免写锁处于饥饿状态。
ReentrantReadWriteLock采用了策略二,但是非公平锁写锁是可以随时插队的,可以插队但是不容易插入。读锁仅在等待队列头结点不是想获取写锁的线程的时候可以插队。
ReentrantReadWriteLock中非公平的实例
/**
* Nonfair version of Sync
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
/* As a heuristic to avoid indefinite writer starvation,
* block if the thread that momentarily appears to be head
* of queue, if one exists, is a waiting writer. This is
* only a probabilistic effect since a new reader will not
* block if there is a waiting writer behind other enabled
* readers that have not yet drained from the queue.
*/
return apparentlyFirstQueuedIsExclusive();
}
}
ReentrantReadWriteLock中公平的实例
/**
* Fair version of Sync
*/
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
六 共享锁和排它锁
排它锁:又称独占锁和独享锁,只允许一条线程持有,写锁是独享锁的一种。
共享锁:又称为读锁,获得共享锁之后,可以查看当无法对数据进行任何的修改和删除,其他线程也可以获取到共享锁,同样只能查看当无法修改和删除数据
共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁。通常情况下,Lock只允许一个线程来访问这个共享资源,但是存在一些特殊的实现允许并发访问,比如ReadWriteLock里面的ReadLock。
在没有读写锁之前,我们可以通过过关键字Synchronized或者ReentrantLock进行加锁,这样我们虽然可以保证线程安全,但是也会浪费一定的资源,因为我们对资源仅仅是进行操作,不对数据进行任何的修改时并不存在线程安全问题,而通过这种方式进行加锁的时候无论是读还是写都必须先获取锁,然后进行相应的操作,这样就会造成资源浪费。所有在读的地方使用共享锁,而在写的地方使用写锁,从而对锁进行灵活控制,如果没有写锁,读是无阻塞的,提高了程序的执行效率。
读写锁的申请策略:
- 多个线程只申请读锁对资源只进行相应的读操作是都可以申请的;
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待读锁的释放;
- 如果一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待写锁的释放;
简单的理解,读写锁只是一把锁,可以通过两种方式锁定,读锁定和写锁定。读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁进行读锁定和写锁定。
七 锁的升级和降级
某些线程在开始阶段需要进行写入操作,所以持有写锁,而完成写操作之后大部分为读操作,为了避免线程执行任务时出现中断现象,所以线程不会主动直接释放写锁,但是读操作需要的读锁又无法获取,会造成资源的浪费,所以通过锁的降级在不释放写锁的时候直接获取读锁然后再将写锁释放,从而实现读锁的获取。
支持锁的降级,而不支持锁的升级。即写锁可以降级为读锁,但是读锁无法升级为写锁,升级过程会被阻塞。
根据读锁写锁的性质可知,读锁可以被多个线程共同持有,写锁只能由一条线程持有,不能同时存在读锁和写锁,所以在所升级的过程中,需要等待所有的读锁都释放。
如果存在多个读锁都进行升级时,会出现锁的升级过程中的死锁。
八 自旋锁和阻塞锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态的切换需要耗费处理时间。当我们同步代码的内容过于简单,状态转换所消耗的时间比用户代码执行的时间还要长。
为了避免同步资源的锁定时间短,需要切换线程,线程挂起和恢复线程的需要消耗的大量时间的情况,所以使用自旋锁。
自旋锁:当物理机存在多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让当前请求锁的线程不放弃CPU的执行时间,并检测是否存在持有锁的线程会很快的释放锁。为了让当前线程完成这部分工作,我们使得当前线程处于自旋状态,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。
如果存在自旋时间的过长的情况,甚至比某些线程获取锁到释放锁的时间还要长,那么自旋的线程会出现浪费处理器资源的情况。因为自旋的过程中,会一直消耗CPU,所以虽然自旋锁的起始开销较低,但是随着自旋时间的增长,开销也是线性增长的。
自旋锁的实现,在Atomic包中很多原子类都是通过自旋锁来实现的 ,自旋锁是基于CAS原理实现的,如AtomicInteger中调用unsafe进行自增操作的源码的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没有修改成功,就在while里死循环,直至修改成功。
public class AtomicInteger extends Number implements java.io.Serializable {
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
}
public final class Unsafe {
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
}
//自旋操作
while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
}
阻塞锁:如果当前线程没有获得锁时,就会直接将线程阻塞,然后直到线程被唤醒。
九 可中断锁与不可中断锁
在Java中,synchronized就是不可中断锁,而Lock可以通过tryLock(time)和lockInterruptibly()响应中断来实现可中断锁。
不可中断锁:申请锁的线程会一直等待已经持有锁的线程释放锁,在此期间是不可以执行中断操作的。
可中断锁:如果某一线程A正在执行锁中的代码,另一个线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它。
十 锁的优化
Java虚拟机对锁的优化
- 自旋锁和自适锁
- 锁消除(对于方法内部的一些私有方法是不可能被外部访问的时候就不需要进行加锁)
- 锁粗化:反复的对一个对象进行加锁和解锁操作时候,会将这些加锁和解锁操作合并成一个加锁和解锁操作,JIT编译器会进行检测,如果相邻的synchronized同步代码块使用的是同一个锁对象,它会自动将这些同步代码块合并成一个大的同步代码块,从而在执行的时候无需反复的申请和释放锁,只需要申请一次锁和释放一次锁。
编程过程中的锁优化
- 缩小同步代码块:在保证锁中原子操作的同时将开销大且不需要加锁的内容移出同步代码块。
- 尽量不要锁住方法:方法的范围可能比较大,而且方法中的内容是会随时被修改的。
- 减少锁的次数:如在日志框架中,存在多个线程同时打印日志,由于它们属于互斥所以效率比较低,我们可以将多个线程的任务汇总到一个线程,让后让其统一完成加锁的写操作,类似于使用消息队列作为中间层,此时,我们只需要一个锁来完成日志打印的工作,从而减少锁的申请次数。
- 避免人为制造阻塞:如HashMap通过size()方法获取HashMap中数据的数量,当有线程调用size方法时会进行HashMap的遍历操作,此时其他线程修改HashMap的方法就会被阻塞,此时我们可以维护一个HashMap数据量的计数,当往HashMap中插入数据时,计数加一,当从HashMap中移出数据时,计数减一,这样查询时可以直接返回当前的计数而避免使用size()方法造成的阻塞。
- 锁中尽量不要再包含锁
- 选择合适的锁类型和合适的工具类