Java多线程系列—线程的运行机制(02)

线程的运行机制

在这一节我们主要学习一下线程的启动,线程的停止以及线程的状态流转

线程的启动

start 方法和 run 方法

这个其实是一个非常老生常谈的问题了,就是说我们只有调用start 方法才会帮我们启动一个线程,如果你是直接调用run 方法的话,那其实就是同步调用。

public class StartRight {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            String name = Thread.currentThread().getName();
            System.out.println(name + "开始执行");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + "执行结束");
        });

        Thread t2 = new Thread(() -> {
            String name = Thread.currentThread().getName();
            System.out.println(name + "开始执行");
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + "执行结束");
        });

        run(t1, t2);
        System.out.println("==========================");
        start(t1, t2);
    }

    public static void run(Thread t1, Thread t2) {
        t1.run();
        t2.run();
    }

    public static void start(Thread t1, Thread t2) {
        t1.start();
        t2.start();
    }
}
复制代码

输出结果如下:我们看到run 方法本质还是在主线程里面执行,start 才是真正的启动了多个线程

main开始执行
main执行结束
main开始执行
main执行结束
==========================
Thread-0开始执行
Thread-1开始执行
Thread-0执行结束
Thread-1执行结束
复制代码

多次启动

我们先看一下多次启动会出现什么

public class ThreadStartTimes {
    public static void main(String[] args) {
        Runnable target;
        Thread thread = new Thread(()->{
            System.out.println(Thread.currentThread().getName());
        });

        thread.start();
        System.out.println(1);
        thread.start();
        System.out.println(2);
        thread.start();
        System.out.println(3);
    }
}
复制代码

输出如下:

1
Thread-0
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.lang.Thread.start(Thread.java:708)
	at thread.thread.ThreadStartTimes.main(ThreadStartTimes.java:12)
复制代码

我们看到报错了,那我们看一下这个方法的实现

public synchronized void start() {
    /**
     * 也就是说我们的线程如果处在新建的状态下(NEW),threadStatus是0,其他状态下的线程是不能调用start 方法的
     * A zero status value corresponds to state "NEW".
     */
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    /* Notify the group that this thread is about to be started
     * so that it can be added to the group's list of threads
     * and the group's unstarted count can be decremented. */
    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {
            /* do nothing. If start0 threw a Throwable then
              it will be passed up the call stack */
        }
    }
}
复制代码

也就是说如果我们的线程在第一次调用start 方法之后threadStatus以及不是0了,那这个时候你如果再去调用这个方法的话,就会报错。但是需要注意的是你在Thread 代码里面看不到状态的变更,也就是说状态的变更是由local 方法维护的

线程的停止

想要启动线程需要调用 Thread 类的 start() 方法,并在 run() 方法中定义需要执行的任务。启动一个线程非常简单,但如果想要正确停止它就没那么容易了。

通常情况下,我们不会手动停止一个线程,而是允许线程运行到结束,然后让它自然停止。但是依然会有许多特殊的情况需要我们提前停止线程,比如:用户突然关闭程序,或程序运行出错重启等。在这种情况下,即将停止的线程在很多业务场景下仍然很有价值。尤其是我们想写一个健壮性很好,能够安全应对各种场景的程序时,正确停止线程就显得格外重要。但是Java 并没有提供简单易用,能够直接安全停止线程的能力

interrupt

对于 Java 而言,最正确的停止线程的方式是使用 interrupt。但 interrupt 仅仅起到通知被停止线程的作用。而对于被停止的线程而言,它拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。那么为什么 Java 不提供强制停止线程的能力呢?

事实上,Java 希望程序间能够相互通知、相互协作地管理线程,因为如果不了解对方正在做的工作,贸然强制停止线程就可能会造成一些安全的问题,为了避免造成问题就需要给对方一定的时间来整理收尾工作。比如:线程正在写入一个文件,这时收到终止信号,它就需要根据自身业务判断,是选择立即停止,还是将整个文件写入成功后停止,而如果选择立即停止就可能造成数据不完整,不管是中断命令发起者,还是接收者都不希望数据出现问题。

如何用 interrupt 停止线程

我们需要使用下面这样的代码设计,去感知程序有没有让线程停止

while (!Thread.currentThread().isInterrupted() && more work to do) {

    do more work

}
复制代码

明白 Java 停止线程的设计原则之后,我们看看如何用代码实现停止线程的逻辑。我们一旦调用某个线程的 interrupt() 之后,这个线程的中断标记位就会被设置成 true。每个线程都有这样的标记位,当线程执行时,应该定期检查这个标记位,如果标记位被设置成 true,就说明有程序想终止该线程。

可以看到下面代码在 while 循环体判断语句中,首先通过 Thread.currentThread().isInterrupt() 判断线程是否被中断,随后检查是否还有工作要做。&& 逻辑表示只有当两个判断条件同时满足的情况下,才会去执行下面的工作。

public class StopThread implements Runnable{
    @Override
    public void run() {
        int count = 0;
        while (!Thread.currentThread().isInterrupted() && count < 1000) {
            System.out.println("count = " + count++);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new StopThread());
          
        thread.start();
        // 为了不让主线程退出,从而方便观察子线程的输出
        Thread.sleep(5);
        // 停止子线程
        
        thread.interrupt();
    }
}
复制代码

在 StopThread 类的 run() 方法中,首先判断线程是否被中断,然后判断 count 值是否小于 1000。这个线程的工作内容很简单,就是打印 0~999 的数字,每打印一个数字 count 值加 1,可以看到,线程会在每次循环开始之前,检查是否被中断了。接下来在 main 函数中会启动该线程,然后休眠 5 毫秒后立刻中断线程,该线程会检测到中断信号,于是在还没打印完1000个数的时候就会停下来,这种就属于通过 interrupt 正确停止线程的情况。

sleep 期间能否感受到中断

如果线程在执行任务期间有休眠需求,也就是每打印一个数字,就进入一次 sleep ,而此时将 Thread.sleep() 的休眠时间设置为 1000 秒钟。

public class StopDuringSleep {

    public static void main(String[] args) throws InterruptedException {
        canStopWhileSleep();
    }


    public static void canStopWhileSleep() throws InterruptedException {
        Runnable runnable = () -> {
            int num = 0;
            try {
                while (!Thread.currentThread().isInterrupted() && num <= 1000) {
                    System.out.println(num);
                    num++;
                    Thread.sleep(1000000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread thread = new Thread(runnable);

        thread.start();
        // 为了不让主线程退出,从而方便观察子线程的输出
        Thread.sleep(5);
        thread.interrupt();

    }
}
复制代码

主线程休眠 5 毫秒后,通知子线程中断,此时子线程仍在执行 sleep 语句,处于休眠中。那么就需要考虑一点,在休眠中的线程是否能够感受到中断通知呢?是否需要等到休眠结束后才能中断线程呢?如果是这样,就会带来严重的问题,因为响应中断太不及时了。正因为如此,Java 设计者在设计之初就考虑到了这一点。

如果调用了sleep、wait 等方法是线程进入休眠状态,这个时候线程是可以感受到中断信号的,并且会抛出一个 InterruptedException 异常,同时清除中断信号,将中断标记位设置成 false。这样一来就不用担心长时间休眠中线程感受不到中断了,因为即便线程还在休眠,仍然能够响应中断通知,并抛出异常。

错误的停止方法

首先,我们来看几种停止线程的错误方法。比如 stop(),suspend() 和 resume(),这些方法已经被 Java 直接标记为 @Deprecated。如果再调用这些方法,IDE 会友好地提示,我们不应该再使用它们了。但为什么它们不能使用了呢?是因为 stop() 会直接把线程停止,这样就没有给线程足够的时间来处理想要在停止前保存数据的逻辑,任务戛然而止,会导致出现数据完整性等问题

也就是Thread.currentThread().isInterrupted()可以给用户一个自控的环节,可以将当前任务处理完成之后再停止。

而对于 suspend() 和 resume() 而言,它们的问题在于如果线程调用 suspend(),它并不会释放锁,就开始进入休眠,但此时有可能仍持有锁,这样就容易导致死锁问题,因为这把锁在线程被 resume() 之前,是不会被释放的。假设线程 A 调用了 suspend() 方法让线程 B 挂起,线程 B 进入休眠,而线程 B 又刚好持有一把锁,此时假设线程 A 想访问线程 B 持有的锁,但由于线程 B 并没有释放锁就进入休眠了,所以对于线程 A 而言,此时拿不到锁,也会陷入阻塞,那么线程 A 和线程 B 就都无法继续向下执行。正是因为有这样的风险,所以 suspend() 和 resume() 组合使用的方法也被废弃了。

volatile 停止线程

这里我们是通过volatile来修饰一个布尔值,也就是是线程是否停止的标记,这样在运行的时候程序就可以判断该值是否为true,从而判断是否停止

public class StopThreadByVolatile implements Runnable{

    private volatile boolean canceled = false;

    @Override
    public void run() {
        int num = 0;
        try {
            while (!canceled && num <= 1000000) {
                if (num % 10 == 0) {
                    System.out.println(num + "是10的倍数");
                }
                num++;
                Thread.sleep(1);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        StopThreadByVolatile r = new StopThreadByVolatile();
        Thread thread = new Thread(r);
        thread.start();
        Thread.sleep(3000);
        r.canceled = true;
    }
}
复制代码

从输出我们已经看到程序停止了

... 
2300是10的倍数
2310是10的倍数
2320是10的倍数
2330是10的倍数
2340是10的倍数
2350是10的倍数
2360是10的倍数
2370是10的倍数
2380是10的倍数
复制代码

通过上面的代码我们其实可以判断处理啊,volatile的的变量是没有办法在休眠状态的时候进行响应的,这也是其不足之处,因为从上面的实现来看,要想退出线程必须是在线程运行的时候,也就是说是在线程运行到判断变量的时候,既然如此我们就不能在任何场景下都去使用volatile的方式去停止线程。

volatile 停止线程的不足

前面我们也说过了,volatile是没有办法在程序休眠的时候进行响应的,往往我们的休眠是在不满足程序的运行条件的时候才进行休眠,那这个时候如果我们想要取消程序,那就是不可能做到了

下面我们模拟一个ETL 的任务,因为是模拟所以我每次等待5s,真实情况应该每次等待的时间更多

public class StopThreadByVolatileNotPerfect implements Runnable {

    private volatile boolean canceled = false;
    private volatile boolean isExisting = false;
    private volatile boolean isSuccess = false;

    @Override
    public void run() {
        int num = 0;
        try {
            while (!canceled && num <= 9) {
                if (!isExisting) {
                    System.out.println("文件不存在,即将等待5秒钟,已经等待了:" + num * 5 + " 秒钟,");
                } else {
                    System.out.println("文件存在,开始处理 ....");
                    // 模拟处理
                    TimeUnit.MILLISECONDS.sleep(30);
                    System.out.println("文件处理完毕");
                    isSuccess = true;
                }
                num++;
                TimeUnit.SECONDS.sleep(5);
            }

            if (canceled) {
                isSuccess = true;
            }

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        StopThreadByVolatileNotPerfect r = new StopThreadByVolatileNotPerfect();
        Thread thread = new Thread(r);
        thread.start();
        TimeUnit.SECONDS.sleep(20);
        System.out.println("业务方告知今天没有数据,任务可以跳过");
        r.canceled = true;
        System.out.println("任务执行结果:"+r.isSuccess);
        TimeUnit.SECONDS.sleep(2);
        System.out.println("任务执行结果:"+r.isSuccess);
    }
}
复制代码

这里其实就是想要我们如果因为一些已知的原因取消了(skip)程序,但是因为线程已经进入等待,所以必须要等子线程唤醒之后才能响应我们的取消操作,而这个时候可能已经过去很久了,下面是输出。

文件不存在,即将等待5秒钟,已经等待了:0 秒钟,
文件不存在,即将等待5秒钟,已经等待了:5 秒钟,
文件不存在,即将等待5秒钟,已经等待了:10 秒钟,
文件不存在,即将等待5秒钟,已经等待了:15 秒钟,
业务方告知今天没有数据,任务可以跳过
任务执行结果:false
任务执行结果:true
复制代码

但是如果你将上面的等待时间改成了30分钟,那你必须等待30分钟后才能拿到正确的结果,其实这是对业务来说是不可接受的。

线程的状态

就像生物从出生到长大、最终死亡的过程一样,线程也有自己的生命周期,在 Java 中线程的生命周期中一共有 6 种状态。从前面我们了解到了线程其实是有状态变更的,所以导致了我们的同一个线程不能重复启动,下面我们看一下这些状态的流转与定义,

public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
复制代码

线程状态转换

  1. New 表示线程被创建但尚未启动的状态:当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,所以也没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New。而一旦线程调用了 start(),它的状态就会从 New 变成 Runnable。

  2. Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running 和 Ready,也就是说,Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。

  3. 在 Java 中阻塞状态通常不仅仅是 Blocked,实际上它包括三种状态,分别是 Blocked(被阻塞)、Waiting(等待)、Timed Waiting(计时等待),这三 种状态统称为阻塞状态,下面我们来看看这三种状态具体是什么含义。

    1. 首先来看最简单的 Blocked,从 Runnable 状态进入 Blocked 状态只有一种可能,就是进入 synchronized 保护的代码时没有抢到 monitor 锁,无论是进入 synchronized 代码块,还是 synchronized 方法,都是一样。
    2. 当处于 Blocked 的线程抢到 monitor 锁,就会从 Blocked 状态回到Runnable 状态
  4. Waiting 状态,Blocked 仅仅针对 synchronized monitor 锁,可是在 Java 中还有很多其他的锁,比如 ReentrantLock,如果线程在获取这种锁时没有抢到该锁就会进入 Waiting 状态,因为本质上它执行了 LockSupport.park() 方法,所以会进入 Waiting 状态。同样,Object.wait() 和 Thread.join() 也会让线程进入 Waiting 状态。Blocked 与 Waiting 的区别是 Blocked 在等待其他线程释放 monitor 锁,而 Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()/notifyAll() 。

  5. Timed Waiting 限期等待,Waiting和 Timed Waiting这两个状态是非常相似的,区别仅在于有没有时间限制,Timed Waiting 会等待超时,由系统自动唤醒,或者在超时前被唤醒信号唤醒。

    以下情况会让线程进入 Timed Waiting 状态。

    1. 设置了时间参数的 Thread.sleep(long millis) 方法;
    2. 设置了时间参数的 Object.wait(long timeout) 方法;
    3. 设置了时间参数的 Thread.join(long millis) 方法;
    4. 设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。
  6. Terminated 终止,再来看看最后一种状态,Terminated 终止状态,要想进入这个状态有两种可能。

  7. run() 方法执行完毕,线程正常退出。

  8. 出现一个没有捕获的异常,终止了 run() 方法,最终导致意外终止。

总结

  1. 已经被舍弃的 stop()、suspend() 和 resume(),它们由于有很大的安全风险比如死锁风险而被舍弃

  2. volatile 这种方法在某些特殊的情况下,比如线程被长时间阻塞的情况,就无法及时感受中断,所以 volatile 是不够全面的停止线程的方法。

  3. 比较好的响应方式其实是interrupt,一方面我们可以通过判断让线程的停止优雅一些,例如完成当前的工作,另外一方面它能在线程休眠阻塞的时候也可以响应中断

  4. 线程的状态对线程来说很重要,例如线程为什么不能多次启动,以及线程的状态流转对我们来说可以更好的理解线程

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享