携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
大家应该都知道LinkedList 是非线程安全的。大家有没有考虑过非线程安全对我们程序有什么影响?今天就带大家来见证下其巨大威力。
通过这篇文章,你将掌握如下知识点:
- LinkedList 为什么是线程不安全的。
- LinkedList 使用不当带来的巨大影响有哪些。
背景
背景是这样的,我们公司后台功耗监控系统偶尔会报我们进程的功耗异常,通过查看CPU运行指标数据,发现是单核cpu几乎占用100%资源,如下图所示:
而始作俑者是我们进程的一个for(混淆后的名称) 线程持续占用cpu资源导致的问题,若是在主线程执行那肯定早就ANR了。
注意:像这种占用100% 的情况,且长时间不释放资源的情况,几乎100%是进入死循环了(若有其他情况,欢迎大家评论区留言讨论)。正常的IO操作导致cpu使用率达到50%左右都算很高了。
接下来我们逐步分析问题场景。
本地复现过程
本地复现大概经历了如下3个过程:
- 开始通过人工操作尝试复现,很遗憾,一次都未能复现。
- 开发同学通过测试用例对相关场景进行暴力压力测试。 很遗憾,此时仍未复现。
- 仍然是暴力压测,但是同时开启多线程验证。最终在执行200多次后复现问题。
这里先上结论:最后定位到就是因为 LinkedList 使用不当导致程序进入死循环导致的功耗问题。
接下来我们逐步探究其发生的本质原因。
队列消费模型
最常见的队列消费模型就是有一个生产者不断生产任务,然后加入到队列中,最后通过消费线程去消费队列中的任务。如下图所示:
- 任务请求首先会加入到队列。
- 消费线程取出请求任务进行消费。
本次问题出现就跟该生产消费模型有关:在Thread 读取线程中有个while 循环消费队列中全部的任务, 因为LinkedList 本身是非线程安全的,然后业务程序代码中又未对offer 和 poll 操作加锁限制,最后导致 isEmpty() 方法判断失效,从而产生死循环。
部分异常业务代码如下:
public synchronized void onReport(OnlineMsg msg) {
if (null == msg || !msg.isValid()) {
return;
}
mSaveMsgsPending.offer(msg); -------@1
Message message = Message.obtain();
message.what = MSG_SAVE;
runMessage(message);
}
@Override
public void handleMessage(Message msg) {
if (null == msg) {
return;
}
switch (msg.what) {
case MSG_SAVE:
handleSave(true);
break;
...
...
...
}
public void handleSave(boolean isSend) {
while (!mSaveMsgsPending.isEmpty()) {---------@2
OnlineMsg msg = mSaveMsgsPending.poll(); --------@3
...
...
...
}
}
其思路如下:
@1 :在onReport() 方法中将消息加入到队列。注意,onReport 方法是添加了 synchronized 锁的。
@2 :队列若不为空,则循环取出首个元素,进行消费。
@3 : 取出首个元素,进行对应的业务处理。
作者不会以为onReport 加上 synchronized 同步锁,然后其他的操作都通过 HandlerThread 来执行就线程安全了吧。这里显然对锁理解有误。
接下来我们来具体分析下产生问题的本质原因。
源码分析
问题的本质当然就是LinkedList 非线程安全导致的呗,那我们就要搞清楚为什么非线程安全呢?只有追根究底才会有所收获哈。
LinkedList 的offer 和 poll 源码如下:
public boolean offer(E e) {
return add(e);
}
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last; ---@1
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode; ---@5
size++; ---@6
modCount++;
}
public E poll() {
final Node<E> f = first;
return (f == null) ? null : unlinkFirst(f);
}
private E unlinkFirst(Node<E> f) {
// assert f == first && f != null;
final E element = f.item;
final Node<E> next = f.next;
f.item = null;
f.next = null; // help GC
first = next; ---@2
if (next == null)
last = null; ---@3
else
next.prev = null;
size--; ---@4
modCount++;
return element;
}
public boolean isEmpty() {
return size() == 0;
}
为了便于分析,我将代码中的 @ 序号整理成对应的内存快照,为了方便演示,我们假设当前队列中仅有一个元素,如下图所示:
此处我们假设添加元素在A线程执行,删除元素在D线程执行。
初始状态:当前队列中仅有一个元素,first 和 last 指针都指向该元素。
@1: A线程执行完 final Node l = last; 此时局部变量 l 也指向Frist 节点。
@2: 同一时刻,因为线程切换,开始执行B线程的删除节点操作。此时因为是最后一个元素了,所以next 此时必为null, 所以执行完 first = next;first成员变量必为null。
@3: 因为next 变量为null,所以last 指向 next 后也变为null。
@4: size-- 执行完后,size 大小也由1 变成 0 了。
@5:因为 l 成员变量此时还不为null,仍然指向第一个元素的内存地址。所以会执行 l.next = newNode;(注意这里新节点内存不一定是和首节点内存连续的,这里只是为了方便演示而已)
@6:size++ 后队列大小变成1, 但是first 指针此时却为空了。
执行完@6 后, handleSave() 方法中的while 代码就进入死循环了:因为size != 0, 所以 isEmpty() 返回false, poll 方法中判断 first 为null, 则直接返回 null, 所以永远走不出该循环了。
解决方案
解决方案也比较简单,有3种方式,
- 给业务方法加锁,比如上面的onReport() 方法 和 handleSave() 方法。
- 直接使用支持同步的数据结构,比如 ConcurrentLinkedQueue 。
- 不使用poll() 方法,而是使用pop() 方法,该方法在first 为null 时会抛出异常,使得业务终止。
所以前两种方法都是通过添加锁的方式保证队列操作的安全性和数据完整性,第三个方法是从使用方法上使其发生异常后能够感知并进行对应的处理。这里优先建议用同步锁的形式去优化,因为业务中还有其他的方法中也有对该list的操作,所以我选择了对整个方案2,直接使用多线程安全的队列来解决问题。
总结
OK,我们最后回归最开始的2个问题:
-
LinkedList 为什么是线程不安全的。
上面LinkedList 源码分析的时候已经解答过了
-
LinkedList 使用不当带来的巨大影响有哪些。
- 功耗问题。如示例中持续占用cpu导致功耗问题
- ANR问题。上述代码若是在主线程中执行,则直接就ANR给你看。
所以项目中使用LinkedList 等非线程安全的集合时,需要提高警惕,尤其是遍历所有元素时需要时刻小心,大家赶快检查自己项目中代码是否有类似的问题吧。