线程的基本概念
线程的基本概念
从本篇开始,我们将开启“并发”系列内容的总结,从线程的基本概念开始,逐步深入到线程池、并发集合的源码分析等主题。并发知识是每个程序员职业生涯中绕不过的重要内容,无论是在面试还是实际项目开发中,都占据着举足轻重的地位。
基本的进程线程概念
进程和线程是操作系统中两个非常基本且重要的概念。进程是操作系统中进行保护和资源分配的基本单位,操作系统分配资源以进程为基本单位。而线程是进程的组成部分,它代表了一条顺序的执行流。
系统中的进程线程模型如下:
- 进程从操作系统获得基本的内存空间,所有的线程共享着进程的内存地址空间。
- 当然,每个线程也会拥有自己私有的内存地址范围,其他线程不能访问。
- 由于所有的线程共享进程的内存地址空间,所以线程间的通信就容易得多,通过共享进程级全局变量即可实现。
- 在没有引入多线程概念之前,所谓的“并发”是发生在进程之间的,每一次的进程上下文切换都将导致系统调度算法的运行,以及各种CPU上下文的信息保存,非常耗时。而线程级并发没有系统调度这一步骤,进程分配到CPU使用时间,并给其内部的各个线程使用。
- 在分时系统中,进程中的每个线程都拥有一个时间片,时间片结束时保存CPU及寄存器中的线程上下文并交出CPU,完成一次线程间切换。当然,当进程的CPU时间使用结束时,所有的线程必然被阻塞。
JAVA 对线程概念的抽象
在Java API中,使用Thread
类来抽象化描述线程,线程有几种状态:
NEW
:线程刚被创建RUNNABLE
:线程处于可执行状态BLOCKED
、WAITING
:线程被阻塞,具体区别后面会说明TERMINATED
:线程执行结束,被终止
其中,RUNNABLE
表示的是线程可执行,但不代表线程一定在获取CPU执行中,可能由于时间片使用结束而等待系统的重新调度。BLOCKED
、WAITING
都是由于线程执行过程中缺少某些条件而暂时阻塞,一旦它们等待的条件满足时,它们将回到RUNNABLE
状态重新竞争CPU。
此外,Thread
类中还有一些属性用于描述一个线程对象:
private long tid
:线程的序号private volatile char name[]
:线程的名称private int priority
:线程的优先级private boolean daemon = false
:是否是守护线程private Runnable target
:该线程需要执行的方法
其中,tid
是一个自增的字段,每创建一个新线程,这个id都会自增一。优先级取值范围,从一到十,数值越大,优先级越高,默认值为五。
Runnable
是一个接口,它抽象化了一个线程的执行流,定义如下:
public interface Runnable {
public abstract void run();
}
通过重写run
方法,你也就指明了你的线程在得到CPU之后执行指令的起点。我们一般会在构造Thread
实例的时候传入这个参数。
创建并启动一个线程
创建一个线程基本上有两种方式,一是通过传入Runnable
实现类,二是直接重写Thread
类的run
方法。我们详细看看:
1. 自定义Runnable
实现
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println("hello world");
}
}
public static void main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();
System.out.println("i am main Thread");
}
运行结果:
i am main Thread
hello world
其实Thread
这个类也是继承Runnable
接口的,并且提供了默认的run
方法实现:
@Override
public void run() {
if (target != null) {
target.run();
}
}
target
我们说过了,是一个Runnable
类型的字段,Thread
构造函数会初始化这个target
字段。所以当线程启动时,调用的run
方法就会是我们自己实现的实现类的run
方法。
所以,自然会有第二种创建方式。
2. 继承Thread
类
既然线程启动时会去调用run
方法,那么我们只要重写Thread
类的run
方法也是可以定义出我们的线程类的。
public class MyThreadT extends Thread {
@Override
public void run() {
System.out.println("hello world");
}
}
Thread thread = new MyThreadT();
thread.start();
效果是一样的。
几个常用的方法
关于线程的操作,Thread
类中也给我们提供了一些方法,有些方法还是比较常用的。
1. sleep
public static native void sleep(long millis)
这是一个本地方法,用于阻塞当前线程指定毫秒时长。
2. start
public synchronized void start()
这个方法可能很多人会疑惑,为什么我通过重写Runnable
的run
方法指定了线程的工作,但却是通过start
方法来启动线程的?
那是因为,启动一个线程不仅仅是给定一个指令开始入口即可,操作系统还需要在进程的共享内存空间中划分一部分作为线程的私有资源,创建程序计数器,栈等资源,最终才会去调用run
方法。
3. interrupt
public void interrupt()
这个方法用于中断当前线程,当然线程的不同状态应对中断的方式也是不同的,这一点我们后面再说。
4. join
public final synchronized void join(long millis)
这个方法一般在其他线程中进行调用,指明当前线程需要阻塞在当前位置,等待目标线程所有指令全部执行完毕。例如:
Thread thread = new MyThreadT();
thread.start();
thread.join();
System.out.println("i am the main thread");
正常情况下,主函数的打印语句会在MyThreadT
线程run
方法执行前执行,而join
语句则指明main
线程必须阻塞直到MyThreadT
执行结束。
多线程带来的一些问题
多线程的优点我们不说了,现在来看看多线程,也就是并发下会有哪些内存问题。
1. 竞态条件
这是一类问题,当多个线程同时访问并修改同一个对象,该对象最终的值往往不如预期。例如:
我们创建了100个线程,每个线程启动时随机sleep
一会,然后为count
加一,按照一般的顺序执行流,count
的值会是100。
但是我告诉你,无论你运行多少遍,结果都不尽相同,等于100的概率非常低。这就是并发,原因也很简单,count++
这个操作它不是一条指令可以做的。
它分为三个步骤,读取count
的值,自增一,写回变量count
中。多线程之间互相不知道彼此,都在执行这三个步骤,所以某个线程当前读到的数据值可能早已不是最新的了,结果自然不尽如期望。
但,这就是并发。
2. 内存可见性
内存可见性是指,某些情况下,线程对于一些资源变量的修改并不会立马刷新到内存中,而是暂时存放在缓存,寄存器中。
这导致的最直接的问题就是,对共享变量的修改,另一个线程看不到。
这段代码很简单,主线程和我们的ThreadTwo
共享一个全局变量flag
,后者一直监听这个变量值的变化情况,而我们在主线程中修改了这个变量的值,由于内存可见性问题,主线程中的修改并不会立马映射到内存,暂时存在缓存或寄存器中,这就导致ThreadTwo
无法知晓flag
值的变化而一直在做循环。
总结一下,进程作为系统分配资源的基本单元,而线程是进程的一部分,共享着进程中的资源,并且线程还是系统调度的最小执行流。在实时系统中,每个线程获得时间片调用CPU,多线程并发式使用CPU,每一次上下文切换都对应着“运行现场”的保存与恢复,这也是一个相对耗时的操作。