Java 并发编程 Lock

可重入锁,可中断锁,公平锁,非公平锁,AQS同步器,读锁,写锁,乐观锁,悲观锁 

2018年拍摄于日本京都幕府(二条城)唐门

微信公众号

王皓的GitHub:https://github.com/TenaciousDWang

 

锁,SUO,在生活中我们都用过,在计算机领域出现资源竞争时,我们也同样需要锁,来保证同时只有一个线程拥有当前资源进行操作,这个操作属于黑盒操作,外面的线程无法获知当前线程在做什么操作,只有当前持有锁的线程本身自己知道。

 

计算机里的锁是从最早的悲观锁发展而来的,后来才发展出如上一回说到的偏向锁,轻量级锁,及乐观锁,分段锁等很多新型的锁。

这里简单唠叨两句悲观锁与乐观锁。

 

悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态,例如我们的重量级锁synchronized。

 

乐观锁顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,例如我们上一回说到的CAS算法,属于非阻塞式同步。

 

 

两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,例如CAS存在:“ABA”这种缺陷,就是当使用A与V比较时,不能保证A是修改过还是没有修改过的数据,CAS算法会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

 

JDK1.5时,引用了Lock同步工具用于多线程下共享资源的操作控制,对于当时来说,Lock的引用是一大进步,比传统的synchronized锁性能更高,使用更加灵活,属于API级别的操作,并提供了很多高级功能,今天这一回说Lock接口及其实现ReentrantLock可重入锁,及ReadWriteLock接口及其实现ReentrantReadWriteLock读写锁,读写锁中包含两个静态内部类实现了Lock接口。

 

Lock接口是java.util.concurrent(JUC)中的顶级接口,其包含的方法如下:

 

 

我们来说一下Lock接口中每个方法的使用,其中lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()是用来获取锁的。

 

unLock()方法是用来释放锁的。

 

newCondition()这个方法用于线程协作,用于替换Object中的wait,notify,notifyAll,更高效更安全,代码更优雅,以后会用经典生产者与消费者模型来说明一下如何使用,这一回先过。

 

ReentrantLock可重入锁是Lock接口的唯一直接实现,也是最常用的锁,所以这里我们使用其创建锁对象。

 

 

lock()方法,尝试获取锁,获取成功则返回,否则阻塞当前线程。多线程下访问(互斥)共享资源时, 访问前加锁,访问结束以后解锁,解锁的操作推荐放入finally块中,以保证锁一定被被释放,防止死锁的发生。运行结果如下。

 

https://mmbiz.qpic.cn/mmbiz_png/U6icu7jjcXN8POenibA2PtpcYPibD1jibxicRE6ico1fh9jevoia7dicFQyYz6gKu9T5xibfmXRECCnJ6JDloXqric7rSKfA/640?wx_fmt=png

 

第二种方式tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。

 

 

运行结果为:

 

 

tryLock(long time, TimeUnit unit) 尝试获取锁,若在规定时间内获取到锁,则返回true,否则返回false,未获取锁之前被中断,则抛出异常,这里不再赘述,使用方法参考tryLock()。

 

lockInterruptibly()尝试获取锁,线程在成功获取锁之前被中断,则放弃获取锁,抛出异常,注意获取锁时一定放到try catch外面,让InterruptedException抛出。

 

首先创建一个LockInterruptedThread线程。

 

 

然后创建测试类用于启动和中断线程。

 

 

线程1在执行中时,线程2挂起,这时可以使用interrupt正常中断。注意这里使用的是Lock,是指在获取锁之前可中断,如果使用synchronized获取锁则是线程阻塞时,即挂起时可中断。

 

在说完Lock接口的基本用法后,我们来说一下ReentrantLock,ReentrantLock重入锁是Lock接口里最重要的实现,也是在实际开发中应用最多的一个,我们首先看一下ReentranLock的构造方法,总共有两个。

 

 

这里我们可以看到两个类FairSync(公平锁),NonfairSync(非公平锁)。

 

第一种声明的是FairSync公平锁,是按照时间先后顺序,使先等待的线程先得到锁,而且,公平锁不会产生饥饿锁,也就是只要排队等待,最终能等待到获取锁的机会。

 

第二种声明的是NonfairSync非公平锁,所谓非公平锁就和公平锁概念相反,线程等待的顺序并不一定是执行的顺序,也就是后来进来的线程可能先被执行。

 

 

图片引用自《码出高效》

 

我们可以看到ReentrantLock对Lock接口的实现主要依赖了Sync,而Sync继承了AbstractQueuedSynchronizer(AQS),AQS是JUC实现同步的基础工具。

 

 

AQS中定义了一个volatile int state变量作为共享变量,这里主要利用了volatile解决多线程共享变量的可见性问题,类似于synchronized,但不具备其互斥性,后面会单独说一下 volatile 关键字,如果线程获取资源失败,则进入同步FIFO(先进先出)队列中等待,如果成功获取资源就执行临界区代码(就是当前线程的需要执行的代码),执行完释放时,会通知队列中等待的线程出队执行。

 

我们以非公平ReentrantLock锁先来说一下如何获取锁,上面说的state为0时可获取资源,获取后设置为1,并不断加1,在释放资源时不断减1,直到为0,这里替换值是使用了CAS方法,非常高效。

 

 

调用compareAndSetState方法,传了第一参数是期望值0,第二个参数是实际值1,当前这个方法实际是调用了unsafe.compareAndSwapInt实现CAS操作的,也就是上锁之前状态必须是0,如果是0调用setExclusiveOwnerThread方法。

 

当compareAndSetState方法返回false时,此时调用的是acquire方法,参数传入1,tryAcquire()方法实际是调用了nonfairTryAcquire()方法。

 

 

注释上写着,请求独占锁,忽略所有中断,至少执行一次tryAcquire,如果成功就返回,否则线程进入阻塞--唤醒两种状态切换中,直到tryAcquire成功。

 

 

这里一直使用CAS来设置状态,效率很高,且一直使用自旋不停地调用,效率比Java1.6之前的synchronized高很多,但是大家知道自旋本身的效率是消耗更多的CPU资源而得来的。

 

接下来我们来对比一下公平锁与非公平锁。

 

在公平的锁中,如果有另一个线程持有锁或者有其他线程在等待队列中等待这个锁,那么新发出的请求的线程将被放入到队列中。

 

 

而非公平锁上,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中(此时和公平锁是一样的)。所以,它们的差别在于非公平锁会有更多的机会去抢占锁。

 

公平锁实现了先进先出的公平性,但是由于来一个线程就加入队列中,往往都需要阻塞,再由阻塞变为运行,这种上下文切换非常消耗性能,且在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。

 

非公平锁由于允许插队所以,上下文切换少的多,能更充分的利用cpu的时间片,尽量的减少cpu空闲的状态时间,性能比较好,保证的大的吞吐量,但是容易出现饥饿问题。

 

ReentrantLock默认是非公平锁,也是我们最常用的锁,公平锁性能不如非公平锁,但是可以对业务进行控制,也是看情况来使用的。

 

ReentrantLock是排他锁,这种锁同一时刻只允许一个线程访问,而读写锁同一时刻可以多个线程访问,但在写线程访问时,所有读线程和其他写线程都要被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读写锁,使得并发性相比一般的排他锁有很大提升。--《Java并发编程的艺术》

 

 

上图是ReadWriteLock接口,只有两个方法就是用来获取读锁与写锁。除了同ReentrantLock一样支持可重入性,公平与非公平锁机制外,只有一个锁降级特性,遵循获取写锁、获取读锁再释放写锁的次序,写锁降级为读锁。

 

接下来是ReadWriteLock的实现类ReentrantReadWriteLock。

 

 

ReentrantReadWriteLock里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和writeLock()用来获取读锁和写锁,下面我们来看一下ReentrantReadWriteLock具体用法。

 

 

运行结果为。

 

 

Thread0和Thread1在同时进行读操作,这样就大大提升了读操作的效率。一般情况下,读写锁的性能比排他锁要好,因为大多数场景读是多于写的,所以在读多余写时,读写锁能够提供比排他锁更好的性能和吞吐量。

 

读锁支持重入和共享,也就是同时可以被多个线程访问,在没有其他写线程访问时,所有读操作总是不会阻塞的,就是都能获取到。但是如果当前线程要获取读锁时,发现写锁已经被获取,那么读锁要进入等待状态。

 

写锁的获取就比读锁的获取繁琐一些,要判断读锁是否存在,存在读锁的话,写锁就不能获取。

 

 

原因在于:要确保写锁的操作对读锁的可见性,如果允许读锁已经被获取的情况下还要获取写锁,那么正在运行的其他读线程就无法感知当前写线程的操作。所以说获取写锁前一定要看是否还有读锁已经被获取。而写锁一旦被获取,其他读写线程都要被阻塞了。我们要保证在写之前不能有线程还在读,这样数据不准确。

 

总结一下规则就是:

 

如果有一个线程已经持有读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待其他释放读锁。

 

如果有一个线程已经持有写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

 

最后基于前面synchronized与本回说的Lock总结几点Lock和synchronized的区别:

 

1、Lock是一个接口属于锁的API级实现,而synchronized是Java中的关键字,synchronized是内置的语言实现。

 

2、synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生,而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。

 

3、Lock属于可中断锁,synchronized属于不可中断锁,Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

 

4、通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

 

5、Lock可以提高多个线程进行读操作的效率,有多种实现,使用起来比synchronized更加灵活。

 

6、ReentrantReadWriteLock不支持锁升级或锁膨胀。

 

JDK1.6之后,由于JVM虚拟机对synchronized这种重量级锁进行了大量优化,在性能上来说,两者的性能是差不多的,且官方文档也建议使用synchronized来实现同步线程,但是当有特殊规则竞争资源且非常激烈时,此时Lock可以使用灵活的编程方式使性能远远优于synchronized。所以说,在具体使用时要根据适当情况选择,以上。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章