写点什么

全面了解 Java 并发编程基础!超详细!

发布于: 39 分钟前
全面了解Java并发编程基础!超详细!

写在前面:


小伙伴儿们,大家好!今天来学习 Java 并发编程基础,作为面试必问的知识点,来深入了解一波!


思维导图:

1,什么是进程和线程?

1.1,进程

  • 程序:程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。

  • 进程:当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

  • 理解:进程就可以视为程序的一个实例。大部分程序可以运行多个实例进程(例如记事本,浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐)。

1.2,线程

  • 现代操作系统调度的最小单元是线程,也叫轻量级进程。

  • 在一个进程里可以创建多个线程,这些线程都拥有各自的程序计数器堆栈局部变量等属性,并且能够访问共享的内存变量。

  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。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 线程和多个其他线程的同时运行。

1.3,二者对比

这个是 Java 内存区域图,我们可以从 JVM 的角度来理解线程和进程之间的关联。

image-20201118101357320

可以看出,在一个进程里可以创建多个线程,这些线程都拥有各自的程序计数器堆栈局部变量等属性,并且能够访问共享的内存变量。

总结:

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量

  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

2,并行和并发

  • 并发:同一时间段,多个任务都在执行 (单位时间内不一定同时执行);

  • 并行:单位时间内,多个任务同时执行。

单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。总结为一句话就是:微观串行,宏观并行 , 一般会将这种线程轮流使用 CPU 的做法称为并发

举个例子:

  • 家庭主妇做饭、打扫卫生、照顾孩子,她一个人轮流交替做这多件事,这时就是并发

  • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一 个人用锅时,另一个人就得等待);

  • 雇了 3 个保姆,一个专做饭、一个专打扫卫生、一个专照顾孩子,互不干扰,这时是并行

3,Java 里面的线程

3.1,创建和运行线程的 3 种方式

  • 继承 Thread 类

    覆写父类中的 run() 方法,新线程类创建线程

    public class Thread1 extends Thread{     //重写父类中的run()方法     @Override     public void run() {         System.out.println("这是第一个线程");     }     public static void main(String[] args) {         Thread1 t1=new Thread1();         t1.start();     } }

  • 实现 Runnable 接口

    实现接口中的 run() 方法,Thread 类创建线程。把线程和任务(要执行的代码)分开;

    Thread 代表线程,Runnable 代表可运行的任务;

    public class Thread2 implements Runnable{     //重写父类中的run()方法     @Override     public void run() {         System.out.println("这是第二个线程");     }     public static void main(String[] args) {         //任务t2         Thread2 t2=new Thread2();         //线程t         Thread t=new Thread(t2);         t.start();     } }

  • FutureTask 类构造创建方法体

3.2,为什么要使用多线程呢?

  • 开销成本低: 线程可以⽐作是轻量级的进程,是程序执⾏的最⼩单位,线程间的切换和调度的成本远远⼩于进程。另外,多核 CPU 时代意味着多个线程可以同时运⾏,这减少了线程上下⽂切换的开销。

  • 并发能力强: 现在的系统动不动就要求百万级甚⾄千万级的并发量,⽽多线程并发编程正是开发⾼并发系统的基础,利⽤好多线程机制可以⼤⼤提⾼系统整体的并发能⼒以及性能。

  • 提高 CPU 利用率:假如我们要计算⼀个复杂的任务,我们只⽤⼀个线程的话,CPU 中只会⼀个 CPU 核⼼被利⽤到,⽽创建多个线程就可以让多个 CPU 核⼼被利⽤到,这样就提⾼了 CPU 的利⽤,这样提高了并行性能,也就是提高了 CPU 利用率。

3.3,线程的状态和生命周期

Java 线程在运行的生命周期中可能处于下表所示的 6 中不同状态,在给定的时刻中,线程只能处于其中一个状态。

Java 线程的状态

线程在自身的生命周期中, 并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java 线程状态变迁如图示。

image-20210319103233634

  1. 初始状态(NEW)用 new 创建一个线程对象,借助实现 Runnable 接口和继承 Thread 类都可以得到一个线程类,此时线程进入初始状态。

  2. 运行状态(RUNNABLE)

    就绪(READY)状态:调用线程的 start()方法可以启动线程。当线程启动时,线程就进入就绪状态。此时,线程将进入线程队列排队,等待 CPU 服务,这表明它已经具备了运行条件。运行中状态(RUNNING 状态):线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态,这也是线程进入运行状态的唯一的一种方式。此时,自动调用该线程对象的**run()**方法。**run()**方法定义了该线程的操作和功能。

    由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(就绪) 状态。就绪状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。

  3. 阻塞状态(BLOCKED)

    阻塞状态是线程阻塞在进入 synchronized 关键字修饰的方法或代码块(获取锁)时的状态。

  4. 等待状态(WAITING)

    处于这种状态的线程不会被分配 CPU 执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

  5. 超时等待(TIMED_WAITING)

    进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也是超时时间到达时将会返回到运行状态。

  6. 终止状态

    当线程的 run()方法完成时,或者主线程的 main()方法完成时,我们就认为它终止了。线程一旦终止了,就不能复生。

    线程创建之后,调用**start()方法开始运行。当线程执行 wait()**方法之 后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将会返回到运行状态。**当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞状态。**线程在执行Runnable的 run()方法之后将会进入到终止状态。

聊完了 Java 线程状态,另外,我们再来聊一聊操作系统进程状态。由于这两者很相似,所以很容易会混淆。

image-20210321090107063

进程一般有 5 种状态:

  • 创建状态(new):进程正在被创建,尚未达到就绪状态。

  • 就绪状态(ready):进程已处于准备运⾏状态,即进程获得了除了处理器之外的⼀切所需资源, ⼀旦得到处理器资源(处理器分配的时间⽚)即可运⾏。

  • 运行状态(running):进程正在处理器上运⾏(单核 CPU 下任意时刻只有⼀个进程处于运⾏状态)。

  • 阻塞状态(waiting):⼜称为等待状态,进程正在等待某⼀事件⽽暂停运⾏如等待某资源为可⽤或等待 IO 操作完成。即使处理器空闲,该进程也不能运⾏。

  • 结束状态(terminated):进程正在从系统中消失。或出现错误,或被系统终止,进入终止状态。无法再执行

3.4,使用多线程会存在什么问题?

从多线程的设计原则中可以看到,多线程虽然并发能力强、CPU 利用率高,但是因为其存在对共享和可变状态的资源进行访问,所以存在一定的线程安全问题。并发编程的⽬的就是为了能提⾼程序的执⾏效率提⾼程序运⾏速度,但是并发编程也会遇到很多问题,⽐如:内存泄漏、上下⽂切换、死锁还有受限于硬件和软件的资源闲置问题。

3.5,什么是上下文切换?

上下文:每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,这就涉及到 CPU 寄存器和程序计数器(PC):

CPU 寄存器是 CPU 内置的容量小、但速度极快的内存;程序计数器会存储 CPU 正在执行的指令位置,或者即将执行的指令位置。这两个是 CPU 运行任何任务前都必须依赖的环境,因此叫做 CPU 上下文。

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第 多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文 切换也会影响多线程的执行速度。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

3.6,如何减少上下文切换?

减少上下文切换的方法有无锁并发编程、CAS 算法、使用最少线程和使用协程。

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。

  • CAS 算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。

  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这 样会造成大量线程都处于等待状态。

  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

3.7,什么是线程死锁?

死锁指多个线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去,如下图所示:

线程死锁示意图

如上图所示,线程 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 resource1Thread[线程 B,5,main]get resource2Thread[线程 A,5,main]waiting get resource2Thread[线程 B,5,main]waiting get resource1
复制代码

从输出结果可知,线程调度器先调度了线程 A,也就是把 CPU 资源分配给了线程 A,线程 A 使用 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 是为了让线程 B 得到 CPU 资源然后执行获取到resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入相互等待的状态,这也就产生了死锁。

3.8,如何避免死锁?

前面说到,死锁产生必须具备四个条件,我们对其破坏就可以避免死锁。

互斥条件:指线程对已获取到的资源进行排它性使用,该资源任意时刻只由一个线程占用;

这个条件无法破坏,因为用锁本来就是想让资源之间排斥的。

请求和保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不释放;

一次性申请所有资源即可。

不可剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源;

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

按照申请资源的有序性原则来预防。按某一顺序申请资源,释放资源则反序释放,破坏循环等待条件。

造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁。

我们对上面线程 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 resource1Thread[线程 A,5,main]waiting get resource2Thread[线程 A,5,main]get resource2Thread[线程 B,5,main]get resource1Thread[线程 B,5,main]waiting get resource2Thread[线程 B,5,main]get resource2
复制代码

分析下上面的代码为什么避免的死锁的发生?

假如线程 A 和线程 B 同时执行到了synchronized (resource1),只有一个线程可以获取到 resource1 上的监视器锁。假如线程 A 获取到了,那么线程 B 就会被阻塞而不会再去获取 resource1,然后线程 A 再去获取 resource2 的监视器锁,可以获取到;这时候线程 A 释放了对resource1resource2的监视器锁的占用,线程 B 获取到就可以执行了。这样就破坏了循环等待条件,因此避免了死锁。


微信搜索公众号《程序员的时光

好了,今天就先分享到这里了,下期继续给大家带来 java 框架面试内容!

更多干货、优质文章,欢迎关注我的原创技术公众号~


参考文献:

[Java 并发编程之美 JavaGuide 面试突击]

发布于: 39 分钟前阅读数: 3
用户头像

程序员的路,会越来越精彩! 2020.04.30 加入

公众号:程序员的时光 记录学习编程的一路时光,从小白到现在也能稳操胜券; 主要从事Java后台开发,数据结构与算法,设计模式等等; 欢迎一起交流,分享经验,学习进步!

评论

发布
暂无评论
全面了解Java并发编程基础!超详细!