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 |
|
继承Thread,重写run方法。也可以使用匿名内部类来完成
Thread 相关构造器:
public Thread()
:默认构造器public Thread(String name)
:指定线程名称
优点:编码简单
缺点:线程类已经继承了 Thread 类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)
start() 方法向 CPU 注册当前线程,并且触发 run() 方法执行
线程的启动必须调用 start() 方法,如果线程直接调用 run() 方法,相当于变成了普通类的执行,此时主线程将只有执行该线程
方法2:Runnable
1 |
|
实现Runnable,实现run(没有返回值,不能抛异常),之后传递给Thread构造方法
Thread 相关构造器:
public Thread(Runnable target)
public Thread(Runnable target, String name)
优点:
- 线程任务类只是实现了 Runnable 接口,可以继续继承其他类,避免了单继承的局限性
- 将线程和任务分开,让任务类脱离了Thread继承体系,更加灵活
- 更容易与线程池等高级API配合,线程池可以放入实现 Runnable 或 Callable 线程任务对象
Thread和Runnable之间的关系
- 方法1直接重写Thread的run方法
- 方法2中将Runnable对象赋值给Thread类内部的一个Runnable类型的target属性,然后在run方法中调用target的run方法
方法3:Callable
1 |
|
- 实现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 |
|
- 可以设置超时时间
- join 方法是一个Thread类的成员方法,并且被 synchronized 修饰
interrupt
public void interrupt()
:打断这个线程,触发InterruptedExecption
异常
public static boolean interrupted()
:Thread类的静态方法,判断当前线程是否被打断,打断返回
true,清除打断标记,连续调用两次一定返回 false
public boolean isInterrupted()
:Thread类的成员方法,判断当前线程是否被打断,不清除打断标记
打断并不是停下线程,而是干扰。而被打断的线程会知道有人在干扰自己。线程判断打断标记,再决定自己要干什么。优雅的停止线程】
- 线程被打断之后,会出现
InterruptedException
- 打断标记用于标记该线程被打断
- 打断阻塞状态的线程,会清除打断标记
- 打断正常情况下的线程,不会清除打断标记
打断阻塞状态的线程:
1 |
|
输出如下:可以看到会清除打断标记
1 |
|
打断正常运行的线程
1 |
|
输出如下:可以看到没有清除打断标记
1 |
|
利用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 状态。【阻塞在锁上】