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

程序员的时光

共 14427字,需浏览 29分钟

 ·

2021-03-27 08:53

点击蓝色“程序员的时光 ”关注我 ,标注“星标”,及时阅读最新技术文章

写在前面:

小伙伴儿们,大家好!今天来学习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(falsefalse);
        // 遍历线程信息,仅打印线程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的角度来理解线程和进程之间的关联。

Java内存区域图

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

总结:

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

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线程状态变迁如图示。

Java线程状态变迁图
  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线程状态,另外,我们再来聊一聊操作系统进程状态。由于这两者很相似,所以很容易会混淆。


进程一般有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 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休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入相互等待的状态,这也就产生了死锁。

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 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释放了对resource1resource2的监视器锁的占用,线程B获取到就可以执行了。这样就破坏了循环等待条件,因此避免了死锁。


参考文献:
Java并发编程之美 
JavaGuide面试突击


微信搜索公众号《程序员的时光》 好了,今天就先分享到这里了,下期继续给大家带来Java线程相关内容!更多干货、优质文章,欢迎关注我的原创技术公众号~

点个[在看],是对我最大的支持!


浏览 51
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报