Java 并发编程解析 | 如何正确Java领域中的线程机制,我们应该知道的那些事?

发布于:2023-01-13 ⋅ 阅读:(513) ⋅ 点赞:(0)

我们都知道,经过多年的发展和无数Java开发者的不懈努力,Java已经由一门单纯的计算机编程语言,逐渐演变成一套强大的以及仍在可持续发展中的技术体系平台。

虽然,Java设计者们根据不同的技术规范,把Java划分为3种结构独立且又彼此依赖的技术体系,分别是Java SE,Java EE 以及Java ME,其中Java EE 在广泛应用在企业级开发领域中。

除了包括Java API组件外,其衍生和扩充了Web组件,事务组件,分布式组件,EJB组件,消息组件等,并且持续发展到如今,其中,虽然有许多组件现如今不再适用,但是许多组件在我们日常开发工作中,扮演着同样重要的角色和依旧服务着我们日新月异的业务需求。

综合Java EE的这些技术,我们可以根据我们的实际需要和满足我们的业务需求的情况下,可以快速构建出一个具备高性能,结构严谨且相对稳定的应用平台,虽然现在云原生时代异军突起许多基于非Java的其他技术平台,但是在分布式时代,Java EE是用于构建SOA架构的首先平台,甚至基于SpringCloud构建微服务应用平台也离不开Java EE 的支撑。

个人觉得,Java的持续发展需要感谢Google,正是起初Google将Java作为Android操作系统的应用层编程语言,使得Java可以在PC时代和移动互联网时代得到快速发展,可以用于手持设备,嵌入式设备,个人PC设备,高性能的集群服务器和大型机器平台。

当然,Java的发展也不是一帆风顺的,也曾被许多开发者诟病和嫌弃,但是就凭Java在行业里能否覆盖的场景来说,对于它的友好性和包容性,这不由让我们心怀敬意。其中,除了Java有丰富的内置API供我们使用外,尤其Java对于并发编程的支持,也是我们最难以释怀的,甚至是我们作为Java开发者最头疼的问题所在。

虽然,并发编程这个技术领域已经发展了半个世纪了,相关的理论和技术纷繁复杂。那有没有一种核心技术可以很方便地解决我们的并发问题呢?今天,我们就来一起走进Java领域的并发编程的核心——Java线程机制。

基本概述

在Java中,对于Java语言层面的线程,我们基本都不会太陌生,甚至耳熟能详。但是在此之前,我们先来探讨一下,什么是管程技术?Java 语言在 1.5 之前,提供的唯一的并发原语就是管程,而且 1.5 之后提供的 SDK 并发包,也是以管程技术为基础的。除此之外,其中C/C++、C# 等高级语言也都支持管程。

关于管程

 管程(Monitor)是指定义了一个数据结构和能为并发进程所执行的一组操作,这组操作能同步进程和改变管程中的数据。主要是指提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

基本定义

首先,系统中的各种硬件资源和软件资源均可用数据结构抽象地描述其资源特性,即用少量信息和对该资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。

其次,可以利用共享数据结构抽象地表示系统中的共享资源,并且将对该共享数据结构实施的特定操作定义为一组过程。进程对共享资源的申请、释放和其它操作必须通过这组过程,间接地对共享数据结构实现操作。

然后,对于请求访问共享资源的诸多并发进程,可以根据资源的情况接受或阻塞,确保每次仅有一个进程进入管程,执行这组过程,使用共享资源,达到对共享资源所有访问的统一管理,有效地实现进程互斥。

最后,代表共享资源的数据结构以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序共同构成了一个操作系统的资源管理模块,我们称之为管程,管程被请求和释放资源的进程所调用。

综上所述,管程(Monitor)是指定义了一个数据结构和能为并发进程所执行的一组操作,这组操作能同步进程和改变管程中的数据。主要是指提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。

基本组成

 

由上述的定义可知,管程由四部分组成:

  • 管程的名称;
  • 局部于管程的共享数据结构说明;
  • 对该数据结构进行操作的一组过程;
  • 对局部于管程的共享数据设置初始值的语句

实际上,管程中包含了面向对象的思想,它将表征共享资源的数据结构及其对数据结构操作的一组过程,包括同步机制,都集中并封装在一个对象内部,隐藏了实现细节。

封装于管程内部的数据结构仅能被封装于管程内部的过程所访问,任何管程外的过程都不能访问它;反之,封装于管程内部的过程也仅能访问管程内的数据结构。

所有进程要访问临界资源时,都只能通过管程间接访问,而管程每次只准许一个进程进入管程,执行管程内的过程,从而实现了进程互斥。

基本特点

 

管程是一种程序设计语言的结构成分,它和信号量有同等的表达能力,从语言的角度看,管程主要有以下特点:

  • 模块化,即管程是一个基本程序单位,可以单独编译;
  • 抽象数据类型,指管程中不仅有数据,而且有对数据的操作;
  • 信息屏蔽,指管程中的数据结构只能被管程中的过程访问,这些过程也是在管程内部定义的,供管程外的进程调用,而管程中的数据结构以及过程(函数)的具体实现外部不可见。

基本模型

 

在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。

接下来,我们就针对几种管程模型分别来简单的说明一下,它们之间的区别。

假设有这样一个进程同步机制中的问题:如果进程P1因x条件处于阻塞状态,那么当进程P2执行了x.signal操作唤醒P1后,进程P1和P2此时同时处于管程中了,这是不被允许的,那么如何确定哪个执行哪个等待?

一般来说,我们都会采用如下两种方式来进行处理:

  • 第一种方式:假如进程 P2进行等待,直至进程P1离开管程或者等待另一个条件
  • 第二种方式:假如进程 P1进行等待,直至进程P2离开管程或者等待另一个条件

综上所述,三种不同的管程模型采取的方式如下:

1.Hasen 模型

Hansan管程模型,采用了基于两种的折中处理。主要是规定管程中的所有过程执行的signal操作是过程体的最后一个操作,于是,进程P2执行完signal操作后立即退出管程,因此进程P1马上被恢复执行。

2.Hoare 模型

 

Hoare 管程模型,采用第一种方式处理。只要进程 P2进行等待,直至进程P1离开管程或者等待。

3.MESA 模型

MESA 管程模型,采用第二种方式处理。只要进程 P1进行等待,直至进程P2离开管程或者等待。

基本实现

在并发编程领域,有两大核心问题:互斥和同步。其中:

  • 互斥(Mutual Exclusion),即同一时刻只允许一个线程访问共享资源
  • 同步(Synchronization),即线程之间如何通信、协作

这两大问题,管程都是能够解决的。主要是由于信号量机制是一种进程同步机制,但每个要访问临界资源的进程都必须自备同步操作wait(S)和signal(S)。

这样大量同步操作分散到各个进程中,可能会导致系统管理问题和死锁,在解决上述问题的过程中,便产生了新的进程同步工具——管程。其中:

  • 信号量(Semaphere):操作系统提供的一种协调共享资源访问的方法。和用软件实现的同步比较,软件同步是平等线程间的的一种同步协商机制,不能保证原子性。而信号量则由操作系统进行管理,地位高于进程,操作系统保证信号量的原子性。

  • 管程(Monitor):解决信号量在临界区的 PV 操作上的配对的麻烦,把配对的 PV 操作集中在一起,生成的一种并发编程方法。其中使用了条件变量这种同步机制。

综上所述,这也是Java中,最常见的锁机制的实现方案,即最典型的实现就是ReenTrantLock为互斥锁(Mutex Lock) 和synchronized 为同步锁(Synchronization Lock)。

具体表现

熟悉Java中synchronized 关键词的都应该知道,它是Java语言为开发者提供的同步工具,主要用来解决多线程并发执行过程中数据同步的问题,主要有wait()、notify()、notifyAll() 这三个方法。其中,最关键的实现是,当我们在代码中声明synchronized 之后,其被声明部分代码编译之后会生成一对monitorenter和monitorexit指令来指定某个同步块。

在JVM执行指令过程中,一般当遇到monitorenter指令表示获取互斥锁时,而当遇到monitorexit指令表示要释放互斥锁,这就是synchronized在Java层面实现同步机制的过程。除此之外,如果是获取锁失败,则会将当前线程放入到阻塞读队列中,当其他线程释放锁时,再通知阻塞读队列中的线程去获取锁。

由此可见,我们可以知道的是,synchronized 代码块是由一对 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。

准确的说,JVM一般通过Monitor来实现monitorenter和monitorexit指令,而且Monitor 对象包括一个阻塞队列和一个等待队列。其中,阻塞队列用来保存锁竞争失败的线程,并且它处于阻塞状态,而等待队列则用来保存synchronized 代码块中调用wait方法后放置的队列,其调用wait方法后会通知阻塞队列。

当然,在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。

这并不意味着,Java是提供信号量这种编程原语来支持解决并发问题的,虽然在《操作系统原理》中,我们知道用信号量能解决所有并发问题,但是在Java中并不是这样的。

其实,最根本的原因,就是Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。

特别指出的是,相对于synchronized来说,ReentrantLock主要有以下几个特点:

  • 从锁获取粒度上来看,比synchronized较为细,主要表现在是锁的持有是以线程为单位而不是基于调用次数。
  • 从线程公平性上来看,ReentrantLock 可以设置公平性(fairness),能减少线程“饥饿”的发生。
  • 从使用角度上来看,ReentrantLock 可以像普通对象一样使用,所以可以利用其提供的各种便利方法,进行精细的同步操作,甚至是实现 synchronized 难以表达的用例。
  • 从性能角度上来看,synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大。虽然在 Java 6之后 中对其进行了非常多的改进,但在高竞争情况下,ReentrantLock 仍然有一定优势。

综上所述,我我相信你对Java中的管程技术已经有了一个明确的认识。接下来,我们便来进入今天的主题——Java线程机制。


 

关于线程

在早期的操作系统中,执行任务被抽象为进程(Process)。其中,进程是操作系统运行和调度的基本单元。

随着计算机技术的不断发展,由于进程开销资源较大,以进程为调度单位的方式逐渐产生弊端。因此,计算机先进工作者(科学家)们在进程的基础上,提出了线程(Thead)的概念。

线程是进程中的运行单位,可以把线程看作轻量级的进程。计算机CPU会按照某种策略为每一个线程分配一定的时间片去执行。

进程是指程序的一次动态执行过程,计算机中正在执行的程序就是进程,每一个程序都对对应着各自的一个进程。

一个进程包含了从代码加载完毕到执行完成的一个完成过程,是操作系统中资源分配的最小单位。

线程是比进程更小的执行单元,是计算机CPU调度和分配的基本单位。

每一个进程都会至少包含一个线程,而一个线程只属于一个进程。

每一个进程都有自己的资源,一个进程内的所有线程都共享这个进程所包含的资源。

每一个线程可以对所属进程的所有资源进行调度和运算,其中,线程可以是操作系统内核来控制调度,也可以是由用户程序来控制调度。

基本定义

 

现代计算机,从组成部分上来看,大体可以分为硬件和软件两个部分。硬件是基础,而软件是运行在硬件之上的程序。

其中,软件可以分为操作系统和应用程序:

  • 操作系统(Operation System):专注于对硬件的支持和交互管理并提供一个运行环境给应用程序使用
  • 应用程序(Application Program):能实现若干功能且运行在操作系统中的软件

由于线程可以由操作系统内核和用户程序来控制调度,因此按照操作系统和应用程序两个层次来分类。

线程可以主要分为内核线程和 用户线程(应用线程)两类,其中:

  • 内核线程(Kernel Thread):由操作系统内核支持和管理的线程,内核线程的创建,启动,同步,销毁,切换等均由操作系统完成。
  • 用户(应用线程,Applciation Thread)线程(User Thread) :用户(应用)线程的管理工作在用户(应用)空间完成,它完全建立在用户(应用)空间的线程库上,由内核支持但不由内核管理,内核也无法感知用户线程的存在。用户(应用)线程的创建,启动,同步,销毁,切换等均在在用户(应用)空间完成,不用切换到内核。

从Java领域来看,Java语言编译后的字节码(Byte Code) 运行在JVM (Java 虚拟机)上,其中JVM其实是一个进程,所以Java属于应用程序层。

我们都知道,Java的线程类为:java.lang.Thread,当任务不能在当前线程中执行时,我们就会去创建一个Thread对象。

我们在Java层通过new 关键字创建一个Thread对象,然后调用start()方法启动该线程,那么从线程的角度来看,主要可以分为:

  • Java应用程序层线程(Java Application Thread ):主要是Java语言编程的程序创建的Thread线程对象,属于用户空间
  • Java虚拟机层线程(Java JVM Thread ):主要是Java虚拟机中包含且支持和管理的线程,属于用户空间,
  • 操作系统层线程(OS Thread):根据操作系统的实际情况而定的抽象表示,主要是看操作系统和库是否支持和管理的线程,一般Linux主要通过pthread库来实现,早期版本不支持。

其中,在Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。

Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。

当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。

一般在Hotspot JVM 后台运行的系统线程主要有下面几方面:

  • 虚拟机线程(VM thread):这个线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有:stop-theworld
  • 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。
  • 周期性任务线程: 这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。
  • GC 线程: 这些线程支持 JVM 中不同的垃圾回收活动。
  • 编译器线程: 这些线程在运行时将字节码动态编译成本地平台相关的机器码。
  • 信号分发线程: 这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。

由此可见,Java层到内层层的线程创建的大致流程:java.lang.Thread(Java应用程序层)—>Java Thread(JVM 层)->OS Thread(操作系统层)->pthread(根据操作系统的情况而定)->内核线程(Kernel Thread)。

 

基本模型

由于Java 中,JVM主要是由C/C++实现,所以Java层线程最终还是会映射到JVM层线程,而Java层的线程到操作系统层线程就得需要看具体的JVM的具体实现来决定。

一般来说,我们都把用户线程看作更高层面的线程,而内核线程则向用户线程提供支持。

由此可见,用户线程和内核线程之间必然存在一定的映射关系,不同的操作系统可能采取不同的映射方式。

一般来说,按照映射方式来看,主要可以分为:多对一映射(用户级方式),一对一映射(内核级方式) 和多对多映射(组合方式)3种方式。其中:

1. 多对一映射(用户级方式)

 多对一映射是指多个用户线程被映射到一个内核线程上。每一个进程都对应着一个内核线程,进程内的所有线程也都对应着该内核线程。

多对一映射模型是指多条用户线程映射同一条内核线程的情况,其中用户线程由库调度器进行调度,而内核线程由操作系统调度器来完成。

对于用户线程而言,其会按照一定的策略轮流执行,具体的调度算法有库调度器完成。

任意一个时刻每一个进程中都只有一个用户线程被执行,它们的执行都由用户态的代码完成切换。

在不支持线程的操作系统中有库来实现线程控制,用户线程创建,销毁,切换的开销代价比内核线程小。

因此,这种模式特点主要有两点:

  • 首先,可以节省内核态到用户态切换的开销
  • 其次,线程的数量不会受到内核线程的限制

但是,因为线程切换的工作是由用户态的代码完成的,所以一个进程内,如果当一条线程发生阻塞时,与该内核线程对应的进程内的其他所有的用户线程也会一起陷入阻塞。

 

. 一对一映射(内核级方式)

一对一映射是指每个用户线程都会被影射到一个内核线程上,用户的整个生命周期都绑定到所映射的内核线程上。一个进程内可以有一个用户线程和至少一个用户线程,都对应着各自一个和至少一个内核线程,进程内的所有线程也都一一对应着各自内核线程。

 

一对一映射模型是指一条用户线程对应着内核中的一条线程的情况,其中用户线程由库调度器进行调度,而内核线程由操作系统调度器来完成,而Java中采用的就是这种模型。

在这种方式下,多个CPU能并行执行同一个进程内的多个线程。

如果进程内的某个线程被阻塞,就可以切换到该进程的其他线程继续执行,并且能切换执行其他进程的线程。

一对一映射模型是真正意义上的并行执行,因为这种模型下,创建一条Java的Thread线程是真正的在内核中创建并映射了一条内核线程的,执行过程中,一条线程不会因为另外一条线程的原因而发生阻塞等情况。

不过因为是每一个用线程都需要对应一个内核线程,这种直接映射内核线程的模式,所以数量会存在上限。

并且同一个核心中,多条线程的执行需要频繁的发生上下文切换以及内核态与用户态之间的切换,所以如果线程数量过多,切换过于频繁会导致线程执行效率下降。

3. 多对多映射(组合方式)

 

多对多映射是指将一对一映射(内核级方式)和多对一映射(用户级方式)组合起来,通过综合两者优点来形成的一种映射方式。该方式在用户空间创建,销毁,切换,调度线程,但是进程中的多个用户线程会被影射到若干个内核线程上。

多对多映射模型就可以避免上面一对一映射模型和多对一映射模型带来的弊端,也就是多条用户线程映射多条内核线程,这样即可以避免一对一映射模型的切换效率问题和数量限制问题,也可以避免多对一映射模型的阻塞问题。

每一个内核线程负责与之绑定的若干用户线程,进程中的某个线程发生系统阻塞并不会导致整个进程阻塞,而阻塞该内核线程内的所对应的若干用户线程,其他线程依旧可以照常执行。

同时,因为用户线程数量比内核线程数量多,所以能有效减少内核线程开销。

基本实现

 

在java中,Java官方提供了三种方式来帮助我们实现一个线程,其中:

其中,Java在ThreadPoolExecutor 已经提供了以下 4 种策略:

同时, Java 在 1.6 版本还增加了 allowCoreThreadTimeOut(boolean value) 方法,表示可以让所有线程都支持超时。

调度方式

 

  • 第一种方式:继承 Thread 对象:extends Thread
    // 自定义线程对象
    class ApplicationThread extends Thread { 
        public void run() { 
        // 线程需要执行的代码 
        ...... 
        }
    }

  • 其中,Thread 类本质上是实现了Runnable 接口的一个实例,代表一个线程的实例。启动线程的唯一方 法就是通过Thread 类的start()实例方法。start()方法是一个native 方法,它将启动一个新线 程,并执行run()方法。

  • 第二种方式:实现 Runnable 接口(无返回值):implements Runnable

  •  

    // 实现Runnable接口
    class ApplicationThread implements Runnable {
        @Override 
        public void run() { 
        // 线程需要执行的代码 
        ......
        }
    }
    
     

    其中,如果自己的类已经extends 另一个类,就无法直接extends Thread,此时,可以实现一个Runnable 接口。

  • 第三种方式:实现Callable 接口(有返回值):implements Callable
  • // 实现Runnable接口
    class ApplicationThread implements Callable {
        @Override 
        public void run() { 
        // 线程需要执行的代码 
        ......
        }
    }
    
     

  • 其中,执行Callable 任务后,可以获取一个Future 的对象,在该对象上调用get 就可以获取到Callable 任务返回的Object对象。

  • 第四种方式:基于线程池方式创建:线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销 毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池。
  • Java 里面线程池的顶级接口是Executor,但是严格意义上讲Executor 并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService。

    Java主要提供了newCachedThreadPool,newFixedThreadPool,newScheduledThreadPool以及newSingleThreadExecutor 等4种线程池。

    目前业界线程池的设计,普遍采用的都是生产者 - 消费者模式。线程池的使用方是生产者,线程池本身是消费者。

    Java 并发包里提供的线程池,比较强大且复杂。Java 提供的线程池相关的工具类中,最核心的是 ThreadPoolExecutor,通过名字你也能看出来,它强调的是 Executor,而不是一般意义上的池化资源。

     

    ThreadPoolExecutor(
      int corePoolSize,
      int maximumPoolSize,
      long keepAliveTime,
      TimeUnit unit,
      BlockingQueue<Runnable> workQueue,
      ThreadFactory threadFactory,
      RejectedExecutionHandler handler)
     

    对于这些参数的意义,我们可以把线程池类比为一个项目组,而线程就是项目组的成员。其中:

  • corePoolSize:表示线程池保有的最小线程数。
  • maximumPoolSize:表示线程池创建的最大线程数。
  • keepAliveTime & unit:一个线程如果在一段时间内,都没有执行任务,说明很闲,keepAliveTime 和 unit 就是用来定义这个“一段时间”的参数。也就是说,如果一个线程空闲了keepAliveTime & unit这么久,而且线程池的线程数大于 corePoolSize ,那么这个空闲的线程就要被回收。
  • workQueue:工作队列。
  • threadFactory:通过这个参数你可以自定义如何创建线程名称。
  • handler:通过这个参数你可以自定义任务的拒绝策略。
  • CallerRunsPolicy:提交任务的线程自己去执行该任务
  • AbortPolicy:默认的拒绝策略,会 throws RejectedExecutionException
  • DiscardPolicy:直接丢弃任务,没有任何异常抛出
  • DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列

由于CPU的计算频率非常高,每秒计算数十亿次,因此可以将CPU的时间从毫秒的维度进行分段,每一小段叫作一个CPU时间片。

目前操作系统中主流的线程调度方式是:基于CPU时间片方式进行线程调度。

线程只有得到CPU时间片才能执行指令,处于执行状态,没有得到时间片的线程处于就绪状态,等待系统分配下一个CPU时间片。

由于时间片非常短,在各个线程之间快速地切换,因此表现出来的特征是很多个线程在“同时执行”或者“并发执行”。

在Javs多视程环境中,为了保证所有线程都能按照一定的策略执行,JVM 需要有一个线程调变器支持工作。

这个调度器定义了线程测度的策略,通过特定的机制为多个线分配CPU的使用权,线程调度器中一般包含多种调度策略算法,由这些算法来决定CPU的分配。

除此之外,每个线程还有自己的优先级(比如有高,中、低级别)调度算法会通过这些优先级来实现优先机制。

常见线程的调度模型目前主要分为两种:(分时)协同式调度模型和抢占式调度模型。

 

  • 抢占式调度:
    • 系统按照线程优先级分配CPU时间片
    • 优先级高的线程优先分配CPU时间片,如果所有就绪线程的优先级相同,那么会随机选择一个,优先级高的线程获取的CPU时间片相对多一些。
    • 每个或程的执行时间和或候的切换高由调度落控划,调度器按照某种略为每个线穆分配执行时间,
    • 调度器可能会为每个线整样分配相的执行时间,也可能为某些特定线程分配较长的执行时间,甚至在极准情况下还可能不给某热线程分!执行时同片,从而导致某技线相得不到执行,
    • 在抢占式调支机制下,一个线程的堵事不会导致整个进程堵客

 

(分时)协同式调度:

  • 系统平均分配CPU的时间片,所有线程轮流占用CPU,即在时间片调度的分配上所有线程“人人平等”。
  • 某一线相执行完后会主动通知调度器切换现下一个线程上继续执行。
  • 在这种模式下,线程的执行时间由线程本身控物,也就是说线程的切换点是可以预先知道的。
  • 在这种模式下,如果某个钱程的逻辑辑存在问题,则可能导致系统运行到一半就阻塞了,最终会导致整个进程阻塞,甚至更糟可能导致整个系统崩溃。

 

由于目前大部分操作系统都是使用抢占式调度模型进行线程调度,Java的线程管理和调度是委托给操作系统完成的,与之相对应,Java的线程调度也是使用抢占式调度模型,因此Java的线程都有优先级。

主要是 因为Java的线程调度涉及JVM的实现,JVM规范中规定每个线程都有各自的优先级,且优先级越高,则越优先执行。

但是,优先级越高并不代表能独占执行时间,可能优先级越高得到的执行时间越长,反之,优先级越低的线程得到执行时间越短,但不会出现不分配执行时间的情况。

假如有若干个线程,我们想让一些线程拥有更多的执行时间或者少分配点执行时间,那么就可以通过设置线程的优先级来实现。

所有处于可执行状态的线程都在一个队列中,且每个线程都有自己的优先级,JVM 线程调度器会根据优先级来决定每次的执行时间和执行频率。

但是,优先级高的线程一定会先执行吗?我们能否在 Java 程序中通过优先级值的大小来控制线程的执行顺序呢?

答案是肯定不能的。主要是因为影响线程优先级语义的因素有很多,具体如下:

  • 不同版本的操作系统和 JVM 都可能会产生不同的行为
  • 优先级对于不同的操作系统调度器来说可能有不同的语义;有些操作系统的调度器不支持优先级
  • 对于操作系统来说,线程的优先级存在“全局”和“本地”之分,不同进程的优先级一般相互独立
  • 不同的操作系统对优先级定义的值不一样,Java 只定义了 1~10
  • 操作系统常常会对长时间得不到运行的线程给予增加一定的优先级
  • 操作系统的线程调度器可能会在线程发生等待时有一定的临时优先级调整策略

JVM 线程调度器的调度策略决定了上层多线程的运行机制,每个线程执行的时间都由它分配管理。

调度器将按照线程优先级对线程的执行时间进行分配,优先级越高得到的 CPU执行时间越长,执行频率也可能更大。

Java把线程优先级分为10个级别,线程在创建时如果没有明确声明优先级,则使用默认优先级。

 

Java定义了 Thread.MIN_PRIORITY、Thread.NORM PRIORITY和 Thread.MAXPRIORITY这3个常量,分别代表最小优先级值(1)、默认优先级值(5)和最大优先级值(10)。

此外,由于JVM 的实现是以宿主操作系统为基础的,所以Java各优先级与不同操作系统的原生线程优先级必然存在着某种映射关系,这样才能够封装所有操作系统的优先级来提供统一的优先级语义。

一般情况下,在Linux中可能要与-20~19之间的优先级值进行映射,而Windows系统则有9个优先级要映射。

生命周期

在 Java 领域,实现并发程序的主要手段就是多线程。线程是本身就是操作系统里的一个概念,不同的开发语言如 Java、C# 等都对其进行了封装,但是万变不离操作系统。

Java 语言里的线程本质上就是操作系统的线程,它们是一一对应的。

在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂生命周期中各个节点的状态转换机制即可。

虽然不同的开发语言对于操作系统线程进行了不同的封装,但是对于线程的生命周期这部分,基本上是雷同的。

通用的线程生命周期基本上可以用 初始状态、可运行状态、运行状态、休眠状态和终止状态等“五态模型”来描述。

Java 语言中线程共有六种状态,分别是:NEW(初始化状态)RUNNABLE(可运行 / 运行状态)BLOCKED(阻塞状态)WAITING(无时限等待)TIMED_WAITING(有时限等待)TERMINATED(终止状态)。

其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权

 

其中,BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢?而这三种状态又是何时转换回 RUNNABLE 的呢?以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的?

1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。

2. RUNNABLE 与 WAITING 的状态转换

总体来说,有三种场景会触发这种转换,其中:

  • 第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。其中,wait() 方法我们在上一篇讲解管程的时候已经深入介绍过了,这里就不再赘述。
  • 第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  • 第三种场景,调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。

3. RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换,其中:

  • 调用带超时参数的 Thread.sleep(long millis) 方法。
  • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法。
  • 调用带超时参数的 Thread.join(long millis) 方法。
  • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法。
  • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

4. 从 NEW 到 RUNNABLE 的状态

Java 刚创建出来的 Thread 对象就是 NEW 状态,而创建 Thread 对象主要有两种方法:

  • 首先,第一种方式是继承 Thread 对象,重写 run() 方法

 

// 自定义线程对象
class ApplicationThread extends Thread { 
    public void run() { 
    // 线程需要执行的代码 
    ...... 
    }
}
// 创建线程对象
ApplicationThread applicationThread = new ApplicationThread();

 

被 interrupt 的线程,是怎么收到通知的呢?

 


 

  • 其次,另一种方式是实现 Runnable 接口,重写 run() 方法,并将该实现类作为创建 Thread 对象的参数
  • // 实现Runnable接口
    class ApplicationThread implements Runnable {
        @Override 
        public void run() { 
        // 线程需要执行的代码 
        ......
        }
    }
    // 创建线程对象
    Thread thread = new Thread(new ApplicationThread());
    
     

    NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法即可。

    5. 从 RUNNABLE 到 TERMINATED

    线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行。

    一般来说, run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?

    Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的姿势其实是调用 interrupt() 方法。

    那么,stop() 和 interrupt() 方法的主要区别是什么呢?

  • stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用。

  • interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。

  • 一种是异常:
  • 线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。
  • 当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。
  • 另一种是主动检测:
  1. 如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。
  2. 如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断。

 

写在最后

首先,管程(Monitor)就是一对monitorenter和monitorexit指令组成的一个对象监视器。任何线程想要访问该资源,就要排队进入监控范围。进入之后,接受检查,不符合条件,则要继续等待,直到被通知,然后继续进入监视器。

在Java中,每个加锁的对象都绑定着一个管程(监视器)。首先,线程访问加锁对象,就是去拥有一个监视器的过程,所有线程访问共享资源,都需要先拥有监视器。其次,监视器至少有两个等待队列:一个是进入监视器的等待队列,一个是条件变量对应的等待队列。最后,当监视器要求的条件满足后,位于条件变量下等待的线程需要重新排队,等待通知再进入监视器。

其次,线程(Thread)是进程(Process)中的运行单位,可以把线程看作轻量级的进程。

线程按照操作系统和应用程序两个层次来分类,主要分为 内核线程(Kernel Thread)和用户(应用线程,Applciation Thread)线程(User Thread) 。

在Java领域中,线程可以分为:Java应用程序层线程(Java Application Thread ),Java虚拟机层线程(Java JVM Thread )和操作系统层线程(OS Thread)。

其中,Java层到内层层的线程创建的大致流程:java.lang.Thread(Java应用程序层)—>Java Thread(JVM 层)->OS Thread(操作系统层)->pthread(根据操作系统的情况而定)->内核线程(Kernel Thread)。

另外,线程按照映射方式来看,主要可以分为:多对一映射(用户级方式),一对一映射(内核级方式) 和多对多映射(组合方式)3种方式。

Java 语言中线程共有六种状态,分别是:NEW(初始化状态)RUNNABLE(可运行 / 运行状态)BLOCKED(阻塞状态)WAITING(无时限等待)TIMED_WAITING(有时限等待)TERMINATED(终止状态)。

Java中实现线程的方式:继承 Thread 对象:extends Thread,实现 Runnable 接口(无返回值):implements Runnable ,实现Callable 接口(有返回值):implements Callable,基于线程池方式创建等。

常见线程的调度模型目前主要分为两种:(分时)协同式调度模型和抢占式调度模型,Java的线程调度也是使用抢占式调度模型,因此Java的线程都有优先级。

Java 线程的调度机制由 JVM 实现,Java定义了 Thread.MIN_PRIORITY、Thread.NORM PRIORITY和 Thread.MAXPRIORITY这3个常量,分别代表最小优先级值(1)、默认优先级值(5)和最大优先级值(10)。

 

本文含有隐藏内容,请 开通VIP 后查看