前言
线程死锁是老生常谈的问题,线程池死锁本质上属于线程死锁的一部分,线程池造成的死锁问题往往和业务场景相关,当然更重要的是对线程池的理解不足,本文根据场景来说明一下常见的线程池死锁问题,当然也会包含线程死锁问题。
线程死锁场景
死锁的场景很多,有线程池相关,也有与线程相关,线程相关的线程池上往往也会出现,反之却不一定,本文会总结一些常见的场景,当然有些场景后续可能还需要补充。
经典互斥关系死锁
这种死锁是最常见的经典死锁,假定存在 A、B 2 个任务,A 需要 B 的资源,B 需要 A 的资源,双方都无法得到时便出现了死锁,这种情况是锁直接互相等待引发,一般的情况下通过dumpheap 的lock hashcode就能发现,相对来说容易定位的多。
//首先我们先定义两个final的对象锁.可以看做是共有的资源.
final Object lockA = new Object();
final Object lockB = new Object();
//生产者A
class ProductThreadA implements Runnable{
@Override
public void run() {
//这里一定要让线程睡一会儿来模拟处理数据 ,要不然的话死锁的现象不会那么的明显.这里就是同步语句块里面,首先获得对象锁lockA,然后执行一些代码,随后我们需要对象锁lockB去执行另外一些代码.
synchronized (lockA){
//这里一个log日志
Log.e("CHAO","ThreadA lock lockA");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB){
//这里一个log日志
Log.e("CHAO","ThreadA lock lockB");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//生产者B
class ProductThreadB implements Runnable{
//我们生产的顺序真好好生产者A相反,我们首先需要对象锁lockB,然后需要对象锁lockA.
@Override
public void run() {
synchronized (lockB){
//这里一个log日志
Log.e("CHAO","ThreadB lock lockB");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockA){
//这里一个log日志
Log.e("CHAO","ThreadB lock lockA");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
//这里运行线程
ProductThreadA productThreadA = new ProductThreadA();
ProductThreadB productThreadB = new ProductThreadB();
Thread threadA = new Thread(productThreadA);
Thread threadB = new Thread(productThreadB);
threadA.start();
threadB.start();
这类问题需要进行排查和不断的优化,重点是优化逻辑尽量减少锁的使用,同时优化调度机制。
Submit递归等待调用死锁
原理是在固定的线程池数量中,不断的 submit 任务,并且从工作线程通过get等待任务完成, 但是线程池数量是固定的,从头到尾所有的线程没执行完成,某次 submit 时就没有足够的线程来处理任务,所有任务都处于等待。
ExecutorService pool = Executors.newSingleThreadExecutor(); //使用一个线程数模拟
pool.submit(() -> {
try {
log.info("First");
//上一个线程没有执行完,线程池没有线程来提交本次任务,会处于等待状态
pool.submit(() -> log.info("Second")).get();
log.info("Third");
} catch (InterruptedException | ExecutionException e) {
log.error("Error", e);
}
});
对于这种特殊逻辑,一定要思考清楚get方法调用的意义,如果仅仅为了串行执行,使用一般队列即可,当然你也可以join其他线程。
公用线程池线程 size 不足造成的死锁
该类死锁一般是把一个Size有限的线程池用于多个任务。
假定 A,B 两个业务各需要2个线程处理生产者和消费者业务,且每个业务都有自己的lock,但是业务之间的lock没有关联关系。提供一个公共线程池,线程大小为2,显然比较合理的执行任务需要4个,或者至少3个,在线程数量不足的情况下这种情况下死锁会高概率发生。
情形一:A,B 有序执行,不会造成死锁
情形二: A、B 并发执行,造成死锁
情形二出现的原因是 A,B 各分配了一个线程,当他们执行的条件都不满足的时处于要wait状态,这时线程池没有更多的线程提供,将导致 A、B 处于死锁。
因此,对于公用线程池的使用,Size不要设置过低,同时要尽可能避免加锁和太耗时的任务,如果有加锁和太耗时的需求,可以尝试使用专用线程池。
RejectedExecutionHandler 使用不当造成的 “死锁”
严格意义上不能称为死锁,但是这也是非常容易忽视的问题。原因在没检测线程池状态的情况下,通过RejectionExectutionHandler回调方法中将任务重新加回去,如此往复循环,锁住Caller线程。
一般处理任务时,触发该 RecjectedExecutionHandler 的情况分为 2 类,主要是 "线程池关闭"、“线程队列和线程数已经达到最大容量”,那么问题一般出现在前者,如果线程池 shutdown 关闭之后,我们尝试在该 Handler 中重新加入任务到线程池,那么会造成死循环问题。
锁住死循环
锁住死循环本身也是一种死锁,导致其他想获取锁资源的线程无法正常获取中断。
synchronized(lock){
while(true){
// do some slow things
}
}
这种循环锁也是相当经典,如果while内部没有wait的调用或者return或者break,那么这个锁会一直存在。
文件锁 & lock互斥
严格来说这种相对复杂,有可能是文件锁与lock互斥,也有可能是多进程文件锁获取时阻塞之后无法释放,导致java lock一直无法释放,因此对于发生死锁时,dumpheap时不要忽略文件操作相关的堆栈。
可见性不足
通常情况下,这不是死锁,而是线程无限循环,以至于该线程无法被其他任务使用,我们对一些线程循环会加一个变量标记其是否结束,但是如果可见性不足,也将无法造成退出的后果。
下面我们用主线程和普通线程模拟,我们在普通线程中修改变量A,但是A变量在主线程中可见性不足,导致主线程阻塞。
public class ThreadWatcher {
public int A = 0;
public static void main(String[] args) {
final ThreadWatcher threadWatcher = new ThreadWatcher();
WorkThread t = new WorkThread(threadWatcher);
t.start();
while (true) {
if (threadWatcher.A == 1) {
System.out.println("Main Thread exit");
break;
}
}
}
}
class WorkThread extends Thread {
private ThreadWatcher threadWatcher;
public WorkThread(ThreadWatcher threadWatcher) {
super();
this.threadWatcher = threadWatcher;
}
@Override
public void run() {
super.run();
System.out.println("sleep 1000");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.threadWatcher.A = 1;
System.out.println("WorkThread exit");
}
}
打印结果:
sleep 1000
WorkThread exit
由于A缺乏可见性,导致主线程一直循环,这里有必要加上volatile或者使用atomic类,或者使用synchronized进行同步。注意,不能用final,final只能保证指令不可乱序,但不能保证可见性。
CountDownLatch 初始值过大
这个原因属于编程问题,比如需要2次countDown完成等待,而初始值为3次以上,必然导致等待的线程卡住。
CountDownLatch latch = new CountDownLatch(6);
ExecutorService service = Executors.newFixedThreadPool(5);
for(int i=0;i< 5;i++){
final int no = i+1;
Runnable runnable=new Runnable(){
@Override
public void run(){
try{
Thread.sleep((long)(Math.random()*10000));
System.out.println("No."+no+"准备好了。");
}catch(InterruptedException e){
e.printStackTrace();
}finally{
latch.countDown();
}
}
};
service.submit(runnable);
}
System.out.println("开始执行.....");
latch.await();
System.out.println("停止执行");
实际上这种问题排查起来比较容易,对于计数式waiter,一定确保waiter能结束,即使发生异常行为。
notify引发死锁
这种情况一般会出现在存在多个生产者,消费者单一的情况,通常我们需要使用notifyAll去规避,当然后者性能开销会有些大。
notify()方法。它用于唤醒单个等待线程,但具体哪个线程被唤醒是随机的,这可能导致线程调度的问题,如果需要唤醒特定条件的线程,还需要额外的同步机制来确保正确性。 notifyAll()方法。它会唤醒所有等待的线程,这虽然简化了线程同步的复杂性,但可能会增加系统开销,因为它需要处理更多的线程竞争锁的情况。
另一种优化方式就是读写分离,使用读锁和写锁,可以用于优化notifyAll引发的性能问题。
线程死锁优化建议
死锁一般和阻塞有关,对待死锁问题,不妨换一种方式。
常见的优化方法
- 1、可以有序执行,当然这种也降低了并发优势
- 2、不要共用同一线程池,如果要共用,避免加锁,阻塞和悬挂
- 3、使用公共锁资源的 wait (long timeout) 机制,让线程超时
- 4、如果过于担心线程池不能回收,建议使用 keepaliveTime+allowCoreThreadTimeOut,回收线程但不影响线程状态,可以继续提交任务。
- 5、必要时扩大线程池大小
公用线程任务移除
如果公共线程池正在执行的线程阻塞了,那所有的任务需要等待,对于不重要的任务,可以选择移除。
这种情况主要发生在用户中途退出相关页面时,如果给线程池加入了过多任务,但是用户决定不再关注结果的场景。
实际上正在执行的线程任务很难去终止,公用线程池可能造成大量任务pending,但是从公用线程池中移除任务队列显然是比较危险的操作。一种可行的方法是warp task,每次添加runnable时记录这些Task,退出特定业务时清理Warpper中的target目标任务
public class RemovableTask implements Runnable {
private static final String TAG = "RemovableTask";
private Runnable target = null;
private Object lock = new Object();
public RemovableTask(Runnable task) {
this.target = task;
}
public static RemovableTask warp(Runnable r) {
return new RemovableTask(r);
}
@Override
public void run() {
Runnable task;
synchronized (this.lock) {
task = this.target;
}
if (task == null) {
MLog.d(TAG,"-cancel task-");
return;
}
task.run();
}
public void dontRunIfPending() {
synchronized (this.lock) {
this.target = null;
}
}
}
下面进行任务清理
public void purgeTaskRunnable() {
for (RemovableTask r : pendingTaskLists){
r.dontRunIfPending();
}
}
注意,这里仍然还可以利用享元模式优化,减少RemovableTask的创建。
通过这种方式可以有效避免内存泄露等问题。
使用Handler
由于Handler的有序性,可以避免进行加锁,这点也是可以利用的。
使用多路复用或协程
对于锁比较厌恶的开发者可以使用多路复用或协程,这种情况下存避免不必要的等待,将wait转化为notify,减少上下文切换,可以提高线程的执行效率。
说到对协程观点,一直存在争议:
(1)协程是轻量级线程?但从cpu和系统角度,协程和多路复用都不是轻量级线程,CPU压根不认识这货,因此不可能比线程快,他只能加速线程的执行,Okhttp也不是轻量级Socket,再快也快不过Socket,他们都是并发编程框架或者风格。
(2)kotlin也不是假协程,有观点说kotlin会创建线程所以是假协程?epoll多路复用机制,难道所有任务都是epoll执行的么?简单的例子,从磁盘拷贝文件到内存,虽然CPU不参与,但DMA也是芯片,毫无疑问,也算线程。协程在用户态执行耗时任务,如果不启用线程,难不成要插入无数entry point 让单个线程执行一个任务?显然,对于协程的认知,有人夸有人贬,主要原因还是是对于“框架”和执行单元存在认知问题。
降低锁粒度
JIT对锁的优化分为锁消除和锁重入,但是很难对锁粒度进行优化,因此,不要添加过大的代码段显然是必要的,因此有些耗时逻辑本身不涉及变量的修改,大可不必加锁,只对修改变量的部分加锁即可。
指标衡量
CPU和线程状态监控,我们可以参考下面的方式,实现线程状态的检查 com.android.internal.os.ProcessCpuTracker
但是,这里可能会遇到一个问题,tid无法获取
"/proc/" + pid + "/stat" + tid
在Android中,大部分线程TID没有获取的相关方法,那怎么办呢?
实际上,有两种方法
遍历文件
我们可以通过遍历读取
/proc/pid/task/tid/stat
提取name,匹配线程name的方式读取到TID
这种性能较差,但是安全性高,可以避免访问到不安全的内存,从而避免crash。
30001 (RenderThread) S 418 418 0 0 -1 1077936448 23539 0 1040 0 107 185 0 0 10 -10 17 0 19912240 14199836672 31176 18446744073709551615 1 1 0 0 0 0 4612 1 1073775864 0 0 0 17 1 0 0 0 0 0 0 0 0 0 0 0 0 0
上面30001 就是TID。
Unsafe指针读取
Unsafe + 线程的nativePeer指针实现读取TID
public class UnsafeThreadUtil {
//https://blog.51cto.com/u_16213643/7824452
private static final String TAG = "UnsafeThreadUtil";
private static Field nativePeerField;
public static final int SIZE_UNKNOWN = -2;
public static final int SIZE_UNINITIALIZED = -1;
private static int offsetInNativeTid = SIZE_UNINITIALIZED;
private static Method getNativeTid;
public static void init() {
int pid = Process.myPid();
//本文开头说过,PID等于主线程TID,因此我们这里直接取PID,通过PID作为哨兵,探测出TID数据的指针偏移位置
try {
nativePeerField = Thread.class.getDeclaredField("nativePeer");
if (!nativePeerField.isAccessible()) {
nativePeerField.setAccessible(true);
}
long nativePeer = (long) nativePeerField.getLong(Looper.getMainLooper().getThread());
UnsafeProxy unsafe = getUnsafe();
int i = 0;
for (; ; ) {
int tid = unsafe.getInt(nativePeer + i);
if (tid == pid) {
offsetInNativeTid = i;
break;
}
i++;
if (i > 64) {
offsetInNativeTid = SIZE_UNKNOWN;
break;
}
}
} catch (Throwable e) {
e.printStackTrace();
}
}
public static int getTid(final Thread thread) {
if(thread.getState() == Thread.State.TERMINATED){
Logger.i("ThreadTid", "Thread is over" );
//这两种状态去拿tid 会引发crash
return SIZE_UNKNOWN;
}
if(thread.getState() == Thread.State.NEW || !thread.isAlive()){
Logger.i("ThreadTid", "Thread is not yet run " );
return SIZE_UNKNOWN;
}
if(Build.VERSION.SDK_INT == 30) {
//java.lang.Thread#getNativeTid
}
return getTidFromNativePeer(thread);
}
private static int getTidFromNativePeer(Thread thread) {
try {
if (thread == null) {
return SIZE_UNKNOWN;
}
if (offsetInNativeTid == SIZE_UNINITIALIZED) {
init();
}
if (offsetInNativeTid == SIZE_UNKNOWN || nativePeerField == null) {
return SIZE_UNKNOWN;
}
synchronized (nativePeerField) {
UnsafeProxy unsafe = getUnsafe();
long nativePeer = (long) nativePeerField.getLong(thread);
if(nativePeer <= 0) {
return SIZE_UNKNOWN;
}
int tid = unsafe.getInt(nativePeer + offsetInNativeTid);
return tid;
}
} catch (Throwable e) {
e.printStackTrace();
}
return 0;
}
public static UnsafeProxy getUnsafe() {
return UnsafeProxy.getUnsafe();
}
public static Thread getThread(String name) {
ThreadGroup threadGroup = getThreadGroup();
Thread[] threads = new Thread[threadGroup.activeCount()];
try {
threadGroup.enumerate(threads);
} catch (Exception e) {
e.printStackTrace();
}
for (Thread t : threads) {
if (t == null) continue;
if (t.getName().equals(name)) {
return t;
}
}
return null;
}
public static ThreadGroup getThreadGroup() {
ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
while (threadGroup != null) {
ThreadGroup parent = threadGroup.getParent();
if (parent == null) {
return threadGroup;
}
threadGroup = parent;
}
return threadGroup;
}
}
这个方法效率很高,但要做兼容,避免读取到内核或者其他进程的内存。
除此之外,如果觉得实现com.android.internal.os.ProcessCpuTracker比较复杂,可以使用下面方式,读取出数据即可。
public class ThreadStat {
static final String TAG = "ThreadStat";
public static String getStat(String threadName) {
Thread thread = UnsafeThreadUtil.getThread(threadName);
if (thread == null) {
return null;
}
return getStat(thread);
}
public static String getStat(Thread thread) {
int tid = UnsafeThreadUtil.getTid(thread);
if (tid <= 0) {
return null;
}
File file = new File("/proc/self/task/" + tid + "/stat");
StringBuilder sb = new StringBuilder();
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new FileReader(file));
String line = null;
while ((line = bufferedReader.readLine()) != null) {
sb.append(line).append("\n");
}
return sb.toString();
} catch (Throwable e) {
e.printStackTrace();
}finally {
if(bufferedReader != null){
try {
bufferedReader.close();
} catch (IOException e) {
}
}
}
return null;
}
}
接下来我们就可以读取状态
String stat = ThreadStat.getStat(thread);
System.err.println(stat + "\n" + stackTrace);
当然,以上方式还可以用来获取线程的优先级,或者更多的其他业务
int renderThreadId = UnsafeThreadUtil.getTid(UnsafeThreadUtil.getThread("RenderThread"));
int threadPriority = Process.getThreadPriority(renderThreadId);
int mainThreadPriority = Process.getThreadPriority(Process.myPid());
Log.d(TAG,"renderThreadId = " + renderThreadId+", threadPriority="+threadPriority);
Log.d(TAG,"mainThreadId = " + Process.myPid()+", mainThreadPriority="+mainThreadPriority);
总结
本文主要是对死锁的问题的优化建议,至于性能问题,其实我们遵循一个原则:在保证流畅度的情况下线程越少越好。对于必要存在的线程,可以使用队列缓冲、逃逸分析、对象标量化、锁消除、锁粗化、降低锁范围、多路复用、消除同步屏障、协程的角度去优化。
对于linux线程的认知,有个很好的博客推荐给大家:www.xiaolincoding.com/