目录
- 1、锁的全家福
- 2、如何验证公平/非公平锁
- 3、底层如何获取锁/释放锁
- 4、自旋锁与自适应自旋
- 5、为什么需要等待/通知机制
1、锁的全家福
2、如何验证公平/非公平锁
公平与非公平区别之处在于获取锁时的策略。
如上图:
1、线程1持有锁。
2、线程2、线程3、线程4 在同步队列里排队等候锁。
这时线程5也想要获取锁,根据公平与否分为两种不同策略。
公平锁
线程5先判断同步队列是是否有线程在等待,明显地此时同步队列里有线程在等待,于是线程5加入到同步队列的尾部等待。
非公平锁
1、线程5不管同步队列是否有线程等待,管他三七二十一先去抢锁再说。若是运气好就能直接捡到便宜获取了锁,若是失败再去排队。
2、线程5还是有机会捡便宜的,若是此时线程1刚好释放了锁,并唤醒线程2,线程2醒过来后去获取锁。若在线程2获取锁之前线程5就去抢锁了,那么它会成功。它的成功对于线程2、线程3、线程4来说是不公平的。
我们知道ReentrantLock 可实现公平/非公平锁,来验证一下。
先来验证公平锁:
public class TestThread {
private ReentrantLock reentrantLock = new ReentrantLock(true);
private void testLock() {
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(runnable);
thread.setName("线程" + (i + 1));
thread.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "启动了,准备获取锁");
reentrantLock.lock();
System.out.println(Thread.currentThread().getName() + "获取了锁");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
}
};
public static void main(String[] args) {
TestThread testThread = new TestThread();
testThread.testLock();
}
}
打印如下:
可以看出,线程2、3、4、5 按顺序获取锁,实际上拿到锁也是按照这顺序的。
因此,符合先到先得,是公平的。
再来验证非公平锁
public class TestThread {
private ReentrantLock reentrantLock = new ReentrantLock(false);
private void testLock() {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable);
thread.setName("线程" + (i + 1));
thread.start();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
private void testUnfair() {
try {
Thread.sleep(500);
while (true) {
System.out.println("+++++++我抢...+++++++");
boolean isLock = reentrantLock.tryLock();
if (isLock) {
System.out.println("========我抢到锁了!!!===========");
reentrantLock.unlock();
return;
}
Thread.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " 启动了,准备获取锁");
reentrantLock.lock();
System.out.println(Thread.currentThread().getName() + " 获取了锁");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
}
}
};
public static void main(String args[]) {
TestThread testThread = new TestThread();
testThread.testLock();
testThread.testUnfair();
}
}
打印如下:
这俩张图结合来看:
1、第一张图:线程1~线程10 依次调用lock抢锁,然后主线程开始抢锁。
2、只要有一次能够证明主线成比线程1~线程10之间的某个线程先获得锁,那么就证明该锁为非公平锁。
3、第二张图:主线程比线程4~线程10先获得了锁,说明过程是非公平的。
值得注意的是:
此处使用tryLock()抢占锁,tryLock()和lock(非公平模式)核心逻辑是一样的。
3、底层如何获取锁/释放锁
一直在提线程获取了锁,线程释放了锁,到底这个逻辑如何实现的呢?
从第一张全家福的图,可以看出锁的基本数据结构包含:
共享锁变量、volatile、CAS、同步队列。
假设设定共享变量为:volatile int threadId。
threadId == 0表示当前没有线程获取锁,thread !=0 表示有线程占有了锁。
获取锁
1、线程调用 CAS(threadId, 0, 1),预期threadId == 0, 若是符合预期,则将threadId设置为1,CAS成功说明成功获取了锁。
2、若是CAS失败,说明threadId != 0,进而说明有已经有别的线程修改了threadId,因此线程获取锁失败,然后加入到同步队列。
释放锁
1、持有锁的线程不需要锁后要释放锁,假设是独占锁(互斥),因为同时只有一个线程能获取锁,因此释放锁时修改threadId不需要CAS,直接threadId == 0,说明释放锁成功。
2、成功后,唤醒在同步队列里等待的线程。
synchronized 和 AQS 获取/释放锁核心思想就是上面几步,只是控制得更复杂,精细,考虑得更全面。
注:CAS(threadId, xx, xx)是伪代码
4、自旋锁与自适应自旋
很多文章说CAS是自旋锁,这说法是有问题的,本质上没有完全理解CAS功能和锁。
1、CAS 全称是比较与交换,若是内存值与期望值一致,说明没有其它线程更改目标变量,因此可以放心地将目标变量修改为新值。
2、CAS是原子操作,底层是CPU指令。
3、CAS 只是一次尝试修改目标变量的操作,结果要么成功,要么失败,最后调用都会返回。
通过上个小结的分析,我们知道synchronized、AQS底层获取/释放锁都是依赖CAS的,难道说synchronized、AQS 也是自旋锁,显然不是。
自旋锁是不会阻塞的,而CAS也不会阻塞,因此可以利用CAS实现自旋锁:
class MyLock {
AtomicInteger atomicInteger = new AtomicInteger(0);
private void lock() {
boolean suc = false;
do {
//底层是CAS
suc = atomicInteger.compareAndSet(0, 1);
} while (!suc);
}
}
如上所示,自定义锁MyLock,线程1,线程2分别调用lock()上锁。
1、线程1调用lock(),因为atomicInteger== 0,所以suc == true,线程1成功获取锁。
2、此时线程2也调用lock(),因为atomicInteger1,说明锁被占用了,所以suc = false,然而线程2并不阻塞,一直循环去修改。只要线程1不释放锁,那么线程2永远获取不了锁。
以上就是自旋锁的实现,可以看出:
1、自旋锁最大限度避免了线程挂起/与唤醒,避免上下文切换,但是无限制的自旋也会徒劳占用CPU资源。
2、因此自选锁适用于线程执行临界区比较快的场景,也就是获得锁后,快速释放了锁。
既想要自旋,又要避免无限制自旋,因此引入了自适应自旋:
class MyLock {
AtomicInteger atomicInteger = new AtomicInteger(0);
//最大自旋次数
final int MAX_COUNT = 10;
int count = 0;
private void lock() {
boolean suc = false;
while (!suc && count <= MAX_COUNT) {
//底层是CAS
suc = atomicInteger.compareAndSet(0, 1);
if (!suc)
Thread.yield();
count++;
}
}
}
可以看出,给自旋设置了最大自旋次数,若还是没能获取到锁,则退出死循环。
实际上synchronized、ReentrantReadWriteLock 等的实现里,同样为了尽量避免线程挂起/唤醒,在抢占锁的过程中也是采用了自旋(自适应自旋)的思想,但这只是它们锁实现的以小部分,它们并不是自旋锁。
5、为什么需要等待/通知机制
先看独占锁的伪代码:
//Thread1
myLock.lock();
{
//临界区代码
}
myLock.unLock();
//Thread2
myLock.lock();
{
//临界区代码
}
myLock.unLock();
Thread1、Thread2 互斥拿到锁后各干各的,互不干涉,相安无事。
若是现在Thread1、Thread2 需要配合做事,如:
//Thread1
myLock.lock();
{
//临界区代码
while (flag == false)
wait();
//继续做事
}
myLock.unLock();
//Thread2
myLock.lock();
{
//临界区代码
flag = true;
notify();
//继续做事
}
myLock.unLock();
如上代码,Thread1需要判断flag == true才会往下运行,而这个值需要Thread2来修改,Thread1、Thread2 两者间有协作关系。于是Thread1需要调用wait 释放锁,并阻塞等待。Thread2在Thread1释放锁后拿到锁,修改flag,然后notify 唤醒Thread1(唤醒时机在Thread2执行完临界区代码并释放锁后)。Thread1 被唤醒后继续抢锁,然后判断flag==true,继续做事。
于是,Thread1、Thread2愉快配合完成工作。
为啥wait/notify 需要先获取锁呢?flag 是线程间共享变量,需要在并发条件下正确访问,因此需要锁。