Java多线程笔记(1)-线程的基本使用

基本知识

进程与线程

进程:

  • 程序由指令和数据组成,但是这些指令要运行,数据要读写,就必须将指令加载至CPU,将数据加载至内存;同时在指令运行过程中还需要用到磁盘、网络等设备。而进程就是用来加载指令、管理内存、管理IO的
  • 当一个程序被执行,从磁盘加载这个程序的代码到内存中,就已经开启了一个进程
  • 进程可以视为程序的一个实例,大部分程序可以同时运行多个实例进程,有的程序只启动一个实例进程

线程:

  • 一个进程可以启动一个或者多个线程
  • 一个线程就是一个指令流,指令流中的指令按照一定的顺序交给CPU执行
  • Java中,线程是调度的基本单位,进程是资源分配的基本单位

两者对比:

  • 进程之间相互独立,而线程是存在于进程内部
  • 线程会共享进程拥有的资源
  • 进程间的通信较为复杂,同一台计算机的进程通信称为IPC(Inter-Process Communication),不同计算机之间的进程通信,则需要通过网络进行,遵循共同的协议
  • 线程间的通信较为简单,因为它们共享进程的内存
  • 线程更加轻量级,线程上下文的切换成本一般要低于进程上下文的切换

线程的作用:

  • 使多道程序更好的并发执行
  • 提高资源利用率和系统吞吐量
  • 增强操作系统的并发性能

并行与并发

  • 并行(parallel):在同一时刻,多个指令在多个CPU上同时执行。(真正同时)
  • 并发(concurrent):在同一时刻,多个指令在单个CPU上交替执行(模拟同时,微观串行,宏观并行)

同步与异步

从方法调用的角度来看:

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步

IO不占用CPU,只是我们一般拷贝文件使用的是阻塞IO,这时相当于线程虽然不用CPU,但是需要一直等待IO结束,没能充分利用线程。所以才有后面的非阻塞IO和异步IO的优化。

Java线程

线程创建

方法1:Thread

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
public static class MyThread extends Thread {
public MyThread(String name) {
super(name);
}

@Override
public void run() {
for (int i = 0; i < 100; i++) {
log.info(i + " ");
}
}
}

public static void main(String[] args) {
MyThread thread1 = new MyThread("thread1"); // 继承Thread
Thread thread2 = new Thread("thread2") {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
log.info(i + " ");
}
}
}; // 匿名内部类形式

thread1.start();
thread2.start();

// 主线程执行
for (int i = 0; i < 100; i++) {
log.info(i + "");
}
}
  • 继承Thread,重写run方法。也可以使用匿名内部类来完成

  • Thread 相关构造器:

    • public Thread():默认构造器
    • public Thread(String name):指定线程名称
  • 优点:编码简单

  • 缺点:线程类已经继承了 Thread 类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)

  1. start() 方法向 CPU 注册当前线程,并且触发 run() 方法执行

  2. 线程的启动必须调用 start() 方法,如果线程直接调用 run() 方法,相当于变成了普通类的执行,此时主线程将只有执行该线程

方法2:Runnable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
log.info(i + " ");
}
}
}

public static void main(String[] args) {
Runnable target = new MyRunnable(); // 实现Runnable接口
Thread thread1 = new Thread(target, "thread1");
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
log.info(i + " ");
}
}, "thread2"); // lambda表达式

thread1.start();
thread2.start();
}
  • 实现Runnable,实现run(没有返回值,不能抛异常),之后传递给Thread构造方法

  • Thread 相关构造器:

    • public Thread(Runnable target)
    • public Thread(Runnable target, String name)
  • 优点:

    1. 线程任务类只是实现了 Runnable 接口,可以继续继承其他类,避免了单继承的局限性
    2. 将线程和任务分开,让任务类脱离了Thread继承体系,更加灵活
    3. 更容易与线程池等高级API配合,线程池可以放入实现 Runnable 或 Callable 线程任务对象

Thread和Runnable之间的关系

  • 方法1直接重写Thread的run方法
  • 方法2中将Runnable对象赋值给Thread类内部的一个Runnable类型的target属性,然后在run方法中调用target的run方法

方法3:Callable

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
public static class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
log.info("Hello1");
return 100;
}
}

public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> myCallable = new MyCallable();
FutureTask<Integer> futureTask1 = new FutureTask<>(myCallable); // 实现Callable接口
FutureTask<Integer> futureTask2 = new FutureTask<>(() -> {
log.info("Hello2");
return 200;
}); // Lambda表达式

// 包装成Thread对象
Thread thread1 = new Thread(futureTask1, "thread1");
Thread thread2 = new Thread(futureTask2, "thread2");

thread1.start();
thread2.start();

Integer res1 = futureTask1.get(); // 调用FutureTask的get方法获取返回值
Integer res2 = futureTask2.get();
log.info("res1: " + res1);
log.info("res2: " + res2);
}
  • 实现Callable接口,重写其中的call方法。在这个call方法中可以有泛型,也可以有返回值
  • 创建一个Callable的线程任务对象,将其包装成一个FutureTask
  • FutureTask能够接收Callable类型的参数,用来处理有结果的情况
  • 将FutureTask再包装成Thread,获得线程对象
  • 同样可以用Lambda表达式简化

FutureTask补充

FutureTask继承了Runnable、Future接口,用于包装Callable对象,实现任务的提交

1
2
3
// 相关类和接口的定义
public class FutureTask<V> implements RunnableFuture<V>;
public interface RunnableFuture<V> extends Runnable, Future<V>

相关API:

public FutureTask(Callable<V> callable):在线程执行完后得到线程的执行结果

  • FutureTask 就是 Runnable 对象,因为 Thread 类只能执行 Runnable 实例的任务对象,所以把 Callable 包装成FutureTask

public FutureTask(Runnable runnable, V result):也可以将Runnable再进行包装

public V get():同步等待 task 执行完毕的结果,如果在线程中获取另一个线程执行结果,会阻塞等待,用于线程同步

  • get() 线程会阻塞等待任务执行完成
  • run() 执行完后会把结果设置到 FutureTask 的一个成员变量,get() 线程可以获取到该变量的值

线程查看

Windows:

  • 任务管理器可以查看进程和线程数,也可以用来杀死进程
  • tasklist 查看进程
  • taskkill 杀死进程

Linux:

  • ps -ef 查看所有进程
  • ps -fT -p <PID> 查看某个进程(PID)的所有线程
  • kill 杀死进程
  • top 按大写 H 切换是否显示线程
  • top -H -p <PID> 查看某个进程(PID)的所有线程

Java:

  • jps 命令查看所有 Java 进程
  • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
  • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)

线程原理

一个方法对应一个栈帧中的内容,其中包括了局部变量表、返回地址,锁记录,操作数栈等。一个线程对应一块栈内存,不同的线程使用不同的栈空间,里面有多个栈帧。即使用不同的Java虚拟机栈,每个线程启动后,虚拟机就会为其分配一块栈内存

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

线程上下文切换(Thread Context Switch):一些原因导致 CPU 不再执行当前线程,转而执行另一个线程。可能发生上下文切换的情况如下:

  • 线程的 CPU 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态(PCB 中),并恢复另一个线程的状态,包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。可以预见的是,上下文切换频繁会影响性能,因此线程数不是越多越好。

线程常用方法

API 列表

方法 说明
public void start() 启动一个新线程,Java虚拟机调用此线程的 run 方法
public void run() 线程启动后被调用的方法
public void setName(String name) 给当前线程设置名称
public void getName() 获取当前线程的名称
线程存在默认名称:子线程是 Thread-索引,主线程是 main
public static Thread currentThread() 获取当前线程对象,代码在哪个线程中执行
public static void sleep(long time) 让当前线程休眠多少毫秒再继续执行
Thread.sleep(0) : 让操作系统立刻重新进行一次 CPU 竞争
public static native void yield() 提示线程调度器让出当前线程对 CPU 的使用
public final int getPriority() 返回此线程的优先级
public final void setPriority(int priority) 更改此线程的优先级,常用 1 5 10
public void interrupt() 中断这个线程,异常处理机制
public static boolean interrupted() 判断当前线程是否被打断,清除打断标记
public boolean isInterrupted() 判断当前线程是否被打断,不清除打断标记
public final void join() 等待这个线程结束
public final void join(long millis) 等待这个线程,最多等待millis 毫秒,0 意味着永远等待
public final native boolean isAlive() 判断线程是否存活(还没有运行完毕)
public final void setDaemon(boolean on) 将此线程标记为守护线程或用户线程

start vs run

如果要使用多线程启动Thread,必须要调用Thread的start方法。如果直接调用run方法,则只是简单的方法调用,而不是多线程执行

start不能多次调用,多次调用会抛出异常java.lang.IllegalThreadStateException

sleep vs yield

sleep:

  • 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态
  • sleep() 方法的过程中,线程不会释放对象锁
  • 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException,可以使用try/catch来进行代码执行
  • 睡眠结束后的线程未必会立刻得到执行,需要抢占 CPU
  • 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性

sleep通常用来防止CPU占用100%。

在没有利用CPU来计算的时候,我们希望不要让while(true)空转占用CPU,这时候就可以使用sleep来让出CPU的使用权给其他程序

1
2
3
4
5
6
7
while(true){
try{
Thread.sleep(50);
} catch (InterruptedException e){
e.printStackTrace();
}
}
  • 在一些服务端监听的场景下会用到 while(true)

yield:

  • 调用yield会让当前线程从Running进入Runnable状态

  • 调用yield之后调度执行其他同优先级的线程。但是如果这时没有同优先级的线程,那么不能保证达到让当前线程暂停的效果

  • 具体的实现依赖于操作系统的任务调度器

  • 会放弃 CPU 资源,锁资源不会释放

线程优先级:

  • 线程优先级会提示(hint)调度器优先调度该线程。但是仅仅是一个提示,调度器可以忽略
  • 如果CPU忙,那么优先级高的线程会获得更多的时间片,如果CPU空闲,则优先级几乎没有用。
  • 最小优先级1,最大优先级10,默认优先级5。数字越大,优先级越高

join

join可以等待线程结束,底层原理是调用者轮询检查线程的alive状态

1
2
3
4
5
6
7
8
9
public final synchronized void join(long millis) throws InterruptedException {
// 调用者线程进入 thread 的 waitSet 等待, 直到当前线程运行结束
// wait(0)和wait()方法的实际意义是一样的,都表示在没有唤醒的情况下该线程一直处于等待状态
...
while (isAlive()) {
wait(0);
}
...
}
  • 可以设置超时时间
  • join 方法是一个Thread类的成员方法,并且被 synchronized 修饰

interrupt

public void interrupt():打断这个线程,触发InterruptedExecption异常

public static boolean interrupted():Thread类的静态方法,判断当前线程是否被打断,打断返回 true,清除打断标记,连续调用两次一定返回 false

public boolean isInterrupted():Thread类的成员方法,判断当前线程是否被打断,不清除打断标记

打断并不是停下线程,而是干扰。而被打断的线程会知道有人在干扰自己。线程判断打断标记,再决定自己要干什么。优雅的停止线程】

  • 线程被打断之后,会出现InterruptedException
  • 打断标记用于标记该线程被打断
  • 打断阻塞状态的线程,会清除打断标记
  • 打断正常情况下的线程,不会清除打断标记

打断阻塞状态的线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Thread thread1 = new Thread(() -> {
log.debug("sleeping....");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thread1");

thread1.start();

Thread.sleep(1000);
// 注意主线程需要先等一会,才会出现打断睡眠状态线程的情况
// 如果主线程不等的话,可能就不是打断睡眠状态的线程了
thread1.interrupt();
log.debug("打断标记: {}", thread1.isInterrupted());

输出如下:可以看到会清除打断标记

1
2
3
4
5
6
15:08:55.136 [thread1] c.ThreadTest - sleeping....
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ThreadTest.lambda$main$0(ThreadTest.java:14)
at java.lang.Thread.run(Thread.java:748)
15:08:56.140 [main] c.ThreadTest - 打断标记: false

打断正常运行的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Thread thread1 = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if (interrupted) {
log.debug("退出循环");
break;
}

}
}, "thread1");

thread1.start();

Thread.sleep(1000);
thread1.interrupt();
log.debug("打断标记: {}", thread1.isInterrupted());

输出如下:可以看到没有清除打断标记

1
2
15:18:35.108 [main] c.ThreadTest - 打断标记: true
15:18:35.108 [thread1] c.ThreadTest - 退出循环

利用interrupt,可以完成多线程设计模式-两阶段终止。具体实现可以参考后面的多线程设计模式笔记。

daemon

守护线程的生命周期与其他非守护线程相同,它是服务于用户线程的。只要其它非守护线程运行结束了,即使守护线程代码没有执行完,也会强制结束。JVM中的垃圾回收线程就是一种守护线程

设置守护线程需要在线程启动之前调用方法setDaemon(boolean on),将其中的标志位设置为true。

线程状态

从操作系统层面来看,分为初始状态、就绪状态、运行状态、阻塞状态、终止状态。

从Java API层面来看,在 API 中 java.lang.Thread.State 这个枚举中给出了六种线程状态,分为新建New、可运行Runnable、阻塞Blocked、等待Waiting、超时等待Timed Waiting、结束Terminated。

  • 操作系统层面的阻塞状态指的是因为等待某一事件而不能运行,例如等待IO。
  • Java API层面的可运行,对应操作系统层面的就绪、运行和阻塞状态
  • Java API层面的阻塞,指的是当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入 Blocked 状态;当该线程持有锁时,该线程将变成 Runnable 状态。【阻塞在锁上】

Java多线程笔记(1)-线程的基本使用
http://example.com/2022/09/08/Java多线程笔记-1-线程的基本使用/
作者
EverNorif
发布于
2022年9月8日
许可协议