0%

JDK Thread API

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在 Unix System V 及 SunOS 中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。

Thread API

Priority

线程有 1-10 的优先级,MAX_PRIORITY=10、NORM_PRIORITY=5,MIN_PRIORITY=1。在操作系统中,线程优先级高的任务可分配到更多的 CPU 资源,CPU 有限执行线程优先级高的任务。设置线程优先级有助于线程规划器确定哪个任务需要优先执行。

线程优先级具有继承特性,当 A 线程创建 B 线程,A 与 B 的线程优先级是相同的。并且其优先级又有其规则性,高优先级的线程总是大部分先执行完,但不代表高优先级的项目总是先全部执行完。先调用执行的线程不一定先执行完。CPU 总会尽量把执行资源让给优先级比较高的线程。

Sleep

sleep 方法会使当前线程进人指定毫秒数的休眠,暂停执行,虽然给定了一个休眠的时间,但是最终要以系统的定时器和调度器的精度为准。Sleep 有一个非常重要的特性,那就是其不会放弃 monitor 锁的所有权。因此,其他需要相同锁的线程会阻塞,即使 CPU 可用。所以要避免在同步方法或块内让线程休眠。

通过调用重载的Thread.sleep()静态方法,线程可以进入指定毫秒数或包括微秒的休眠。

//接受要休眠的毫秒数
public static void sleep (long milliseconds) throws InterruptedException;
//接受要休眠的亳秒数和亳微秒数
public static void sleep (long milliseconds, int nanoseconds) throws InterruptedException;

Thread 类提供的 sleep 方法的版本能够让开发者以纳秒来指定时间。大部分的 JVM 并不支持这种时间精确度。在运行 sleep 方法时,它会四舍五入纳秒的参数成最接近的毫秒。事实上,大部分的操作系统还会进一步地将毫秒参数以一个数字的倍数来调整:如 20 或 50 毫秒。因此,在大部分 Java 的实现中,可用的最小沉睡时间是 20 或 50 毫秒。如果想拥有更加精确的时间,是需要本地硬件和虚拟机同时满足的。

Java Community Process 正在进行的项目中有一个 real-time 的 Java 实现,在这个实现中,指定给 sleep 方法的分辨率终于可以实现。在大部分的平台上,开发者无法设计出可以支持纳秒(甚至是精确的毫秒)的应用程序。

线程不能绝对保证一定会休眠所期望的那么长时间。有时,在请求唤醒呼叫之后过段时间线程才会真正唤醒,因为 VM 正在忙于做其他事情。也可能时间还没有到,但有其他线程完成了一些操作而唤醒了休眠的线程。一般情况下,通过调用休眠线程的 interrupt 方法来唤醒休眠的线程。这会让休眠中的线程得到一个 InterruptedException 异常,正确捕获这个异常可以让线程继续运行下去。

Interrupt

有人也许会认为“当调用 interrupt 方法时,调用对象的线程就会抛出 InterruptedException 异常”,其实这是一种误解。实际上,interrupt 方法只是改变了线程的中断状态而已。所谓中断状态(interrupted status),是一种用于表示线程是否被中断的状态。

假设当线程 A 执行了 sleep、wait、join 而停止运行时,线程 B 调用了 A 的 interrupt 方法。这时,线程 A 的确会抛出 InterruptedException 异常。但这其实是因为这些方法内部对线程的中断状态进行了检查,进而抛出了 InterruptedException 异常。

需要注意的是,当抛出 InterruptedException 异常的同时会清除中断状态。

检查中断的方法

function description
thread.isInterrupted() 检查中断状态,但不清除中断状态
Thread.interrupted() 检查中断状态,并清除中断状态

Thread.interrupted 仅能清除自己线程的中断状态

Suspend/Resume

在 Thread 调用中可使用 suspend 暂停线程,resume 恢复线程执行。对于暂停线程的情况来说,暂停线程后,不会释放 Monitor 等资源,因此会带来线程独占的问题。下面有一个有趣的线程独占例子:

public class MyThread extends Thread {

    public int i = 0;

    @Override
    public void run() {
        while (true) {
            i++;
//            System.out.println(i);
        }
    }
}
public class MAIN {
    public static void main(String[] args) throws InterruptedException {
        MyThread thread = new MyThread();
        thread.start();
        Thread.sleep(1000);
        thread.suspend();
        System.out.println("MAIN END");
    }
}

如果在 MyThread 中将输出注释掉,运行结果是线程被暂停,程序不结束运行,MAIN 线程运行完成,输出 MAIN END。如果将注释打开,线程持续输出数字,直到被休眠,MAIN END 不被输出。原因如下:

public void println(String x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

输出函数为同步方法,当程序运行到 println 方法内部暂停后,同步锁未被释放,被锁住的打印函数无法打印 MAIN END。

Yield

yield 方法属于一种启发式的方法,其会提醒调度器我愿意放弃当前的 CPU 资源,如果 CPU 的资源不紧张则会忽略这种提醒。调用 yield 方法会使当前线程从 RUNNING 状态切换到 RUNNABLE 状态。

Wait/Notify Mechanism

等待 - 通知机制,是指一个线程 A 调用了对象 O 的 wait() 方法进入等待状态,而另一个线程 B 调用了对象 O 的 notify() 或者 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait() 方法返回,进而执行后续操作。上述两个线程通过对象 O 来完成交互,而对象上的 wait() 和 notify/notifyAll() 的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。

在虚拟机规范中存在一个 wait set 的概念,至于该 wait set 是怎样的数据结构,不同的 JVM 由不同的实现方式,但线程调用了某个对象的 wait 方法之后都会被加入与该对象 monitor 关联的 wait set 中,并且释放 monitor 的所有权。

上图是若干个线程调用了 wait 方法之后被加入与 monitor 关联的 wait set 中,待另外一个线程调用该 monitor 的 notify 方法之后,其中一个线程会从 wait set 中弹出,至于是随机弹出还是以先进先出的方式弹出,虚拟机规范同样也没有给出强制的要求。而执行 notifyAll 则不需要考虑哪个线程会被弹出,因为 wait set 中的所有 wait 线程都将被弹出。

需要注意的是,wait() 方法会使线程状态由 RUNNING 变为 WAITING,并释放对应的 Monitor 锁。而 notify() 方法将等待队列中的一个等待线程从等待队列中移到同步队列中,被移动的线程状态由 WAITING 转为 BLOCKED,并尝试获取 Monitor 锁。

关键字 synchronized 可以将任何一个 Object 对象作为同步对象来看待,而 Java 为每个 Object 都实现了 wait 和 notify 方法,它们必须用在被 synchronized 同步的 Object 的临界区内。通过调用 wait 方法可以使处于临界区内的线程进入等待状态,同时释放被同步对象的锁。而 notify 操作可以唤醒一个因调用了 wait 操作而处于阻塞状态中的线程,使其进入就绪状态。被重新换醒的线程会试图重新获得临界区的控制权,也就是锁,并继续执行临界区内 wait 之后的代码。如果发岀 notify 操作时没有处于阻塞状态中的线程,那么该命令会被忽略。

API Lock 实现

关于 JUC 包中的 Lock 实现,详情请阅读 JUC 系列。

通过 Wait/Notify Mechanism 实现一个 API 级别的锁。

public class BooleanLock {
    //当前拥有该锁的线程
    private Thread currentThread;
    private boolean locked = false;
    //储存阻塞状态的线程
    private final List<Thread> blockedList = new ArrayList<>();

    public void lock() throws InterruptedException {
        synchronized (this) {
            while (locked) {
                blockedList.add(Thread.currentThread());
                Thread.currentThread().wait();
            }

            blockedList.remove(Thread.currentThread());
            this.locked = true;
            this.currentThread = Thread.currentThread();
        }
    }

    public void lock(long mills) throws InterruptedException, TimeoutException {
        synchronized (this) {
            if (mills <= 0) {
                this.lock();
            } else {
                long deadline = System.currentTimeMillis() + mills;
                while (locked) {
                    if (deadline < System.currentTimeMillis())
                        throw new TimeoutException(String.format("can not get the lock during %d ms", mills));
                    if (!blockedList.contains(Thread.currentThread()))
                        blockedList.add(Thread.currentThread());
                    this.wait(deadline - System.currentTimeMillis());
                    this.locked = true;
                    this.currentThread = Thread.currentThread();
                }
            }
        }
    }

    public void unlock() {
        synchronized (this) {
            if (currentThread == Thread.currentThread()) {
                this.locked = false;
                this.notifyAll();
            }
        }
    }
}

问题:线程被中断,它还有可能存在于 blockList

lock() 方法中捕获 InterruptedException 异常,处理完 blockList 再抛出就好了。

Daemon Thread

Java 线程分为用户线程和守护线程。在 JVM 中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。当进程中不存在非守护线程,则守护线程自动销毁。最典型的守护线程就是垃圾回收线程,当进程中没有非守护线程,则垃圾回收线程也没有存在的必要了,自动销毁。

守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

Exception Handler

可以通过以下三种方式对线程做异常处理:try-catch 代码块、setUncaughtExceptionHandler 设置对象异常处理器、setDefaultUncaughtExceptionHandler 设置类异常处理器。当然以上顺序也是优先级从高到低的。

Hook

JVM 进程的退出是由于 JVM 进程中没有活跃的非守护线程,或者收到了系统中断信号,向 JVM 程序注人一个 Hook 线程,在 JVM 进程退出的时候,Hook 线程会启动执行,通过 Runtime 可以为 JVM 注人多个 Hook 线程。

Runtime.getRuntime().addShutdownHook(new Thread(()->{
    //OP
}));

Hook 线程应用场景以及注意事项

  • Hook 线程只有在收到退出信号的时候会被执行,如果在 kill 的时候使用了参数 -9,那么 Hook 线程不会得到执行,进程将会立即退出。
  • Hook 线程中也可以执行一些资源释放的工作,比如关闭文件句柄、socket 链接、数据库 connection 等。
  • 尽量不要在 Hook 线程中执行一些耗时非常长的操作,因为其会导致程序迟迟不能退出。

线程组

可以把线程归属到某一个线程组中,线程组中可以有线程对象,也可以有线程组,组中还可以有线程。这样的组织结构有些类似于树的形式。

所有 Java 线程都基于 System Thread Group 创建,在实例化一个 ThreadGroup 线程组时如果不指定所属的线程组则该线程组自动归到当前线程对象所属的线程组中。Java 应用程序都是以 Main 函数启动应用,因此通常情况下的线程组的继承关系为:system -> main -> custom

线程组可以很方便的以组为单位对线程进行组织,例如:interrupt() 中断整个线程组的线程,或是 enumerate(Thread list[], boolean recurse) 递归或非递归的获取组内的所有线程对象。

销毁 Group

销毁 ThreadGroup 及其子 ThreadGroup,在该 ThreadGroup 中所有的线程必须是空的,也就是说,ThreadGroup 或者子 ThreadGroup 所有的线程都已经停止运行,如果有 Active 线程存在,调用 destroy 方法则会抛出异常。

守护 Group

线程可以设置为守护线程,ThreadGroup 也可以设置为守护 ThreadGroup,但是若将一个 ThreadGroup 设置为 daemon,也并不会影响线程的 daemon 属性,如果一个 ThreadGroup 的 daemon 被设置为 true,那么在 group 中没有任何 active 线程的时候该 group 将自动 destroy。