Java知识之并发

并发这部分内容相对来说还是有一些难度的,并且,对于初学者来说最难的就是在项目中去实践。

如果你刚学完 Java 基础的话,我建议你学习并发这部分内容的时候,可以先简单地了解一下基础知识比如线程和进程的对比。到了后面,你对于 Java 了解的更深了之后,再回来仔细看看这部分的内容。

救急准备

下面的知识点都是要看的,我通过打星与加粗的方式对知识点的重要性进行评级!难度是针对互联网大厂的。

  • ⭐ :面试中不常问到,如果面试官问到尽量能答出来,答不出来也没关系。
  • ⭐⭐ :面试中不常问到,但是如果面试官问到的话,答不出来对你的印象会减分。
  • ⭐⭐⭐:面试中会问到,答不出来面试有点悬。面试官会惊讶为什么你这也不会。
  • ⭐⭐⭐⭐:面试高频考点。
  • ⭐⭐⭐⭐⭐:面试超高频考点。四星考点和五星考点是参加十场面试,至少能有五场面试问到这些的。大家在准备面试过程中尽量把这些知识点的回答条理梳理清楚,面试官一问就开背。

Java 并发常见面试题汇总

  1. 进程和线程的区别。【⭐⭐⭐⭐⭐】这是一个超高频考点,面试回答时别一句一个进程包含很多线程就没了。要答清楚什么是线程什么是进程,线程和进程各自的运行状态、线程的通信方式和进程的通信方式
    何为进程?
    进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
    在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
    何为线程?
    线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
    请简要描述线程与进程的关系,区别及优缺点?

    从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
    总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
    程序计数器为什么是私有的?
    程序计数器主要有下面两个作用:
    字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
    在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
    需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
    所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
    虚拟机栈和本地方法栈为什么是私有的?
    虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
    本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
    所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
    一句话简单了解堆和方法区
    堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  2. 创建线程的方式。【⭐⭐⭐⭐】不仅要把创建线程的方式记熟、记住各种方式的优缺点,还要能写出代码来。有的面试官是会让你写代码创建两个线程然后执行一些操作的,比如两个线程交替输出数字。
    Java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。Java可以用三种方式来创建线程,如下所示:
    1)继承Thread类创建线程
    2)实现Runnable接口创建线程
    3)使用Callable和Future创建线程
    下面让我们分别来看看这三种创建线程的区别。
    实现Runnable和实现Callable接口的方式基本相同,不过是后者执行call()方法有返回值,后者线程执行体run()方法无返回值。
    因此可以把这两种方式归为一种这种方式与继承Thread类的方法之间的差别如下:
    1、线程只是实现Runnable或实现Callable接口,还可以继承其他类。
    2、这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
    3、但是编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。
    4、继承Thread类的线程类不能再继承其他父类(Java单继承决定)。
    注:一般推荐采用实现接口的方式来创建多线程

  3. 什么是死锁,死锁如何产生,死锁如何避免。【⭐⭐⭐⭐⭐】超高频问题,几乎大厂的一面和二面都会问到。
    线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
    产生死锁必须具备以下四个条件:
    互斥条件:该资源任意一个时刻只由一个线程占用。
    请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
    循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
    如何预防和避免线程死锁?
    如何预防死锁?
    破坏死锁的产生的必要条件即可:
    破坏请求与保持条件 :一次性申请所有的资源。
    破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
    破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
    如何避免死锁?
    避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
    安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3…..Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称<P1、P2、P3…..Pn>序列为安全序列。

  4. 并发编程的三大特性(原子性、可见性以及有序性)。【⭐⭐⭐⭐】
    原子性 : 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。synchronized 可以保证代码片段的原子性。
    可见性 :当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
    有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

  5. synchronized 锁升级流程。【⭐⭐⭐⭐⭐】这又是面试八股文的一大考点,锁升级流程记清楚。
    JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
    锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
    锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。

  6. volatile 关键字。【⭐⭐⭐⭐⭐】对比和 synchronized 的区别。
    volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。
    synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!
    volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
    volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
    volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

  7. JMM(Java Memory Model,Java 内存模型)和 happens-before 原则。【⭐⭐⭐⭐⭐】面试中重点!几乎必问。
    Java 内存模型抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。Java 内存模型主要目的是为了屏蔽系统和硬件的差异,避免一套代码在不同的平台下产生的效果不一致。
    在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
    主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
    本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
    https://blog.csdn.net/webor2006/article/details/119894581

  8. ThreadLocal。【⭐⭐⭐⭐】这也是面试八股文的一个高频考点。我面试到后面不想背这里了,面试过程中就尽可能躲着这个知识点,不提到和这相关的,竟然真的苟过去了。
    通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
    如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

  9. 线程池。【⭐⭐⭐⭐⭐】超高频考点。需要答出线程池有哪几种,各种线程池的优缺点,线程池的重要参数、线程池的执行流程、线程池的饱和策略、如何设置线程池的大小等等。这里也能背十几分钟。
    池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
    线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
    好处:
    降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
    提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
    提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
    通过 ThreadPoolExecutor 的方式创建线程池:
    方式一:通过构造方法实现(4个)
    方式二:通过 Executor 框架的工具类 Executors 来实现
    我们可以创建三种类型的 ThreadPoolExecutor:
    FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
    SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
    CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
    ThreadPoolExecutor构造函数重要参数分析
    ThreadPoolExecutor 3 个最重要的参数:
    corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。
    maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
    workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
    ThreadPoolExecutor其他常见参数:
    keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
    unit : keepAliveTime 参数的时间单位。
    threadFactory :executor 创建新线程的时候会用到。
    handler :饱和策略。关于饱和策略下面单独介绍一下。
    ThreadPoolExecutor 饱和策略定义:
    如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:
    1.ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。
    2.ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
    3.ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
    4.ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

  10. ReentrantLockAQS。【⭐⭐⭐⭐⭐】其实我在面试的时候对这里不是很熟,我面试的时候尽量不提到这里,也苟过去了。大家如果时间充足的话还是把这块好好理解一下。如果这里理解透彻了,也能在这里和面试官聊很久。
    谈谈 synchronized 和 ReentrantLock 的区别
    两者都是可重入锁
    “可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
    synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
    ReentrantLock 比 synchronized 增加了一些高级功能
    相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:
    等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

  11. 乐观锁和悲观锁的区别。【⭐⭐⭐⭐⭐】
    乐观锁
    乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
    Java中的乐观锁基本都是通过CAS操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
    悲观锁
    悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。
    Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。

  12. CAS 了解么?原理?什么是 ABA 问题?ABA 问题怎么解决?【⭐⭐⭐⭐⭐】CAS(Compare-and-Swap)绝对是面试中的高频中的高频,很多地方都用到了 CAS 比如 ConcurrentHashMap 采用 CASsynchronized 来保证并发安全,再比如java.util.concurrent.atomic包中的类通过 volatile+CAS 重试保证线程安全性。和面试官聊 CAS 的时候,你可以结合 CAS 的一些实际应用来说。
    乐观锁用到的机制就是CAS,Compare and Swap。
    CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
    就是它在没有锁的状态下,可以保证多个线程对一个值得更新。
    例如:多线程一致情况下,A线程去读这个值,这个值原来是个“0”,然后A线程去拿这个值“0”来修改成“1”。最后A线程把修改的“1”返回去的时候,先判断是不是原来的这个值是“0”,如果还是“0”,说明其他线程没有动过这个值,然后A线程直接把修改的值“1”写上去。这样就要考虑别的线程同步问题了。
    使用CAS会造成ABA问题,一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,这种过程就叫ABA问题。
    解决ABA问题非常简单,就是使用版本号标志,每当修改操作一次版本号加1,这样比较时候,不管比较值还比较了版本号。但是在java5中,已经提供了AtomicStampedReference来解决问题了。

  13. Atomic 原子类【⭐⭐】
    Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
    所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

  14. ……

系统学习

推荐大家跟着 《Java 并发实现原理:JDK 源码剖析》 这部分来学习 Java 并发相关的知识。

总体来说,市面上关于 Java 并发的书籍非常多,但是,大部分都是一些基础入门读物,真正讲原理的很少。然而,真正重要的就是这些原理知识。

这本书的整体内容如下,我简单概括一下:

  • 第 1 章 多线程基础 :讲了 Java 并发中比较重要的一些基础概念比如 synchronized 关键、volatile 关键字、JMM(Java Memory Model,Java 内存模型)和 happens-before 原则、 内存屏障、final 关键字、无锁编程。都是一些非常重要的概念,不论是对于你学习并发编程,还是说准备 Java 面试。
  • 第 2 章 Atomic 类 :不光讲了 Atomic 类(AtomicIntegerAtomicLongAtomicBooleanAtomicReferenceAtomicStampedReferenceAtomicMarkable Reference 等等),还提到了悲观锁与乐观锁、Unsafe 的 CAS、自旋与阻塞、ABA 问题与解决办法等等比较重要的并发知识点。
  • 第 3 章 Lock 与 Condition :主要讲了互斥锁(实现原理、源码分析)、读写锁 (实现原理、AQS、WriteLockReadLock)、Condition(使用场景、实现原理、源码分析) 。
  • 第 4 章 同步工具类 :这一张主要介绍了四个非常重要的并发类SemaphoreCountDownLatchCyclicBarrierExchanger(用于线程间进行通信、数据交换的多线程交互工)、Phaser(和 CyclicBarrier 以及 CountDownLatch 很像,但是使用上更加的灵活。相关阅读:Phaser 使用介绍)。
  • 第 5 章 并发容器 : 介绍了并发环境下使用的集合,包括 BlockingQueueBlockingDequeCopyOnWriteConcurrentLinkedQueue/ DequeConcurrentHashMap
  • 第 6 章 线程池与 Future : 从原理出发来讲解线程池!
  • 第 7 章 ForkJoinPool :从原理出发来讲解 ForkJoinPool (JDK7 引入的线程池,支持将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合成总的计算结果)。
  • 第 8 章 CompletableFuture :介绍了 CompletableFuture 的常见用法以及原理。

如果你比较喜欢看视频的话,首推尚硅谷 2021 最新的《JUC 并发编程系列》

这门课是我在网上看到的讲解并发编程的课程中最棒的一个!


Java知识之并发
https://leehoward.cn/2022/03/20/Java知识之并发/
作者
lihao
发布于
2022年3月20日
许可协议