三十. JAVA线程调度机制与应用

前言

说到并发,离不开线程这个概念,在操作系统层面有线程,在JAVA进程层面也有线程。同时还知道线程既可以共享进程资源,又可以被独立调度,那么JAVA进程的线程,也肯定存在一种调度机制。本篇文章将对操作系统的线程的三种实现方式进行说明,然后再对JAVA线程的实现方式进行阐述,最后针对JAVA线程的调度机制进行解释并结合例子说明JAVA线程调度机制的应用。

本篇文章参考资料为《深入理解JAVA虚拟机》3版,第12章。

正文

一. 操作系统的线程实现

1. 内核线程实现

内核线程的实现又叫做1:1实现。内核线程(Kernel-Level Thread,KLT)就是由操作系统内核(Kernel)支持的线程,内核线程的调度由调度器(Scheduler)负责,调度器同时也负责将内核线程的任务映射到CPU核心上。

通常应用程序不会直接使用内核线程,而是使用内核线程提供的轻量级进程(Light Weight Process,LWP),每个轻量级进程都由一个内核线程支持。

内核线程的实现模型如下图所示(LWPKLT1:1关系)。

在这里插入图片描述

内核线程的实现方式的优点如下:

  • 简单。无需考虑线程的创建销毁切换调度等;
  • 安全。一个轻量级进程在系统调用时被阻塞,不会阻塞整个进程。

内核线程的实现方式的缺点如下。

  • 对线程的操作涉及用户态内核态的切换,对效率有影响;
  • 操作系统支持的轻量级进程数有限的。
2. 用户线程实现

用户线程的实现又叫做1:N实现。用户线程(User Thread,UT)是建立在用户空间的线程库上,系统内核无法感知其实现与存在。

用户线程的实现模型如下图所示(进程UT1:N关系)。

在这里插入图片描述

用户线程的实现方式由于不需要操作系统内核的支持,所以具有以下优点。

  1. 。线程的创建销毁切换调度完全在用户态中完成,不需要切换到内核态,因此线程相关的操作可以非常
  2. 。用户线程相较于内核线程,消耗更小,能够支持更大规模的线程数量。

也正是由于用户线程不需要操作系统内核的支持,所以用户线程的实现有如下缺点

  • 程序复杂。因为用户程序需要考虑线程的创建销毁切换调度等,而在内核线程的实现中,这些问题是由操作系统内核来完成;
  • 容易阻塞进程。由于操作系统只把CPU资源分配到进程,那么如果用户线程实现不当,容易出现一个用户线程的阻塞导致整个用户进程的阻塞;
  • 难以利用多核CPU。用户程序中需要考虑如何将用户线程任务映射到其它CPU核心上,这往往是很困难的。
3. 混合实现

混合实现又叫做N:M实现。在混合实现中,内核线程用户线程并存,用户线程的创建销毁切换用户态中完成,同时轻量级进程LWP作为用户线程UT与内核线程KLT的桥梁,使得用户线程的调度CPU映射可以由操作系统内核完成。

混合实现的模型如下图所示(UTLWPN:M关系)。

在这里插入图片描述

混合模式的优点就是:能够支持大规模的线程数量,同时还能将线程的调度CPU映射交由操作系统内核完成,并且也不容易出现一个用户线程阻塞导致整个用户进程阻塞的情况。

二. JAVA线程的实现

通常(JDK1.3以后的HotSpot虚拟机),JAVA线程的实现采用的是1:1模型,也就是我们通过new Thread()创建出来的JAVA线程会直接映射到一个内核线程,从而JAVA线程的调度由操作系统内核来完成。

三. JAVA线程的调度

已知JAVA线程的调度完全由操作系统来完成,而操作系统调度主要有两种方式。

第一种协同式线程调度:线程的执行时间由线程本身来控制,线程执行完任务后,需要主动通知操作系统切换到另外一个线程。这种方式的优点实现简单缺点是可能会因为代码自身问题导致线程一直不通知操作系统进行切换,最终导致其它线程一直得不到CPU资源而成为饥饿线程

第二种抢占式线程调度:由操作系统来为线程分配时间片执行时间),将时间片分配给哪个线程也完全由操作系统决定。这种方式由于CPU资源的分配是操作系统来决定,所以不会有饥饿线程的出现。

JAVA线程的调度采用的就是抢占式线程调度。一个JAVA线程何时得到时间片,完全由操作系统决定。

四. Thread#yield方法详解

通过前面的讨论已知,JAVA线程的实现模型是1:1实现,即一个JAVA线程直接映射到操作系统的一个内核线程,所以JAVA线程的调度由操作系统来实现(内核操作调度器来调度线程),并且调度方式是抢占式线程调度

所以一个JAVA线程何时得到时间片是JAVA线程无法控制的,但是JAVA线程可以通过Thread#yield方法来决定放弃分配给自己的时间片。Thread#yield方法的官方注释如下。

向调度器示意当前线程愿意放弃当前使用的处理器。

Thread#yield的使用,可以先看一下如下的一个场景。

线程1需要计算1-100万的累加和,线程2需要在线程1计算完毕后,打印这个累加和。

通常上述场景可以使用下面的程序实现

public class YieldTest {

    volatile long finalSum = -1;

    @Test
    public void Yield的占用CPU资源的例子() throws Exception {
        Thread thread1 = new Thread(() -> {
            long tempSum = 0;
            for (long num = 1; num < 100000000; num++) {
                tempSum += num;
            }
            System.out.println(Thread.currentThread().getName() + " 执行完毕");
            finalSum = tempSum;
        }, "线程1");
        thread1.start();

        Thread thread2 = new Thread(() -> {
            while (finalSum == -1) {
                // empty run
            }
            System.out.println("累加结果是:" + finalSum);
        });
        thread2.start();

        // 主线程等待线程2执行完毕
        thread2.join();
    }

}

线程2分配到时间片后,会循环的判断finalSum的值,但finalSum只有在线程1完成累加计算后才会被赋值,而同一时刻时间片又只能给线程1或者线程2,则会出现线程2无效占用时间片的情况(浪费CPU资源),拖慢线程1累加计算的速度。

为了避免上述问题的出现,可以进行如下改进。

public class YieldTest {

    volatile long finalSum = -1;

    @Test
    public void Yield的放弃CPU资源的例子() throws Exception {
        Thread thread1 = new Thread(() -> {
            long tempSum = 0;
            for (long num = 1; num < 100000000; num++) {
                tempSum += num;
            }
            System.out.println(Thread.currentThread().getName() + " 执行完毕");
            finalSum = tempSum;
        }, "线程1");
        thread1.start();

        Thread thread2 = new Thread(() -> {
            while (finalSum == -1) {
                Thread.yield();
            }
            System.out.println("累加结果是:" + finalSum);
        });
        thread2.start();

        // 主线程等待线程2执行完毕
        thread2.join();
    }

}

也就是每次线程2分配到时间片后,如果判断finalSum还没有完成赋值,则通过Thread.yield方法及时放弃时间片,好让线程1尽可能的拿到时间片并完成累加计算。

特别注意:Thread.yield方法是会导致线程上下文切换的(线程上下文切换会有资源性能的开销,且线程上下文切换会导致用户态内核态的切换),所以不宜频繁的调用Thread.yield方法,除非可以肯定另外一个线程很快就能执行结束并输出当前线程期望的结果,那么可以使用Thread.yield方法来帮助另外一个线程更快的完成任务执行。

五. 设置线程优先级

前面提到,JAVA线程是由操作系统基于抢占式调度的方式来分配时间片,实际上操作系统会让优先级更高的线程有更大概率获得时间片。在Thread类中,提供了Thread#setPriority方法来设置JAVA线程的优先级。

JAVA中为线程优先级提供了十个级别(110),在两个线程状态都是Ready(等待分配时间片)时,优先级更高的线程有更大概率分配到时间片。

如下是一个示例。

public class ThreadPriorityTest {

    @Test
    public void 线程优先级简单示例() throws Exception {
        // 计数任务
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                long count = 0;
                while (count != 2000000000) {
                    count++;
                }
                System.out.println(Thread.currentThread().getName() + " 计数完成");
            }
        };

        // 线程1,优先级1
        Thread thread1 = new Thread(runnable, "线程1");
        thread1.setPriority(1);

        // 线程2,优先级5
        Thread thread2 = new Thread(runnable, "线程2");
        thread2.setPriority(5);

        // 线程3,优先级10
        Thread thread3 = new Thread(runnable, "线程3");
        thread3.setPriority(10);

        // 同时运行三个线程
        thread1.start();
        thread2.start();
        thread3.start();

        // 主线程等待5秒
        Thread.sleep(5000);
    }

}

运行测试程序,结果如下。

在这里插入图片描述

总结

操作系统的线程实现,有如下三种方式。

  1. 内核线程实现轻量级进程内核线程1:1关系)。优点是无需考虑线程的创建销毁切换调度,且一个轻量级进程的阻塞不会导致整个进程的阻塞。缺点是线程操作的效率不高,且无法支持大规模的线程数量;
  2. 用户线程实现进程UT1:N关系)。优点是线程操作全部在用户态中完成,线程操作可以很快,且能够支持大规模的线程数量。缺点是用户程序实现复杂,难以利用多核CPU的优势,且容易出现一个线程的阻塞导致整个进程的阻塞;
  3. 混合实现用户线程轻量级进程N:M关系)。优点是能够支持大规模的线程数量,同时还能将线程的调度CPU映射交由操作系统内核完成,并且也不容易出现一个用户线程阻塞导致整个用户进程阻塞的情况。

JDK1.3以后的HotSpot虚拟机中,JAVA线程的实现采用的是1:1模型,即一个JAVA线程直接映射到一个内核线程。

JAVA线程的调度是由内核调度器进行调度,调度方式是抢占式线程调度,一个JAVA线程何时被分配时间片,完全由操作系统决定。

JAVA线程中可以通过Thread#yield方法来放弃时间片,但是放弃时间片(或时间片耗尽)会导致线程上下文切换,所以不宜频繁调用Thread#yield方法。

可以通过Thread#setPriority方法来为JAVA线程设置优先级,JAVA提供了十个级别(110),优先级更高的线程有更大概率获得时间片,但并不是绝对的。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

樱花祭的约定

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值