JavaEE初阶 - 多线程基础篇 (Thread类的基本用法)

1. 创建线程

  继承Thread类
  实现Runnable接口
  其他变形写法

2. 比较单线程和多线程的运行效率

3. Thread类的构造方法和属性

4. 中断线程

5. 线程等待

6. 线程休眠


  在Java标准库中提供了一个Thread类来表示/操作线程, 操作系统提供了一种关于线程的API(C语言风格, 因为操作系统是由C/C++实现的), Java将这组API封装成了Thread类.

创建好的Thread对象和操作系统中的线程是一一对应的关系.

1. 创建线程

继承Thread类

//最基本的创建线程的方法
class MyThread extends Thread{
    @Override
    public void run() {
        System.out.println("Hello MyThread");
    }
}
public class Demo1 {
    public static void main(String[] args) {
        Thread thread = new MyThread();
        thread.start();
    }
}
//结果:
Hello MyThread

run()方法描述了线程内部需要执行哪些代码. 每个线程都是并发执行的.

注意:run()方法只是描述了进程创建后要执行的功能, 并没有创建线程, 直到调用start()方法, 才是真正创建了线程, 创建线程之后开始执行run()操作.

一个进程内部的Thread创建的线程都是在这个进程中的.

在一个进程中, 至少会有一个线程, 在一个Java进程中, 至少会有一个main()方法的进程(系统自动生成), 这时, 我们自己创建的线程和main线程就是并发的关系.

我们创建一个进程, 让这个进程每隔一秒打印一条"hello MyThread2"语句, 并且让main线程每隔一秒打印一条"hello main"语句:

class MyThread2 extends Thread{
    @Override
    public void run() {
        while (true){
            System.out.println("hello MyThread2");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class Demo2 {
    public static void main(String[] args) {
        Thread thread = new MyThread2();
        thread.start();
        while (true) {
            System.out.println("hello main");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
//结果:
hello main
hello MyThread2
hello MyThread2
hello main
hello MyThread2
hello main
hello MyThread2
hello main
hello MyThread2

这里的sleep()方法的作用是强制让线程休眠一段时间, 强制让线程进入阻塞状态(单位:毫秒), sleep(1000)也就是1s内, 这个线程不会在CPU上运行, 但使用sleep的时候需要try-catch来捕获异常, 这里产生的异常是多线程中最常见的一种异常-线程被强制中断.

我们通过观察打印结果发现, thread和main的执行顺序是乱的, 也就是说, 当1s的阻塞状态结束后, CPU到底是先唤醒thread还是main线程, 是随机的.

因此, 我们可以得出结论:操作系统内部对于执行线程的顺序, 在宏观上可以认为是随机的, 这种执行方式我们称为"抢占式执行"(谁先抢到就是谁的), 这种方式会给多线程编程带来许多麻烦.

那么, 我们能否通过将main线程中的sleep(1000)改为sleep(999), 来实现先执行main线程, 再执行thread线程呢?

  这个显然行不通, sleep(1000)并不是1000ms之后立刻执行这个线程, 而是1000ms内不执行这个线程,
换句话来说, 1000ms过后, 这个进程只是有可能会被执行. 对于CPU而言, 1000ms的阻塞时间过后,
CPU也不一定会立刻执行这个进程, 有可能CPU正在被占用, 或者CPU还没有为这个进程分配时间, 这都是有可能的.

实现Runnable接口

class MyRunnable implements Runnable{

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

这种写法通过Runnable接口来描述任务的内容, 进一步地将描述好的任务交给Thread对象.

其他变形写法

匿名内部类创建Thread对象:

//使用匿名类创建 Thread 子类对象
Thread t1 = new Thread() {
	@Override
	public void run() {
		System.out.println("使用匿名类创建 Thread 子类对象");
	}
};

匿名内部类创建Runnable对象:

//使用匿名类创建 Runnable 子类对象
Thread t2 = new Thread(new Runnable() {
	@Override
	public void run() {
		System.out.println("使用匿名类创建 Runnable 子类对象");
	}
});

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

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

2. 比较单线程和多线程的运行效率

单线程:
我们将两个整型变量串行自增十亿次, 查看运行时间:

public class Demo {
    public static void main(String[] args) {
        serial();
    }
    public static void serial(){
        long count = 10_0000_0000L;
        long a = 0L, b = 0L;
        long beg = System.currentTimeMillis();//自增之前的时间
        for(int i=0;i<count;++i){
            ++a;
        }
        for(int i=0;i<count;++i){
            ++b;
        }
        long end = System.currentTimeMillis();//自增之后的时间
        System.out.println(end - beg + "ms");
    }
}
//多次执行结果:
832ms
870ms
815ms

多线程:
通过两个线程并行计算:

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        serial();
    }
    public static void serial() throws InterruptedException {
        long count = 10_0000_0000;
        long beg = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
           long a = 0;
           for(int i=0;i<count;++i){
               ++a;
           }
        });
        t1.start();
        Thread t2 = new Thread(() -> {
            long b = 0;
            for(int i=0;i<count;++i){
                ++b;
            }
        });
        t2.start();
        t1.join();//通过join()方法让main线程等待t1和t2线程结束
        t2.join();
        long end = System.currentTimeMillis();
        System.out.println(end - beg + "ms");
    }
}
//多次执行结果:
532ms
582ms
523ms

  可以看出, 通过多线程耗费的时间明显少于串行执行消耗的时间, 但并不是说两个线程消耗的时间一定是单线程的1/2, 因为这两个线程在微观上有可能是并行的, 也有可能是并发的, 只有真正并行执行的时候, 效率才会有显著提升.

注意:并不是使用多线程就一定能提高执行效率, 因为创建线程也是需要消耗时间的, 如果要计算的值很小, 有可能多线程还没有创建完, 单线程已经执行完了, 这时候用多线程就得不偿失了. 因此, 是否使用多线程还是要看具体场景, 多线程适合CPU密集的程序, 如果程序需要进行大量的计算, 这时使用多线程就可以充分的利用CPU的多核资源.

  Thread对象和内核中对应的线程, 这两个的生命周期是不一样的, 创建完对象之后, 在执行start语句之前, 线程都是不存在的, 在run()方法执行完之后, 线程就被销毁了, 但thread对象可能还存在.

  注意run()方法和start()方法的区别, run()方法只是一个普通的方法, 并不会创建线程, 只有在执行start()方法时才会创建线程.

3. Thread类的构造方法和属性

Thread类的常用构造方法:
JavaEE初阶 - 多线程基础篇 (Thread类的基本用法)_第1张图片
Thread类的常用属性:

属性 获取方法
ID getId()
名称 getName()
状态 getState()
优先级 getPriority()
是否后台线程 isDaemon()
是否存活 isAlive()
是否被中断 isInterrupted()

4. 中断线程

对于一般的线程, 让线程停下来的关键是让线程对应的run()方法执行完毕, 而main线程, 需要让main()方法执行完毕.

  1. 我们可以手动设置一个标志位, 来控制线程是否要执行结束.
  2. 使用Thread内置的标志位来进行判定:
    Thread.interrupted() //静态方法
    Thread.currentThread().isInterrupted() //实例方法(这种写法比较常用)
public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (!Thread.interrupted()){
                System.out.println("hello");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //这里一旦抛出异常, 直接跳出循环
                break;    //注意, 跑出异常后一定要用break跳出, 不然线程抛出异常后会继续执行
            }
        });
        thread.start();
        Thread.sleep(5000);
        //在主线程中通过thread.interrupt()来修改原有的标识符, 以达到中断线程的目的, 谁调用interrupt()就中断谁
        thread.interrupt();
    }
}
//结果:
hello
hello
hello
hello
hello
抛出异常, 运行结束

注意:调用interrupt()方法可能导致两种情况:

  1. 如果thread处于就绪态, 这时调用这个方法就是设置线程的标志位为true(可以通过上面代码中while循环的条件来中断线程)
  2. 如果thread线程此时处于阻塞态, 这时并不会修改标志位, 而是会产生一个InterruptException异常.

5. 线程等待

   一个进程的多个线程之间, 调度顺序是不确定的, 但有时我们需要控制线程之间的顺序, 而线程等待就是控制线程执行顺序的一种手段.

  具体方法就是通过join()方法. 哪个线程调用join()方法, 哪个线程就会阻塞等待, 等到对应的线程执行完毕.

  例如, 我们在main线程中使用语句 thread.join(); 这时, 就是让main线程等待thread线程结束. 在main线程中使用join语句后, main线程就会进入阻塞状态. 然后, 代码在main线程中就不会再往下执行了, 直到thread线程完全执行完(也就是thread的run()方法全部执行完), main方法才会恢复为就绪状态.

  我们通过线程等待, 就是控制让thread线程先结束, main线程后结束, 这样一定程度上就干预了两个线程的执行顺序.

但是, 如果thread线程是一个死循环, 永远都不会结束, 那么调用thread.main()方法的这个线程也会一直等下去, 这样显然也是不合适的, 因此, 我们可以在join方法中加入一个整数表示等待的时长(单位:毫秒), 例如, thread.join(5000)就是只等待thread这个线程5000ms, 也就是5秒, 一旦5秒过了, main线程就会直接结束, 不会继续等待; 如果thread线程在5秒内执行完了, 那main线程也会直接结束, 不会强行等5秒到了再结束.

6. 线程休眠

  我们通过Thread.sleep()方法来休眠线程, 这个方法在上面的代码中已经见到了, 但要注意的是, 由于线程的调度是不可控的, 所以这种方法只能保证实际休眠时间大于或等于参数设置的休眠时间.

那么, sleep()方法是如何使线程休眠的呢?

  之前我们提到了, 操作系统是通过将进程的PCB串成双向链表来组织进程的, 这种说法有个前提, 就是一个进程中只存在一个线程. 如果一个进程中有多个线程, 此时每个线程都有一个PCB, 这时一个进程对应的就是一组PCB了. PCB上有一个字段tgroupid, 同一个进程中, 不同PCB上的tgroupid是相同的, 这个id就相当于进程的id.

   所有的PCB并不都是被串在同一个链表上的, 处于就绪态的PCB会被串在同一个链表中, 处于阻塞态的PCB也会被串在同一个链表中, 操作系统调度线程的时候只会在就绪链表中挑选合适的PCB并将其运行到CPU上, 而阻塞态的PCB就只能等着. 而当某个线程执行sleep()方法时, 这个线程的PCB就会被串到阻塞链表上, 这样就实现了对线程的休眠操作. 等到休眠时间结束之后, 操作系统又会将这个CPU移动到就绪链表中.

你可能感兴趣的:(JavaEE初阶,java-ee,学习)