线程 & 多线程

x33g5p2x  于2021-11-23 转载在 其他  
字(4.4k)|赞(0)|评价(0)|浏览(343)

线程

线程概念

所谓的"线程",可以理解成轻量级"进程",也是一种实现并发编程的方式

如果把一个进程,想象成是一个工厂,线程就是工厂中的若干个流水线

为啥要有线程?

可以实现并发编程

  • 单核 CPU 的发展遇到了瓶颈,要想提高算力,就需要多核 CPU,而并发编程能更充分利用多核 CPU 资源
  • 有些任务场景需要 “等待 IO”,为了让等待 IO 的时间能够去做一些其他的工作,也需要用到并发编程
  • 线程之间共享的资源多,完成一些操作时更方便

线程比进程更轻量

  • 创建一个线程,比创建一个进程成本低
    销毁一个线程,比销毁一个进程成本也低
    (成本低的原因:新创建一个线程,不需要给这个线程分配很多的资源
    如果新建一个进程,就需要给这个进程分配较多的资源)
  • 创建线程比创建进程更快
    销毁线程比销毁进程更快
    调度线程比调度进程更快

实际进行并发编程的时候,多线程方式要比多进程方式更常见,也效率更高

线程和进程的联系和区别

联系:

  1. 线程其实是包含在进程中的 (每个进程至少有一个线程存在,即主线程)
  2. 一个进程中可能会有多个线程
  3. 每个线程都有一段自己要执行的逻辑 / 指令
    (每个线程都是一个独立的"执行流")
  4. 同一个进程中的很多线程之间,共享了一些资源

同一个进程的多个线程之间共享的资源主要是两方面:
1.内存资源(两个不同进程之间,内存不能共享)
2.打开的文件
也有一些不是共享的资源:
1.上下文 / 状态 / 记账信息 / 优先级(每个进程要独立的参与 CPU 的调度)
2.内存中有一块特殊的区域:栈 (每个线程要独立一份)

区别:

  • 进程是操作系统分配资源的最小单位
    线程是造作系统进行调度和执行的最小单位 (所谓的操作系统进行进程调度,本质上就是操作系统针对这个进程的若干个线程进行调度)
  • 当创建一个进程的时候,就会自动随之创建一个线程(主线程)
    一个进程被创建出来的同时,至少会随之创建一个线程 (主线程)
  • 进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间

线程管理

本质上和管理进程一样,先用PCB描述,再使用双向链表来组织

内核只认 PCB
即:一个线程和一个 PCB 对应,而一个进程可能和多个 PCB 对应

上述逻辑是在 Linux 中的实现方式

多线程

吃鸡例子: 滑稽吃100只鸡

思考:线程数量是越多越好吗?

不是,因为线程的调度是有开销的,随着线程数量的增多,线程调度的开销也就越大
线程数量太多,非但不会提高效率,反而会降低效率

那么:一个进程中,最多能有多少个线程呢?

  1. 和 CPU 个数有关
  2. 和线程执行的任务的类型也相关
    a) CPU 密集型:程序一直在执行计算任务
    b) IO 密集型:程序没咋进行计算,主要是进行输入输出操作

假设这个主机有 8核 CPU (两种极端情况:)
若任务纯是 CPU 密集型的,此时线程的数目大概就是 8 个左右
若任务纯是 IO 密集型的,理论上,有多少个线程多可以
现实中的情况是要介于两者之间,实践中一般需要通过 测试 的方式来找到合适的线程数,让这个程序效率够高,同时系统的压力也不会过大

多线程程序缺点

第一个多线程程序

Java 中如何使用多线程?
标准库中提供了一个类:Thread 类

public class ThreadDemo1 {
    static class MyThread extends Thread{
        @Override
        public void run() {
            System.out.println("Hello World, 我是一个线程");
        }
    }

    public static void main(String[] args) {
        // 创建线程需要使用 Thread 类,来创建一个 Thread 的实例
        // 另一方面还需要给这个线程指定 要执行哪些指令/代码
        // 指定指令的方式有很多种,此处先用一种简单的,直接继承Thread类,
        // 重写 Thread 类中的 run 方法

        // 当 Thread 对象被创建出来的时候,内核中并没有随之产生一个线程(PCB)
        Thread t = new MyThread();
        // 执行这个 start 方法,才是真的创建出一个线程
        // 此时内核中才随之出现了一个 PCB,这个 PCB 就会对应让 CPU 来执行该线程的代码(上面的run方法中的逻辑)
        t.start();
    }
}

输出结果:

为了进一步观察当前确实是俩线程,可以借助第三方工具
JDK 中内置了一个 jconsole

但此时并不能看到线程信息,因为当前进程已经结束了

必须要想办法让进程不要那么快结束,才能看到线程信息

直接安排一个死循环~

public class ThreadDemo1 {
    static class MyThread extends Thread{
        @Override
        public void run() {
// super.run();
            System.out.println("Hello World, 我是一个线程");
            while (true){
                //死循环
            }
        }
    }
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start();
        while (true){
            // 死循环
        }
    }
}

输出结果:

此时运行程序,然后双击 jconsole.exe:

多线程并发执行 和 单线程 的对比:
例: 针对一个整数进行大量的循环相加:

  • 串行
private static long count = 100_0000_0000L;

public static void main(String[] args) {
    serial();      //串行
// concurrency(); // 并发
}

private static void serial() {
    // 获取当前时间戳
    long begin = System.currentTimeMillis();
    int a = 0;
    for (long i = 0; i < count; i++) {
        a++;
    }
    int b = 0;
    for (long i = 0; i < count; i++) {
        b++;
    }
    long end = System.currentTimeMillis();
    System.out.println("time: " + (end - begin) + "ms");
}

输出结果:

即:串行针对两个整数累加100亿次,大概消耗5s

System.currentTimeMillis( ) — 获取到毫秒级的时间戳
时间戳:
以 1970 年 1月1日 0时0分0秒为基准时刻,计算当前时刻和基准时刻之间的秒数 / 毫秒数 / 微秒数 之差

  • 并发
private static long count = 100_0000_0000L;

public static void main(String[] args) {
// serial(); //串行
    concurrency(); // 并发
}

private static void concurrency() {
    long begin = System.currentTimeMillis();
    // 匿名内部类
    Thread t1 = new Thread(){
        @Override
        public void run() {
            int a = 0;
            for (long i = 0; i < count; i++) {
                a++;
            }
        }
    };
    Thread t2 = new Thread(){
        @Override
        public void run() {
            int b = 0;
            for (long i = 0; i < count; i++) {
                b++;
            }
        }
    };
    // 启动线程
    t1.start();
    t2.start();

    try {
        // 线程等待,让main 线程等待 t1和t2 执行结束,然后再继续往下执行
        t1.join();
        t2.join();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    // t1,t2,和 main 线程之间都是并发执行的
    // 调用了 t1.start 和 t2.start之后,两个新线程正在忙着进行计算
    // 此时 main线程仍然会继续执行,下面的 end 也就会被随之计算了
    // 正确做法: 应该是 t1 t2计算完毕后,再来计算 end 的时间戳
    long end = System.currentTimeMillis();
    System.out.println("time: " + (end - begin) + "ms");
}

输出结果:

两个线程并发执行的时候,时间大概是 2.7s 左右,时间缩短了很多~

创建线程的几种代码写法

1.通过显示继承一个 Thread 类的方式来实现
2.通过匿名内部类的方式继承 Thread 类
3.显式创建一个类,实现 Runnable 接口;然后把 Runnable实例 关联到一个 Thread 实例上
4.通过匿名内部类的方式,实现Runnable
5.使用 lambda 表达式,来指定线程执行的内容

代码示例:

public class ThreadDemo3 {
    // Runnable 本质上就是描述了 一段要执行的任务代码是啥
    static class MyRunnable implements Runnable{
        @Override
        public void run() {
            System.out.println("我是一个新线程~");
        }
    }
    public static void main(String[] args) {
        // 1.显式继承 Thread
        
        // 2.通过匿名内部类的方式,继承Thread来创建线程
// Thread t = new Thread(){
// @Override
// public void run() {
//
// }
// };
// t.start();

        // 3.显式创建一个类,实现 Runnable 接口
        // 然后把 Runnable实例 关联到一个 Thread 实例上
// Thread t = new Thread(new MyRunnable());
// t.start();

        // 4.通过匿名内部类的方式,实现Runnable
// Runnable runnable = new Runnable() {
// @Override
// public void run() {
// System.out.println("我是一个新线程~~");
// }
// };
// Thread t2 = new Thread(runnable);
// t2.start();

        // 5.使用 lambda 表达式,来指定线程执行的内容
        Thread t = new Thread(()->{
            System.out.println("我是一个新线程~~~");
        });
        t.start();
    }
}

无论是哪种方式,没有本质上的区别 (站在操作系统的角度),核心都是依靠Thread类,只不过指定线程执行的任务的方式有所差异

细节上有点差别(站在代码耦合性角度):
通过 Runnable / lambda 的方式来创建线程 和 继承 Thread 类相比,代码耦合性要更小一些,在写 Runnable / lambda 的时候 run 中没有涉及到任何 Thread 相关的内容,这就意味着,很容易把这个逻辑从多线程中剥离出来,去搭配其他的并发编程的方式来执行,当然也可以很容易的改成不并发的方式执行

相关文章