前言
锁相关概念、Synchronized
的一些优化、CAS
实现(AtomicInteger
为例)、ReentrantLock
实现原理、AQS
目录
一、锁相关概念
1、AQS(AbstractQueuedSynchronizer)
java.util.concurrent
类的许多阻塞类,例如ReentrantLock
、Semaphore
、ReentrantRead-WriteLock
、CountDownLatch
、SynchronousQueue
和FultureTask
等都是基于AQS
构建的。AQS
内部维护了一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问(condition)。
2、独占锁
独占锁是一种悲观技术,比较并交换(CAS
)是乐观技术,大多数处理器架构使用的是CAS
。CAS
包括3个操作数----需要读写的内存位置V
、进行比较的值A
和拟写入的新值B
,当且仅当V
的值等于A
时,CAS``才会通过原子方式用新值``B
来更新V
的值,否则不执行任何操作,无论位置V
的值是否等于A
,都将返回V
的值。
3、乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发的可能性低。Java中的乐观锁基本是通过CAS
操作实现的。AQS
框架下的锁则是先尝试CAS
乐观锁去获取锁,获取不到才会转为悲观锁,如ReentrantLock
。
4、自旋锁
自旋锁原理很简单,如果持有锁的线程能在很短时间内释放资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程和内核线程的切换消耗。
注意:线程自旋是需要消耗CPU
的,如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。适用于锁的竞争不激烈场景,减少线程阻塞。
5、偏向锁
偏向锁,顾名思义,它会偏向第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况下,就会给线程加一个偏向锁。适用场景为只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行,也就是在锁无竞争的情况下使用。一旦有了竞争就会升级为轻量级锁,升级为轻量级锁的时候就需要撤销偏向锁,撤销偏向锁的时候就会导致stop the world
操作。
6、轻量级锁
轻量级锁,由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用时,就会升级为轻量锁。
7、Synchronized
Synchronized
或导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级锁,为了缓解性能问题,JVM
从1.5
开始,引入了轻量锁与偏向锁,默认启用了自旋锁,这些都属于乐观锁。
8、对象头
对象内存结构,由三部分构成,分别是对象头、对象实例、对齐填充。
对象头包括两部分,第一部分是markword,用于存储对象自身运行时数据,如哈希码(HashCode
)、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程ID
、偏向时间戳等。这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。第二部分是类类型指针,即对象指向它的元数据指针,虚拟机通过这个指针来确定这个对象是哪个实例的。**如果是数组对象的话,那么对象头中还必须有一块数据记录数组长度。**对象实例这部分则是对象真正存储的有效信息,页时程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的还是在子类中定义的,都需要记录下来。对齐填充不是必须的,仅仅起着占位符的作用,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
markword
9、锁的分类
二、CAS原理
1、原理
本质是利用了处理器支持的CAS
指令,循环指令,直到成功。
我们看一下 Java JDK
中相关原子操作类
基本类型:AtomicBoolean
,AtomicInteger
,AtomicLong
数组类型:AtomicIntegerArray
,AtomicLongArray
,AtomicReferenceArray
引用类型:AtomicReference
,AtomicMarkableReference
,AtomicStampedReference
我平时用到得大范围内只有基本类型,这里通过AtomicInteger
举例,主要是其 getAndAdd
、getAndSet
方法
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(1);
new Thread(new Runnable() {
@Override
public void run() {
atomicInteger.getAndSet(4);
System.out.println(atomicInteger.get());
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
atomicInteger.getAndAdd(1);
System.out.println(atomicInteger.get());
}
}).start();
}
输出 4、5
getAndAdd
方法
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
//# 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;
}
public native int getIntVolatile(Object var1, long var2);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
最后都是通过调用native
方法实现的
在 getAndAddInt
中,通过 getIntVolatile
方法 拿到当前 AtomicInteger
在内存中的 value
值,然后通过调用 UnSafe
类的compareAndSwapInt
尝试将 AtomicInteger
的值修改为内存中的值 +
新增加的值,var4
就是通过getAndAdd
的参数值,var2
是valueOffset
,valueOffset
是如何获取的?
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
valueOffst
代表着 value
变量在内存中的偏移地址,每次修改只能对内存中同一个地址进行修改,保证一个变量的原子操作。
所以CAS
操作可以理解为如下:
多线程并发情况下,线程1、线程2、线程3同时执行 count ++ 操作,
执行步骤(对应于getAndAddInt
方法的do-while
语句逻辑):
从内存中取出count
的值,为0
,
然后都执行count ++
,count
在每个线程内值都为1
然后线程1会 判断当前count
值是否是0
,如果是0
,则count
值由0
变为1
(假设这里线程1执行成功)
然后线程2会判断当前count
值是否为0
,不是,则从内存中继续重新取出count
值,此时为1
,再执行count++
,然后再会比较内存中count
值是否为1
,为1
,则count
值由1
变成2
线程3也是如此,从内存中取得count
值不是0
,则重新取,此时内存中count
值为2
,再执行count++
操作,再看内存中的count
值是否为2
,是则count
值由2
变成3
虽然说通过加锁也能实现原子操作,但是加锁的方式涉及到线程切换上下文,而CAS
不存在上下文切换,这一点比加锁好。(CPU
执行一条指令时间大概0.6
纳秒,上下文切换一次,在5000
到20000
个时间周期内,大约为3
到5
毫秒,也就是CAS
的0.6
纳秒与加锁耗费的3
到5
毫秒对比)
2、问题
那么这种CAS
操作会不会有什么问题呢
假设线程1需要将值从A
修改为B
,它会判断变量值是否为A
,是就设置为B
。
线程2此时的操作为,将值从A
修改为C
然后快速修改为A
,假如线程2先执行compareAndSet
,线程1中将会认为变量值没有被修改过,还是A
,然后直接修改为B
。
上面这种问题,是CAS
中典型的ABA
问题。解决思路就是加一个版本号,每次变量变化就新加一个版本号,如果版本号不一致,就说明修改过。
还有一个问题就是开销问题,就是在CAS
操作中,线程不会休眠,没有修改值成功的线程,将会一直执行do-while
循环。
最后一个问题是CAS
只能保证一个共享变量的原子操作,多个共享变量操作时,循环CAS
无法保证原子性了,除非把国歌共享变量合成一个,例如AtomicReference
(可以原子读写的对象引用变量),或者利用锁。
三、Synchronized
Synchronized
是通过 monitorenter
和monitorexit
指令来实现的,monitorenter
在编译后插入到同步代码块开始位置,monitorexit
插入到方法结束处和异常处。上面截图方法b
字节码没有对应的monitorenter
和monitorexit
指令,但在实际执行过程中,原理一样,只是这两个指令加在了我们看不到的地方。
还记得之前提到过对象头么,对象头持有ObjectMonitor
,即任何一个对象都有对应的monitor与这两个指令关联。获取锁和释放锁其实都是对这个monitor
的持有权获取和释放的一个过程。
如果是重量级锁,没有获取到锁的线程,将会入队,加入到ObjectMonitor
的EnterList
队列中,调用PostEvent.park
方法阻塞,内部对应于C++
的 muter_lock
。区别于 C++
的spin_lock
,这个是一个自旋锁,拿不到锁会一直去获取。
Synchronized
对某个方法加锁,那么任一时刻只能有一个线程能访问这个方法,其它线程就会被操作系统挂起,发生两次上下文切换(挂起时、重新争夺锁),锁对象都是同一个ObjectMonitor
。
JDK1.5
后对此做了一些优化
-
结合统计,一个锁大概率总是由同一个线程获得。在线程A访问该方法时,虚拟机会直接测试一下,看下是否是当前线程获得锁,具体做法就是通过对象头的
markworld
中存储的线程ID
来判断,是的话直接执行方法内代码,这时锁状态称为偏向锁,偏向当前线程。不是的话,通过CAS
替换Markword
信息,将其中存储的线程ID
指向自己。 -
此时其它线程也来访问同一个方法,早期做法是其它线程直接挂起,现在优化成了,为了避免上下文切换,其它挂起的线程不挂起,新来访问的线程B也会通过线程
ID
来判断是否是当前线程持有该锁,不是,就会撤销偏向锁,通过CAS
自旋来尝试获取锁和解锁(CAS
替换markword
),此时锁的状态就变成了自旋锁
,为了防止过度消耗CPU
,大概自旋10
次左右,这个值由JVM
动态计算,自旋时间约等于一个上下文切换时间,此时锁的状态变成了 适应性自旋锁。这些都属于轻量级锁。因为线程A持有偏向锁时,线程B将锁改成了轻量级锁,即修改了对象头的信息,需要通知线程A同步修改线程上对象头的堆栈内容,此时线程A会暂停线程,会出现类似GC
过程中的Stop the world
现象,修改完后恢复线程。 -
线程B自旋次数超过一定次数后,轻量级锁就会膨胀为重量级锁,释放
CPU
,阻塞线程。追求吞吐量一般会用重量级锁。
四、ReentrantLock
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();
构造函数
public ReentrantLock() {
sync = new NonfairSync();
}
//fair true公平锁还是非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
之前一篇文章介绍过,公平锁可以理解为排队拿锁,先来后到,非公平锁则靠CPU
调度决定
1、获取锁原理
lock()
public void lock() {
sync.lock();
}
Sync
是继承自AbstractQueuedSynchronizer
的静态抽象类,FairSync
和NonfairSync
都是继承自Sync
NonfairSync.lock
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
这个方法内先判断是否能将父类 AbstractQueuedSynchronizer
中定义的state
,默认是0
,从0
改成1
,CAS
操作如果返回true
,说明已经拿到锁了,然后记录一下当前拿到所得线程。如果没有拿到锁(锁被占用了),则调用acquire
方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个方法内 通过 tryAcquire
方法尝试拿锁,拿不到会执行 acquireQueued
、addWaiter
方法,将当前节点入队(双向链表)处理,入队失败就进入真正意义上的线程阻塞。
NonfairSync.tryAcquire
内调用的是nonfairTryAcquire
方法,主要是尝试拿锁并修改state
状态值,一般自定义锁,都会重写tryAcquire
方法和tryRelease
方法
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;
}
我们先看AbstractQueuedSynchronizer.acquireQueued
方法,分析在代码内
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//当前Node的前一个节点
final Node p = node.predecessor();
//头节点,尝试拿锁
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;//不需要中断
}
//否则通过CAS不断尝试拿锁,如果还是拿不到锁,线程中断执行,进入阻塞,这里是重点
//如果线程进入阻塞条件成功,那么failed == true
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // 这个方法就是让线程进入阻塞
interrupted = true;
}
} finally {
if (failed)
//放弃尝试获取锁,这里也是重点
cancelAcquire(node);
}
}
AbstractQueuedSynchronizer.addWaiter(Node.EXCLUSIVE)
方法
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred; //当前Node的前一个节点指向队列中的尾节点
if (compareAndSetTail(pred, node)) {//CAS操作,将尾节点变成当前Node
pred.next = node;
return node;
}
}
//如果之前入队没成功,则不断循环,通过CAS操作,不断入队直到入队成功
enq(node);
return node;
}
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;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
从上面大概能了解到,拿锁的过程中,会根据当前线程会创建新的Node
,加入到链表中,主要方法是以下这两个方法
shouldParkAfterFailedAcquire(p, node)
cancelAcquire(node)
shouldParkAfterFailedAcquire
注释写在代码中
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; //获取前一个节点的状态
if (ws == Node.SIGNAL) //前一个节点的状态是 -1,可以停止各种自旋操作进入阻塞
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
if (ws > 0) {//如果前一个大于0,代表前一个等待超时或者被中断了,需要从队列中往下继续寻找在等待的节点
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);//小于0的状态,那就设置成-1,到阻塞等待状态,对于SIGNAL源码介绍是指后续线程需要取消unparking
}
return false;
}
节点大概长这样
其中waitStatus
状态值有 ,源码中有解释
-
CANCELLED
:值为1,结束状态,进入该状态后的结点将不会再变化 -
SIGNAL
:值为-1,阻塞等待唤醒阶段,只有前一个节点释放锁。 -
CONDITION
:值为-2,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。 -
PROPAGATE
:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态 -
0
状态:值为0,初始状态
首先这里Node
节点的waitStatus
默认是0
,然后线程B获取锁时,(一个节点对应于一个线程)
1、如果前一个节点没有释放锁,也就是waitStatus
没有变成-1
,会通过CAS
操作,不断轮询,将前一个节点的waitStataus
变成-1
,代表前一个线程释放了锁,线程B就不用进入阻塞等待获取锁。
2、如果前一个节点释放了锁,即waitStatus = 1
, 就需要从双向链表中不断往后寻找到没有获取锁的节点,然后将当前节点插入到寻找到的节点的后面,这种就不用阻塞,理解为我前面的节点,谁持有锁,插入到谁的后面。
3、如果前一个节点waitStatus = -1
,那当前节点不用考虑了,直接进入阻塞吧。
其实就是通过前一个节点的状态来判断当前节点对应的线程是否需要进行阻塞。
AQS这个思想叫做CLH队列锁,是CLH的变体实现
CLH
具体思想我们可以理解为一个线程对应于一个Node
节点,Node
节点中假想有一个locked
标志是否获取了锁(true
没有,false
释放了锁),每次线程获取锁,都需要加入到链表队列末尾,然后通过CAS
操作,不断判断前一个线程的locked
是否变成false
,释放了锁,然后线程将自己的locked
变成true
,前一个节点的变成false
。CAS
操作一定次数后,会判断需不需要进入阻塞等待唤醒阶段。(公平锁实现)
AQS中是双向链表结构,并且新增加了公平和非公平的实现,上面分析的都是非公平锁,我们对比一下公平锁和非公平锁的lock
方法,你就知道了区别了,非公平锁多了一个CAS
尝试去拿锁,线程对应的节点完全可能先于其它线程先插入链表后面。
FairSync 公平锁
final void lock() {
acquire(1);
}
NonfairSync 非公平锁
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
2、释放锁原理
我们再来看一下如何释放锁的,即ReentrantLock
的unlock
方法
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {//这里就是通过CAS将state值递减
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
唤醒其它节点方法 unparkSuccessor
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
上面方法可以简单概括为,寻找下一个有效节点,waitStatus
变成CANCEL
就是无效了(锁释放了),然后唤醒它所对应的线程。从这里可以看到AQS
的前后节点指针,一个是用来向前寻找非阻塞的,有效的节点,另一个是在释放锁时,寻找有效的节点,来唤醒它所对应的线程。
五、经典问题系列
多个线程能否共享一把锁
ReentrantLock
你应该很了解,那么ReentrantReadWriteLock
读写锁呢?前者是排它锁,后者是共享锁。
CAS的原理
每次都会判断内存中的值和旧值是否相等,相等说明其它线程没有改变,然后直接将内存中的值改成新值。不相等,则将这个过程进行轮询。
然后CAS
中有三个典型问题,ABA
问题、开销问题、只能保证一个共享变量原子操作
ReentrantLock实现原理
显示锁的实现,线程锁一次,计数器就会增加一次,重入一次增加一次,释放锁一次就累减一次,计数器为0,则代表当前线程已经释放该锁了。
内部实现是基于GUC
包下的AQS
来实现的。
AQS是啥
并发锁的基本构建,局限不包括ReentrantLock
、CountDownLatch
、信号量、读写锁等,独占锁、共享锁等。一般自定义锁,都会继承自AQS
,重写它的 tryAcquire
和 tryRelease
方法来重置计数器。
内部使用了 int
state
来表示同步状态,内部还维护了一个队列,来完成线程获取资源的一个排队工作,AQS
是CLH
队列锁的一种变体实现。 如果自定义一个类似的锁,我们一般会写一个子类继承自AQS
,实现AQS
的抽象方法来管理同步状态,例如tryAcquire
、tryRelease
Synchronized的原理以及与ReentrantLock的区别
Synchronized
关键字,内置锁
从之前的分析来看,实现上涉及到字节码方面就是两条指令,monitorenter
和monitorexit
,同步块能看到这两个指令,同步方法反编译会多一个ACC_Synchronized
关键字。JVM
实现加锁,主要利用这两个指令,访问到 monitorenter
,会去相关联的monitor
上获取锁,获取成功 计数器+1, 访问到moitorexit
,也是会在monitor
上释放锁,计数器减1。 显示锁,ReentrantLock
,提供了Synchronized
没有的一些功能,例如锁中断、尝试获取锁,公平锁和非公平锁两种。
volatile 能否保证线程安全?它在DCL上的作用是什么?
根据操作系统和处理器的不同来选择对应的调用代码,以 Windows
和 X86
处理器为例,如果是多处理器,通过带 lock
前缀的 cmpxchg
指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作;如果是单处理器,通过 cmpxchg
指令完成原子操作。
总之有volatile
变量修饰的共享变量进行写操作的时候会使用CPU
提供的Lock
前缀指令,作用是将当前处理器缓存的数据写回到系统内存,写回内存时会导致在其它CPU
里缓存了该内存地址的数据无效,可以理解为通知其它线程取新数据。
具有原子性和可见性。一般配合锁使用。
在DCL
上的作用,上篇文章分析过**,主要是禁止指令重排**,按照顺序来构建对象,分配内存空间、初始化对象,将空间地址赋值给引用,保证这三步按顺序进行,防止其它线程判断引用不为空时,直接使用,导致业务出错。
volatile和synchronize有什么区别?
这个很常见。
volatile
最轻量级的同步机制,保证了线程间的可见性,不保证操作原子性。 synchronize
保证了线程间的可见性和排他性,内置锁机制。
Sleep 、wait、yield 的区别,wait 的线程如何唤醒它?
sleep
、yield
不会释放锁
sleep
暂停线程的执行,休眠,可打断,等休眠时间一过,才有执行权资格,但是只有又有了资格,不代表马上就能被执行,什么时候执行取决于操作系统的调用
wait
线程间的交互,消费者观察者常用,等待别人来唤醒,唤醒后,才有执行权的资格
yield
让出CPU
执行权,进入到可执行状态
涉及多线程操作相关的Android类ThreadLocal
ThreadLocal
是什么?
分析可见 常用集合类相关知识点总结
线程本地变量,特殊的变量,ThreadLocal
为每个线程提供了一个变量的副本,使得每一个线程同一时间访问到的是不同的对象,隔离了线程间对数据的共享访问。内部实现上,每个线程内部都有一个ThreadLocalMap
,用来保存每一个线程所拥有的变量的副本。