Java并发与多线程之基本概念
1、前情概要
并发(Concurrency):指在某个时间段内,多任务交替处理的能力。CPU把可执行时间均匀地分成若干份,每个线程执行一段时间后,记录当前的工作状态,释放相关的执行资源并进入等待状态,让其他进程抢占CPU资源。
并行(Parallelism):指多任务同时处理的能力。目前CPU已经发展为多核,可以同时执行多个互不依赖的指令及执行块。
并发与并行两个概念非常容易混淆,它们的核心区别在于进程是否同时执行。以KTV唱歌为例,并行指的是有多少人可以使用话筒同时唱歌;并发指的是同一个话筒被多个人轮流使用。
并发与并行的目标都是尽可能快的执行完所有任务。以医生坐诊为例,某个科室有两个专家同时出诊,这就是两个并行任务;其中一个医生,时而问诊,时而查看化验单,然后继续问诊,突然又中断去处理病人的咨询,这就是并发。
在并发环境下,由于程序的封闭性被打破,出现了以下特点:
- 并发程序之间有相互制约的关系。直接制约体现为一个程序需要另一个程序的计算结果;间接制约体现为多个程序竞争共享资源,如处理器、缓冲区等。
- 并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点。
- 当并发数设置合理并且CPU拥有足够的处理能力时,并发会提高程序的运行效率。
进程:进程是资源分配的最小单位。每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含1–n个线程。
线程:线程是cpu调度和分派的最小单位。每个线程有独立的操作栈和程序计数器、局部变量表等资源,它与同一进程内的其他线程共享该进程的所有资源。线程切换开销小。
2、线程状态
线程的一生分为六个阶段:创建、可运行、阻塞、等待、计时等待、终止。
- New:代表已创建但还没启动的新线程,当我们用new Thread();新建一个线程之后,但是还没有执行start()方法,此时就处于New的状态;
- Runnable(可运行状态):从
New
,调用了start()方法之后,就会处于Runnable
状态;Runnable
有可能在执行,也有可能没有在执行(等待CPU分配运行时间);比如说一个线程拿到了CPU资源了————Runnable
状态,CPU资源是被我们调度器不停的调度,所以有的时候,会突然被拿走,一旦我们某一个线程拿到了CPU资源,正在运行了,突然CPU资源被抢走分配给别人,此时这个线程还是Runnable
状态;因为虽然它并没有在运行中,但它依然是处于可运行状态,随时随地都有可能又被调度器分配回来CPU资源,然后就又可以运行了; - Blocked:当一个线程进入到被
synchronized
的代码块的时候,并且该锁(monitor
)已经被其他线程所拿走了,此时的状态是Blocked
;等待另外线程释放排它锁在进入可运行状态; - Waiting(等待):与第三个状态类似;一方面是没有设置
timeout
参数的Object.wait()
、Thread.join()
、LockSupport.park()
直到被唤醒才可进入可运行状态; - Timed Waiting(计时等待):和等待状态非常类似,有时间期限;
Thread.sleep(time)
、Object.wait(time)
、、Thread.join(time)
、LockSupport.parkNanos(time)
、LockSupport.parkUntil(time)
; - Terminated(终止):,run()正常退出、出现未被捕获的异常;
一般习惯而言,把Blocked(被阻塞)、Waiting(等待)、Timed_waiting(计时等待)都称为阻塞状态,不仅仅是Blocked。
3、多线程的实现
在java中要想实现多线程,有两种手段,一种是继承Thread
类,另外一种是实现Runable
接口。
Runnable
也是非常常见的一种,我们只需要重写run()
方法即可。
run()
方法是多线程程序的一个约定。所有的多线程代码都在run()
方法里面。
Thread
类实际上也是实现了Runnable
接口的类。
在启动的多线程的时候,需要先通过 Thread
类的构造方法Thread(Runnable target)
构造出对象,然后调用Thread对象的start()方法来运行多线程代码。
实际上所有的多线程代码都是通过运行Thread的start() 方法来运行的。因此,不管是扩展Thread类还是实现Runnable接口来实现多线程,最终还是通过Thread的对象的API来控制线程的,熟悉Thread类的API是进行多线程编程的基础。
start() 和 run()的区别说明:
start() : 它的作用是启动一个新线程,新线程会执行相应的run()方法。start()不能被重复调用。
run() : run()就和普通的成员方法一样,可以被重复调用。单独调用run()的话,会在当前线程中执行run(),而并不会启动新线程!
3.1、用Thread 方式实现线程
下面通过代码演示下区别:
class MyThread extends Thread{
public MyThread(String name) {
super(name);
}
public void run(){
System.out.println(Thread.currentThread().getName()+" is running");
}
};
public class Demo {
public static void main(String[] args) {
Thread mythread=new MyThread("mythread");
System.out.println(Thread.currentThread().getName()+" call mythread.run()");
mythread.run();
System.out.println(Thread.currentThread().getName()+" call mythread.start()");
mythread.start();
}
}
运行结果:
main call mythread.run()
main is running
main call mythread.start()
mythread is running
结果说明:
- Thread.currentThread().getName()是用于获取当前线程的名字。当前线程是指正在cpu中调度执行的线程。
- mythread.run()是在主线程main中调用的,该run()方法直接运行在主线程main上。
- mythread.start()会启动线程mythread,线程mythread启动之后,会调用run()方法;此时的run()方法是运行在线程mythread上。
3.2、用Runnable方式创建线程
public class RunnableStyle implements Runnable {
public static void main(String[] args) {
Thread thread = new Thread(new RunnableStyle());
thread.start();
}
@Override
public void run() {
System.out.println("用Runnable方法实现线程");
}
}
4、常见问题
4.1 有多少种实现多线程的方式?
从不同的角度看,会有不同的答案。 典型答案是两种,分别是实现Runnable接口和继承Thread类。但是,我们看原理,其实Thread
类实现了Runnable
接口,并且看Thread
类的run
方法,会发现其实那两种本质都是一样的,run方法的代码如下:
@Override
public void run () {
if (target != null) {
target.run();
}
}
继承Thread
类和实现Runnable
接口在实现多线程的本质上,并没有区别,都是最终调用了start()方法来新建线程。
这两个方法的最主要区别在于run()
方法的内容来源:
- 继承
Thread
类:run()
整个都被重写; - 实现
Runnable
接口:最终调用target.run()
;
我们只能通过新建Thread
类这一种方式来创建线程,但是类里面的run方法有两种方式来实现:第一种是重写run()
方法;
第二种实现Runnable
接口的run()
方法,然后再把该runnable实例传给Thread
类。
除此之外,从表面上看线程池、定时器等工具类也可以创建线程,但是细看源码,从没有逃出过本质,也就是实现Runnable
接口和继承Thread
类。
4.2 start方法的执行流程是什么?
- 检查线程状态,只有
NEW
状态下的线程才能继续,否则会抛出IllegalThreadStateException
(在运行中或者已结束的线程,都不能再次启动,详见CantStartTwice类); - 被加入线程组
- 调用
start0
()方法启动线程
注意:start
方法是被synchronized
修饰的方法,可以保证线程安全;由JVM
创建的main
方法线程和system
组线程,并不会通过start来启动。
4.3 一个线程两次调用start()方法会出现什么情况?为什么?
会抛出IllegalThreadStateException
异常。因为start()方法开始的时候就会对当前线程的状态进行一个检查,如果不符合规定(如果已经执行了start()方法)就会抛出异常。
4.4 既然start()方法会调用run()方法,为什么我们选择调用start()方法,而不是直接调用run()方法呢?
因为调用start()
方法才是真正意义上启动一个线程,它会去经历线程的各个生命周期;而如果直接调用run()方法,它就是一个普通的方法而已,不会通过子线程去调用,所以这并不是多线程工作。
public class StartAndRunMethod {
public static void main(String[] args) {
Runnable runnable = () ->{
System.out.println(Thread.currentThread().getName());
};
runnable.run(); //main
new Thread(runnable).start(); //Thread-0
}
}
运行结果:
main
Thread-0
4.5 实现Runnable接口比继承Thread类所具有的优势
- 从代码架构角度:具体的任务(run方法)应该创建和运行线程的机制(Thread类) 解耦,用Runnable对象可以实现解耦。
- 资源的节约角度:如果说继承Thread类实现的话,每次我们想新建一个任务只能去新建一个独立的线程,而新建一个独立的线程的损耗是比较大的:需要创建、执行、销毁;而如果使用Runnable,我们就可以利用后续的线程池之类的工具,而利用这样的工具就可以大大减小这些创建线程和销毁线程所带来的损耗;
- 可扩展性:继承了Thread之后,由于Java不支持双继承导致这个类就无法继承其他的类了,这大大限制了我们的可扩展性。
4.6 线程属性
- 编号(ID):每个线程有自己的ID,用于标识不同的线程;
- 名称(Name):作用让用户或程序员在开发、调试或运行过程中,更容易区分每个不同的线程、定位问题等;
- 是否是守护线程(isDaemon):true代表是,false代表不是(用户线程);
- 优先级(Priority):优先级这个属性的目的是告诉线程调度器,用户希望哪些线程相对多运行,哪些少运行。
4.7 线程和进程的区别
进程和线程的根本区别是进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。另外区别还有资源开销、包含关系、内存分配、影响关系、执行过程等。
- 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的程序计数器、虚拟机栈和本地方法栈,线程之间切换的开销小。
- 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
- 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的。
- 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行。
4.8 为什么要使用多线程?
先从总体上来说:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
- 多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。
下一篇: 第二章 synchronized关键字