您现在的位置是:首页 > 正文

初识多线程编程

2024-04-01 00:59:58阅读 1

目录

一、什么是进程

     (1)什么是进程?

       (2)如何查看当前电脑上面的所有的进程?

   (3)进程有哪些属性:

    (4)内存与进程

进程的隔离:

二、什么是线程(Thread)

       线程的定义

     线程调度

如何解决线的调度:

        ①并行:

        ②并发:

线程调度的属性:

A.线程的状态:

B.线程的优先级:

C.上下文:

D.记账系统: 

四、为什么需要使用线程,为什么通常不是通过多进程的方式处理?

线程为什么更加轻量? (4个原因)

多线程引发一些问题:

总结一下线程和进程的区别,以及为什么要使用线程:

五、Java当中,创建线程,有哪几种方式?分别有什么不同

    ①让一个类,继承自Thread类,并且重写Run方法。

②让一个类,实现Runnable接口

③匿名内部类的方式

④使用匿名内部类的接口方式

⑤使用lambda表达式创建任务,相当于④的简写版

一、什么是进程

     (1)什么是进程?

      一个正在运行的程序,就成为进程。比如正在运行的QQ音乐,已经打开的idea编辑器。但是,如果没有运行该程序,那么他不是进程,仅仅是一个程序。每一个进程,都会消耗一定的资源。

       (2)如何查看当前电脑上面的所有的进程?

    使用快捷键:ctri+alt+delete,选择查看任务管理器:

     

在右侧,可以看到,每个进程都占用了一定的资源;比如 CPU,内存,磁盘。所以,如果当当前电脑比较卡顿的时候,可以看一下当前电脑是否有太多的进程,可能会影响运行的效率。

       因而,还可以得出结论,进程是操作系统资源分配的基本单位。进程是一个重要的“软件资源”,由操作系统内核负责进行管理的。

   (3)进程有哪些属性:

          A.Pid:进程的身份标识符。

          B.内存指针:指向了自己内存有哪些;

         C.文件描述表:硬盘上的文件等其他资源。


    (4)内存与进程

 

       如图所示,为一个内存条,有10个大小的“门”,每个门的下标都是从0开始,这个内存编号,就是所谓的“物理地址”。每个房间的“门”的大小为1Byte

      内存拥有一个比较特别的特性->访问任意地址的数据,速度都极快,时间上都差不多。因此,数组取下标的时间复杂度就是O(1)

      所以,操作系统当中的各个进程,是按照数组的数据结构存储的


      

如图所示,在传统的操作系统当中,内存空间是相互不隔离的,这就导致有一些可能:比如当某一个进程出现bug的时候很有可能就会出现另外一个进程也出现问题。即:有可能指针越界,影响了其他的内存执行。

所以,需要对进程正在使用的空间,进行“隔离”,因此引入了“虚拟地址空间”。而不是真实的地址空间。如上图,进程1访问的空间为OX1000~OX1FFF,进程2访问的空间为0X8000~0X8FFF,这都是真实的地址空间。

进程的隔离:

虚拟地址空间:为了能够避免进程之间产生相互影响

由操作系统和专门的MMU硬件设备,负责进行虚拟地址到物理地址的转换。当进程开始运行的时候,CPU会为其在虚拟空间当中分配一块内存空间,后面通过操作系统的MMU硬件设备,会映射到真实的物理空间当中。

如果一个进程出现错误的时候,操作系统内核发现当前这里的地址超出该进程的访问范围,就会发送一个错误的信号,引起进程的崩溃。


二、什么是线程(Thread)

       线程的定义

        线程,其实是要针对于进程这个概念,来产生的。

       线程是 操作系统能够进行运算调度的最小单位。每个进程至少包含一个线程。一个进程可以有很多线程,每条线程并行执行不同的任务。


       每一个线程,在操作系统当中都对应一个PCB。一个进程当中的多个线程的pid相同。共用一块内存空间。每个线程都有自己的执行逻辑,这个逻辑称为(执行流)。一个进程当中的多个PCB,是使用双向链表的数据结构组织的。

      每一个线程执行自己的任务的时候,就被操作系统调度到CPU上面执行自己的任务。

     图解:

    


     线程调度

        线程可能存在上百个,但是,CPU是如何分配资源的呢?难道只有一个CPU负责运行上百个进程吗?

下面,就要引入一个概念:让大量的线程在少数的CPU上面同时运行,每一个线程都拥有自己的执行流,它们操作系统调度到CPU内核上面各自执行自己的任务,这样的一个过程,就是线程调度。


如何解决线的调度:

        ①并行:

       微观上同一时刻,两个核心上的进程,是同时执行的。比如一个CPU上面运行QQ,另外一个CPU上面运行微信。二者同时运行,这就是并行。


        ②并发

       微观上,同一时刻,同一个核心只能运行一个线程,但是它可以对进程进行快速的切换。比如,在同一个CPU内核当中,先运行QQ音乐,再运行QQ,再运行微信......但是,他运行的时候,切换地特别快,让你无法感觉到一样。有多块?(2.5GHZ)即:每秒执行25亿次,如此之快的速度,肉眼怎样可以发掘呢?

     因此,线程的调度,解决方案就是并行+并发,统一称之为并发。


线程调度的属性:

A.线程的状态:

           了解了并发与并行之后,再了解一下各个进程的状态:即:哪些进程是在运行,哪些没有在运行,哪些进程在干什么?

           (1)就绪状态,随时准备去CPU上面运行。通俗地讲,随叫随到,随时准备好去CPU上面运行。

             (2)   运行状态,正在CPU上面运行的线程,它的状态就是运行状态。

             (3)   阻塞状态,短时间内无法去CPU上面运行。


B.线程的优先级:

             Java基础篇:什么是线程优先级?_kaikeba的博客-CSDN博客_什么是线程优先级,它在线程调度中的作用?


C.上下文:

        本质上 :保存的就是程序运行过程中的中间结果。预防一个线程执行到一半,然后离开CPU,再回来时候发现不是原来的状态了。

        比如一个线程当中有任务for(int i=0;i<100;i++){

              System.out.println(i);

}

        当执行到i=70的时候,这个线程突然被调度离开CPU了,那么当这个线程继续被调度到CPU上面执行的时候,仍然会从上一次执行的地方开始执行。因此将会继续输出i=71,72......

D.记账系统: 

       统计每个线程进行的次数,防止某个进程执行次数过多,或者其他进程执行次数过少。让每个线程尽量被调度到CPU上面执行自己的任务的时间比较平均。


四、为什么需要使用线程,为什么通常不是通过多进程的方式处理?

       这里,需要重新回顾一下进程的相关知识:进程,是一个正在运行的程序,进程是操作系统资源分配的基本单位。

     进程由于”太重了“,何为”太重了“?即:创建一个进程开销比较大,调度,销毁一个进程开销也比较大。因此,线程就产生了:线程,”也称为轻量级进程"。因此,为了在解决并发问题的前提下面,让创建,销毁,调度进行的更快一些,就引入了线程

的概念。


线程为什么更加轻量? (4个原因)

       

       原因1:线程的创建时间比较快。因为进程在创建的过程当中,还需要涉及文件的管理文件的打开与关闭操作等等,而线程只是共享;

       原因2:线程的终止时间比较快,因为线程需要释放的资源比进程少;

       原因3:同一进程下面的所有线程共享进程的文件。这也就意味着,线程之间在进行数据传递的时候,不需要经过内核,不需要系统调用,也就提高了效率。

       原因4:一个进程当中的所有线程都拥有相同的虚拟地址空间。也就是,同一个进程当中的所有线程都共用进程的同一个页表,线程切换的时候,不需要切换页表。

       切换页表也是开销比较大的


我们画一张图来理解一下:多进程与多线程。

 如图所示,这是一个房间,里面正在运行一个程序:一个人要吃掉桌面上这一只鸡,按照传统的情况,他可以一个人吃完。如果分开两个房间。这一只鸡分开两半,那么,如果两个人同时吃,确实也可以提高吃的效率:

下面这张图,就是多进程的描述:

       但是这样,就需要多申请一块内存空间,来执行吃这一只鸡的任务。也就是说,传统一个进程仅仅运行一个线程这种方式,比较消耗内存。因为,进程是操作系统进行资源分配的基本空间,每多开启一个进程,是需要消耗新的内存空间的。因此,引入了线程:

 如图所示,还是在当前这一个房间内,吃一只鸡,这个时候引入了多个人,同时吃鸡,这就可以在不多消耗内存空间的前提下面,提高了”吃完这只鸡“的运行效率。因此,总的”吃完这只鸡“,可以划分成各个线程”每个人都一起吃鸡,直到大家都吃完“,吃完之后,当前的这个进程也就结束了。因此,多线程的一个目的,多线程是为了同步完成多项任务,为了提高资源使用效率来提高系统的效率。


多线程引发一些问题:

       ①如果一个房间内,人太多,同时“吃鸡”,那么,有可能导致正在“吃鸡”的人,大家相互推推攘攘,导致正在“吃”的人没有办法专心吃。对应在实际情况当中,就是,线程太多,核心数目有限,不少的开销会浪费在线程的调度上面。所以,多线程不一定可以提高程序运行的效率,在一些特定的场景下面,甚至有可能降低程序运行的效率

      下面有一个业务场景,现在有一个任务:让其中一个变量int a+=5共count次,让另外一个变量int b自减count次。如果是单线程,写法如下:

 public static int count=10000;
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        //任务1:+=5共count次
        int a = 0;
        for (int i = 0; i < count; i++) {
            a += 5;
        }
        //任务2:--b共count次
        int b = 0;
        for (int i = 0; i < count; i++) {
            b--;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

如果是多线程,把这一个任务拆解成两个子任务,其中一个线程执行自增count次的任务,另外一个线程执行执行自减count次的任务,代码如下:

public static int count=10000;
    public static void main(String[] args) {
       long start=System.currentTimeMillis();
       //任务1:让一个线程单独自增count次
        Thread t1=new Thread(new Runnable() {
            @Override
            public void run() {
                int a=0;
                for(int i=0;i<count;i++){
                    a+=5;
                }
            }
        });

       //任务2:让另外一个线程单独自增count次
        Thread t2=new Thread(new Runnable() {
            @Override
            public void run() {
                int b=0;
                for(int i=0;i<count;i++){
                    b--;
                }
            }
        });
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //统计执行的时间
        long end=System.currentTimeMillis();
        System.out.println(end-start);
    }

       关于这两个不同的写法,在《Java并发编程的艺术》当中,有一个专门的测试统计数据:

循环次数 串行执行耗时/ms 并发执行耗时/ms 并发比串行快多少
1亿

130

77 约1倍
1千万 18 9 约1倍
1百万 5 5 差不多
10万 4 3 并发略快
1万 <0 1

 可以看到,在数据量比较小的时候,串行执行反而比并发快。这就是因为,在两个线程并发执行当中,操作系统调度,线程上下文切换的,也是需要开销耗时的。


       ②还会引发一系列的线程安全问题,下一篇文章会介绍。

        但是,多进程并行执行的情况下面就不会导致这种情况发生。因为一个进程只有一个线程,每个进程当中的唯一线程只会运行在一个CPU核心当中,只占用一块内存资源。每个进程不会共享内存空间。


总结一下线程和进程的区别,以及为什么要使用线程:

     随着多核CPU时代的降临,并发编程成为“刚需”。

①线程的空间的利用效率得到提升:

        每开辟一个进程,就需要多开辟一块内存空间,如果需要执行的任务过多,就有可能造成内存空间的浪费;但是同一个进程当中,可以拥有多个线程,多个线程共享当前线程开辟的内存空间。

     ②线程比进程更加轻量:线程的创建,销毁,调度,相比起进程,会耗时更少;

     ③线程是操作系统进行运算调度的基本单位,每一个线程都拥有自己的执行流,会被操作系统轮流调度到CPU内核上面执行自己的任务;进程是操作系统进行系统内存空间分配的基本单位,每一个进程都有自己独立的内存空间;

      ④进程和线程相比,进程的运行环境更加安全。因为每一个进程在操作系统当中都有一块自己的内存空间,各个进程之间的内存空间是相互隔离的;而每一个线程都会与自己对应的同一进程下的所有线程共享同一块内存空间。多个线程并发执行,会有线程安全问题。

      ⑤有一些特殊的业务场景,可能会比较耗时,比如"等待IO",比较耗时。为了让等待IO的时间可以去执行其他一些任务,于是有了线程。


五、Java当中,创建线程,有哪几种方式?分别有什么不同

    ①让一个类,继承自Thread类,并且重写Run方法。

 在主方法中,让父类的指针指向子类的引用。并且产生的父类对象调用start方法。

       其中,run方法具体的执行,就是由新创建的线程:thread来执行的。误区:run方法是在start方法内部调用的:错误!start方法只是创建一个线程,并没有直接调用run方法。run方法,是由新创建的线程去执行的。调用的start方法之后,相当于调用了操作系统的API,通过操作系统内核创建PCB。并且把要执行的指令交给了这个PCB。当PCB被调度到CPU上面执行的时候,也就执行了线程的run方法了。

start方法和run方法的区别?

start方法,相当于调用操作系统内核,创建新的PCB,创建新的线程。run方法,描述的是线程的执行的过程。

图解:

运行后:

 控制台输出了"hello world"。这里有一个问题:

刚刚的代码当中,继承一个Thread类,重写了run方法,然后调用.start()方法启动线程,和直接在主方法输出"hello world"这两种方式有什么不同?

       具体的不同就是,如果直接输出hello world,那么相当于只有1个线程在执行,即:主线程。如果创建一个新的线程,然后调用start方法的话,只相当于主线程创建了另外一个线程,也就是说,运行的时候,一共是2个线程 .新创建的线程去执行run方法。

体验一下:两个线程同时运行的效果:

class MyThread extends Thread{
    /**
     * 重写Run方法
     */
    @Override
    public void run() {
        //主线程,调用t.start(),创建一个新的线程,
        //新的线程,调用start方法。
        while (true) {
            System.out.println("hello world");
        }
    }
}
/**
 * @author 25043
 */
public class ThreadDemo1 {

    public static void main(String[] args) throws InterruptedException {
        Thread thread=new MyThread();

        thread.start();
        //run,只是描述了线程需要干的活是什么
        //如果紧急run,那就是相当于单个线程,并没有创建其他的线程。

        while (true){
            System.out.println("hello main");
        }

    }
}

运行:

 可以看到,两个线程,即使在不同无限循环当中,也会重复执行。

如果是单线程的话,仅仅只会反复输出"hello world"或者“hello main"。

至于哪个线程先执行,哪个后执行,这个没有确定的,这是由操作系统负责调度时候决定。也就是说,操作系统安排哪个线程先到CPU上面运行,哪个线程就先输出语句。

在第一种方法当中:任务指的是执行"run"方法的内容,任务与新创建的线程”耦合“在一起,耦合度比较大。接下来,看一种耦合度比较小的方式

②让一个类,实现Runnable接口

/**
 *
 * Runnable的作用是描述一个需要执行的接口
 * 描述一个要执行的任务
 */
class MyRunnable implements Runnable{


    @Override
    public void run() {
        System.out.println("hello");
    }
}
/**
 * @author 25043
 */
public class ThreadDemo2 {


    public static void main(String[] args) {
        //描述了一个任务
        Runnable runnable=new MyRunnable();
        //把任务交给线程执行,解耦合
        Thread thread=new Thread(runnable);

        thread.start();
    }
}

步骤:让一个类实现Runnable接口。在主线程当中,让Runnable 接口实现一个已经实现Ruaable接口的类。接着,把任务交给进程;即:Thread thread=new Thread(runnable);

经典面试问题,为什么第二种方式更加优胜?请说明原因:

答:继承Thread类,线程对象和线程任务耦合在一起。一旦创建Thread类的子类对象,既是线程对象,有又有线程任务。那个对应的线程任务就是去执行"run”方法。

再来了解一下Runnable接口,Runnable接口本质上是描述了一个任务,如果让一个类去实现Runnable接口然后重写run方法,这就意味着实现Runnable接口的这一个类相当于也描述了一个任务。接着,把这个runnable对象传给Thread的构造方法,相当于Runnable接口对线程对象和线程任务进行解耦。

③匿名内部类的方式

/**
 * @author 25043
 */
public class ThreadDemo3 {
    public static void main(String[] args) {
        Thread thread=new Thread(){
            @Override
            public void run() {
                System.out.println("hello thread");
            }
        };
        thread.start();
    }
}

以上操作,步骤为:先创建了一个Thread的子类,然后,让这个子类重写了run方法。本质和①的方式一样。都是“新建一个线程子类,让当前这个线程去执行run方法,执行它的任务”。

④使用匿名内部类的接口方式


/**
 * @author 25043
 */
public class ThreadDemo4 {
    public static void main(String[] args) {
        //本质和2一样
        Thread thread=new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello Thread");
            }
        });
        thread.start();
    }
}

 这种方式,也是匿名内部类的实现方式,但是这个被“匿”掉名称的类,实现了Runnable接口,本质上是创建了一个任务,交给thread来执行。

⑤使用lambda表达式创建任务,相当于④的简写版

/**
 * @author 25043
 */
public class ThreadDemo5 {
    public static void main(String[] args) {
        //用lambda表达式来描述,直接把lambda
        //传给Thread方法。
        Thread t=new Thread(()->
                System.out.println("hello"));
        t.start();
    }
}

网站文章

  • SpringBoot小彩蛋

    SpringBoot小彩蛋

    spingboot小彩蛋

    2024-04-01 00:59:51
  • FTP开发中下载文件的两种方式

    1. 通过 GetFile 方式下载2. 通过 CInternetFile::Read 方式下载两种方式的区别:第一种方式,操作级别较高。直接调用就好,这种方式封装了一切操作。第二种方式,自己可以控制。其基本原理,就是在网络上打开一个文件,就像本地打开文件一样。读取,然后写入到本地文件。以下代码,两种方式都有,第二种方式注释掉了。打开即可使用。在FTP下载中,碰到的奇异问题:下载大于100...

    2024-04-01 00:59:44
  • atoi()函数原型与itoa()函数原型

    1、atoi函数原型: #include using namespace std; int atio1(char *s) { int sign=1,num=0; if(*s==&#39;-&#39;) sign=-1; s++; while((*s)!=&#39;\0&#39;) { num=num*10+(*s-&#39;0&#39;); s++; }

    2024-04-01 00:59:20
  • 海量数据处理思想 + 一些例题

    海量数据,顾名思义就是数据量太大,内存里装不下,基本思路就是分治,借助一些合适的数据结构;下来看一下具体的例子一、bit-map:使用bit数组来表示元素是否存在,这样只需要存储比特位即可;1、如果有...

    2024-04-01 00:59:12
  • kafka可视化工具kafka tool

    kafka可视化工具kafka tool

    kafka tool官网下载地址http://www.kafkatool.com/download.html连接zookeeper服务地址其中message信息乱码,解决方案如下:1、点击tools—settings—选择topics中将key message设置为string2、选择指定的topic中properties将key message设置为string ...

    2024-04-01 00:59:05
  • Java中httpClient中三种超时设置

    本文章给大家介绍一下关于Java中httpClient中的三种超时设置小结 在Apache的HttpClient包中,有三个设置超时的地方:/* 从连接池中取连接的超时时间*/ ConnManagerParams.setTimeout(params, 1000); /*连接超时*/ HttpConnectionParams.setConnectionTimeout(params, 2000)

    2024-04-01 00:58:41
  • 从微服务开始 vs 不从微服务开始

    从微服务开始 vs 不从微服务开始

    本文的题目看似自相矛盾,实则不然。 我想讲两个故事。一个是不从微服务开始,一个是从微服务开始。我认为,通过观察事物的两面,我们将对微服务的实际好处有更多的了解。 闲话少叙,言归正题。 不从微服务开始 ...

    2024-04-01 00:58:33
  • python引用另一个py的函数

    引用test.py的函数testafrom test import testatesta() 或者import testtest.testa()

    2024-04-01 00:58:26
  • VSCode 联合调试Python/C++

    VSCode 联合调试Python/C++

    本文选择Vscode实现Python/C++代码的联合调试,一是它跨平台,二是通过插件支持多语言代码编辑以及调试。在尝试ptvsd调试器失败后换用gdb调试器做讲解。

    2024-04-01 00:58:03
  • 《机器学习高频面试题详解》4.4:模型融合:Bagging

    机械工程师岗,18K x (14-16)薪,七成是基本工资,三成绩效,公积金12%,每月交通补助600元,每日餐补大约20元,试用期半年,试用期内工资80%,宿。三方违约金是一个月工资,但是我现在没有...

    2024-04-01 00:57:57