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

简介: 大家好,我是王有志。今天是Java面试中线程问题的最后一部分内容,包括我们来聊同步与互斥,线程的本质,调度,死锁以及线程的优缺点等问题。

大家好,我是王有志,欢迎和我聊技术,聊漂泊在外的生活。快来加入我们的Java提桶跑路群:共同富裕的Java人

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

  • 线程的同步与互斥

  • 线程的本质与调度

  • 死锁的产生与解决

  • 多线程的是与非

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

线程的同步与互斥

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

线程同步:

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

线程互斥:

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

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

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

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

管程的思想

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

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

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

这里不过多的涉及管程的内容,只举一个通俗的例子解释下管程的实现原理。最近大家都有好好的做核酸吧?

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

图1:通俗的管程.png

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线程≈操作系统内核线程≈操作系统轻量级进程。那么对于Java线程的调度方式来说就有:Java线程的调度方式≈操作系统进程的调度方式

恰好,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>

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

图2:线程堆栈信息.png

这个输出信息就非常明显了吧?虽然实际工作中,情况可能会更加复杂,但是大致思路是一样的:程序阻塞 -> 查看线程状态 -> 查看持有与等待情况 -> 查看问题代码

预防死锁

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

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

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

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

多线程的是与非

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

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

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

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

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

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

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

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

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

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

结语

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

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

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


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

目录
相关文章
|
3月前
线程18
线程18
39 4
|
3月前
|
监控 安全 Java
线程(一)
线程(一)
|
6月前
|
NoSQL Java 应用服务中间件
线程不够用怎么办?
### 并发编程挑战与解决方案概览 - 多线程导致线程爆炸,浪费CPU及可能导致JVM崩溃。线程池缓解问题,但仍有阻塞IO的效率低下。 - 非阻塞IO(如servlet3.1/Tomcat)和事件驱动(Reactive/Future)减少线程使用,但学习曲线陡峭。 - 轻量级线程如Netty、Spring Flux和虚拟线程(Java Loom)提升性能,但普及尚需时日。Java21引入虚拟线程,有望成未来性能关键。
232 10
|
Java C语言 Python
线程那些事
线程那些事
53 0
|
7月前
|
存储 安全 Java
C++线程浅谈
C++线程浅谈
|
算法 NoSQL Java
02.关于线程你必须知道的8个问题(上)
大家好,我是王有志,欢迎来到《Java面试都问啥?》。 今天我们来一起看看在面试中,关于线程各大公司大都喜欢问哪些问题。
110 1
02.关于线程你必须知道的8个问题(上)
|
Java
线程理解
个人学习理解
83 0
|
缓存 监控 Java
线程
多线程
113 0
线程睡眠
Thread.sleep方法会导致当前线程暂停执行一段指定的时间...