java 并发基础
基础概念
进程
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是动态的,系统运行一个程序即 一个进程从创建,运行到消亡的过程
在Java种启动main函数时就是启动了一个JVM进程,而main函数所在的线程就是这个进程的主线程。
线程
线程和进程类似,但线程是比进程更小的执行单位。一个进程在执行过程种可以产生多个线程。与进程不同的是,同类的多个线程共享进程的 堆 和 方法区 资源,但是每个线程都有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或在各个线程之间切换时,负担比进程小得多,线程也被称为轻量级进程。
一个Java程序的运行是main线程和多个其他线程同时运行
进程和线程的关系、区别、优缺点
一个进程中可以有多个线程,多个线程共享进程的 堆和 **方法区(JDK1.8之后的元空间)**资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈
总结:线程是进程划分成的更小的运行单位,线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程种的线程极有可能会相互影响,线程执行开销小,但不利于资源的管理和保护,进程相反
为什么程序计数器是线程私有的
程序计数器作用
- 字节码解释器通过改变程序计数器的值来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道线程上次运行到哪了
如果执行的是native方法,那么程序计数器记录的是undefined地址,只有执行Java代码时程序计数器记录的才是下一条指令的地址。
程序计数器线程私有主要是为了线程切换后能恢复到正确的执行位置
为什么虚拟机栈和本地方法栈是线程私有的
- 虚拟机栈:每个Java方法在执行时会创建一个栈帧用于存储局部变量表,操作数栈,常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。
- 本地方法栈:和虚拟机栈发挥的作用相似,区别是:虚拟机栈为虚拟机执行Java方法(字节码)服务,本地方法栈则为虚拟机使用到的native方法服务。在HotSpot虚拟机中和Java虚拟机栈合二为一
为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈也是线程私有的
堆和方法区
堆和方法区都是所有线程共享的资源,堆是进程中最大的一块内存,主要存放新创建的对象(几乎所有对象都在这里分配内存);方法区主要是存放 已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
并发和并行
- 并发:同一时间段,多个任务都在执行(单位时间内不一定同时执行)
- 并行:单位时间内,多个任务同时执行。
为什么使用多线程
从计算机底层:线程可以比作轻量级的进程,是程序执行的最小单位,线程间的切换和调度成本远低于进程。另外,多核CPU时代意味着多个线程可以同时运行,减少了线程上下文切换的开销
从当代互联网发展趋势:现在的系统要求百万级甚至千万并发量,多线程编程正式开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能
单核时代:多线程主要是为了提高单进程利用CPU和IO系统的效率。假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
多核时代:多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
多线程带来的问题
并发编程的目的就是为了提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度,可能会遇到:内存泄漏、死锁、线程不安全等问题
线程的生命周期核状态
Java线程在运行的生命周期中的指定时刻只可能处于下面6中不同的状态的其中一个状态
线程在生命周期中并不是固定某个状态而是随着代码的执行在不同状态之间切换。Java线程状态变迁如下
线程创建之后处于NEW状态,调用Thread.start()
开始执行,此时处于READY(就绪)。可运行状态的线程获得CPU时间片(timeslice)后处于RUNNING状态。当线程执行wait()
方法后,线程进入WATING状态。进入等待状态的线程需要依靠其他线程的通知才能返回运行状态,而TIME_WAITING(超时等待)状态相当于在等待状态的基础上增加了超时限制,比如通过sleep(long millis)
方法或wait(long millis)
方法将线程置于TIME_WAITING状态。当达到时间后,Java线程返回RUNNABLE状态。当线程调用同步方法时,在没有获取到锁的情况下,线程进入BLOCKED状态。线程在Runnable的run()
方法之后会进入到TERMINATED状态。
JVM没有区分这两种状态,因为现在的时分多任务操作系统架构通常是用时间分片方式进行抢占式轮转调度,时间分片很小,一个线程一次最多在CPU上运行10-20ms的时间,从RUNNING-READY,线程状态切换太快,区分没有意义
上下文切换
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说的程序计数器,栈信息等,当出现如下情况,线程会从占用CPU状态退出
- 主动让出CPU,比如调用
sleep()
,wait()
等 - 时间片用完,因为操作系统要防止一个线程或进程长时间占用CPU导致其他线程或进程饿死
- 调用了阻塞类型的系统中断,比如请求IO,线程被阻塞
- 被终止或结束运行
前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用CPU时恢复现场,并加载下一个将要占用CPU的线程上下文。这就是所谓的上下文切换
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,将占用CPU,内存等系统资源进行处理,也意味着效率有一定损耗,频繁切换会造成整体效率低下。
线程死锁
认识线程死锁
线程死锁:多个线程同时被阻塞,它们中的一个或全部都在等待某个资源被释放,由于线程被无限期的阻塞,因此程序不可能正常终止。
如图,线程A持有资源2,线程B持有资源1,它们同时申请对方资源,两个线程就会互相等待进入死锁状态
public class DeadLockDemo {
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[]args){
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
},"线程1").start();
new Thread(() -> {
synchronized (resource2){
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
},"线程2").start();
}
}
输出
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程A通过synchronized(resource1)
得到resource1的监视器锁,然后通过Thread.sleep(1000);
让线程A休眠1s为的是让线程B得到执行然后获得resource2的监视器锁。线程A 和线程B休眠结束了都开始企图请求获取对方资源,然后两个线程陷入互相等待的状态,也就产生了死锁。
死锁的四个条件
- 互斥条件:该资源任何时刻只能被一个线程占用
- 请求与保持条件:一个进程因请求资源而阻塞时,对以获得的资源保持不放
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
- 不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完之后才能释放资源
预防和避免线程死锁
预防死锁,破坏死锁的产生的必要条件:
- 破坏请求与保持条件:一次性申请所有资源
- 破坏不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,就主动释放占有的资源
- 破坏循环等待条件:靠按序申请资源来预防,按照某一顺序申请资源,释放资源则反序释放,破坏循环等待条件
避免死锁
避免死锁就是在分配资源时,借助算法比如银行家算法等对资源分配进行计算评估,使其进入安全状态
安全状态:系统能够按照某种进行推进顺序(P1,P2,P3,…,Pn)来为每个进程分配所需的资源,直到满足每个进程对资源的最大需求,使每个进程都可以顺利完成,称<P1,P2,P3,..,Pn>序列为安全序列
对上面线程2代码修改
new Thread(() -> {
synchronized (resource1){
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
},"线程2").start();
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
sleep() 和 wait() 区别和共同点
- 两者最主要区别在于:
sleep()
方法没有释放锁,而wait()
方法释放了锁 - 两者都可以暂停线程的执行
wait()
通过被用于线程间交互/通信,sleep()
通过被用于暂停执行wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者使用wait(long timeout)超时后线程自动苏醒
调用start()方法时会执行run()方法
为什么调用start()方法时会执行run()方法,为什么不能直接调用run()方法?
new一个Thread,线程进入了新建状态,调用start()方法,会启动一个线程并使线程进入了就绪状态,当分配时间片后,就可以开始运行。start()会执行线程的相应准备工作,然后自动执行run()方法的内容。如果直接执行run()方法,会把run()方法当成一个main()线程下的普通方法去执行,并不会在某个线程中执行它,这并不是多线程工作。
总结: 调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行
- 本文链接:https://wentianhao.github.io/2021/08/10/%E5%B9%B6%E5%8F%91/
- 版权声明:本博客所有文章除特别声明外,均默认采用 许可协议。
若没有本文 Issue,您可以使用 Comment 模版新建。