今天我们来学习线程中最后4个问题:
- 线程的同步与互斥
- 线程的本质与调度
- 死锁的产生与解决
- 多线程的是与非
通过本篇文章,你可以了解到计算机中经典的同步机制--管程,Java线程的本质与调度方式,如何解决死锁问题,以及为什么要使用多线程。
线程的同步与互斥
首先来看线程同步与线程互斥的概念,这里引用百度百科中的定义:
线程同步:
即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。
线程互斥:
线程互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
线程同步关注的是线程间的执行顺序,强调线程t2必须在线程t1执行完成后执行,是串行方式。
线程互斥,关注的是不同线程对共享资源的使用方式,同一时间只允许一个线程访问共享资源,在共享资源的访问上是串行方式,而其它处理过程可以并发执行。
实现同步与互斥的方式有很多,比如:互斥锁,信号量和管程。Java 1.5前只提供了基于MESA管程思想实现的synchronized
。之后,提供了JUC工具包,包含信号量,互斥锁等同步工具。
管程的思想
管程是由Hoare和Hansen提出的,最早用于解决操作系统进程间同步问题。Hansen首次在Pascal上实现了管程,Hoare证明了管程与信号量是等价的。
管程的发展历史上,先后出现了3种管程模型:
- Hansen管程,Hansen提出;
- Hoare管程,Hoare提出;
- MESA管程,施乐公司在MESA语言中实现。
这里不过多的涉及管程的内容,只举一个通俗的例子解释下管程的实现原理。
最近大家都有好好的做核酸吧?
首先,大家(线程)从四面八方赶到核酸亭(并发执行),随后进入排队区(入口队列,串行执行),紧接着是身份识别(检查条件变量),最后进行核酸监测(操作共享变量),当下一个人看到你完成了核酸监测后,开始进行核酸检测(唤醒)。
Java中synchronized
的底层正是借鉴了MESA管程的实现思想。应用层面,使用synchronized
和Object.wait
方法,来实现的同步机制也是管程的实现。这些会在synchronized
的部分中详细解释。
线程的本质与调度
关于线程你必须知道的8个问题(中)中,我们看到了thread.cpp创建操作系统层面的线程,不过碍于篇幅没有继续往下追,今天我们来看下os_linux.cpp中是如何创建线程的:
bool os::create_thread(Thread* thread, ThreadType thr_type, size_t req_stack_size) {
int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
return true;
}
可以看到,是通过调用pthread_create
来创建线程的,该方法是Linux的thread.h
库中创建线程的方法,用来创建操作Linux的线程。
到这里你可能会有疑问,或者听到过这样的问题,Java的线程是用户线程还是内核线程?
早期Linux并不支持线程,但可以通过编程语言模拟实现“线程”,本质还是调用进程,这时创建的线程就是用户线程。
2003年RedHat初步完成了NPTL(Native POSIX Thread Library)项目,通过轻量级进程实现了符合POSIX标准的线程,这时创建的线程就是内核线程。
因此,如果不是跑在古董服务器上的项目的话,使用的Java线程都会映射到一个内核线程上。
好了,你已经知道现代Java线程的本质是操作系统的内核线程,并且也知道了操作系统内核线程是通过轻量级进程实现的。所以,我们可以得到:
$Java线程\approx操作系统内核线程\approx操作系统轻量级进程$
那么对于Java线程的调度方式来说就有:
$Java线程的调度方式\approx操作系统进程的调度方式$
恰好,Linux中使用了抢占式进程调度方式。因此,并不是JVM中实现了抢占式线程调度方式,而是Java使用了Linux的进程调度方式,Linux选择了抢占式进程调度方式。
死锁的产生与解决
我们随便写个例子:
public static void main(String[] args) {
String lock_a = "lock-a";
String lock_b = "lock-b";
ShareData lock_a_shareData = new ShareData(lock_a, lock_b);
ShareData lock_b_shareData = new ShareData(lock_b, lock_a);
new Thread(lock_a_shareData, "lock-a-thread").start();
new Thread(lock_b_shareData, "lock-b-thread").start();
}
static class ShareData implements Runnable {
private final String holdLock;
private final String requestLock;
public ShareData(String holdLock, String requestLock) {
this.holdLock = holdLock;
this.requestLock = requestLock;
}
@SneakyThrows
@Override
public void run() {
synchronized (holdLock) { // 1
System.out.println("线程:" + Thread.currentThread().getName() + ",持有:" + this.holdLock + ",尝试获取:" + this.requestLock);
TimeUnit.SECONDS.sleep(3);
synchronized (requestLock) { // 2
System.out.println("成功获取!");
}
}
}
}
lock_a_shareData
持有lock_a
,尝试请求lock_b
,相反的lock_b_shareData
持有lock_b
,尝试请求lock_a
,在它们互相都不放手的情况下,谁也无法请求成功,因此双双阻塞在那里。
通过上面的例子我们可以总结出死锁产生的4个条件:
- 代码1和代码2处添加了
synchronized
,保证只有持有对应锁的线程可以进入,这是互斥条件,锁只能被一个线程持有; - 代码1处持有锁不释放,并且在代码2处请求锁,这是保持和请求条件,保持自己的锁,并请求其它的锁;
- 线程
lock-a-thread
和线程lock-b-thread
只是在那里不断请求,并没有谁要求其它线程放弃,这是不剥夺条件,不抢夺其它线程已获取的锁,只能由其主动释放; - 线程
lock-a-thread
和线程lock-b-thread
的持有与互相请求锁形成了一个环路,这是循环等待条件,多个线程间的资源请求形成了环路。
知道了死锁产生的条件,那么解决的办法也就显而易见了。首先互斥条件是无法被打破的,因为本身的目的就是在此处形成互斥,避免并发造成的“意外”。
那么我们可以尝试打破剩余的3个条件:
- 通过一次性申请所有资源来打破保持和请求条件,增加Admin角色去统一管理资源的申请和释放;
- 通过主动释放资源来打破不剥夺条件,既然不能主动抢,那主动释放总归是可以的吧?
- 通过按照资源顺序申请来打破循环等待条件,每个资源由小到大依次编号,只有申请到编号较小的资源后才可以申请编号较大的资源。
Java中定位死锁
涉及到多线程的问题,往往具有难排查的特点,不过好在我们可以借助Java提供的工具。
首先是通过jps,ps或者它工具确定Java程序的进程ID:
# Linux平台
jps -1
# window平台
.\jps
然后通过jstack查看线程的堆栈信息,确定“事故”:
# Linux平台
jstack <进程ID>
# window平台
.\stack <进程ID>
得到大致如下的信息(省略了非常多):
这个输出信息就非常明显了吧?虽然实际工作中,情况可能会更加复杂,但是大致思路是一样的:
程序阻塞 -> 查看线程状态 -> 查看持有与等待情况 -> 查看问题代码
预防死锁
通常快速定位解决死锁问题,会在程序员中获得“技术大牛”的称赞,但质量效能部门会记一个大大的事故。为了避免这种情况,我们还是要多做预防工作。
首先是尽量避免使用多个锁,避免这种持有与请求的情况发生,如果必须要用多个锁,请保证多个锁的使用至少满足以下一种:
- 线程按照特定顺序获取锁;
-
为每把锁添加超时时间,当然
synchronized
是没办法做到的。
另外也可以借助工具在上线前发现死锁问题,比如:FindBugs™ 。
多线程的是与非
使用多线程的目的是什么?
无论是说多核处理器时代不用多线程就是浪费资源,还是说程序既要处理数据,又有IO操作,多线程可以在IO期间处理数据保证CPU的利用率,归根结底就是要提速。
通常意义上,多线程确实会快于单线程。
PS:《Java并发编程的艺术》中在章节“1.1.1 多线程一定快吗”给出了一个反例。我提供了这本书的电子版,有兴趣的可以去阅读。
我经常会和小伙伴聊到,引入一种技术,有利就会有弊,无论是技术选型还是架构设计,都是一门权衡的艺术。
那么引入多线程会带来什么问题?
显而易见的是编程难度的提升,人的思维是线性的,因此编程过程中也总是倾向于线性处理流程,在程序中编写代码的难度可想而知。
另外,《Java并发编程的艺术》中提到了上下文切换,死锁,以及资源限制的问题,这些大家都耳熟能详了,就不过多赘述了。
以上的问题我们都有解决办法或者可以忽略,并发编程中最大的挑战其实是线程安全问题带来的数据错误,比如,前公司的同事曾经使用了有状态的Spring单例Bean。
最后是额外的一点,如无必要,勿增实体,在可预见的未来(大约3年),如果业务发展并没有使用多线程的必要,那就遵循奥卡姆剃刀原理,选择最简单的解决方案。
结语
今天的内容其实都可以在操作系统的发展史中找到它们的影子,与其说是线程的问题不如说是多任务处理的问题。
文章中涉及到了一些操作系统的内容,尤其是在线程的同步与互斥和线程的本质与调度中,最早写了3种管程模型,但写完发现文章奔着上万字去了,于是就删掉了这部分内容,尽量做到简短准确的表达。
关于线程的问题到这里就告一段落了,希望这3篇文章能够给你带来帮助。接下来我们从synchronized
,volatile
和final
开始。
好了,今天就到这里了,Bye~~