java 并发面试题

63 阅读53分钟

什么是线程和进程?

  • 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
  • 在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
  • 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

请简要描述线程与进程的关系,区别及优缺点?

一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。

线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

程序计数器为什么是线程私有的?

程序计数器主要有下面两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。

虚拟机栈和本地方法栈为什么是线程私有的?

  • 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

一句话简单了解堆和方法区?

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发与并行的区别?

并发:同一时间内有多个任务被同时执行,但无需同时执行,比如同一时间内有多个任务被同时执行,但无需同时执行; 并行:同一时间执行多个任务,比如多核CPU或GPU执行多线程计算;

同步和异步的区别?

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

为什么要使用多线程?

  • 单核时代:多线程主要是为了提高进程利用单核 CPU 和 IO 系统的效率。假如是单线程,当线程请求 IO 的时候,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了进程利用系统资源的整体效率。
  • 多核时代: 多线程主要是为了提高进程利用多核 CPU 的能力。比如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

如何理解线程安全和不安全?

线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。

  • 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
  • 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

单核 CPU 上运行多个线程效率一定会高吗?

不一定,比如只做一件事的时候,多线程是一种浪费。比如 cpu 密集型任务,上下文切换导致开销增大,反而有可能降低效率。如果是 IO 密集型任务,在适当的并发任务数下,可以提高效率。

如何创建线程?

  • 继承 Thread 类;
  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 使用线程池工具、CompletableFuture、ForkJoin 框架等; 但是本质都是 new Thread().start();

什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当发生线程切换时,需要保存当前线程的上下文,等待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是上下文切换。 触发线程上下文切换的场景:

  • 主动让出 CPU,比如调用了 sleep(), wait() 等;
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死;
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞;
  • 被终止或结束运行;

什么是线程死锁?

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

产生死锁的四个必要条件?

  • 互斥条件:该资源任意一个时刻只由一个线程占用;
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源;
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系;

如何预防线程死锁?

破坏死锁的产生的必要条件即可:

  • 破坏请求与保持条件:一次性申请所有的资源;
  • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源;
  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件;

如何避免死锁?

在资源分配时,借助算法(比如银行家算法)对资源进行计算评估,使其进入安全状态;

什么是安全状态?

系统能够按照某种线程推进顺序来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使得每个线程都可以顺利完成;

sleep() 方法和 wait() 方法对比?

共同点:两者都可以暂停线程的执行; 区别:

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁;
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行;
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep() 是 Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

为什么 wait() 方法不定义在 Thread 中,而要定义在 Object 对象中?

wait() 是让获得对象锁的线程实现等等,会自动释放当前线程占有的对象锁,既然要释放当前线程占有的对象锁并让它进入 waiting 状态,自然是要操作对象,而不是当前的线程

为什么 sleep() 方法定义在 Thread 中?

sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是直接执行 run() 方法,会把 run() 方法当成一个主线程线程下的普通方法去执行,并不会在新线程中执行它,所以这并不是多线程工作。

为什么要用 CPU 高速缓存和内存缓存?

cpu缓存:是介于 cpu 和主存之间,是为了克服 cpu 处理速度远高于主存访问速度的矛盾; 内存缓存:是介于主存和磁盘之间,为了减少磁盘访问; 由于引入了缓存,有可能存在数据不一致的问题,为了解决数据不一致,可以通过一致性协议来解决。

什么是指令重排?

指令重排就是,为了提升执行速度/性能,计算机在执行程序代码时,会对指令进行重新排序。

常见的指令重排有两种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。

什么是主内存?什么是本地内存?

  • 主内存就是共享内存,所有的变量、常量及类信息都放在主内存中;
  • 除了共享内存,每个线程还有一个私有的本地内存,本地内存存储了该线程需要读/写的共享变量的副本,每个线程只能操作自己的本地内存中的变量,无法直接访问其他线程的本地内存,线程之间的通信需要通过主内存来进行。本地内存是一个抽象概念,并不正真存在。

线程之间如何通信?

  • 当一个线程在本地内存修改了共享变量副本后,把副本的值同步到主内存中;
  • 另一个线程从主内存中读取被修改后的共享变量的值,从而实现线程之间的通信;
  • 多个线程之间进行通信会导致线程安全问题,因此 java 内存模型定义了八种(lock\unlock\read\load\use\assign\store\write)同步操作来保证线程安全;

Java 内存区域和 JMM 有何区别?

Java 内存区域和内存模型是完全不一样的两个东西:

  • JVM 内存区域与 Java 虚拟机的运行时内存区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型与 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

什么是 happens-before 原则?

happens-before 是先行发生原则,它是 java 内存模型中的一个概念,它用于定义操作之间的内存可见性。更具体地说,它规定了什么时候对共享变量的写操作对其他线程是可见的。

happens-before 的定义?

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

happens-before 有哪些规则?

  • 程序次序规则:在一个线程内,按照控制流顺序,控制流书写在前面的操作先行发生于写在后面的操作,这里说的是控制流,而不是代码顺序。
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作,强调的是同一个锁,后面是指时间上的先后。 volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,也就是对 volatile 变量的修改对所有线程可见。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中所有操作都先行发生于其他线程检查到此线程终止的点。也就是说 A 线程中的所有操作都先行发生于 B 线程检测到 A 线程已经终止之前。可以用 Thread::join()方法是否结束,Thread::isAlive() 的返回值来监测线程是否已经终止执行。
  • 线程中断规则:线程对 interrupt() 方法调用先行发生于被中断线程的代码监测到中断事件的发生,可以通过 Thread::interrupted() 的返回值检测到是否由中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。(对于对象,如果对象仅仅实例化完成,没有开始初始化,也可能发生对象终结)。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么就可以得出操作 A 先行发生于操作 C 的结论。

并发编程的三个重要特性?

  • 原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行;在 Java 中,可以借助 synchronized、各种 Lock 以及各种原子类实现原子性。
  • 可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值;在 Java 中,可以借助 synchronized、volatile 以及各种 Lock 实现可见性。
  • 有序性:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。由于单线程的串行语义是可以保证的,对于多线程可以借助 synchronized、volatile 以及各种 Lock 来保证语义的真确性;

volatile 关键字如何保证变量的可见性?

  • 当一个变量被 volatile 修饰,表示这个变量是共享的且不稳定的,因此使用这个变量时每次都要去主存中读取,从而保证修改时对所有线程可见的;
  • volatile 关键字可以保证可见性(和有序性),但是不能保证数据的原子性(例子:多线程自增操作同一个 volatile 变量);

volatile 如何禁止指令重排序?

当我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序。

举例说明禁止指令重排?

比如双重加锁创建单例时,为了保证线程我们需要将静态变量声明为 volatile 修饰的(private volatile static Singleton uniqueInstance;),这是因为 new 关键字在创建对象时会分为三步:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址 如果不禁止指令重排,上面的步骤可能变为:1->3->2从而导致其他线程获取到一个没有初始化完成的对象;

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

JUC 包下面的原子变量类就是使用的乐观锁的一种实现方式 cas 实现的;

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

如何实现乐观锁?

  • 版本号机制:给数据加上一个版本号,每次读取数据的同时读取版本号,修改数据时当版本号与读的时候一致才修改,同时对版本进行更新,否则重新读取数据,并重试修改,直到成功;由于版本号机制存在两步操作,因此需要一些额外的机制保证原子性,比如借助数据库进行update操作;
  • cas算法:比较交换算法,它的实现就是用一个预期的值与要更新的变量比较,如果两个值相等才会进行更新。cas 是一个原子操作,底层依赖于 cpu 的原子指令;

乐观锁存在哪些问题?

  • ABA问题: 当一个变量去取是是 A,进行 cas 操作时也是 A,这不能说明这个变量没有被修改过,有可能存在在读取到修改的间隙中,被修改成 B 后,又修改回了 A,而 cas 不能发现这个变量被修改过,这就是 ABA 问题; 解决方式就是给变量添加一个版本号或者时间戳,AtomicStampedReference 就是用来解决 ABA 问题的。

  • 循环时间长开销大: cas 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。 解决方式一般是限制自旋次数;

  • 只能保证一个共享变量的原子操作: cas 只对单个共享变量有效,当操作涉及跨多个共享变量时 cas 无效。但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 cas 操作,所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

synchronized 是什么?有什么用?

synchronized 是 Java 中的一个关键字,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized。

由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃。

如何使用 synchronized?

  • 修饰实例方法:给当前对象实例加锁;
  • 修饰静态方法:给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前 Class 的锁;
  • 修饰代码块:对括号里指定的对象/类加锁,进入同步代码块之前要获得给定的对象锁/Class 的锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能,如果多个线程使用同样的String常量作为锁对象,其实就是使用了同一个锁对象,这样就会带来多线程安全隐患;

构造方法可以用 synchronized 修饰么?

构造方法不能使用 synchronized 关键字修饰; 构造方法本身就属于线程安全的,不存在同步的构造方法一说;

synchronized 底层原理?

  • synchronized 同步代码块: synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

  • synchronized 修饰方法: synchronized 修饰方法时,使用 ACC_SYNCHRONIZED 标识来指明方法被声明为同步方法,从而执行相应的同步操作;虽然是使用 ACC_SYNCHRONIZED 标识,但本质都是使用 monitorenter 和 monitorexit 指令;

synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在;

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock 是什么?

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

公平锁和非公平锁有什么区别?

公平锁:阻塞等待锁的线程根据申请锁顺序来获取锁; 非公平锁:阻塞等待锁的线程随机获取到锁,非公平锁可能造成线程饥饿;

synchronized 和 ReentrantLock 有什么区别?

  • 两者都是可重入锁 可重入锁也叫递归锁,指的是线程可以再次获取自己的内部锁,如果重复进入不可重入锁会造成线程死锁,因为锁不可重入,该线程会永远阻塞在获取锁的操作上。
  • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API synchronized 依赖于操作系统的互斥量,而 ReentrantLock 是 jdk 层面的实现,需要用 lock、unlock 配合 try/catch 块来完成。 相比 synchronized,ReentrantLock 提供了一些高级功能:
  • 等待可中断:lock.lockInterruptibly();
  • 可以实现公平锁:ReentrantLock(boolean fair);
  • 可实现选择性通知:锁可以绑定多个条件 Condition;

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock 是什么?

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

ReentrantReadWriteLock 适合什么场景?

由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。

共享锁和独占锁有什么区别?

共享锁:一把锁可以被多个线程同时获得,ReentrantReadWriteLock 的 Read 锁是共享锁,所有的读线程共享; 独占锁:一把锁只能被一个线程获得;

线程持有读锁还能获取写锁吗?

在线程持有读锁的情况下,该线程不能取得写锁 在线程持有写锁的情况下,该线程可以继续获取读锁

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。 另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

ThreadLocal 有什么用?

ThreadLocal 用来存储每个线程的私有数据;

ThreadLocal 原理了解吗?

每个 Thread 对象都持有两个 ThreadLocal.ThreadLocalMap,一个叫 threadLocals,另一个叫 inheritableThreadLocals, threadLocals 用来存放线程私有变量, inheritableThreadLocals 用来存放继承自父线程的线程私有变量;

ThreadLocal.ThreadLocalMap 是一个定制化的 map,key 是 ThreadLocal 对象,value 是要设置的值,因此当多个 value 需保存时,可以用多个 ThreadLocal 对象来保存;

Thread 类里的 threadLocals 和 inheritableThreadLocals 只有在 set、get 时才会创建它;

ThreadLocal 的使用流程?

  • 当我们调用 ThreadLocal 对象的 get/set 方法时,会先获取到当前线程;
  • 根据当前线程,获取到它的 ThreadLocal.ThreadLocalMap 成员;
  • 然后以当前 ThreadLocal 作为 key 取获取对应的值;

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocal.ThreadLocalMap 的 key 是对 ThreadLocal 对象的弱引用;对于使用线程池的场景下,线程会被复用,ThreadLocal.ThreadLocalMap 也会一直存在,如果线程的上一个任务结束后,ThreadLocal 对象被回收,且 ThreadLocal.ThreadLocalMap 的 key 作为弱引用,在没有其他强引用的情况下,会被回收变成 null,此时 ThreadLocal.ThreadLocalMap 的 value 不能被回收掉,造成内存泄露;

ThreadLocal 需要定义成 static 类型吗?

理论上来说,ThreadLocal 定义为 ThreadLocal 类型,ThreadLocal.ThreadLocalMap 就只会存在一个 ThreadLocal 的引用,并且 static 类型是 GCRoot 不会被回收,就造成了强引用,每次对 ThreadLocal 设置,都是对同一个 ThreadLocal 设置,不会留下 null key 的 vaule,但是 ThreadLocal 的 set\get\remove 方法都会清理 null key,不会大量的 null key value,所以定不定义成 static 类型都行;

ThreadLocal.ThreadLocalMap 的 key 为什么不用强引用?

如果是强引用,而线程任务执行完后,用户没有手动进行 remove,会导致 ThreadLocal.ThreadLocalMap 存在一个只存在于 ThreadLocal.ThreadLocalMap 的引用,后续清理无法确定哪些数据需要清理掉,从而导致 ThreadLocal.ThreadLocalMap 大量内存泄露。

什么是线程池?

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是归还线程池等待下一个任务。

为什么要用线程池?

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。

如何创建线程池?

  • 推荐通过ThreadPoolExecutor构造函数来创建;
  • 通过 Executor 框架的工具类 Executors 来创建;

通过 Executors 工具类我们可以创建多种类型的 ThreadPoolExecutor:

  • FixedThreadPool:返回一个固定线程数量的线程池,该线程池中的线程数量始终不变,当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务;
  • SingleThreadExecutor:返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务;
  • CachedThreadPool:返回一个可根据实际情况调整线程数量的线程池。初始大小为 0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为 60 秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小;
  • ScheduledThreadPool:返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池;

为什么不推荐使用内置线程池?

  • FixedThreadPool 和 SingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;
  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM;
  • ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM;

线程池常见参数有哪些?如何解释?

  • corePoolSize: 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
  • keepAliveTime: 线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于 corePoolSize ,回收过程才会停止。
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。关于饱和策略下面单独介绍一下。

线程池的饱和策略有哪些?

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy: 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

线程池常用的阻塞队列有哪些?

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  • 容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue(无界队列):FixedThreadPool 和 SingleThreadExector 。FixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
  • SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor 。DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。

线程池处理任务的流程?

  1. 核心线程池是否已满:
    • N -> 创建线程
    • Y -> 判断等待队列
  2. 等待队列是否已满:
    • N -> 加入队列
    • Y -> 判断线最大程池
  3. 最大线程池是否已满:
    • N -> 创建线程
    • Y -> 使用拒绝策略

如何给线程池的线程命名?

  • 自己实现 ThreadFactory,并在创建线程的时候按照规则给线程命名;
  • 借助一些可以给线程命名的三方的线程工厂实现类;

如何设定线程池的大小?

如果线程池太小,如果同一时间有大量的任务/请求需要处理,可能导致大量任务/请求在任务队列中排队,大量任务堆积可能导致 oom; 如果线程池太大,大量线程同时争取 cpu 资源,导致大量的上下文切换,从而增加了线程的执行时间,影响整体的执行效率; 对于通常情况,我们有一个简单且实用的公式:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序或者计算。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

如何动态修改线程池的参数?

个人经验:corePoolSize 核心线程数和 maximumPoolSize 最大线程数,可以提供接直接进行修改,对于 workQueue 工作队列可以使用自定义的队列,在向线程池提交新任务的时候,手动判断和控制 workQueue 工作队列的使用;

自适应线程池思考:可以根据线程池的三个核心参数以及 jvm 内存使用情况,进行动态自适应设置;

如何设计一个能够根据任务的优先级来执行的线程池?

  • 首先是给 ThreadPoolExecutor 指定一个优先级队列比如:PriorityBlockingQueue;
  • 然后传入的任务必须要具备排序的能力,实现的方式有两种:
    • 提交到线程池的任务实现 Comparable 接口,并重写 compareTo 来指定任务之间的比较规则;
    • 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则; 存在的问题:
  • PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 oom;
  • 可能导致饥饿问题,低优先级的任务长时间得不到执行;
  • 由于需要对队列中的元素进行排序操作以及保证线程安全,因此可能降低性能;

如何解决这些问题?

  • 对于请求堆积,可以重写 PriorityBlockingQueue 的 offer 方法,来控制请求的插入;
  • 对于饥饿问题,可以将等待时间过长的任务,增加优先级,并重写添加到队列中;
  • 性能问题没有办法避免,因为要对任务进行排序及保证线程安全操作;

Future 类有什么用?

用来获取异步计算结果,它支持取消任务,判断任务是否被取消,判断任务是否已经执行完成,获取任务执行结果;

Callable 和 Future 有什么关系?

  • Callable 可以作为参数 submit 给 ExecutorService.submit() 并返回结果;
  • ExecutorService.submit() 可以接受 Callable 和 Runnable 作为参数;
  • Callable 可以返回执行结果,Runnable 不会返回执行结果;

CompletableFuture 类有什么用?

由于 Future 不支持异步任务编排以及获取计算结果是阻塞的 get()方法; CompletableFuture 提供了大量的方法用来编排异步请求;

什么是 AQS?

  • AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面;
  • AQS 是一个抽象类,主要用来构建锁和同步器;
  • AQS 为构建锁和同步器提供了一些通用功能的实现,因此使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue等等皆是基于 AQS 的;

AQS 的原理是什么?

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

Semaphore 有什么用?

synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

当初始化的可访问资源线程数为1时,信号量退化为排它锁;

Semaphore 有两种模式:

  • 公平模式: 调用 acquire() 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的,默认是非公平模式

Semaphore 的原理是什么?

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,permits 值可以理解为许可证的数量,只有拿到许可证的线程才能执行;

当调用 semaphore.acquire() 时,线程尝试获取许可证,如果 state > 0, 则表示可以获取成功,获取成功后,使用 cas 取修改 state 值,如果 state - 1 小于 0,表示线程数量不足,此时会创建一个 Node 节点加入阻塞队列,挂起当前线程;

调用 semaphore.release() 时,线程尝试释放许可证,并使用 cas 去修改 state 的值 = state + 1, 释放成功后,同时会唤醒一个同步队列中的线程,被唤醒的线程会去 cas 修改 state 的值,如果修改成功表示获取令牌成功,否则重新加入阻塞队列挂起线程;

CountDownLatch 有什么用?

CountDownLatch 允许 count 个线程阻塞在一个地方(await()的地方),直到所有的线程都执行完毕(countDown()); CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕,它不能再次被使用;

CountDownLatch 的原理是什么?

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count,当线程使用 countDown() 时,使用 cas 取减少 state,直到 state 为 0, 当调用 await() 方法时,如果 state 不为 0 ,表示任务还没有执行完毕, await() 会一直阻塞,直到 count 个线程都调用了 countDown() 使 state 值为 0, 或者 await() 线程被中断,该线程才会从阻塞中被唤醒, await() 后的代码才会被执行;

用过 CountDownLatch 么?什么场景下用的?

比如多个线程同时去取文件,读取完成后才能进行下一步处理; 或者前序有多个线程执行计算,计算完成后,才能进行后续处理;

CountDownLatch 有替代方案吗?

可以使用 java 8 的 CompletableFuture 来替代,因为 CompletableFuture 可以对多个线程的任务进行编排。

CyclicBarrier 有什么用?

CyclicBarrier 和 CountDownLatch 类似,它也可以实现线程的技术等待,但是它比 CountDownLatch 更强大,主要引用场景与 CountDownLatch 类似;

CountDownLatch 是基于 AQS 实现的,而 CyclicBarrier 是基于 ReentrantLock 和 Condition 实现的; ReentrantLock 是基于 AQS 实现的;

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。

CyclicBarrier 的原理是什么?

CyclicBarrier 内部通过一个 count 变量作为计数器, count 的初始值为 parties 属性的初始化值,当一个线程到栅栏里了,就将计数器的值减 1,如果 count 值为 0 了,表示这是最后一个线程到达栅栏,就尝试执行构造方法中输入的任务。 CyclicBarrier 的构造方法除了接收一个 parties 参数,还可以接收一个 runnable 的 action。

使用线程池需要注意些什么?

1. 正确声明线程池: 线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用 Executors 类创建线程池,会有 oom 风险。在实际的使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数,比如核心线程数、使用的队列、饱和策略等。另外还需要显示的给线程池命名,这样有助于我们定位问题。 2. 检测线程池的运行状态: 我们可以通过一些手段来检测线程池的运行状态,比如 Spring boot 的 actuator 组件。也可以利用 ThreadPoolExecutor 的相关 api 来做一个监控,来监控线程池的当前线程数、活跃线程数、已经执行完成的任务数、正在排队的任务数等等。 3. 不同类别的业务用不同的线程池: 一般建议不同的业务使用不同的线程池,配置线程池的时候根据当前业务场景进行配置,因为不同的业务场景并发以及对资源的使用情况不同,重心优化系统性能瓶颈相关业务。 4. 给线程池命名: 初始化线程池的时候要显示的命名,有利于定位问题。命名的方式可以使用 guava 的 ThreadFactoryBuilder 或者自己实现 ThreadFactory。 5. 正确配置线程池参数: 根据实际情况配置线程池参数,对 cpu 密集和 io 密集的任务做出不同的配置。 6. 记得关闭线程池: 如果线程池不在使用时,应该显示的关闭线程池,释放线程资源。shutdown() 方法关闭线程池,线程池不再接受新任务,但是队列里的任务会执行完;shutdownNow() 方法关闭线程池,线程池状态变为 stop。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。

shutdown() 和 shutdownNow() 都是异步通知线程池进行关闭,如果需要同步等待线程池彻底关闭后才继续往下执行,需要调用 awaitTermination 方法进行同步等等。

调用 awaitTermination 时,需要设置合理的超时时间,以避免程序长时间阻塞而导致的性能问题,同时还需要处理 awaitTermination 方法会抛出的异常。 7. 线程池尽量不要放耗时任务: 线程池的目的是为了提高任务的执行效率,避免因频繁创建和销毁线程而带来的性能开销,如果将耗时任务提交到线程池中执行,可能会导致线程池中线程被长时间占用,无法及时响应其他任务,甚至导致线程池崩溃或者程序假死。对于一些比较耗时的操作,比如网络请求、文件读写等,可以采用异步操作(比如CompletableFuture)的方式来进行处理,以避免阻塞线程池中的线程,对于其他类型的耗时操作,还可以考虑定时任务,比如全量扫描数据;也可以创建工作队列,对耗时任务进行排队执行; 8. 不要重复创建线程池: 线程池需要定义为类变量,不要定义为实例变量,从而避免重复创建线程池。 9. Spring 内部使用线程池一定要手动自定义线程池,配置合理的参数: 比如使用异步框架(@EnableAsync)或者是 ForkJoin 框架时,要自定义线程池,否则会出现一个请求一个线程的,导致线程耗尽。 10. 线程和 ThreadLocal 共用时,需要防止线程读取到旧值/脏数据: 如果线程池和 ThreadLocal 共用时,需要防止线程读取到旧值/脏数据。解决这个问题还可以使用阿里巴巴的 TransmittableThreadLocal,TransmittableThreadLocal 继承并加强了 JDK 的 InheritableThreadLocal 类,它提供了在使用线程池的情况下提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。

ConcurrentSkipListMap 跳表

对于单链表来说,如果想要进行数据查找,只能从头到尾变量整个链表,查找效率较低。跳表是一种可以快速查找的数据结构,有点儿类似平衡树。它的查找时间复杂度是 O(logn),它是一个以空间换时间的数据结构。

跳表的最底层是维护了跳表内的所有元素,每上一面一层都是下面一层的子集,跳表内的所有元素都是排序的,查找时可以从顶级链表开始找,一旦被查找的元素大于当前节点值并且小于下一个节点值,就转向下一层链表继续找。

AQS 介绍?

AQS 全称是抽象队列同步器 AbstractQueuedSynchronizer,它位于 java.util.concurrent.locks 包下面。

AQS 是一个抽象类,主要用来构建锁和同步器。

AQS 为构建锁和同步器提供了一些通用功能的实现,因此使用 AQS 能简单高效的构造出应用广泛的大量同步器,比如:ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue 等都是基于 AQS。

AQS 的原理?

AQS 的核心思想是,如果被请求的共享资源空闲,则把当前请求资源的线程设置为有效的工作线程,并把共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,AQS 的这个机制是基于 CLH 锁实现的。

CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列,暂时获取不到锁的线程将会被加入到该队列中, AQS 将每条请求封装成一个 CLH 队列锁的节点来实现锁分配,在CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用、当前节点在队列中的状态、前驱节点、后继节点,当前驱节点的状态是释放锁时,当前节点就可以尝试去获取锁了。

以可重入的互斥锁 ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。

再以倒计时器 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 countDown() 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 state 的值减少 1。当所有的子线程都执行完毕后(即 state 的值变为 0),CountDownLatch 会调用 unpark() 方法,唤醒主线程。这时,主线程就可以从 await() 方法(CountDownLatch 中的await() 方法而非 AQS 中的)返回,继续执行后续的操作。

AQS 资源共享方式?

AQS 定义两种资源共享方式:独占,只有一个线程执行,比如 ReentrantLock;共享,多个线程同时执行,比如 Semaphore / CountDownLatch。

一般来说,自定义同步器要么是独占,要么是共享,他们也只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。但是 AQS 也支持自定义同步器同时实现独占和共享两种方式,比如 ReentrantReadWriteLock。

Semaphore 信号量?

synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,而 Semaphore 可以用来控制同时访问特定资源的线程数量。

比如 Semaphore semaphore = new Semaphore(5); 同时允许 5 个线程访问共享资源,其他阻塞线程只有等到有线程释放了共享资源,它们才能获得。当初始资源个数为 1 时,Semaphore 退化为排他锁。semaphore.acquire(2) 也可以一次申请/释放多个许可,但是一般没有必要这样做。

Semaphore 有两种模式:公平模式,获取锁的顺序遵循 FIFO 原则。非公平模式,抢占式。

使用示例:

public static void main(String[] args) {
    final Semaphore semaphore = new Semaphore(5);
    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            try {
                semaphore.acquire();
                System.out.println("业务逻辑");
                semaphore.release();
            } catch (InterruptedException e) {}
        }).start();
    }
   }

CountDownLatch 倒计时器?

CountDownLatch 允许 Count 个线程阻塞在一个地方,直到所有的任务都执行完毕。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再对其设置值,CountDownLatch 使用完毕后,它不能再次被使用。

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count,当线程调用 countDown() 时,其实使用了 tryReleaseShared 方法 用 CAS 的操作来减少 state,直到 state 为 0。

使用示例:

// 示例1
public static void main(String[] args) throws InterruptedException {
    final CountDownLatch countDownLatch = new CountDownLatch(5);
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            try {
                System.out.println("业务逻辑");
            } finally {
                countDownLatch.countDown();
            }
        }).start();
    }
    countDownLatch.await();
}
// 示例2
public static void main(String[] args) throws InterruptedException {
    final CountDownLatch countDownLatch = new CountDownLatch(1);
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            countDownLatch.await();
            System.out.println("业务逻辑");
        }).start();
    }
    countDownLatch.countDown();
}

CountDownLatch 的两种用法?

  1. 主线程等待 n 个线程执行完毕后,主线程再继续运行。使用方式:主线程开启 n 个线程任务后,执行 countDownLatch.await(),这个 n 个线程每个线程完成后自己的任务后执行一次 countDownLatch.countDown(),当计数器值为 0 时 countDownLatch.await() 被唤醒,主线程继续往下执行。
  2. 主线程做完一些准备工作后, n 个线程开始并行执行。使用方式:首先初始化 count 为 1 的 CountDownLatch(1), 然后主线程开启多个个线程任务,并继续执行准备工作代码,同时每个线程任务执行 countDownLatch.await() 进行阻塞等待,当主线程准备工作代码执行完成后,调用 countDownLatch.countDown(),阻塞的多个线程任务开始执行。

CyclicBarrier 循环栅栏?

CyclicBarrier 循环栅栏与 CountDownLatch 倒计时器非常类似,它的使用场景与 CountDownLatch 类似,但是它能循环使用,而 CountDownLatch 是一次性的。

使用示例:

public static void main(String[] args) {
    final CyclicBarrier barrier = new CyclicBarrier(2);
    for (int i = 0; i < 4; i++) {
        new Thread(() -> {
            try {
                System.out.println("准备开始");
                barrier.await();
                System.out.println("执行完毕");
            } catch (InterruptedException | BrokenBarrierException e) {}
        }).start();
    }
}

Atomic 原子类介绍?

Atomic 原子类位于 juc 包下的 atomic 包里。总共包含四种类型的原子类:

  • 基本类型:
    • AtomicInteger
    • AtomicLong
    • AtomicBoolean
  • 数组类型:
    • AtomicIntegerArray
    • AtomicLongArray
    • AtomicReferenceArray, 引用类型数组原子类
  • 引用类型:
    • AtomicReference 引用类型原子类
    • AtomicMarkableReference 带有标记的引用类型,该类将 boolean 标记与引用关联起来。
    • AtomicStampedReference 带有版本号的引用类型
  • 对象属性修改类型:
    • AtomicIntegerFieldUpdater, 整型字段原子更新
    • AtomicLongFieldUpdater, 长整型字段原子更新
    • AtomicReferenceFieldUpdater, 引用类型原子更新

Atomic 原子类的常见用法?

  • 基本类型原子类的三个类用法基本相同,常用方法:
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
  • 数组类型原子类的三个类使用方法基本相同,常用方法:
public final int get(int i) //获取 index=i 位置元素的值
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
  • 引用类型原子类常用用法:

AtomicReference:

public static void main(String[] args) {
    AtomicReference<Person> reference = new AtomicReference<>();

    Person person1 = new Person();
    person1.name = "张三";
    reference.set(person1);

    Person person2 = new Person();
    person2.name = "李四";
    reference.compareAndSet(person1, person2);
    
    Person person = reference.get();
    System.out.println(person.name);
}

static class Person {
    public String name;
}

AtomicStampedReference:

public static void main(String[] args) {
    String initialRef = "Initial Value";
    int initialStamp = 0;
    AtomicStampedReference<String> reference = new AtomicStampedReference<>(initialRef, initialStamp);

    String newRef = "new Value";
    int newStamp = 1;
    reference.compareAndSet(initialRef, newRef, initialStamp, newStamp);

    System.out.println(reference.getReference());
    System.out.println(reference.getStamp());

    // 获取当前 ref 和 stamp
    int[] array = new int[1];
    String currRef = reference.get(array);
    int currStamp = array[0];
    System.out.println(currRef);
    System.out.println(currStamp);
}

AtomicMarkableReference:

public static void main(String[] args) {
    String initialRef = "Initial Value";
    boolean initialStamp = false;
    AtomicMarkableReference<String> reference = new AtomicMarkableReference<>(initialRef, initialStamp);

    String newRef = "new Value";
    boolean newStamp = true;
    reference.compareAndSet(initialRef, newRef, initialStamp, newStamp);

    System.out.println(reference.getReference());

    // 获取当前 ref 和 stamp
    boolean[] array = new boolean[1];
    String currRef = reference.get(array);
    boolean currStamp = array[0];
    System.out.println(currRef);
    System.out.println(currStamp);
}
  • 对象的属性修改类型原子类
public static void main(String[] args) {
    AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");

    User user = new User("Java", 22);
    System.out.println(a.getAndIncrement(user));
    System.out.println(a.get(user));

}

static class User {
    private String name;
    public volatile int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}