多线程基础

进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

例如在windows环境下,可以使用任务管理器或pslist命令来查看系统的进程相关信息。

image-20210902211621185

也可以使用 [pslist](PsList - Windows Sysinternals | Microsoft Docs) 工具在windows中查看线程信息

1
pslist -t

image-20210902211947434

可以使用下面的命令来查看一个进程所包含的线程的详细信息:

1
pslist -dmx PID

例如:

image-20210902212146920

Linux下的操作更为简便,可以使用 ps 命令来查看进程及相关信息。htop 也是一款相当不错的查看进程及线程相关信息的图形化工具。

线程

一个进程中可以容纳若干个线程,线程就是轻量级进程,是程序执行的最小单位。使用多线程而不是用多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程。

协程

协程,又称微线程,纤程。英文名Coroutine。协程的概念很早就提出来了,但直到最近几年才在某些语言(如Lua)中得到广泛应用。子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。

所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。

协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

注意,在一个子程序中中断,去执行其他子程序,不是函数调用,有点类似CPU的中断。比如子程序A、B:

1
2
3
4
5
6
7
8
9
def A():
    print '1'
    print '2'
    print '3'

def B():
    print 'x'
    print 'y'
    print 'z'

假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A,结果可能是:

1
2
3
4
5
6
1
2
x
y
3
z

但是在A中是没有调用B的,所以协程的调用比函数调用理解起来要难一些。看起来A、B的执行有点像多线程,但协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

因为协程是一个线程执行,那怎么利用多核CPU呢?最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。

线程的状态

img

1
2
3
4
5
6
7
8
9
// Thread.java 中关于线程状态的定义
public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}
  • 初始(NEW): 新创建了一个线程对象,但还没有调用start()方法。
  • 运行(RUNNABLE): Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  • 阻塞(BLOCKED): 表示线程阻塞于锁。
  • 等待(WAITING): 进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  • 超时等待(TIMED_WAITING): 该状态不同于WAITING,它可以在指定的时间后自行返回。
  • 终止(TERMINATED): 表示该线程已经执行完毕。

线程类的常用方法

start

启动线程,Java虚拟机会去调用这个线程的run方法。一个线程是不能够被启动两次的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Thread t1 = new Thread(() -> {
	int count = 0;
	while (count <= 3){
		System.out.println("t1 running @ " + System.currentTimeMillis());

		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}

		count++;
	}
});

t1.start();
t1.join();
t1.start(); // <-- 这里会抛出:java.lang.IllegalThreadStateException

sleep

使得当前的执行线程休眠指定的毫秒数(暂时停止执行),休眠时间的精度受制于系统时钟的频率和调度程序的精确度。

1
2
3
4
5
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    System.out.println("t1 interrupted");
}

yield

调用yield(让步)方法,就可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用CPU了。这个暗示将通过调用yield()方法来作出(不过这只是一个暗示,没有任何机制保证它将会被采纳)。当调用yield()时,你也是在建议具有相同优先级的其他线程可以运行。

join

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ThreadTest3 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int count = 0;
            while (count < 10) {
                System.out.println("t1 running @ " + System.currentTimeMillis());

                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                count++;
            }
        });

        t1.start();

        // t1.join(n) 等待t1线程结束,最多等待n秒。如果n秒后t1不结束,则继续主线程
        // t1.join() 或 t1.join(0), 表示等待时间不限,一直等到线程t1结束
        t1.join();

        System.out.println("main finished @ " + System.currentTimeMillis());
    }
}

interrupt

一个线程在未正常结束之前, 被强制终止是很危险的事情. 因为它可能带来完全预料不到的严重后果比如会带着自己所持有的锁而永远的休眠,迟迟不归还锁等。 所以你看到Thread.suspend, Thread.stop等方法都被Deprecated了。

正确的结束一个线程的方式,是用一个变量来标记线程是否应该停止运行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private class MyThread extends Thread {

    private boolean isRunning = true;

    @Override
    public void run(){
        while(isRunning){
            // xxxxx

            // 通过控制isRunning变量,来控制线程是否结束
            // 可以在run方法中去控制isRunning变量,也可以在别的地方去控制。
            if(someCondition){
                isRunning = false;
            }
        }
    }
}

另外使线程终止的方法,还有使用等待(wait)通知(notify)机制,或者使用中断信号(interrupt)。

中断是通过调用Thread.interrupt()方法来做的。这个方法通过修改了被调用线程的中断状态来告知那个线程, 说它被中断了。对于非阻塞中的线程,只是改变了中断状态,即Thread.isInterrupted()将返回true。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class ThreadTest4 {

    public static void main(String[] args) throws InterruptedException {
        // 这个线程没有使用sleep,wait,join等方法进行阻塞,所以当主线程调用 t1.interrupt的
        // 时候,isInterrupted将返回true。
        Thread t1 = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("t1 running @ " + System.currentTimeMillis());
            }
        });

        t1.start();
        Thread.sleep(3000);
        t1.interrupt();
    }
}

对于可取消的阻塞状态中的线程, 比如等待在这些函数上的线程, Thread.sleep(),Object.wait(),Thread.join(),这个线程收到中断信号后,会抛出InterruptedException,同时会把中断状态置回为true。但调用Thread.interrupted()会对中断状态进行复位。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ThreadTest4 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            Thread t = Thread.currentThread();
            while (!t.isInterrupted()) {
                System.out.println("t1 running @ " + System.currentTimeMillis());

                // 对于阻塞线程(调用了Thread.sleep, Object.wait,Thread.join等方法),这个线程收到中断信号后,
                // 会抛出InterruptedException, 同时会把中断状态置回为true
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // 捕获这个异常的时候,中断状态会被重置回true,再执行interrupt会将中断状态置回
                    t.interrupt();
                    System.out.println("INTERRUPTED!");
                }
            }
        });

        t1.start();
        Thread.sleep(3000);
        
        t1.interrupt();
    }
}

wait / notify / notifyAll

wait()使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力。通常,这种条件将由另一个任务来改变。你肯定不想在你的任务测试这个条件的同时,不断地进行空循环,这被称为忙等待,通常是一种不良的CPU周期使用方式。因此wait()会在等待外部世界产生变化的时候将任务挂起,并且只有在notify()或notifyAll()发生时,即表示发生了某些感兴趣的事物,这个任务才会被唤醒并去检查所产生的变化。因此,wait()提供了一种在任务之间对活动同步的方式。

有两种形式的wait()。第一种版本接受毫秒数作为参数,含义与sleep()方法里参数的意思相同,都是指“在此期间暂停”。但是与sleep()不同的是,对于wait()而言:

  • 在wait()期间对象锁是释放的。
  • 可以通过notify()、notifyAll(),或者令时间到期,从wait()中恢复执行。

第二种,也是更常用形式的wait()不接受任何参数。这种wait()将无限等待下去,直到线程接收到notify()或者notifyAll()消息。

wait()、notify()以及notifyAll()有一个比较特殊的方面,那就是这些方法是基类Object的一部分,而不是属于Thread的一部分。尽管开始看起来有点奇怪——仅仅针对线程的功能却作为通用基类的一部分而实现,不过这是有道理的,因为这些方法操作的锁也是所有对象的一部分。所以,你可以把wait()放进任何同步控制方法里,而不用考虑这个类是继承自Thread还是实现了Runnable接口。实际上,只能在同步控制方法或同步控制块里调用wait()、notify()和notifyAll()(因为不用操作锁,所以sleep()可以在非同步控制方法里调用)。如果在非同步控制方法里调用这些方法,程序能通过编译,但运行的时候,将得到IllegalMonitorStateException 异常,并伴随着一些含糊的消息,比如“当前线程不是拥有者”。消息的意思是,调用wait()、notify()和notifyAll()的任务在调用这些方法前必须“拥有”(获取)对象的锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
 * wait / notify / notifyAll 的例子, t1启动后,等待signal对象的notify / notifyAll信号。t2启动3秒之后发送notify信号
 * 这时t1会接收到信号,继续执行后面的代码。
 * 
 * 在线程中调用wait方法的时候要用synchronized或其他方式锁住对象,如果没有锁住对象,那么当前的线程不是此对象监视器的所有者
 * , 就会抛出 IllegalMonitorStateException 异常信息
 */
public class ThreadTest6 {

    public static void main(String[] args) {

        final Object signal = new Object();

        Thread t1 = new Thread(() -> {
            try {
                synchronized (signal) {
                    signal.wait();
                }
                System.out.println("t1 wake up @ " + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (signal) {
                signal.notify();
            }

            System.out.println("t2 finished @ " + System.currentTimeMillis());
        });

        t1.start();
        t2.start();
    }
}

在线程中调用wait方法的时候要用synchronized或其他方式锁住对象,确保代码段不会被多个线程调用。如果没有synchronized加锁,那么当前的线程不是此对象监视器的所有者, 就会抛出 IllegalMonitorStateException 异常信息。

后台线程

所谓后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package thread;

import static java.lang.Thread.sleep;

public class DaemonThread {

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

        Thread t1 = new Thread(() -> {
            while (true){
                System.out.println("t1 running @ " + System.currentTimeMillis());
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.setDaemon(true);
        t1.start();
        
        sleep(3000);
        System.out.println("main finished!");
    }
}

如上例,当将t1线程设置为Daemon线程之后,主线程结束之后,t1线程就退出了。而如果不设置为Daemon线程,主线程终止后,t1线程还会继续执行。

volatile

如果你查阅一下英文字典,有关volatile的解释,你会得到最常用的解释是“易变的,不稳定的”。这也正是使用volatile关键字的语义。

当你用volatile去申明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够“看到”这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点。

需要注意的是:volatile 关键字只能保证应用范围内某个线程对变量的改动能够被其他线程看到,并不能保证这些改动是原子性的。

比如下面的例子,通过volatile是无法保证i++的原子性操作的,在多线程的情况下,即使变量i被声明为volatile类型,由于i++操作不是原子性的,下面的程序在多线程情况下依然会出问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static volatile int i = 0;

public static class PlusTask implements Runnable{
    @Override
    public void run() {
        for(int k=0;k<10000;k++){
            i++;
        }
    }
}

public static void main(String[] args) throws InterruptedException {
    Thread[] threads=new Thread[10];
    
    for(int i=0;i<10;i++){
        threads[i]=new Thread(new PlusTask());
        threads[i].start();
    }
    
    for(int i=0;i<10;i++){
        threads[i].join();
    }
    
    System.out.println(i);
}

参考