一、简介
锁,是一种同步机制,用于在多线程中实现对资源的控制,解决并发问题。
二、锁的种类
-
公平锁/非公平锁
-
可重入锁
-
独享锁/共享锁
-
互斥锁/读写锁
-
乐观锁/悲观锁
-
分段锁
-
偏向锁/轻量级锁/重量级锁
-
自旋锁
上面包含了8种锁的名词,这些分类并不全是指锁的状态,有的是指锁的特性,有的是指锁的设计。下面,将对每个锁的名词进行解释。
在介绍之前,说一下饥饿现象
如果一个线程的cpu执行时间都被其他线程抢占了,导致得不到cpu的分配执行,这种情况就叫做“饥饿”。解决饥饿现象的方法就是实现公平(无法实现完全100%公平),保证所有线程都公平的获得执行的机会。
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReentrantLock(可重入锁)而言,通过构造函数指定该锁是否为公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS来实现线程调度,所以并没有任何办法使其变成公平锁。(ReentrantLock内部提供了两种AQS的实现,一种公平模式,一种是非公平模式)
Synchronized锁是公平锁还是非公平锁?
在多线程的环境中,假如有7个线程,第一次抢到的可能是3号线程。3号线程释放锁之后,第二次这7个线程继续抢,有可能还是3号线程抢到,所以,当然是非公平的锁啦.而且,synchronized 是可重入锁。
可重入锁
可重入锁又名递归锁,是指同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁。
我的理解就是,同一线程已经获得某个锁,可以无需等待而再次获取锁,并且不会出现死锁(不同线程当然不能多次获得锁,需要等待)。
ReentrantLock和synchronized,都是可重入锁。
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的关键部分,如果不是可重入锁的话,setB方法就会阻塞,造成死锁。
独享锁/共享锁
独享锁也称独占锁,是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。
对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读 、写写的过程是互斥的。独享锁与共享锁是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。对于synchronized而言,是独享锁。(AQS,AbstractQueuedSynchronizer,抽象队列同步器)
互斥锁/读写锁
上面说到的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
互斥锁在Java中的具体实现就是ReentrantLock。
读写锁在Java中的具体实现就是ReadWriteLock。其包含了读锁和写锁,其中读锁是可以多线程共享的,即共享锁,而写锁是排他锁,在更改时候不允许其他线程操作。读写锁底层是同一把锁(基于同一个AQS),所以会有同一时刻不允许读写锁共存的限制。如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
乐观锁/悲观锁
乐观锁和悲观锁并不是一种真实存在的锁,而是一种思想。
乐观锁:非常“乐观”的认为在“我”之后不会再有人对该数据进行操作,所以不会加锁。但是在进行写入操作的时候会判断当前数据是否被修改过。
悲观锁:非常”悲观“的认为在”我“之后会有人对数据进行操作,为了保证数据不被别人修改,会加锁(行锁、读锁、写锁等)。
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作,ConcurrentHashMap中的分段锁称为Segment,它类似于HashMap(JDK7与JDK8)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,也就是获取HashMap全局信息的时候,需要获取所有的分段锁才能统计。
分段锁设计的目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的部分进行加锁操作。
偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。(如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁)
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
锁状态的切换
synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)
Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制:
偏向锁(Biased Lock )
轻量级锁( Lightweight Lock)
重量级锁(Heavyweight Lock)
上述这三种机制的切换是根据竞争激烈程度进行的, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。
什么是自旋?
“自旋”可以理解为“自我旋转”,这里的“旋转”指“循环”,比如 while 循环或者 for 循环。“自旋”就是自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞。如果获取锁失败就再次尝试,直到成功为止。
自旋锁
是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待并尝试获取,不断的判断锁是否能够被成功获取,直到成功获取到锁才会退出循环。
三、锁的使用
预备知识
AQS
AbstractQueuedSynchronized,抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch…
AQS原理
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程归为有效工作线程,并且该资源设置为锁定状态。其他线程如果要请求该共享资源,由于该资源被占有,因此无法请求成功,那么就需要一套线程阻塞等待以及唤醒时锁分配的机制。这个机制AQS使用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH同步队列是一个FIFO双向队列。
AQS维护了一个volatile int state(共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。state的访问方式有三种:
getState()
setState()
compareAndSetState()
AQS定义两种资源共享方式
Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
-
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
-
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享):多个线程可同时执行,如Semaphore、CountDownLatch、ReadWriteLock的Read锁。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要继承AQS,实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以可重入的独占锁ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态。
再以共享锁CountDownLatch为例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行(顺序)执行的,每个子线程执行完后countDown()一次,state会 CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()唤醒主线程,然后主线程就会从await()函数返回,继续后续动作。
一般来说,自定义同步器要么是独占方式的,要么是共享方式的,他们只需实现tryAcquire-tryRelease或tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
CAS
CAS(Compare and Swap 比较并交换)是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,其它线程都会失败,失败的线程并不会被挂起,只是被告知这次竞争中失败,并且可以再次尝试。说白了,CAS 其实是乐观锁,是一种思想。
CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和准备写入的新值(B)。如果内存位置V的值与预期原值A相等,则将内存位置值更新为新值B,否则不更新。无论哪种情况,它都会在CAS 指令之前返回该位置的值(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)。CAS有效地说明了“ 我认为位置V应该包含值A,如果包含该值A,则将 B放到这个位置;否则,不要更改该位置的值,只告诉我这个位置现在的值即可”。这其实和乐观锁的冲突检查 + 数据更新的原理是一样的。
JAVA对CAS的支持:
在JDK1.5中新增java.util.concurrent包就是建立在CAS之上的。相对于synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以java.util.concurrent在性能上有了很大的提升。
以java.util.concurrent包中的AtomicInteger为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解 getAndIncrement方法,该方法的作用相当于 ++i 操作。
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int get() {
return value;
}
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}
实战
1.验证synchronized可重入独占锁
public class Test implements Runnable {
//private ReentrantLock reentrantLock = new ReentrantLock();
public synchronized void get(){
System.out.println("2 get enter thread name-->" + Thread.currentThread().getName());
//reentrantLock.lock();
System.out.println("3 get thread name-->" + Thread.currentThread().getName());
set();
//reentrantLock.unlock();
System.out.println("5 get leave run thread name-->" + Thread.currentThread().getName());
}
public synchronized void set(){
//reentrantLock.lock();
System.out.println("4 set thread name-->" + Thread.currentThread().getName());
//reentrantLock.unlock();
}
@Override
public void run() {
System.out.println("1 run thread name-->" + Thread.currentThread().getName());
get();
}
public static void main(String[] args){
Test test = new Test();
for(int i = 0; i < 10; i++){
//自定义线程名式启动
new Thread(test, "thread-" + i).start();
}
}
}
结果:get()方法中顺利进入了set()方法,说明synchronized的确是可重入锁。分析打印信息,进入run方法的顺序是线程1、线程2、线程0、线程4、线程3,说明sychronized的确是非公平锁。而且,每个 2、3、4、5的执行都是同一个线程完成的,说明了synchronized的确是独占锁。
2. 验证ReentrantLock可重入独占锁
ReentrantLock既可以构造公平锁又可以构造非公平锁,默认为非公平锁,将上面的代码改为用ReentrantLock实现,再次运行。
public class Test implements Runnable{
private ReentrantLock reentrantLock = new ReentrantLock();
public void get() {
System.out.println("2 get enter thread name-->" + Thread.currentThread().getName());
reentrantLock.lock();
try{
System.out.println("3 get thread name-->" + Thread.currentThread().getName());
set();
}catch (Exception e){
}finally {
reentrantLock.unlock();
System.out.println("5 get leave run thread name-->" + Thread.currentThread().getName());
}
}
public void set() {
reentrantLock.lock();
try{
System.out.println("4 set thread name-->" + Thread.currentThread().getName());
}catch (Exception e){
}finally {
reentrantLock.unlock();
}
}
@Override
public void run() {
System.out.println("1 run thread name-->" + Thread.currentThread().getName());
get();
}
public static void main(String[] args){
Test test = new Test();
for(int i = 0; i < 10; i++){
//自定义线程名式启动
new Thread(test, "thread-" + i).start();
}
}
}
结果:截图附近线程9获取锁后进行释放,是线程5还是线程4获取了锁?线程4,因为可以看到最近一次锁释放是线程4进行的。这个过程,也说明了锁是可重入的。而且,先执行get方法代码片段的线程未必先获取到锁,说明默认的确是非公平锁。
将其构造为公平锁,看看运行结果是否符合预期。查看源码构造公平锁很简单,只要在构造器传入boolean值true即可。
修改上面示例代码中的构造方法:
ReentrantLock reentrantLock = new ReentrantLock(true);
如果使用了IntelliJ IDEA IDE,可以看到在true前面有个公平fair的提示。
public class Test implements Runnable{
// private ReentrantLock reentrantLock = new ReentrantLock();
private ReentrantLock reentrantLock = new ReentrantLock(true);
public void get() {
try{
System.out.println("2 get enter thread name-->" + Thread.currentThread().getName());
reentrantLock.lock();//获取锁(加锁)
System.out.println("3 get thread name-->" + Thread.currentThread().getName());
set();
}catch (Exception e){
}finally {
reentrantLock.unlock();
System.out.println("5 get leave run thread name-->" + Thread.currentThread().getName());
}
}
public void set() {
try{
reentrantLock.lock();
System.out.println("4 set thread name-->" + Thread.currentThread().getName());
}catch (Exception e){
}finally {
reentrantLock.unlock();
}
}
@Override
public void run() {
System.out.println("1 run thread name-->" + Thread.currentThread().getName());
get();
}
public static void main(String[] args){
Test test = new Test();
for(int i = 0; i < 10; i++){
//自定义线程名式启动
new Thread(test, "thread-" + i).start();
}
}
}
结果:
1 run thread name-->thread-0
1 run thread name-->thread-1
1 run thread name-->thread-2
2 get enter thread name-->thread-1
3 get thread name-->thread-1
4 set thread name-->thread-1
5 get leave run thread name-->thread-1
1 run thread name-->thread-4
2 get enter thread name-->thread-0
1 run thread name-->thread-3
2 get enter thread name-->thread-4
2 get enter thread name-->thread-2
1 run thread name-->thread-5
2 get enter thread name-->thread-3
3 get thread name-->thread-0
4 set thread name-->thread-0
1 run thread name-->thread-6
2 get enter thread name-->thread-6
2 get enter thread name-->thread-5
1 run thread name-->thread-9
2 get enter thread name-->thread-9
1 run thread name-->thread-8
2 get enter thread name-->thread-8
3 get thread name-->thread-4
5 get leave run thread name-->thread-0
1 run thread name-->thread-7
4 set thread name-->thread-4
2 get enter thread name-->thread-7
3 get thread name-->thread-2
5 get leave run thread name-->thread-4
4 set thread name-->thread-2
5 get leave run thread name-->thread-2
3 get thread name-->thread-3
4 set thread name-->thread-3
5 get leave run thread name-->thread-3
3 get thread name-->thread-6
4 set thread name-->thread-6
5 get leave run thread name-->thread-6
3 get thread name-->thread-5
4 set thread name-->thread-5
5 get leave run thread name-->thread-5
3 get thread name-->thread-9
4 set thread name-->thread-9
5 get leave run thread name-->thread-9
3 get thread name-->thread-8
4 set thread name-->thread-8
5 get leave run thread name-->thread-8
3 get thread name-->thread-7
4 set thread name-->thread-7
5 get leave run thread name-->thread-7
分析:可以把2语句看做申请锁的顺序,分别是线程1、线程0、线程4、线程2、线程3、线程6、线程5、线程9… 可以把3语句看做得到锁的顺序,分别是线程1、线程0、线程4、线程2、线程3、线程6、线程5、线程9… 可以看到,先申请锁的先获取锁,说明的确实现了公平锁。
3.验证ReentrantReadWriteLock可重入读写锁
ReentrantReadWriteLock有两种锁策略,公平方式和非公平方式。其读锁和写锁都支持线程重进入。
公平、非公平
指线程在获取锁时的机会是否公平。我们知道AQS中有一个FIFO的线程排队队列,那么如果所有线程想要获取锁时都来排队,大家先来后到井然有序,这就是公平;而如果每个线程都不守秩序,选择插队,而且还插队成功了,那这就是不公平。
但是为什么需要不公平呢?
因为效率。
有两个因素会制约公平机制下的效率:
-
上下文切换带来的消耗
-
依赖同步队列造成的消耗
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock,它提供的特性如下所示。
公平性选择:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
重进入:该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁(同一线程)。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁。
锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁(同一线程)。
代码实现
缓存类
public class Cache {
static Map<String, Object> map = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
/**
* 获取用于读取操作的读锁
*/
static Lock r = rwl.readLock();
/**
* 获取用于写入操作的写锁
*/
static Lock w = rwl.writeLock();
/**
* 【读】获取一个key对应的value
*/
public static final Object get(String key) {
//加读锁
r.lock();
try {
System.out.println("get 加完读锁" + Thread.currentThread().getName());
return map.get(key);
} finally {
//实际unlock()要放在行首,我之所以这么放仅为了演示效果,如果该语句放在最后会误以为线程加锁后其他线程却可进入的错觉,因为可能unlock()执行完了,但是该语句并未执行。
System.out.println("get 放读锁" + Thread.currentThread().getName());
//释放读锁
r.unlock();
}
}
/**
* 【写】设置或更新key对应的value,并返回旧的value(首次为null)
*/
public static final Object put(String key, Object value) {
//加写锁
w.lock();
try {
System.out.println("put 加完写锁" + Thread.currentThread().getName());
return map.put(key, value);
} finally {
//实际unlock()要放在行首,我之所以这么放仅为了演示效果,如果该语句放在最后会误以为线程加锁后其他线程却可进入的错觉,因为可能unlock()执行完了,但是该语句并未执行。
System.out.println("put 放写锁" + Thread.currentThread().getName());
//释放写锁
w.unlock();
}
}
/**
* 【写】清空map所有的内容
*/
public static final void clear() {
//加写锁
w.lock();
try {
System.out.println("clear 加完写锁" + Thread.currentThread().getName());
map.clear();
} finally {
//实际unlock()要放在行首,我之所以这么放仅为了演示效果,如果该语句放在最后会误以为线程加锁后其他线程却可进入的错觉,因为可能unlock()执行完了,但是该语句并未执行。
System.out.println("clear 放写锁" + Thread.currentThread().getName());
//释放写锁
w.unlock();
}
}
}
测试类
public class Test {
public static void main(String[] args){
for(int i = 0; i < 10; i++){
/**
* 自定义线程名式启动
* 【写】put
*/
new Thread(new Runnable() {
@Override
public void run() {
Cache.put("key", new String(Thread.currentThread().getName() + " dg"));
}
}, "threadPUT-"+ i).start();
/**
* 【读】get
*/
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Cache.get("key"));
}
}, "threadGET-"+ i).start();
/**
* 【写】clear
*/
new Thread(new Runnable() {
@Override
public void run() {
Cache.clear();
}
}, "threadCLEAR-"+ i).start();
}
}
}
执行结果:
put 加完写锁threadPUT-0
put 放写锁threadPUT-0
clear 加完写锁threadCLEAR-5
clear 放写锁threadCLEAR-5
put 加完写锁threadPUT-6
put 放写锁threadPUT-6
put 加完写锁threadPUT-4
put 放写锁threadPUT-4
clear 加完写锁threadCLEAR-7
clear 放写锁threadCLEAR-7
put 加完写锁threadPUT-8
put 放写锁threadPUT-8
get 加完读锁threadGET-0
get 放读锁threadGET-0
threadPUT-8 dg
put 加完写锁threadPUT-5
put 放写锁threadPUT-5
clear 加完写锁threadCLEAR-0
clear 放写锁threadCLEAR-0
put 加完写锁threadPUT-1
put 放写锁threadPUT-1
put 加完写锁threadPUT-3
put 放写锁threadPUT-3
clear 加完写锁threadCLEAR-4
clear 放写锁threadCLEAR-4
get 加完读锁threadGET-1
get 放读锁threadGET-1
null
put 加完写锁threadPUT-2
put 放写锁threadPUT-2
clear 加完写锁threadCLEAR-1
clear 放写锁threadCLEAR-1
get 加完读锁threadGET-3
get 放读锁threadGET-3
null
get 加完读锁threadGET-2
get 放读锁threadGET-2
null
clear 加完写锁threadCLEAR-2
clear 放写锁threadCLEAR-2
get 加完读锁threadGET-4
get 放读锁threadGET-4
null
clear 加完写锁threadCLEAR-3
clear 放写锁threadCLEAR-3
get 加完读锁threadGET-5
get 放读锁threadGET-5
null
get 加完读锁threadGET-6
get 放读锁threadGET-6
null
clear 加完写锁threadCLEAR-6
clear 放写锁threadCLEAR-6
get 加完读锁threadGET-7
get 放读锁threadGET-7
null
put 加完写锁threadPUT-7
put 放写锁threadPUT-7
get 加完读锁threadGET-8
get 放读锁threadGET-8
threadPUT-7 dg
clear 加完写锁threadCLEAR-8
clear 放写锁threadCLEAR-8
put 加完写锁threadPUT-9
put 放写锁threadPUT-9
get 加完读锁threadGET-9
get 放读锁threadGET-9
threadPUT-9 dg
clear 加完写锁threadCLEAR-9
clear 放写锁threadCLEAR-9
分析可知:
-
可以看到,某个线程加锁后,其它线程处于等待阻塞,只有当该线程放锁后,其它线程才能加锁。
-
关于外层读值的输出(threadPUT-8 dg、null这些),这些值都是最近一次put的那个线程操作的,比如threadPUT-8 dg是线程8放入的值,null都是因为最近一次put的那个线程操作之后另一个线程进行了清除操作,所以取到的值为null(具体看下图)。
3.可以看到,某个线程加完读锁后还没放锁,其它线程可以继续加读锁(见下图)。
Ps:编码规范要求unlock()要放在行首,我之所以这么放仅为了演示效果,如果该语句放在最后会误以为线程加锁后其他线程却可进入的错觉,因为可能unlock()执行完了,但是该语句并未执行。
读写锁的使用场景
对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了(只能自己读和写)。
可重入的理解
一个 Lock 对象可以被同一线程加锁多次。
什么时候使用读锁、写锁?
如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁;如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。也就是说,在只读数据的时候上读锁,在只写数据的时候上写锁!
再测试一下ReentrantReadWriteLock可重入读写锁
public class ReentrantReadWriteLockRRExample {
public static void main(String[] args) {
ReentrantReadWriteLock rWlock = new ReentrantReadWriteLock();
Runnable r = () ->{
rWlock.readLock().lock();//加读锁
// rWlock.writeLock().lock();//加写锁
try {
System.out.println(Thread.currentThread().getName()+" start 当前时间戳:" + System.nanoTime());
Thread.sleep(2000);//2秒
//先打印输出的,其时间反而要晚,什么情况?因为线程执行获取时间后可能没有执行输出,此时另一个线程继续执行了。
System.out.println(Thread.currentThread().getName()+" end 当前时间戳:" + System.nanoTime());
} catch (Exception e) {
e.printStackTrace();
}finally {
rWlock.readLock().unlock();//放读锁
// rWlock.writeLock().unlock();//放写锁
}
};
Thread t1 = new Thread(r,"t1");
Thread t2 = new Thread(r,"t2");
Thread t3 = new Thread(r,"t3");
Thread t4 = new Thread(r,"t4");
Thread t5 = new Thread(r,"t5");
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
}
}
执行结果:
t1 start 当前时间戳:967753860143300
t2 start 当前时间戳:967753860247000
t5 start 当前时间戳:967753860252000
t3 start 当前时间戳:967753860257600
t4 start 当前时间戳:967753860247100
t2 end 当前时间戳:967755866153100
t3 end 当前时间戳:967755866243300
t5 end 当前时间戳:967755866154600
t1 end 当前时间戳:967755866153100
t4 end 当前时间戳:967755866151900
分析可知:
-
一个线程加读锁(未放锁),另一个线程可以继续加读锁。
-
N个读锁,多个线程之间不互斥,可以实现并发读数据(并不是真正意义上的同时,而是宏观上的同时)。
-
一个线程加写锁(未放锁),另一个线程不可以继续加写锁。
-
加写锁,多个线程之间是互斥的。某个线程执行完释放锁后,另一个线程再执行并释放锁
-
依据读写锁的特性,利用其读锁实现并发读,利用其写锁实现唯一写(一个线程独占,自己进行写操作)。保证数据的安全性、准确性。