线程1(Thread)

一、线程的介绍

所谓线程,可以理解成一种轻量级的进程,因为他所需的资源比进程要少。创建一个线程比创建一个进程成本低。销毁一个线程也比销毁一个进程成本低。线程也是一种并发编程的方式。

成本低的原因是:新创建一个线程,不需要给这个线程分配很多新的资源,大部分资源都是和原来的线程共享的,销毁一个线程也是一样,避免了重新申请和释放的开销。如果新创建一个进程,就需要给这个进程分配较多的资源。

线程比进程更优的原因:
1.线程比进程更轻量,并发编程的效率更高
2.线程之间能够共享资源,完成一些操作时更方便

当创建一个进程的时候,就会自动随之创建一个线程(主线程),因此一个进程被创建出来的同时,至少会随之创建一个线程。

二、线程的概念

进程是操作系统资源分配的最小单位,线程是操作系统进行调度和执行的最小单位。 **所谓的操作系统进行进程调度,本质上是操作系统针对这个进程的若干个线程进行调度。**一个进程当中可以有多个线程。因此如果一个进程看作是一个工厂,那么线程就可以看作是若干个流水线。

因此可以得出:
1.线程是包含在进程当中的
2.一个进程中可能会有多个线程
3.每个线程都有一段自己要执行的逻辑(指令),每个线程都是一个独立的“执行流”。(可以理解为工厂的每条流水线都有独立的任务去执行)
4.同一个进程中的很多线程之间可以共享一些资源(可以理解为一个工厂的资源是可以随意分配的,能够共享资源)

三、线程的资源分配

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

同一个进程的多个线程之间共享的资源主要是两方面:
1.内存资源(但是两个进程之间的内存资源不能共享)
2.打开的文件(说明进程之间是有隔离性的)

但也有些不能共享的资源:
1.状态、优先级、线程记账信息、上下文(每个线程要独立的参与CPU的调度)
2.内存中有一块特殊的区域——栈,空间是每个线程要独立的一份。

四、操作系统管理线程的方式

操作系统管理线程的方式和管理进程的方式大致相同。先描述:用PCB来描述每一个线程。组织:用一个双向链表来来组织。

以Linux为例:
内核是只认得PCB的,内核不区分进程和线程,用户态才区分。因此:一个线程和一个PCB对应,而一个进程可以和多个PCB对应。
在这里插入图片描述
线程的PCB有pid,指的是每个线程都是不一样的,都有自己的id,而tgroupid可能会相同,指的是它们都是同属于一个进程当中的。

这三个PCB就对应java.exe这个进程的三个线程,内核中也把这若干个同属于一个进程的线程称为“线程组”。

每个线程都能独立地在CPU上调度执行

五、画图理解进程和线程之间的关系

场景:派人(线程,执行调度)去房间(进程,分配资源)中吃100只鸡。
1.单进程单线程(一个人去一个房间中吃鸡)
在这里插入图片描述
2.为了加快吃鸡的速度,可以选择并发编程。**多进程,每个进程单线程的方式来吃。**这种方式,速度是提升了,但是消耗的资源更多。(房间要两个)
在这里插入图片描述
两个进程之间的数据、资源是不共享的(彼此看不到对方的进度),这是进程的隔离性。

3.单进程,多线程
虽然线程相同效率上跟2差不多,但是消耗的资源少了(只用一个房间吃鸡),并且两个人可以看到互相的进度,因此同一个进程之间的资源是共享的。
在这里插入图片描述
4.多进程多线程
此时并发的效率更高,比3的效率快。但是这种方式是不必要的,能有替代方式,题代方式为5。
在这里插入图片描述
5.单进程多线程,此时效率跟4差不多,但是消耗的资源更少了。
在这里插入图片描述
6.一个进程中的线程并不是越多越好。此图中左边的4个人都没有机会去吃鸡,则说明有些线程即使创建了也没有机会去进行调度和执行,却还占用了资源,反而会拖慢效率。
在这里插入图片描述
因此一个进程当中最多搞多少个线程呢?
1.跟CPU的个数相关,即桌子上座位的数量。
2.和线程执行的任务的类型也相关。

任务的类型分为:
a) CPU密集型:程序一直在执行计算任务。(如果一个人什么都不干,一直吃鸡,就相当于CPU密集型)
b) IO密集型:程序没怎么计算,主要是进行输入输出操作(一个人在吃一只鸡后又在思考人生5分钟,就相当于IO密集型)

假设一个主机是8核CPU:
若任务都是纯用CPU计算的,此时的线程的数目大概就是8个左右。
若任务都是纯IO型的,理论上搞多少个线程都可以。
但是现实中的情况是介于两者之间的->实际中一般需要通过测试来找到合适的线程数。
对于一个业务比较复杂的服务器来说,进程中有几十个线程都是常见的现象。

多线程的程序还有一些额外的问题:
7.线程之间不一定是安全的。可能为了同时处理同一个任务时一起来执行。(可以理解为A和B为了吃鸡腿而打起来了)
在这里插入图片描述
8.线程可能会抛出异常,但是若没有处理这个异常,整个进程就会被终止。(可以理解为有人的心情不好掀桌了,那么其他人也吃不到鸡,就会终止)
在这里插入图片描述

六、利用代码观察线程

线程若一个代码结束,则进程就会关闭,随之线程也会关闭。因此为了增加代码的运行时间,我们可以设置循环。

public class TreadDemo {
    static class myThread extends Thread {
        @Override
        public void run() {
            System.out.println("hello world,这是一个线程");
            while(true) {

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

        }
    }
}

为了进一步的观察当前确实是两线程,可以借助第三方的工具来查看该进程中的线程情况。JDK中内置了一个jconsole这样的程序。
在这里插入图片描述
我们点进去,选择本地进程,再选择跟我们类名相同的进程,再进行连接。
在这里插入图片描述
点入连接后点击不安全的连接即可进入。
在这里插入图片描述
可以看到各种有关计算机的内容,可以选择线程。
在这里插入图片描述
选择线程之后可以看到有main和Thread_0的线程,此时的代码是在运行当中,main为main方法对应的线程,Thread_0为我们自己创建的线程。这里的全部线程都是当前的Java进程中包含的。都是由JVM自动创建出来的。
在这里插入图片描述
可以看到main线程和Thread_0的状态和栈跟踪。
在这里插入图片描述
在这里插入图片描述

七、多线程并发执行和单线程的对比

场景:我们让一个数循环累加100_0000_0000次,在串行和并行之间查看它们运行的时间。

运行时间的查看可以用库中的System.currentTimeMills()方法,单位是毫秒它是一个时间戳,以1970年1月1日0时0分0秒为基准,计算当前时刻和基准时刻之间的秒数/毫秒数/微秒数之差。

首先来看串行:
先设置一个时间戳记录开始执行代码的时间,再在两个数串行执行累加之后再设置一个时间戳来记录代码执行了多长时间,最后将两个时间相减即能得出累加操作的运行时间。
串行执行的时间:
在这里插入图片描述

再来看并行:
并行也是一样,首先要设置一个时间戳,但是因为两个数的累加操作是并行完成的,因此要让两个操作并行完成之后再设置时间戳。为了让两个操作执行完之后时间戳再计算时间,此处涉及到线程的join()方法,它能让线程执行完后等待,等待其它线程结束后再计算时间戳。如下,则等待t1,t2执行完毕后再执行代码后面的部分,否则main方法、t1线程、t2线程会并行执行,计算的时间就不再准确。
并行执行的时间:
在这里插入图片描述
可见,并行执行的时间比串行执行的时间快了将近5秒!所以说线程的并行执行能非常大地提高运行效率。

public class ThreadDemo2 {
    public static long count = 100_0000_0000L;
    public static void main(String[] args) {
        serial();//串行编程
        //concurrency();//并行编程
    }

    private static void concurrency() {
        long beg = System.currentTimeMillis();
        Thread t1 = new Thread() {
            @Override
            public void run() {
                long a =0;
                for(long i=0;i<count;i++) {
                    a++;
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                long b = 0;
                for(long i=0;i<count;i++) {
                    b++;
                }
            }
        };
        t1.start();
        t2.start();
        try {
            t1.join();//线程等待,让主线程等待t1和t2执行结束后,然后再继续往下执行
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("time: "+(end-beg) +"ms");
    }

    private static void serial() {
        long beg = 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-beg) +"ms");
    }
}

注:线程多了之后,效率确实会提高,但是不一定是成倍的提高,受到影响的因素可能很多。线程的创建和销毁也是需要时间的。线程的调度,也是需要时间的。单线程的代码编译器能更好地优化。要执行的任务越复杂,多线程的效果就越明显。

八、线程的创建方法

1.创建Thread类,使用匿名内部类

匿名内部类后直接重写Thread里面的Thread方法。

Thread t = new Thread() {
        @Override
        public void run() {
            System.out.println("这是一个线程");
        }
    };

2.创建一个静态类实现Runnable接口

创建一个静态类实现Runnable接口并重写Runnable里面的run方法

//Runnable本质上就是描述一段要执行的任务代码是啥
    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("这是一个新线程");
        }
    }
    public static void main(String[] args) {
        //显式创建一个类,实现Runnable接口,然后把这个Runnable的实例关联到Thread实例上
        Thread t = new Thread(new MyRunnable());
        t.start();
    }

3.使用Runnable接口并使用匿名内部类重写run方法

使用Runnable接口并使用匿名内部类重写run方法,再将接口的实例关联到Thread的实例当中。

public static void main1(String[] args) {
        //通过匿名内部类实现Runnable接口
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("这是一个新线程");
            }
        };
        Thread t = new Thread(runnable);
        t.start();
    }

4.使用Lambda接口

使用Lambda接口一定要熟悉我们所要调用的方法的参数和内部结构。

public static void main(String[] args) {
        //使用Lambda表达式来指定线程执行的内容
        Thread t = new Thread(()->{
            System.out.println("这是一个新线程");
        });
        t.start();
    }

5.显示继承Thread类,重写run来指定线程执行的代码

static class myThread extends Thread {
        @Override
        public void run() {
            System.out.println("hello world,这是一个线程");
        }
    }
    public static void main(String[] args) {
        Thread t = new myThread();
        t.start();
    }

这五种创建线程的方式,没有本质上的区别,核心都是依赖于Thread类,只不过指定线程执行的任务的方式有所差异。

通过代码耦合性的角度来说,通过Runnable接口和Lambda表达式的方式来创建线程和继承Thread类相比,代码的耦合性更小。因为在写Runnable接口和Lambda表达式的时候run中没有涉及到任何的Thread相关的内容。

这意味着很容易比这个逻辑从多线程中剥离出来,去搭配其它的并发编程的方式来执行。

6. 使用Callable接口创建线程

在上面我们知道了Runnable是只描述一个任务,不在乎结果,它重写的run方法是没有返回值的。但是Callable也是描述任务,同时也关注返回的结果。

Callable接口中包含一个call方法,和Runnable里面的run方法相似,都是描述一个具体的任务。但是call方法是带返回值的。如果我们期望创建一个线程,并返回线程产生的返回结果,那么使用Callable就比较合适了。

如果我们要计算1到1000的累加,使用Runnable就比较麻烦。
代码:

public class ThreadDemo30 {
    static class Result {
        public int sum = 0;
        public Object locker = new Object();
    }

    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();

        Thread t = new Thread() {
            @Override
            public void run() {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                result.sum = sum;

                synchronized (result.locker) {
                    result.locker.notify();
                }
            }
        };
        t.start();

        // 此处我们期望, 这个线程的计算结果能够被主线程获取到~~
        // 为了解决这个问题, 就需要引入一个辅助的类.
        // 当代码写成这个样子的时候, 发现在主线程中, 是无法得到 sum 的值的.
        // 主要是因为当前 t 线程和主线程之间是并发的关系.
        // 执行的先后顺序不能确定.
        // 解决方案是, 让 main 这个线程先等待(wait). t 线程计算完毕之后, 通知唤醒 main 线程即可.
        synchronized (result.locker) {
            while (result.sum == 0) {
                result.locker.wait();
            }
        }
        System.out.println(result.sum);
    }
}

这种情景下使用Callable接口就会方便很多

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class ThreadDemo31 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        // 由于 Thread 不能直接传一个 callable 实例, 就需要一个辅助的类来包装一下.
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        // 尝试在主线程获取结果.
        // 如果 FutureTask 中的结果还没有生成呢, 此时就会阻塞等待.
        // 一直等到啥时候, 等到最终的线程把这个结果算出来之后, get 才会返回.
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

注:Callable接口是一个泛型接口,泛型里面填的就是返回值的类型。由于 Thread 不能直接传一个 callable 实例, 就需要一个辅助的类来包装一下,FutureTask就是那个辅助的类,它也是泛型类,泛型里面填的也是返回值的类型。当我们创建出FutureTask这个实例的时候,此时Callable大概率还是没有算完的。最终的结果还不知道。
当Callable在线程中执行完了之后,此时就会把这个结果给写入到FutureTask实例中。此处的这个FutureTask的定位,其实就是和用Runnable的Result类接收结果代码中的Result类是类似的。

九、线程中的run和start方法的区别

让一个线程调用run方法,这只是一个普通方法的调用,没用创建新的线程。输出语句是在原线程当中执行。而start方法是会创建一个新的线程,由新的线程来执行run方法当中的语句。

public static void main(String[] args) {
        Thread t = new Thread() {
            @Override
            public void run() {
                System.out.println("这是一个新线程");
            }
        };
        //t.run();
        t.start();
    }

创建一个Thread 子类,重写run方法,此时这个run方法 不是我们自己调用的,而是JVM 来调用的。此时的run 方法相当于 回调函数,只不过Java 的方法不能脱离类。

  • 62
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 55
    评论
评论 55
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zjruiiiiii

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值