写在前面:
小伙伴儿们,大家好!今天来学习Java并发编程基础,作为面试必问的知识点,来深入了解一波!
思维导图:
Java程序天生就是多线程程序,因为执行main()方法的是一个名称为main的线程。下面使用JMX来查看一个普通的Java程序包含哪些线程,代码如下。
public class MultiThread {
public static void main(String[] args) {
// 获取Java线程管理MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的monitor和synchronizer信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程ID和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.
getThreadName());
}
}
}
上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):
[6] Monitor Ctrl-Break //这个是在idea中特有的线程,eclipse并不会产生
[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main线程,程序入口
可以看到,一个Java程序的运行不仅仅是main()方法的运行,而是main线程和多个其他线程的同时运行。
这个是Java内存区域图,我们可以从JVM的角度来理解线程和进程之间的关联。
Java内存区域图
可以看出,在一个进程里可以创建多个线程,这些线程都拥有各自的程序计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。
总结:
单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。总结为一句话就是:微观串行,宏观并行 , 一般会将这种线程轮流使用 CPU 的做法称为并发。
举个例子:
Java线程在运行的生命周期中可能处于下表所示的6中不同状态,在给定的时刻中,线程只能处于其中一个状态。
Java线程的状态
线程在自身的生命周期中, 并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态变迁如图示。
Java线程状态变迁图
start()
方法后开始运行,线程这时候处于 READY(就绪) 状态。就绪状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。Runnable
的run()方法之后将会进入到终止状态。聊完了Java线程状态,另外,我们再来聊一聊操作系统进程状态。由于这两者很相似,所以很容易会混淆。
进程一般有5种状态:
从多线程的设计原则中可以看到,多线程虽然并发能力强、CPU利用率高,但是因为其存在对共享和可变状态的资源进行访问,所以存在一定的线程安全问题。并发编程的⽬的就是为了能提⾼程序的执⾏效率提⾼程序运⾏速度,但是并发编程也会遇到很多问题,⽐如:内存泄漏、上下⽂切换、死锁还有受限于硬件和软件的资源闲置问题。
上下文:每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,这就涉及到 CPU 寄存器和程序计数器(PC):
CPU 寄存器是 CPU 内置的容量小、但速度极快的内存;程序计数器会存储 CPU 正在执行的指令位置,或者即将执行的指令位置。这两个是 CPU 运行任何任务前都必须依赖的环境,因此叫做 CPU 上下文。
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第 多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文 切换也会影响多线程的执行速度。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
死锁指多个线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去,如下图所示:
线程死锁示意图
如上图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
那么为什么会产生死锁呢?学过操作系统的应该都知道,死锁的产生必须具备四个条件:互斥条件,请求和保持条件,不可剥夺条件,循环等待条件。下面通过一个例子来说明线程死锁。
public class DeadLock {
//创建资源1和资源2
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");
}
}
}, "线程 A").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");
}
}
}, "线程 B").start();
}
}
运行结果:
Thread[线程 A,5,main]get resource1
Thread[线程 B,5,main]get resource2
Thread[线程 A,5,main]waiting get resource2
Thread[线程 B,5,main]waiting get resource1
从输出结果可知,线程调度器先调度了线程A,也就是把CPU资源分配给了线程A,线程A使用 synchronized (resource1)
获得 resource1
的监视器锁,然后通过Thread.sleep(1000)
;让线程A休眠1s是为了让线程B得到CPU资源然后执行获取到resource2
的监视器锁。线程A和线程B休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入相互等待的状态,这也就产生了死锁。
前面说到,死锁产生必须具备四个条件,我们对其破坏就可以避免死锁。
互斥条件:指线程对已获取到的资源进行排它性使用,该资源任意时刻只由一个线程占用;
这个条件无法破坏,因为用锁本来就是想让资源之间排斥的。
请求和保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不释放;
一次性申请所有资源即可。
不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源;
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
按照申请资源的有序性原则来预防。按某一顺序申请资源,释放资源则反序释放,破坏循环等待条件。
造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁。
我们对上面线程B的代码进行修改:
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");
}
}
}, "线程 B").start();
运行结果:
Thread[线程 A,5,main]get resource1
Thread[线程 A,5,main]waiting get resource2
Thread[线程 A,5,main]get resource2
Thread[线程 B,5,main]get resource1
Thread[线程 B,5,main]waiting get resource2
Thread[线程 B,5,main]get resource2
分析下上面的代码为什么避免的死锁的发生?
假如线程A和线程B同时执行到了synchronized (resource1)
,只有一个线程可以获取到resource1上的监视器锁。假如线程A获取到了,那么线程B就会被阻塞而不会再去获取resource1,然后线程A再去获取resource2的监视器锁,可以获取到;这时候线程A释放了对resource1
、resource2
的监视器锁的占用,线程B获取到就可以执行了。这样就破坏了循环等待条件,因此避免了死锁。
参考文献: Java并发编程之美 JavaGuide面试突击
微信搜索公众号《程序员的时光》 好了,今天就先分享到这里了,下期继续给大家带来Java线程相关内容!更多干货、优质文章,欢迎关注我的原创技术公众号~
点个[在看],是对我最大的支持!