概念
Reentrant = Re + entrant,Re是重复、又、再的意思,entrant是enter的名词或者形容词形式,翻译为进入者或者可进入的,所以Reentrant翻译为可重复进入的、可再次进入的,因此ReentrantLock翻译为重入锁或者再入锁。
可重入锁又名递归锁,指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
可重入锁,当同一个线程在外层方法获取对象锁之后,再进入该线程的内层方法会自动获取锁(前提,锁对象是同一个对象),不会因为之前已经获取过还没释放而阻塞。
重入到哪里:进入同步域(即同步代码块/方法或显式锁锁定的代码)
通俗理解
- 可重入锁就是一证通/一卡通,只需一张卡就可以通过所有相同关卡。
- 不可重入锁就是:即使每个关卡相同,你也得再拿一个一摸一样的卡来。
如果把证件/卡看作是同步锁,把关卡看作是同步域(即同步代码块/方法或显式锁锁定的代码),那么可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
在Java中,除了ReentrantLock(显式的可重入锁)以外,synchronized也是重入锁(隐式的可重入锁)。
不可重入锁别名:不可重入锁也叫自旋锁。
可重入锁的工作原理
可重入锁的工作原理很简单,就是用一个计数器来记录锁被获取的次数,获取锁一次计数器+1,释放锁一次计数器-1,当计数器为0时,表示锁可用。
ReentrantLock实现了Lock接口,Lock接口里面定义了java中锁应该实现的几个方法:
// 获取锁
void lock();
// 获取锁(可中断)
void lockInterruptibly() throws InterruptedException;
// 尝试获取锁,如果没获取到锁,就返回false
boolean tryLock();
// 尝试获取锁,如果没获取到锁,就等待一段时间,这段时间内还没获取到锁就返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 释放锁
void unlock();
// 条件锁
Condition newCondition();
总结
- 可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
- 隐式锁(即synchronized关键字使用的锁)默认是可重入锁,显式锁(即Lock)也有ReentrantLock这样的可重入锁。
- 可重入锁的工作原理很简单,就是用一个计数器来记录锁被获取的次数,获取锁一次计数器+1,释放锁一次计数器-1,当计数器为0时,表示锁可用。
- 不可重入锁也叫自旋锁。
ReenTrantLock可重入锁和synchronized的区别
可重入性:
从名字上理解,ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
锁的实现:
Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。
性能的区别:
在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。
功能区别:
便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。
锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized
ReenTrantLock独有的能力:
- ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
- ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。
- ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。
ReenTrantLock实现的原理:
在网上看到相关的源码分析,本来这块应该是本文的核心,但是感觉比较复杂就不一一详解了,简单来说,ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
什么情况下使用ReenTrantLock:
答案是,如果你需要实现ReenTrantLock的三个独有功能时。
ReentrantLock源码分析:
主要内部类:
ReentrantLock中主要定义了三个内部类:Sync、NonfairSync、FairSync。
abstract static class Sync extends AbstractQueuedSynchronizer {}static final class NonfairSync extends Sync {}static final class FairSync extends Sync {}
(1)抽象类Sync实现了AQS的部分方法;
(2)NonfairSync实现了Sync,主要用于非公平锁的获取;
(3)FairSync实现了Sync,主要用于公平锁的获取。
主要属性:
private final Sync sync;
主要属性就一个sync,它在构造方法中初始化,决定使用公平锁还是非公平锁的方式获取锁。
主要构造方法:
// 默认构造方法
public ReentrantLock() {sync = new NonfairSync();//默认创建非公平锁
}
// 自己可选择使用公平锁还是非公平锁
public ReentrantLock(boolean fair) {
//参数是true,则创建公平锁,false创建非公平锁sync = fair ? new FairSync() : new NonfairSync();//自己决定使用公平锁还是非公平锁
}
公平锁
这里我们假设ReentrantLock的实例是通过以下方式获得的:
ReentrantLock reentrantLock = new ReentrantLock(true);//公平锁
下面的是加锁的主要逻辑:
// ReentrantLock.lock()
public void lock() {// 调用的sync属性的lock()方法// 这里的sync是公平锁,所以是FairSync的实例sync.lock();
}
// ReentrantLock.FairSync.lock()
final void lock() {// 调用AQS的acquire()方法获取锁// 注意,这里传的值为1acquire(1);
}
// AbstractQueuedSynchronizer.acquire()
public final void acquire(int arg) {// 尝试获取锁// 如果失败了,就排队if (!tryAcquire(arg) &&// 注意addWaiter()这里传入的节点模式为独占模式acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();
}
// ReentrantLock.FairSync.tryAcquire()
protected final boolean tryAcquire(int acquires) {// 当前线程final Thread current = Thread.currentThread();// 查看当前状态变量的值int c = getState();// 如果状态变量的值为0,说明暂时还没有人占有锁if (c == 0) {// 如果没有其它线程在排队,那么当前线程尝试更新state的值为1// 如果成功了,则说明当前线程获取了锁if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {// 当前线程获取了锁,把自己设置到exclusiveOwnerThread变量中// exclusiveOwnerThread是AQS的父类AbstractOwnableSynchronizer中提供的变量setExclusiveOwnerThread(current);// 返回true说明成功获取了锁return true;}}// 如果当前线程本身就占有着锁,现在又尝试获取锁// 那么,直接让它获取锁并返回trueelse if (current == getExclusiveOwnerThread()) {// 状态变量state的值加1int nextc = c + acquires;// 如果溢出了,则报错if (nextc < 0)throw new Error("Maximum lock count exceeded");// 设置到state中// 这里不需要CAS更新state// 因为当前线程占有着锁,其它线程只会CAS把state从0更新成1,是不会成功的// 所以不存在竞争,自然不需要使用CAS来更新setState(nextc);// 当线程获取锁成功return true;}// 当前线程尝试获取锁失败return false;
}
// AbstractQueuedSynchronizer.addWaiter()
// 调用这个方法,说明上面尝试获取锁失败了
private Node addWaiter(Node mode) {// 新建一个节点Node node = new Node(Thread.currentThread(), mode);// 这里先尝试把新节点加到尾节点后面// 如果成功了就返回新节点// 如果没成功再调用enq()方法不断尝试Node pred = tail;// 如果尾节点不为空if (pred != null) {// 设置新节点的前置节点为现在的尾节点node.prev = pred;// CAS更新尾节点为新节点if (compareAndSetTail(pred, node)) {// 如果成功了,把旧尾节点的下一个节点指向新节点pred.next = node;// 并返回新节点return node;}}// 如果上面尝试入队新节点没成功,调用enq()处理enq(node);return node;
}
// AbstractQueuedSynchronizer.enq()
private Node enq(final Node node) {// 自旋,不断尝试for (;;) {Node t = tail;// 如果尾节点为空,说明还未初始化if (t == null) { // Must initialize// 初始化头节点和尾节点if (compareAndSetHead(new Node()))tail = head;} else {// 如果尾节点不为空// 设置新节点的前一个节点为现在的尾节点node.prev = t;// CAS更新尾节点为新节点if (compareAndSetTail(t, node)) {// 成功了,则设置旧尾节点的下一个节点为新节点t.next = node;// 并返回旧尾节点return t;}}}
}
// AbstractQueuedSynchronizer.acquireQueued()
// 调用上面的addWaiter()方法使得新节点已经成功入队了
// 这个方法是尝试让当前节点来获取锁的
final boolean acquireQueued(final Node node, int arg) {// 失败标记boolean failed = true;try {// 中断标记boolean interrupted = false;// 自旋for (;;) {// 当前节点的前一个节点final Node p = node.predecessor();// 如果当前节点的前一个节点为head节点,则说明轮到自己获取锁了// 调用ReentrantLock.FairSync.tryAcquire()方法再次尝试获取锁if (p == head && tryAcquire(arg)) {// 尝试获取锁成功// 这里同时只会有一个线程在执行,所以不需要用CAS更新// 把当前节点设置为新的头节点setHead(node);// 并把上一个节点从链表中删除p.next = null; // help GC// 未失败failed = false;return interrupted;}// 是否需要阻塞if (shouldParkAfterFailedAcquire(p, node) &&// 真正阻塞的方法parkAndCheckInterrupt())// 如果中断了interrupted = true;}} finally {// 如果失败了if (failed)// 取消获取锁cancelAcquire(node);}
}
// AbstractQueuedSynchronizer.shouldParkAfterFailedAcquire()
// 这个方法是在上面的for()循环里面调用的
// 第一次调用会把前一个节点的等待状态设置为SIGNAL,并返回false
// 第二次调用才会返回true
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {// 上一个节点的等待状态// 注意Node的waitStatus字段我们在上面创建Node的时候并没有指定// 也就是说使用的是默认值0// 这里把各种等待状态再贴出来//static final int CANCELLED = 1;//static final int SIGNAL = -1;//static final int CONDITION = -2;//static final int PROPAGATE = -3;int ws = pred.waitStatus;// 如果等待状态为SIGNAL(等待唤醒),直接返回trueif (ws == Node.SIGNAL)return true;// 如果前一个节点的状态大于0,也就是已取消状态if (ws > 0) {// 把前面所有取消状态的节点都从链表中删除do {node.prev = pred = pred.prev;} while (pred.waitStatus > 0);pred.next = node;} else {// 如果前一个节点的状态小于等于0,则把其状态设置为等待唤醒// 这里可以简单地理解为把初始状态0设置为SIGNAL// CONDITION是条件锁的时候使用的// PROPAGATE是共享锁使用的compareAndSetWaitStatus(pred, ws, Node.SIGNAL);}return false;
}
// AbstractQueuedSynchronizer.parkAndCheckInterrupt()
private final boolean parkAndCheckInterrupt() {// 阻塞当前线程// 底层调用的是Unsafe的park()方法LockSupport.park(this);// 返回是否已中断return Thread.interrupted();
}
下面我们看一下主要方法的调用关系:
ReentrantLock#lock()
->ReentrantLock.FairSync#lock() // 公平模式获取锁->AbstractQueuedSynchronizer#acquire() // AQS的获取锁方法->ReentrantLock.FairSync#tryAcquire() // 尝试获取锁->AbstractQueuedSynchronizer#addWaiter() // 添加到队列->AbstractQueuedSynchronizer#enq() // 入队->AbstractQueuedSynchronizer#acquireQueued() // 里面有个for()循环,唤醒后再次尝试获取锁->AbstractQueuedSynchronizer#shouldParkAfterFailedAcquire() // 检查是否要阻塞->AbstractQueuedSynchronizer#parkAndCheckInterrupt() // 真正阻塞的地方
获取锁的主要过程大致如下:
(1)尝试获取锁,如果获取到了就直接返回了;
(2)尝试获取锁失败,再调用addWaiter()构建新节点并把新节点入队;
(3)然后调用acquireQueued()再次尝试获取锁,如果成功了,直接返回;
(4)如果再次失败,再调用shouldParkAfterFailedAcquire()将节点的等待状态置为等待唤醒(SIGNAL);
(5)调用parkAndCheckInterrupt()阻塞当前线程;
(6)如果被唤醒了,会继续在acquireQueued()的for()循环再次尝试获取锁,如果成功了就返回;
(7)如果不成功,再次阻塞,重复(3)(4)(5)直到成功获取到锁。
以上就是整个公平锁获取锁的过程,下面我们看看非公平锁是怎么获取锁的。
相对于公平锁,非公平锁加锁的过程主要有两点不同:
(1)一开始就尝试CAS更新状态变量state的值,如果成功了就获取到锁了;
(2)在tryAcquire()的时候没有检查是否前面有排队的线程,直接上去获取锁才不管别人有没有排队;
总的来说,相对于公平锁,非公平锁在一开始就多了两次直接尝试获取锁的过程。
可重入锁代码演示:
代码演示synchronized可重入锁:
package com.fan.sync;
public class SyncLockDemo {public static void main(String[] args) {//synchronizedObject o = new Object();new Thread(()->{//要同步的任务,synchronized是隐式可重入锁synchronized (o){//第一层关卡,用的是同一把锁System.out.println(Thread.currentThread().getName()+"外层");synchronized (o){//第二层关卡,用的是同一把锁System.out.println(Thread.currentThread().getName()+"中层");synchronized (o){//第三层关卡,用的是同一把锁System.out.println(Thread.currentThread().getName()+"内层");}}}},"t1").start();}
}
输出:
t1外层
t1中层
t1内层
演示synchronized 可以递归调用,即可重入锁的案例:
package com.fan.lock;public class SyncLockDemo2 {public synchronized void add(){add();//递归调用add()}public static void main(String[] args) {new SyncLockDemo2().add();//对象调用递归方法}
}
可以进行循环递归调用,因为可以重新进去调用。
ReentrantLock演示可重入锁代码:
package com.fan.lock;import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class ReenterLockDemo {public static void main(String[] args) {//Lock演示可重入锁Lock lock = new ReentrantLock();//创建线程new Thread(()->{//要子线程单独执行的任务,此任务要上锁/加锁//加锁/上锁lock.lock();try {System.out.println(Thread.currentThread().getName()+"外层");//内层try {lock.lock();//内层上锁System.out.println(Thread.currentThread().getName()+"内层");}finally {lock.unlock();//内层释放锁}}finally {lock.unlock();//解锁/释放锁}},"t1").start();}
}
输出:
t1外层
t1内层
证明可以重入进入。
不释放内层锁的代码:
造成其他线程等待,程序不能结束,不能输出sss字符串:
package com.fan.lock;import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class ReenterLockDemo {public static void main(String[] args) {//Lock演示可重入锁Lock lock = new ReentrantLock();//创建线程new Thread(()->{//要子线程单独执行的任务,此任务要上锁/加锁//加锁/上锁lock.lock();try {System.out.println(Thread.currentThread().getName()+"外层");//内层try {lock.lock();//内层上锁System.out.println(Thread.currentThread().getName()+"内层");}finally {}}finally {lock.unlock();//解锁/释放锁}},"t1").start();//另外创建新线程new Thread(()->{lock.lock();System.out.println("sss");lock.unlock();},"t2").start();}
}
输出结果如下:
由于内层锁未被释放,不能输出“sss”。
可重入锁(ReentrantLock):
- 可重入锁是一种支持重进入的锁机制。重进入是指一个线程在持有锁的情况下,可以再次获取相同的锁而不会被阻塞。
- 可重入锁实现了Lock接口,提供了比内置锁(synchronized关键字)更多的灵活性和功能。
- 可重入锁允许一个线程反复获得该锁,避免了死锁的发生,同时也提高了代码的简洁性和可读性。
- 可重入锁支持公平性设置,使得等待时间最长的线程优先获取锁。
不可重入锁(NonReentrantLock):
- 不可重入锁是一种不支持重进入的锁机制。也就是说,当一个线程获得了不可重入锁之后,如果再次尝试获取锁,就会被阻塞,直到当前持有锁的线程释放锁。
- 不可重入锁在Java中没有内置的实现,需要通过自定义实现或基于AQS(AbstractQueuedSynchronizer)等基础类来构建。
- 不可重入锁可能会导致死锁问题,因为如果一个线程在持有锁的情况下又尝试获取同一个锁,就会导致自己无限等待。
总结
可重入锁允许同一线程多次获得锁,而不可重入锁则不支持同一个线程多次获得锁。在大多数情况下,可重入锁是更常用和推荐的选择,因为它提供了更多的功能、灵活性和安全性,同时避免了死锁问题。但在某些特殊情况下,不可重入锁也可能有其应用场景,例如需要强制确保某段代码只能被一个线程执行。
synchronized具体采用了哪些锁策略呢?
1.既是悲观锁,又是乐观锁
2.既是重量级锁,又是轻量级锁
3.重量级锁部分是基于多系统互斥锁实现的,轻量级锁部分是基于自旋锁实现的
4.synchronized是非公平锁(不会遵守先来后到,锁释放之后,哪个线程拿到锁个凭本事
5.synchronized是可重入锁(内部会记录哪个线程拿到了锁,记录引用计数)
6.synchronized不是读写锁
synchronized-内部实现策略(自适应)
讲解一下自适应:代码中写了一个synchhronized之后,可能产生一系列自适应的过程,锁升级(锁膨胀)
无锁->偏向锁->轻量级锁->重量级锁
偏向锁,不是真的加锁,而只是做了一个标记,如果有别的线程来竞争锁,才会真的加锁,如果没有别的线程竞争,就自始至终都不加锁了(渣女心态,没人来追你,我就钓鱼,你要是被追了,我先给你个身份,让别人别靠近你。)——当然加锁本身也有一定消耗
偏向锁在没人竞争的时候就是一个简单的(轻量的)标记,如果有别的线程来尝试加锁,就立即把偏向锁升级成真正加锁,让别人阻塞等待(能不加锁就不加锁)
轻量级锁-synchronized通过自旋锁的方式实现轻量级锁——这边把锁占据了,另一个线程按照自旋的方式(这个锁操作比较耗cpu,如果能够快速拿到锁,多耗点也不亏),来反复查询当前的锁状态是不是被释放,但是后续,如果竞争这把锁的线程越来越多了(锁冲突更加激烈了),从轻量锁,升级到重量级锁~随着竞争激烈,即使前一个线程释放锁,也不一定能够拿到锁,何时能拿到,时间可能比较久了会
💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖
锁清除:编译器,会智能的判断,当前这个代码,是否有必要加锁,如果你写了加锁,但实际没必要加锁,就会自动清除锁
如:单个线程使用StringBuffer编译器进行优化,是保证优化之后的逻辑和之前的逻辑是一致的,这样就会让代码优化变的保守起来~~咱们猿们也不能指望编译器优化,来提升代码效率,自己也要有作用,判断何时加锁,也是咱们非常重要的工作。
锁粗化:
关于锁的粒度,锁中操作包含代码多:锁粒就大
//1号 全写的是伪代码
和2号比较明显是2号的粒度更大
for(
synchronized(this){count++}
}//2号
synchronized(this){
for{
count++}
}
锁粒大,锁粒小各有好处:
锁粒小,并发程度会更高,效率也会更快
锁粒大,是因为加锁本身就有开销。(如同打电话,打一次就行,老打电话也不好)
上述的都是基本面试题
CAS全称(Compare and swap)
字面意思:比较并且交换
能够比较和交换,某个寄存器中的值和内存中的值,看是否相等,如果相等就把另一个寄存器中的值和内存进行交换
boolean CAS(address,expectValue,swapValue){if(&address==expectValue){ //这个&相当于C语言中的*,看他两个是否相等&address=swapValue; //相等就换值return true;
}return false;
此处严格的说是,adress内存的值和swapValue寄存器里的值,进行交换,但是一般我们重点关注的是内存中的值,寄存器往往作为保存临时数据的方式,这里的值是啥,很多时候我们选择是忽略的。
这一段逻辑是通过一条cpu指令完成的(原子的,或者说确保原子性)给我们编写线程安全代码,打开了新的世界。
CAS的使用
1.实现原子类:多线程针对一个count++,在java库中,已经提供了一组原子类
java.util.concurrent(并发的意思).atomic
AtomicInteger,AtomicLong,提供了自增/自减/自增任意值,自减任意值··,这些操作可以基于CAS按照无锁编程的方式来实现。
如:
for(int i=0;i<5000;i++){count.getAndIncrement(); //count++count.incrementAndGet(); //++countcount.getAndDecrement(); //count--count.decrementAndGet() //--count}
import java.util.concurrent.atomic.AtomicInteger;public class Demo6 {public static AtomicInteger count=new AtomicInteger(0); //这个类的初值呗public static void main(String[] args) throws InterruptedException {Thread t1=new Thread(()->{for (int i=0;i<500;i++){count.getAndIncrement();}});Thread t2=new Thread(()->{for (int i=0;i<500;i++){count.getAndIncrement();}});t1.start();t2.start();t1.join(); //注意要等待两个线程都结束再开始调用t2.join();System.out.println(count);}
}
上述原子类就是基于CAS完成的
当两个线程并发的线程执行++的时候,如果加限制,意味着这两个++是串行的,能计算正确的,有时候者两个++操作是穿插的,这个时候是会出现问题的
加锁保证线程安全:通过锁,强制避免出现穿插~~
原子类/CAS保证线程安全,借助CAS来识别当前是否出现穿插的情况,如果没有穿插,此时直接修改就是安全的,如果出现了穿插,就会重新读取内存中最新的值,再次尝试修改。
部分源码合起来的意思就是
public int getAndIncrement(){int oldValue=value; //先储存值,防止别的线程偷摸修改之后,无法恢复到之前的值while(CAS(value,oldValue,OldValue+1)!=true){ //检查是否线程被别的偷摸修改了//上面的代码是Value是否等于oldValue,假如等于就把Value赋值OldValue+1oldValue=value; //假如修改了就恢复了原来的样子}return oldValue;}
假如这种情况,刚开始设置value=0,
CAS是一个指令,这个指令本身是不能够拆分的。
是否可能会出现,两个线程,同时在两个cpu上?微观上并行的方式来执行,CAS本身是一个单个的指令,这里其实包含了访问操作,当多个cpu尝试访问内存的时候,本质也是会存在先后顺序的。
就算同时执行到CAS指令,也一定有一个线程的CAS先访问到内存,另一个后访问到内存
为啥CAS访问内存会有先后呢?
多个CPU在操作同一个资源,也会涉及到锁竞争(指令级别的锁),是比我们平时说的synchronized代码级别的锁要轻量很多(cpu内部实现的机制)