【多线程】初步认识Thread类及其应用

💐个人主页:初晴~

📚相关专栏:多线程 / javaEE初阶


        上篇文章我们简单介绍了什么是进程与线程,以及他们之间的区别与联系,实际应用中还是以多线程编程为主的,所以这篇文章就让我们更加深入地去剖析多线程编程的具体应用吧

目录

一、初识Thread类

1、创建线程

(1)继承Thread类

(2)实现Runnable接口

(3)匿名内部类

2、多线程的优势-增加运⾏速度

二、 Thread 类及常⻅⽅法

1、 Thread 的常⻅构造⽅法

2、 Thread 的⼏个常⻅属性

三、线程的状态

四、线程的核心操作

1、启动一个线程-start()

2、获取当前线程引用

3、休眠当前线程

4、线程的中断(终止)

5、线程等待-join()

总结


一、初识Thread类

⼀个线程就是⼀个 "执⾏流". 每个线程之间都可以按照顺序执⾏⾃⼰的代码. 多个线程之间 "同时" 执⾏着多份代码.
Java 的线程 和 操作系统线程 的关系
线程是操作系统中的概念. 操作系统内核实现了线程这样的机制, 并且对⽤⼾层提供了⼀些 API 供⽤⼾使⽤(例如 Linux 的 pthread 库).

1、创建线程

Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进⾏了进⼀步的抽象和封装
接下来我们就来看一下创建线程的几种写法吧:

(1)继承Thread类

编写的MyThread需要继承Thread类,不需要导包,因为Thread类是java.lang中内置的类。

继承不是主要目的,主要是为了重写Thread类中的run方法,在其中写入所创建线程需要执行的逻辑语句

若要让线程运行,需先实例化编写的MyThread类,接着调用start方法就会在进程内部创建一个新的线程,新的线程就会执行刚才run里的代码。

具体代码如下:

class MyThread extends Thread{
    //重写run方法
    @Override
    public void run(){
        //线程执行的逻辑
        System.out.println("Hello World!");
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread myThread=new MyThread();
        //创建线程
        myThread.start();
    }
}

这个代码,运行起来是一个进程,但这个进程包含了两个线程。
1、调用main方法的线程被称为“主线程”,之前提过一个进程中至少有一个线程,这个线程就是主线程
2、调用myThread.start()方法时会手动创建一个新的线程
主线程和新线程会并发/并行地在CPU上运行
不过,上述代码还不能很好地体现多线程编程的并发性与随机性,接下来用一个更加形象的代码表示一下:
class MyThread extends Thread{
    //重写run方法
    @Override
    public void run(){
        while (true){
            System.out.println("Hello thread");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread t=new MyThread();
        //创建线程
        t.start();
        while (true){
            System.out.println("Hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

可以发现,多个线程之间,谁先去CPU上调度执行,这个过程是 “不确定的”,这个调度顺序取决于操作系统内核里的 “调度器”,调度器里有一套规则,但是对于应用程序开发,无法进行干预,也无法察觉,因此把这个过程近似于 “随机”,多线程的运行调度也被称之为 “抢占式执行”

注意:

上述代码中,并没有直接手动调用run方法,但是也被执行了。像run这种,用户手动定义了,但是没有手动调用,最终被系统/库/框架调用执行了的方法,被称为“回调函数(call back)”

(2)实现Runnable接口

Runnable就是用来描述“要执行的任务”是什么
class MyRunnable implements Runnable{
    @Override
    public void run() {
        System.out.println("Hello Runnable!");
    }
}
public class Main {
    public static void main(String[] args) {
        MyRunnable runnable=new MyRunnable();
        Thread myThread=new Thread(runnable);
        //创建线程
        myThread.start();
    }
}

通过Thread创建线程,而线程要执行的任务是通过Runnable来描述的,而不是通过Tread自己来描述,这样能起到一定的“解耦合”的作用,便于代码后期维护。

Runnable只是描述了一个任务,并不与“线程”强相关,后续执行这个任务的载体可以是线程,也可以是其他东西,比如线程池、虚拟线程(协程)等,一定程度上提高了代码的复用率。

对⽐上⾯两种⽅法:
继承 Thread 类, 直接使⽤ this 就表⽰当前线程对象的引⽤.
实现 Runnable 接⼝, this 表⽰的是 MyRunnable 的引⽤. 需要使⽤Thread.currentThread()

(3)匿名内部类

匿名指没有类名,内部类指定义在其它类内部的类,匿名内部类一般就是“一次性”使用的类,用完就丢掉,相对来说内聚性会更好一些

匿名内部类创建 Thread ⼦类对象
// 使⽤匿名类创建 Thread ⼦类对象
Thread t=new Thread(){
    @Override
    public void run() {
        System.out.println("使⽤匿名类创建 Thread ⼦类对象");
    }
};
这个方法本质上和(1)是一致的,具体原理如下:
1、定义匿名内部类,这个类是Thread的子类
2、类的内部,重写了父类的run方法
3、创建了一个子类的实例,并把实例的引用赋值给了t
匿名内部类创建 Runnable ⼦类对象:
//使⽤匿名类创建 Runnable ⼦类对象
Thread t2=new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("使⽤匿名类创建 Runnable ⼦类对象");
    }
});

lambda表达式创建 Runnable 子类对象:

Thread t3=new Thread(()-> System.out.println("使⽤lambda表达式创建 Runnable ⼦类对象"));
Thread t4=new Thread(()->{
      System.out.println("使用lambda表达式创建 Runnable 子类对象");
});

lambda表达式本质上就是匿名内部类的更简化的写法,很多时候,写匿名内部类都不是为了写“类”,而是写类中的“方法”,而lambda就可以避开类而直接描述其中的run方法


2、多线程的优势

可以观察多线程在⼀些场合下是可以提⾼程序的整体运⾏效率的。
使⽤ System.nanoTime() 可以记录当前系统的 纳秒 级时间戳.
serial 串⾏的完成⼀系列运算. concurrency 使⽤两个线程并⾏的完成同样的运算

public class ThreadAdvantage {
    // 多线程并不⼀定就能提⾼速度,可以观察,count 不同,实际的运⾏效果也是不同的
    private static final long count = 10_0000_0000;

    public static void main(String[] args) throws InterruptedException {
        // 使⽤并发⽅式
        concurrency();
        // 使⽤串⾏⽅式
        serial();
    }

    private static void concurrency() throws InterruptedException {
        long begin = System.nanoTime();

        // 利⽤⼀个线程计算 a 的值
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                int a = 0;
                for (long i = 0; i < count; i++) {
                    a--;
                }
            }
        });
        thread.start();
        // 主线程内计算 b 的值
        int b = 0;
        for (long i = 0; i < count; i++) {
            b--;
        }
        // 等待 thread 线程运⾏结束
        thread.join();

        // 统计耗时
        long end = System.nanoTime();
        double ms = (end - begin) * 1.0 / 1000 / 1000;
        System.out.printf("并发: %f 毫秒%n", ms);
    }

    private static void serial() {
        // 全部在主线程内计算 a、b 的值
        long begin = System.nanoTime();
        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.nanoTime();
        double ms = (end - begin) * 1.0 / 1000 / 1000;
        System.out.printf("串⾏: %f 毫秒%n", ms);
    }
}

二、 Thread 类及常⻅⽅法

Thread 类是 JVM ⽤来管理线程的⼀个类,换句话说,每个线程都有⼀个唯⼀的 Thread 对象与之关联。
⽤我们上⾯的例⼦来看,每个执⾏流,也需要有⼀个对象来描述,类似下图所⽰,⽽ Thread 类的对象就是⽤来描述⼀个线程执⾏流的,JVM 会将这些 Thread 对象组织起来,⽤于线程调度,线程管理。

1、 Thread 的常⻅构造⽅法

代码事例:
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

如果不给线程起名字,那么默认就会是叫做Thread-0,Thread-1……

给线程起名字,并不会影响线程的执行效果,不过起一个合适的名字,更有利于调试程序

ThreadGroup线程组是把多个线程放在一组里,方便统一地设置线程的一些属性。不过现在很少会使用线程组了,线程相关属性用到也不多,更多的是会使用“线程池”

2、 Thread 的⼏个常⻅属性

ID 是JVM自动分配的,是线程的唯⼀标识,不同线程不会重复
名称是各种调试⼯具⽤的,Thread对象的身份标识
状态表⽰线程当前所处的⼀个情况,下⾯我们会进⼀步说明
优先级⾼的线程理论上来说更容易被调度到
关于后台线程,需要记住⼀点:JVM会在⼀个进程的所有⾮后台线程结束后,才会结束运⾏。
是否存活,即简单的理解,为 run ⽅法是否运⾏结束了
线程的中断问题,下⾯我们进⼀步说明

关于前台线程与后台线程:

后台线程:如果这个线程执行过程中,不能阻止进程的结束,(虽然线程在执行着,但是进程要结束了,此时这个线程也会随之被带走),这样的线程就被称为“后台线程”,也被称为“守护线程”。

前台线程:如果某个线程在执行过程中,能够阻止进程结束,就被称为“前台线程”

一个线程中可以有多个前台线程(创建的线程默认是前台线程),必须所有的前台线程都结束,进程才能结束

关于线程是否存活:

isAlive()返回值为true表示还存活,false表示线程没了

代码中,new Thread对象的生命周期与内核中实际线程的生命周期不一定是一致的,可能会出现Thread对象存在但是内核中线程不存在的情况,如:

(1)调用start前,此时内核中还未创建线程

(2)线程中的run执行完毕,内核的线程销毁,但此时Thread对象任然存在

注意:不存在Thread对象不存在,而线程还存在的情况


三、线程的状态

线程的状态是⼀个枚举类型 Thread.State,反映了线程生命周期的不同阶段,了解这些更有利于对线程的调试与优化,以下是线程的六种主要状态:
NEW(初始):
  • Thread对象已创建,但是内核的线程还没有(还未调用start方法
  • 安排了⼯作, 还未开始⾏动
RUNNABLE(可运行):
  • 线程的start方法一旦被调用就会进入RUNNABLE状态
  • 就绪状态,可能正在CPU上运行,也可能在等待CPU分配资源以便运行
BLOCKED(阻塞):
  • 因为锁竞争而引起的阻塞等待的状态
  • 当线程试图获取某一对象的锁,而该对象的锁正被其它线程占有时,就会进入BLOCKED状态
  • 一般指线程因同步操作(synchronized)而被阻塞的状态
WAITING(等待):
  • 当线程调用了Object.wait(),Thread.join(),LockSupport.park()等方法时,就会进入WAITING状态
  • 此时线程不会争夺CPU资源,一直等待直到其它线程发出对应的通知信息(如notify()或notifyAll())时重新恢复RUNNABLE状态
  • 没有超时时间的阻塞等待,如果没有收到通知将会一直等待下去
TIMED_WAITING(超时等待):
  • 与WAITING状态类似,但是是有超时时间的等待
  • 当调用Object.wait(long timeout),Thread.join(long),Thread.sleep(long millis),LockSupport.parkNanos(),LockSupport.parkUntil()等方法时,线程会进入TIMED_WAITING状态
  • 线程将会等待直到被唤醒或超时
TERMINATED(终止):
  • 当线程的run方法执行完毕或者由于某些原因(如抛出为捕获的异常)而提前结束时,线程进入TERMINATED状态
  • 当前Thread对象虽然还在,但是内核的线程已经被销毁了(线程已经结束了)
  • 终止的线程无法被重启

可以形象的类比于以下状态:


上述线程状态可以通过jdk自带的jconsole来观察:

学习线程的状态,主要就是为了调试与优化,比如遇到某个线程没有正常运行时,就可以观察对应线程的状态,来确定是否是由于一些原因导致线程进入了阻塞状态


四、线程的核心操作

1、启动一个线程-start()

之前我们已经看到了如何通过覆写 run ⽅法创建⼀个线程对象,但线程对象被创建出来并不意味着线程就开始运⾏了。
覆写 run ⽅法是提供给线程要做的事情的指令清单
线程对象可以认为是把 李四、王五叫过来了
⽽调⽤ start() ⽅法,就是喊⼀声:”⾏动起来!“,线程才真正独⽴去执⾏了。

调⽤ start ⽅法, 才真的在操作系统的底层创建出⼀个线程
注意要区分好 runstart的关系:
run:线程的入口,描述了线程 要执行的任务
start:调用了 系统api,在系统内核中 真正创建了线程(创建PCB加入到链表中)
也就是说,如果直接调用run方法,虽然也能执行run方法内的程序,但也只会在调用它的线程中执行,是不会创建新的线程的,这样就背离多线程编程的初心了
注意
start对于一个Thread对象只能调用一次,java中的Thread对象与内核中的线程是“一对一”的关系,因此,不存在一个线程终止后,再通过调用start重新执行的情况
      这是由于一个Thread对象能够对应到多个线程的话,管理起来就会非常麻烦,JVM的设计实现就会非常复杂了。因此java中希望一个Thread对象只能对应到系统中的一个线程,这样在调用start后就可以通过线程的状态来判断是否能成功创建。
      如果一个Thread对象是没有调用过start的,此时就会处于NEW状态,此时调用start就可以顺利地执行创建出线程了。而如果已经调用过start,线程就会进入其它状态,只要不是处于NEW状态,接下来执行start都会抛出异常了

2、获取当前线程引用

想在某个线程中,获取到自身的Thread对象的引用,就可以通过currentThread()来获取

任何线程都可以通过这样的操作获取线程的引用

3、休眠当前线程

也是我们⽐较熟悉⼀组⽅法,有⼀点要记得,因为线程的调度是不可控的,所以,这个⽅法只能保证实际休眠时间是⼤于等于参数设置的休眠时间的

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println(System.currentTimeMillis());
        Thread.sleep(3 * 1000);
        System.out.println(System.currentTimeMillis());
    }
}

线程执行sleep,就会使这个线程不参与CPU调度,从而把CPU资源让出来,给别人使用。像sleep这样的操作也被称之为“放权”,放弃使用CPU的权利

在有些开发场景中,如果某个线程CPU占用率过高,就可以通过sleep来改善。虽然线程的优先级就可以对其产生影响,但影响是比较有限的,通过sleep可以更加明显地影响到CPU占用

4、线程的中断(终止)

在工作中,我们可能会因为领导的一通电话,而不得不停下手头的工作,去做别的事。线程运行时可能也会遇到类似问题,有时我们可能会因为某些原因而需要提前结束线程的运行,该如何停止呢,这就涉及到停止线程的方式了。

如果有两个线程a和b,b正在运行,a想要b结束运行,其实核心就是a要想办法让b的run方法更快地执行完毕,此时b自然就结束了。而不是说b的run执行一半,a直接强行把b结束了。java中结束线程的方法是比较“温柔”的,并不是直接粗暴的。因为如果强制结束某个线程的话,可能导致其逻辑未完全执行,对应的结果数据是个“半成品”,从而影响程序最终的结果,这样肯定是不合理的。

1、一个简单的做法是使用自定义的标志位

public class Demo {
    //设置全局变量isQuit作为标志位
    public static boolean isQuit=false;

    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            while (!isQuit){
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
        Thread.sleep(4000);
        System.out.println("main线程尝试终止 t 线程");
        //通过修改isQuit变量的值,就能终止 t 线程的执行了
        isQuit=true;
    }

}

注意,如果把isQuit变成main方法的局部变量,就会出现以下情况:

我们可以看到lambda语句中的isQuit标红报错了。这与lambda表达式/匿名内部类的变量捕获操作有关。

isQuit与lambda表达式定义在一个作用域内,lambda的内部是可以访问到lambda外部(与lambda同一作用域)的变量的。但是lambda的变量是有要求的,能够捕获的变量得是final或者事实final(即虽没有final修饰,但并没有人修改),而上述代码中的isQuit变量被修改过,不是final/事实final,导致lambda表达式无法通过变量捕获操作获取到它,从而导致程序出现了错误

而当把isQuit写成成员变量后,就成了内部类访问外部类的成员变量,这本就是合法的,因此就不会出现问题了


2、使用Thread自带的interrupted作为标志位

 

Thread中有一个boolean类型的成员变量interrupted,它的初始值为false,表示未被终止,一旦其它线程调用了interrupt方法,就会设置上述标志位值为true,表示已被终止。

public class Main {
    public static void main(String[] args) throws InterruptedException {
        //下列的 lambda 的代码在编译器眼里,出现在 Thread t 的上方
        //此时 t 还没有被定义
        Thread t=new Thread(()->{
            //先获取到线程的引用
            Thread currentThread = Thread.currentThread();
            //两种判断方式都行
            while (!currentThread.isInterrupted()){
            //while(!Thread.interrupted())
                System.out.println("hello thread");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        t.start();
        Thread.sleep(3000);
        //在主线程中,控制 t 线程被终止,设置上述标志位
        t.interrupt();
    }
}

但是执行上述代码我们会发现程序运行是有问题的:

可以看出程序运行抛出了一个RuntimeException异常。主要是由于判断isInterrupted()和执行打印操作的速度过快,因此在整个循环中,主要时间都是处于sleep状态中,main调用interrupt()时,不仅仅会设置标志位,还会把sleep给唤醒,假如sleep刚执行了100ms,还剩900ms,此时interrupt被调用,sleep就会被强制唤醒,并且抛出interruptedException异常

又由于catch中的代码默认再次抛出了一个RuntimeException异常,而这个异常没有被处理,就会导致直接抛到JVM层面,使得进程直接异常终止了

这时我们尝试将代码改为:

这样不抛出新的异常而是输出一段语句是否就能让程序正常运行呢?

显然是不能的,我们可以看到在执行catch操作后,线程并没有被终止,仍然在不断地运行输出。

这是由于当sleep等阻塞函数被唤醒后,会清空刚刚设置的interruptded标志位,这样在线程的下次循环判断时,程序就会认为标志位任未被设置,从而继续执行下去了。

此时,如果想要结束循环,结束线程,就需要在catch中加上return/break语句:

这样,线程就能正常被终止了:

出现这样的现象主要是由于java中,线程终止是一个相对“温柔”的过程,并不是强行就终止。当a线程想让b线程终止时,b可以自行决定,是否要终止/是立即还是稍后,这些都由b线程内部的代码来决定,其他线程无权干涉。

比如:

(1)如果b线程想无视a线程的终止请求,就直接在catch中啥也不做,b仍然会继续执行

(2)如果b线程相要立即结束,就在catch中写入return或break,此时b线程就会立刻结束

(3)如果b线程想要稍后结束,就可以在catch上写入一些其他逻辑(如释放资源,清理一些数据,提交一些结果……等收尾工作),这些逻辑完成后,再进行return/break操作

这些全都得益于sleep这类阻塞方法强制唤醒时会清除标志位,才能让b做出各种选择,否则b将被强制结束,无法写出让程序继续执行的代码了。这样可以给程序员更多的操作空间


5、线程等待-join()

操作系统针对多个线程的执行,是一个“随机调度”,抢占式执行的过程。线程的调度执行是随机的,我们无法确定两个线程的调度顺序,但是可以控制谁先结束谁后结束。

线程等待就是确定两个线程的结束顺序,通过让后结束的线程等待先结束的线程执行,进入阻塞状态,直到先结束的线程执行完毕,此时阻塞解除,后结束的线程开始执行。这样就能使两个线程的结束顺序得以确定

这时就可以使用join关键字实现线程等待

比如有两个线程a,b,此时在a线程中调用b.join,意思就是让a线程等待b线程先结束,a再继续执行。通俗的来讲,就相当于是让b插入到a线程的执行过程中

不过也要注意a和b本质上还是两个线程,依旧是并发执行,只是确定了结束顺序

代码示例:

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            System.out.println("t 线程开始执行");
            for (int i = 0; i < 3; i++) {
                System.out.println("这是线程 t");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("t 线程执行结束");
        });
        t.start();
        //让主线程等待 t 线程
        System.out.println("main 线程开始等待");
        t.join();
        System.out.println("main 线程等待结束");
    }
}

可以很明显地看到主线程是先开始执行的,但当执行到t.join()语句时,主线程开始阻塞等待 t 线程的执行,当 t 线程执行结束后,join才会返回,主线程才会继续执行后续代码。

不过如果 t 线程先执行完毕了,然后主线程才开始join,此时主线程不会出现阻塞等待,而是会正常执行:

注意

        上述操作都是无参数的join方法,就是“死等”,只要被等待的线程没有执行完毕,就会一直阻塞等待。这并不是一个好的选择,因为一旦被等待的线程代码出现bug,可能导致该线程迟迟无法结束,从而使等待线程一直阻塞而无法继续执行其它操作了

方法如下:

可以在join方法中加入参数来确定等待时间,如果等待时间超过设定时间,就会停止等待,退出阻塞状态继续执行后续代码了。


方法汇总:

 

那么本篇文章就到此为止了,如果觉得这篇文章对你有帮助的话,可以点一下关注和点赞来支持作者哦。作者还是一个萌新,如果有什么讲的不对的地方欢迎在评论区指出,希望能够和你们一起进步✊

  • 42
    点赞
  • 36
    收藏
    觉得还不错? 一键收藏
  • 17
    评论
评论 17
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值