多线程基础
同步、异步、阻塞、非阻塞
- 同步:在并发环境下,保持操作之间的偏序关系的行为。进程同步是指多个进程中发生的事件存在某种时序关系,必须协同动作共同完成一个任务。简单来说就是一种协作关系。同步亦称直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递消息所产生的制约关系。
- 异步:是指进程以不可预知的速度向前推进。在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系(一般是资源问题)。内存中的每个进程何时执行,何时暂停,以怎样的速度向前推进,程序总共需要多少时间才能完成等,都是不可以预知的。
- 阻塞和非阻塞,这个当时就说,阻塞是由于条件不满足而无法继续向下运行
线程安全问题(出现、乱序)
为什么会出现线程安全问题
-
计算机系统资源分配的单位为进程,同一个进程中允许多个线程并发执行,并且多个线程会共享进程范围内的资源:例如内存地址
-
当多个线程并发访问同一个内存地址并且内存地址保存的值是可变的时候可能会发生线程安全问题,因此需要内存数据共享机制来保证线程安全问题
线程为什么会出现乱序
- 处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致
- 如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证
方法内部的声明的局部变量是否有线程安全的问题
- Java方法里面的局部变量是不存在并发问题的
- 每个线程都有自己独立的调用栈,局部变量保存在各自的调用栈中,不会被共享,自然也就没有并发问题
线程的声明周期和状态
- NEW:初始状态,线程被创建,但是还没有调用 start()方法
- RUNNABLE:运行状态,就绪和运行两种状态笼统称为 “运行中”
- BLOCKED: 阻塞状态
- WAITTING:等待状态, 当前线程需要等待其他线程做出一些特定动作(通知或中断)
- TIME_WAITING: 超时等待状态 , 不用于WAITING,可以在指定的时间自行返回
- TERMINATED:终止状态, 当前线程已经自行完毕
-
线程创建之后它将处于 初始状态,调用 start()方法后开始运行,线程这时候处于可运行状态,可运行状态的线程获得了 CPU 时间片后就处于 运行状态
-
当线程执行 wait() ⽅法之后,线程进⼊ 等待状态
-
进⼊等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 超时等待状态 相当于在 等待状态 的基础上增加了超时限制,比如通过 sleep(long millis) ⽅法或 wait(long millis) ⽅法可以将Java 线程置于 超时等待状态 。当超时时间到达后 Java 线程将会返回到 运行状态
-
当线程调⽤同步⽅法时,在没有获取到锁的情况下,线程将会进⼊到 阻塞状态
-
线程在执⾏ Runnable 的 run() ⽅法之后将会进⼊到 终⽌状态
多线程创建方式
继承Thread类并重写 run ()
- 在run()内可以使用 this 直接获取当前线程。
- 继承方式的好处是 方便传参,可以在子类里面 添加成员变量,通过 set() 设置参数 或者通过 构造函数 进行传递
- Java不支持多继承,如果继承了Thread类,那么就不能再继承其他类
- 另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码,而Runnable则没有这个限制
实现Runnable接口的run()
- 与 继承Thread类并重写run() 不同,保持了扩展性,可以继承其他类
- 共同的缺点 : 就是任务没有返回值
使用FutureTask和Callable接口
- 首先创建一个类实现 Callable接口 的 Call()
- 创建一个 FutureTask 对象,构造函数为刚才实现 Callable接口的 Call() 的 类
- 将FutureTask对象作为 传入 Thread 的构造函数 并 启动线程
- 调用 FutureTask对象的 get() 来获取子线程执行结束的返回值
start()和run()
-
Thread 类也是实现了 Runnable 接口, 而 Runnable 接口定义了唯一的一个 run() 方法,所以基于 Thread 和 Runnable 创建多线程都需要实现 run() 方法,是多线程真正运行的主方法
-
sart() 方法则是 Thread 类的方法,用来异步启动一个线程,然后主线程立刻返回。该启动的线程不会马上运行,会放到等待队列中等待 CPU 调度,只有线程真正被 CPU 调度时才会调用 run() 方法执行
-
所以 start() 方法只是标识线程为就绪状态的一个附加方法,start() 方法被标识为 synchronized 的,即为了防止被多次启动的一个同步操作
-
如果直接调用 run() 方法,那就等于调用了一个普通的同步方法,达不到多线程运行的异步执行
sleep()和wait()
- **sleep()**没有释放锁,**wait()**释放了锁
- sleep()通常被用于暂停执行,wait()通常被用于线程间通信
- sleep()执行完成后,线程会自动苏醒
- wait()被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或者notifyAll()
- 如果是wait(long timeout)超时后线程会自动苏醒
wait() notify() notifyAll()
wait():
当一个线程调用一个共享变量的wait() 方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:
-
其他线程调用了该共享对象的notify()或者notifyAll()方法
-
其他线程调用了该线程的 interrupt()方法,该线程抛出InterruptedException异常返回
-
如果调用wait()方法的线程 没有事先获取该对象的监视器锁,则调用wait()方法时调用线程会抛出IllegalMonitorStateException异常
wait(long timeout) :
- 如果一个线程调用共享对象的该方法挂起后,没有在指定的 timeout ms 时间内被其他线程调用该共享变量的**notify()或者notifyAll()**方法唤醒,那么该函数还是会因为超时而返回
- 如果将 timeout 设置为 0 则和 wait 方法效果一样,因为在wait方法内部就是调用了 wait(0)
- 如果在调用该函数时,传递了一个 负的timeout 则会抛出 IllegalArgumentException 异常
wait(long timeout, int nanos) :
- 在其内部调用的是wait(long timeout)函数,只有在nanos>0时才使参数timeout递增1
notify() :
- 一个线程调用共享对象的notify()方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的
- 唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行
- wait系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify()方法,否则会抛出IllegalMonitorStateException异常
notifyAll():
- notifyAll()方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程
死锁
认识线程死锁
死锁是指 两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象。在无外力的情况下,这些线程会一直相互等待
产生死锁的四个必然条件
- 互斥条件:该资源同时只能由一个线程占用。如果有其他线程请求获取该资源只能等待占有的线程释放资源
- 不剥夺条件:线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源
- 请求与保持条件:一个线程在至少持有一个资源的情况下又请求别的资源,而请求的别的资源被其他线程占有,则当前线程被阻塞的同时又不能释放持有的资源
- 环路等待条件:在发生死锁时,必然存在线程请求资源的环形链,即线程集合{T0, T1, T2, …, Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源
避免线程死锁
- 要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可
- 目前只有 请求并持有 和 环路等待条件 是可以被破坏的
死锁的例子
public class DeadLockTest {
// 创建资源
private static Object resourceA = new Object();
private static Object resourceB = new Object();
public static void main(String[] args) {
// 创建线程A
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceA){
System.out.println(Thread.currentThread() + " get A");
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " waiting get B");
synchronized (resourceB){
System.out.println(Thread.currentThread() + " get B");
}
}
}
});
// 创建线程B
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceB){
System.out.println(Thread.currentThread() + " get B");
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " waiting get A");
synchronized (resourceA){
System.out.println(Thread.currentThread() + " get A");
}
}
}
});
// 启动线程
threadA.start();
threadB.start();
}
}
本例是如何满足死锁的四个条件的:
- resourceA 和 resourceB都是互斥资源,当线程A调用synchronized(resourceA)方法获取到resourceA上的监视器锁并释放前,线程B再调用synchronized(resourceA)方法尝试获取该资源会被阻塞,只有线程A主动释放该锁,线程B才能获得,这满足了资源互斥条件
- 线程A首先通过synchronized(resourceA)方法获取到resourceA上的监视器锁资源,然后通过synchronized(resourceB)方法等待获取resourceB上的监视器锁资源,这就构成了请求并持有条件
- 线程A在获取resourceA上的监视器锁资源后,该资源不会被线程B掠夺走,只有线程A自己主动释放resourceA资源时,它才会放弃对该资源的持有权,这构成了资源的不可剥夺条件
- 线程A持有objectA资源并等待获取objectB资源,而线程B持有objectB资源并等待objectA资源,这构成了环路等待条件。所以线程A和线程B就进入了死锁状态
造成死锁的原因其实和 申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁
- 让在线程B中获取资源的顺序和在线程A中获取资源的顺序保持一致
- 资源分配有序性 :假如线程A和线程B都需要资源1,2,3, …, n时,对资源进行排序,线程A和线程B只有在获取了资源n-1时才能去获取资源n
- 假如线程A和线程B同时执行到了synchronized(resourceA),只有一个线程可以获取到resourceA上的监视器锁,假如线程A获取到了,那么线程B就会被阻塞而不会再去获取资源B
- 线程A获取到resourceA的监视器锁后会去申请resourceB的监视器锁资源,这时候线程A是可以获取到的,线程A获取到resourceB资源并使用后会放弃对资源resourceB的持有,然后再释放对resourceA的持有,释放resourceA后线程B才会被从阻塞状态变为激活状态
- 所以资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此避免了死锁
//创建线程B
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceA){
System.out.println(Thread.currentThread() + " get A");
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " waiting get B");
synchronized (resourceB){
System.out.println(Thread.currentThread() + " get B");
}
}
}
});
手撕代码
两个线程交替打印 A B
public class PrintABC {
static Thread threadA,threadB;
public static void main(String[] args) {
threadA = new Thread(() -> {
for (int i = 0; i < 10; i++){
// 打印当前线程名称
System.out.println(Thread.currentThread().getName());
// 唤醒下一个线程
LockSupport.unpark(threadB);
//当前线程阻塞
LockSupport.park();
}
},"A");
threadB = new Thread(() -> {
for (int i = 0; i < 10; i++){
//先阻塞等待被唤醒
LockSupport.park();
System.out.println(Thread.currentThread().getName());
// 唤醒下一个线程
LockSupport.unpark(threadA);
}
},"B");
threadA.start();
threadB.start();
}
}
Synchronized
- synchronized解决的是 多个线程之间访问资源的同步性,它可以保证被 修饰的方法或者代码块在任意时刻只有一个线程执行
底层原理(Monitor)
- 使用synchronized会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,依赖操作系统底层互斥锁实现,主要就是实现 原子性操作和共享变量的内存可见性 问题
- 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器 +1。此时其他竞争锁的线程则会进入等待队列中,执行monitorexit指令时则会把计数器 -1,当计数器值为 0 时,则锁释放,处于等待队列中的线程再继续竞争锁
- synchronized是互斥锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤 醒时会从用户态切换到内核态,这种转换非常消耗性能
- 从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存
深入到源码来说,synchronized实际上有两个队列waitSet和entryList
- 当多个线程进入同步代码块时,首先进入entryList
- 有一个线程获取到 monitor锁 后,就赋值给当前线程,并且计数器 +1
- 如果线程调用 wait方法,将释放锁,当前线程置为 null,计数器 -1,同时进入waitSet等待被唤醒,调用 notify 或者 notifyAll 之后又会进入 entryList 竞争锁
- 如果线程执行完毕,同样释放锁,计数器 -1,当前线程置为null
使用方式
static方法和实例方法的区别
- 修饰static方法,是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象
- 修饰代码块也是给 当前类加锁
- 修饰实例方法,是给 对象实例 加锁
尽量不要使用 synchronized(String a)因为JVM中, 字符串常量池具有缓存功能
锁升级
从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,优化机制包括自旋锁、自适应锁、锁消除、锁粗化、轻量级锁和偏向锁
锁的状态从低到高依次为 无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的
- 自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,什么都不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置
- 自适应锁:自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定
- 锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除
- 锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外
- 偏向锁: 当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁
- 轻量级锁: JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁
偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞
对象头具体包含哪些内容
Hotspot虚拟机中,对象在内存中布局实际包含3个部分:
- 对象头
- 实例数据
- 对齐填充
对象头包含两部分内容,Mark Word中的内容会随着锁标志位而发生变化
- 对象自身运行时所需的数据,也被称为Mark Word,也就是用于轻量级锁和偏向锁的关键点。具体的内容包含对象的hashcode、分代年龄、轻量级锁指针、重量级锁指针、GC标记、偏向锁线程ID、偏向锁时间戳
- 存储类型指针,也就是指向类的元数据的指针,通过这个指针才能确定对象是属于哪个类的实例
Synchronized和Lock区别
- synchronized 在发生异常的时候会自动释放占有的锁,不会出现死锁。lock在发生异常的时候不会主动释放占有的锁,必须手动 unlock来释放,可能引起死锁的发生
- lock 等待锁过程中可以用 interrupt来中断等待,synchronized 只能等到锁的释放而不能中断
- lock 可以通过 trtlock来知道是否获取到锁,synchronized 则不能
- synchronized 使用 Object对象本身的 wait()、notify()、notifyAll()调度机制, Lock 可以使用 Condition 进行线程之间的调度
volatile
内存屏障
- 为了禁止编译器重排序和CPU 重排序,在编译器和CPU 层面都有对应的指令,也就是 内存屏障
- 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障就消失了,CPU并不会感知到编译器中内存屏障的存在
- 这也正是JMM和happen-before规则的底层实现原理
基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序
- StoreStore:禁止写和写的重排序
- LoadStore:禁止读和写的重排序
- StoreLoad:禁止写和读的重排序
- 在volatile 写操作的前面 插入一个 StoreStore屏障,保证volatile 写操作不会和 之前的写操作 重排序
- 在volatile 写操作的后面 插入一个 StoreLoad屏障,保证volatile 写操作不会和 之后的读操作 重排序
- 在volatile 读操作的后面 插入一个 LoadLoad屏障+LoadStore屏障 。保证volatile读操作不会和之后的读操作、写操作重排序
内存可见性
- 多核CPU下,每个核上面有L1、L2、L3缓存,其中 L3缓存是共享的
- 由于CPU缓存一致性协议,多个CPU之间不会有内存可见问题,但是 CPU缓存一致性协议非常影响性能,为了解决这个问题,在计算单元和L1、L2之间加了Store Buffer、Load Buffer等(还有其他各种Buffer)
- 因为有缓存一致性的保证 L1、L2、L3缓存和主内存之间是同步的,但是StoreBuffer、Load Buffer和L1之间却是异步的
- 即往内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入L1中,同时同步写入主内存中
保证可见性
- 当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值写入主内存
- 当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值
Double-check的单例模式
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if (singleton == null){
synchronized (Singleton.class){
if (singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
第一次检查singleton 为空后为什么内部还进行第二次检查
- 线程 A 进行判空检查之后开始执行synchronized代码块时发生线程切换,线程 B 也进行判空检查,执行synchronized代码块,如果synchronized代码块内部不进行二次判空检查,singleton 可能会初始化二次
volatile 防止指令重排序
- 指令重排序 是编译器和处理器为了优化程序执行的性能而对指令序列进行重排的一种手段,现象就是CPU 执行指令的顺序可能和程序代码的顺序不一致,例如 a = 1; b = 2; 可能 CPU 先执行b=2; 后执行a=1
- singleton = new Singleton(); 由三步操作组合而成,
- 分配一块内存
- 在内存上初始化成员变量
- 把singleton 引用指向内存
- 如果不使用volatile 修饰,可能发生指令重排序。步骤3 在 步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,别的线程调用单例的方法就会引发未被初始化的错误
Synchronized和volatile
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块
- volatile保证数据的可见性,但是不保证原子性
- volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了
JMM 和 happen-before
JMM
- Java内存模型明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序
- 这个模型就是一套规范,对上,是JVM和开发者之间的协定。对下,是JVM和编译器、CPU之间的协定
定义规范的原因:
- 定义这套规范,其实是要在开发者写程序的方便性和系统运行的效率之间找到一个平衡点
- 一方面,要让编译器和CPU可以灵活地重排序;另一方面,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重排序
- 根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过volatile、synchronized等线程同步机制来禁止重排序
happen-before
为了描述这个规范,JMM引入了happen-before,使用happen-before描述两个操作之间的内存可见性
如果 A happen-before B,则A的执行结果必须对B可见,也就是保证跨线程的内存可见性
A happen before B 不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before 只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束
- 单线程中的每个操作,happen-before 对应该线程中任意后续操作
- 对volatile变量的写入,happen-before对应后续对这个变量的读取
- 对synchronized的解锁,happen-before对应后续对这个锁的加锁
- 即 JMM对编译器和CPU 来说,volatile 变量不能重排序,非volatile 变量可以任意重排序
happen-before的传递性
happen-before还具有传递性,即 Ahappen-before B,B happen-before C,则A happen-before C
在多线程 程序中,不一定所有加锁或者把所有变量都声明为volatile变量
class A{
private int a = 0;
private volatile int c= 0;
public void set(){
a = 5; // (1)
c = 1; // (2)
}
public int get(){
int d= c; // (3)
return a; // (4)
}
}
线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5
- (1) 和 (2) 是在同一个线程内存中执行的,(1) happen-before (2),同理,(3) happen-before (4)
- 因为c是volatile变量, c的写入 happen-before 对c的读取,所以 (2) happen-before (3)
- 利用happen-before的传递性,(1) happen-before (2) happen-before (3) happen-before (4),所以,操作1的结果一定对操作4可见
锁
悲观锁和乐观锁
悲观锁
- 悲观锁在持有资源的时候总会把 资源 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止
- Synchronized 和 ReentrantLock 是悲观锁思想的实现,即不管是否持有资源,它都会尝试去加锁
乐观锁
- 乐观锁不会上锁,但是乐观锁在进行 写入操作 的时候会判断当前数据是否被修改过,多适用于 多读 的场景,这样可以提高吞吐量
乐观锁一般有两种实现方式:采用版本号机制 和 CAS算法实现
版本号机制
- 版本号机制是在表中加上一个 version 字段来实现的,表示数据被修改的次数,当执行写操作并且写入成功后,version = version + 1
- 例如当线程A要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的version值相等 时才更新,否则重试更新操作,直到更新成功
CAS
- 线程在读取数据时不进行加锁,在准备写入数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写入数据,若以被修改,则重新执行读取流程
如何解决ABA问题
- 版本号机制
- AtomicStampedReference类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值
- 采用CAS的一个变种DCAS来解决这个问题。DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。对于每个V,如果引用修改了一次,这个计数器就加1。然后再这个变量需要update的时候,就同时检查变量的值和计数器的值
循环开销大问题
- 乐观锁在进行写操作的时候会判断是否能够写入成功,如果写入不成功将触发等待 -> 重试机制,这种情况是一个自旋锁,适用于短期内获取不到,进行等待重试的锁,它不适用于长期获取不到锁的情况,另外,自旋循环对于性能开销比较大
只能保证一个共享变量的原子操作
- CAS操作单个共享变量的时候可以保证原子的操作,多个变量就不行了,JDK 5之后 AtomicReference可以用来保证对象之间的原子性,就可以把多个对象放入CAS中操作
悲观锁和乐观锁使用场景
- 悲观锁对读写都加锁,所以它的性能比较低,但是一般多写的情况下还是需要使用悲观锁。虽然加锁的性能比较低,但是也阻止了像乐观锁一样遇到写不一致的情况下一直重试的时间
- 乐观锁用于读多写少的情况,即很少发生冲突的场景这样可以省去锁的开销,增加系统的吞吐量
独占锁和共享锁
独占锁
- 独占锁 是指锁一次只能被一个线程所持有,如果一个线程对数据加上独占锁后,那么其他线程不能再对该数据加任何类型的锁,获得独占锁的线程即能读数据又能修改数据
- synchronized 和 JUC 包中Lock的实现类就是独占锁
共享锁
- 共享锁 是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据
- ReentrantReadWriteLock 就是一种共享锁
互斥锁和读写锁
互斥锁
- 互斥锁 是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性
- 互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待
读写锁
- 读写锁 是 共享锁 的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁
- 读锁可以在没有写锁的时候 被 多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容
- 读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读
- 读写锁的接口:ReadWriteLock,ReentrantReadWriteLock 实现了ReadWriteLock接口
公平锁和非公平锁
公平锁
- 公平锁 是指多个线程按照申请锁的顺序来获取锁,是公平的
非公平锁
- 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者某个线程一直得不到锁
- synchronized 关键字是非公平锁,ReentrantLock 默认也是非公平锁
可重入锁
- 可重入锁 又称之为 递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁
- Synchronized 和 ReentrantLock 都是可重入锁,可重入锁可一定程度上避免死锁
public synchronized void mehtodA() throws Exception{
// Do some magic tings
mehtodB();
}
public synchronized void mehtodB() throws Exception{
// Do some magic tings
}
- methodA 调用 methodB,如果一个线程调用 methodA 已经获取了锁再去调用 methodB 就不需要再次获取锁了,这就是可重入锁的特性
- 如果不是可重入锁的话,mehtodB 可能不会被当前线程执行,可能造成死锁
自旋锁
- 自旋锁 是指线程在没有获得锁时不是被直接挂起,而是执行一个自旋的循环,目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作
- 锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,自旋循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的
自适应自旋锁
- 自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定
- 如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源
CAS
为什么使用CAS
-
当一个线程没有获取到锁时会被 阻塞挂起,这会导致 线程上下文的切换和重新调度开销
-
非阻塞的volatile关键字来解决共享变量的可见性问题,这在一定程度上弥补了锁带来的开销问题,但是volatile只能保证共享变量的可见性,不能解决原子性问题
原理
- 线程在读取数据时不进行加锁,在准备写入数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写入数据,若以被修改,则重新执行读取流程
- JDK中的 Unsafe类提供了一系列的compareAndSwap(),是非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性
- CAS有四个操作数,分别为:对象内存位置、对象中的变量的偏移量、变量预期值、新的值
- 操作含义:如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect。这是处理器提供的一个原子性指令
JDK的rt.jar包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe类中的方法都是 native方法,它们使用 JNI的方式访问本地C++ 实现库
ABA问题
- 线程1 使用 CAS修改 变量X,其中X的初始值为A,那么 线程1 首先会获取当前变量X的值,然后使用CAS操作尝试修改X变量的值为B
- CAS操作成功了,程序运行不一定正确。有可能在 线程1 获取变量X的值A后,在执行CAS前,线程2 使用CAS修改了变量X的值为B,然后又使用CAS修改了变量X的值为A。虽然 线程1 执行 CAS 时 X的值 是A,但是这个 A 已经不是 线程1 获取时的A了
解决ABA问题
- ABA问题的产生是因为 变量的状态值产生了环形转换,就是变量的值可以从A到B,然后再从B到A
- 如果变量的值只能朝着一个方向转换,比如A到B,B到C,不构成环形,就不会存在问题
- JDK中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题的产生
AtomicLong
public class AtomicLong extends Number implements java.io.Serializable {
private static final long serialVersionUID = 1927816293512124184L;
// (1) 获取Unsafe实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// (2) 存放变量value的偏移量
private static final long valueOffset;
// (3) 判断JVM是否支持Long类型无锁CAS
static final boolean VM_SUPPORTS_LONG_CAS = VMSupportsCS8();
private static native boolean VMSupportsCS8();
static {
try {
// (4) 获取value在AtomicLong中的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicLong.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// (5) 实际变量值
private volatile long value;
public AtomicLong(long initialValue) {
value = initialValue;
}
- (1) 通过Unsafe.getUnsafe()方法获取到Unsafe类的实例。因为AtomicLong类也是在rt.jar包下面的,AtomicLong类就是通过BootStarp类加载器进行加载的
- (5) 中的value被声明为volatile的,这是为了在多线程下保证内存可见性,value是具体存放计数的变量
- (2)(4) 获取value变量在 AtomicLong类中的偏移量
递增和递减操作代码
通过调用Unsafe的getAndAddLong方法来实现操作,这个函数是个原子性操作
这里第一个参数是AtomicLong实例的引用,第二个参数是value变量在AtomicLong中的偏移值,第三个参数是要设置的第二个变量的值
// (6) 调用unsafe方法,原子性设置value值为原始值+1,返回值为递增后的值
public final long incrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
}
// (7) 调用unsafe方法,原子性设置value值为原始值-1,返回值为递减后的值
public final long decrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, -1L) - 1L;
}
// (8) 调用unsafe方法,原子性设置value值为原始值+1,返回值为原始值
public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
// (9) 调用unsafe方法,原子性设置value值为原始值-1,返回值为原始值
public final long getAndDecrement() {
return unsafe.getAndAddLong(this, valueOffset, -1L);
}
public final long getAndAddLong(Object var1, long var2, long var4) {
long var6;
do {
var6 = this.getLongVolatile(var1, var2);
} while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
return var6;
}
boolean compareAndSet(long expect, long update)方法
如果原子变量中的value值等于expect,则使用update值更新该值并返回true,否则返回false
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
LongAdder
- 使用 AtomicLong时,在高并发下大量线程会同时去竞争更新同一个原子变量,但是由于同时只有一个线程的CAS操作会成功,这就造成了大量线程竞争失败后,会通过无限循环不断进行自旋尝试CAS的操作,而这会白白浪费CPU资源
- AtomicLong 的性能瓶颈是由于过多线程同时去竞争一个变量的更新而产生的,把一个变量分解为多个变量,让同样多的线程去竞争多个资源,解决了性能问题
线程池
为什么使用线程池?
- 当执行大量异步任务时线程池能够提供较好的性能。线程的创建和销毁是需要开销的。线程池里面的线程是可复用的,不需要每次执行异步任务时都重新创建和销毁线程
- 线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。每个ThreadPoolExecutor也保留了一些基本的统计数据,比如当前线程池完成的任务数目等
线程的创建方式
线程池的创建方法总共有 7 种,但是总体来说可分为 2 类
- 一类是通过
ThreadPoolExecutor
创建的线程池 - 一类是通过
Executors
创建的线程池
1 种是通过 ThreadPoolExecutor
创建的,其他 6 种是通过 Executors
创建的
- ThreadPoolExecutor 最原始的创建线程池的方式,它包含了 7 个参数可供设置
- Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待
- Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程
- Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序
- Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池
- Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池
- Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)
不推荐使用便捷创建线程池
不建议使用Executors提供的两种快捷的线程池的原因:
- 要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数
- 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量CPU、线程执行出现异常等问题时,往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题
- newFixedThreadPool 的默认构造方法LinkedBlockingQueue是一个Integer.MAX_VALUE长度的队列,可以认为是无界的。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致OOM
- newCachedThreadPool 线程池的最大线程数是Integer.MAX_VALUE,可以认为是没有上限的,而其工作队列SynchronousQueue是一个没有存储空间的阻塞队列。只要有请求到来就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的。如果当前的任务需要耗时一段时间才能执行完成,大量的任务进来后会创建大量的线程。线程是需要分配一定的内存空间作为线程栈的,比如1MB,因此无限制创建线程必然会导致OOM
线程池基础
线程池的总体设计
ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分
线程的种类
newFixedThreadPool
- 创建一个 核心线程个数 和 最大线程个数 都为 n 的线程池,阻塞队列为 LinkedBlockingQueue,默认构造方法的LinkedBlockingQueue是一个 Integer.MAX_VALUE长度的队列,可以认为是无界的
- 虽然使用new FixedThreadPool可以把工作线程控制在固定的数量上,但任务队列是无界的。如果任务较多并且执行较慢的话,队列可能会快速积压,撑爆内存导致OOM
- keeyAliveTime=0 说明只要 线程个数比核心线程个数多并且当前空闲则回收
newCachedThreadPool
- 创建一个 按需创建线程的线程池,初始线程个数为0,最多线程个数为 Integer.MAX_VALUE,可以认为是没有上限的,并且阻塞队列 SynchronousQueue 是没有存储空间的,只要有请求到来就必须找到一条工作线程来处理,如果当前没有空闲的线程就再创建一条新的
- 例如: 当前的任务需要1小时才能执行完成,大量的任务进来后会创建大量的线程。线程是需要分配一定的内存空间作为线程栈的,比如1MB,因此无限制创建线程必然会导致OOM
- keeyAliveTime=60 说明只要当前线程在60s内空闲则回收。这个类型的特殊之处在于,加入同步队列的任务会被马上执行,同步队列里面最多只有一个任务
newSingleThreadPool
- 创建一个 核心线程个数 和 最大线程个数 都为 1 的线程池,并且阻塞队列长度为Integer.MAX_VALUE
- keeyAliveTime=0 说明只要 线程个数比核心线程个数多并且当前空闲则回收
newScheduledThreadPool
- 支持定时及周期性任务执行的线程池
- 允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM
线程池的状态
-
RUNNING:接受新任务 并且 处理阻塞队列 里的任务
-
SHUTDOWN:拒绝新任务 但是 **处理阻塞队列 **里的任务
-
STOP:拒绝新任务 并且 **抛弃阻塞队列 **里的任务,同时会 中断正在处理 的任务
-
TIDYING:所有任务都执行完(包含阻塞队列里面的任务)后当前线程池活动线程数为0,将要调用terminated方法
-
TERMINATED:终止状态
线程池的参数及含义
ThreadPoolExecutor 3 个最重要的参数:
- corePoolSize (核心线程数) : 当线程池运行的线程少于 corePoolSize 时,将创建一个新线程来处理请求,即使其他工作线程处于空闲状态
- maximumPoolSize (最大线程数) : 线程池允许开启的最大线程数
- blockingqueue (队列) : 用于保存等待执行的任务的阻塞队列
ThreadPoolExecutor 其他常⻅参数:
- threadFactory (线程工厂) : 用于创建工作线程的工厂
- **handler ** : 饱和策略,当 队列满并且线程个数达到 最大线程数 后采取的策略
- keepAliveTime (保持存活时间) : 如果线程池当前线程数超过 corePoolSize,则多余的线程空闲时间超过 keepAliveTime 时会被终止
- TimeUnit : keepAliveTime 参数的时间单位
线程池的工作流程
- 线程池不会初始化 corePoolSize个线程,有任务来了才创建工作线程
- 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
- 当工作队列满了后扩容线程池,一直到线程个数达到 maximumPoolSize为止
- 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理
- 当线程数大于核心线程数时,线程等待 keepAliveTime后还是没有任务需要处理的话,收缩线程到核心线程数
改变默认工作行为
- 声明线程池后立即调用prestartAllCoreThreads方法,来启动所有核心线程
- 传入true给allowCoreThreadTimeOut方法,来让线程池在空闲的时候同样回收核心线程
Java线程池是先用工作队列来存放来不及处理的任务,满了之后再扩容线程池。当我们的工作队列设置得很大时,最大线程数这个参数显得没有意义,因为队列很难满,或者到满的时候再去扩容线程池已经于事无补了
优先开启更多的线程,把队列当成一个后备方案
任务执行得很慢,需要10秒,如果线程池可以优先扩容到5个最大线程,那么这些任务最终都可以完成,而不会因为线程池扩容过晚导致慢任务来不及处理
- 由于线程池在工作队列满了无法入队的情况下会扩容线程池,那么我们是否可以重写队列的offer方法,造成这个队列已满的假象呢
- 由于我们Hack了队列,在达到了最大线程后势必会触
饱和策略
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任
时, ThreadPoolTaskExecutor 定义⼀些策略
- AbortPolicy (中止策略) :默认的拒绝策略,直接抛出 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码
- DiscardPolicy (抛弃策略) :直接抛弃被拒绝的任务,不抛出异常
- DiscardOldestPolicy (抛弃最旧策略) :抛弃 队列中 下一个将要被执行的任务,然后重新提交被拒绝的任务。 如果阻塞队列是一个优先队列,那么 “抛弃最旧的” 策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用
- CallerRunsPolicy (调用者运行策略) : 在调用者线程中执行该任务。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将任务回退到调用者(调用线程池执行任务的主线程),由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得线程池有时间来处理完正在执行的任务
线程池中的阻塞队列
- ArrayBlockingQueue : 基于数组结构的有界阻塞队列,按先进先出对元素进行排序
- LinkedBlockingQueue : 基于链表结构的有界/无界阻塞队列,按先进先出对元素进行排序,吞吐量通常高于 ArrayBlockingQueue。Executors.newFixedThreadPool 使用了该队列
- SynchronousQueue : 不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入 SynchronousQueue 中,必须有另一个线程正在等待接受这个元素。如果没有线程等待,并且线程池的当前大小 小于最大值,那么线程池将创建一个线程,否则根据拒绝策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交给执行它的线程,而不是被放在队列中,然后由工作线程从队列中提取任务。只有当线程池是无界的或者可以拒绝任务时,该队列才有实际价值。Executors.newCachedThreadPool使用了该队列
- PriorityBlockingQueue : 具有优先级的无界队列,按优先级对元素进行排序。元素的优先级是通过自然顺序或 Comparator 来定义的
队列有什么需要注意的
- 有界队列时,需要注意线程池满了后,被拒绝的任务如何处理
- 无界队列时,需要注意如果任务的提交速度大于线程池的处理速度,可能会导致内存溢出
线程池配置
线程池的混用策略
要根据任务的 “轻重缓急” 来指定线程池的核心参数,包括线程数、回收策略和任务队列
- 对于执行比较慢、数量不大的IO任务,或许要考虑更多的线程数,而不需要太大的队列
- 对于吞吐量较大的计算型任务,线程数量不宜过多,可以是CPU核数或核数*2 (线程一定调度到某个CPU进行执行,如果任务本身是CPU绑定的任务,那么过多的线程只会增加线程切换的开销,并不能提升吞吐量),但可能需要 较长的队列来做缓冲
ThreadLocal
基本用法
- ThreadLocal 主要用来做 线程变量的隔离
- ThreadLocal 提供了线程本地变量,如果创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。创建一个ThreadLocal变量后,每个线程都会复制一个变量到自己的本地内存
//存放用户信息的ThreadLocal
private static final ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
public Response handleRequest(UserInfo userInfo) {
Response response = new Response();
try {
// 1.用户信息set到线程局部变量中
userInfoThreadLocal.set(userInfo);
doHandle();
} finally {
// 3.使用完移除掉
userInfoThreadLocal.remove();
}
return response;
}
//业务逻辑处理
private void doHandle () {
// 2.实际用的时候取出来
UserInfo userInfo = userInfoThreadLocal.get();
//查询用户资产
queryUserAsset(userInfo);
}
底层原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MCtjbN2T-1649846400270)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20220323202933531.png)]
- Thread类 中有一个 threadLocals 和一个 inheritableThreadLocals,它们都是ThreadLocalMap 类型的变量,而ThreadLocalMap 是一个定制化的 Hashmap
- 在默认情况下,每个线程中的这两个变量都为null,只有当前线程第一次调用ThreadLocal的set或者get方法时才会创建它们。每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的 threadLocals 变量里面
- ThreadLocal 通过 set方法 把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用
- 如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的threadLocals变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal变量的remove方法,从当前线程的threadLocals里面删除该本地变量
Thread里面的threadLocals为何被设计为map结构?
- 每个线程可以关联多个ThreadLocal变量
Thread类中有个ThreadlocalMap属性的成员变量,但ThreadlocalMap的定义却在Threadlocal中
- ThreadLocalMap 就是为维护线程本地变量而设计的,只做这一件事情
复用线程和拿到上一次的数据
- 保证每次都用新的值覆盖线程变量
- 保证在每个请求结束后清空线程变量
1. 既然是线程局部变量,那为什么不用线程对象(Thread对象)作为key,这样不是更清晰,直接用线程作为key获取线程变量?
- 已经把用户信息存在线程变量里了,这个时候需要新增加一个线程变量,比方说新增用户地理位置信息,我们ThreadlocalMap 的key用的是线程,再存一个地理位置信息,key都是同一个线程(key一样),不就把原来的用户信息覆盖了嘛。( Map.put(key,value) 操作熟悉吧 )
2. 新增的地理位置信息应该怎么做
- 新创建一个Threadlocal对象就好了,因为ThreadLocalMap 的 key 是Threadlocal 对象
- 比如新增地理位置,我就再 Threadlocal < Geo> geo = new Threadlocal(), 存放地理位置信息
- 这样线程的ThreadlocalMap里面会有二个元素,一个是用户信息,一个是地理位置
ThreadLocal不支持继承性
public class TestThreadLocal {
// 创建线程变量
public static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 设置线程变量
threadLocal.set("hello world");
// 启动子线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 子线程输出线程变量的值
System.out.println("thread:" + threadLocal.get());
}
});
thread.start();
// 主线程输入线程变量的值
System.out.println("main:" + threadLocal.get());
}
}
//输出结果如下:
main:hello world
thread:null
- 同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的
- 因为在子线程thread里面调用get方法时当前线程为thread线程,而这里调用set方法设置线程变量的是main线程,两者是不同的线程,自然子线程访问时返回null
InheritableThreadLocal类
- InheritableThreadLocal继承自ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
- InheritableThreadLocal 重写了createMap方法 ,那么现在当第一次调用set方法时,创建的是当前线程的inheritableThreadLocals变量的实例而不再是threadLocals
- 当调用get方法获取当前线程内部的map变量时,获取的是inheritableThreadLocals而不再是threadLocals
- 在InheritableThreadLocal中,变量inheritableThreadLocals替代了threadLocals
子线程可以访问父线程的本地变量的原因
- InheritableThreadLocal类 通过重写getMap() 和 createMap() 让本地变量保存到了具体线程的inheritableThreadLocals变量里面,那么线程在通过InheritableThreadLocal类实例的set或者get方法设置变量时,就会创建当前线程的inheritableThreadLocals变量
- 当父线程创建子线程时,构造函数会把父线程中inheritableThreadLocals变量里面的本地变量复制一份保存到子线程的inheritableThreadLocals变量里面
什么情况下需要子线程可以获取父线程的threadlocal变量
- 子线程需要使用存放在threadlocal变量中的用户登录信息
- 一些中间件需要把统一的id追踪的整个调用链路记录下来
- 其实子线程使用父线程中的threadlocal方法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个map作为参数传递给子线程,但是这些都改变了我们的使用习惯,所以在这些情况下InheritableThreadLocal就显得比较有用
API实现逻辑
void set(T value)
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 将当前线程作为key, 去查找对应的线程变量,找到则设置
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
// 第一次调用就创建当前线程对应的HashMap
createMap(t, value);
}
- getMap(t)的作用是获取线程自己的变量threadLocals,threadlocal变量被绑定到了线程的成员变量上
- 如果getMap(t)的返回值不为空,则把value值设置到threadLocals中
- 如果getMap(t)返回空值则说明是第一次调用set方法,这时创建当前线程的threadLocals变量,则调用 createMap(t, value) 它创建当前线程的threadLocals变量
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
T get()
public T get() {
// 获取当前线程
Thread t = Thread.currentThread();
// 获取当前线程的threadLocals变量
ThreadLocalMap map = getMap(t);
// 如果threadLocals不为null, 则返回当前线程绑定的本地变量
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// threadLocals为空则初始化当前线程的threadLocals成员变量
return setInitialValue();
}
private T setInitialValue() {
// 初始化为null
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
// 如果当前线程的threadLocals变量不为空,则设置当前线程的本地变量值为null
if (map != null)
map.set(this, value);
else
// 否则调用createMap方法创建当前线程的createMap变量
createMap(t, value);
return value;
}
void remove()
如果当前线程的threadLocals变量不为空,则删除当前线程中指定ThreadLocal实例的本地变量
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
ThreadLocalMap
跟HashMap 一样,也是数组实现的
class ThreadLocalMap {
//初始容量
private static final int INITIAL_CAPACITY = 16;
//存放元素的数组
private Entry[] table;
//元素个数
private int size = 0;
}
table 就是存储线程局部变量的数组,数组元素是Entry类,Entry由key和value组成,key是Threadlocal对象,value是存放的对应线程变量
ThreadlocalMap 发生hash冲突怎么办?跟HashMap 有什么区别?
- ThreadlocalMap既没有链表,也没有红黑树,采用的是开放定址法 ,如果发生冲突,ThreadlocalMap直接往后找 相邻 的下一个节点,如果相邻节点为空,直接存进去,如果不为空,继续往后找,直到找到空的,把元素放进去,或者元素个数超过数组长度阈值,进行扩容
ThreadlocalMap 中key 设计成 WeakReference(弱引用)类型
为了尽最大努力避免内存泄漏
- 引用关系是 userInfoThreadLocal 引用了ThreadLocal对象,这是个强引用
- ThreadLocal对象同时也被 ThreadlocalMap 的 key 引用,这是个WeakReference引用,前面说GC要回收ThreadLocal对象的前提是它只被WeakReference引用,没有任何强引用
- 一旦一个对象只被弱引用引用,GC的时候就会回收这个对象。ThreadLocal对象如果还被 userInfoThreadLocal(强引用) 引用着,GC是不会回收被WeakReference引用的对象的
ThreadLocal对象有强引用,回收不掉,干嘛还要设计成WeakReference类型呢
- ThreadLocal的设计者考虑到线程往往生命周期很长,比如经常会用到线程池,线程一直存活着,根据JVM 根搜索算法,一直存在 Thread -> ThreadLocalMap -> Entry(元素)这样一条引用链路, 如下图,如果key不设计成WeakReference类型,是强引用的话,就一直不会被GC回收,key就一直不会是null,不为null Entry元素就不会被清理(ThreadLocalMap是根据key是否为null来判断是否清理Entry)
- 所以ThreadLocal的设计者认为只要 ThreadLocal 所在的作用域结束了工作被清理了,GC回收的时候就会把key引用对象回收,key置为null,ThreadLocal会尽力保证Entry清理掉来最大可能避免内存泄漏
//元素类
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value; //key是从父类继承的,所以这里只有value
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//WeakReference 继承了Reference,key是继承了范型的referent
public abstract class Reference<T> {
//这个就是被继承的key
private T referent;
Reference(T referent) {
this(referent, null);
}
}
Entry 继承了WeakReference类,Entry 中的 key 是WeakReference类型的,在Java 中当对象只被 WeakReference 引用,没有其他对象引用时,被WeakReference 引用的对象发生GC 时会直接被回收掉
那如果Threadlocal 对象一直有强引用,那怎么办?岂不是有内存泄漏风险
最佳实践是用完手动调用remove函数
JUC
CountDownLatch
介绍
在日常开发中经常会遇到需要在 主线程中开启多个线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景
public class JoinCountDownLatch {
// 创建一个 CountDownLatch 实例
private static volatile CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
System.out.println("thread1 Over!");
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
System.out.println("thread2 Over!");
});
// 启动子线程
thread1.start();
thread2.start();
System.out.println("wait all thread over");
// 等待子线程执行完毕,返回
countDownLatch.await();
System.out.println("all thread over");
}
}
public class JoinCountDownLatch2 {
// 创建一个CountDownLatch实例
private static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 添加线程A
executorService.submit(() -> {
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
System.out.println("A");
});
// 添加线程B
executorService.submit(() -> {
try {
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
System.out.println("B");
});
System.out.println("wait");
// 等待子线程执行完毕,返回
countDownLatch.await();
System.out.println("all over");
executorService.shutdown();
}
}
- 主线程调用countDownLatch.await()方法后会被阻塞
- 线程执行完毕后调用 countDownLatch.countDown()方法让countDownLatch内部的计数器减1,所有子线程执行完毕并调用countDown()方法后计数器会变为0,这时候主线程的await()方法才会返回
CountDownLatch与join方法的区别:
- 调用一个子线程的 join()方法后,该线程会一直被阻塞直到子线程运行完毕
- CountDownLatch则使用 计数器 来允许 子线程运行完毕或者在运行中递减计数,也就是CountDownLatch可以在子线程运行的任何时候让await方法返回而不一定必须等到线程结束
- 使用线程池来管理线程时一般都是直接添加Runable到线程池,这时候就没有办法再调用线程的join方法了,就是说countDownLatch相比join方法让我们对线程同步有更灵活的控制
实现原理探究
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KjeI3tkX-1649846400273)(D:\Work-Space\TyporaImage\image-20210826191040875.png)]
CountDownLatch是使用AQS实现的。实际上是把 计数器 的值赋给了AQS的 状态变量state,也就是这里使用AQS的状态值来表示计数器值
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
Sync(int count) {
setState(count);
}
1.void await()方法
当线程调用CountDownLatch对象的 await方法 后,当前线程会被 阻塞 ,直到下面的情况之一发生才会返回:
- 当所有线程都调用了 CountDownLatch对象的 countDown 方法后,也就是计数器的值为0时
- 其他线程调用了当前线程的interrupt()方法中断了当前线程,当前线程就会抛出InterruptedException异常,然后返回
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// AQS获取共享资源时可被中断的方法
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
// 如果线程被中断则抛出异常
if (Thread.interrupted())
throw new InterruptedException();
// 查看当前计数器值是否为0,为0则直接返回,否则进入AQS的队列等待
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
// sync类实现的AQS的接口
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1: -1;
}
- 方法的特点是 线程获取资源时可以被中断,并且获取的资源是 共享资源
- acquireSharedInterruptibly() 首先判断当前线程是否已被中断,若是则抛出异常,否则调用sync实现的tryAcquireShared 方法 查看当前 状态值是否为0,是则当前线程的await()方法直接返回,否则调用AQS的doAcquireSharedInterruptibly 方法让 当前线程阻塞
2. boolean await(long timeout, TimeUnit unit)方法
当线程调用了CountDownLatch对象的该方法后,当前线程会被阻塞,直到下面的情况之一发生才会返回:
- 当所有线程都调用了CountDownLatch对象的 countDown方法 后,也就是计数器值为0时,这时候会返回true
- **其他线程调用了当前线程的interrupt()**方法中断了当前线程,当前线程会抛出InterruptedException异常,然后返回
- 设置的timeout时间到了,因为超时而返回false
public boolean await(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
3. void countDown() 方法
线程调用该方法后,计数器的值递减,递减后如果计数器值为0则唤醒所有因调用await方法而被阻塞的线程,否则什么都不做
public void countDown() {
// 委托sync调用AQS的方法
sync.releaseShared(1);
}
// AQS的方法
public final boolean releaseShared(int arg) {
// 调用sync实现的tryReleaseShared
if (tryReleaseShared(arg)) {
// AQS的释放资源方法
doReleaseShared();
return true;
}
return false;
}
// sync的方法
protected boolean tryReleaseShared(int releases) {
// 循环进行CAS,知道当前线程成功完成 CAS使计数器值 (状态值state) 减1 并更新到 stete
for (;;) {
int c = getState();
// 如果当前状态值为0则直接返回 (1)
if (c == 0)
return false;
// 使用CAS让计数器值减 1 (2)
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
}
- 首先获取当前 状态值
- 判断如果当前 状态值为0 则直接返回 false,从而countDown()方法直接返回
- 否则使用 CAS将计数器值减1, CAS失败则循环重试,否则如果当前 计数器值为 0 则返回true,返回true说明是最后一个线程调用的countdown方法,那么该线程除了让计数器值减1外,还需要唤醒因调用CountDownLatch的await方法而被 阻塞的线程,具体是调用AQS的doReleaseShared方法来 激活阻塞的线程
4. long getCount() 方法
获取当前计数器的值,也就是AQS的state的值
public long getCount() {
return sync.getCount();
}
int getCount(){
return getState();
}
Semaphore
介绍
public class SemaphoreTest {
// 创建一个Semaphore 实例
private static Semaphore semaphore = new Semaphore(0);
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 将线程A添加到线程池
executorService.submit(new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread() + " over");
semaphore.release();
}catch (Exception e){
e.printStackTrace();
}
}
});
// 将线程B添加到线程池
executorService.submit(new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread() + " over");
semaphore.release();
}catch (Exception e){
e.printStackTrace();
}
}
});
// 等待子线程执行完毕,返回
semaphore.acquire(2);
System.out.println("all over");
// 关闭线程池
executorService.shutdown();
}
}
信号量Semaphore也是Java中的一个同步器,它内部的计数器是递增的,并且在一开始初始化Semaphore时可以指定一个初始值
并不需要知道需要同步的线程个数,而是在 需要同步的地方调用acquire方法时指定需要同步的线程个数
- 首先创建了一个信号量实例,构造函数的入参为0,说明当前信号量计数器的值为0
- 然后main函数向线程池添加两个线程任务,在每个线程内部调用信号量的release方法,这相当于让计数器值递增1
- 传参为2说明调用acquire方法的线程会一直阻塞,直到信号量的计数变为2才会返回
- 如果构造Semaphore时传递的参数为N,并在M个线程中调用了该信号量的release方法,那么在调用acquire使M个线程同步时传递的参数应该是M+N
实现原理探究
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fU8MCCEv-1649846400274)(D:\Work-Space\TyporaImage\image-20210901160420664.png)]
Semaphore 还是使用 AQS 实现的。Sync 只是对AQS的一个修饰,并且Sync有两个实现类,用来指定 获取信号量时是否采用公平策略
Semaphore 默认采用非公平策略,如果需要使用公平策略则可以使用带两个参数的构造函数来构造Semaphore对象。另外,如CountDownLatch构造函数传递的初始化信号量个数permits被赋给了AQS的state状态变量一样,这里AQS的state值也表示当前持有的信号量个数
public Semaphore (int permits) {
sync = new NonfairSync(permits);
}
public Semaphore (int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
Sync (int permits) {
setState (permits);
}
void acquire() 方法
获取一个信号量资源,如果当前信号量个数 大于0,则当前信号量的计数会减1,然后该方法直接返回
否则如果当前信号量个数 等于0,则当前线程会被放入AQS的阻塞队列
当其他线程调用了当前线程的interrupt()方法中断了当前线程时,则当前线程会抛出InterruptedException异常返回
public void acquire() throws InterruptedException {
// 传递参数为1,要获取1个信号量资源
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
// 信号中断直接抛出异常
if (Thread.interrupted())
throw new InterruptedException();
// 调用Sync子类方法尝试获取,这里根据构造函数确实使用公平策略
if (tryAcquireShared(arg) < 0)
// 如果获取失败则放入阻塞队列。然后再次尝试,如果失败则调用park方法挂起当前线程
doAcquireSharedInterruptibly(arg);
}
非公平策略NonfairSync类
由于NonFairSync是非公平获取的,也就是说先调用aquire方法获取信号量的线程不一定比后来者先获取到信号量
如果线程A先调用了aquire()方法获取信号量,但是当前信号量个数为0,那么线程A会被放入AQS的阻塞队列。过一段时间后线程C调用了release()方法释放了一个信号量,如果当前没有其他线程获取信号量,那么线程A就会被激活,然后获取该信号量,但是假如线程C释放信号量后,线程C调用了aquire方法,那么线程C就会和线程A去竞争这个信号量资源
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
// 获取当前信号量的值
int available = getState();
// 如果当前信号量小于0或者CAS设置成功则返回剩余值
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}
公平性的FairSync类
公平策略是看当前线程节点的前驱节点是否也在等待获取该资源,如果是则自己放弃获取的权限,然后当前线程会被放入AQS阻塞队列,否则就去获取
protected int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
void acquireUninterruptibly()
该方法与acquire()类似,不同之处在于该方法对中断不响应,也就是当当前线程调用了acquireUninterruptibly获取资源时(包含被阻塞后),其他线程调用了当前线程的interrupt()方法设置了当前线程的中断标志,此时当前线程并不会抛出InterruptedException异常而返回
public void acquireUninterruptibly() {
sync.acquireShared(1);
}
void release() 方法
把当前Semaphore对象的信号量值增加1,如果当前有线程因为调用aquire方法被阻塞而被放入了AQS的阻塞队列,则会根据公平策略选择一个信号量个数能被满足的线程进行激活,激活的线程会尝试获取刚增加的信号量
public void release() {
// arg = 1
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
// 尝试释放资源
if (tryReleaseShared(arg)) {
// 资源释放成功则调用park方法唤醒AQS队列里面最先挂起的线程
doReleaseShared();
return true;
}
return false;
}
protected final boolean tryReleaseShared(int releases) {
// 无限循环,使用CAS保证了release方法对信号量递增1的原子性操作
for (;;) {
// 获取当前信号量值
int current = getState();
// 将当前信号量值增加 releases,这里为增加 1
int next = current + releases;
if (next < current) // 移除处理
throw new Error("Maximum permit count exceeded");
// 使用CAS保证更新信号量值的原子性
if (compareAndSetState(current, next))
return true;
}
}
concurrenthashmap
为什么选用 ConmcurrentHashMap
HashTable
- 多线程环境下用 Hashtable 来解决线程安全的问题,不管是往 map 里边添加元素还是获取元素,都会用 synchronized 关键字加锁。当有多个元素之前存在资源竞争时,只能有一个线程可以获取到锁,操作资源。不管是 get 还是 put 操作,都是锁住了整个 table,效率低下,因此 并不适合高并发场景
SynchronizedMap
- 集合工具类 Collections,生成一个SynchronizedMap。其实,它和 Hashtable 差不多,同样的原因,锁住整张表,效率低下
JDK1.8做的改进
JDK1.7
JDK1.7 版本中,ConcurrentHashMap
由数组 + Segment + 分段锁实现,其内部分为一个个段(Segment)数组,Segment 通过继承 ReentrantLock
来进行加锁,通过每次锁住一个 segment 来降低锁的粒度而且保证了每个 segment 内的操作的线程安全性,从而实现全局线程安全
缺点 : 每次通过 hash 确认位置时需要 2 次才能定位到当前 key 应该落在哪个槽
- 通过 hash 值和 段数组长度-1 进行位运算确认当前 key 属于哪个段,即确认其在 segments 数组的位置
- 再次通过 hash 值和 table 数组(即 ConcurrentHashMap 底层存储数据的数组)长度 - 1进行位运算确认其所在桶
JDK1.8
jdk1.8 版本中,对 ConcurrentHashMap
做了优化,取消了分段锁的设计,取而代之的是通过 cas 操作和 synchronized
关键字来实现优化,而扩容的时候也利用了一种分而治之的思想来提升扩容效率,在 JDK1.8 中 ConcurrentHashMap
的存储结构和 HashMap 基本一致
key和value不允许为null
- 在并发编程中,null 值容易引来歧义, 假如先调用
get(key)
返回的结果是 null,无法确认是因为当时这个 key 对应的 value 本身放的就是 null,还是说这个 key 值根本不存在,这会引起歧义 - 在非并发编程中,可以进一步通过调用
containsKey
方法来进行判断,但是并发编程中无法保证两个方法之间没有其他线程来修改 key 值,所以就直接禁止了 null 值的存在
如何保证线程的安全性
CAS保证数组初始化的安全
有一个非常重要的变量 sizeCtl
- sizeCtl < -1: 表示有 N - 1 个线程正在执行扩容操作,如 -2就表示有 2-1个线程正在扩容
- **sizeCtl = -1 : ** 占位符,表示当前正在初始化数组
- sizeCtl = 0 : 默认状态,表示数组还没有被初始化
- **sizeCtl > 0 : ** 记录下一次需要扩容的大小
初始化操作
- 判断 sizeCtl 是否 < 0,如果小于0说明有线程在扩容,调用 Thread.yield 让出CPU
- 否则调用 CAS 将 sizeCtl 改为 -1,表示正在扩容
- 计算首次初始化之后会对sc赋值为下一次扩容的大小
- 计算下次扩容的大小(当前容量的 3 / 4)
- 将sc赋值给sizeCtl
put操作保证数组元素的可见性
- 获取key的hash值。自旋。
- 判断数组有没有初始化,没有的话就初始化数组
- 否则调用 tabAt()获取元素,该方法是一个CAS操作
- 如果当前元素为null,也是通过CAS操作(casTabAt)来存储当前元素
- 如果不为null,使用 synchronized 关键字锁住当前节点(),进行对应的设值操作
- (这里相当于每次只锁住一个下标,锁的粒度更细了,所以最多可以支持的并发数比1.7)
精妙的计数方式
- 不能直接通过CAS操作来修改size,因为假设同时有非常多的线程要修改 size 操作,那么只会有一个线程能够替换成功,其他线程只能不断的尝试CAS,这会影响到 concurrentHashMap 的性能
- 定义了一个数组来计数,并且这个数组也能扩容,每次线程需要计数的时候,都通过随机的方式获取一个数组下标的位置进行操作,这样就可以尽可能的降低锁的粒度,最后获取 size
addCount()
- 首先会判断 counterCell 数组是不是为 null,这里的CAS操作是将 BASECOUNT 和 baseCount 进行比较
- 如果相等则说明当前没有其他线程过来修改baseCount(说明CAS操作成功),则此时不需要使用CounterCell数组,而直接采用 baseCount来计数
- 假如 CounterCell 为null且CAS失败,那么就会通过 fullAddCount() 来对 CounterCell 数组进行初始化和赋值等操作
初始化CounterCell数组
- 变量 cellsBusy默认是0,表示当前没有线程在初始化或者扩容
AQS
原理
ReentrantLock 基于 AQS (AbstractQueuedSynchronizer 抽象队列同步器 )实现
AQS内部维护一个 state状态位,尝试加锁的时候通过 CAS 修改值,如果成功设置为1,并且把当前线程ID赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把state重新置为0,同时当前线程ID置为空
前言
AQS是抽象的队列式的同步器,内部定义了很多锁相关的方法,熟知的ReentrantLock、、CountDownLatch、Semaphore等都是基于AQS来实现的
三部分组成, state 同步状态、 Node 组成的 CLH 队列、ConditionObject 条件变量(包含Node组成的条件单向队列)
状态
- getState(): 返回同步状态
- setState(int newState): 设置同步状态
- compareAndSetState(int expect, int update): 使用C A S设置同步状态
- isHeldExclusively(): 当前线程是否持有资源
独占资源(不影响线程中断)
- tryAcquire(int arg): 独占式获取资源,子类实现
- acquire(int arg): 独占式获取资源模板
- tryRelease(int arg): 独占式释放资源,子类实现
- release(int arg): 独占式释放资源模板
共享资源(不响应线程中断)
- tryAcquireShared(int arg): 共享式获取资源,返回值大于等于0则表示获取成功,否则获取失败,子类实现
- acquireShared(int arg): 共享式获取资源模板
- tryReleaseShared(int arg): 共享式释放资源,子类实现
- releaseShared(int arg): 共享式释放资源模板
同步状态
- 在 A Q S 中维护了一个同步状态变量state,getState函数获取同步状态,setState、compareAndSetState函数修改同步状态
- 对于A Q S来说,线程同步的关键是对state的操作,可以说获取、释放资源是否成功都是由state决定的
- state > 0 代表可获取资源,否则无法获取,所以state的具体语义由实现者去定义,现有的ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch定义的state语义都不一样
ReentrantLock 的 state用来表示是否有锁资源
ReentrantReadWriteLock的state 高16位代表读锁状态,低16位代表写锁状态
Semaphore的state用来表示可用信号的个数
CountDownLatch的state用来表示计数器的值
CLH队列
- CLH 是 A Q S内部维护的 FIFO(先进先出)双端双向队列(方便尾部节点插入),基于链表数据结构
- 当一个线程竞争资源失败,就会将等待资源的线程封装成一个 Node 节点,通过 C A S原子操作插入队列尾部,最终不同的 Node 节点连接组成了一个 CLH 队列,所以说A Q S通过CLH队列管理竞争资源的线程
CLH队列具有如下几个优点:
- 先进先出保证了公平性
- 非阻塞的队列,通过自旋锁和C A S保证节点插入和移除的原子性,实现无锁快速插入
- 采用了自旋锁思想,所以CLH也是一种基于链表的可扩展、高性能、公平的自旋锁
Node内部类
Node是A Q S的内部类,每个等待资源的线程都会封装成Node节点组成C L H队列、等待队列
waitStatus等待状态如下
nextWaiter特殊标记
- Node 在 CLH队列时,nextWaiter表示 共享式或独占式标记
- Node 在 条件队列时,nextWaiter表示 下个Node节点指针
流程概述
线程获取资源失败,封装成 Node 节点从 C L H 队列尾部入队并阻塞线程,某线程释放资源时会把C L H队列首部Node节点关联的线程唤醒,再次获取资源
入队
获取资源失败的线程需要封装成 Node 节点,接着尾部入队,在 A Q S中提供addWaiter函数完成Node节点的创建与入队
private Node addWaiter(Node mode) {
//根据当前线程创建节点,等待状态为0
Node node = new Node(Thread.currentThread(), mode);
// 获取尾节点
Node pred = tail;
if (pred != null) {
//如果尾节点不等于null,把当前节点的前驱节点指向尾节点
node.prev = pred;
//通过cas把尾节点指向当前节点
if (compareAndSetTail(pred, node)) {
//之前尾节点的下个节点指向当前节点
pred.next = node;
return node;
}
}
//如果添加失败或队列不存在,执行end函数
enq(node);
return node;
}
添加节点的时候,如果从C L H队列已经存在,通过C A S快速将当前节点添加到队列尾部,如果添加失败或队列不存在,则指向enq函数自旋入队
private Node enq(final Node node) {
for (;;) { //循环
//获取尾节点
Node t = tail;
if (t == null) {
//如果尾节点为空,创建哨兵节点,通过cas把头节点指向哨兵节点
if (compareAndSetHead(new Node()))
//cas成功,尾节点指向哨兵节点
tail = head;
} else {
//当前节点的前驱节点设指向之前尾节点
node.prev = t;
//cas设置把尾节点指向当前节点
if (compareAndSetTail(t, node)) {
//cas成功,之前尾节点的下个节点指向当前节点
t.next = node;
return t;
}
}
}
}
通过自旋C A S尝试往队列尾部插入节点,直到成功,自旋过程如果发现C L H队列不存在时会初始化C L H队列
第一次循环
1. 刚开始**C L H**队列不存在,**head** 与 **tail** 都指向 **null**
2. 要初始化**C L H**队列,会创建一个哨兵节点,**head**与**tail**都指向哨兵节点
第二次循环
- 当前线程节点的前驱节点指向尾部节点(哨兵节点)
- 设置当前线程节点为尾部,tail指向当前线程节点
- 前尾部节点的后驱节点指向当前线程节点(当前尾部节点)
出队
C L H队列中的节点都是获取资源失败的线程节点,当持有资源的线程释放资源时,会将head.next指向的线程节点唤醒(C L H队列的第二个节点),如果唤醒的线程节点获取资源成功,线程节点清空信息设置为头部节点(新哨兵节点),原头部节点出队(原哨兵节点)
acquireQueued函数中的部分代码
//1.获取前驱节点
final Node p = node.predecessor();
//如果前驱节点是首节点,获取资源(子类实现)
if (p == head && tryAcquire(arg)) {
//2.获取资源成功,设置当前节点为头节点,清空当前节点的信息,把当前节点变成哨兵节点
setHead(node);
//3.原来首节点下个节点指向为null
p.next = null; // help GC
//4.非异常状态,防止指向finally逻辑
failed = false;
//5.返回线程中断状态
return interrupted;
}
private void setHead(Node node) {
//节点设置为头部
head = node;
//清空线程
node.thread = null;
//清空前驱节点
node.prev = null;
}
只需要关注1~3步骤即可,假设获取资源成功,更换头部节点,并把头部节点的信息清除变成哨兵节点,注意这个过程是不需要使用C A S来保证,因为只有一个线程能够成功获取到资源
条件变量
- Object 的wait、notify函数是配合Synchronized锁实现线程间同步协作的功能,A Q S的ConditionObject条件变量也提供这样的功能,通过ConditionObject的await和signal两类函数完成
- 不同于Synchronized锁,一个A Q S可以对应多个条件变量,而Synchronized只有一个
- ConditionObject内部维护着一个单向条件队列,不同于C H L队列,条件队列只入队执行await的线程节点,并且加入条件队列的节点,不能在C H L队列, 条件队列出队的节点,会入队到C H L队列
- 当某个线程执行了ConditionObject的await函数,阻塞当前线程,线程会被封装成Node节点添加到条件队列的末端,其他线程执行ConditionObject的signal函数,会将条件队列头部线程节点转移到C H L队列参与竞争资源
- 条件队列Node类是使用nextWaiter变量指向下个节点,并且因为是单向队列,所以prev与next变量都是null
独占式获取资源
acquire是个模板函数,模板流程就是线程获取共享资源,如果获取资源成功,线程直接返回,否则进入CLH队列,直到获取资源成功为止,且整个过程忽略中断的影响
- 执行 tryAcquire 函数,tryAcquire是由子类实现,代表获取资源是否成功,如果资源获取失败,执行下面的逻辑
- 执行 addWaiter 函数,根据当前线程创建出独占式节点,并入队CLH队列
- 执行 acquireQueued 函数,自旋阻塞等待获取资源
- 如果 acquireQueued 函数中获取资源成功,根据线程是否被中断状态,来决定执行线程中断逻辑
final boolean acquireQueued(final Node node, int arg) {
//异常状态,默认是
boolean failed = true;
try {
//该线程是否中断过,默认否
boolean interrupted = false;
for (;;) {//自旋
//获取前驱节点
final Node p = node.predecessor();
//如果前驱节点是首节点,获取资源(子类实现)
if (p == head && tryAcquire(arg)) {
//获取资源成功,设置当前节点为头节点,清空当前节点的信息,把当前节点变成哨兵节点
setHead(node);
//原来首节点下个节点指向为null
p.next = null; // help GC
//非异常状态,防止指向finally逻辑
failed = false;
//返回线程中断状态
return interrupted;
}
/**
* 如果前驱节点不是首节点,先执行shouldParkAfterFailedAcquire函数,shouldParkAfterFailedAcquire做了三件事
* 1.如果前驱节点的等待状态是SIGNAL,返回true,执行parkAndCheckInterrupt函数,返回false
* 2.如果前驱节点的等大状态是CANCELLED,把CANCELLED节点全部移出队列(条件节点)
* 3.以上两者都不符合,更新前驱节点的等待状态为SIGNAL,返回false
*/
if (shouldParkAfterFailedAcquire(p, node) &&
//使用LockSupport类的静态方法park挂起当前线程,直到被唤醒,唤醒后检查当前线程是否被中断,返回该线程中断状态并重置中断状态
parkAndCheckInterrupt())
//该线程被中断过
interrupted = true;
}
} finally {
// 尝试获取资源失败并执行异常,取消请求,将当前节点从队列中移除
if (failed)
cancelAcquire(node);
}
}
独占式释放资源
有获取资源,自然就少不了释放资源,A Q S中提供了release模板函数来释放资源,模板流程就是线程释放资源成功,唤醒CLH队列的第二个线程节点(首节点的下个节点)
public final boolean release(int arg) {
if (tryRelease(arg)) {//释放资源成功,tryRelease子类实现
//获取头部线程节点
Node h = head;
if (h != null && h.waitStatus != 0) //头部线程节点不为null,并且等待状态不为0
//唤醒CHL队列第二个线程节点
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
//获取节点等待状态
int ws = node.waitStatus;
if (ws < 0)
//cas更新节点状态为0
compareAndSetWaitStatus(node, ws, 0);
//获取下个线程节点
Node s = node.next;
if (s == null || s.waitStatus > 0) { //如果下个节点信息异常,从尾节点循环向前获取到正常的节点为止,正常情况不会执行
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
//唤醒线程节点
LockSupport.unpark(s.thread);
}
}
共享式获取资源
acquireShared 是个 模板函数,模板流程就是线程获取共享资源,如果获取到资源,线程直接返回,否则进入CLH队列,直到获取到资源为止,且整个过程忽略中断的影响
public final void acquireShared(int arg) {
/**
* 1.负数表示失败
* 2.0表示成功,但没有剩余可用资源
* 3.正数表示成功且有剩余资源
*/
if (tryAcquireShared(arg) < 0) //获取资源失败,tryAcquireShared子类实现
//自旋阻塞等待获取资源
doAcquireShared(arg);
}
- 节点的标记是共享式
- 获取资源成功,还会唤醒后续资源,因为资源数可能 > 0,代表还有资源可获取,所以需要做后续线程节点的唤醒
共享式释放资源
A Q S中提供了releaseShared模板函数来释放资源,模板流程就是线程释放资源成功,唤醒CHL队列的第二个线程节点(首节点的下个节点)
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {//释放资源成功,tryReleaseShared子类实现
//唤醒后继节点
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
for (;;) {
//获取头节点
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {//如果头节点等待状态为SIGNAL
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))//更新头节点等待状态为0
continue; // loop to recheck cases
//唤醒头节点下个线程节点
unparkSuccessor(h);
}
//如果后继节点暂时不需要被唤醒,更新头节点等待状态为PROPAGATE
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
非公平锁和公平锁区别
- 非公平锁性能高于公平锁性能。非公平锁可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量
- 非公平锁性能虽然优于公平锁,但是会存在导致线程饥饿的情况。在最坏的情况下,可能存在某个线程一直获取不到锁。不过相比性能而言,饥饿问题可以暂时忽略,这可能就是ReentrantLock默认创建非公平锁的原因之一了
公平锁和非公平锁
- 基本概念: 公平 指的是按照线程请求的顺序,来分配锁;非公平指的是,不完全按照请求的顺序,在合适的时机插队,而不是盲目插队
- 提高效率 - 避免唤醒带来的空档期
公平与非公平对比(以ReentrantLock为例)
- 如果在创建ReentrantLock对象时,参数填写为true,就是个公平锁。线程是按顺序调用lock方法
- 如果在线程1释放锁的时候,线程5恰好去执行lock(),由于ReentrantLock()发现此时并没有线程持有lock这把锁(线程2还没有来得及获得到,因为获取需要时间)。线程5可以插队,直接拿到这把锁,这也是ReentrantLock 默认 的公平策略,也就是“不公平”
- 特例:针对**tryLock()方法,当有线程执行tryLock()**的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使在它之前已经有其他现在在等待队列里了
对比公平和非公平的优缺点
公平锁
- 优势:每个线程在等待一段时间后,总有执行的机会
- 劣势:更慢,吞吐量更小
非公平
- 优势:更快,吞吐量更大
- 劣势:可能产生线程饥饿
JMM
Java 内存模型(JMM)
JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果
所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成
线程的工作内存在主存还是缓存中?
JMM 中定义的每个线程私有的工作内存是抽象的规范,Java 内存模型和真实硬件内存架构是不同的,JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此!
JMM内存模型规范
- 初始变量首先存储在主内存中
- 线程操作变量需要从主内存拷贝到线程本地内存中
- 线程的本地工作内存是一个抽象概念,包括了缓存、store buffer(后面会讲到)、寄存器等
JMM模型中多线程如何通过共享变量通信
线程间通信必须要经过主内存
线程A与线程B之间要通信的话,必须要经历下面2个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去
- 线程B到主内存中去读取线程A之前已更新过的共享变量
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成 :
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断
- 不允许read和load、store和write操作之一单独出现,必须成对出现
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
JMM实现volatile平台无关
可见性问题
如果对象obj 没有使用volatile 修饰,A 线程在将对象count读取到本地内存,从1修改为2,B 线程也把obj 读取到本地内存,因为A 线程的修改对B 线程不可见,这是从Java 内存模型层面看可见性问题(前面从物理内存结构分析的)
有序性问题
重排序发生的地方有很多,编译器优化、CPU 因为指令流水批处理而重排序、内存因为缓存以及store buffer 而显得乱序执行
带store buffer (写缓冲)的CPU 架构图
每个处理器上的Store Buffer(写缓冲区),仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序
原子性问题
例如多线程并发执行 i = i +1。i 是共享变量,看完Java 内存模型,知道这个操作不是原子的,可以分为+1 操作和赋值操作。因此多线程并发访问时,可能发生线程切换,造成不是预期结果
可见性 & 有序性 问题解决
volatile 可以让共享变量实现可见性,同时禁止共享变量的指令重排,保障可见性
JSR-333 规范:JDK 5定义的内存模型规范
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法
实现原理 :
- Java编译器在生成指令序列的适当位置会插入 内存屏障 指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性
- 内存屏障指令: 写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性