前言
作为一名Java程序员,对线程的掌握,是必不可少的,那么,线程是如何发起的?又是如何执行的?通过这篇文章,来进行详细阐述。
1. 多线程概念
首先,简单来说一下概念,有很多小伙伴还分不清【进程和线程】【并行与并发】的区别。
1.1 进程 & 线程 & 管程(monitor 监视器)
我们平时使用Java语言写出来的程序都是以 .java 结尾的文件组成的,这些文件是存储硬盘上的静态文件,通过Java虚拟机编译成和平台无关的字节码,也就是变成了.class 结尾的文件。当我们通过main()方法运行这个程序后,这些.class文件会被加载到内存中等待被执行,接着 CPU开始执行这些程序的每一行指令,然后基于这些指令产生相应的结果,我们把这个运行中的程序称为进程。一台正在运行的计算机,可以同时启动多个进程,干不同的事,互不影响。
假设在这个程序中,有一段逻辑是从磁盘上解析一个文件进行持久化操作,当CPU执行到从磁盘读取数据这个指令时,由于磁盘的IO速度相比 CPU的运算速度来说要慢很多,所以CPU 在等待磁盘I/O 返回的过程中一直处于闲置状态。CPU作为计算机的核心资源,被闲置显然是不合理的。
分时系统的出现解决了这个问题,分时系统是计算机对资源的一种共享方式,它利用多道程序和CPU时间片调度的方式使得多个用户可以同时使用一台计算机。
什么是多道程序呢?由于单个程序无法让CPU和I/O设备始终处于忙碌状态,所以操作系统允许同时加载多个程序到内存,也就是说可以同时启动多个进程,系统给这些进程分配独立的地址空间,以保证每个进程的地址不会相互干扰。
当CPU在执行某个进程中的指令出现I/O或其他阻塞时,为了提高CPU的利用率,操作系统会采用CPU调度算法把闲置的CPU时间片分配给第二个进程,当前进程运行结束后又会把CPU时间片分配给之前阻塞的进程来执行,从而保证CPU一直处于忙碌状态,整个调度过程如下图所示。
在多核CPU架构中运行多个进程,从而实现多个进程的并行执行,一切看起来很美好,那么为什么又要有线程这种设计呢?
原因是进程本身是一个比较重的设计。首先,每个进程需要有自己的地址空间,并且每次涉及进程切换时,需要保存当前CPU指令上下文,使得资源的消耗及性能的损耗比较大。其次,对一个独立的进程来说,该进程内同一时刻只能做一件事,如果在这个进程中想实现同时执行多个任务并行执行,很显然是做不到的。最重要的是,当进程中某个代码出现阻塞时,会导致整个进程挂起,即便有些逻辑不依赖于该阻塞的任务也会无法执行,为了解决这个问题,人们把进程的资源分配和进程中任务调度的执行分开处理,因此形成了线程的概念。
引入线程的设计后,CPU的最小调度和分配单元就变成了线程,在一个进程中可以创建多个线程。因此,当出现上面描述的情况,即一个进程中存在多个任务时,我们可以针对每个任务分配独立的线程来执行,当其中一个任务因为阻塞无法执行时,其他任务不会受到影响。
除此之外,线程的好处还有很多。
-
由于线程不需要分配操作系统资源,所以它相对进程来说是比较轻的。
-
线程的切换只需要保存少量的寄存器内容,相比进程来说,资源耗费更小,因此效率更高。
-
一个进程中可以创建多个线程,同一个进程中多个线程的CPU时间片的切换并不会导致
进程切换,而且还能实现单进程中多个任务的并行执行。
总结一下,进程和线程的主要区别是:
操作系统的资源管理方式不同,进程有独立的地址空间,当一个进程崩溃后,不会影响其他的进程。而线程是一个进程中不同的执行路径,它有自己的堆栈和 局部变量,但是没有单独的地址空间。
管程:Monitor(监视器),也就是我们平时所说的锁。这个需要从 JVM 层面进行说明 ,也就是 JVM 对“对象头(Mark Word)”中锁的监控,此篇文章不做扩展,后面在 synchronized 锁的内容篇幅会进行详细说明。
① Monitor其实是一种同步机制,它的义务是保证(在同一时间)只有一个线程可以访问被保护的数据和代码;
② JVM中同步时基于进入和退出的监视器对象(Monitor,管程);每个对象实例都有一个Monitor对象;
③ Monitor对象和JVM对象一起销毁,底层由C来实现;
1.2 并行 & 并发
从操作系统层面来说,并发和并行可以表示 CPU 执行多个任务的方式。
- 并发:并发是指两个或者多个任务在同一时间间隔内发生,我们生活中常见的案例,比如 12306 抢票、电商网站的秒杀等;
- 并行:当有多个 CPU 核心是,在同一个时刻可以同时运行多个任务,这种方式叫做并行。比如打开某个 Word 文档的时候,同时也打开另外一个 Excel 文档;
总的来说,并行和并发的区别就是,多个人做多件事和一个人做多件事的区别。
2. Java 中创建线程的方式
2.1 继承 Thread类
public class ThreadDemo{
public static void main(String[] args) {
//4.创建Thread类的子类对象
MyThread myThread=new MyThread();
//5.调用start()方法开启线程
//[ 会自动调用run方法这是JVM做的事情,源码看不到 ]
myThread.start();
for (int i = 0; i < 100; i++) {
System.out.println("我是主线程"+i);
}
}
}
class MyThread extends Thread{
//2.重写run方法
public void run(){
//3.将要执行的代码写在run方法中
for(int i=0;i<100;i++){
System.out.println("我是线程"+i);
}
}
}
2.2 实现 Runnable 接口
public class RunnableDemo {
public static void main(String[] args) {
//4.创建Runnable的子类对象
MyRunnale mr=new MyRunnale();
//5.将子类对象当做参数传递给Thread的构造函数,并开启线程
//MyRunnale taget=mr; 多态
new Thread(mr).start();
for (int i = 0; i < 1000; i++) {
System.out.println("我是主线程"+i);
}
}
}
//1.定义一个类实现Runnable
class MyRunnale implements Runnable{
//2.重写run方法
@Override
public void run() {
//3.将要执行的代码写在run方法中
for (int i = 0; i < 1000; i++) {
System.out.println("我是线程"+i);
}
}
}
2.3 实现 Callable 接口(带返回值)
/*
创建线程的方式三: 实现callable接口 ---JDK 5.0 新增
1.创建一个实现Callable接口的实现类
2.实现call方法,将此线程需要执行的操作声明在call()中
3.创建callable接口实现类的对象
4.将此callable的对象作为参数传入到FutureTask构造器中,创建FutureTask的对象
5.将FutureTask对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用star
6.获取callable接口中call方法的返回值
* */
public class ThreadNew {
public static void main(String[] args) {
//3.创建callable接口实现类的对象
NumThead m=new NumThead();
//4.将此callable的对象作为参数传入到FutureTask构造器中,创建FutureTask的对象
FutureTask futureTask = new FutureTask(m);
//5.将FutureTask对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()方法
//FutureTask类继承了Runnable接口
//new Runnable = futrueTask;
new Thread(futureTask).start();
//6.获取callable接口中call方法的返回值
try {
//get()方法返回值即为FutureTask构造器参数callable实现类重写的call方法的返回值
Object sum = futureTask.get();
System.out.println("总和是:"+sum);
} catch (Exception e) {
e.printStackTrace();
}
}
}
//1.创建一个实现Callable接口的实现类
class NumThead implements Callable{
// class NumThead implements Callable<Integer>{
//2.实现call方法,将此线程需要执行的操作声明在call()中
@Override
public Object call() throws Exception {
//public Integer call() throws Exception {
int sum=0;
for(int i=1;i<=100;i++){
System.out.println(i);
sum+=i;
}
return sum;
}
}
2.4 通过线程池创建
后面会针对线程池的设计进行单独说明。
3. 线程启动的原理分析
通过上面我们得知,在 Java中,创建线程的方式有四种,创建完之后,最后都是通过 start()方法来进行线程的启动,那它是如何启动的?我们通过查看源码来分析一下。
先从我们写的代码来看:
new Thread(() -> System.out.println("===== 线程启动 ====="),"t1").start();
可以看到,在 Java 中,跟到最后,发现是调用了底层的 native 修饰的 start0() 方法。
navite()方法是一个本地方法,它是非 Java语言实现的一个接口,使用 C/C++语言在其他文件中定义实现。简单来说,native()方法就是在 Java 中声明的可以调用非 Java 语言的方法。
既然 Thread.java 文件中找不到实现,那么肯定是在 JVM 层面中做了一些处理,于是在openjdk的源码中,我们找到了与其对应的Thread.c 文件,惊奇的发现了下面的内容:
附上 openjdk 中 Thread.c 源码的地址:jdk8/jdk8/jdk: 00cd9dc3c2b5 src/share/native/java/lang/Thread.c
继续分析,需要下载JVM 源码,下载地址:https://hg.openjdk.java.net/jdk8/jdk8/hotspot/archive/tip.zip
之后,找到对应的 jvm.cpp、thread.cpp 两个文件,具体路径:
openjdk8\hotspot\src\share\vm\prims\jvm.cpp
openjdk8\hotspot\src\share\vm\runtime\thread.cpp
简单说明:.cpp 文件是 源代码;.hpp 文件是头文件,类似于 Java 中的 import
打开 jvm.cpp文件,搜索在 Thread.c 文件中对应的方法 JVM_StartThread
可以看到,JVM 调用了 Thread::start(native_thread) 方法启动,将一个本地线程传入。接着打开 Thread.cpp 文件,搜索 Thread::start,找到了如下内容:
最终是通过调用了操作系统来完成了线程的启动任务。
至此,Java 中线程的启动过程分析完毕,也清楚的知道了,其实是由JVM 负责调度工作,告知操作系统需要启动线程。