大家好!我是未来村村长,就是那个“请你跟我这样做,我就跟你这样做!”的村长👨?🌾!
||To Up||
未来村村长正推出一系列【To Up】文章,该系列文章重要是对Java开发知识体系的梳理,关注底层原理和知识重点。”天下苦八股文久矣?吾甚哀,若学而作苦,此门无缘,望去之。“该系列与八股文不同,重点在于对知识体系的构建和原理的探究。
一、Java多线程💐
1、进程与线程
(1)进程
? 一个进程是一个程序的一次启动和执行,一个进程一般由程序段、数据段、进程控制块组成:
- 程序段:代码段,需要执行的指令集合
- 数据段:需要操作的数据和程序运行时产生的数据
- 进程控制块:进程的描述信息和控制信息,是进程存在的唯一标志
- 进程的描述信息:进程ID和进程名称
- 进程的调度信息:程序的起始地址和通信信息
- 进程的资源信息:内存信息和文件句柄
- 进程上下文:CPU寄存器的值、当前程序计数器的值
? 每当使用Java命令启动一个Java应用程序时,就会启动一个JVM进程,所有的Java程序代码都是以线程运行,JVM找到程序的入口main()方法,然后运行main方法产生一个线程,同时还会启动另外一个GC线程用于垃圾回收。
(2)线程
? 线程是指“进程代码段”的一次顺序执行流程,线程是CPU调度的最小单位,而进程是操作系统资源分配的最小单位,线程之间共享进程的内存空间、系统资源。
? 线程的组成如下:
- 线程ID:线程的唯一标识,同一个进程内的线程ID不会重复
- 线程名称:用于用户识别,若没有显式命名系统会自动分配
- 线程优先级:表示线程调度的优先级,优先级越高获得CPU的执行机会越大
- 线程状态:分别为新建(NEW)、可执行(RUNNABLE)、阻塞(BLOCKED)、无限期等待(WAITING)、限时等待(TIMED_WAITING)、结束(TERMINATED)。
- 线程类型:是否为守护线程
(3)线程与进程的区别
? 线程是“进程代码段”的一次顺序执行流程,一个进程由多个线程组成,一个进程至少有一个线程。线程是CPU调度的最小单位,进程是操作系统分配资源的最小单位。进程之间相互独立,但进程内部的各个线程之间并不完全相互独立。各个线程之间共享进程的方法区内存、堆内存以及系统资源。
2、线程的创建
(1)继承Thread类
? Thread类是Java多线程编程的基础,通过继承Thread类创建线程类可以实现线程的创建:
- 继承Thread类,创建新的线程类
- 重写run()方法,将需要并发执行的业务代码编写在run()方法中
- 调用Thread实例的start()方法启动线程
- 线程启动后,线程的run方法将被JVM执行
? Thread类的源码量较大,不作展示分析。我们只需要知道Thread定义了线程的状态以及操作线程的相关方法即可。
(2)实现Runnable接口
? Thread也实现了Runnable接口,且Thread中有以下构造方法可通过传入Runnable接口实现对象参数来实现线程的创建。
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
public Thread(Runnable target, String name) {
init(null, target, name, 0);
}
? 则通过实现Runnable接口来创建线程的步骤如下:
- 定义一个类实现Runnable接口
- 实现Runnable接口中的run()抽象方法【业务处理逻辑】
- 通过Thread的构造方法创建线程对象,传入Ruunable实例作为参数
- 调用Thread实例的start()方法启动线程
- 线程启动后,线程的run方法将被JVM执行,Thread的run方法将会调用Runnable实例的run方法
? 这里Thread的run()方法先判断target是否为null,这里的target类型就是Runnable,即我们传入的参数。
public void run() {
if (target != null) {
target.run();
}
}
? 我们也可以看看Runnable的源码,可以看到Runnable是一个函数式接口,即只有一个方法的接口,其与Thread都来自java.lang包。
package java.lang;
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
(3)使用Callable和FutureTask
? 在使用Callable和FutureTask之前我们先来看看它们的源码,来认识一下他们。首先是Callable,同样是一个函数式接口,其中的call与run类似,但是其具有返回值,可通过泛型来定义。
package java.util.concurrent;
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
? 但是我们知道要创建线程离不开Thread类,所以这里使用了FutureTask进行牵线搭桥。我们可以看到FutureTask类的声明和构造器。
public class FutureTask<V> implements RunnableFuture<V> {
public FutureTask(Callable<V> callable) {
if (callable == null)
throw new NullPointerException();
this.callable = callable;
this.state = NEW;
}
}
? FutureTask继承了RunnableFuture,其源码如下。所以我们可以想到,通过FutureTask构造器可构造一个Runnable实例,这样就可以传入Thread代理执行。
package java.util.concurrent;
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}
? 使用Callable和FutureTask创建线程的步骤如下:
- 创建一个Callable接口的实现类,实现call()方法【编写业务逻辑并设置返回值】
- 使用Callable实现类的实例构造一个FutureTask实例
- 使用FutureTask实例作为Thread构造器的参数target创建一个线程
- 调用Thread实例的start()方法启动新线程,Thread的run方法会执行FutureTask的run方法,最终会调用Callable的call方法
(4)使用线程池创建
? 可以通过Executors工厂类构建线程池,然后通过其execute()【没有返回值,只接收Runnable实例和Thread实例】方法和submit()【可接收有返回值的Callable实例,或Runnable实例和Thread实例】方法实现线程的创建和执行。但生产环境不允许通过Executors创建线程池,需要通过调用ThreadPoolExecutor的构造方法完成。
? 线程池具体原理与操作后续会进行说明。
3、线程的状态与相关操作
(1)相关状态与操作
? 在Thread源码中使用enum枚举了Thread的六种状态:
- NEW(新建):线程创建,未调用start方法启动,这里会调用相应的init方法进行创建【new】
- RUNNABLE(可运行):线程处于可执行状态,已经在Java虚拟机执行,但还在等其它操作系统资源【start()】
- 就绪状态:调用start()、CPU时间片用完、sleep()操作结束、join()操作结束、抢到对象锁、调用yield()方法
- BLOCKED(阻塞):线程处于阻塞,线程在等待一个监控锁,特别是多线程下的场景等待另一个线程同步块的释放。【synchronized】
- WAITING(等待):线程处于等待状态,指的是该线程正在等待另一个线程执行某些特定的操作【wait()、join()】
- TIMED_WAITING(调校时间的等待):与时间相关的等待,调用了设定等待时长参数的方法【sleep(xx)、wait(xx)、LockSupport.parkNanos(xx)/parkUntil(xx)】
- TERMINATED(终止):线程执行完毕的状态或执行过程发生了异常
? 我们可以通过getState()方法获取线程的执行状态,或者通过isAlice()方法判断一个线程是否还存活。
(2)守护线程
? JVM进程中的GC线程就是一个守护线程,守护线程的使用有以下要点:
- 守护线程必须在启动前通过setDaemon()方法将状态设置为true,启动后就不能进行设置,否则报InterruptedException异常
- 守护线程存在被JVM强制终止的风险,所以在守护线程中尽量不去访问系统资源
- 守护线程中创建的线程也是守护线程
(3)线程组
一组线程或线程组的集合,在多线程情况下,对线程进行分组管理。直接在main方法中运行的线程或线程组,都属于main线程组,在main方法中运行的代码上一级为System线程组,其中线程的上一级为main线程组。
① 创建
ThreadGroup threadGroup01 = new ThreadGroup()
② 使用
Thread thread01 = new Thread(threadGroup01,new ThreadImplentsRunnable(),"thread-01");
Thread thread02 = new Thread(threadGroup01,new ThreadImplentsRunnable(),"thread-02")
③ 线程组的枚举
Thread[] threadList = new Thread[10];
threadGroup.enumerate(threadList);
④ main线程组的获取
ThreadGroup mainGroup = Thread.currentThread().getThreadGroup()
4、线程间通信
? 线程的通信可以定义为:当多个线程共同操作共享资源时,线程间通过某种方式互相告知自己的状态,避免无效的资源争夺。通信方式有:等待-通知、共享内存、管道流。其中[等待-通知]是使用较普遍的通信方式。
? Java内置锁可以使用wait()和notify()来实现”等待-通知“的通信方式。使用wait()方法以后,JVM会将当前线程加入该锁监视器的等待集合(WaitSet)。使用notify()后,JVM会唤醒该锁监视器等待集合中的第一条线程,若使用notifyAll会唤醒监视器等待集合的所有线程。
5、线程池的架构设计
? Java线程的创建和销毁代价都比较高,频繁的创建和销毁线程非常低效,所以出现了线程池。线程池的好处:
- 提升性能:不需要自己创建线程,将任务交给线程池去执行,线程池能尽可能使用空闲的线程执行异步任务,对创建的线程实现复用
- 线程管理:线程池可以对线程进行有效管理,使得异步任务得到高效调度执行
(1)线程池架构设计
? ① Executor:Executor是Java异步目标任务”执行者“接口,其只包含一个方法execute(Runnable command)。
? ② ExecutorService:ExecutorService继承Executor,其新增了submit和invoke系列方法,对外提供了异步任务的接收服务。
? ③ AbstractExecutorService:AbstractExecutorService是一个抽象类,它实现了ExecutorService接口。
? ④ ThreadPoolExecutor:JUC线程池的核心实现类,线程池预先提供了指定数量的可重用线程,并对每个线程池都维护了一些基础数据统计,方便线程的管理和监控。
? ⑤ ScheduledExecutorService:继承于ExecutorService,用于完成”延时“和周期性任务的调度线程接口。
? ⑥ ScheduledThreadPoolExecutor:它提供了ScheduledExecutorService中的”延时执行“和”周期执行“等抽象调度方法的具体实现。
? ⑦ Executors是一个静态工厂类,提供了快速创建线程池的方法。
(2)Executors的使用与弊端
? Java通过Executors工厂类提供了4中快捷创建线程池的方法。
方法名 | 功能简介 |
---|
newSingleThreadExecutor() | 创建只有一个线程的线程池 | newFixedThreadPool(int nThreads) | 创建固定大小的线程池 | newCachedThreadPool() | 创建一个不限制线程数量的线程池,任何提交的任务都立即执行,但空闲线程会得到及时回收 | newScheduledThreadPool() | 创建一个可定期或延时执行任务的线程池 |
? 使用Executors工厂类创建线程池有以下潜在问题:
- 通过newFixedThreadPool(int nThreads)创建固定大小的线程池或通过newSingleThreadExecutor()创建只有一个线程的线程池,若任务提交速度持续大于任务处理速度,会造成大量的任务等待,等待队列过大会造成内存溢出异常
- 通过newCachedThreadPool()创建一个不限制线程数量的线程池,若大量任务被启动,则需要创建大量的线程,也可能导致内存溢出异常(OOM,Out Of Memory)
- 通过newScheduledThreadPool()创建一个可定期或延时执行任务的线程池,同样会因为线程数不设限制,从而导致OOM。
5、线程池标准创建方式与相关原理
(1)ThreadPoolExecutor
? 企业开发规范会要求使用标准的ThreadPoolExecutor构造工作线程池,其中会使用到其较重要的构造器如下:
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
① 核心和最大线程数量
? 线程池执行器根据corePoolSize和maximumPoolSize来自动维护线程池的工作线程,当maximumPoolSize被设置为Integer.MAX_VALUE时,线程池可以接收任意数量的并发任务。corePoolSize和maximumPoolSize可以在运行过程中通过set方法动态更改。
? 使用线程池可以降低资源消耗,提高相应速度和线程的管理性。但是线程数配置不合理会适得其反。对于不同的任务类型将配置不同的线程数:
- IO密集型任务:IO操作时间较长会导致CPU处于空闲状态,可将核心线程数设置为CPU核数的两倍
- CPU密集型任务:CPU一直在运行,可将核心线程数设置为CPU核数
- 混合型任务:最佳线程数=((线程等待时间+线程CPU时间)/线程CPU时间)*CPU核数
② BlockingQueue
? 阻塞队列,用于展示接收异步任务,当工作线程多于corePoolSize时,就会将异步任务放到阻塞队列中
③ keepAliveTime
? 空闲线程存活时间,若超过这个时间,非核心线程会被回收
(2)向线程池提交任务的两种方式
? 方式一:execute()
void execute(Runnable command);
? 方式二:submit()
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task,T result);
Future<?> submit(Runnable task);
? 两者区别如下:
- 两者接收的参数不同:execute()只能接收Runnable类型参数,而submit还可以接收Callable类型参数。两者的区别是Callable可以返回执行结果并允许抛出异常,而Runnable不行。
- submit()提交任务有Future类型的返回值,而execute没有
- submit()在执行过程中可以进行异常捕获
- submit()实际上在最终调用时还是调用了execute()方法
(3)任务调度流程
- 工作线程少于corePoolSize,当线程池接收到新任务,即使有空闲线程也会创建一个新线程来处理该请求;
- 工作线程多于corePoolSize少于maximumPoolSize,当阻塞队列blockingQueue未满时,会将新增任务放到阻塞队列中,当阻塞队列满时,会新增一个非核心线程,立即开始执行新任务;
- 每次完成一个任务,执行器总是优先从阻塞队列获取下一个任务,直到所有缓存任务被取完;
- 工作线程数超过maximumPoolSize,corePool和blocking被使用完时,会执行RejectedExecutionHandler拒绝策略。
(4)线程工厂ThreadFactory
? ThreadFactory是Java线程工厂接口,其只包含一个newThread方法。
package java.util.concurrent;
public interface threadFactory{
Thread newThread(Runnable target);
}
(5)任务阻塞队列
? BlockingQueue是JUC的接口,其有常见实现类:ArrayBlockingQueue,LinkedBlockingQueue,PriorityBlockingQueue(优先队列),DelayQueue(无界阻塞延迟队列),SynchronousQueue(同步队列)。
(6)线程池的拒绝策略
? 当线程池关闭或阻塞队列和maximumPoolSize已满时会执行任务拒绝策略。RejectedExecutionHandler是JUC中的拒绝策略接口,其有以下实现:
- AbortPolicy:拒绝策略,默认策略,当队列已满时会拒绝新任务并抛出RejectedExecutionException异常
- DiscardPolicy:抛弃策略,队列已满时新任务直接抛弃,无异常抛出
- DiscardOldestPolicy:抛弃最老任务策略,将最早进入队列的任务抛弃腾出队列空间
- CallerRunsPolicy:调用者执行策略,提交任务的线程自己进行任务的执行
- 自定义策略
(7)线程池的状态与关闭
? 线程池一共有5种状态:
- RUNNING:线程池创建后的初始状态,可以执行任务
- SHUTDOWN:执行了shutdown()实例方法,不在接收任务,会将工作队列的任务执行完毕
- STOP:执行了shutdownNow()实例方法,不再接收任务,也不再执行任务,中断所有工作线程
- TIDYING:所有任务执行完毕,将会执行terminated()钩子方法
- TERMINATED:执行完terminated钩子函数的状态
? 关闭线程池主要涉及三个方法:shutdown、shutdownNow、awaitTermination,一般的关闭流程如下:
- 先执行shutdown()方法,拒绝新任务的提交,等待任务有序执行完毕【RUNNING→SHUTDOWN】
- 然后执行awaitTermination(long timeout,TimeUnit unit)方法,指定超时时间,会判断是否已经执行完成所有任务,若返回ture则说明线程池关闭完成
- 若awaitTermination(long timeout,TimeUnit unit)返回false,则通过shutDownNow()方法立即关闭线程池所有任务
6、ThreadLocal:线程安全解决
(1)ThreadLocal原理
? 为了保证线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中,使变量子啊每个线程都有独立值,不会出现一个线程读取变量时被另一个线程修改。
? TheadLocal类也被称为线程局部变量类。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本,即实现“线程隔离”或“数据隔离”。
? ThreadLocal能实现每个线程都拥有一份变量的本地值,其原理是每个线程都拥有自己独立的ThreadLocalMap空间,采用了空间换时间的方式实现“无锁编程”。
(2)与Synchnized区别
Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。
Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
(3)ThreadLocal成员方法
方法 | 说明 |
---|
set(T value) | 设置绑定本地值 | T get() | 获取绑定的本地值 | remove() | 移除绑定的本地值 |
? 内存泄露:不再用到的内存没有及时释放。使用ThreadLocal时要在使用完本地值后执行remove操作,将对应ThreadLocalMap中的Key置为null,以便GC回收。
二、Java内置锁🌷
? 在进入锁之前我们需要了解以下几个知识点:
-
线程同进程一样,也可以设置临界区资源,当线程申请资源时可以视作线程进入进入区代码段,当线程释放资源时可视作线程进入退出区代码段。 -
临界区代码段是每个线程中访问临界区资源的那段代码,多个线程必须互斥地对临界区资源进行访问。 -
线程的阻塞和唤醒操作需要进程在内核态【使用到原语,即操作系统底层操作】和用户态之间来回切换,这会导致性能降低。
1、synchronized关键字
? 线程同步使用较多的是synchronized,这是一个重量级锁。使用synchronized(Object)调用相当于获取Java对象的内置锁,所以使用sychronized的本质是利用Java对象的内置锁对临界区代码段进行排他性保护。
(1)synchronized同步方法
? 非静态同步方法如下,对实例方法进行synchronized同步,实际上是当前对象this的监视锁,依旧使用的是Java内置锁。
public synchronized void xxxMethod(){
xxx;
}
? 静态同步方法如下,当sychronized修饰静态方法时,当类加载时还未创建相应对象,此时synchronized获取到的是该类对应的class对象的内置锁,我们称其为类锁。使用类锁会导致所有线程都要互斥的进入临界区代码段,粒度较粗。
public static synchronized void xxxMethod(){
xxx;
}
(2)synchronized同步块
? 使用synchronized修饰方法进行同步,实际上是将该对象的资源视作临界区资源,这会导致临界区资源限制等待,所以可以使用synchronized同步块减小临界区范围。
synchronized(Object){
xxx;
}
(3)Synchronized的两个推论
- 被synchronized修饰的同步块对一条线程来说是可重入的,意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入,即称为重量级锁。
2、Java内置锁
(1)Java对象与内置锁
? Java对象结构包含三个部分:对象头、对象体和对齐字节。
? 我们重点看Mark Word,其中包含了GC标志位、分代年龄、哈希码、锁状态等信息。Java内置锁的信息就放在该字段中,其中锁状态分为:无锁、偏向锁、轻量级锁、重量级锁。JDK1.6以前Java内置锁是重量级锁,但是JDK1.6后将锁的状态分为4种,四种状态不可逆。
? JVM中每个对象会有一个监视器,监视器会同对象一起创建和销毁。监视器的义务是保证同一时间只有一个线程可以访问被保护的临界区代码块。
- 任何一个线程进入临界区代码都要获得监视器的许可。
- 监视器同时也负责线程通信监视,进入临界代码的线程可以通过wait进入阻塞状态,等待其它线程使用notify进行唤醒。
(2)内置锁的状态
① 无锁状态
? Java对象刚创建时还没有任何线程来竞争,对象处于无锁状态。
② 偏向锁状态
? 偏向锁是指同一段同步代码一直被同一个线程所访问,该线程会自动获取锁,降低获取锁的代价。偏向锁状态的Mark Word会记录内置锁偏爱的线程的线程ID。
? 锁记录:存在线程的栈帧中,每一个线程都有一份自己的锁记录,锁记录存放的是内置锁的Mark Word内容,供释放锁时使用。
? 偏向锁的加锁过程:新线程判断内置锁对象的Mark Word种的线程ID是不是自己的ID,若不是或内置锁的线程ID为空,则需要使用CAS交换,将自己的线程ID交换到内置锁对象的Mark Word中。
? 偏向锁的撤销:当锁升级时需要撤销偏向锁,这需要停止拥有锁的线程,然后遍历线程的栈帧,检查是否存在锁记录进行撤销,并消除内置锁中的偏向线程ID,将锁升级为轻量级锁。
③ 轻量级锁状态
? 当锁处于偏向锁,又被另一个线程企图抢占时,锁会升级为轻量级锁,哪个线程先占有锁对象,锁对象就指向哪个线程栈帧的锁记录。企图抢占的线程会通过自旋尝试获取锁,该线程不会进入阻塞。
? 自旋:企图抢占锁的线程会进行不断尝试获取锁,当另一个线程释放锁后,即可获取该锁。这样线程就不需要进行内核态与用户态的切换。但是自旋操作消耗CPU,若处于自旋状态的线程过多,或处于自旋状态的时间过长也较为低效。JDK1.6后引入了适应性自旋锁,设定自旋最大时长,在该时间内线程自旋成功,则下次自旋的允许次数就会增多,若线程自旋失败,则下次会减少自旋次数。
? 原理:
- 获取锁的准备:当线程进入同步块时,若该内置锁的标志位为01即未被锁定,虚拟机会在当前线程的栈帧中建立一个锁记录空间(Lock Record),用于存储锁对象Mark Word的拷贝称为Displaced Mark Word。
- 锁的获取:然后虚拟机使用CAS操作尝试将该对象的Mark Word更新为指向锁记录的指针,若更新操作成功,即代表该线程拥有该锁,内置锁的标志位转变为00
- 锁的竞争:更新操作失败,虚拟机首先检查内置锁对象的Mark Word是否指向当前线程栈帧的锁记录,如果是,则说明该线程已经拥有了锁。如果不是,则说明该锁已经被抢占。则进入自旋状态(重复执行CAS)。
- 锁的释放:同样通过CAS操作,将内置锁的Mark Word和线程中最开始拷贝的Displaced Mark Word替换,如果替换成功则释放锁,若替换失败,则说明有其他线程尝试过获取该锁,则需要在释放锁的同时唤醒阻塞线程
④ 重量级锁状态
? 重量级锁会让其他申请的线程进入阻塞,重量级锁也称为同步锁。
? 重量级锁的开销:轻量级锁使用CAS进行自旋抢锁,CAS操作是在用户态下的,但重量级锁对于进程的阻塞和唤醒需要使用到内核操作,即进程会在用户态和内核态之间切换,则重量级锁开销就较大。
3、synchronized执行原理
? ① 线程枪锁时,JVM先检测内置锁对象Mark Word中的偏向锁标识是否为1,锁标志位是否位01,若满足则确认锁对象为可偏向状态。
? ② JVM检查Mark Word中的线程ID是否为抢锁线程ID,如果是就表示抢锁线程处于偏向锁状态。如果不是则会通过CAS操作竞争锁,竞争成功,则将内置锁的Mark Word中的线程ID设置为抢锁线程,即获取锁。
? ③ 若CAS操作失败,说明发生了竞争。就会撤销偏向锁,进而升级为轻量级锁。JVM使用CAS操作将锁对象的Mark Word替换为抢锁线程的记录指针,若成功则获取锁。若失败,JVM会使用CAS自旋替换抢锁线程的所记录指针,若自旋成功,则锁依旧处于轻量级锁状态。
? ④ 若JVM的CAS替换锁记录指针自旋失败,轻量级锁就升级为重量级锁,后面等待的线程不进行自旋,进入阻塞状态。
4、CAS
(1)原理
? Java中的CAS操作是sun包中的Unsafe类的native方法,基于C++。其主要提供了三个“比较并交换”的原子方法,Compare And Set即再Set之前先Compare该值有没有变化,只有在没变的情况下才对其赋值。
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
? CAS指令需要三个操作数,分别是:
(2)使用
? CAS在Java.util.concurrent.atomic包中的原子类、JavaAQS以及显式锁中广泛使用,在JUC原子类中使用了CAS和volatile来实现操作的原子性。内置锁的升级,锁的获取和释放也是通过CAS来完成。
(3)CAS操作的弊端和规避
? 1.ABA问题
? 两个线程同时拿到对象V,V的值为A。一个线程将A改成了B,然后又改成了A。另一个对象想修改V,对其进行CAS操作,期望原值为A,然后确实是A,修改成功,但是实际上改对象发生了改变。
? 规避方式:为变量增加版本号,每次修改都会导致版本号的改变,CAS操作需要检查版本号的值变化。例如Java中的AtomicStampedReference和AtomicMarkableReference。
? 2.只能保证一个共享变量之间的原子性操作
? 当对一个共享变量执行操作时,我们可以使用循环CAS方式保证原子操作,但对多个共享变量时,CAS就无法保证原子性。
? 规避方式:将多个共享变量合并成一个共享变量,例如Java中的AtomicReference,将多个变量放到AtomicReference中再进行CAS操作。
? 3.开销问题
? CAS自旋会给CPU带来较大的开销,可以采用队列,将发生CAS争用的线程放到一个队列中,降低CAS的争用激烈程度,例如JUC中的AQS。
三、JUC显式锁🌹
? Java内置锁使用简单,但功能单一,不具备限时抢锁、可中断抢锁、多个等待队列等高级锁功能。除此外重量级锁的线程状态切换开销较大,性能较低。于是需要使用到Java显示锁Lock。
1、显式锁Lock接口
package java.util.concurrent.locks;
import java.util.concurrent.TimeUnit;
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
? 从Lock接口可看出,Java显式锁具有可中断获取锁、可非阻塞获取锁、可限时抢锁。
2、可重入锁ReentrantLock
? ReentrantLock是JUC包提供的显式锁的一个基础实现类,ReentrantLock类实现了Lock接口,是一个可重入的独占锁。该锁与synchronized具有同样的并发性和内存语义,但在该基础上增加了限时抢占和可中断抢占等功能。
- 可重入:表示该锁能够支持一个线程对资源的重复加锁。即一个线程可多次进入同一个锁所同步的临界区代码块,或者说一个线程能在外层函数获取锁后,在内层函数再次获得该锁,甚至多次抢占同一把锁。synchronized是可重入锁。
- 独占锁:在同一时刻只能有一个线程获取到该锁,而其他获取锁的线程只能等待,只有当线程释放锁后,其他线程才能获取。
? 其部分源码如下
public class ReentrantLock implements Lock, java.io.Serializable {
private final Sync sync;
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
public void lock() {
sync.lock();
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.release(1);
}
public Condition newCondition() {
return sync.newCondition();
}
public boolean isLocked() {
return sync.isLocked();
}
}
3、显式锁的使用
(1)使用lock阻塞抢锁
Lock lock = new SomeLock();
lock.lock();
try{
}finally{
lock.unlock();
}
? 释放锁操作一定在finally中执行,这时防止临界区发生异常导致锁无法释放。lock抢占锁操作需要在try之外,要先获取锁才能执行临界区代码,才需要进行锁的释放。
(2)使用tryLock非阻塞抢锁
Lock lock = new SomeLock();
if(lock.tryLock()){
try{
}finally{
lock.unlock();
}
}
else{
}
? 不常用,一般使用其重载方法tryLock(long time,TomeUnit unit)。
(4)使用tryLock限时抢锁
Lock lock = new SomeLock();
if(lock.tryLock(xxx,TimeUnit.SECONDS)){
try{
}finally{
lock.unlock();
}
}
else{
}
? 该方法在抢不到锁时会阻塞一段时间,在阻塞期间获取倒锁则会返回true。
4、LockSupport
? LockSupport是JUC提供的一个线程阻塞和唤醒的工具类,该工具类可以让线程在任意位置阻塞和唤醒,其所有方法都是静态方法。其方法主要分为两类:park和unpark。
? LockSupport.park()与Thread.sleep()区别:
- 两者都会让线程阻塞,调用sleep不能主动唤醒线程,只能等待线程自己醒过来,而使用park,可调用unpark主动唤醒线程
- sleep是一个native方法,park不是native方法,但其本质还是调用的native方法
- 两者都不会释放锁,sleep声明了中断异常,park不会抛出异常
? LockSupport.park()与Object.wait()区别:
- Object.wait方法需要在synchronized块中执行,而LockSupport.park可以在任意地方执行
- 若线程在执行wait之前执行了notify操作,则会抛出异常,而park和unpark不会抛出异常
- wait会释放锁,park不会释放锁
5、锁的类型
(1)可重入锁和不可重入锁
? 可重入锁指一个线程可以多次抢占同一个锁。例如线程A在进入外层函数时抢占了一个Lock显示锁后,当线程A继续进入内层函数时,遇到有抢占同一个Lock显式锁的代码,线程A依然可以抢到该Lock显式锁。例如synchronized、ReentrantLock。
? 不可重入锁指一个线程只能抢占一次同一个锁,例如当线程A在进入外层函数时抢占了一个Lock显示锁后,当线程A继续进入内层函数时,遇到有抢占同一个Lock显式锁的代码。线程A不可抢占该锁,除非线程A释放之前占有的Lock显式锁。
(2)悲观锁和乐观锁
? 悲观锁:每次进入临界区操作数据时都认为别的线程会进行修改,所以线程每次在读写数据时都会上锁。例如synchronized重量级锁。
? 乐观锁:每次拿数据时都认为别的线程不会进行修改,只有在提交数据更新时才会检查数据是否被修改(一般采用版本号的方式),才会进行加锁执行写操作。例如synchronized轻量级锁、JUC中基于抽象队列同步器实现的显式锁(如ReentrantLock)。
? Java乐观锁都是通过CAS自旋操作实现,即比较当前值和传入值是否相同,是则更新,不是则失败。
(3)公平锁和非公平锁
? 公平锁:指不同的线程抢占锁的机会是公平的,抢锁成功次序为FIFO。例如默认情况下ReentrantLock是非公平锁,可设置为公平锁,synchronized也是非公平的。
? 非公平锁:指不同线程抢占锁的机会不同,抢锁次序不一定为FIFO。
(4)可中断锁和不可中断锁
? 可中断锁:这里是中断阻塞的意思,即若A线程正占有锁,B阻塞式抢占锁,B可以中断自己的阻塞继续执行。例如JUC显式锁。
? 不可中断锁:若线程在抢占锁失败进入阻塞时,不能中断阻塞,只能继续等待直到拿到锁。例如synchronized。
(5)共享锁和独占锁
? 独占锁:一个锁一次只能由一个线程占有。例如ReentrantLock、synchronized
? 共享锁:允许多个线程同时获取锁。例如ReentrantReadWriteLock是一个共享锁实现类,读操作可以有很多线程一起读,但写操作只能有一个线程写。
(6)读写锁
? 通过ReentrantReadWriteLock类能获取读锁和写锁,它的读锁是可以多线程共享的共享锁,而它的写锁是排他锁。同一时刻不允许读锁和写锁同时被抢占,二者互斥,具体使用范例如下。
final static ReetrantReadWriteLock RWLock = new ReentrantReadWriteLock();
final static Lock readLock = RWLock.readLock();
final static Lock writeLock = RWLock.writeLock();
final static Map<String,String> map = new ReentrantReadWriteLock();
public static String put(String key,String value){
writeLock.lock();
try{
String put = map.put(key,value);
return put;
}finally{
writeLock.unLock();
}
return null;
}
public static String get(String key){
readLock.lock();
try{
String value = map.get(key);
return value;
}finally{
readLock.unLock();
}
return null;
}
四、AQS:显式锁的原理🍁
? CAS自旋会导致浪费大量CPU资源,可以使用队列削峰来解决。JUC并法包中的许多类比如ReentrantLock都是基于AQS构建的。AQS是一个双向队列的抽象基础类AbstractQueuedSynchronizer,称为抽象同步队列类,简称AQS。显式锁的获取与释放都基于AQS实现
1、AQS的组成
? AQS的核心组成有四个模块:状态标志位、队列节点类、FIFO双向同步队列、钩子方法
(1)状态标志位
private volatile int state;
protected final int getState() return state;
protected final boolean compareAndSetState(int expect,int update){
return unsafe.compareAndSwapInt(this,stateOffset,expect,update);
}
? 以ReentrantLock为例,state初始化状态为0,表示未锁定。当A线程执行lock操作时,会调用tryAcquire()独占该锁并将state加1.此后,其他线程再tryAcquire()时就会失败,直到线程执行unlock时,将state减1为0,其他线程才能获取该锁。
? Reentrant的可重入:若A线程在释放锁之前,重复获取该锁,state会进行累加。但获取多少次就得unlock多少次,才能释放锁。
(2)队列节点类
? AQS是一个虚拟队列,不存在队列实例,仅存在节点间的前后关系。
static final class Node {
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
Node nextWaiter;
volatile Thread thread;
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
}
? AQS节点与等待线程相关联,每个节点维护一个状态watiStatus。
? 值为0:表示当前节点处于初始状态。
? 值为-1:表示其后继节点处于等待状态,若当前节点释放锁或被取消,会通知后继节点,使后继节点开始运行。
? 值为1:表示当前节点已释放(中断或等待超时),取消等待。
? 值为-2:表示该线程在条件队列中阻塞。
? 值为-3:表示当前锁为共享锁,该节点线程获取共享锁后通知后续的共享线程赶快来享用。
(3)FIFO双向同步队列
? AQS通过内置的FIFO双向队列来完成线程的排队工作,内部通过节点head和tail记录队首和队尾元素,元素节点类型为Node类型。每当线程通过AQS获取锁失败时,线程将被封装成一个Node节点,通过CAS操作插入队列尾部。当有线程释放锁时,AQS会尝试让队头的后继节点占用锁。
private transient volatile Node head;
private transient volatile Node tail;
(4)钩子方法
? AQS定义了两种资源共享方式:独占锁和共享锁。其为不同的资源共享方式提供了不同的模板流程,自定义的同步器只需要实现共享资源state的获取和释放即可,这些逻辑都编写在钩子方法中,需要重写的钩子方法如下:
- tryAcquire(int):独占锁钩子,尝试获取资源,成功返回true
- tryRelease(int):独占锁钩子,尝试释放资源,成功返回true
- tryAcquireShared(int):共享锁钩子,尝试获取资源,失败返回负数,成功返回正数表示还有剩余资源,返回0表示获取成功但无剩余资源
- tryReleaseShared(int):共享锁钩子,尝试释放资源,成功返回true
- isHeldExclusively():独占锁钩子,判断该线程是否正在独占资源。
2、AQS实现锁原理
(1)自己实现一个简单锁
public class SimpleLock implements Lock{
private final Sync sync = new Sync();
private static class Sync extends AbstractQueuedSynchronizer{
protected boolean tryAcquire(int arg){
if(compareAndSetState(0,1)){
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
protect boolean tryRelease(int arg){
if(Thread.currentThread()!=getExclusiveOwnerThread){
throw new IllegalMonitorStateException();
}
if(getState()==0){
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
}
public void lock(){
sync.acquire(1);
}
public void unlock(){
sync.release(1);
}
}
(2)AQS锁抢占原理
① 锁抢占执行过程
? 依上图,首先Lock实现类执行lock(),该方法会调用AQS的模板方法acquire(),该模板方法会调用我们实现的同步器Sync的tryAcquire()方法,如果tryAcquire()返回true,则表示获取锁成功。若失败,会将该线程加入等待队列。
② acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();
}
? 我们可以看到acquire只使用了一行代码,但是其执行逻辑顺序却不简单。
- 首先进入if判断,会先执行&&前面的方法tryAcquire(),若该方法返回true,则[!true]为falseif判断执行结束,线程获取锁。
- tryAcquire()若返回flase,会执行&&后面的addWaiter()方法,该方法会将线程加入等待队列,并返回加入等待队列的节点给acquireQueued()方法。
- 然后执行acquireQueued()的方法,该方法会启动自旋抢锁,若抢锁失败则会返回true,线程执行interrupt()
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
? 这里补充Thread.interrupt()的相关使用:
③ addWaiter()
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
? 若addWaiter第一次在尾部添加节点失败,则意味着有抢锁情况发生,需要进行自旋,自旋的方法为enq(node)。
④ acquireQueued()
? 在节点入队后,启动自旋抢锁操作acquireQueued():当前Node节点线程在死循环中不断获取同步状态,并且不断在前驱节点上自旋,只有当前驱节点是头节点时才能尝试获取锁。
? 处于自旋的节点对应的线程将会被挂起进入阻塞状态这样就不会执行无效的空循环,如果头节点获取了锁,那么该节点绑定的线程会终止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);
p.next = null;
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
(3)AQS释放锁原理
① 锁释放执行过程
? 依上图,当Lock实现类执行unLock()方法时,会先调用AQS的模板方法relaese()方法,该方法会调用我们Sync实例的tryRelease(int)方法,tryRelease(int)方法执行成功后会调用unparkSuccessor()来唤醒后继节点。
② release()
? release()方法会先执行tryRelaes()方法,如果返回true,则说明释放锁成功。若头节点不位null,说明后面有后继节点在等待获取锁,这就需要唤醒后继节点,具体唤醒方法为unparkSuccessor。
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
② tryRelease()
protect boolean tryRelease(int arg){
if(Thread.currentThread()!=getExclusiveOwnerThread){
throw new IllegalMonitorStateException();
}
if(getState()==0){
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
? tryRelease()的核心逻辑是将同步状态设置为0,即释放锁。tryAcquire()的核心逻辑是通过CAS将同步状态从0置换为1。
③ unparkSuccessor(h)
? 节点在获得锁时就已经出队,则AQS队列的头节点就是下一个获得锁的节点。我们只需要判断头节点是否为null,不为null则使用unparkSuccessor()将其唤醒。
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 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);
}
? 这里唤醒方法使用的是LockSupport支持类的unpark方法将其唤醒,唤醒后该节点继续执行acquireQueued()方法。
3、AQS条件队列
? Condition是JUC用来替代传统Object的wait()/notify()线程间通信与协作机制的新组件。
(1)Condition基本原理
? Condition中的await和signal与Object中的wait()/notify()类似,前者通过Java的基础类库实现,而后者是JVM底层的native方法。
public interface Condition {
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
? 在AQS中有一个内部类ConditionObject是实现条件队列的关键,每个ConditionObject对象都维护一个单独的条件等待队列。
public class ConditionObject implements Condition, java.io.Serializable{
private transient Node firstWaiter;
private transient Node lastWaiter;
}
? 在一个显式锁上我们可以创建多个等待任务队列。Condition条件队列是单向的,AQS同步队列是双向的。一个AQS实例可以有多个条件队列,但只有一个同步队列。
private Lock lock = new ReentrantLock();
private Condition waitQueue1 = lock.newCondition();
private Condition waitQueue2 = lock.newCondition();
(2)await等待方法原理
? 当线程调用await()时,说明当前线程的节点为当前AQS同步队列的头节点,正好处于锁状态。await()方法会将线程从AQS同步队列移动到Condition条件队列,释放锁并唤醒AQS同步队列中头节点的下一个节点。
(3)signal唤醒方法原理
? 线程在某个ConditionObject对象上调用siganl()后,Condition条件队列中的firstWaiter会被加入到AQS同步队列中,等待节点被唤醒。整体流程如下:
- 通过enq()方法自旋将Condition条件队列的头节点放到AQS同步队列的尾部,并获取它在AQS队列的前驱节点
- 如果前驱节点是取消状态,就唤醒当前节点的线程,否则节点就在同步队列的尾部,参与排队
- 该线程被唤醒后,会继续执行condition.await()后续的代码
五、JUC原子类🏵?
? Atomic操作指不可中断的操作,多个线程一起执行Atomic类型操作时,一个操作一旦开始就不会被其他线程中断。JUC并发包中的原子类都存放在java.util.concurrent.atomic类路径下,可分为4类。
- 基本原子类
- AtomicInteger
- AtomicLong
- AtomicBoolean
- 数组原子类
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
- 引用原子类
- AtomicReference
- AtomicMarkableReference
- AtomicStampedReference
- 字段更新原子类
- AtomicInteferFieldUpdater
- AtomicLongFieldUpdater
- AtomicReferenceFieldUpdater
(1)基本类型原子操作
? 在多线程环境下,若涉及基本数据类型的并发操作,不建议采用synchronized重量级锁进行线程同步,建议优先使用基础原子类。
? 基础原子类基于CAS自旋+volatile的方案实现。
(2)引用类型原子操作
? 对于对象变量也可以通过JUC原子类实现其原子操作,可以使用原子类型原子类和属性更新原子类。
? 使用AtomicReference对对象引用进行原子性修改,首先需要对对象进行包装,然后再需要更新值的引用时采用CAS交换。
class Person{
int no;
public Person(int no){
this.no = no;
}
}
class Main{
public static void main(String args[]){
AtomicReference<Person> atomicPerson = new AtomicReference<>();
Person person1 = new Person(1);
atomicPerson.set(person);
Person person1 = new Person(2);
boolean success = atomicPerson.compareAndSet(person1,person2);
}
}
六、volatile🍀
1、并发三大问题
(1)原子性问题
? 原子操作,即不可中断的一个或一系列操作。我们之前的内置锁、显示锁以及原子类都是解决原子性问题的方案。
(2)可见性问题
? 一个线程对共享变量的修改,另一个线程立刻可见。
? 可见性问题原因:工作私有内存与主存同步延迟
- 每个线程想要修改共享变量前,都需要将该变量复制到自己的私有内存中,然后更新该变量的值。更新的值刷新到主存的时间是不确定的。
- 如果这时候其他线程也想要修改该共享变量,只会基于原来主存的变量进行修改。就可能发生多次值的刷新,导致修改失败。
? 使用Java提供的关键字volatile修改共享变量可解决可见性问题。
(3)有序性问题
? 有序性指的是程序代码执行的先后顺序,编译器编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,使语句执行顺序发生改变。
? 有序性问题原因:在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程。
? 如下伪代码,有线程A和线程B,其通过拥有共享变量flag,我们想让线程A输出"hello"后,线程B再输出"world",如果flag=true提前执行了,则可能导致"world"被先输出。
boolean flag = false;
System.out.println("hello");
flag = true;
while(!flag){
sleep();
}
System.out.println("world");
? 使用Java提供的关键字volatile修改共享变量也可以解决有序性问题。
2、volatile原理
? 使用volatile修饰的变量,在编译后增加一个以lock为前缀的操作,该lock前缀操作具体有三个功能:
- 可见性:将当前CPU缓存行的数据立即写到内存,使其他CPU中缓存了该内存地址的数据无效
- lock前缀指令会使执行线程的CPU独占主存,这时通过缓存一致性来阻止两个CPU同时修改共享内存的数据,每次CPU的缓存都会立即刷新到主存。
- 每个CPU在操作共享数据前会先检查自己的缓存是否过期,当CPU发现自己缓存行对应的内存地址被修改时,会将该缓存行设置为无效,操作共享数据时会重写从主存将数据读到CPU缓存。
- 有序性:禁止指令重排序
- 建立一个内存屏障,重排序时不能将后面的指令重排序到内存屏障之前的位置
——————————————————————
作者:未来村村长
参考: [1]《Java高并发核心编程》尼恩 [2]《深入理解Java虚拟机》周志明
个人网站:www.76pl.com
👨?🌾可以收藏加关注下吗🙂👩?🌾
——————————————————————
|