04.关于线程你必须知道的8个问题(下)

时间:2023-01-01 10:05:16

今天我们来学习线程中最后4个问题:

  • 线程的同步与互斥
  • 线程的本质与调度
  • 死锁的产生与解决
  • 多线程的是与非

通过本篇文章,你可以了解到计算机中经典的同步机制--管程Java线程的本质与调度方式,如何解决死锁问题,以及为什么要使用多线程。

线程的同步与互斥

首先来看线程同步线程互斥的概念,这里引用百度百科中的定义:

线程同步:

即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。

线程互斥:

线程互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。

线程同步关注的是线程间的执行顺序,强调线程t2必须在线程t1执行完成后执行,是串行方式

线程互斥,关注的是不同线程对共享资源的使用方式,同一时间只允许一个线程访问共享资源,在共享资源的访问上是串行方式,而其它处理过程可以并发执行。

实现同步与互斥的方式有很多,比如:互斥锁,信号量和管程。Java 1.5前只提供了基于MESA管程思想实现的synchronized。之后,提供了JUC工具包,包含信号量,互斥锁等同步工具。

管程的思想

管程是由Hoare和Hansen提出的,最早用于解决操作系统进程间同步问题。Hansen首次在Pascal上实现了管程,Hoare证明了管程与信号量是等价的

管程的发展历史上,先后出现了3种管程模型:

  • Hansen管程,Hansen提出;
  • Hoare管程,Hoare提出;
  • MESA管程,施乐公司在MESA语言中实现。

这里不过多的涉及管程的内容,只举一个通俗的例子解释下管程的实现原理。

最近大家都有好好的做核酸吧?

首先,大家(线程)从四面八方赶到核酸亭(并发执行),随后进入排队区(入口队列,串行执行),紧接着是身份识别(检查条件变量),最后进行核酸监测(操作共享变量),当下一个人看到你完成了核酸监测后,开始进行核酸检测(唤醒)。

04.关于线程你必须知道的8个问题(下)

Java中synchronized的底层正是借鉴了MESA管程的实现思想。应用层面,使用synchronizedObject.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. 代码1和代码2处添加了synchronized,保证只有持有对应锁的线程可以进入,这是互斥条件,锁只能被一个线程持有
  2. 代码1处持有锁不释放,并且在代码2处请求锁,这是保持和请求条件,保持自己的锁,并请求其它的锁
  3. 线程lock-a-thread和线程lock-b-thread只是在那里不断请求,并没有谁要求其它线程放弃,这是不剥夺条件,不抢夺其它线程已获取的锁,只能由其主动释放
  4. 线程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>

得到大致如下的信息(省略了非常多):

04.关于线程你必须知道的8个问题(下)

这个输出信息就非常明显了吧?虽然实际工作中,情况可能会更加复杂,但是大致思路是一样的:

程序阻塞 -> 查看线程状态 -> 查看持有与等待情况 -> 查看问题代码

预防死锁

通常快速定位解决死锁问题,会在程序员中获得“技术大牛”的称赞,但质量效能部门会记一个大大的事故。为了避免这种情况,我们还是要多做预防工作。

首先是尽量避免使用多个锁,避免这种持有与请求的情况发生,如果必须要用多个锁,请保证多个锁的使用至少满足以下一种:

  • 线程按照特定顺序获取锁
  • 为每把锁添加超时时间,当然synchronized是没办法做到的。

另外也可以借助工具在上线前发现死锁问题,比如:FindBugs™

多线程的是与非

使用多线程的目的是什么?

无论是说多核处理器时代不用多线程就是浪费资源,还是说程序既要处理数据,又有IO操作,多线程可以在IO期间处理数据保证CPU的利用率,归根结底就是要提速

通常意义上,多线程确实会快于单线程。

PS:《Java并发编程的艺术》中在章节“1.1.1 多线程一定快吗”给出了一个反例。我提供了这本书的电子版,有兴趣的可以去阅读。

我经常会和小伙伴聊到,引入一种技术,有利就会有弊,无论是技术选型还是架构设计,都是一门权衡的艺术。

那么引入多线程会带来什么问题?

显而易见的是编程难度的提升,人的思维是线性的,因此编程过程中也总是倾向于线性处理流程,在程序中编写代码的难度可想而知。

另外,《Java并发编程的艺术》中提到了上下文切换,死锁,以及资源限制的问题,这些大家都耳熟能详了,就不过多赘述了。

以上的问题我们都有解决办法或者可以忽略,并发编程中最大的挑战其实是线程安全问题带来的数据错误,比如,前公司的同事曾经使用了有状态的Spring单例Bean。

最后是额外的一点,如无必要,勿增实体,在可预见的未来(大约3年),如果业务发展并没有使用多线程的必要,那就遵循奥卡姆剃刀原理,选择最简单的解决方案。

结语

今天的内容其实都可以在操作系统的发展史中找到它们的影子,与其说是线程的问题不如说是多任务处理的问题。

文章中涉及到了一些操作系统的内容,尤其是在线程的同步与互斥线程的本质与调度中,最早写了3种管程模型,但写完发现文章奔着上万字去了,于是就删掉了这部分内容,尽量做到简短准确的表达。

关于线程的问题到这里就告一段落了,希望这3篇文章能够给你带来帮助。接下来我们从synchronizedvolatilefinal开始。


好了,今天就到这里了,Bye~~