JUC笔记
JUC笔记
简介
JUC(Java Util Concurrent)是 Java并发工具包,它是 Java标准库(JDK)的一部分,专门为多线程编程提供了强大的工具和类库。JUC的设计目的是帮助开发者更容易、高效地处理并发问题,尤其是在高并发的场景下,减少线程安全问题和提高性能。通过合理使用 JUC 提供的工具,可以提高程序的并发性能、代码的可读性与可维护性,减少多线程编程中常见的错误和性能问题。JUC 是现代 Java 开发中不可或缺的工具之一,在高性能和高并发系统的开发中起着至关重要的作用。
基本概念
进程与线程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在 指令运行过程中还需要用到磁盘、网络等设备。当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。进程就可以视为程序的一个实例。
一个进程之内可以分为一到多个线程。线程作为最小调度单位,进程作为资源分配的最小单位。 从属于同一个进程的不同线程之间拥有共享资源,线程通信相对简单且上下文切换成本更低,进程间通信则较为复杂。
并行与并发
单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感 觉是 同时运行的 。总结为一句话就是:微观串行,宏观并行。
一般会将这种 线程轮流使用 CPU 的做法称为并发, concurrent。
多核 cpu下,每个核(core) 都可以调度运行线程,这时候线程可以是并行的。
临界区
一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源,在多个线程对共享资源读写操作时发生指令交错,就会出现问题,一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
Java线程
创建和运行线程
继承Thread类
实现Runnable接口,配合Thread
实现Callable接口,配合FutureTask、Thread
Java里的线程和操作系统线程是一样的,是 1 对 1 的线程模型
。
Linux下查看线程
ps -fe 查看所有进程
ps -fT -p 查看某个进程(PID)的所有线程
kill 杀死进程
top 按大写 H 切换是否显示线程
top -H -p 查看某个进程(PID)的所有线程
jps 命令查看所有 Java 进程
jstack 查看某个 Java 进程(PID)的所有线程状态
部分方法
Thread
isInterrupted(),判断是否被打断,不会清除 打断标记。
interrupt(),打断线程,如果被打断线程正在 sleep,wait,join 会导致被打断的线程抛出 InterruptedException,并清除打断标记 ;如果打断的正在运行的线程,则会设置打断标记 ;park 的线程被打断,也会设置打断标记。
sleep(long n),让当前执行的线程休眠n毫秒, 休眠时让出 cpu 的时间片给其它线程,不会释放锁。
join(),等待线程运行结束。
Object
wait() 让进入 object 监视器的线程到 waitSet 等待,会释放锁。
notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒 。
notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒。
线程池
ThreadPoolExecutor,ThreadPoolExecutor 使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量。
构造方法:
1 | public ThreadPoolExecutor(int corePoolSize,//核心线程数 |
拒绝策略:
AbortPolicy 让调用者抛出 RejectedExecutionException 异常,这是默认策略
CallerRunsPolicy 让调用者运行任务
DiscardPolicy 放弃本次任务
DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之
调用 interrupt 是如何让线程抛出异常的?
每个线程都有一个与之关联的布尔属性来表示其中断状态,初始为false,当一个线程被其他线程调用interrupt方法中断时,会根据实际情况做出响应。
如果该线程正执行低级别的可中断方法,如sleep,join,wait,则会清除打断标记,并抛出InterruptException异常。
否则仅设置打断标记,在被中断的线程种可通过轮询打断标记来决定是否要停止当前正在执行的任务。
happens before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。
synchronized解锁m对象之前对变量的写对接下来对m加锁的线程对该变量的读可见
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
其他线程在该线程 start 前对变量的写,对该线程开始后对该变量的读可见
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待 它结束)
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
Synchronized原理
synchronized是悲观锁、非公平锁,synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割 的,不会被线程切换所打断。
①乐观锁 VS 悲观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
②公平锁 VS 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
③无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,专门针对synchronized的。
synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
Java对象头
普通对象(非数值对象)的对象头如下:
其中Mark Word结构如下:
四种锁状态对应的的Mark Word内容如下:
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。CAS原理及应用即是无锁的实现。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
如果调用 hashCode 会导致偏向锁被撤销,轻量级锁在锁记录中记录hashCode,重量级锁会在Monitor中记录hashCode。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
首先将在当前线程的栈帧中建立一个名为锁记录(LockRecord)的空间,用于存储锁对象目前的MarkWord的拷贝,然后拷贝对象头中的MarkWord复制到锁记录中。拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行(重入,见下图),否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后当前线程进入 Monitor 的 EntryList 中BLOCKED,当Thread 0退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头失败,进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
Volatile原理
volatile能够保障可见性和有序性, volatile 的底层实现原理是内存屏障,对 volatile 变量的写指令后会加入写屏障,对 volatile 变量 的读指令前会加入读屏障。
可见性:
- 写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中,而读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
有序性:
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后,读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。
并发安全类
原子类(CAS)
原子整数
AtomicInteger、AtomicBoolean、AtomicLong
原子引用
AtomicReference、AtomicStampedReference(带版本号,解决ABA问题)
原子数组
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
字段更新器
AtomicReferenceFieldUpdater、AtomicIntegerFieldUpdater、AtomicLongFieldUpdater
原子累加器
LongAdder(引入累加单元cell将累加操作的cas进行区分,同时防止了cell的伪共享)
缓存行伪共享:多个线程对不同变量进行操作时,由于这些变量位于同一缓存行中,导致缓存行频繁失效在不同的 CPU 核心之间传输。
不可变类
String、Integer等
AQS原理
AQS全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。AQS 类的核心数据结构是CLH 锁的变体。
自旋锁存在的问题
自旋锁实现简单,同时避免了操作系统进程调度和线程上下文切换的开销,但他有两个缺点:
第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差。
CLH锁
CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。
CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁时,只有它的后一个节点才可以得到锁。CLH 锁是一种隐式的链表队列,没有显式的维护前驱或后继指针。因为每个等待获取锁的线程只需要轮询前一个节点的状态就够了,而不需要遍历整个队列。
优点:
性能优异,获取和释放锁开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。
公平锁。先入队的线程会先得到锁。
实现简单,易于理解。
缺点:
有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。
功能单一,不改造不能支持复杂的功能。
AQS对CLH锁的改造
针对 CLH 的缺点,AQS 对 CLH 队列锁进行了一定的改造。针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。针对第二个缺点,AQS 对 CLH 锁进行改造和扩展。
AQS 中的对 CLH 锁数据结构的改进主要包括三方面:
扩展每个节点的状态
显式的维护前驱节点和后继节点
AQS 用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞。
辅助 GC
在 AQS 中需要在释放锁时显式的设置为 null,避免引用的残留,辅助垃圾回收。
AQS使用int state表示同步状态,并提供了final修饰的get、set、compareAndSetState方法。
对于互斥锁实现,通常state = 0
表示锁可用,state = 1
表示锁被占用。
对于信号量实现,通常state
表示许可数量,每次acquire
操作减少,每次release
操作增加。
对于可重入锁实现,通常state
表示重入次数,初始为0
,每次同一线程获取锁时增加,释放时减少。
对于读写锁实现,通常将state
将分为两部分,高16
位记录写锁状态,低16
位记录读锁数量。
继承AQS的子类主要重写以下方法:
一般来说,自定义同步器要么是独占方式,要么是共享方式,它们也只需实现 tryAcquire - tryRelease、tryAcquireShared - tryReleaseShared 中的一种即可。AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。
AQS框架(有颜色的为 Method,无颜色的为 Attribution):
AQS队列-Node
AQS 是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配,Node为AQS队列中的节点,节点状态上面有图。
加锁代码(JDK17):
1 | final int acquire(Node node, int arg, boolean shared, |
ReentrantLock原理
ReentrantLock内部有一个内部类Sync,Sync继承AQS,添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:!hasQueuedPredecessors(),hasQueuedPredecessors()用于判断是否有其他线程正在队列中等待获取锁。
九、ThreadLocal(TODO)
十、ConcurrentHashMap(TODO)
部分内容转载自黑马程序员
预览: