线程学习笔记

64 阅读15分钟

学习《Java并发编程艺术》这本书做的笔记

线程简介

现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个 Java 程 序,操作系统就会创建一个 Java 进程。现代操作系统调度的最小单元是线程,也叫轻量 级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自 的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程 上高速切换,让使用者感觉到这些线程在同时执行。

线程的状态

状态名称说明
NEW初始状态,线程被构建,但是还没有调用start0方法
RUNNABLE运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称作"运行中"
BLOCKED阻塞状态,表示线程阻塞于锁
WAITING等待状态,表示线程进入等待状态,进入该状态表示当前线和程需要等待其他线程做出一些特定动作(通知或中断)
TIME WAITING超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的
TERMINATED终止状态,表示当前线程已经执行完毕
public class ThreadStatusDemo {
    public static void main(String[] args) {
        new Thread(new TimeWaiting(), "TimeWaitingThread").start();
        new Thread(new Waiting(), "WaitingThread").start();
        // 使用两个 Blocked 线程,一个获取锁成功,另一个被阻塞
        new Thread(new Blocked(), "BlockedThread-1").start();
        new Thread(new Blocked(), "BlockedThread-2").start();
    }

    private static void second(long seconds) {
        try {
            TimeUnit.SECONDS.sleep(seconds);
        } catch (InterruptedException e) {
        }
    }

    // 该线程不断地进行睡眠
    static class TimeWaiting implements Runnable {
        @Override
        public void run() {
            while (true) {
                second(100);
            }
        }
    }

    // 该线程在 Waiting.class 实例上等待
    static class Waiting implements Runnable {
        @Override
        public void run() {
            while (true) {
                synchronized (Waiting.class) {
                    try {
                        Waiting.class.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    // 该线程在 Blocked.class 实例上加锁后,不会释放该锁
    static class Blocked implements Runnable {
        public void run() {
            synchronized (Blocked.class) {
                while (true) {
                    second(100);
                }
            }
        }
    }
}

运行该示例,打开终端或者命令提示符,键入“jps”,输出如下。

PS C:\Users\xxx> jps
20020 Jps
23476 ThreadStatusDemo
5304 RemoteMavenServer36
16540
22364 GradleDaemon

可以看到运行示例对应的进程 ID 是 23476,接着再键入“jstack 23476”(这里的进程 ID 需要和读者自己键入 jps 得出的 ID 一致),部分输出如下所示。

PS C:\Users\xxx> jstack 23476
"TimeWaitingThread" #13 prio=5 os_prio=0 cpu=0.00ms elapsed=107.47s tid=0x000001f5fcaa9000 nid=0x5b98 waiting on condition  [0x00000015f9eff000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
   
"WaitingThread" #14 prio=5 os_prio=0 cpu=0.00ms elapsed=107.47s tid=0x000001f5fca49000 nid=0x30f0 in Object.wait()  [0x00000015f9fff000]
   java.lang.Thread.State: WAITING (on object monitor)

"BlockedThread-1" #15 prio=5 os_prio=0 cpu=0.00ms elapsed=107.46s tid=0x000001f5fca4a000 nid=0x4f10 waiting on condition  [0x00000015fa0fe000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)

"BlockedThread-2" #16 prio=5 os_prio=0 cpu=0.00ms elapsed=107.46s tid=0x000001f5fca4b800 nid=0x550c waiting for monitor entry  [0x00000015fa1ff000]
   java.lang.Thread.State: BLOCKED (on object monitor)

通过示例,我们了解到 Java 程序运行中线程状态的具体含义。线程在自身的生命周期中,并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换, Java 线程状态变迁如图。

image.png

线程创建之后,调用 start()方法开始运行。当线程执行 wait() 方法之后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能够返 回到运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超 时时间到达时将会返回到运行状态。当线程调用同步方法时,在没有获取到锁的情况 下,线程将会进入到阻塞状态。线程在执行 Runnable 的 run()方法之后将会进入到终止状态。

注意:

  • Java 将操作系统中的运行和就绪两个状态合并称为运行状态。
  • 阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或代码块(获取锁)时的状态,
  • 阻塞在 java.concurrent 包中 Lock 接口的线程状态却是等待状态,因为 java.concurrent 包中 Lock 接口对于阻塞的实现均使用了 LockSupport 类中的相关方法。

Daemon 线程

Daemon 线程是一种支持型线程(常被叫做守护线程),因为它主要被用作程序中后台 调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候, Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置为 Daemon 线 程。

注意:

  • Daemon 属性需要在启动线程之前设置,不能在启动线程之后设置。 Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行
  • 在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理 资源的逻辑

启动和终止线程

在前面的示例中通过调用线程的 start()方法进行启动,随着 run()方法的执行完毕,线程也随之终止

构造线程

在运行线程之前首先要构造一个线程对象,线程对象在构造的时候需要提供线程所 需要的属性,如线程所属的线程组、线程优先级、是否是 Daemon 线程等信息。 一个新构造的线程对象是由其 parent 线程来进行空间分配的,而 child 线程继承了 parent 是否为 Daemon、优先级和加载资源的 contextClassLoader 以及可继承的 ThreadLocal,同时还会分配一个唯一的 ID 来标识这个 child 线程。至此,一个能 够运行的线程对象就初始化好了,在堆内存中等待着运行。

启动线程

调整线程的start方法,使线程进入运行状态(READY或RUNNING)

注意 启动一个线程前,最好为这个线程设置线程名称,因为这样在使用 jstack 分析程序或者进行问题排查时,就会给开发人员提供一些提示,自定义的线程最好能够起个名字

中断线程

其他线程通过调用该线程的 interrupt()方法对其进行中断操作。

线程通过检查自身是否被中断来进行响应,线程通过方法 isInterrupted()来进行判断 是否被中断,也可以调用静态方法 Thread.interrupted()对当前线程的中断标识位进行复 位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的 isInterrupted()时依旧会返回 false。

从 Java 的 API 中可以看到,许多声明抛出 InterruptedException 的方法(例如 Thread.sleep(longmillis)方法)这些方法在抛出 InterruptedException 之前,Java 虚拟机会 先将该线程的中断标识位清除,然后抛出 InterruptedException,此时调用 isInterrupted() 方法将会返回 false。

在以下所示的例子中,首先创建了两个线程,SleepThread 和 BusyThread, 前者不停地睡眠,后者一直运行,然后对这两个线程分别进行中断操作,观察二者的中断标识位。

public class ThreadInterruptDemo {
    public static void main(String[] args) throws Exception {
        // sleepThread 不停的尝试睡眠
        Thread sleepThread = new Thread(new SleepRunner(), "SleepThread");
        sleepThread.setDaemon(true);
        // busyThread 不停的运行
        Thread busyThread = new Thread(new BusyRunner(), "BusyThread");
        busyThread.setDaemon(true);
        sleepThread.start();
        busyThread.start();
        // 休眠 5 秒,让 sleepThread 和 busyThread 充分运行
        TimeUnit.SECONDS.sleep(5);
        sleepThread.interrupt();
        busyThread.interrupt();
        System.out.println("SleepThread interrupted is " + sleepThread.isInterrupted());
        System.out.println("BusyThread interrupted is " + busyThread.isInterrupted());

        // 防止 sleepThread 和 busyThread 立刻退出
        TimeUnit.SECONDS.sleep(2);
    }

    static class SleepRunner implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static class BusyRunner implements Runnable {
        @Override
        public void run() {
            while (true) {
            }
        }
    }
}

输出结果如下:

SleepThread interrupted is false
BusyThread interrupted is true
java.lang.InterruptedException: sleep interrupted

从结果可以看出,抛出 InterruptedException 的线程 SleepThread,其中断标识位被清 除了,而一直忙碌运作的线程 BusyThread,中断标识位没有被清除

过时的 suspend()、resume()和 stop()

不建议使用的原因主要有:以 suspend()方法为例,在调用后,线程不会释放已经占 有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样, stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资 源释放工作的机会,因此会导致程序可能工作在不确定状态下。

注意 正因为 suspend()、resume()和 stop()方法带来的副作用,这些方法才被标 注为不建议使用的过期方法,而暂停和恢复操作可以用后面提到的等待/通知机制来替代。

安全地终止线程

除了中断以外,还可以利用一 个 boolean 变量来控制是否需要停止任务并终止该线程。 如下示例:

public class ThreadShutdownDemo {
    public static void main(String[] args) throws Exception {
        Runner one = new Runner();
        Thread countThread = new Thread(one, "CountThread");
        countThread.start();
        // 睡眠 1 秒,main 线程对 CountThread 进行中断,使 CountThread 能够感知中断而结束
        TimeUnit.SECONDS.sleep(1);
        countThread.interrupt();
        Runner two = new Runner();
        countThread = new Thread(two, "CountThread");
        countThread.start();
        // 睡眠 1 秒,main 线程对 Runner two 进行取消,使 CountThread 能够感知 on 为 false 而结束
        TimeUnit.SECONDS.sleep(1);
        two.cancel();
    }

    private static class Runner implements Runnable {
        private long i;
        private volatile boolean on = true;
        @Override
        public void run() {
            while (on && !Thread.currentThread().isInterrupted()) {
                i++;
            }
            System.out.println("Count i = " + i);
        }
        public void cancel() {
            on = false;
        }
    }
}

输出结果:

Count i = 523404104
Count i = 478187006

示例在执行过程中,main 线程通过中断操作和 cancel()方法均可使 CountThread 得以 终止。这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而 不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅

线程间通信

Java 支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有 这个变量的拷贝(虽然对象以及成员变量分配的内存是在共享内存中的,但是每个执行 的线程还是可以拥有一份拷贝,这样做的目的是加速程序的执行,这是现代多核处理器 的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。

关键字 volatile

关键字 volatile 可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问 均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程 对变量访问的可见性。

过多地使用 volatile 是不必要的,因为它会降低程序执行的效率。

关键字 synchronized

关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多 个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访 问的可见性和排他性。

image.png

从图 4-2 中可以看到,任意线程对 Object(Object 由 synchronized 保护)的访问,首 先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻 塞在同步队列中的线程,使其重新尝试对监视器的获取。

等待/通知机制

方法名称描述
notify()通知一个在对象上等待的线程,使其从wait0方法返回,而返回的前提是该线程获取到了对象的锁
notifyAll()通知所有等待在该对象上的线程
wait()调用该方法的线程进入WAITING状态,只有等待另外线程的的通知或被中断才会返回,需要注意,调用wait()方法后,会释放对象的锁
wait(long)超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回
wait(long, int)对于超时时间更细粒度的控制,可以达到纳秒
代码示例:
public class ThreadWaitOrNotifyDemo {
    static boolean flag = true;
    static final Object lock = new Object();
    public static void main(String[] args) throws Exception {
        Thread waitThread = new Thread(new Wait(), "WaitThread");
        waitThread.start();
        TimeUnit.SECONDS.sleep(1);
        Thread notifyThread = new Thread(new Notify(), "NotifyThread");
        notifyThread.start();
    }
    public static String getDate() {
        return new SimpleDateFormat(" HH: mm: ss ").format(new Date());
    }
    static class Wait implements Runnable {
        public void run() {
            // 加锁,拥有 lock 的 Monitor
            synchronized (lock) {
                // 当条件不满足时,继续 wait,同时释放了 lock 的锁
                while (flag) {
                    try {
                        System.out.println(Thread.currentThread() + " flag is true. wa @ " + getDate());
                        lock.wait();
                    } catch (InterruptedException ignored) {
                    }
                }
                // 条件满足时,完成工作
                System.out.println(Thread.currentThread() + " flag is false. running @ " + getDate());
            }
        }
    }
    static class Notify implements Runnable {
        public void run() {
            // 加锁,拥有 lock 的 Monitor
            synchronized (lock) {
                // 获取 lock 的锁,然后进行通知,通知时不会释放 lock 的锁,
                // 直到当前线程释放了 lock 后,WaitThread 才能从 wait 方法中返回
                System.out.println(Thread.currentThread() + " hold lock. notify @ " + getDate());
                lock.notifyAll();
                flag = false;
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
            // 再次加锁
            synchronized (lock) {
                System.out.println(Thread.currentThread() + " hold lock again. sleep @ " + getDate());
                try {
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

输出结果如下:

Thread[WaitThread,5,main] flag is true. wa @  16: 02: 09 
Thread[NotifyThread,5,main] hold lock. notify @  16: 02: 10 
Thread[NotifyThread,5,main] hold lock again. sleep @  16: 02: 15 
Thread[WaitThread,5,main] flag is false. running @  16: 02: 20 

上述第 3 行和第 4 行输出的顺序可能会互换,而上述例子主要说明了调用 wait()、notify() 以及 notifyAll() 时需要注意的细节,如下。

  1. 使用 wait()、notify()notifyAll() 时需要先对调用对象加锁。
  2. 调用 wait() 方法后,线程状态由 RUNNING 变为 WAITING,并将当前线程放置 到对象的等待队列。
  3. notify()notifyAll() 方法调用后,等待线程依旧不会从 wait() 返回,需要调用 notify()notifAll() 的线程释放锁之后,等待线程才有机会从 wait() 返回。
  4. notify() 方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而 notifyAll() 方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程 状态由 WAITING 变为 BLOCKED
  5. wait() 方法返回的前提是获得了调用对象的锁。 从上述细节中可以看到,等待/通知机制依托于同步机制,其目的就是确保等待线程 从 wait() 方法返回时能够感知到通知线程对变量做出的修改。

image.png

在图 4-3 中,WaitThread 首先获取了对象的锁,然后调用对象的 wait() 方法,从而放弃了锁并进入了对象的等待队列 WaitQueue 中,进入等待状态。由于 WaitThread 释放了 对象的锁, NotifyThread 随后获取了对象的锁,并调用对象的 notify() 方法,将 WaitThreadWaitQueue 移到 SynchronizedQueue 中,此时 WaitThread 的状态变为阻塞状 态。NotifyThread 释放了锁之后, WaitThread 再次获取到锁并从 wait() 方法返回继续执行。

等待/通知的经典范式

等待方遵循如下原则:

  1. 获取对象的锁。
  2. 如果条件不满足,那么调用对象的 wait() 方法,被通知后仍要检查条件。
  3. 条件满足则执行对应的逻辑。

对应的伪代码如下。

synchronized(对象) { 
    while(条件不满足) { 
        对象.wait(); 
    } 
    对应的处理逻辑 
}

通知方遵循如下原则:

  1. 获得对象的锁。
  2. 改变条件。
  3. 通知所有等待在对象上的线程。

对应的伪代码如下。

synchronized(对象) { 
    改变条件 
    对象.notifyAll(); 
}

管道输入/输出流

管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它 主要用于线程之间的数据传输,而传输的媒介为内存。管道输入/输出流主要包括了如下 4 种具体实现:PipedOutputStreamPipedInputStreamPipedReaderPipedWriter,前两 种面向字节,而后两种面向字符。

public class PipedDemo {
    public static void main(String[] args) throws Exception {
        PipedWriter out = new PipedWriter();
        PipedReader in = new PipedReader();
        // 将输出流和输入流进行连接,否则在使用时会抛出 IOException
        out.connect(in);
        Thread printThread = new Thread(new Print(in), "PrintThread");
        printThread.start();
        int receive = 0;
        try {

            while ((receive = System.in.read()) != -1) {
                out.write(receive);
            }
        } finally {
            out.close();
        }
    }
    static class Print implements Runnable {
        private final PipedReader in;
        public Print(PipedReader in) {
            this.in = in;
        }
        public void run() {
            int receive = 0;
            try {
                while ((receive = in.read()) != -1) {
                    System.out.print((char) receive);
                }
            } catch (IOException ignored) {
            }
        }
    }
}

Thread.join()的使用

如果一个线程 A 执行了 thread.join()语句,其含义是:当前线程 A 等待 thread 线程终 止之后才从 thread.join()返回。线程 Thread 除了提供 join()方法之外,还提供了 join(long millis)和 join(longmillis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果 线程 thread 在给定的超时时间里没有终止,那么将会从该超时方法中返回。

示例代码:

public class ThreadJoinDemo {
    public static void main(String[] args) throws Exception {
        Thread previous = Thread.currentThread();
        for (int i = 0; i < 10; i++) {
            // 每个线程拥有前一个线程的引用,需要等待前一个线程终止,才能从等待中返回
            Thread thread = new Thread(new Domino(previous), String.valueOf(i));
            thread.start();
            previous = thread;
        }
        TimeUnit.SECONDS.sleep(5);
        System.out.println(Thread.currentThread().getName() + " terminate.");
    }

    static class Domino implements Runnable {
        private final Thread thread;

        public Domino(Thread thread) {
            this.thread = thread;
        }

        public void run() {
            try {
                thread.join();
            } catch (InterruptedException ignored) {
            }
            System.out.println(Thread.currentThread().getName() + " terminate.");
        }
    }

}

输出如下:

main terminate.
0 terminate.
1 terminate.
2 terminate.
3 terminate.
4 terminate.
5 terminate.
6 terminate.
7 terminate.
8 terminate.
9 terminate.

ThreadLocal 的使用

ThreadLocal,即线程变量,是一个以 ThreadLocal 对象为键、任意对象为值的存储 结构。这个结构被附带在线程上,也就是说一个线程可以根据一个 ThreadLocal 对象查询 到绑定在这个线程上的一个值。可以通过 set(T)方法来设置一个值,在当前线程下再通 过 get()方法获取到原先设置的值。

代码示例:

public class ThreadLocalDemo {
    // 第一次 get()方法调用时会进行初始化(如果 set 方法没有调用),每个线程会调用一次
    private static final ThreadLocal<Long> TIME_THREAD_LOCAL = new ThreadLocal<>();

    protected Long initialValue() {
        return System.currentTimeMillis();
    }

    public static void begin() {
        TIME_THREAD_LOCAL.set(System.currentTimeMillis());
    }

    public static long end() {
        return System.currentTimeMillis() - TIME_THREAD_LOCAL.get();
    }

    public static void main(String[] args) throws Exception {
        ThreadLocalDemo.begin();
        TimeUnit.SECONDS.sleep(1);
        System.out.println("Cost: " + ThreadLocalDemo.end() + " mills");
    }
}