引言
什么是线程
线程是一个执行流,一个进程由一个或多个线程构成,简单来说:线程是进程的子集,或者可以将线程视为轻量级进程。它是 CPU 调度执行的基本单位。
- 从 OS (操作系统) 的角度看,线程是调度的基本单位。
- 从应用开发者角度看,线程是一个分担任务的角色。
一、为什么引入线程
引入进程的目的,就是为了能够 " 并发编程 ",虽然多进程已经能够解决并发的问题了,但是我们认为还不够理想。
串行:单车道运输
并行:多车道运输
创建进程 / 销毁进程 / 调度进程,开销有较大。
因为进程是系统资源分配的基本单位,所以创建进程,需要分配资源,销毁进程,就需要释放资源。因此,如果频繁创建销毁,这样所带来的开销就比较大了。
于是就有了 " 线程 "(Thread) 这一概念,而线程在有些系统上也叫做 “ 轻量级进程 "。
为什么呢?
因为创建线程比创建进程更高效,销毁线程比销毁进程更高效,调度线程比调度进程更高效。
创建线程,并没有去申请资源,而销毁线程,也不需要释放资源。让线程产生在进程内部,那么线程就能够共用之前的资源。
我们必须明确:进程和线程之间是包含关系,一个进程可以包含一个线程或者多个线程。当我们先把进程创建出来之后,这时候就相当于资源都分配好了,接着,我们再在这个进程里面,创建线程。这样一来,线程就可以和之前的进程共用一样的资源了。
二、进程和线程之间的区别和联系( 经典面试题 )
① 一个进程至少包含一个线程,线程不能独立于进程而存在。也就是说,进程是包含线程的,一个进程里可以有一个线程,也可以有多个线程。
② 进程是操作系统资源分配的基本单位,线程是操作系统调度执行的基本单位。
③ 每个进程都有独立的内存空间( 虚拟地址空间 ),同一个进程的多个线程之间,共用这个虚拟地址空间。也就是说,多个进程间不能共享资源,而线程可以共享当前的进程资源文件。
④ 从第③点来看,虽然线程更加轻量,但并不是所有场景都适用的,当我们需要一个项目执行起来稳定,并做到各个用户使用稳定,就需要用到进程。
举例说明: 如下图所示,我们可以发现,进程可以看作是计算机正在运行的软件,CPU 和内存为这些进程提高硬件资源,而线程就是某个软件正在执行的多个任务。例如,百度网盘正在执行的有三个任务,而这些任务下载的时候都共享着百度网盘这个软件的所有资源。如果其中一个任务出现了问题,就可能导致整百度网盘停止运行了,但即使百度网盘停止运行了,也不会影响 迅雷和 QQ 音乐,因为 CPU 好好的在那呢。所以说,软件与软件之间没有影响,但一个软件的内部可能受一部分的线程所影响。即进程之间更加稳定,线程虽然更轻量、开销小,但相对不稳定。
三、图解进程与线程之间的区别
举个例子,辅导员现在让班长做一份表格,统计班级100 个同学的成绩,而这都要让班长一个人做,班长肯定不乐意了,所以班长想了一个办法,多叫几个人帮他统计,这样效率就能提高很多。
1. 多进程
多进程如下图所示:
班长叫来了一个同学,让他在房间B 中统计表格,而班长在原来的房间A 统计表格,他们两人之间互不干扰,这体现了进程的隔离性。
另外,两个房间,两张桌子,说明每次创建进程,都需要给其分配一些资源。
2. 多线程
多线程如下图所示:
班长起初叫来了同学A,之后为了提高统计表格的效率,又叫来了同学B…他们都在一间屋子进行统计表格,他们互相之间都能够看到彼此,这体现了:一个进程的多个线程之间,共用了一个虚拟地址空间。
另外,多了几个同学,多了几张桌子,并没有创建房间,这体现了:创建线程的成本比创建进程的成本更低。
3. 注意多线程的使用
① 这里我们需要注意创建线程时的数目,刚开始,我们为了提高效率,多叫几个同学来统计表格,这当然没有问题,当同学非常多的时候,是不是整个屋子就装不下那么多人了?也就是说,线程的数目不是越多越好,如果线程数目多到一定程度,线程之间就会更频繁地进行调度,而这时候,调度所实现的开销就比较大了 !!! 于是,执行速度反而会变慢。
② 这么多同学在一个房间中统计表格,如果事先没有约定好谁负责哪一部分的内容,是不是同学之间容易重复地统计了某项信息?就比如说:同学A 和同学B 同时统计了同一个同学的成绩,之后又互相修改了,这就乱了套了。这种冲突体现了:线程不安全。【 多线程编程的重点问题 】
③ 如果其中一个同学在使用电脑的时候,不小心脚踩到了拖线板的开关,这就造成了整个房间的电脑都熄屏了,那么之前所有统计的同学成绩都因此而作废,这是不是也带来了一个问题呢?而这就这体现了:一个进程里面,如果某个线程抛出了异常,并没有合理 catch 到的话,可能导致整个进程都异常退出 !!!
四、站在系统内核的角度,再来看待进程和线程
在 Linux 系统中,线程同样是使用 PCB 来描述的。从操作系统内核的角度来看,它不分进程还是线程,两者都用 PCB 来代替。
当创建一个进程的时候,就是创建了一个 PCB 出来了,同时这个 PCB,也可以视为是当前进程中已经包含了一个线程了。( 一个进程至少有一个线程 )
在下图中,进程1 对应了一个 PCB,在这个进程1 中创建了一个线程,也就是再加了一个 PCB。
五、线程与代码之间有什么联系?
我们可以认为,一个线程就是代码中的一个执行流。
执行流:按照一定的顺序,来执行一组指令。
六、使用 Java 来操作线程
理解 Java 线程的三个概念
主线程:当一个程序启动时,一个线程立刻运行,该线程通常叫做程序的主线程。在 Java 中,主线程就是执行 main 方法的线程。
单线程程序:一个 Java 程序中只有一个线程,执行从 main 方法开始,从上到下依次执行。
多线程程序:不仅仅有执行 main 方法的线程,也有其他线程。
1. 程序清单1
// Thread 是Java 标准库中描述的一个关于线程的类 // 常用的方法就是自己定义一个类继承 Thread // 重写 Thread 中的 run 方法,而run 方法就表示线程需要执行的具体任务 class MyThread extends Thread{ @Override public void run() { System.out.println("hello thread !"); } } public class ThreadDemo1 { public static void main(String[] args) { Thread thread = new MyThread(); //start 方法会在操作系统中真正地创建一个线程出来(内核中创建一个 PCB,加入到双向链表中) //这个新的线程,就会执行 run 方法中的代码 thread.start(); //方式一 //thread.run(); //方式二 } }
输出结果:
start 和 run 方法之间的区别
在程序清单1中,start 方法和 run 方法有什么本质上的区别呢?
① 使用 start 方法,真正意义上创建了一个新的线程出来,能够使得线程之间并发执行。此时,执行 main 方法的作为主线程,创建出来新线程的为子线程。
② 使用 run 方法,并没有创建一个新的线程出来,此时依旧是单线程的状态。
结论:使用 start 方法,真正意义上创建了一个新的线程出来,能够使得线程之间并发执行。使用 run 方法,并没有创建一个新的线程出来。
举个例子,大学辅导员让班长统计表格,班长觉得任务量太重,于是就叫来了副班长,他们两个一起干活,这就对应到 start 方法;而 run 方法就表示只是班长一个人在干活。
图解分析:
2. 程序清单2
class MyThread2 extends Thread{ @Override public void run() { while (true){ System.out.println("hello thread !"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } public class ThreadDemo2 { public static void main(String[] args) { Thread thread = new MyThread2(); thread.start(); while (true){ System.out.println("hello main !"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }
输出结果:
下面的结果是死循环,我只截图了一部分,可以发现 main 方法中的代码和重写的 run 方法中 的代码之间,交替运行,即给我们呈现出来【并发式运行】的逻辑。
3. 程序清单3
在下面的程序清单中,我们把程序清单2的 start方法改变成 run方法,看看其对应的效果。
class MyThread2 extends Thread{ @Override public void run() { while (true){ System.out.println("hello thread !"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } } public class ThreadDemo3 { public static void main(String[] args) { Thread thread = new MyThread2(); thread.run(); //方式二 while (true){ System.out.println("hello main !"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }
输出结果:
下面的结果是死循环,我只截图了一部分,可以发现,我们只调用了子类中重写的 run 方法。
这很好理解,run 方法一直在死循环,这就是按照顺序执行的,所以程序并没有走到 main方法后面的代码。
至此,我们通过程序清单2 和 程序清单3,就能够更好地理解 start 方法和 run 方法了。
七、多线程的优势
多线程的优势在于:同一情况下,能够提升程序的运行速度
串行:一个线程执行了 20亿 次循环
并行:两个线程各自执行了10亿 次循环
程序清单4:
public class ThreadDemo4 { private static final long count = 10_0000_0000;// 10亿 /** * 串行来针对 x 和 y 自增 */ public static void serial() { //System.currentTimeMillis 这个方法可以获取到当前系统的时间戳 long begin = System.currentTimeMillis(); int x = 0; for (long i = 0; i < count; i++) { x++; } int y = 0; for (long i = 0; i < count; i++) { y++; } long end = System.currentTimeMillis(); System.out.println("串行时间(毫秒): " + (end - begin) ); } /** * 并行来针对 x 和 y 自增 */ public static void concurrency() throws InterruptedException { long begin = System.currentTimeMillis(); Thread t1 = new Thread() { @Override public void run() { int x = 0; for (long i = 0; i < count; i++) { x++; } } }; t1.start(); Thread t2 = new Thread() { @Override public void run() { int y = 0; for (long i = 0; i < count; i++) { y++; } } }; t2.start(); //保证 t1 和 t2 都执行完了之后,再结束计时 //join 方法就是等待对应的线程结束 //t1.join(); 就表示 t1 对应的 run 方法没执行完之前,会阻塞等待 //t2.join(); 就表示 t2 对应的 run 方法没执行完之前,会阻塞等待 t1.join(); t2.join(); long end = System.currentTimeMillis(); System.out.println("并行时间(毫秒): " + (end - begin) ); } public static void main(String[] args) throws InterruptedException { serial(); concurrency(); } }
输出结果:
下面是我通过 IDEA 编译器测试输出的三次结果,我们发现,并行时间总是比串行时间快一倍左右,那么,并行总是比串行的速度快一倍吗?
不一定,因为对于线程调度来说,自身也是有开销的,而这两组数据调度的时间完全不确定,所以我们无法得出时间上的数据,但一般来说,运行某个程序时,并行确实比串行速度快。
八、创建线程的几种写法
方法一:通过自定义类继承 Thread 类
class MyThread extends Thread{ @Override public void run() { System.out.println("hello thread !"); } } public class ThreadDemo1 { public static void main(String[] args) { Thread thread = new MyThread(); thread.start(); } }
变形1:匿名内部类创建 Thread 子类的对象
这个方法和方法一没有区别,只是简化了一下而已,不过这样写方便不少。
下面的写法相当于创建了一个匿名的类,这个类继承了 Thread 类
【 new Thread 】这个代码实际上不是 new 了 Thread 类本身,而是 new 了一个 Thread类 的子类的实例。这里第一次看确实有点混人,它给你一种创建了父类的错觉,不理解的小伙伴可以去我的博客看看 《Java 中的内部类》,其中,有提到内部类。
public class Test1 { public static void main(String[] args) { Thread thread = new Thread() { @Override public void run() { System.out.println("hello thread !"); } }; thread.start(); } }
方法二:通过自定义类实现 Runnable 接口
class MyRunnable implements Runnable{ @Override public void run() { System.out.println("hello thread !"); } } public class ThreadDemo2 { public static void main(String[] args) { Thread thread = new Thread(new MyRunnable()); thread.start(); } }
变形1:匿名内部类创建 Runnable 子类对象
同样地,在这里我们创建了一个 Runnable类 的子类,之后将这个子类再作为 Thread 构造方法的参数,这和方法一中的匿名内部类有一点点区别,不过思想是一样的。
public class ThreadDemo3 { public static void main(String[] args) { Thread thread = new Thread( new Runnable() { @Override public void run() { System.out.println("hello thread !"); } } ); thread.start(); } }
方法三:lambda 表达式
public class Test2 { public static void main(String[] args) { Thread thread = new Thread(() -> System.out.println("hello thread !")); thread.start(); } }
十、Thread 的常见构造方法
十一、Thread 的几个常见属性
程序清单5:
public class ThreadDemo5 { public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { while (true) { //打印当前线程的名字 //Thread.currentThread 的这个方法,可以获取到当前线程的实例 //哪个线程调用这个方法,就能获取到对应的实例 System.out.println(Thread.currentThread().getName()); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }, "我的线程名字叫十七"); //thread.start(); System.out.println("ID: " + thread.getId()); System.out.println("name: " + thread.getName()); System.out.println("state: " + thread.getState()); System.out.println("priority: " + thread.getPriority()); System.out.println("isDaemon: " + thread.isDaemon()); System.out.println("isAlive: " + thread.isAlive()); System.out.println("isInterrupted: " + thread.isInterrupted()); } }
输出结果:
我们可以看到在使用 start 方法的前后,线程的状态和存活这两个属性发生了改变。
十二、再谈线程与进程
1. 线程之间能够共享的资源
线程之间能够共享的资源主要分为两方面:
① 内存
线程1 和线程 2 都可以共享同一份内存( 同一个变量 )
② 文件
线程1 打开的文件,线程 2 也能去使用。
比方说:我们创建一个 .java 文件,处于同一个进程的两个线程就能够使用里面的各个变量、函数…而创建两个 .java 文件,当某个文件执行的时候,另一个文件不受干扰,这就相当于进程之间互不干扰。
2. 多进程的优势
前面对比了线程与进程,说的最多的就是线程更加轻量,使用多线程能够达到更高效的并发编程。
问:难道进程相比于线程,是一无是处的吗?
答:其实并不是,进程更加具有 " 独立性 "。
例如:在一个操作系统上,同一时刻运行着很多个进程,如果某个进程挂了,那么它并不会影响到其他进程,因为每个进程有各自的地址空间。相比之下,由于多个线程之间共用着同一个进程的地址空间,若某个线程挂了,就很可能会把整个进程也带走。
这很好理解,比方说,我们打开电脑的 QQ 和 浏览器,如果在我们使用浏览器的时候,一个页面卡住了,通常情况下,电脑就会问你:" A. 是否继续等待? B. 是否关闭当前程序? " 这时候,如果你点击关闭程序,你之前在浏览器上访问的其他页面也会随之关闭,然而,我们后台的 QQ 并不会受到影响。