深入理解Java系列 | 线程基础
Hi 大家好,我是「 毛与帆 」,一个热爱技术的后端工程师,感谢你的关注!
在上一篇文章深入理解Java系列 | 并发编程基础中,我们主要整理了并发编程的基础知识以及三大核心问题,并了解了线程是并发编程的重要实现方式,所以本文我们主要来了解一下Java中线程的基础知识。
现在开始吧!
1. 什么是线程?
在上一篇文章深入理解Java系列 | 并发编程基础中我们已经知道:线程是进程的组成部分,一个进程至少有一个线程组成。一个进程中可以创建多个线程,每个线程拥有各自的计数器、堆栈和局部变量等属性,并且能够访问进程共享的内存变量。线程也是操作系统进行任务执行和调度的基本单位,操作系统可以控制多个线程之间交替运行,以实现并发执行的效果。
为什么我们的程序需要多个线程呢?
随着计算机处理器的发展,目前我们的计算机处理器都有多个核心;由于一个线程只能在一个处理器核心上运行,所以为了充分利用多个处理器核心的并行能力,我们希望每个处理器核心都可以运行一个线程,所以就需要程序能够支持多线程机制,否则一个程序只能在一个处理器上核心上执行,其他核心则处于空闲状态。
基于多线程机制,可以充分利用处理器资源,并提高程序的响应速度和处理能力,
是否程序的最大线程数就等于处理器核心的数量?
并不是的。首先CPU执行线程的过程,并不是一直运行某个线程,而是通过时间片轮转调度算法对不同的线程进行调度,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。
比如线程A会在CPU中只执行一个时间片的时间,然后挂起再切换到其他线程执行;等时间片再分配给线程A时,会进行状态的恢复并重新执行,这样也就完成了一次上下文切换。
由于每个线程单次执行的时间非常短,所以可以认为所有的线程是在同时执行的。基于这个机制,我们可以在程序中创建多于处理器核心的线程,这样也可以充分利用处理器资源。
2. 线程的用法
1.1 如何运行一个线程
首先,如果需要运行一个线程,我们必须先构建一个线程对象,线程类是Thread
,对于一个线程来说,还必须执行该线程需要执行的任务,这个是通过Runnable
接口来实现的,也就是一个线程是执行一个Runnable
任务。
我们可以定义一个任务类TaskRunnable
,并实现Runnable
接口,在run
方法中循环打印0~99的数字,代码如下:
public static class TaskRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println("数字:" + i);
}
}
}
然后我们可以定义线程对象thread
,并创建TaskRunnable
任务对象作为Thread构造方法的入参;然后设置线程的属性,比如name
,daemon
,priority
等;最后调用线程的start()
方法,完成线程的启动。代码如下:
public static void main(String[] args) {
Thread thread = new Thread(new TaskRunnable());
thread.setName("task-thread");
thread.setDaemon(false);
thread.setPriority(5);
thread.start();
}
线程启动后,相当于当前线程告诉虚拟机已经创建了一个线程,可以对该线程进行调度执行。到此我们就完成了一个线程的创建和启动。线程开始执行后,会调用TaskRunnable
类中run
方法的代码,执行打印字符串的任务,循环完成后线程执行完毕并销毁。
1.2 线程的属性
在上面的例子中,我们可以看到线程的主要属性有如下几个:
Runnable target
:这个是线程所要执行的任务对象,通过构造方法传入;在runnable对象的run
方法中定义了对象所要执行的任务详情。String name
:这个是线程的名称,可以自定义线程名称,如果不设置会生成默认的名称;boolean daemon
:通过该字段来标识线程是否为守护线程,需要在调用thread.start()
方法前设置daemon,否则会抛除异常。
守护线程是指为其他线程服务的线程,在Java虚拟机中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。对守护线程来说,它可以自动结束自己的生命周期,而其他非守护线程不具备这个功能。
守护线程的一个典型用法就是JVM中的垃圾回收线程,当JVM需要结束时,垃圾回收线程可以自动结束。所以通常来说,守护线程的主要功能是执行一些后台任务,且在JVM退出时可以自动关闭。另外,对于守护线程来说,不要持有任何资源的连接,否则在JVM退出时会无法正常释放连接而出现异常。
最后需要注意的是,一个守护线程中产生的线程默认是守护线程,在用户线程中产生的线程默认是用户线程。
int priority
:线程的优先级,优先级范围为1~10,在构建线程的时候可以通过setPriority(int)
方法来修改优先级,默认优先级为5,优先级高的线程分配时间片的数量要多余优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。
但是线程优先级不作为程序线程正确调度的依赖,因为不同的操作系统在线程规划上设计不同,某些操作系统不会完全按照优先级的设定进行调度,甚至会忽略优先级的设定。
1.3 如何查看程序的线程?
如果我们想要看看自己程序当前运行了哪些线程以及线程的执行状态,我们可以使用jdk
自带的命令行工具jstack
来查看程序执行的线程。
使用jstack -l <进程ID>
来查看这个进程的的所有线程状态,其中包括线程名称,线程编号,是否守护线程,线程优先级,线程状态,线程执行的堆栈,线程持有的锁等信息,通过这些信息我们可以分析一个进程的线程状态,并可以分析是否有死锁产生。
如下图所示则为一个程序的部分线程信息。
2. 线程的状态
上面我们了解了线程了定义以及基本使用方法,下面我们来介绍下线程的状态,以及各个状态之间的转换。
首先,Java中线程共有六大状态,分别为:
状态 | 说明 |
---|---|
NEW |
初始状态,线程被创建,但未调用start() 方法 |
RUNNABLE |
运行状态,Java线程将操作系统中的就绪和运行两种状态都称为运行态 |
BLOCKED |
阻塞状态,表示线程阻塞于锁 |
WAITING |
等待状态,线程进入等待,需要等待其他线程作出一些动作(唤醒或者中断) |
TIME_WAITING |
超时等待状态,不同于WAITINIG,可以在指定时间内超时自行返回 |
TERMINATED |
终止状态,表示当前线程已经执行完毕 |
下图为各个线程生命周期的各状态的转换图:
首先,线程创建之后会进入初始状态,调用线程的start方法后则进入运行状态,然后JVM虚拟机会对线程进行规划,这里的运行状态包括了就绪和运行中两种状态,其中运行中表示线程被分配了时间片可以正常执行;当线程执行需要等待锁的获取时,则会进入到阻塞状态,直到获取锁成功会再进入运行状态;在线程运行状态时,可以主动休眠进入等待状态,在等待状态时该线程只能被其他线程唤醒才可以恢复到运行状态;或者可以设置等待时间进入超时等待状态,这时线程可以被其他线程唤醒,或者到达等待时间后自动被唤醒;当线程运行结束后,则进入终止状态,线程整个生命周期结束。
3. 线程间通信
在上一节的线程状态中,我们看到会有一些方法可以使线程的状态进行切换,比如Object.wait()
方法、Object.join()
方法等,线程之间进行通信就是基于这些方法来实现的,这是Java提供的等待/通知机制,基于这一机制,可以保证线程通信的及时性,以及降低等待过程的CPU开销。
本节我们主要介绍下等待/通知机制的具体用法。下面是等待/通知的主要方法:
wait()
:调用该方法的线程将进入WAITINIG
状态,只有等待其他线程的通知或者被中断才可以返回;当调用wait
方法后,会释放对象的锁;wait(long)
:超时等待一段时间,调用该方法后会进入TIME_WAITING
状态,可以被其他线程唤醒,也可以在一定时间之后自动返回,超时时间的单位是毫秒;同样的,调用wait(long)
方法后会释放对象的锁;wait(long, int)
:与wait(long)
方法功能相同,支持更细粒度的超时等待,最低可以支持纳秒;notify()
:通知一个对象上等待的线程,使其从wait()
方法返回,返回的前提是需要重新获取对象的锁;notifyAll()
:与notify
方法功能相同,通知所有等待在对象上的线程;thread.join()
:等待thread线程终止后,当前线程才从join方法返回;thread.join(long)
和thread.join(long, int)
:与join()
方法类似,支持设置等待线程thread执行的超时时间,当在超时时间内未执行完成也会从join方法返回。
下面的示例展示了如何通过wait和notify进行线程间通信(代码参考自《并发编程的艺术》一书第4.3.2节):
public class ThreadTest2 {
static Object lock = new Object();
static boolean flag = true;
public static void main(String[] args) {
Thread waitThread = new Thread(new Wait(), "WaitThread");
waitThread.start();
sleep(1);
Thread notifyThread = new Thread(new Notify(), "NotifyThread");
notifyThread.start();
}
static class Wait implements Runnable {
@Override
public void run() {
synchronized (lock) {
while (flag) {
// 条件不满足,继续wait,同时释放lock的锁
try {
System.out.println(Thread.currentThread() + " flag is true, wait @ " + System.currentTimeMillis());
lock.wait();
} catch (InterruptedException e) {
}
}
// 条件满足,完成工作
System.out.println(Thread.currentThread() + " flag is false, running @ " + System.currentTimeMillis());
}
}
}
static class Notify implements Runnable {
@Override
public void run() {
synchronized (lock) {
System.out.println(Thread.currentThread() + " hold lock, notify @ " + System.currentTimeMillis());
lock.notifyAll();
flag = false;
sleep(5);
}
synchronized (lock) {
System.out.println(Thread.currentThread() + " hold lock again, sleep @ " + System.currentTimeMillis());
sleep(5);
}
}
}
static void sleep(long sec) {
try {
TimeUnit.SECONDS.sleep(sec);
} catch (InterruptedException e) {
}
}
}
运行输出的结果如下:
Thread[WaitThread,5,main] flag is true, wait @ 1630831336411
Thread[NotifyThread,5,main] hold lock, notify @ 1630831337415
Thread[NotifyThread,5,main] hold lock again, sleep @ 1630831342415
Thread[WaitThread,5,main] flag is false, running @ 1630831347416
在上面的代码中,首先WaitThread
获取锁,然后判断flag
为true,则调用wait
方法进入等待,并释放锁;然后NotifyThread
线程执行,并获取到锁,然后首先唤醒了等待的线程WaitThread
,然后再修改flag
标志为false,并sleep休眠5秒钟;由于在sleep时,线程并未释放锁,所以此时WaitThread
虽然被唤醒但无法获取锁,所以不能从wait
方法返回;等NotifyThread
执行完成后会释放锁,此时WaitThread
获取锁,并最终执行完成。
通过上面的例子,我们大概了解了两个线程如何进行交互通信,在使用wait
、notify
方法时,需要注意如下几个方面:
wait
、notify
和notifyAll
方法调用时,需要先对调用的对象进行加锁;- 调用
wait()
方法后,线程状态由RUNNING
变为WAITING
,并将该线程放置到对象的等待队列中; notify
或者notifyAll
方法调用后,等待线程不会立刻从wait()
返回,需要等待调用notify
的线程释放锁之后,等待的线程才有机会从wait
返回;notify
方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll
方法是将所有的等待队列中的线程移到同步队列;被移动的线程状态由WAITING
变为BLOCKED
;- 从
wait
方法返回的前提是需要重新获得调用对象的锁。
4. 线程间通信的主要模式
从上面的WaitThread
和NotifyThread
的示例中可以了解到,其中一个线程(WaitThread
)充当任务处理者,也就是消费者,另一个线程(NotifyThread
)充当任务触发者,也就是生产者,两者之间可以基于等待/通知机制
进行通信,来保证任务生产后立即有消费者处理,无任务时所有消费者等待。
对于消费者来说,主要实现流程为;
- 获取对象的锁
- 如果条件不满足,则调用对象的
wait
方法进行等待;被唤醒后需要再次检查是否满足条件 - 条件满足则执行对应的处理逻辑
伪代码实现为:
synchronized(object) {
while(checkState()) { // 检查条件
object.wait(); // 加入等待队列
}
doSth(); // 执行业务逻辑
}
对于生产者来说,主要实现流程为:
- 获取对象的锁
- 改变条件:比如修改flag标识、往任务队列中插入任务等
- 通知所有等待在对象上的线程
伪代码实现为:
synchronized(object) {
changeState(); // 改变条件
object.notifyAll(); // 通知所有的等待线程
}
到这,我们回想一下前面关于BlockingQueue
的入队和出队逻辑时,就是基于上面这种模式实现的。
5. 总结
本文我们是Java并发编程系列的第2篇,主要介绍了Java中线程基础,包括线程的定义、基本用法、线程状态以及线程间通信的方法。后面我们会继续介绍Java中并发编程的知识,感谢各位小伙伴持续关注。
我是「 毛与帆 」,如果本文对你有帮助,欢迎向各位小伙伴点赞、评论和关注,感谢各位老铁,我们下期见
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!