JAVA多线程学习笔记—— 线程的简介与入门
理论知识
为什么会出现多线程
多线程出现的主要原因
- 科学技术的发展。计算机从早期的巨型机到微型机,从早期的单核CPU到现在的多核CPU,从单核CPU的伪多线程到现在多核CPU的真正意义上的多线程,以及取决于决定性因素的CPU处理能力与程序运行的高度不匹配都是促使多线程出现的原因之一,
- 贪婪之心。人是串行化的动物(神童,天才,超能力者除外),一次只能做一件事,当然,只要给与足够的时间,同时交给你的任务总是能够采用
串行
化的方式执行完,不知道这是不是996的由来。人虽然不能,但是计算机可以,计算机并行
执行的能力相较于人类同时处理多种事情导致的上下文切换
,更能保证正确性。 - 充分利用资源。我们都知道CPU的运行速度是很快的,计算机在执行非CPU型任务,比如:读取文件,写数据到数据库,此时CPU是空闲的,但是却会因为程序是串行化(非多线程程序)的执行,而导致CPU得不到很好的利用,其实,此时CPU本可以做其他事情的,如继续读取其他的指令进行执行。这样也能充分的利用CPU资源,高效的完成任务。
串行与并行
在上面我们提到了串行
,并行
的概,那么什么是串行
,什么是并行
。在程序的世界里,串行和并行主要是指程序任务的执行方式。
串行
多个任务时,各个任务按顺序执行,完成一个之后才能执行下一个。
并行
多个任务时,多个任务可以同时执行
举个简单的例子用来理解上面的概念:
每个人或多或少都有过去窗口打饭的经历,那么当只有一个窗口的时候,如果你想吃饭怎么办呢,只好排队对吧,因为窗口只有一个,那么当你前面的的人在没有打饭完成之前,你都只好等着,等你前面的人依次排队把饭打完才能轮到你打饭对吧,这就是串行,排队式,一个接一个的执行,只有前一个执行完成才能轮到你的执行。打饭打了很多天了,结果每天都会因为有很多人因为等的时间比出去找个新地方吃饭的时间要长而选择离开,而你是最能忍的,你觉得还可以接受,主要是饭菜可口,但是食堂老板不接受啊,这跑的可都是钱呀,他进行调研之后仔细一算,决定再开一个窗口,来提高食堂供应饭菜的速度,这样同一时间内就能提供两份饭菜了,从前丢失的顾客又再次投入了老板的怀抱,老板开心的笑了。其实这里的增设窗口就是并行的处理了顾客等待时间的问题,提供了一种能力,一种能够同时供应两个用户的就餐能力。
针对上面的问题,我们会在下面通过实战模拟来体会一下串行和并行。
Java中的多线程
很多人在使用java中的多线程的时候总是搞不清楚某些概念,而导致使用不好多线程。首先要能够区分的概念我认为是线程对象
和线程
的概念,相信来学线程的,一定是对java基础还是有一定了解的,我们常常一说线程就会说Thread
,很多人就会认为Thread
就是一个线程,真的是这样吗?显然不是这样,这是一个认知的误区,那么我们来分析下到底什么是线程
,什么是线程对象
线程对象
线程对象,顾名思义——持有线程的对象。那么在JAVA中,是谁呢,没错,就是我们的Thread对象,无论你是直接new出来的Thread对象,还是其子类的实例化,只要在没有执行它的
start()
方法,那么这个对象就可以称之为线程对象,它只是持有这个线程的引用,并没有通过你的new方法创建了一个线程。
线程
如果仔细理解了上面线程对象的概念,那么就不难理解这里的线程的概念,只有当我们显示的调用了这个
线程对象
的start()
方法时,才能称之为真正的创建了一个线程,那么这个线程是怎么创建的呢,这就和操作系统有关了,JAVA会通过调用本地方法
start0()
来再操作系统中开辟线程的生存空间,从而创建一个我们理解意义上的线程。
如果上述概念已经澄清了,那么我们就可以学习JAVA中线程的实现方式了。
实现方式
- 声明一个类继承自
Thread
,并重写其run()
方法 - 声明一个类实现
Runnable
接口,实现其run()
方法,将其作为Thread
的参数来创建线程
实战
将会上面描述的窗口打饭进行描述,用于达成以下目的
- 理解串行和并行
- 理解线程对象和线程
- 使用线程
体会线程
下面我们创建一个线程对象,然后启动这个线程,查看执行结果
创建线程方式一:
声明一个类继承自Thread
,并重写其run()
方法
public class TaskThread extends Thread {
// run()方法是线程的主要业务执行单元
@Override
public void run() {
System.out.println("我是TaskThread,我的任务就是执行一系列的任务");
}
public static void main(String[] args) {
/*======================================继承方式创建一个线程=========================*/
/**
* 这里仅是声明了一个线程对象,并没有实际的创建出一个线程
*/
TaskThread taskThread = new TaskThread();
/**
* 这里是将线程对象变成一个线程的方法,如果注释这行,启动程序的时候讲不会打印`我是一个线程,
* 新创建的哦`这句话。由于线程的执行需要CPU的调度,而不是调用的start方法后就立即执行
*/
taskThread.start();
/*======================================内部类方式创建========================*/
// 采用内部类方式创建,如果不重写run方法,则什么也不会输出
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("我是一个线程,新创建的哦。");
}
};
thread.start();
System.out.println("main方法执行结束");
}
}
创建线程方式二:
声明一个类实现Runnable
接口,实现其run()
方法,将其作为Thread
的参数来创建线程
public class TaskRunnable implements Runnable {
@Override
public void run() {
System.out.println("我是通过Runnable方式创建的线程哦");
}
public static void main(String[] args) {
// 创建一个线程对象,使用实现了Runnable接口的对象作为参数
Thread thread = new Thread(new TaskRunnable());
// 线程对象启动,成为一个线程
thread.start();
System.out.println("main方法结束了哦");
}
}
一个窗口点餐(串行)
code清单1
/**
* @ClassName SingleWindowOrderFood
* @Description 单个窗口点餐
* @Author Administrator
* @Date 2019/10/11 22:56
* @Version 1.0
*/
public class SingleWindowOrderFood {
public static void main(String[] args) {
System.out.println("到饭点了,大家可以开始排队点餐了");
long start = System.currentTimeMillis();
// 窗口一: 处理50人的用餐
for (int i = 0; i < 50; i++) {
try {
// 这里模拟一个人点餐需要1秒,那么50个人点餐就需要50s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("一个窗口,每人耗时1秒,50人点餐,执行总共耗时:" + ((System.currentTimeMillis() - start) / 1000) +"s");
}
}
通过运行上述的代码,我们可以发现,程序一共跑了50s
可能有人会写出下面的代码,心想:不就是两个窗口吗,我写两个循环不就完了,分别处理25个客户请求:
public class DoubleWindowsOrderFood {
public static void main(String[] args) {
System.out.println("到饭点了,大家可以开始排队点餐了,今天两个窗口哦");
long start = System.currentTimeMillis();
// 窗口一: 处理25人的用餐
System.out.println("窗口一点餐开始");
for (int i = 0; i < 25; i++) {
try {
// 这里模拟一个人点餐需要1秒,那么25个人点餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口一点餐结束");
System.out.println("窗口二点餐开始");
// 窗口二: 处理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 这里模拟一个人点餐需要1秒,那么25个人点餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口二点餐结束");
System.out.println("两个窗口,每人耗时1秒,50人点餐,执行总共耗时:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
}
执行结果:
其实这个的执行结果和上面单个窗口的执行结果是一样的,那么我们来分析一下为什么?很简单,程序是串行化的,也就是一行一行执行的,并没有做到并行化执行,他不会从窗口一就直接跳到窗口二的代码去,只能一行一行的执行,一行一行的出结果,所以这里执行的结果都是一样的,都会耗费50s
并行化改造
站在生活的角度,两个窗口肯定是同时提供服务的,而不是必须等一个执行完了,才开始第二个,我们需要真正意义上的贴近生活的设计,那么线程就是用来干这个事的,下面我们对代码进行改造一下。
public class OrderFoodConcurrency {
public static void main(String[] args) {
System.out.println("到饭点了,大家可以开始排队点餐了,今天两个窗口哦,这里并行点餐哦");
Thread windowOne = new Thread(){
@Override
public void run() {
long start = System.currentTimeMillis();
System.out.println("窗口一开始点餐了");
// 窗口一: 处理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 这里模拟一个人点餐需要1秒,那么25个人点餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口一点餐耗时:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
};
windowOne.start();
Thread windowTwo = new Thread(){
@Override
public void run() {
long start = System.currentTimeMillis();
System.out.println("窗口二开始点餐了");
// 窗口二: 处理25人的用餐
for (int i = 0; i < 25; i++) {
try {
// 这里模拟一个人点餐需要1秒,那么25个人点餐就需要25s
Thread.sleep(1_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("窗口二点餐耗时:" + ((System.currentTimeMillis() - start) / 1000) + "s");
}
};
windowTwo.start();
try {
// 这里让main方法睡眠30s,主要是为了验证整个程序的执行时间,当然在后面会有来统计线程
// 执行时间的方法,这里就不做讲解了,其实main方法也是执行在一个线程中的,名字就叫main线程,这点 // 我们会在后面解析,因为窗口一和窗口二同时执行,那么最少需要25秒的时间才能完成两个线程的执行, // 这里我们睡眠30s,足矣,不然main线程启动完两个线程后直接挂掉了,无法监视时间
Thread.sleep(30_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("所有窗口按照预期都执行完了点餐");
}
}
执行结果:
分析
由于窗口一和窗口二是并行执行的,所以理论上两个线程完美状态是共耗时25s