按照规划,从本篇开始我们开启『并发』系列内容的总结,从本篇的线程开始,到线程池,到几种并发集合源码的分析,我们一点点来,希望你也有耐心,因为并发这块知识是你职业生涯始终绕不过的坎,任何一个项目都或多或少的要涉及一些并发的处理。
这一系列文章只能算是对并发这块基本理论知识的一个总结与介绍,想要成为并发高手,必然是需要通过大规模并发访问的线上场景应用,或许以后我有了相关经验了,再给你们做一点分享吧。
基本的进程线程概念
进程和线程算是操作系统内两个很基本、很重要的概念了,进程是操作系统中进行保护和资源分配的基本单位,操作系统分配资源以进程为基本单位。而线程是进程的组成部分,它代表了一条顺序的执行流。
系统中的进程线程模型是这样的:
进程从操作系统获得基本的内存空间,所有的线程共享着进程的内存地址空间。当然,每个线程也会拥有自己私有的内存地址范围,其他线程不能访问。
由于所有的线程共享进程的内存地址空间,所以线程间的通信就容易的多,通过共享进程级全局变量即可实现。
同时,在没有引入多线程概念之前,所谓的『并发』是发生在进程之间的,每一次的进程上下文切换都将导致系统调度算法的运行,以及各种 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,每一次上下文切换都对应着「运行现场」的保存与恢复,这也是一个相对耗时的操作。
ps:前段时间确实有点忙,拖更好多天,这里再给大家说声抱歉了,感谢你们还没有走,现在正式恢复,开启并发系列总结~
文章中的所有代码、图片、文件都云存储在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
欢迎关注微信公众号:OneJavaCoder,所有文章都将同步在公众号上。