1、我们的后端服务可以处理很高的QPS远远超过cpu的核数,但cpu占用还是很少,这是为什么呢?
2、后端服务经常会使用线程池,有的应用一个就会创建很多线程池,但实际上我们的cpu都是四核心的,根本不满足我们运行那么多线程的需求,那又是为什么可以这样用呢?

1、进程VS线程

1.1、概念介绍

进程(procss): 是操作系统为正在运行的程序提供的抽象,是操作系统对资源分配的基本单位,比如我们电脑上运行的各种程序,每次运行的web项目都是一个单独的进程。
**线程(thread)**线程是资源调度的基本单位,线程是属于某一个进程的,一个进程中可以有多个线程存在,它们共享这个进程的资源,但是每个线程都拥有自己的程序计数器,虚拟机栈,本地方法栈 。

194CB964-7288-4832-8E53-C7E1A3CCEB47 (1)

1.2、为什么会有进程和线程?

我们日常使用电脑,就是运行各个程序为我们所使用,要想运行这些程序,就需要电脑资源进行支持,包括CPU,内存,网络,硬盘等等,但真正需要运行指令的是靠CPU进行计算的,CPU的速度非常快而且价格也非常昂贵,目前普遍都是2核或者4核的cpu,如果一个cpu只能运行一个程序的话,那我们每台电脑基本只能运行几个程序了,完全不能发挥出电脑的作用,为什么现在的电脑都可以同时打开很多软件进行正常的运行呢?

操作系统会拆分CPU为一段段时间的运行片,轮流分配给不同的程序。对于多cpu,多个进程可以并行在多个cpu中计算,当然也会存在进程切换;对于单cpu,多个进程在这个单cpu中是并发运行,根据时间片读取上下文+执行程序+保存上下文。同一个进程同一时间段只能在一个cpu中运行,如果进程数小于cpu数,那么未使用的cpu将会空闲。

多线程的概念主要有两种:一种是用户态多线程;一种是内核态多线程,对于内核态多线程(java1.2之后用内核级线程),在操作系统内核的支持下可以在多核下并行运行; 对于多核cpu,进程中的多线程并行执行。对于单核cpu,多线程在单cpu中并发执行,根据时间片切换线程。同一个线程同一时间段只能在一个cpu内核中运行,如果线程数小于cpu内核数,那么将有多余的内核空闲。

970D7252-D3BC-4AE6-8D3E-3CEA08BAEF72

1.3、线程和进程的区别?

  1. 传统UNIX操作系统是以进程为最小单元进行管理运行的程序的,如果需要并行处理任务,需要创建多个进程来实现,当主进程接收到一些指令需要进行并行处理的时候,是通过fork()出一个子线程进行处理的,虽然这样也可以实现并行处理,但有两点主要的问题
    1. 进程间数据资源不能共享
    2. 进程创建和销毁需要消耗大量性能
  2. 线程是对进程的更小细分,可以说是轻量级的进程,创建和销毁线程不需要进行资源的分配,它共享进程的资源。在许多系统中,创建一个线程要比创建一个进程快 10 - 100 倍。同一个进程中可以创建多个线程,它们可以独立并发运行,并且共享进程中的内存数据。

2、JVM中的线程

2.1、如何创建线程

继承Thread进行线程创建

class PrimeThread extends Thread {
           long minPrime;
           PrimeThread(long minPrime) {
               this.minPrime = minPrime;
           }
   
           public void run() {
               // compute primes larger than minPrime
                . . .
           }
}
 
 
PrimeThread p = new PrimeThread(143);
p.start();

实现Runnable方法

class PrimeRun implements Runnable {
           long minPrime;
           PrimeRun(long minPrime) {
               this.minPrime = minPrime;
           }
   
           public void run() {
               // compute primes larger than minPrime
                . . .
           }
}
 
 
PrimeRun p = new PrimeRun(143);
new Thread(p).start();

Thread和Runnable 从JDK1.0就已经有了

2.2、源码分析

创建线程初始化

private void init(ThreadGroup g, Runnable target, String name,long stackSize, AccessControlContext acc) {
    if (name == null) {      
          throw new NullPointerException("name cannot be null");  
    }   
    this.name = name;  
    Thread parent = currentThread();   
    this.group = g;
   this.daemon = parent.isDaemon();
    this.priority = parent.getPriority();
    this.inheritedAccessControlContext =acc != null ? acc : AccessController.getContext();
    this.target = target;
   setPriority(priority);
     /* Stash the specified stack size in case the VM cares */
     this.stackSize = stackSize;
     /* Set thread ID */
     tid = nextThreadID();
}

启动线程

public synchronized void start() {
    /**    
    * A zero status value corresponds to state "NEW".
    */
    if (threadStatus != 0)
      throw new IllegalThreadStateException();
    /* Notify the group that this thread is about to be started
    * so that it can be added to the group's list of threads
    * and the group's unstarted count can be decremented. */
    group.add(this);
    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
              group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
        /* do nothing. If start0 threw a Throwable then              it will be passed up the call stack */         
        }
    }
}
#原生C方法,最终会调用操作系统的方法
private native void start0();

native方法源码解析https://www.cnblogs.com/lusaisai/p/12729334.html

2.3、线程状态

2.3.1、JVM线程状态:

A thread state. A thread can be in one of the following states:

  • NEW A thread that has not yet started is in this state.(线程刚创建时默认状态)
  • RUNNABLE A thread executing in the Java virtual machine is in this state.(运行状态)
  • BLOCKED A thread that is blocked waiting for a monitor lock is in this state.(阻塞状态)
  • WAITING A thread that is waiting indefinitely for another thread to perform a particular action is in this state.(等待状态)
  • TIMED_WAITING A thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state.(超时等待状态)
  • TERMINATED A thread that has exited is in this state.(终止状态)

A thread can be in only one state at a given point in time. These states are virtual machine states which do not reflect any operating system thread states.

Since: 1.5

See Also:getState

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED, 
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}

2.3.2、操作系统线程状态:

  • NEW
  • READY
  • RUNNING
  • BLOCKED
  • TERMINATED

2.3.3、线程状态流转图:

B7E183C7-484C-4B01-9777-CBAE5A2BDD5A

2.3.4、操作系统状态和JVM状态对比与联系

操作系统JVM备注
NewNew
ReadyRunning等待cpu片段调度运行
Running真正消耗CPU资源,进入运行状态
BlockedBlockedJava中未获取到锁
Waiting调用了wait/join/park主动陷入阻塞
TimedWaiting调用了sleep/wait/join/park等主动陷入阻塞,并设置了超时唤醒时间
TerminatedTerminated

3、线程监控

我们知道了如何创建线程和线程有哪些状态,那我们怎么去验证呢?当线程出现问题的时候怎么去排查呢?

3.1、使用工具进行线程监控

3.1.1、使用jvisualvm工具

jdk自带的监控软件,可以在jdk的安装目录里找到

监控本地jvm进程
打开软件后,在本地运行的所有jvm进程都会在软件左侧的菜单中进行自动展示

12E0B251-6DC6-45AB-A7E2-3C4772AF3ED6
我们可以写一段需要CPU计算的代码进行模拟

public class Thread1Test {
 
 
    public static void main(String[] args) {
        DemoThread thread = new DemoThread();
        thread.start();
    }
 
 
    static class DemoThread extends Thread {
 
        public DemoThread() {
            super("demoThread1");
        }
 
        @Override
        public void run() {
            while (true) {
                runTask();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
 
        // 计算圆周率
        public void runTask() {
            double p = 2;
            for (int i = 0; i < 500000000; i++) {
                p *= ((double) (((int) ((i + 2) / 2)) * 2)) / (((int) ((i + 1) / 2)) * 2 + 1);
            }
            System.out.println("Thread run result=" + p);
        }
    }
}

点击上方线程的菜单,可以看到当前jvm的所有线程及运行情况

2F4D5CCB-3FC5-4F5A-9952-2C6F4D9D0010
点击线程dump可以看到线程当前的状态:

1BDE8C77-6E35-4E96-BFCC-CF14BE1E2D0A
通过抽样器我们可以发现sleep是不消耗cpu资源的,真正消耗cpu资源的只有代码运行的方法

33B89174-84EA-438B-A2EA-33D0D66590A6

3.1.2、使用jstack命令

适合在无UI系统的服务器上使用,jdk自带命令

1、先使用jps命令找到需要查询的jvm进程pid

936B594C-6097-41EF-83FD-3167C2F6CA53

2、使用jstack命令

93BF7D02-C4DC-40E3-AE2A-D35ECBA0D44E
和上面的线程dump结果是一样的

3.2、线程其他状态验证Case

3.2.1、网络IO

目前后端主要的IO操作都在网络上,很少有磁盘IO,比如查询redis缓存,查询DB数据,查询ES数据,调用其他服务的接口等通过网络调用获取数据的形式
服务调用代码列子

public class ThreadTest2 {
 
    public static final String URL = "http://localhost:8088/slowTime/sleepTime?time=5000";
 
    public static void main(String[] args) {
        DemoThread2 thread2 = new DemoThread2();
        thread2.start();
    }
 
    static class DemoThread2 extends Thread {
 
        public DemoThread2() {
            super("DemoThread2");
        }
 
        @Override
        public void run() {
            while (true) {
                runTask();
            }
        }
 
        public void runTask() {
            try {
                OkHttpClient client = new OkHttpClient();
                Request request = new Request.Builder()
                        .url(URL)
                        .get()
                        .build();
                try (Response response = client.newCall(request).execute()) {
                    String resStr = response.body().string();
                    System.out.println("res=" + resStr);
                }
            } catch (Exception e) {
                System.err.println("调用服务失败:" + e.getMessage());
            }
        }
    }
}
 
@RestController()
@RequestMapping("slowTime")
@Slf4j
public class SlowTimeController {
 
    @GetMapping("sleepTime")
    public String sleepTime(@RequestParam("time") Long time) {
        if (time == null) {
            time = 100L;
        }
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
            log.error("休眠异常", e);
        }
        return "SUCCESS";
    }
}

线程状态监控结果:

64E5147F-6E8A-4E01-AD97-7D6B4330D99C

可以发现网络IO操作一直是运行状态
我们再看下cpu使用情况:

63F72341-676A-4526-8773-8CD51BBE1A60

几乎看不到有在使用过,是不是很奇怪,为什么线程在Running状态,但是cpu却没有任何资源的消耗呢?

Java将操作系统的IO阻塞状态对应到自己的****Running状态了,虽然此时是阻塞状态,但是是不消耗操作系统的CPU资源的,为什么Java需要这样处理呢?
当线程调用阻塞API时,线程进入休眠状态,这是指操作系统级别。从JVM层面来看,java线程状态还处于runnable状态。JVM 不关心操作系统线程的实际状态。从JVM的角度来看,等待CPU使用率(操作系统线程状态处于runnable状态)和等待I/O(操作系统线程状态处于睡眠状态)没有区别。它们都在等待某个资源,所以都处于可运行状态。

3.2.2、获取锁

代码示列:

public class ThreadDemoLockTest {
 
    private static final Object LOCK = new Object();
 
 
    public static void main(String[] args) {
        DemoThread thread3 = new DemoThread("DemoThread3");
        DemoThread thread4 = new DemoThread("DemoThread4");
        thread3.start();
        thread4.start();
    }
 
 
    static class DemoThread extends Thread {
        public DemoThread(String name) {
            super(name);
        }
 
        @Override
        public void run() {
            while (true) {
                runTask();
                System.out.println(Thread.currentThread().getName()+"运行完成");
            }
        }
 
        public void runTask() {
            synchronized (LOCK) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                }
            }
        }
    }
}

线程状态监控:

10BE5284-C1CF-484D-8067-EFA2ECBE2714

BCC62E4A-9524-4B88-A83C-FAF9E314F64C
从上面的分析来看,我们大部分后端服务都是IO密集型的而非CPU密集型,对CPU的依赖不是很高,很多时候线程都是处于IO BLOCK状态

3.3、线程问题分析Case

3.3.1、程序突然cpu负载过高且无法定位问题

我们的线上服务很可能出现这种情况,突然cpu使用率告警,cpu一直出现超过80%的占用的情况,但是看接口的请求量也没有大的调用量发生,这时候如果我们通过日志分析,调用分析等无法找到cpu使用率过高的问题,还能怎么去排查呢?

1、在服务器使用top 命令查看当前哪个进程长时间占用cpu的资源,这一步我们获取到进程的PID

top -c 

2D9F39B8-678B-4E6C-A0FE-F1C0445AE9CD

2、根据pid获取进程中哪个线程最占用cpu资源

ps p 17403 -L -o pcpu,pmem,pid,tid,time,tname,cmd # 或者 top -H -p 17403

83FE5395-0FE0-4BA3-AD03-3AF8F4C2A090

3、根据线程TID查询具体线程状态和堆栈信息

#  printf "%x\n" 17441 

691E141B-9D23-4379-A3B9-09E6B14101DC

// 使用与jvm运行相同的用户进行执行命令  jstack 17403 | grep '4421' -F 

4123D96F-DF7E-4931-8DBB-B099E18D012F

现在我们可以查询到出问题的线程是哪一个了,有线程名称,还有线程状态

4、继续借助jstack找到出问题的线程执行的代码

jstack 17403

研发部 > 11-深入理解jvm线程和线程监控 > 4123D96F-DF7E-4931-8DBB-B099E18D012F.png

备注:第2步骤中通过pid查询哪些线程占用高cpu的情况中,有时候可能出现多个线程均占用很高的cpu,这时候需要每个进行单独排查,大部分情况其他的线程为GC线程

4、使用线程池创建线程

虽然创建线程已经比创建进程快10-100倍,但是创建线程仍然需要调用操作系统方法,需要进行一些初始化分配,仍然会给cpu带来额外的开销

4.1、创建线程池

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
corePoolSize核心线程数
maxinumPoolSize最大线程数
keepAliveTime空闲线程存活时间
unit存活时间的单位
workQueue存放线程任务队列
threadFactory线程工厂,创建新线程
handler线程池拒绝处理后的任务

4.2、线程执行策略

A16F0A48-D1DA-498F-84B5-D703412FCA22

4.3、线程池这些参数应该怎么调整呢?

4.3.1、区分是IO密集型任务还是cpu密集型任务

cpu密集型:

通过上面的分析可以发现对于cpu密集型的任务会长时间占用cpu的支援,对于多核系统,每个核心也只能执行一个任务,所以对于这类的资源,我们不应该在线程池中创建的线程总数远大于系统核心线程数,这由可能导致频繁的线程切换,效率更低,应该使用队列对任务进行暂存

Case cpu密集型任务:

每个任务预计处理时间 500ms 每秒预计提交100个任务 允许最大的等待时间为5s

根据上面的信息我们可以大致计算出以下几点

  • 由于是cpu密集型,每个核数1s内只能执行2个任务
  • 假设没有队列的情况,则需要cpu个数为 100 / (1000/500) = 50 个,但这个的效果是1S内全部完成任务
  • 如果5S内需要处理完所有的任务,则需要 100 / (1000/500) / 5 = 10 个
  • 这时候队列最大堆积为 100 - 10 = 90 个

由于cpu密集型,如果想发挥最大作用,只能提升机器性能,比如cpu升级为6核12线程的,设置 corePoolSize = maxinumPoolSize = 12;队列设置为100 就可以满足了

但如果cpu只有4核4线程呢?

  • 每s最多处理8个任务
  • 5s最多处理40个任务
  • 不管我们怎么设置队列长度和调整线程数都是没有用的,最好的办法是提前拒绝任务

IO 密集型:

对于io密集型的任务,从上面的分析看大部分时间都不会消耗cpu资源的,cpu可以用空余的时间来处理更多的线程,来提高任务的吞吐率

Case io密集型任务

每个任务预计处理时间50ms,每秒预计提交1000个任务,允许最大等待时间为1s

根据上面的信息我们可以大致计算出以下几点

  • 每个任务线程1s内可以处理 1000 / 50 = 20 个任务
  • 1s内提交1000个任务,还要全部处理完毕,则实际需要线程数为 1000 / 20 = 50 个线程
  • 根据线程执行策略,我们一次提交1000个任务,要么最大线程数要达到1000个,或者队列里可以暂存(1000 - 最大线程数),但从上条计算可知,实际上并不需要这么多线程进行处理,而且申请1000个线程对于操作系统来说也实属夸张,是肯定不可取的

假如系统cpu为4核4线程

我们使用如下设置进行测试:

A6D6E22B-6E68-4807-9951-4B02E6FAF76A

可以发现如果我们设置的队列小了,是不能满足瞬时的流量峰值的,如果设置的过大,又不会创建新线程加快处理速度,这可怎么处理好呢?

推荐的参数配置参考为: