(1)进程和线程的区别是什么?
进程
程序的一次执行过程,系统执行程序的基本单位。进程是动态的,包含创建、运行和消亡的过程。
Java 启动 main 函数就是启动一个 JVM 进程,而 main 函数所在的线程就是该进程当中的一个线程,叫做主线程。
线程
线程与进程类似,但线程是比进程更小的执行单元,一个进程在运行的时候可能存在多个线程。同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈。因此系统在生成一个线程、在不同的线程之间进行切换的时候,系统的压力要比在进程中切换小得多,所以线程也被乘坐轻量级进程。
例如:我打开 QQ,QQ 的线程在运行;而 QQ 中可能存在多个线程:视频、聊天、语音等等
Java 是一个天生的多线程程序,由 main 主线程和其他线程组成。
(2)简要介绍进程和线程的关系、区别以及它们的优缺点?
图解:
一个进程中可以包含多个线程,线程共享进程的堆空间和方法区(1.8之后的元空间),但它也有自己的程序计数器、虚拟机栈和本地方法栈、
进程可以由多个线程组成,它们之间的最大区别是进程之间基本相互独立,而线程之间有可能互相影响。线程的开销小,但不利于资源的管理和保护;进程相反。
程序计数器为什么是线程私有的?
1、字节码解释器通过改变程序计数器来依次读取指令,以此来完成代码的流程控制。
2、当存在多线程的时候,程序计数器用于记录该线程运行到哪个位置,当线程运行的时候知道从哪里开始运行。
线程私有原因:线程切换后知道从哪条指令开始执行
虚拟机栈和本地方法栈为什么是线程私有的?
虚拟机栈:为虚拟机执行 Java 方法服务
本地方法栈:为调用 native 方法服务
在 HotSpot 虚拟机中,虚拟机栈和本地方法栈合二为一。
栈私有的原因:保证线程中的局部变量不会被其他线程所访问。
一句话了解堆和方法区
堆用于存放新创建的对象
方法区用于存放已被加载的类信息、常量、静态变量、及时编译器编译后的代码等
(3)并行和并发的区别?
并发:两个及两个以上的作业在 同一时间段 执行
并行:两个及两个以上的作业在 同一时刻 执行
(4)同步和异步的区别?
同步:发出一个调用后,在没有得到结果前,该调用不可返回,一直等待。
异步:发出一个调用后,不用等待返回结果,该调用可以直接返回。
(5)使用多线程的原因?
计算机底层:线程是轻量级的进程,线程之间的切换消耗资源较少;另外,现在多核 CPU 占主流,多个线程可以同时运行,这减少了线程上下文切换的开销。
互联网发展趋势:现在系统要求百万级甚至千万级并发量,多线程并发编程正是开发高并发系统的基础,利用多线程编程可以大大提高系统整体并发能力和性能。
(6)多线程存在什么问题?
内存泄漏、死锁、线程不安全等
(7)说说线程的生命周期和状态?
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
new:初始状态,线程被创建但没有被 start()
runnable:运行状态,调用了 start()
blocked:阻塞状态,需要等待锁释放
waiting:等待状态,表示该线程需要等待其他线程做出一些特定行为
time_waiting:超时等待,可以在指定的时间后自行返回而非像 waiting 一样一直等待
terminated:终止状态,表明该线程已经运行完毕
在 runnable 状态中 存在 ready 和 running 两种状态;当线程调用了 start 方法后转为 ready 状态,而当线程获得时间片(timeslice)的时候转为 running 状态。(在操作系统层面,线程有 ready 和 running 状态;而在 JVM 层面,线程只有 runnable 状态,一般 Java 系统将这两个状态统称为 runnable 状态)
JVM 为什么没有区分这两种状态呢?
现在的操作系统通常使用时间片抢占式轮转调度。每个线程在 CPU 上运行的时间非常短 10 - 20ms(running),然后就会被放回队尾等待再次调度(ready)。线程切换的很快,区分这两种状态的意义不大。
(8)什么是上下文切换?
线程在运行的时候有自己的运行条件和状态(上下文),当线程让出 CPU 的时候会发生上下文切换。
1、主动让出 CPU,比如调用了 wait sleep 等
2、时间片用完了
3、申请了阻塞资源,例如 IO
4、线程被终止或结束
前三种都会发生线程的切换,线程切换意味着需要保存当前线程的上下文,等待下次占用 CPU 的时候需要恢复该现成的上下文,并加载下一个将要占用 CPU 的线程的上下文。这就是所谓的上下文切换。
(9)什么是线程死锁?如何避免死锁?(经典面试题)
线程死锁
线程 A 持有资源 2,线程 B 持有资源 1;线程 A 等待资源 1,线程 B 等待资源 2。这种情况下出现循环等待而导致没有一个线程可以正常运行,这就是死锁。
产生死锁的四个条件
1、互斥:资源在任意时刻只由一个线程占用
2、请求与保持:一个线程在请求资源的时候,保持已经占用的资源不释放
3、不剥夺:线程占用的资源在未释放前不能被其他线程剥夺
4、循环等待:若干线程之间形成头尾相接的循环等待资源关系
如何预防和避免死锁?
预防死锁,破坏产生死锁的条件即可:
1、破坏请求与保持:一次性申请所有的资源
2、破坏不剥夺:占用部分资源的线程申请其他资源的时候,如果申请不到,则释放已有资源
3、破坏循环等待:按顺序申请资源来预防。按某一顺序申请资源,释放资源的时候则按反序进行。
避免死锁,在资源分配的时候,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态指一个线程序列,按照这个序列运行线程,可以满足线程对资源的最大需求,使得每个线程都能顺利完成。
(10)sleep() 方法和 wait() 方法有什么区别?
它们的共同点:都可以暂停线程的运行。
区别:sleep() 方法不会释放锁,而 wait() 方法会释放锁。
1、sleep() 用于暂停线程执行,而 wait() 用于线程之间的通信
2、sleep() 方法在执行完成后会自动苏醒,而 wait() 方法执行后不会自动苏醒,需要调用同一个对象的 notify() 或者 notifyAll() 方法后才会苏醒。或者使用 wait(long timeout) 超时后也会自动苏醒。
3、sleep() 方法是 Thread 类的静态方法,wait() 是 Object() 类的本地方法。
(11)思考:为什么 wait() 方法被设计在 Object 类中而 sleep() 被设计在 Thread 类
wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象都拥有对象锁,既然要释放对象锁,当时是要操作对应的对象 Object 而不是线程 Thread。
而 sleep() 是让当前线程暂停,不涉及到对象,所以不需要获得对象锁。
(12)可以直接调用 Thread 类的 run 方法吗?(经典)
new 一个 Thread,线程进入 new 状态;调用 start() 方法,线程进入 runnable 状态,等 CPU 分配到时间片就可以开始运行了。在调用 start() 方法的时候,会自动执行 run() 方法的内容,这是多线程的情况。当直接调用 run() 方法,会把 run() 方法当做 main 线程下的一个普通方法去执行,而不会在某个线程中执行,所以不是多线程工作。
(13)JMM (Java Memory Model)Java 内存模型
JMM 内存模型详解
CPU 缓存模型(用于介绍内存模型的作用)
缓存就是为了解决速度不对等的问题。对于 CPU 而言,缓存是为了解决 CPU 处理速度 和 内存处理速度 不对等的情况。
内存缓存是为了解决 内存处理速度 和 硬盘处理速度不对等的情况。
现代 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。
CPU Cache 的工作方式:先复制一份数据到 CPU Cache 中,当需要使用这部分数据的时候就直接将其拿出进行使用,当运算完成后,将得到的数据写回 Main Memory 中,但是这存在 内存缓存不一致性的情况。
对于这种情况,我们可以采用缓存一致协议来解决(例如 MESI协议),这个协议也就是 CPU 缓存与主存交换数据的时候需要遵守的一系列规则。
CPU Cache 图解(图源Javaguide 侵删):
我们的程序运行在操作系统上,操作系统屏蔽了底层的硬件,将各种硬件资源虚拟化。于是操作系统也需要解决这种缓存内存不一致的情况。
操作系统通过 内存模型(Memory Model)定义一系列规范来解决这个问题。
指令重排序(为了实现多线程而对此进行部分禁止)
指令重排序意思就是说在运行程序的时候,指令执行的顺序不一定与你代码中指令的顺序一致。编译器可能为了提高性能而对指令进行重排序。
常见的指令重排序有以下两种情况:
1、编译器优化重排:编译器(JVM JIT)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
2、指令并行重排:利用指令级并行技术将多个指令并行执行,如果指令之前没有数据依赖,则可以改变其机器指令的运行顺序。
3、内存系统重排:由于处理使用缓存和读写缓冲区,所以它们是乱序的
Java 源代码会经过 编译器优化重排 - 指令并行重排 - 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但不保证多线程间的语义也一直,所以在多线程下指令重排序可能存在一些问题。
编译器和处理器对于指令重排序的处理不同。对于编译器,可以通过禁止特定类型的编译器来禁止重排序;对于处理器,需要插入内存栅栏(Memory Fence)来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于处理器级别的指令重排序。
JMM (Java Memory Model) Java 内存模型
上面我们说到,操作系统存在内存模型以处理 CPU Cache 存在的不一致问题。而对于 Java 而言,我们不能简单的套用操作系统的内存模型,因为 Java 旨在跨平台运行,所以我们需要一个 Java 自己的内存模型以适应该情况。
这只是 JMM 存在的一个原因。另外,对于 Java 而言,你可以把 JMM 看作是 Java 定义的并发编程相关的一套规范,它抽象了线程和主内存之间的关系,规定了 Java 源代码到 CPU 可执行程序的转换过程需要遵循的原则和规范,其目的是为了简化多线程编程,增强程序的可移植性。
在指令重排序中,因为其不保证多线程语义一致,所以 JMM 中抽象了 happens-before 原则来解决这个问题。
总而言之,就是 Java 为了解决本地内存和主存 以及 并发多线程的一系列问题,而定义出了一系列的规范(JMM)来解决这些问题。对于开发者而言,你不需要了解底层实现细节,你只需要遵守这些规范,使用并发相关的关键字和类,就可以开发出并发安全的程序啦~
JMM 是如何抽象线程和主内存之间的关系的?
在当前的 Java 内存模型下,线程可以把变量保存在 本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中对变量进行修改,但另一个线程仍然使用的是本地内存中的变量,造成数据的不一致。(与 CPU 缓存模型类似)
主存:所有线程创建的实例对象都保存在主内存中。
本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程 读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,并不真实存在。
Java 内存区域和 JMM 有什么区别?
Java 内存区域主要用于定义 JVM 在运行的时候如何分区存储数据,例如堆中存放对象实例。
JMM 是 Java 内存模型,它抽象了线程和主内存之间的关系,规定了 Java 源代码到 CPU 可执行指令的转换过程要遵循哪些规范。其主要目的是为了简化多线程编程,增强程序可移植性。
happens-before 原则是什么?
JMM 对 happens-before 原则进行了抽象,它的设计思想是:
1、对编译器和处理器的约束尽可能少。只要不改变程序最终运行的结构,编译器和处理器可以尽可能进行指令重排序。
2、对于可能对程序运行结果产生影响的指令重排序,JMM 要求编译器和处理器对这种重排序进行禁止。
图源 《Java 并发编程的艺术》:
happens-before 原则的定义:
1、如果一个操作 happens-before 另一个操作,那么第一个操作的结果应当对第二个操作是可见的,并且第一个操作的执行顺序先于第二个操作。
2、两个操作之间存在 happens-before 关系,如果重排序之后的执行结果和 happens-before 顺序的执行结果一致,那么 JMM 同样允许这种重排序。
happens-before 常见规则有哪些?谈谈你的理解。
happens-before 和 JMM 的关系是什么?
(14)并发编程的三个重要特征
原子性
一段代码,要么全部执行,要么全部不执行。
为了实现代码的原子性,在 Java 中可以借助 synchronized 或者各种 Lock 以及各种原子类来实现原子性。
synchronized 和 Lock 是通过加锁,使得同一时间只有一个线程访问该代码块,保障原子性。
原子类是利用 CAS(compare and swap)操作来保证原子性。
可见性
当一个线程对共享变量进行修改,其他的线程对该共享变量都是可见的(立即可以看到)。
在 Java 中,可以借助 synchronized、volatile 以及各种 Lock 实现可见性。
如果变量声明为 volatile,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
有序性
代码的执行顺序未必是代码的编写顺序(指令重排序)
在 Java 中,volatile 可以禁止指令进行重排序优化。
(15)volatile 关键字
如何保证变量的可见性?
Java 中,volatile 关键字可以保证变量的可见性。如果一个变量被 volatile 修饰,那么每次说明这个变量是共享且不稳定的,每次访问它都需要到主存中去访问才可以。
volatile 并非 Java 中特有的,它在 C 语言中表明禁用 CPU 缓存,这与我们前面讲到的 JMM 类似。
volatile 可以保证数据的可见性,但是不能保证数据的原子性。synchronized 关键字则可以保证这两者。
如何禁止指令重排序?
在 Java 中,volatile 除了可以保证数据的可见性,另外一个作用就是禁用指令重排序。如果我们将变量声明为 volatile,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
常见面试题:
uniqueInstance 采用 volatile 关键字进行修饰,uniqueInstance = new Singleton();
这段代码分三步执行:
1、为 uniqueInstance 分配内存空间
2、初始化 uniqueInstance
3、将 uniqueInstance 指向分配的内存地址
如果不使用 volatile 进行修饰,在单线程环境下不会出现问题;而在多线程环境下,可能存在指令重排序,将顺序改为1 3 2,这就有可能导致线程获得尚未初始化的实例。
volatile 可以保证原子性吗?
答:可以保证可见性,不能保证原子性。
例如:
自增操作 inc++
它分为三步:
1、读取 inc 的值
2、将 inc 自增
3、将 inc 的值写回内存
有 5 个线程进行了 500 次操作,如果能保证原子性,那么 inc 的值应该为 2500;但实际上,可能会存在线程 1 对 inc 进行操作后,还没有来得及写入内存,线程 2 也读取了 inc 的 值进行自增,写回 inc 到内存中。这样的操作实际上只让 inc 增加了 1。
可以使用 synchronized 对代码进行改进:
ReentrantLock:
(16)synchronized 关键字
synchronized 在中文中是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
Java 6 之后,Java 官方对 JVM 层面对 synchronized 进行了较大优化,所以现在它的锁效率也很不错。JDK 1.6 对锁的实现引入了大量优化,如自旋锁、适应性自旋锁、锁消除、锁粗话、偏向锁、轻量级锁等技术来减少锁操作的开销。
如何使用 synchronized 关键字?
最主要的三种使用方式:
1、修饰实例方法(锁当前对象实例)
2、修饰静态方法(锁当前类)
给当前类加锁,会作用与当前类的所有对象实例。
静态 synchronized 方法和 非静态 synchronized 方法的调用并不互斥。
3、修饰代码块
对括号内指定的对象 / 类 加锁
尽量不是使用 synchronized(String str),因为在 JVM 中,字符串常量池具有缓冲作用。
构造方法可以使用 synchronized 进行修饰吗?
构造方法不能使用 synchronized 进行修饰,它本身就是线程安全的。
synchronized 关键字的底层原理是什么?
synchronized 底层原理属于 JVM 层面的知识。
synchronized 同步代码块,在开始位置会执行 monitorenter 指令,在结束位置会执行 monitorexit 指令。而 monitorenter 指令执行时,线程试图获取锁也就是 对象监视器 monitor 的持有权。
在执行 monitorenter 的时候,会尝试获取对象锁,如果锁计数为 0 则可以获取,获取后将计数器设置为1。
在 Java 虚拟机中,每个对象内置了一个 ObjectMonitor 对象,它底层是 C++ 实现的。
另外 wait / notify 方法依赖于 Monitor 对象,所以只有在同步的块或者方法中才能调用 wait / notify 方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常。
获取锁流程:
释放锁流程:
synchronized 修饰方法的情况
synchronized 修饰方法没有 monitorenter 和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明该方法是一个同步方法。JVM 通过该标识来辨别一个方法是否是同步方法。
如果该方法是一个实例方法,则获取对象实例锁;如果该方法是一个静态方法,则获取当前 class 的锁。
(17)JDK 1.6 之后 synchronized 关键字底层做了哪些优化?
JDK1.6 对锁的实现引⼊了⼤量的优化,如偏向锁、轻量级锁、⾃旋锁、适应性⾃旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。需要注意的是锁可以升级但是不可以降级,这种策略是为了提高获得锁和释放锁的效率。
Java6及以上版本对synchronized的优化 - 蜗牛大师 - 博客园 (cnblogs.com)
(18)synchronized 和 volatile 的区别?
synchronized 和 volatile 是互补而不是对立的存在
1、volatile 是轻量级锁,性能比 synchronized 更好,但 volatile 只能修饰变量,而 synchronized 可以修饰方法和代码块。
2、volatile 可以保证数据的可见性,不能保证原子性。而 synchronized 可以保证数据的原子性。
3、volatile 关键字主要用于保证变量在多个线程之间的可见性,而 synchronized 解决的是多线程之间访问资源的同步性。
(19)synchronized 和 ReentrantLock 之间的区别?
1、synchronized 是依赖于 JVM 但是 ReentrantLock 是依赖 API 的。
synchronized 关键字在 1.6 的时候进行了很多优化,但这些优化都是在虚拟机层面进行的,并没有把细节暴露给我们;而 ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try / finally 语句块来完成),所以我们可以通过查看它的源代码来看它是如何实现的。
2、两者都是可重入锁
可重入锁 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,这个对象锁尚未释放的时候,再次想要获取这个对象的锁的时候依然可以获取,如果是不可重入锁的话会产生死锁。同一个线程每次获取锁,计数器 +1,等到锁的计数器为0的时候才释放锁。
3、ReentrantLock 比 synchronized 多的功能
(1)等待可中断:提供了一种能够中断等待锁的线程的机制。通过 lock.lockInterruptibly() 实现
(2)公平锁:公平锁即先等待的线程先获得锁。synchronized 只能是非公平锁。
(3)可实现选择性通知:synchronized 和 wait() / notify() 方法相结合可以实现等待/通知机制。ReentrantLock 通过 Condition 接口 和 newCondition() 方法。
(20)ThreadLocal
ThreadLocal 的作用是什么?
通常情况下,我们创建的变量可以被所有线程所共享。但如果我们希望部分变量为线程私有的,我们可以使用 ThreadLocal 类。
ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象比喻为存放数据的盒子,盒子中可以存储每个线程的私有数据。对于每个访问这个变量的线程,它会在自己的线程中保存一份这个变量的本地副本,可以使用 get() 和 set() 方法来获取默认值或将其更改为当前线程副本中的值,避免线程安全问题。
如何使用 ThreadLocal?
(21)线程池的工作原理
常见的对比:
Runnable vs Callable
Runnable 接口在 1.0 中就存在,而 Callable 在 1.5 中被引进。原因是 Runnable 接口没有返回值并且不会抛出异常,但是 Callable 可以。所以如果代码不需要返回值或抛出异常时,选择 Runnable 使代码更简洁。
execute() vs submit()
其他
线程池创建时的一些参数
线程池在创建的时候有两种方法,一种是使用 ThreadPoolExecutor
的构造函数;
另外一种是使用 Executor
框架的工具类 Executors
来进行创建。
在《阿里巴巴 Java 开发手册》强制线程池不允许使用第二种方法去创建线程池,这这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
三个最重要的参数:
1、int corePoolSize 任务队列未达到队列容量时,最大可以同时运行的线程数量。
2、int maximumPoolSize 任务队列达到队列容量时,最大可以同时运行的线程数量。
3、BlockingQueue<Runnable> workQueue 先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。(这是被存放的队列)
其他参数:
1、long keepAliveTime 当线程池的线程数大于 corePoolSize 时,不会直接销毁多余的线程,而是在其存活时间超过 keepAliveTime 时才销毁
2、TimeUnit unit keepAliveTime 的时间单位
3、ThreadFactory threadFactory 线程工厂
4、RejectedExecutionHandler handler 拒绝策略,在提交的任务过多的时候根据该策略进行行动
(22)AQS(AbstractQueuedSynchronizer 抽象队列同步器)
AQS 是一个抽象类,在它下面简单且高效地构建出了例如 ReentrantLock 、 Semaphore,其他的诸如 ReentrantReadWriteLock 等等都是基于 AQS 实现的。
AQS 原理 核心思想
如果请求的资源是空闲的,那么将请求该资源的线程设置为工作线程,并且将共享资源设置为被占用;如果请求的资源被占用,那么我们需要一套线程阻塞机制和被唤醒分配锁的机制,这个机制是基于 CLH 锁实现的。
* CLH (Craig, Landin, and Hagersten locks)锁
CLH 锁是 AQS 的核心数据结构
1、自旋锁
自旋锁 的 Java 实现
如果锁未被占用,则设置当前线程为锁的拥有者,获取锁时,线程会对一个原子变量循环执行 compareAndSet 方法,直到该方法返回成功时即为成功获取锁。compareAndSet 方法底层是用 compare-and-swap (CAS) 实现的。该操作通过将内存中的值与指定数据进行比较,当这两者相同的时候,将内存中的数据替换为新的值。这个操作是一个原子操作,原子性保证了根据最新信息计算出新值,如果与此同时值已经由另外一个线程进行更新,那么写入失败。这段代码可以实现互斥锁的功能。
自旋锁的优点:
1、实现简单
2、避免操作系统进程调度和上下文切换的开销
自旋锁的缺点:
1、锁饥饿问题,在锁竞争激烈的情况下,可能存在一个线程一直被其他线程插队而获取不到锁的情况。
2、性能问题,自旋锁在锁竞争激烈时性能较差。
自旋锁的性能和理想情况相距甚远。这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率。(CPU底层知识)
自旋锁适用于锁竞争不激烈、锁持有时间短的场景。
2、CLH 锁
CLH 锁是对 自旋锁的一种改进,有效的解决了以上两个缺点(锁饥饿、性能问题)。首先它将线程组织成一个队列,先请求的线程先获得锁,避免锁饥饿问题;第二,锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁的时候,只能使其后续线程的高速缓存失效,缩小了影响范围,减小了 CPU 的开销。
CLH 数据结构:类似一个链表队列,所有请求锁的线程会被放在队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁的时候,只有它的后一个节点会得到锁。CLH 锁本身有一个队尾指针 Tail,它是一个原子变量,指向队列最末尾的 CLH 节点。每个 CLH 节点有两个属性,一个是它代表的线程,另一个是标识是否持有锁。
当一个线程需要获取锁的时候,对 tail 节点进行一个 getAndSet 的原子操作,这个操作会返回 tail 当前指向的节点,然后使 tail 指向新节点。
1、初始队列为空
2、线程 T1 请求锁,对 tail 指向的节点进行 getAndSet 操作,返回值为空节点。T1 发现返回节点的状态变量为 false,成功获取到锁,进行相应逻辑。
3、线程 T2 请求锁,对 tail 指向的节点进行 getAndSet 操作,返回值为 T1 节点。T2 发现返回节点的状态变量为 true,说明锁正在被占用,最后使 tail 指向 T2。
4、T1 释放锁时,将状态变量设置为 false。
5、T2 轮询到上一个节点状态为 false,获取锁成功,进入5。
CLH 锁的 Java 实现
在并发编程领域,“细节是魔鬼”。
自定义互斥锁需要保证这一规则的成立:happens-before 一个监视器锁上的解锁发生在该监视器锁的后续锁定之前。上述代码通过 volatile 关键字来解决重排序问题。
CLH 锁是一个链表队列,但是 Node 节点中并没有指向前驱或者后继指针,原因是 CLH 锁是一种隐式的链表队列,并不会显式地维护前驱和后继。因为每个等待获取锁的线程只需要轮询前一个节点的状态就足够了,不需要遍历整个队列。这种情况下,只需要一个局部变量来保存前驱节点即可。
最后 红框中 这段代码,它是为了避免 Node 复用而导致的死锁问题。
如果没有这段代码,Node 可能被复用,导致死锁。
当 T1 释放锁,但是 T2 尚未抢占到锁;此时 T1 再次调用 lock() 请求获取锁,将状态设置为 True,但是它的前驱节点为 T2,会自旋等待 T2 释放锁;而 T2 等待 T1 释放锁,此时造成了死锁。因此需要这行代码生成新的 Node 节点,避免 Node 节点复用带来的死锁问题。
CLH 的优点:
1、性能好
2、公平锁
3、实现简单
4、拓展性强
CLH 的缺点:
1、自旋操作带来的 CPU 开销
2、基本的 CLH 锁功能单一,不改造不能支持复杂的功能
AQS 对 CLH 进行的改造
针对 CLH 锁的缺点,AQS 对此进行了改造。
针对第一个缺点,AQS 将自旋操作修改为阻塞线程操作;
针对第二个缺点,AQS 对 CLH 锁进行改造和拓展。
AQS 中对 CLH 锁数据结构的改进主要包括三方面:
1、拓展每个节点的状态
2、显式维护前驱节点和后继节点
3、出队节点显式设为 null 等辅助 GC 优化
拓展每个节点的状态
volatile int waitStatus;
AQS 提供了对该状态变量的原子读写,不同的是,节点状态在 AQS 中被清晰定义,如下表所示:
状态名 | 描述 |
SIGNAL | 表示该节点正常等待 |
PROPAGATE | 应将 releaseShared 传播到其他节点 |
CONDITION | 该节点位于条件队列,不能用于同步队列节点 |
CANCELLED | 由于超时、中断或其他原因,该节点被取消 |
显式维护前驱和后继节点
CLH 中,它只是一个虚拟队列;通过显式维护前驱和后继节点,CLH 锁就可以处理“超时”和各种形式的“取消”。在 AQS 的实现稍有不同,因为 AQS 使用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息,需要释放锁的节点通知下一个节点解除阻塞。
由于没有针对双向队列的原子性的 getAndSet 操作,因此后继节点的设置并非作为原子性插入操作的一部分,而仅是在节点被插入后简单赋值。在释放时,如果当前节点的后驱节点不可用,那么利用 tail 从尾部遍历找到正确的后驱节点。
辅助 GC
JVM 垃圾回收机制无需开发者手动释放对象,但在 AQS 中需要在释放锁的时候显式地将节点设置为 null,避免引用的残留,辅助垃圾回收。
AQS 核心原理图
AQS 定义了两种资源共享方式:Exclusive(独占,只有一个线程能执行,如 ReentrantLock);Share(共享,多个线程可同时执行,如 Semaphore / CountDownLatch)。
一般来说,自定义同步器要么独占要么共享;它们只需要实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种模式,如 ReentrantReadWriteLock。
自定义同步器
AQS 使用了模板方法模式,自定义同步器时需要重写下面的几个 AQS 提供的 钩子方法:
//独占方式。尝试获取资源,成功则返回true,失败则返回false。
protected boolean tryAcquire(int)
//独占方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryRelease(int)
//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected int tryAcquireShared(int)
//共享方式。尝试释放资源,成功则返回true,失败则返回false。
protected boolean tryReleaseShared(int)
//该线程是否正在独占资源。只有用到condition才需要去实现它。
protected boolean isHeldExclusively()
钩子方法:声明在抽象类中,使用 protected 进行修饰,可以是空方法,也可以有默认实现。模板设计模式通过钩子方法控制固定步骤的实现。
AQS 中,除了上述几个方法,其他方法都是 final 修饰的,无法被子类重写。
常见的同步工具类
1、Semaphore(信号量)
Semaphore 信号量,它可以控制同一时间访问共享资源的线程数。
使用方法:
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
这里设置最多只能有 5 个线程同时访问共享资源,其他线程都会阻塞。只有当访问共享资源的线程释放资源,阻塞的线程才能获取。
当信号量设置为 1,Semaphore 退化为排他锁。
Semaphore 有两种模式:
(1)公平模式:调用 acquire() 方法的顺序就是获取许可证的顺序,FIFO。
(2)非公平模式:抢占模式
对应的两个构造方法(默认非公平):
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
Semaphore 用于那些资源有明确访问数量限制的场景比如限流(仅限于单机,实际项目中使用 Redis + Lua 来做限流)。
Semaphore 默认构造 AQS 的 state 为 permits(许可证)。当执行任务的线程数超过 permits,多余的线程会被放入等待队列 Park,并自旋判断 state 是否大于0。
2、CountDownLatch(倒计时器)
CountDownLatch 允许 count 个线程全部阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch 是一次性的,计数器的值只能在构造器中被初始化一次,之后没有任何方法可以设置它的值,当 CountDownLatch 使用完毕后,它不能被再次使用。
原理:
CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 为 count。构造方法:
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) {
setState(count);
}
//...
}
当线程调用 countDown() 方法时,实际上是调用 tryReleaseShared 方法以 CAS 的操作来使 count 减少。当 count 为0的时候,说明所有线程都已经执行完毕(都调用了 countDown 方法),那么在 CountDownLatch 上等待的线程就会被唤醒并继续执行。
public void countDown() {
// Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer
sync.releaseShared(1);
}
releaseShared 方法是 AQS 中的默认实现:
// 释放共享锁
// 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。
public final boolean releaseShared(int arg) {
//释放共享锁
if (tryReleaseShared(arg)) {
//释放当前节点的后置等待节点
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared 方法是 CountDownLatch 内部类 Sync 重写的一个方法,AQS 中的默认实现仅仅抛出一个 UnsupportedOperationException 异常:
// 对 state 进行递减,直到 state 变成 0;
// 只有 count 递减到 0 时,countDown 才会返回 true
protected boolean tryReleaseShared(int releases) {
// 自选检查 state 是否为 0
for (;;) {
int c = getState();
// 如果 state 已经是 0 了,直接返回 false
if (c == 0)
return false;
// 对 state 进行递减
int nextc = c-1;
// CAS 操作更新 state 的值
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
compareAndSet(CAS 操作)来保证原子性。
以无参 await() 方法为例,调用该方法时,如果 state 不为0,那么该方法后面所有的语句都不会被执行(main 线程被加入到等待队列中,也就是 CLH 队列中去了)。此后会一直 CAS 检查 state == 0,如果 state == 0,那么所有阻塞的线程竣备释放,await() 后的语句就可以执行了。
// 等待(也可以叫做加锁)
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// 带有超时时间的等待
public boolean await(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
acquireSharedInterruptibly 方法是 AQS 中的默认实现。
// 尝试获取锁,获取成功则返回,失败则加入等待队列,挂起线程
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 尝试获得锁,获取成功则返回
if (tryAcquireShared(arg) < 0)
// 获取失败加入等待队列,挂起线程
doAcquireSharedInterruptibly(arg);
}
tryAcquireShared 方法是 CountDownLatch 内部类 Sync 重写的方法,它的目的就是判断 state 是否为0,是返回1,不是返回-1。
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
CountDownLatch 的两种用法:
1、某一线程开启前需要其他 N 个线程执行完毕
2、实现多个线程开始执行任务的最大并行性:注意是并发性,不是并发;强调的是多个线程在同一时间开始执行,类似于赛跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器设置为1,多个线程在开始执行任务前先 countDownLatch.await(),当主线程调用 countDown() 时,计数器变为0,所有线程被唤醒。
3、CyclicBarrier(循环栅栏)
CyclicBarrier 和 CountDownLatch 类似,都可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更复杂和强大。主要应用场景和 CountDownLatch 类似。
CountDownLatch 基于 AQS 实现;CyclicBarrier 基于 ReentrantLock 实现(但是 ReentrantLock 也是基于 AQS 实现的)和 Condition 实现的。
CyclicBarrier 意思是循环栅栏,只有当最后一个线程到达屏障的时候,屏障才会开门,所有被屏障拦截的线程才会继续工作。