Java与线程

1 篇文章 0 订阅

1. 线程的实现

  主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。

  实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。

1.1 内核线程实现

  使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。

  程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如下图所示。

在这里插入图片描述

轻量级进程与内核线程之间1:1的关系

  由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。(PS:一个进程的多个线程可以同时执行,充分利用多核处理器,但单个线程需要用户态和内核态进行来回切换

1.2 用户线程实现

  使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制(这里是单论单个线程的效率来讲),并不具备通常意义上的用户线程的优点。

在这里插入图片描述

进程与用户线程之间1:N的关系

  而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如上图所示。

  用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通常都比较复杂 ,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。(PS:不能利用多核处理器,同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。)

1.3 混合实现

  线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系,如下图所示,这种就是多对多的线程模型。

在这里插入图片描述

用户线程与轻量级进程之间M:N的关系

  许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了M:N的线程模型实现。在这些操作系统上的应用也相对更容易应用M:N的线程模型。

2. Java线程的实现

  Java线程如何实现并不受Java虚拟机规范的约束,这是一个与具体虚拟机相关的话题。Java线程在早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程实现的,但从JDK 1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

  以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

  前面强调是两个“主流”,那就说明肯定还有例外的情况,这里举两个比较著名的例子,一个是用于Java ME的CLDC HotSpot Implementation(CLDC-HI,介绍可见第1章)。它同时支持两种线程模型,默认使用1:N由用户线程实现的线程模型,所有Java线程都映射到一个内核线程上;不过它也可以使用另一种特殊的混合模型,Java线程仍然全部映射到一个内核线程上,但当Java线程要执行一个阻塞调用时,CLDC-HI会为该调用单独开一个内核线程,并且调度执行其他Java线程,等到那个阻塞调用完成之后再重新调度之前的Java线程继续执行。

  操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎样映射的,这一点在不同的平台上很难达成一致,因此《Java虚拟机规范》中才不去限定Java线程需要使用哪种线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是完全透明的。

3. java线程调度

  线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

  • 协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去

  协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

  • 抢占式调度:允许调度程序根据某个原则,去停止某个正在执行的进程,将处理机重新分配给另一个进程

对于抢占方式,抢占有如下的原则:

  • 时间片原则:各进程按时间片运行,当一个时间片用完后,便停止该进程的执行而重新进行调度。这个原则适用于分时系统;
  • 优先权原则:通常对一些重要的和紧急的进程赋予较高的优先权。当这种进程进入就绪队列时,如果其优先权比正在执行的进程优先权高,便停止正在执行的进程,将处理机分配给优先权高的进程,使之执行;
  • 短作业优先原则:当新到达的作业比正在执行的作业明显短时,将暂停当前长作业的执行,将处理机分配给新到的短作业,使之执行。

  譬如在Java中,有Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式调度。

  至于抢占式调度的CPU各种算法实现可以看这篇文章:

CPU各种调度算法实现

  虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行

  不过,线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。尽管现代的操作系统基本都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有2147483648(2的31次幂)种优先级,但Windows中就只有七种优先级。如果操作系统的优先级比Java线程优先级更多,那问题还比较好处理,中间留出一点空位就是了,但对于比Java线程优先级少的系统,就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。

Java线程优先级与Windows线程优先级之间的对应关系

在这里插入图片描述

在这里插入图片描述

  线程优先级并不是一项稳定的调节手段,这不仅仅体现在某些操作系统上不同的优先级实际会变得相同这一点上,还有其他情况让我们不能过于依赖线程优先级:优先级可能会被系统自行改变,例如在Windows系统中存在一个叫“优先级推进器”的功能(Priority Boosting,当然它可以被关掉),大致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它分配执行时间,从而减少因为线程频繁切换而带来的性能损耗。因此,我们并不能在程序中通过优先级来完全准确判断一组状态都为Ready的线程将会先执行哪一个。

4. 线程状态转换

  Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换,但是也需依赖内核线程实现。这6种状态分别是:

  • 新建(New):创建后尚未启动的线程处于这种状态。(new 了线程还未调用start()方法)

  • 运行(Runnable):包括操作系统线程状态中的RunningReady,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。

  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线
    程显式唤醒。以下方法会让线程陷入无限期的等待状态:

    • 没有设置Timeout参数的Object::wait()方法;
    • 没有设置Timeout参数的Thread::join()方法;
    • LockSupport::park()方法。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    • Thread::sleep()方法;
    • 设置了Timeout参数的Object::wait()方法;
    • 设置了Timeout参数的Thread::join()方法;
    • LockSupport::parkNanos()方法;
    • LockSupport::parkUntil()方法。
  • 阻塞(Blocked):线程被挂起,进入阻塞队列,这时候不会参与cpu时间分片。如果是争取synchronized锁失败,则会在锁被释放时唤醒;如果是IO阻塞,则会在IO就绪后被唤醒

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

  上述6种状态在遇到特定事件发生的时候将会互相转换,它们的转换也依赖内核线程去完成,它们的转换关系如下图所示。

在这里插入图片描述

有如下代码,请分析它们线程状态是怎么转换的?

public class Test2 {

    public static Object obj = new Object();

    public static Thread thread1 = new Thread() {
        @Override
        public void run() {
            synchronized (obj) {
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    };

    public static Thread thread2 = new Thread() {
        @Override
        public void run() {
            synchronized (obj) {
                try {
                    obj.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    };
    public static void main(String[] args) throws InterruptedException {

        thread.start();
        thread1.start();
        Thread.sleep(1000);
        synchronized (obj) {
            obj.notifyAll();
        }
        
    }
}

  从上面可以看出,程序运行将会有三个线程,我们分别标记为主线程、thread1、thread2。首先主线程会被触发启动进入Runnable状态,分别启动thread1,thread2它们从new状态进入Runnable状态,这里会存在锁竞争,假设thread1先拿到了锁,thread2会先自旋,如果自旋失败则进入Blocked状态,thread1拿到锁后继续往下执行遇到wait()方法,则会进入waiting状态,并且释放所持有的锁,这时候thread2从blocked状态被唤醒进入Runnable状态,然后去获取到了锁,往下执行也遇到wait()方法进入waiting状态,并且释放锁,这时候主线程进入Timed waiting状态,睡眠1s后主动被唤醒进入Runable状态,继续往下执行,获取到了锁,执行notifyAll()方法,这时thread1、thread2从Blocked状态进入Runable状态,主线程进入Terminated状态,这时thread1、thread2会竞争锁,假设还是thread1抢到了锁,thread2自旋,自旋失败则进入Blocked状态,thread1往下执行,释放锁,这时hread2从从Blocked状态进入Runable状态,thread1进入Terminated状态,thread2获取了锁往下执行,释放锁,然后thread2进入Terminated状态。

5. Java与协程

5.1 内核线程的局限

  笔者可以通过一个具体场景来解释目前Java线程面临的困境。今天对Web应用的服务要求,不论是在请求数量上还是在复杂度上,与十多年前相比已不可同日而语,这一方面是源于业务量的增长,另一方面来自于为了应对业务复杂化而不断进行的服务细分。现代B/S系统中一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。

  Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

  传统的Java Web服务器的线程池的容量通常在几十个到两百之间,当程序员把数以百万计的请求往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是相当可观的,现实的需求在迫使Java去研究新的解决方案。(PS:如果业务是CPU密集型计算,我们web服务的线程池数可以设置成cpu数量 + 1,这样是最效率;但是如今很多业务都涉及有网络IO,磁盘IO等,为了充分利用CPU我们不得把线程数适当调高,线程数多了也带来了一些其它性能消耗,比如线程上下切换频繁、线程依赖内核调度进入阻塞状态

5.2 协程的复苏

  为什么内核线程调度切换起来成本就要更高?

  内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。请读者试想以下场景,假设发生了这样一次线程切换:

线程A -> 系统中断 -> 线程B

  处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码指令的组合体,代码执行时还必须要有上下文数据的支撑。而这里说的“上下文”,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源;以线程的角度来看,是方法的调用栈中存储的各类信息;而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。

  如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分开销就能够省略掉吗?答案是“不能”。但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。(PS:用户线程可以通过一些手段来缩减这些开销)

  最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名——“协程”(Coroutine)。又由于这时候的协程完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)(PS:有栈协程没有上下文切换的开销?)

  协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。与之相对的,一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。(PS:协程是轻量级的,调度状态转换开销下,上下文切换开销小,占内存小)

  协程当然也有它的局限,需要在应用层面实现的内容(调用栈、调度器这些)特别多(PS:要设计要不容易)

5.3 golang 协程

5.3.1 实际工作中的我们

  程序一般分为CPU密集型和IO密集型,对于CPU密集型我们的优化空间可能没那么多,但对于IO密集型却有非常大的优化空间,试想我们的程序总是处于IO等待中让CPU呼呼睡大觉,那该多糟糕。

  • 解决方案一:异步IO。我们现在写的程序大部分都是同步IO的,效率还不够高,因此出现了一些异步IO框架,但是异步框架的编程难度比同步框架要大,但不可否认异步是一个很好的优化方向,先不要晕,来看下同步IO和异步IO就知道了:

同步是指应用程序发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行,异步是指应用程序发起I/O请求后仍继续执行,当内核I/O操作完成后会通知应用程序或者调用应用程序注册的回调函数。

  但异步IO需要考虑业务自旋,也是浪费了一部分性能。(参考NIO模型 + epoll)

  • 解决方案二:增大线程数,提高cpu使用率。我们尝试多进程/多线程编程等让多个任务一起跑分时复用抢占式调度,样提高了CPU的利用率,但由于多个进线程存在调度切换,这也有一定的资源消耗,内核线程状态转换也是笔开销,进线程数量也不可能无限大。
  • 解决方案三:协程
5.3.2 golang 协程

  协作式调度中用户态协程会主动让出CPU控制权来让其他协程使用,确实提高了CPU的使用率,但是不由得去思考用户态协程不够智能怎么办?不知道何时让出控制权也不知道何时恢复执行。为了解决这个问题我们需要一个中间层来调度这些协程,这样才能让用户态的成千上万个协程稳定有序地跑起来,我们姑且把这个中间层称为用户态协程调度器吧!

  Go的调度器非常复杂,篇幅所限本文只提一些基本的概念和原理,

  最近几个版本的Go调度器采用GPM模型,其中有几个概念先看下:

逻辑处理器执行创建的goroutine,绑定一个线程
调度器Go运行时钟的,分配goroutine给不同的逻辑处理器
全局运行队列所有刚创建的goroutine都会放到这里
本地运行队列逻辑处理器的goroutine队列

  GPM模型使用一种M:N的调度器来调度任意数量的协程运行于任意数量的系统线程中,从而保证了上下文切换的速度并且利用多核,但是增加了调度器的复杂度。

  来看两张图来进一步理解一下:

在这里插入图片描述

  整个GPM调度的简单过程如下:

新创建的Goroutine会先存放在Global全局队列中,等待Go调度器进行调度,随后Goroutine被分配给其中的一个逻辑处理器P,并放到这个逻辑处理器对应的Local本地运行队列中,最终等待被逻辑处理器P执行即可。
在M与P绑定后,M会不断从P的Local队列中无锁地取出G,并切换到G的堆栈执行,当P的Local队列中没有G时,再从Global队列中获取一个G,当Global队列中也没有待运行的G时,则尝试从其它的P窃取部分G来执行相当于P之间的负载均衡。

  这是最简单的调度模式,假如一个运行的协程G调用syscall进入阻塞怎么办?如下图左边,G0进入阻塞,那么P会转移到另外一个内核线程M1(此时还是1对1)。当syscall返回后,需要抢占一个P继续执行,如果抢占不到,G0挂入全局就绪队列runqueue,等待下次调度,理论上会被挂入到一个具体P下面的本地队列

在这里插入图片描述
协程需要的内核线程会很多吗?

  针对于现在的应用程序,大部分G 协程的业务逻辑都会调用 syscall 进入阻塞状态,那么这样不是会出现很多 G 和 M 阻塞的映射关系吗,也就是需要很多内核线程?其实并不然,G协程里面的业务逻辑大部分调用 syscall 进入阻塞状态时间不会太长,就不会消耗很多个内核线程。

协程会有上下文切换吗?

  有,如上图 G0

Java 线程、golang 协程

  • 这里可以看出 G0 阻塞了,下一个 Goroutine 可以接着寻找新的内核线程运行,也发生内核线程上下文切换,但相对于java线程,Goroutine 上下文切换只涉及到三个寄存器(PC / SP / DX)的值修改;而java线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;
  • 线程栈空间通常是 2M,Goroutine 栈空间最小 2K;java线程是1:1,Goroutine 是N:M,不会占用太多的内核线程资源;Golang 程序中可以轻松支持10w 级别的 Goroutine 运行,而线程数量达到 1k 时,内存占用就已经达到 2G。
  • Golang 针对 cpu 型计算不会发生上下文切换,会直至队列中的 Goroutine 执行完。而 java 线程即便是 cpu 型计算也会按时间分片执行,会有上下文切换的开销。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值