JAVA并发八股文
JAVA并发八股文
JAVA并发类图
线程基础
线程状态
Thread.State枚举类中将线程分为6种状态,从java语言层面将线程分为6种状态
-
NEW、RUNNABLE、BLOCKED、TERMINATED
-
BLOCKED
被阻止等待监视器锁定的线程的线程状态。
处于阻塞状态的线程正在等待监视器锁进入同步的块/方法。
或者在调用Object.wait后重新进入同步块/方法。 -
WAITING:
由于调用以下方法之一,线程处于等待状态:
Object.wait with no timeout
Thread.join with no timeout
LockSupport.park
处于等待状态的线程正在等待另一个线程执行特定操作。例如,对某个对象调用了Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()或Object.notifyAll()。一个调用了thread.join()的线程正在等待指定的线程终止。 -
TIMED_WAITING:
具有指定等待时间的等待线程的线程状态。由于使用指定的正等待时间调用以下方法之一,线程处于定时等待状态:
Thread.sleep
Object.wait with timeout
Thread.join with timeout
LockSupport.parkNanos
LockSupport.parkUntil
Monitor
Monitor地址的存储
monitor地址被存储于 java对象markword中的区段,ptr_to_heavyweight_monitor存储monitor对象的地址
Monitor概念
监视器或者管程,Monitor是重量级锁,每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)锁之后,该对象的Mark Word中就被设置指向monitor对象的指针
刚开始Monitor中Owner为null
当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
Thread-2上锁过程中,如果Thread-3、Thread-4、Thread-5也来执行synchronized(obj)就会进入EntryList 阻塞队列
Thread-2执行完同步代码后,唤醒 EntryList中等待的线程来竞争锁,竞争的锁是非公平的
WaitSet中的Thread-0和Thread-1是之前获得过锁,但是条件不满足的,进入Waiting状态的线程
JVM锁
轻量级加锁
锁记录(Lock Record)对象:每个线程的栈帧都包含一个锁记录的结构,内部可以存储锁对象的Mark Word
让锁记录中object reference指向锁对象,并尝试用cas替换Object的Mark Word,将Mark Word的值存入锁记录
如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,
状态00表示轻量级锁(可以查看Monitor中对象头State内容)
如果cas失败,有两种情况
1)如果是其他线程已经持有了该Object的轻量级锁,这表明锁有竞争,进入锁膨胀过程
2)如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数(同一个线程,对同一个对象加了N次锁,就会有N个锁记录的个数)
当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头。
1)成功,则解锁成功
2)失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
在尝试加轻量级锁过程中,CAS操作无法成功,这时有一种情况就是其他线程为对象加上了轻量级锁(有竞争),这时需要进行锁膨胀
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁(锁状态不是01,是00表示已经加上轻量级锁,cas操作注定失败)
这时Thread-1加轻量级锁失败,进入锁膨胀流程
1)为Object对象申请Monitor锁,让Object指向重量级锁地址
2)然后自己进入Monitor的EntryList队列进行阻塞
Thread-0退出同步代码块时,使用cas将Mark Word值恢复给对象头,但是会失败,这时会进入重量级锁解锁流程
即按照Monitor地址找到Monitor对象,设置Owner的值为null,唤醒EntryList中的BLOCKED线程。
注意:
这边还有一个问题,解锁后的Thread-0的Lock Record应该归还Hashcode Age Bias 01给Object对象的,这边貌似没有提到,不知道具体实现方式
锁自旋(优化)
- 重量级锁竞争时,还可以使用自旋来进行优化,如果当前线程自旋成功(即持锁线程已经退出同步块,释放了锁),这时当前线程可以避免阻塞(阻塞的坏处)
- JAVA6以后自旋锁是自适应的,如果刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就会多自旋几次,反之就少自旋或者不自旋
- 自旋会占用CPU时间,单核CPU自旋就是浪费计算资源,多核CPU才能发挥作用,JAVA7之后不能控制是否开启自旋功能
锁偏向(优化)
情景:单一线程一直获取到当前锁对象
在轻量级锁时,每次线程对对象进行加锁时,都会使用CAS操作将 线程栈帧中的锁记录 替换成锁对象的 markword,这个操作实际上也会有性能损耗,而偏向锁用来解决这种损耗,不用重新CAS
在回顾markword的结构,biased_lock为1 的时候,就是启用了偏向锁
锁偏向的撤销
如果调用了对象的hashcode方法,但偏向锁对象的markword存储的是线程ID,会导致偏向锁被撤销,因为第一次调用hashcode会复制到markword上(这或许也解释了为什么解锁后线程lock record不需要归还hashcode给对象)
当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
调用wait/notify方法会撤销偏向锁,因为wait notify只有重量级锁才会有,entryList阻塞队列
线程操作
Object.wait & Object.notify
entryList:竞争锁的线程队列
waitSet:等待着被唤醒进入竞争锁线程队列的线程集合
owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态
BLOCKED和WAITING线程都处于阻塞状态,不占用CPU时间
BLOCKED线程会在线程释放锁时唤醒
WAITING线程会在owner线程调用notify或者notifyAll时唤醒,被唤醒后进入EntryList重新竞争锁
notify会在waitSet中挑一个唤醒
notifyAll会唤醒锁有waitSet中的线程
LockSupport
- wait,notify和notifyAll必须配合Object Monitor一起使用,而unpark不必
- LockSupport.park & LockSupport.unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒锁有等待线程,就不那么【精确】
- LockSupport.park & LockSupport.unpark可以先LockSupport.unpark,而wait & notify不能先notify
Thread t1=new Thread(()->{
sleep(1);
LockSupport.park();
},"t1");
sleep(2);
// 可以唤醒指定线程
LockSupport.unpark(t1);
每个线程都有自己的一个Parker对象,由三部分组成_counter,_cond和_mutex
调用park会让_counter=_counter-1(_counter>0),如果_counter=0则会阻塞
调用unpark会使得_counter=1
这就解释了为什么可以先unpark,再park
先unpark会使得_counter=1,再park时_counter–(_counter>0)成功,则线程可以继续往下执行
与interrupt()联动
public void interrupt(){} 给线程打一个 中断标志=true
public boolean isInterrupted(){} 检测下线程是否被中断
public static boolean interrupted() {} 也是检测下线程是否被中断,中断标志=false
// 采自 AbstractQueuedSynchronizer.parkAndCheckInterrupt
private final boolean parkAndCheckInterrupt() {
// 阻塞线程,等待唤醒
LockSupport.park(this);
// 如果线程走下来,要么是 unpark()了,要么是interrupt()了,而这两者 interrupt() 会将中断状态置为true,导致park()失效
// interrupted()恰好返回中断状态,并置中断状态为false,使得下一次park() 仍然是生效的
return Thread.interrupted();
}
park与interrupt
调用park()的线程可以被interrupt()方法打断,park()进入时首先会判断当前线程是否有"许可",然后判断是否被打断。
如果线程没有被打断则正常判断是否有"许可"(_counter==1),过程与以上park()方法介绍一致;
如果线程中断状态=true
被打断则先判断是否有"许可",若有"许可"则消费掉"许可"然后直接跳出阻塞,
若没有许可则直接跳出阻塞且不抛出InterruptedException并且也不会处理中断状态
这一点被用在ReentrantLock.parkAndCheckInterrupt
方法
所以如果线程被打断,连续调用park()方法线程也不会被阻塞住(可以理解为park()方法失效)。若想重新生效park()方法需要重置中断状态为false;
JDK锁
CAS
CompareAndSet(origin,now):如果值是origin,那么设置成now,否则CAS失败
底层是lock cmpxhg指令(X86架构),在单核CPU和多核CPU下都能够保证【比较-交换】的原子性
多核状态下,某个核执行到带lock的指令时,CPU会让总线锁住,当这个核把指令执行完毕再开启总线,这个过程不会被线程调度机制打断,保证多个线程对内存操作的准确性,是原子的
AQS(重要,整个加锁解锁流程,以ReentrantLock实现为例)
全称AbstractQueuedSynchronizer,阻塞式和相关的同步器工具框架
特点:
用state属性来表示资源的状态(独占模式和共享模式),子类继承后去定义和维护这个状态,控制如何获得锁和释放锁
- getState:获取state属性
- setState:设置state属性
- compareAndSetState:cas机制设置state状态
- 独占模式:只有一个线程能够访问资源。共享模式:允许多个线程访问资源
提供了基于FIFO的等待队列,类似与Monitor的EntryList
条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的WaitSet
子类需要实现以下方法(默认抛出UnsupportedOperationException):
tryAcquire:获取锁
tryRelease:释放锁
tryAcquireShared:获取共享锁
tryReleaseShared:释放共享锁
isHeldExclusively:线程是否持有锁
加锁解锁流程
Thread-1执行过程:
// 采自 AbstractQueuedSynchronizer.acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
- CAS尝试将state由0改为1,结果失败
- 进入tryAcquire逻辑,这时state已经是1,结果仍然失败
- 接下来进入addWaiter逻辑,构造Node队列
- 途中黄色三角表示Node的waitStatus状态,其中0为默认正常状态
- Node的创建时懒惰的
- 其中第一个Node成为Dummy(亚元)或哨兵,用来占位,并不关联线程
- 添加到队列中时,采用尾插法
// 采自 AbstractQueuedSynchronizer.acquireQueued
// 处于队列中时尝试获取锁
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 当前节点的上一个节点是头节点,则去获得锁(队列中第一个有效线程节点优先获取锁)
// 获得锁成功后,state 会
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
// 采自 AbstractQueuedSynchronizer.Node.release
// 这是acquire循环中主要的控制信号
// 前一个节点状态是-1时,表示当前线程需要阻塞
// 前一个节点状态时0 时,需要设置前一个节点状态为-1,并且当前线程需要继续尝试获得锁
// 前一个节点状态>0时,前一个节点获取到了锁
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
- shouldParkAfterFailedAcquire执行完毕,前置节点的waitStatus置为-1,并返回false,回到acquireQueued,再次tryAcquire尝试获取锁,这时state仍为1,失败
- 当再次进入shouldParkAfterFailedAcquire时,这时因为其前驱node的waitStatus已经是-1,这次返回true
- 进入parkAndCheckInterrupt(),将当前线程使用LockSupport.park阻塞住
- 多个线程经历加锁失败后
- Thread-0释放锁
// 采自 AbstractQueuedSynchronizer.release
// 释放锁,唤醒等待队列中离头节点最近的那一个节点中的线程,让其参与tryAcquire 竞争锁
public final boolean release(int arg) {
// 释放锁,exclusiveOwnerThread=null,且state 设置为 0 (以ReentrantLock为例)
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
// 采自 AbstractQueuedSynchronizer.Node.unparkSuccessor
// unpark 唤醒离头节点最近节点
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 设置头节点状态为0
compareAndSetWaitStatus(node, ws, 0);
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);
}
- 当队列部位null,并且head的waitStatus=-1时,进入unparkSuccessor流程,找到队列中离head最近的Node,unpark恢恢复其运行,本例中为Thread-1,如果加锁成功(没有竞争),会设置
- exclusiveOwnerThread为Thread-1,state=1
- head指向刚刚Thread-1所在的Node,该Node清空Thread
- 原本的head从链表断开,没有指针指向它,因而可以被垃圾回收
为什么唤醒节点要从后往前找?
节点插入到链表时,node.prev指向tail,tail指向node,node.prev.next指向node
如果从前往后找,最后一个节点的next还是null,还没来得及指向插入的节点
1)而此时又开始唤醒节点了,如果从前往后找,那么节点会丢失
2)
// AbstractQueuedSynchronizer.addWaiter 向等待队列中添加节点
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;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
公平与非公平
默认的ReentrantLock是非公平的实现方式,从加锁的实现方式可以看出tryAcquire(arg)
// 采自 ReentrantLock.NonfairSync.tryAcquire
// NonfairSync 继承 Sync
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
// 采自 ReentrantLock.Sync.nonfairTryAcquire
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;
}
// 采自 ReentrantLock.FairSync.tryAcquire 方法,也继承Sync,但覆写了Sync的tryAcquire方法,使其公平
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 公平,先判断队列是否有等待线程,如果有等待线程则加锁失败,会进入等待队列末尾去等待锁
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;
}
可重入性
回到ReentrantLock.Sync.nonfairTryAcquire中,我们可以发现,current == getExclusiveOwnerThread() 时,会对state进行+acquires操作,而释放锁时,会对state进行-releases操作,只有当 减过后的 state == 0 的时候,free才会为true,也就是说state减到0了,才会彻底释放锁,去unparkSuccessor唤醒下一个线程
// 采自 ReentrantLock.tryRelease
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
可以打断的加锁
我们知道LockSupport.park()可以被unpark()和interrupt() 两个方法打断,如果锁被interrupt()打断,中断标志会置为true,而parkAndCheckInterrupt 被打断后会返回中断标识位=true,接着会抛出InterruptedException异常,不可打断的加锁实现:acquireQueued
// 采自 AbstractQueuedSynchronizer.doAcquireInterruptibly
private void doAcquireInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
// 可打断与不可打断的实现区别,这边打断后会直接抛出异常
throw new InterruptedException();
// 而不可打断的实现如下,只是改变了一个变量,并且会继续回到acquireQueued的循环中去
// interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
JAVA线程创建方式
继承Thread类
Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例。 启动线程的唯一方法就是通过 Thread 类的 start()实例方法。 start()方法是一个 native 方法,它将启动一个新线程,并执行 run()方法
public class MyThread extends Thread {
public void run() {
System.out.println("MyThread.run()");
}
}
MyThread myThread1 = new MyThread();
myThread1.start();
实现Runnable方法
public class MyThread extends OtherClass implements Runnable {
public void run() {
System.out.println("MyThread.run()");
}
}
//启动 MyThread,需要首先实例化一个 Thread,并传入自己的 MyThread 实例:
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
实现Callable方法
//执行Callable方式,需要FutureTask实现类的支持,用于接受运算结果
FutureTask<Integer> run=new FutureTask<>(
new Callable<Integer>() {
/**
* 相较于Runable接口,方法有返回值,并且可以抛出异常
* @return
* @throws Exception
*/
@Override
public Integer call() throws Exception {
int sum=0;
for (int i = 0; i < 100; i++) {
sum+=100;
}
return sum;
}
}
);
new Thread(run).start();
run.get();
线程池
构造方法
线程池运行过程
1)线程池刚开始没有线程,当一个任务提交给线程池后,会创建一个新线程来执行任务
2)当线程数达到corePoolSize,且线程没有空闲时,再加入任务会加入到workQueue阻塞队列,直到有空余线程
3)如果选择有界队列,且超过了任务队列的大小时,会创建maximumPoolSize-corePoolSize数目的线程来救急
4)如果线程达到maximumPoolSize仍有新任务时会执行拒绝策略,JDK有四种实现
- AbortPolicy:调用者抛出RejectedExecutionException异常
- CallerRunsPolicy:调用者直接运行任务
- DiscardPolicy:放弃本次任务
- DiscardOldestPolicy:放弃队列中最早的任务,本任务取而代之
- Dubbo的实现,抛出RejectedExecutionException异常之前会记录日志,并dump线程堆栈信息,方便定位问题
5)当高峰过去后,超过corePoolSize的救急线程如果一段时间没有任务做,会释放掉线程资源,这个时间由keepAliveTime来控制
为何用一个Int类型变量即表示线程池状态又表示线程数量(方便CAS)
submit和execute区别
execute和submit的区别
execute和submit都属于线程池的方法,execute只能提交Runnable类型的任务
submit既能提交Runnable类型任务也能提交Callable类型任务。
异常:
execute会直接抛出任务执行时的异常,可以用try、catch来捕获,和普通线程的处理方式完全一致
submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。
返回值:
execute()没有返回值
submit有返回值,所以需要返回值的时候必须使用submit
内存可见性
导致内存可见性的原因
JIT编译器对于频繁访问的主存变量,会缓存到线程的工作高速缓存中
使用volatile
修饰变量,避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值
volatile原理
给volatile变量赋值后一行指令会加入写屏障,确保写屏障之前的共享变量直接赋值到主存,并且写屏障之前的指令不会重排序到写屏障后
读volatile变量的值前一行指令会加入读屏障,确保读屏障之后的共享变量直接从主存中读值,并且读屏障之后的指令不会重排序到读屏障前
单例模式最正确的实现
除了加锁前后变量的双重判断外,还需要给单例变量加上volatile!细品