线程池(一)Java线程与OS进程

本文主要以实战的方式,探索Java语言中的Thread与OS中进程(线程)的关系,观察OS创建线程的细节,并总结其中的资源消耗。
通过这个过程,可以了解到操作系统是如何支持Java线程的。

随处可见的论调

在生产环境中,为每个任务分配一个线程的做法存在一些缺陷,尤其当并发量很高的时候。

  • 创建和销毁线程的代价相当高。
  • 活跃的线程会消耗系统资源,尤其是内存。
  • 稳定性难以保证。可创建线程的数量受多个条件限制,包括JVM的启动参数、Thread构造函数中请求的栈大小以及底层操作系统的限制。

基于此,线程池的优势就体现出来了。线程池是一种基于池化思想管理线程的工具,与String的字符串缓冲池、Integer的IntegerCache、数据库的连接池等一样,都是享元模式的典型应用。优点主要有以下三点:

  • 复用线程对象。节省了大量创建和销毁线程的开销。
  • 减少响应时间。每个任务被提交之后,通常状况下,有创建好的线程执行它,不需要即时创建线程。
  • 统一管理线程

以上论调基本回答了本文的主题,为什么要使用线程池。可是只有理论毕竟流于表面,我还有两个疑问。

  • Java中的线程和OS中的进程(线程)的对应关系是怎样的?也就是,java中的线程和OS的线程是一对一还是多对一。很自然地,如果是多对一,大量java线程只需要少量OS线程支持,那么大量创建Java线程,OS资源消耗也许没有太大。
  • 对于高并发场景,线程数量是不是越多越好?线程数量太多会有哪些方面的不良影响?

以下实战环节,主要是探索第一个问题,这也是本文的主题。顺带根据一些实验现象,分析得出第二个问题的答案。

实战

一、验证java线程与OS线程对应关系

实验环境:

  • linux内核:3.1
  • CPU
    • 物理槽位:2
    • 每槽位核数:1
    • 每核线程数:1

在这里插入图片描述
线程池(一)Java线程与OS进程_第1张图片

实验素材:

  • 一个计算密集型任务 : 求斐波那契数列的第n项。

        //递归,求斐波那契数列第n项
        public static long fib(int n){
            if(n == 1 || n == 2){
                return 1;
            }
            return fib(n-1) + fib(n-2);
        }
    
  • 下面的代码只创建一个线程。该线程求解斐波那契数列的第50项。

    import java.io.IOException;
    
    public class CreateOneThread {
        public static void main(String[] args) throws IOException {
            System.out.println("BLOCKING...");
            System.in.read();//阻塞1
            
            Thread t1 = new Thread(() -> {
                long start = System.currentTimeMillis();
                System.out.println(fib(50));//求解斐波那契数列中第50个数
                System.out.println("total time:\t" + (System.currentTimeMillis() - start));
            });
    
            System.out.println("PRESS ENTER TO START...");
            System.in.read();//阻塞2
            t1.start();
        }
    }
    
  • 下面的代码创建1k个线程。每一个线程求解斐波那契数列的第50项。

    
    import java.io.IOException;
    
    public class CreateMultiThreads {
        public static void main(String[] args) throws IOException {
            System.out.println("BLOCKING...");
            System.in.read();//阻塞1
    
            long start = System.currentTimeMillis();
            for(int i = 0; i < 1000; i++){
                Thread t1 = new Thread(() -> {
                    System.out.println(fib(50));
                });
                t1.start();
            }
            System.out.println("total time:\t" + (System.currentTimeMillis() - start));
        }
    }
    
  • 一个脚本,方便实验过程中的操作

    rm -fr *out*
    /home/appsvr/jdk/jdk1.8*/bin/javac CreateOneThread.java
    strace -ff -o out /home/appsvr/jdk/jdk1.8*/bin/java CreateOneThread
    

    第一行删除当前目录下的所有含有“out”的文件,
    第二行编译某java文件,
    第三行运行第二行编译好的字节码,并且用strace追踪该进程执行过程中发生的系统调用、信号传递、进程状态变更等等。其中,-ff 和 -o的联合使用,使得每个进程的输出都写入到**out.{pid}**中。因此,通过查看当前目录下out文件的数量可以非常直观的看出启动了几个线程。

所有实验素材罗列完毕。在两个版本的代码中,每个创建的线程都求解斐波那契数列第50项,这是一个CPU密集型任务,目的是让每个线程都长时间保持运行or就绪状态,一直参与CPU调度。
上述四份素材存在同一目录下,让我们看看截图,当前目录下只有这四个文件。
强调一下,整个实验过程都在该目录下进行
在这里插入图片描述

实验过程:

整个实验共分3步。

  1. 执行mysh.sh --------->编译并执行了 CreateOneThread.java
    CreateOneThread进程启动了。
    程序阻塞在这一行代码: System.in.read();//阻塞1

    在这里插入图片描述
    先让它阻塞着,通过jps指令,查到CreateOneThread的进程id是28282
    我们看一下当前目录,如下图。
    线程池(一)Java线程与OS进程_第2张图片
    线程池(一)Java线程与OS进程_第3张图片可以看到当前目录增加了12个out.pid文件,正是进程CreateOneThread所生成的。其中尾缀数字代表进程id。通过观察这些out文件,发现12个进程之间的关系如上图所示。即,28283是28282的子进程,28284 … 28293是28283的子进程。
    我们发现,操作系统内核通过系统调用 clone 创建子进程。下面粘贴一下进程28283创建28293的指令(出自out.28283),= 后面的返回值就是子进程ID。

    clone(child_stack=0x7fb6117d4fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|
    CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|
    CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, 
    parent_tidptr=0x7fb6117d59d0, 
    tls=0x7fb6117d5700, 
    child_tidptr=0x7fb6117d59d0) = 28293
    

    计算out.28283中“clone”的数量,发现有10个,这与28284~28293这十个子进程对应,进一步佐证了调一次clone创建一个进程
    在这里插入图片描述
    至此试验第一步结束,总结如下:

    执行了mysh.sh,然后一通观察与分析,主要是看out文件里的system call,发现了每个子进程都是通过调用内核的clone来创建的。还有一点,此时的共12个进程,是CreateOneThread自身的,java代码阻塞住了,还没有执行到new Thread。这也是“阻塞1”的作用,让我们可以先观察到该进程自身的线程数。

  2. 第一步阻塞在这一行代码:System.in.read();//阻塞1
    敲击Enter键,程序开始往下执行,又阻塞在了这行代码:

    System.in.read();//阻塞2
    

    在这里插入图片描述
    再次执行“ll -h”查看当前目录所有文件,发现和第一步没有差别;
    再次执行“grep clone out.28283 | wc -l”,结果还是10。
    由于这两条指令的结果和第一步一致,此处不再贴图。
    此阻塞处,已经新建了线程对象,但是还没有调用该线程的start方法。
    OS没有调用clone函数创建线程

    接下来再次敲击Enter,该线程真正开始执行起来,计算斐波那契数列的第50项。

    再次执行“ll -h”和“grep clone out.28283 | wc -l”。发现当前目录多了一个文件out.3482,说明新创建了一个进程,pid是3482,。而out.28283中clone数量也多了一个,变成了11。
    线程池(一)Java线程与OS进程_第4张图片
    在这里插入图片描述
    看一眼out.28283中增加的clone调用。正是返回了3482,由此可见进程3482和我们java代码中创建的线程相对应。

    clone(child_stack=0x7fb6116d3fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|
    CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|
    CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
     parent_tidptr=0x7fb6116d49d0, 
     tls=0x7fb6116d4700,
      child_tidptr=0x7fb6116d49d0) = 3482
    

    在线程求解第50项的过程中,观察对cpu使用率的监控,如下图。1个线程在运行,12个在sleep,50.0us说明用户态进程的CPU占用率50%。这没问题,因为OS共有两个逻辑CPU,我们只有一个线程在运行,因此另一个CPU闲着呢。一切都很合理!
    线程池(一)Java线程与OS进程_第5张图片
    最终java线程执行完毕,该CPU密集型任务,单线程执行共耗时52294ms。
    线程池(一)Java线程与OS进程_第6张图片
    第二步总结:

    第二步,代码首先阻塞在调用start方法之前,此时OS没有创建线程,而是在调用start之后创建了3482进程。说明操作系统为java线程创建相对应的进程是在start方法调用之后。
    且证实了java代码中start开启一个线程之后,OS会调用系统调用clone创建一个进程与之对应。

    综合实验前两步,我们可以看到java代码中创建一个线程并且start之后,OS也通过系统调用clone创建了一个进程。在这里我如果下结论:java线程和OS进程是一对一的关系,你可能会觉得说服力不够。

    接下来的实验第三步,我们创建1K个线程,每个线程计算斐波那契数列第50项,看一看OS会为这1K个线程创建多少OS级别的进程,同时可以观察1K个CPU密集型任务在执行的时候,对OS各项性能指标的影响。

    第三步我们运行CreateMultiThreads.java,所以修改一下实验素材mysh.sh,修改为:

    rm -fr *out*
    /home/appsvr/jdk/jdk1.8*/bin/javac CreateMultiThreads.java
    strace -ff -o out /home/appsvr/jdk/jdk1.8*/bin/java CreateMultiThreads
    
  3. 执行./mysh.sh,开启CreateMultiThreads进程,敲击Enter,开始创建线程,当创建完1K个线程之后,主线程结束运行,并打印出创建1K个线程的总耗时:
    在这里插入图片描述

    可以看到,对于极度耗时的CPU密集任务,仅仅是创建1K个线程并start,就需要862s。况且这只是创建线程的时间,那这1K个线程并发执行,何时能够完成自己的计算任务呢?小编陷入了漫长的等待中…

    接下来搜索CreateMultiThreads进程中调用了多少个clone呢?由实验前两步可知,该进程初始状态下,会调用10次clone创建10个进程。

    这次java代码里创建了1K个线程,那么CreateMultiThreads进程(通过jps查到其pid是13322,那么系统调用记录在子进程13323中)现在调用了多少次clone呢?

    如果是10+1000 = 1010次。就可以说明,java线程和OS线程是一对一的关系了。看下图:
    在这里插入图片描述

    以上三个点代表漫长的等待。看下面两幅图片,第一个截图是该线程刚开始不久的截图,看左上角时间是14:36,第二幅截图是18:09。

    3.5小时,没有任何一个线程完成任务。而单线程情况下,由实验过程第二步可知,每个任务完成时间大概是52s。

    我们使用了多线程,导致在3.5小时内,没有任何一个线程完成任务,可以预见的是,再有3.5h也未必有任一个线程完成任务。此处可见,并发执行未必优于串行执行。

    而且在运行过程中,cpu的使用率一直是99+%,这导致服务器接收新的请求延迟非常大,可以说服务器基本处于崩溃状态。

    线程池(一)Java线程与OS进程_第7张图片

    线程池(一)Java线程与OS进程_第8张图片

    我们随便看一下线程的上线文切换次数,pid是13336这个进程共占用CPU时间30s,自愿调度了283次,被强制调度了15912次,约16K次。

    这只是1K个进程中一个非常普通的进程,不太严谨的估计一下,在这3.5h的时间里,可以认为1K个进程总共约调度1600w次。大量的CPU时间浪费在了进程调度上。
    线程池(一)Java线程与OS进程_第9张图片

总结回顾

   结合上述三步实验过程,我们可以得出以下结论。并且回答最开始的两个疑问。
  1. Java中的线程和OS中的进程(线程)的对应关系是怎样的?也就是,java中的线程和OS的线程是一对一还是多对一。很自然地,如果是多对一,大量java线程只需要少量OS线程支持,那么大量创建线程,资源消耗也许没有太大。
  2. 对于高并发场景,线程数量是不是越多越好?线程数量太多会有哪些方面的不良影响
回答1:

通过以上实验过程,可以得出结论:至少在目前OS kernal 3.1版本下,OS调用clone创建一个进程服务于一个Java线程,他们是一对一的关系。至于以后会不会有改进,是否OS一个进程可以服务多个Java线程,不得而知。

个人的看法是:虽然linux是开源的,但是内核的每一步升级都有许多考量在其中,不止技术。全球范围内大都在用linux作为服务器,那么linux的每一步升级就涉及到好多厂商的利益,很明显地,如果OS底层对于某家厂商的软件能够提供更好地支持,他的竞争对手肯定是不乐意看到的。错综复杂的利益纠葛中,linux内核的每一步升级都历经风雨。这也是为什么至今linux不支持真正的AIO,而一直闭源的windows却能够支持。全世界那么多技术大牛还比不过微软一家吗?引入新的功能可以,技术上也许不是太难,但是要让全球范围内大佬们都基本满意,这就难了。。。

回答2:

线程数量不是越多越好。一般来讲,对于多核CPU的操作系统,线程数由一到多增加的过程中,并发执行效率类似于一个抛物线,会先升高后降低,我们的目的就是找到那个最高点。到底多少线程数量是最好的?比较复杂,本文不展开谈了。线程较少时,增加线程数量可以更充分地利用多核的优势,这个很好理解。下面谈一下为什么线程数过多,反而会使得并发执行效率降低。

  • 创建线程本身需要调起内核提供的系统调用(system call),而调起一个系统调用的过程是相当消耗资源的。许多语言/框架的优化点,就立足于减少对底层OS发起系统调用的次数。三个例子:

    1. 当运行在用户态的进程要写数据到磁盘或者网络时,需要调起write()系统调用把字节流从用户空间写到内核空间的页缓存中。我们知道Java的IO流在用户态中引入Buffer的概念(例如BufferedInputStream),目的就是不要每次写数据都直接调起write()系统调用,而是攒够一定数量的数据统一写入内核的页缓存中。
    2. mmap(),把用户内存空间的一块区域和内核内存空间的一块区域对应起来,用户进程可以不调用write(),直接写入数据到内核的页缓存,这就是在极致地减少系统调用。
    3. 在网络IO模型中,随着OS内核的发展,逐步引入了select、poll、epoll以及kqueue等多路复用器,极大地提升了朴素NIO模型的运行效率。多路复用器的工作原理就是基于事件驱动,每次返回ready状态的事件,使得用户态进程只处理那些ready状态的fd(对应一个Socket)就好了,其终极奥义也是减少系统调用次数
  • 活跃线程越多,CPU上下文在切换上就费的越多,“sy”这一项就越大,这样一来真正干活的“us”就越少。

  • 而且对于CPU密集型任务,大量线程并发,导致并发的任务都长时间无法完成,大家一起超时。

你可能感兴趣的:(java并发)