掘金 后端 ( ) • 2024-04-20 09:21

写在前面

如果想成为一个合格的开发人员,多线程和并发编程的知识是必须要掌握的,不管是在日常业务开发中,还是学习其他框架源码的过程中,我们都不可避免的要和并发编程打交道,所以了解掌握并发编程是我们的一门必修课,接下来我们将一起开始学习并发编程,将并发编程一网打尽!

本章主要学习进程和线程的基础知识,对一些概念的扫盲和回顾,从基础知识开始入手,渐进式学习。

进程

1、进程的由来

首先我们先要知道程序是什么?程序就是一个指令序列。在早期的计算机中,只支持单道程序,也就是说同一时间段内只能有一个程序在运行,此时cpu只为这一个程序服务,内存当中也只会存在一个程序相关的数据,这个程序运行相关的数据包括程序段和数据段。这里的程序段指的就是程序运行的代码,而程序运行时产生的数据(如变量,常量等)则存在数据段中。引入多道程序技术之后,同一个操作系统可以有多个程序并发的执行,那么内存当中也会产生多个程序相关的数据。此时就会引发一个问题,操作系统作为多个程序的管理者,它是怎么知道各个程序的存放位置呢,除此之外,系统的设备可能是分配给不同程序的,这些信息也是需要记录下来的,于是就引入了进程的概念。

2、进程的定义

那么操作系统是怎么管理多个程序的呢?操作系统在每个程序执行之前,都会创建一个PCB(就是一个数据结构),这个PCB会管理程序运行的各种信息,包括程序代码的存放位置啊,数据段的存放位置啊等等。那么由PCB、程序段、数据段三部分就构成了进程。所谓创建进程,实质上就是创建一个PCB。对于进程的定义还有很多,比如: 1)进程是程序的一次执行过程 2)进程是系统进行资源分配和调度的一个独立单位。

3、什么是PCB?

在上一个小节中,我们提到在进程的创建过程中会创建一个PCB,那么什么是PCB呢?

PCB就是存放操作系统对进程管理的数据,有进程标识符PID,进程的状态,程序段、数据段的指针,以及各种寄存器的值。其中PID就是这个操作系统为这个进程分配的一个唯一的、不重复的ID,类似于身份证号。程序段指针、数据段指针可以理解成在内存中存放的位置。为什么要存放寄存器的值呢,是因为当进程切换的时候,需要把当前运行的情况记录下来,也就是保护现场,比如程序计数器的值表示当前程序执行到了哪一句。

4、进程的状态与切换

在操作提供执行程序的过程中,整个进程从创建到结束会有多种状态:

  1. 创建态:进程正在被创建,操作系统为其分配资源、初始化PCB
  2. 就绪态:进程已经创建完成,拥有了除CPU之外的所有资源,等待被CPU调度
  3. 运行态:占有CPU,并在CPU上运行
  4. 阻塞态:因等待某一事件而暂时不能运行,比如等待磁盘读取的结果
  5. 终止态:程序执行完成,操作系统开始回收进程的资源,撤销PCB

注意:单核CPU下,只能有一个进程处于运行态!~

那在整个进程的生命周期当中,以上状态是怎样切换的呢?请看下图:

5、总结

进程是统进行资源分配和调度的一个独立单位,由以下三部分组成:

  1. PCB :进程管理相关的数据
  2. 程序段:存放要执行的代码
  3. 数据段:存储程序执行过程中处理的各种数据

线程

1、线程的定义

现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作系统就会创建一个Java进程。现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局 部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。

2、线程和进程的区别

  • 线程是CPU调度的最小单元,线程是进程的子集。
  • 线程之间资源共享,进程之间资源是不共享的。

3、如何创建线程?

1.继承Thread类,重写run方法

public class ThreadCreate {
    public static void main(String[] args) {
        MyThread1 myThread1 = new MyThread1();
        myThread1.start();
        System.out.println("主线程name:" + Thread.currentThread().getName());
    }
    static class MyThread1 extends Thread{
        public synchronized void run() {
            System.out.println("MyThread1 启动。。。");
            System.out.println("MyThread1 name:" + Thread.currentThread().getName());
        }
    }
}

2.实现Runnable接口,重写run方法

public class ThreadCreate {
    public static void main(String[] args) {
        MyThread2 myThread2 = new MyThread2();
        Thread thread = new Thread(myThread2);
        thread.start();
        System.out.println("主线程name:" + Thread.currentThread().getName());
    }
    static class MyThread2 implements Runnable{
        public void run() {
            System.out.println("MyThread2 启动。。。");
            System.out.println("MyThread2 name:" + Thread.currentThread().getName());
        }
    }
}

3.使用Callable和Future创建线程

public class ThreadCreate {
    public static void main(String[] args) {
        MyThread3 myThread3 = new MyThread3();
        FutureTask<String> futureTask = new FutureTask<String>(myThread3);
        new Thread(futureTask,"有返回值的线程").start();
        System.out.println("子线程的返回值" + futureTask.get());
    }
      static class MyThread3 implements Callable{
        public Object call() throws Exception {
            return "MyThread3";
        }
    }
}

4.使用线程池创建线程

线程池创建后续会单独探讨~

4、线程的状态

  • 新建(NEW)新建了一个线程,但是还没有调用start方法
  • 运行(RUNNABLE)Java线程中将就绪(Ready)和运行中(Running)两种状态统称为“运行”。线程对象创建后,其它线程(比如main线程)调用了该对象的start方法。该线程等待被线程调度器执行,获取cpu的使用权,此时状态处于就绪状态。就绪状态的线程获取到了cpu的时间片后开始变成为运行中状态。
  • 阻塞(BLOCKED)线程阻塞于锁
  • 等待(WAITING)进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)
  • 等待超时(TIMED_WAITING)不同于等待,它可以在指定的时间后自行返回。
  • 终止(TERMINATED)表示该线程已经执行完毕

摘自于Java并发编程的艺术

5、线程的优先级

1.什么是线程的优先级?

首先我们已经知道,线程是CPU调度的基本单位,也就说线程的运行需要CPU来调度。那么CPU是通过什么方式来调度运行的线程呢?现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性。

2.如何设置线程的优先级?

Thread类有一个setPriority()方法,通过一个整型成员变量newPriority来设置线程的优先级,我们可以通过下面的源码看到newPriority值的范围在1-10之间,否则就会抛出异常,如果不设置的话,默认就是5。

接下来我们将通过一个案例来测试下设置了线程优先级之后,是不是和我们想的那样,priority越大,执行的优先级越高。

package com.xieanzhu.concurrency.base;

import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

/**
 * @author xieanzhu281
 * @Description 线程的优先级
 * @createTime 2022/3/19 3:50 下午
 */
public class ThreadPriority {
    private static volatile boolean notStart = true;
    private static volatile boolean notEnd = true;
    public static void main(String[] args) throws InterruptedException {
        ArrayList<Job> jobs = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            int priority = i < 5 ? Thread.MIN_PRIORITY : Thread.MAX_PRIORITY;
            Job job = new Job(priority);
            jobs.add(job);
            Thread thread = new Thread(job, "Thread:" + i);
            thread.setPriority(priority);
            thread.start();
        }
        notStart = false;
        TimeUnit.SECONDS.sleep(10);
        for (int i = 0; i < jobs.size(); i++) {
            System.out.println("Job priority : " + jobs.get(i).priority + ",Count :" + jobs.get(i).jobCount);
        }
    }
    static class Job implements Runnable{
        private int priority;
        private long jobCount;
        public Job(int priority){
            this.priority = priority;
        }
        @Override
        public void run() {
            while (notStart){
                Thread.yield();
            }
            while (notEnd){
                Thread.yield();
                jobCount++;
            }
        }
    }
}

通过上面运行的结果我们发现,priority设置10的线程可能反而还没有设置1的线程执行次数多,这就表明了程序正确性不能依赖线程的优先级高低。

注意: 线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定。

6、为什么要使用多线程?

传统的软件系统中,用户基数、数据量、业务场景没那么复杂的情况下,我们可能并不需要使用多线程。但是当用户增长到一定程度,或者需要处理大量数据的时候,又或者特殊业务场景下,多线程就会帮我们解决很多问题。比如现在有一个需求,需要在用户下单15分钟之后没有支付的时候,发送短信提醒用户目前有待支付的订单。我们需要在下单之后发送一个延迟消息,这个操作不能影响原先下单的逻辑,所以我们需要单独使用另一个线程去处理发送异步消息的操作。或者在计算大批量数据的时候,也可以利用多线程分批去处理,这样就能更好的利用cpu资源。下面就总结下使用多线程的好处:

  • 更多的处理器核心
    线程是大多数操作系统调度的基本单元,一个程序作为一个进程来运行,程序运行过程中能够创建多个线程,而一个线程在一个时刻只能运行在一个处理器核心上。试想一下,一个单线程程序在运行时只能使用一个处理器核心,那么再多的处理器核心加入也无法显著提升该程序的执行效率。相反,如果该程序使用多线程技术,将计算逻辑分配到多个处理器核心 上,就会显著减少程序的处理时间,并且随着更多处理器核心的加入而变得更有效率。

  • 更快的响应时间
    有时我们会编写一些较为复杂的代码(这里的复杂不是说复杂的算法,而是复杂的业务逻辑),例如,一笔订单的创建,它包括插入订单数据、生成订单快照、发送邮件通知卖家和记录 货品销售数量等。用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。但是这么多业务操作,如何能够让其更快地完成呢?在上面的场景中,可以使用多线程技术,即将数据一致性不强的操作派发给其他线程处理(也可以使用消息队列),如生成订单快照、发送邮件等。这样做的好处是响应用户请求的线程能够尽可能快地处理完成,缩短了响应时间,提升了用户体验。

  • 更好的编程模型
    Java为多线程编程提供了良好、考究并且一致的编程模型,使开发人员能够更加专注于问题的解决,即为所遇到的问题建立合适的模型,而不是绞尽脑汁地考虑如何将其多线程化。一 旦开发人员建立好了模型,稍做修改总是能够方便地映射到Java提供的多线程编程模型上。

7、多线程一定快吗?

有些同学可能觉得,我使用了多线程,就会更好的利用CUP的资源,当然会更快了。对于这种问题,我们先不去下结论到底多线程快不快,直接上代码来证实这个问题。

package com.xieanzhu.concurrency.base;

/**
 * @author xieanzhu281
 * @Description 串行,并发执行测试
 * @createTime 2022/3/19 4:20 下午
 */
public class ThreadConcurrencyTest {

    private static final long count = 10000L;
    
    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }

    //多线程 计算 a,b + count次
    private static void concurrency() throws InterruptedException {
        long startTime = System.currentTimeMillis();
        Thread thread = new Thread(() -> {
            int a = 0;
            for (int i = 0; i < count; i++) {
                a += 5;
            }
        });
        thread.start();
        int b = 0;
        for (int i = 0; i < count; i++) {
            b += 5;
        }
        long time = System.currentTimeMillis() - startTime;
        thread.join();
        System.out.println("concurrency :" + time+"ms");

    }
    //单线程 计算 a,b + count次
    private static void serial(){
        long startTime = System.currentTimeMillis();
        int a = 0;
        for (int i = 0; i < count; i++) {
            a += 5;
        }
        int b = 0;
        for (int i = 0; i < count; i++) {
            b += 5;
        }
        long time = System.currentTimeMillis() - startTime;
        System.out.println("serial :" + time+"ms");
    }
}

上面代码分别计算a、b count次,这个时候分别测试下count等于1万,10万,100万,1000万,1亿,10亿的时候,两者的速度,得到结果如下表:

循环次数 串行执行耗时/ms 并行执行耗时/ms 串行并行速度比较 1万 0 51 串行比并行快 10万 2 53 串行比并行快 100万 5 55 串行比并行快 1000万 14 60 串行比并行快 1亿 99 101 串行和并行差不多 10亿 901 558 并行比串行快

通过上面测试可以得到这样一个结果,在计算次数较小的时候,串行的速度是明显优于并行的,当计算次数逐渐增加时,并行的优势就体现出来了,所以我们可以说多线程不一定比单线程快,那么原因在哪呢?其实是因为线程有创建和上下文切换的开销。

8、什么是线程的上下文切换?

CPU在进行线程调度的时候是给每线程分配时间片,时间片用完后会切换到另外一个线程。在切换线程之前会保存上一个线程的执行状态,如果再次切换回来,会继续在上次保持的状态上执行操作。这个就是线程的上下文切换。类似我们在看英语书一样,如果遇到不认识的单词,先记住读到什么地方了,然后再拿牛津词典去翻阅查询,查完之后再继续读,这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

9、如何减少线程的上下文切换

  1. 无锁并发编程
  2. CAS算法
  3. 使用最少线程
  4. 使用协程

10、守护线程

在Java中,线程分为用户线程和守护线程。简单来说,在jvm中,守护线程是为用户线程服务的,只要还有一个用户线程没结束,那么所有的守护线程都会继续工作。如果所有的用户线程结束后,守护线程会随着jvm一同结束工作。 守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。

可以通过下面的方式手动设置守护线程

Thread daemonTread = new Thread();     
// 设定 daemonThread 为 守护线程,default false(非守护线程)  
daemonThread.setDaemon(true);  
// 验证当前线程是否为守护线程,返回 true 则为守护线程  
daemonThread.isDaemon();