锁
文章目录
JUC是什么? 就是java.util.concurrent的简称,concurrent是一个处理线程的工具包
图中可以看到JDK源码中平时常用的锁和线程安全的集合都在concurrent包里。
锁是什么?可以理解为存放在Java对象的对象头里的Mark Word里的一个标记性引用。
锁的是什么?保险箱锁住的是钞票,Java中的锁自然锁住的也是钞票一样珍贵的资源,即 对象 。而线程只是用🔑上锁的人,与普通锁不同的是,线程锁住的资源,在线程不想用的时候就把🔐拆开了,别的线程还是可以使用滴。
锁又是如何处理多线程的呢?根据JVM可以知道每个线程都有独立的Java虚拟机栈,每个线程在栈帧中存储着锁记录,可以简单理解为只要对象头里Mark Word的锁标志引用指向的是这个线程,就代表这个对象被此线程锁住了。JVM内存区域可以看这里
1.锁的类型
根据特性的不同,可以分为如下几种锁:
乐观锁
乐观锁会假设所有线程访问共享资源的时候不会出现冲突,从而就不会阻塞其他线程的操作。可以用**CAS(compare and swap)**来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。因此,线程就不会出现阻塞停顿的状态。
CAS
Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。是乐观锁的主要实现方式 。
CAS的操作过程
CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程
CAS的三个问题
1. ABA问题
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。另外在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
2. 自旋时间过长
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
3. 只能保证一个共享变量的原子操作
当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
悲观锁
悲观锁基本都是在显式的锁定之后再操作同步资源,即假设每一次执行临界区代码(公用资源)都会产生冲突,
自旋锁
为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程和恢复现场的开销。这就是自旋锁。实现原理同样也是CAS。
适应性自旋锁
自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。目标就是在只有一个线程执行同步代码块时能够提高性能。
轻量级锁
当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
重量级锁
若当前只有一个等待线程,则该线程通过自旋进行等待,当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。此时等待锁的线程都会进入阻塞状态。(使线程不使用自旋竞争,减少cpu消耗)
公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
非可重入锁
独享锁
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程既能读数据又能修改数据。
共享锁
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
锁状态只能升级不能降级。
偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞,避免自旋造成的CPU消耗。
《高性能MySQL》中对锁和事务的解释:
锁和事务
共享锁(读锁):
是共享的,相互不阻塞的。多个客户再同一时刻读取同一个资源,而互不干扰。
排他锁(写锁):
是排他的,一个写锁会阻塞其他的写锁和读锁。确保再给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。
表锁:
表锁是MySQL中最基本的锁策略,并且是开销最小的策略。
它会锁定整张表,一个用户在对标进行写操作(插入、删除、更新等)前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。
行级锁:
行级锁可以最大程度地支持并发处理(同时也带来了最大的锁开销)。
事务:
事务就是一组原子性的SQL查询,或者说一个独立的工作单元。如果数据库引擎能够成功地对数据库应用该组查询的全部语句,那么就执行该组查询。如果其中有任何一条语句因为崩溃或其他原因无法执行,那么所有的语句都不会执行。也就是说,事务内的语句,要么全部执行成功,要么全部执行失败。
ACID:
原子性(atomicity),一个事务必须被视为一个不可分割的最小单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。
一致性(consistency),数据库总是从一个一致性的状态转换到另外一个一致性的状态。
隔离性(isolation),通常来说,一个事务所做的修改在最终提交之前,对其他事务是不可见的。
持久性(durability),一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。
隔离级别:
READ UNCOMMITTED(未提交读),事务中的修改,即使没有提交,对其他事务也都是可见的。事务可以读取未提交的数据,这也被称为脏读。很少使用。
READ COMMITTED(提交读),大多数数据库系统的默认隔离级别(MySQL不是)。一个事务开始时,只能“看见”已经提交的事务所做的修改。换句话说,一个事务从开始直到提交之前,所做的任何修改对其他事务都是不可见的。也叫不可重复读(nonrepeatable read),因为两次执行同样的查询,可能会得到不一样的结果。
REPEATABLE READ(可重复读),(MySQL的默认事务隔离级别),解决了脏读问题。保证了在同一个事务中多次读取同样记录的结果是一致的。
SERIALIZABLE(可串行化),最高的隔离级别。它通过强制事务串行执行,避免了幻读问题(幻读,指的是当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行)。简单来说,可串行化会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。只有在非常需要确保数据一致且可以接受没有并发的情况下使用该级别。
死锁:
死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能产生死锁。多个事务同时锁定同一个资源时,也会产生死锁。
事务日志:
事务日志可以帮助提高事务的效率。使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘。
2.锁状态升级原理
在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头。Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。32为JVM Mark Word默认存储结构为:
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
如何关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态
轻量级锁
加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
各种锁的比较
3.synchronized
synchronized是Java中的关键字,最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性
synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。
synchronized可使用在代码块和方法中,根据synchronized用的位置可以有这些使用场景:
3.1 synchronized修饰方法和同步代码块的区别
synchronized修饰实例方法或静态方法都是通过标识ACC_SYNCHRONIZED实现同步。而同步代码块是是采用monitorenter、monitorexit两个指令来实现同步。
底层如何实现?
synchronized修饰方法,实现同步是隐式的。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如有,则需要先获取监视器锁,然后才开始执行方法。方法执行之后再释放监视器锁。在线程执行方法的时候,有另外线程也来请求执行该方法,会因为无法获取监视器锁而被阻断。
synchronized修饰同步代码块是是采用monitorenter、monitorexit两个指令来实现同步。在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0的时候,锁就会被释放。如果获取对象锁失败,那当前线程就要阻塞等待。直到对象锁被另外一个线程释放为止。monitorenter、monitorexit这两个字节码指令都需要一个reference类型的参数来明确要锁定和解锁的对象。例如使用synchronized (this)和synchronized (test.class)。
3.3 synchronized分别修饰在实例方法和静态方法时,多线程并发时会竞争锁
synchronized使每个线程依次排队操作共享变量,因为线程执行数据操作时使用了同步代码块,这样就能保证每个线程所获得共享变量的值都是当前最新的值,如果不使用同步的话,就可能会出现A线程累加后,而B线程做累加操作有可能是使用原来的就值,即“脏值”,导致最终的计算结果不正确。而使用Synchronized就可保证每个线程都是操作的最新值。
执行结果:表明多线程之间同步成功,每个线程读取到的count都是当前最新的数据。
如果此时将count3改为count2,多线程并发时会竞争锁,就会导致计算结果错误。
4.ReentrantLock
ReentrantLock(可重入互斥锁)。可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
ReentrantLock与Synchronized的区别:
ReentrantLock与Synchronized使用方式上的区别:
// **************************Synchronized的使用方式**************************
// 1.用于代码块--锁住当前类的实例对象
synchronized (this) {}
// 2.用于代码块--锁住类对象
synchronized (test.class) {}
// 3.用于代码块--锁住任意的实例对象
synchronized (object) {}
// 4.用于实例方法--锁住当前类的实例对象
public synchronized void test () {}
// 5.用于静态方法--锁住当前类对象
public static synchronized void test () {}
// 6.可重入
for (int i = 0; i < 100; i++) {
synchronized (this) {}
}
// **************************ReentrantLock的使用方式**************************
public void test () throw Exception {
// 1.初始化选择公平锁、非公平锁(默认非公平锁)
ReentrantLock lock = new ReentrantLock(true);
// 2.可用于代码块
lock.lock();
try {
try {
// 3.支持多种加锁方式,比较灵活; 具有可重入特性
if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
} finally {
// 4.手动释放锁
lock.unlock()
}
} finally {
lock.unlock();
}
}
根据上面对ReentrantLock的简单使用,从源码来看ReentrantLock是如何实现的:
ReentrantLock源码实现:
①、初始化一个ReentrantLock锁。即ReentrantLock lock = new ReentrantLock(true);
// ReentrantLock的两个构造函数,默认使用非公平sync对象
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
②、初始化后采用公平或非公平的方式开始加锁。即lock.lock();
// java.util.concurrent.locks.ReentrantLock#NonfairSync
// 非公平锁
static final class NonfairSync extends Sync {
...
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
...
}
// java.util.concurrent.locks.ReentrantLock#FairSync
// 公平锁
static final class FairSync extends Sync {
...
final void lock() {
acquire(1);
}
...
}
非公平锁加锁流程为:通过CAS设置同步状态变量State,也就是获取锁,如果成功则将当前线程设置为独占线程;如果获取锁失败则进入Acquire方法进行后续处理。
公平锁老老实实排队,不尝试获取锁,而是直接进入Acquire方法进行后续处理。
这里compareAndSetState(0, 1):0是期望值,1是更新值。只要期待值与当前同步状态state相等,则把state更新为update(更新值)。
state就是当前线程获得锁的状态。先看下state的说明。
private volatile int state;//state是Volatile修饰的,用于保证一定的可见性和有序性。
- State初始化的时候为0,表示没有任何线程持有锁。
- 当有线程持有该锁时,值就会在原来的基础上+1,同一个线程多次获得锁时,就会多次+1,这里就是可重入的概念。
- 解锁也是对这个字段-1,一直到0,此线程对锁释放。
所以此处的compareAndSetState(0, 1)就是为了判断当前线程state是否为0,是0则获取锁成功,并更新state为1.
而acquire是抽象类AbstractQueuedSynchronizer中的方法:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
先了解一下AbstractQueuedSynchronizer(AQS)
AQS核心思想是,如果被请求的共享资源空闲(state=0),那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体虚拟双向队列(FIFO)实现的,将暂时获取不到锁的线程加入到队列中。AQS是通过将每条请求共享资源的线程封装成一个虚拟双向队列(FIFO)中的Node节点来实现锁的分配。
再来看acquire方法,首先调用tryAcquire获取锁(这里tryAcquire虽然是AbstractQueuedSynchronizer中定义的方法,但是真正调用的是在ReentrantLock中被(非)公平同步类重写的tryAcquire方法):
/*******************公平方式获取锁************************/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 非公平nonfairTryAcquire方法中没有!hasQueuedPredecessors()这一个判断
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;
}
/*******************非公平方式获取锁************************/
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;
}
非公平同步类调用tryAcquire:如果state=0,即没有线程获取锁,则执行CAS尝试获取锁;如果state!=0,即当前线程已获取锁,则将state加1,这也是重入锁的体现。
公平同步类调用tryAcquire:与非公平不同之处在于CAS之前多了hasQueuedPredecessors()方法,表示只有队列中当前线程之前没有其他线程,且当前线程在对头,或者队列为空时才CAS尝试获取锁,而不是像非公平锁那样直接插队。
回到acquire方法,当执行tryAcquire方法失败,即当前线程获取锁失败之后便会执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法,首先是addWaiter方法,将新建一个Node节点加入到排队的队列中。
这里的 Node 指的就是AQS中实现等待队列的虚拟双向队列(FIFO)中的节点。从下面源码中可以看出,Node实际上就是存储某个线程的锁模式、获取锁的状态、前驱节点以及后继节点,以供AQS进行入队出队操作。
static final class Node {
/** 标志线程以共享/独占方式等待锁 */
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
/** 表示线程获取锁的请求已经取消 */
static final int CANCELLED = 1;
/** 表示线程已经准备好,就等资源释放了(被唤醒了) */
static final int SIGNAL = -1;
/** 表示节点在等待队列中,节点线程等待唤醒 */
static final int CONDITION = -2;
/** 当前线程处在SHARED情况下,该字段才会使用(其他操作介入,也要确保传播继续) */
static final int PROPAGATE = -3;
//当前节点在队列中的状态
volatile int waitStatus;
//前驱指针
volatile Node prev;
//后继指针
volatile Node next;
//表示处于该节点的线程
volatile Thread thread;
//指向下一个处于CONDITION状态的节点
Node nextWaiter;
/** 返回前驱节点,没有则抛出空指针异常 */
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
/** 几个Node构造函数 */
//用来初始化头节点或SHARED标志
Node() {
}
//用于addWaiter方法
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
//用于Condition
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
//队列头节点
private transient volatile Node head;
//队列尾节点
private transient volatile Node tail;
现在再来看addWaiter方法是如何把需要等待的线程加入到双端队列的。
//这里的mode是Node.EXCLUSIVE,表示独占模式
private Node addWaiter(Node mode) {
//通过当前的线程和锁模式新建一个节点。
Node node = new Node(Thread.currentThread(), mode);
//pred指向尾节点tail
Node pred = tail;
//如果尾节点不为空,则将当前节点插入到尾节点后面(设为尾节点),并返回node
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//如果Pred指针是Null(说明等待队列中没有元素),或者当前Pred指针和Tail指向的位置不同(说明已经被别的线程修改),则进入enq初始化head节点,并将存有当前线程的node节点设为尾节点。
enq(node);
return node;
}
private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 这里又判断了一下,如果经历了初始化或者并发导致尾节点不为空,则仍要把当前节点添加到队尾
// 如果还是没有节点,就把当前线程所在节点作为head结点。
if (t == null) {
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
简而言之:addWaiter方法就是把当前需要等待的线程及其信息存储在一个Node节点中并添加到虚拟双向队列(FIFO),并返回一个Node给acquireQueued。
该排队的线程已经让他排队了,那什么时候出队呢?
继续上面的代码,看acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
方法
final boolean acquireQueued(final Node node, int arg) {
// 标记是否成功拿到资源
boolean failed = true;
try {
// 标记等待过程中是否中断过
boolean interrupted = false;
// 开始自旋,要么获取锁,要么中断
for (;;) {
// 获取当前节点的前驱节点
final Node p = node.predecessor();
// 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
if (p == head && tryAcquire(arg)) {
// 获取锁成功,头指针移动到当前node
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 说明p为头节点且当前没有获取到锁(可能是非公平锁被抢占了)或者是p不为头结点,这个时候就要判断当前node是否要被阻塞(被阻塞条件:前驱节点的waitStatus为-1),防止无限循环浪费资源。
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)//未成功拿到资源,设置当前节点状态为CANCELLED
cancelAcquire(node);
}
}
// 靠前驱节点判断当前线程是否应该被阻塞,true阻塞,false不阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的节点状态
int ws = pred.waitStatus;
// 说明前驱结点处于唤醒状态,此时需要阻塞当前节点,返回true
if (ws == Node.SIGNAL)
return true;
// 通过枚举值我们知道waitStatus>0是取消状态
if (ws > 0) {
do {
// 循环向前查找取消节点,把取消节点从队列中剔除
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
//直到前面某一个节点非取消节点,将非取消节点连接当前节点
pred.next = node;
} else {//前驱节点waitStatus为0或-3。
// 设置前驱节点等待状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
//parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
语言描述acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
整个方法的逻辑就是:
将获取锁失败的线程以Node节点结构保存到AQS的虚拟双向队列中,然后开始自旋地获取锁:
第一种情况:自旋时发现前一个节点已经是head节点了,说明当前节点该出队去获取锁了,获取锁成功的话,就把当前节点设为头节点,让队列后面的节点可以判断。
第二种情况:前一个节点不是head节点,或者当前节点去获取锁时失败了,可能是被突然出现的非公平锁抢占了资源,这时为了避免无线循环就要根据前驱节点判断当前线程是否应该被阻塞了。这里又复制了一下上面提过的Node类的几个重要属性,这几个属性代表了线程的获取锁的等待状态,
/** 表示线程获取锁的请求已经取消 */
static final int CANCELLED = 1;
/** 表示线程已经准备好,就等资源释放了(被唤醒了) */
static final int SIGNAL = -1;
/** 表示节点在等待队列中,节点线程等待唤醒 */
static final int CONDITION = -2;
如果waitStatus = -1,说明前驱节点已经被唤醒,那就可以阻塞当前线程了。
如果waitStatus = 1,说明前驱节点已经不需要获取锁,直接踢出队列就好,继续循环而不需要阻塞当前线程。
如果waitStatus = 2,说明前驱节点等待唤醒,需要用CAS设置前驱节点等待状态为 -1 ,也不需要阻塞当前线程。
**【注意双向链表中,第一个节点(头节点)为虚节点,其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是在第二个节点开始的,**所以每次循环要判断前一个节点是不是head头节点,然后才能证明当前节点为首(个有数据的)节点。】
③ 加了锁又如何解锁?lock.unlock()
与加锁不同,ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,可以看下面源码:
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
// 如果当前锁没有被任何线程所持有
if (tryRelease(arg)) {
Node h = head;
// 头结点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
/*
这里的判断条件为什么是h != null && h.waitStatus != 0?
h == null Head还没初始化。初始情况下,head == null,第一个节点入队,Head会被初始化一个虚拟节点。所以说,这里如果还没来得及入队,就会出现head == null 的情况。
h != null && waitStatus == 0 表明后继节点对应的线程仍在运行中,不需要唤醒。
h != null && waitStatus < 0 表明后继节点可能被阻塞了,需要唤醒。
*/
// 判断一个锁是否没被线程所持有,true:没有线程持有当前锁。
protected final boolean tryRelease(int releases) {
// 减少可重入次数,及state - 1;
int c = getState() - releases;
// 当前线程不是持有锁的线程,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果持有线程全部释放,将当前独占锁所有线程设置为null,并更新state
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
5. volatile
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
volatile可见性实现原理
-
在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令。
-
Lock前缀的指令会引起处理器缓存写回内存;
-
一个处理器的缓存回写到内存会导致其他处理器该内存地址缓存的数据无效;
-
当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
参考: