文章目录
1、Java Thread
1.1 进程与线程
- 进程是资源分配的最小单位,线程是程序执行的最小单位
- 进程有自己独立的地址空间,每启动一个进程,系统就会为它分配地址空间,这种操作非常昂贵,而线程是共享进程中的数据的,使用相同的地址空间,创建和切换一个线程的花费要远比进程小很多
- 同一进程下的线程共享全局变量、静态变量等数据,因此线程之间的通信更方便,而进程之间的通信是要以IPC的方式进行
- 多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响
1.2 守护线程
守护线程是指程序运行时在后台提供一种通用服务的线程,当用户线程(非守护线程)运行结束时,守护线程也会停止,需要注意的是在操作系统中是没有守护线程的,只有守护进程的说法。
- 设置一个守护线程,Thread类的setDaemon()设置为true,并且该方法必须在start()方法之前设置
- 在守护线程中产生的新线程也是守护线程
- 对于一些计算操作或者IO操作最好不要分配给守护线程,因为可能还没来得及执行,程序就退出了
- Java中的垃圾回收线程就是守护线程,另一个例子比如线程池中的调度线程,调度线程只负责接受请求,接受请求之后从线程池中分配一个工作线程来处理请求,以实现并发(Tomcat中的Acceptor和Poller)
1.3 创建线程的方式
- 继承Thread类,重写run方法
- new Thread(Runnable command)
- new Thread(Callable command)
1.4 线程状态
1.4.1 操作系统层面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F16GBJ69-1692550108999)(…\Pictures\blog\thread_state_pc.png)]
- 初始状态:仅仅在语言层面上创建了线程,还未与操作系统线程关联
- 可运行状态:线程已经创建(与操作系统线程关联),可以被CPU调度执行
- 运行状态:获取了CPU时间片的执行权,当时间片用完后,会导致线程的上下文切换,从运行态转换为可运行状态
- 阻塞状态:如果线程调用了阻塞API,比如BIO读写文件,这时线程不会用到CPU,会发生线程切换,进入阻塞状态,等BIO操作完成,会由操作系统唤醒阻塞的线程,转为可运行状态
- 终止状态:线程执行完毕
1.4.2 Java API层面
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wvZMVvnw-1692550109001)(…\Pictures\blog\java_thread_state.png)]
- New:线程被创建,还没有调用start方法
- Runnable:对应操作系统线程状态中的两种,Ready和Running,所以Java中处于Runable状态的线程可能正在被执行,也可能在等待CPU的调度
- Blocked:只有当进入到synchronized代码块中时没能获得到相应的锁时才会处于这个状态,也就是这个状态只与synchronized挂钩。在ReentrantLock.lock()上阻塞的线程是处于Waiting状态的,lock方法本质上是调用的park方法。处于Blocked状态的线程是在等待其它线程释放monitor锁(被动行为)
- Waiting:处于Waiting状态的线程则是在等待某个条件(主动行为),比如join的线程执行完毕或者是notify/notifyAll。如果线程调用wait方法在等待状态,当其它线程调用notify或notifyAll方法,如果该线程没有抢到monitor锁时会进入Blocked状态,如果获得了monitor锁才会进入Runnable状态
- Timed Waiting:有时间限制的等待,超时后由操作系统唤醒(sleep|join|parkNanos|parkUntil),当被notify、notifyAll提前通知唤醒时,如果获取到monitor锁则进入Runable状态,如果没有获得monitor锁,进入Blocked状态
- Terminated:run方法正常执行完毕或者运行时抛出异常未处理
1.5 线程常见方法
1.5.1 start与run
start方法是用来启动一个线程,如果该线程获得了时间片,则就会执行run方法。而run方法是Thread类中的一个普通方法而已,直接调用run方法相当于调用了一个普通方法,并不会启动一个线程
1.5.2 sleep和yield
- sleep方法会让线程从Running进入Timed Waiting状态,会释放CPU执行权,但是当前线程进入了同步锁,sleep方法不会释放锁,结束休眠后未必会立刻执行,使用TimeUnit的sleep方法来代替Thread.sleep方法来获得更好的可读性
- yield方法会让线程从Running进入Ready状态,给更高优先级的线程使用CPU,当然优先级仅仅是个提示,调度器是可以忽略它的,一般cpu繁忙时优先级高的线程获得的时间片会更多,CPU空闲时,优先级几乎没什么用
1.5.3 sleep和wait
- sleep方法是Thread的静态方法,是线程用来控制自身流程的,会把CPU执行权让给其它线程,等到时间一到,会由操作系统唤醒,而wait方法是Object的方法,用于线程间的通信,会让拥有monitor锁的线程进入Waiting状态,直到其它获取到锁的线程调用notify或notifyAll
- 当线程获取到monitor锁时,调用sleep不会释放锁,而wait方法会释放锁
- wait方法必须放在同步块中使用,sleep方法可以在任何地方使用
- 其它线程可以使用interrupt方法打断正在sleep的线程,此时sleep方法抛出中断异常,而wait方法不需要捕获异常
1.5.4 join
- join方法的主要作用就是同步
- join方法中可以传入等待时间,表示调用方等待给定时间后,就不会等待了,如果传入join(0)等同于join(),表示一直等待被调用方执行完毕或者被调用方执行抛出异常
- join方法必须在start方法之后调用
- join方法底层是调用wait方法实现的,所以join方法也会释放锁
1.6 线程中断机制
首先需要明白一个线程不应该由其它线程来强制中断或停止,而应该由线程自己自行停止,在Java中没有办法能够立即停止一个线程,Java提供了用于停止线程的协商机制,也就是中断标识协商机制。每个线程都有一个中断标识位,true标识中断,false标识未中断
-
普通方法interrupt():调用该方法会设置中断标志位为true,可以在别的线程中调用,也可以自身调用。如果线程正常运行,调用该方法,仅仅设置打断标识,如果线程处于阻塞状态(wait|sleep|join等),调用该方法,线程立即退出阻塞状态并抛出中断异常
-
普通方法isInterrupted():返回线程的中断标识位
-
静态方法interrupted():返回线程的中断标识位,并将标志位重置为false
两阶段终止模式:
public static void main(String[] args) { Thread t1 = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { TimeUnit.SECONDS.sleep(2); System.out.println("======模拟线程正在执行任务======"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); //二阶段终止 System.out.println(Thread.currentThread().isInterrupted()); } } }, "t1"); t1.start(); t1.interrupt(); //一阶段打断 }
1.7 线程间通信(线程同步问题)
-
Object类的wait和notify(基于内置管程synchronized的隐式锁)
//交替打印A1B2C3... private static volatile boolean strHasPrint; public static void main(String[] args) { Object monitor = new Object(); Thread t1 = new Thread(() -> { for (char i = 'A'; i <= 'Z'; i++) { synchronized (monitor) { while (strHasPrint) { try { monitor.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.print(i); strHasPrint = true; monitor.notify(); } } }); Thread t2 = new Thread(() -> { for (int j = 1; j <= 26; j++) { synchronized (monitor) { while (!strHasPrint) { try { monitor.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.print(j); strHasPrint = false; monitor.notify(); } } }, "t2"); t1.start(); t2.start(); }
-
JUC中Condition的await和signal(基于管程Lock接口的显式锁)
//交替打印A1B2C3... private static volatile boolean strHasPrint; public static void main(String[] args) throws InterruptedException { ReentrantLock lock = new ReentrantLock(); Condition condition = lock.newCondition(); Thread t1 = new Thread(() -> { for (char i = 'A'; i <= 'Z'; i++) { try { lock.lock(); while (strHasPrint) { condition.await(); } System.out.print(i); strHasPrint = true; condition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } }); Thread t2 = new Thread(() -> { for (int j = 1; j <= 26; j++) { try { lock.lock(); while (!strHasPrint) { condition.await(); } System.out.print(j); strHasPrint = false; condition.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } }); t1.start(); t2.start(); }
-
JUC中LockSupport的park和unpark(工具类,基于信号量实现)
//交替打印A1B2C3... private static volatile boolean strHasPrint; private static Thread t1 = null, t2 = null; public static void main(String[] args) throws InterruptedException { t1 = new Thread(() -> { for (char i = 'A'; i <= 'Z'; i++) { while (strHasPrint) { LockSupport.park(); } System.out.print(i); strHasPrint = true; LockSupport.unpark(t2); } }); t2 = new Thread(() -> { for (int j = 1; j <= 26; j++) { while (!strHasPrint) { LockSupport.park(); } System.out.print(j); strHasPrint = false; LockSupport.unpark(t1); } }); t1.start(); t2.start(); }
构造线程时未传入ThreadGroup时,Thread默认和父线程在同一个ThreadGroup,可以指定线程栈占用的大小-Xss10M。
1.8 查看进程线程命令
- windows
- tasklist
- taskkill
- linux
- ps -ef 查看所有进程
- ps -fT -p 查看某个进程的所有线程
- top 按大写H切换是否显示线程
- top -H -p 查看某个进程的所有线程
- java
- jps 查看所有java进程
- jstack 查看某个java进程的所有线程状态
- jconsole 查看某个java进程中线程的运行情况(图形界面)
1.9 线程运行原理
栈与栈帧
线程上下文切换
- 线程的CPU时间片用完
- 垃圾回收,切换为垃圾回收线程
- 有更高优先级的线程需要运行
- 线程自己调用了sleep、yield、wait、join、park、synchronized、lock等方法
- 当线程上下文切换时,需要由操作系统保存当前线程的状态,并回复另一个线程的状态,Java中对应的就是程序计数器,它是线程私有的,作用是记住下一条jvm指令的执行地址,
2、JMM
JMM定义了Jvm对内存数据的访问规则,线程和主存的抽象关系(多线程进行共享数据读写的一种规则)
JMM屏蔽了不同硬件和操作系统的内存访问差异,保证了Java程序在各种平台下都能获得一致的内存访问效果
JMM是一种模型与规范
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jiywAUAH-1692550109001)(…\Pictures\blog\JMM.png)]
2.1 并发编程的三大特征
JMM主要围绕并发下引起线程安全的三个特征展开
2.1.1 原子性
- 一个操作不能被打断,要么全部执行完毕,要么不执行。
- Java中实现原子性的两种方式:锁和CAS。
- Java中基本数据类型和引用类型的变量的赋值操作都是原子性的,但在32位的Jvm中long和double的读写是分两次进行,是线程不安全的,一些复合操作比如i++,Object o = new Object(),a=b等都是线程不安全的。
2.1.2 可见性
-
是由于编译器优化、CPU指令优化导致的
-
一个线程对共享变量修改之后,后续访问该变量的线程能够立即看到这种变化。
-
Java中volatile修饰的变量能够保证多线程之间操作变量的可见性,使用锁也可以保证可见性。
2.1.3 有序性
-
是由于编译器优化、CPU指令优化导致的
-
程序执行顺序不一定是按照编写的顺序来执行的,可能经过了编译器的重排序、处理器的重排序和存储子系统的重排序,在单线程下遵循As-If-Serial规则,也就是单线程中指令重排序不会对结果造成影响。但在多线程下,一个线程中的指令重排序虽然对自身没有影响,但是可能会影响另一个线程的执行结果(主存和工作内存的延迟同步)。
-
Java中的有序性是通过内存屏障实现的,锁也可以实现有序性。
-
Java天然支持的有序性规则:Happens-Before规则
- 程序次序规则
- 管程锁定规则
- volatile变量规则
- 线程启动规则
- 线程中断规则
- 线程终止规则
- 对象终结规则
- 传递性
原子性保证了要么执行,要么不执行,不会出现中间结果,但是即使原子性保证了,是否对另外的线程可见,是无法保证的,所以需要可见性,而有序性则是另外的线程对当前线程执行看起来的顺序,所以如果都不可见,何谈有序性,所以可见性是有序性的基础,另外,有序性对可见性是有影响的,比如有些操作本来在前面,结果是可见的,但是重排序之后,可能导致不可见。
2.2 理解内存屏障
由于CPU运算速度和内存访问速度不一致,在CPU和内存之间引入了高速缓存(L1,L2,L3),多个CPU各自拥有一组高速缓存,这就有可能引起各个CPU数据不一致的问题,因此需要保证缓存的一致性。缓存读取数据是按照缓存行的大小来读取的,Linux系统默认缓存行大小为64字节。
2.2.1 缓存一致性协议
缓存一致性协议就是为了解决这个问题的,缓存一致性协议的实现方式主要有两种:基于目录记录缓存行的使用情况和总线嗅探。前者适用于CPU核心多的大型系统,后者适用于小型系统。这里介绍总线嗅探的方式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BHOGqkag-1692550109001)(…\Pictures\blog\cache_consistence_1.png)]
总线是CPU和内存数据交互的桥梁,总线嗅探就是监视总线上的数据变化,当某个CPU缓存行中的数据被修改时,总线会将此事件告知其它CPU,如果其它CPU中也存在该缓存行,那么可以将缓存行中数据更新,也可以使缓存行失效。由于更新会在总线上产生巨大的流量,更多的是使缓存行失效,也就是“写无效”,比较出名的缓存一致性协议MESI就是使用这种方式实现的。
2.2.2 缓存行的四种状态:
- M:modified,表明缓存行中的数据已经修改,与主内存中的数据是不一致的,如果其它CPU需要读取主存中的该数据,该缓存行中的数据必须回写到主存,并且状态变为共享状态S。
- E:exclusive,表明缓存行中的数据只在当前CPU中存在,并且和主存中的数据一致,如果其它CPU需要读取主存中的该数据,该缓存行的状态变为共享状态S,如果当前CPU修改了缓存行中的数据,状态变为M。
- S:shared,表明缓存行中的数据在多个CPU中都存在,并且它们是一致的,缓存行可以在任意时刻变为无效状态I。
- I:invalid,缓存行是无效的。
2.2.3 缓存状态变化的消息传递
缓存行状态的切换依赖总线上不同类型消息的传递,分为以下几种消息:
- Read:通知其它处理器和主内存,准备读取某个内存地址的消息。
- Read Response:响应Read请求读取的消息,可能来自主内存,也可能来自某个CPU的缓存。
- Invalidate:通知其它处理器删除缓存中对应tag的数据副本。
- Invalidate Acknowledge:响应Invalidate已经删除缓存中相应tag的数据副本。
- Read Invalidate:通知其它处理器,准备更新一个数据,请求其它处理器删除各自缓存中的数据副本,收到消息的处理器必须回复Read Response,Invalidate Acknowledge。
- Writeback:该消息包含写入主存的数据和地址。
2.2.4 MESI协议动画演示
MESI协议的动画演示,可以很好的理解该协议。
2.2.5 MESI协议优化
2.2.5.1 Store Buffer
当某个CPU对某个数据进行写操作时,如果该数据不在缓存行中,那么该CPU就会发送Read Invalidate消息给其它拥有该缓存数据的处理器,并且会阻塞等待响应。这会降低处理器的性能。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RALE72XU-1692550109002)(…\Pictures\blog\MESI_store_buffer_2.png)]
为此,硬件工程师引入了Store Buffer,当处理器如果写入缓存行中不存在的数据时,首先会将要写入的数据先写入Store Buffer,然后发送消息给其它处理器,当其它处理器的回复都收到时,才会将Strore Buffer中的数据写入缓存和主存中。相当于执行了一个异步操作。
但是这会造成同一个处理器中缓存和Store Buffer数据不一致的情况,比如,先写入一个缓存不存在的值到Store Buffer,还没有收到其它处理器的回复时,那么就可能使用缓存中的旧值进行计算,导致出错。
为此,硬件工程师又实现了“store forwarding",也就是处理器读数据的时候,先判断store buffer中有没有数据,如果有,就优先使用store buffer中的数据,没有的话才使用缓存中的数据,这样就保证了同一个处理器中始终能够读取到最新的修改。
但是,多线程下,情况就不一样了,比如这个例子:
int a,b;
void c1(){ //CPU0执行该方法
a = 1;
b = 1;
}
void c2(){ //CPU1执行该方法
while(b == 0) continue;
assert(a == 1);
}
//示意图如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ojb8duK2-1692550109002)(…\Pictures\blog\MESI_store_buffer.png)]
当断言失败后,虽然最后CPU1一定会回复CPU0对变量a=1写操作的read invalidate消息,但为时已晚。看上去就好像CPU0中的代码的执行顺序变成了下面这样
void c1(){
b=1;
a=1;
}
看上去像代码的顺序发生了错乱,虽然这不影响c1()函数的正确性,但是会影响到所有依赖c1函数赋值顺序的线程。原因在于CPU0对a的写入需要和其它cpu通信,这之间会有延迟,对b的写入是直接写入到缓存中的,因此b就a先在缓存中生效,导致cpu1读到b=1时,a还因为延迟没有同步过来。
写入操作有延迟导致其它处理器可能获得的不是最新的数据。单线程中没有依赖关系的代码可以重排序,重排序的结果是不变的,多线程中因为无法预知多线程之间的数据依赖关系,重排对线程的执行是有很大影响的。硬件设计者提供了写内存屏障来保证当写入的数据同步到缓存中后,才能继续后面的操作。
void c1{
a=1;
smp_wmb(); //写屏障:强制刷新store buffer,然后再进行之后的赋值操作
b=1;
}
2.2.5.2 Invalidate Queue
由于store buffer很小,如果其它Cpu繁忙的时候响应消息的速度变慢,store buffer的空间很快就会填满。
这个问题本质上是由于响应消息的速度变慢导致的,Invalidate Queue就是提高Invalidate消息(无效消息)的响应速度。
一个带有invalidate queue的Cpu可以立即应答一个invalidate ack消息,而不必等待相应的缓存行真正变成无效状态。那么这就可能导致读取了原本应该失效的缓存数据(还没将失效队列中失效的缓存行清除,又读取了缓存中的数据)。
相应的例子和store buffer类似。解决办法就是在读取变量之前加上一个读屏障,保证读取到的是最新的数据,而不是缓存中失效的信息。
多核CPU下缓存一致性协议是在硬件层面来保证的,硬件层面引入Store Buffer和Invalidate Queue之后,延迟写和无效读可能导致的问题是通过内存屏障来避免的。
2.3 volatile原理
2.3.1 volatile的内存语义
-
对volatile变量的写,会将线程本地内存中的共享变量值立即刷新到主内存中。
-
多volatile变量的读,会将线程本地内存中的共享变量值设置为无效,重新到主内存中读取。
为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器的重排序,可以说限制了重排序就保证了可见性。。JMM对编译器指定的重排序规则:
-
第一个操作是volatile读,不管第二个操作是什么都不会重排序
-
第二个操作是volatile写,不管第一个操作是什么都不会重排序
-
第一个操作是volatile写,第二个操作是volatile读时,也不会重排序
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kfA2CsYt-1692550109003)(…\Pictures\blog\JMM_volatile_rule.png)]
如何实现这些规则呢?JMM规定使用内存屏障来保证不会重排序,JMM层面上内存分为了读屏障和写屏障,排列组合就有了四种屏障:
类型 | 示例 | 说明 |
---|---|---|
LoadLoad | Load1,LoadLoad,Load2 | 保证load1的读取是在loade2的读取操作之前执行 |
StoreStore | Store1,StoreStore,Store2 | 保证store2的写入之前,store1的写入已经刷新到主存 |
LoadStore | Load1,LoadStore,Store | 保证store2的写入之前,load1的读取已经结束 |
StoreLoad | Store1,StoreLoad,Load2 | 保证load2的读取之前,store1的写入已经刷新到主存 |
2.3.2 JMM内存屏障的插入策略
-
在每个volatile写操作的前面插入一个StoreStore屏障,在后面插入一个StoreLoad屏障(保证在volatile写之前的写入已经刷新到内存,并且volatile写之后的读取能够读到volatile写入的数据)(之前其它的写已经写完,写之后其他的读能够读到最新的数据)
-
在每个volatile读操作的后面插入一个LoadLoad屏障和LoadStore屏障(保证后续的读不能在volatile读之前,保证volatile读之后的写在读之后写入缓存)(读取保证顺序,不能读取到之后才会写入的数据)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q7srTmra-1692550109003)(…\Pictures\blog\volatile_memory_barrier.png)]
在硬件层面上,也提供了一系列内存屏障来保证一致性,以x86为例:
- lfence:在lfence之前的读操作必须在lfence之后的读操作之前完成,类似读屏障
- sfence:在sfence之前的写操作必须在sfence之后的写操作之前完成,类似写屏障
- mfence:在mfence之前的读写操作必须在mfence之后的读写操作之前完成,类似读写屏障
虽然JMM规范规定了要使用内存屏障来保证一致性,实际情况并不需要加这么多的内存屏障,java采取了比较保守的方式,比如x86处理器只会对写-读操作重排序,所以只需在volatile的写操作后面插入StoreLoad屏障就能实现。在x86处理器中,实现StoreLoad屏障的功能可以有三种方式:mfence、cpuid和lock前缀指令。
在HotSpot中关于volatile的实现实际上使用的就是lock前缀指令。lock前缀指令的作用是锁住缓存行,能起到和读写屏障一样的功能,为什么HotSpot使用了lock指令而不是mfence指令,可能因为性能上差不多,亦或者是lock指令的通用性吧。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qT6B5A2B-1692550109003)(…\Pictures\blog\orderAccess_linux_x86.png)]
3、Monitor
共享带来的问题
多线程下对共享变量的操作可能导致线程安全(同步和互斥)问题,Java通过管程来保证共享变量及对共享变量的操作的线程安全性。
3.1 理解管程
-
管程是指管理共享变量以及对共享变量的操作,使共享变量能够支持并发,对于Java来说就是管理类的成员变量和成员方法。
-
管程解决互斥的思路就是将共享变量和对共享变量的操作统一封装起来。对于同步,管程中引入了条件变量的概念,并且每个条件变量都对应一个等待队列。只有获得了锁才能进行执行同步块中的代码(互斥),而线程间的同步通过锁对象提供的条件变量来控制(同步)。注意条件变量是和锁对象关联的。同步是指保证线程之间的顺序性
-
Java内置的管程方案(synchronized+wait()、notify()、notifyAll())在编译期间会自动生成加锁和解锁的代码,但是仅仅支持一个条件变量。JUC中实现的管程(Lock接口)支持多个条件变量,不过并发包中的锁需要手动进行加锁和解锁操作。
-
线程安全可以通过管程技术实现,也可以使用信号量实现,Java选择管程(MESA模型)实现并发主要还是因为实现管程比较容易。
-
三种管程模型在通知上的区别:管程要求同一时刻只允许一个线程执行,当线程T2唤醒T1线程后,T1和T2的执行顺序?
-
Hasen模型中,要求将notify放在代码的最后,这样T2通知之后,T2就结束了,然后T1再执行
-
Hoare模型中,T2通知之后,T2阻塞,T1立马执行,T1执行完在唤醒T2,相比之下多了一次唤醒操作
-
MESA模型中,T2通知之后,T2还是会接着执行,T1并不会立即执行,仅仅是从条件变量的waitSet中进入到EntryList中,但是T1再次执行时,可能抢不到锁,所以需要通过循环的方式检验条件变量(防止虚假唤醒)。这样的好处是notify不需要放到代码的最后,并且也没有多余的唤醒操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NbAs7fqX-1692550109004)(…\Pictures\blog\monitor.png)]
条件变量的等待队列就是ObjectMonitor中的waitSet,处于该队列的线程是Waiting或TimeWaiting状态,在等待条件满足后(被唤醒)加入entryList中,参与锁的争抢,入口的等待队列就是ObjectMonitor中的entryList,没有抢到锁的线程就会进入该队列,线程处于Blocked状态。
-
3.1.2 锁的分类
-
乐观锁与悲观锁
并不是一个具体类型的锁,是指看待并发同步的角度,并发操作下,乐观锁认为数据是不会发生修改的,在更新数据时,会采用尝试更新。悲观锁认为数据是一定会发生修改的,在更新时必须进行加锁操作。悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,Java中的悲观锁就是各种各样的锁,乐观锁就是同过UnSafe类提供的CAS操作实现的,比如原子类
-
公平锁和非公平锁
公平锁是指多线程按照申请锁的顺序来获取锁。非公平锁的获取方式则不是一定按照申请的顺序,非公平锁的吞吐量高,synchronized是非公平的,ReentrantLock可以执行是否公平,默认也是非公平
-
互斥锁和共享锁
互斥锁仅仅只能被一个线程拥有,共享锁可以同时被多个线程拥有。synchronized和ReentrantLock是互斥锁,ReadWriteLock是共享锁
-
自旋锁
是指尝试获取锁的线程并不会立即阻塞,而是采用循环的方式尝试获取锁,目的是减少线程上下文切换带来的性能消耗,缺点就是会消耗CPU资源,轻量级锁就是一种自旋锁
-
分段锁
是一种设计锁的思想,通过控制锁的粒度来提高并发。
3.2 synchronized原理
-
字节码层面:Java中同步是基于进入和退出管程对象(monitor)实现的,synchronized同步代码块在编译时会自动添加加锁和解锁代码
-
jvm层面:Java中的每个实例对象都可以关联一个c++中的ObjectMonitor对象,如果用synchronized给对象上锁(重量级)之后,该对象的Mark Word中就被设置指向ObjectMonitor对象的指针。ObjectMonitor对象属性主要属性有:
- _count:记录该线程获取锁的次数
- _recursions:锁重入次数
- _owner:持有锁的线程
- _EntryList:存放没有抢到锁的线程,处于Blocked状态
- _WaitSet:存放待唤醒的线程,处于Waiting状态,调用wait()方法会进入该队列
-
操作系统层面:本质上是依赖操作系统的Mutex Lock(互斥锁)实现的,因此synchronized是一个重量级操作
3.2.1 对象的内存布局
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qSNtMdfe-1692550109004)(…\Pictures\blog\object_layout.png)]
-
Mark Word:存储对象自身的运行时数据,占8个字节,比如哈希吗、GC分代年龄、锁状态标记、偏向线程ID、偏向锁时间戳等,64位HotSpot的Mark Word如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J5EmtYcw-1692550109004)(…\Pictures\blog\markword_64.png)]
- 锁标志位(lock):区分锁的状态
- bised_lock:是否是偏向锁
- hashcode:哈希码是延迟计算的,只有在调用没有被重写的Object.hashCode()方法或System.identityHashCode(o)方法才会写入mark word,执行用户自定义的hashCode()方法不会被写入,对象加锁后,hashcode会被存储到Monitor中
- age:GC分代年龄,只有4位,因此最大年龄为15
- JavaThread:记录持有偏向锁的线程的ID
- epoch:偏向锁的时间戳
- ptr_to_lock_record:轻量级锁下,指向栈中锁记录的指针。JVM通过CAS操作在对象头中设置指向锁记录的指针。
- ptr_to_heavyweight_monitor:重量级锁下,指向对象监视器Monitor的指针。
-
Klass Pointer:指向方法区中类元数据的指针,通过该指针确定对象是哪个类的实例,未开启指针压缩时占8字节,开启指针压缩时为4字节。
-
Length:数组对象的长度属性,占4字节。
-
Instance Data:存储实例对象中的属性字段,如果有继承关系,子类还会包含从父类继承过来的字段。不同数据类型所占的字节数不同,比如int占4字节,引用类型开启指针压缩后占4字节,未开启占8字节。
-
Padding:对象的起始地址到结束地址需要对齐到8的倍数,目的是为了高效寻址。
简单对象内存布局
org.stu.TestObjectLayOut object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
数组对象内存布局
[I object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363) 12 4 (object header) 02 00 00 00 (00000010 00000000 00000000 00000000) (2) 16 8 int [I.<elements> N/A Instance size: 24 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
3.2.2 synchronized锁升级
3.2.2.1无锁
默认情况下,Jvm开启偏向锁会有4秒的延迟,在这个延迟时间内的对象处于无锁状态(001),
如果关闭偏向锁启动延迟或者是4秒后偏向锁启动了且没有线程竞争时的对象处于无锁可偏向状态(101),锁状态为101,但是线程ID都为0,锁还没有向任何线程偏向,所以也称作匿名偏向。
如果关闭偏向锁,那么在线程获取锁对象之前,会处于无锁不可偏向状态(001)。无锁不可偏向状态下,如果有线程获取锁,则将跳过偏向锁,直接使用轻量级锁(偏向锁不可用)。
在无锁可偏向状态下,如果调用了hashCode方法(未被重写),对象的mark word中将写入hashCode,对象会回到无锁态,并且在这个状态下,如果有线程尝试获取锁,则将直接从无锁升级到轻量级锁,不会再升级到偏向锁。因为偏向锁中存的是线程ID,没有空间存储hashCode。
3.2.2.2 偏向锁
- 偏向锁原理
无锁可偏向状态时偏向锁的初始状态,当线程试图获取锁时,会使用CAS操作尝试将自己的线程ID写入锁对象的mark word中,若成功,无锁可偏向状态升级为已偏向状态,在该状态下,对象mark word中存储了获得锁的线程ID,并且时间戳epoch为有效值。CAS操作能成功的前提是对象处于匿名偏向状态或者可重偏向状态。
如果之后有线程再次尝试获取锁时,先判断mark word中存储的线程ID是否就是自身,如果相同,表示当前线程已经持有了该对象的偏向锁,不需要再进行CAS操作来进行加锁。如果不同,那么将执行CAS操作,试图将mark word中的线程ID替换为自己的线程ID,CAS失败将执行偏向锁撤销操作。偏向锁的撤销需要等待全局安全点Safe Point(STW),在这个安全点上会挂起持有偏向锁的线程,然后遍历当前Jvm所有的线程,检查持有偏向锁的线程的状态,如果线程还存活并且正在执行同步块中的代码时,偏向锁升级为轻量级锁。如果持有偏向锁的线程已经死亡或者未执行同步块中的代码时,则进行校验是否允许重偏向。如果不允许,则撤销偏向锁,将mark word升级为轻量级锁,进行CAS竞争锁。如果允许重偏向,则设置锁为匿名偏向状态,CAS将偏向锁指向新线程。完成上述操作后,唤醒暂停的线程,从安全点继续执行。
-
偏向锁升级
升级为轻量级锁:CAS操作失败后,原持有锁线程还在同步块中,则会升级为轻量级锁,在原持有锁的线程栈中分配锁记录Lock Record,复制锁对象的mark word到该锁记录中,锁对象头中的锁记录指针指向该锁记录,原持有偏向锁的线程获得轻量级锁。当前线程的栈中也会分配锁记录,并且拷贝对象头中的mark word到对当前线程的锁记录中,当前线程执行CAS操作尝试将锁对象的mark word中的锁记录指针替换为当前线程的锁记录,如果成功,当前线程获得轻量级锁,失败则会自旋,自旋超过一定次数后,锁会升级为重量级锁。
升级为重量级锁:同步块中调用了wait方法将会从偏向锁直接升级到重量级锁,并在锁释放后重新变为无锁态。调用hashCode方法也会升级为重量级锁。
-
偏向锁的优化:批量重偏向和批量撤销
在未禁用偏向锁的情况下,当在一个线程中使用了很多对象进行了多次同步操作后,所有对象都处于偏向锁状态(偏向标志位为1),如果又来了一个线程也尝试获取这些对象的锁,就可能会引起偏向锁的批量重偏向,也就是第二个线程访问到20个之后的锁对象时,这些锁对象的状态将被重置为可重偏向状态,以便允许快速重偏向到第二个线程,而不是进行偏向锁撤销升级为轻量级锁,这样就减少了撤销偏向锁再升级到轻量级锁的性能消耗。
虚拟机参数 说明 BiasedLockingBulkRbiasThreshold 偏向锁批量重偏向阈值,默认20次 BiasedLockingBulkRevokeThreshold 偏向锁批量撤销阈值,默认40次 BiasedLockingDecayTime 重置计数的延迟时间,默认25000ms 批量重偏向是以class为单位的,也就是每个class都会维护一个偏向锁撤销计数器,每次该class的对象撤销一次偏向锁,计数器加一,当该值达到配置的20次时,jvm就会认为该锁对象已经不适合原线程,从而进行批量重偏向,,如果撤销次数达到40,就会发生批量撤销,如果超过25秒,就会重置在[20,40)内的计数。批量撤销会导致jvm禁用该class创建对象时的可偏向性,该类新创建的线程全部将会是无锁不可偏向状态,也就相当于禁用了该类新创建对象的偏向锁功能。
3.2.2.3 轻量级锁
-
轻量级锁原理
在关闭偏向锁时或者偏向锁需要升级为轻量级锁时等情况下,Jvm会在当前线程的栈帧中创建一条锁记录Lock Record,包括dispiaced mark word和owner两个内容,前者用于存放锁对象的mark word拷贝,后者则是指向锁对象的指针,在拷贝mark word期间暂时不会处理owner。
拷贝完锁对象的mark word后,首先会挂起当前线程,Jvm使用CAS操作尝试将锁对象mark word中的lock record指针指向栈帧中的锁记录,并将锁记录的owner指针指向锁对象的mark word,
- 如果CAS成功,表示当前线程竞争锁成功
- 如果CAS失败,则判断当前锁对象的mark word是否指向了当前线程的栈帧。
- 如果是,则表示当前线程已经持有对象锁,执行的是锁重入
- 如果不是,则表示其他线程已经持有了该对象锁,当前线程将在自旋一定次数后如果仍然未获得锁,则轻量级锁需要升级重量级锁,之后的线程将会进入阻塞状态。
-
轻量级锁释放
释放轻量级锁时需要判断当前锁是否是轻量级锁状态,同样使用CAS操作尝试将栈帧中的displaced mark word替换回对象头中的mark word,并且检查锁对象mark word中l的ock record指针是否指向当前线程栈帧中的lock record
- 如果替换成功,则表示锁仍然是轻量级锁状态,没有发生竞争,同步过程结束
- 如果替换失败,表示发生了竞争,其它线程可能在自旋后没有获取到锁并将轻量级锁升级为了重量级锁,这种情况下需要执行重量级锁的解锁流程并唤醒阻塞的线程
-
轻量级锁重入
轻量级锁的每次重入,都会在栈中生成一个lock record,但是保存的数据不同
- 首次分配的lock record中displaced mark word保存锁对象mark word,owner指向锁对象。
- 重入分配的lock record中displaced mark word为null,只存储指向对象锁的owner指针。
重入的次数就是栈帧中lock record的数量,这个数量隐式充当了锁重入的计数器。释放锁时,如果是重入则会删除栈帧中的lock record,直到没有重入时使用CAS操作替换锁对象的mark word
-
轻量级锁升级
jdk1.6之前默认轻量级锁的自旋次数是10次,超过该次数后或者自旋的线程数超过CPU核心数的一半,就会升级为重量级锁,jdk1.6之后加入了自适应自旋锁,自旋的次数由jvm控制,根据前一次同一个锁上的自选时间和锁的拥有者的状态来决定。
- 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为之后自旋也很可能成功获得到锁,进而允许自旋等待相对较长的时间
- 对于某个锁对象,如果自旋很少成功获得到锁,则之后尝试获取该锁时就不自旋而是直接进入阻塞状态,也就是升级为重量级锁。
3.2.2.4 重量级锁
重量级锁依赖与对象关联的C++的ObjectMonitor对象实现,ObjectMonitor又是依赖于操作系统的互斥锁Mutex Lock来实现的,因此互斥锁下线程之间的切换,需要从用户态切换到内核态,消耗Cpu资源,导致性能低下。重量级锁中锁对象的mark word不在指向栈帧中的lock record,而是指向堆中与锁对象关联的monitor对象,多线程下没有获得monitor的线程将会被阻塞。
3.2.2.5 锁升级总结
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nKJgzGLF-1692550109005)(…\Pictures\blog\synchronized_upgrade.png)]
- 偏向锁在无资源竞争的情况下完全消除了同步,提高了线程在单线程下访问同步资源的性能,当出现多个线程竞争时,就会撤销偏向锁,升级为轻量级锁。因此当同步资源一直是被多线程访问的,偏向锁就失去了作用,这时就应该禁用偏向锁,来减少偏向锁撤销升级的性能消耗。适用于单线程的情况。
- 轻量级锁是在竞争不激烈的情况下,通过CAS自旋操作(自旋一定次数就能获得到锁)来减少多线程进入互斥,也就是多线程交替执行同步块时,jvm使用轻量级锁保证同步,避免切换线程的开销。当自旋次数过高时,会引起cpu资源的浪费。适用于追求响应时间,线程是交替执行的,同步块执行时间短的情况。
- 重量级锁是在资源竞争激烈的情况下,通过操作系统的互斥锁来完成同步,由于涉及到内核态和用户态的转换,因此性能相对较低。适用于追求吞吐量,同步块执行时间较长的情况
- 尽管Java对Synchronized做了这些优化,但是在使用过程中,应当尽量减少锁的竞争,通过减小加锁力度和减少同步代码的执行时间来降低锁的竞争,尽量使锁维持在偏向锁和轻量级锁的级别,避免升级为重量级锁,造成性能上的影响。
3.3 显示锁
3.3.1 Synchronized和Lock的区别
- synchronized是Jvm实现的。Lock是Jdk层面实现的
- synchronized是隐式的,无论如何都会释放。Lock是显示的,需要手动加锁解锁,并且Lock需要自己保证正确释放锁
- synchronized是阻塞式的获取锁、不可中断、非公平的,无法判断锁状态。Lock可以阻塞获取、可中断、可配置公平性、还可以配置超时、可以判断锁状态。
- synchronized的条件变量只有一个,不够灵活,而Lock可以使用多个条件变量。以生产者消费者模型来说,synchronized中生产者为了唤醒消费者,需要调用notifyAll方法,这就导致了可能也会唤醒生产者线程,不够灵活。而Lock中生产者只会唤醒消费者,消费者只会唤醒生产者,这是因为Lock中可以定义两个条件变量,针对不同的条件变量唤醒处于不同等待状态下的线程。
3.3.2 Lock接口
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yru44CpW-1692550109005)(…\Pictures\blog\lock_api.png)]
4、AQS
AQS是用来构建锁或者其它同步组件的基础框架。AQS屏蔽了对同步状态的管理、线程排队、线程阻塞和唤醒等底层操作。
把来竞争的线程及其等待状态封装为Node对象,并将这些Node对象放到同步队列中,使用int类型的变量表示同步状态(是否有线程获得锁、线程的重入次数,具体的含义由子类定义)
4.1 同步状态的管理
也就是根据同步状态判断线程是否获得了锁
private volatile int state; //独占模式下0表示没有线程获得锁,1表示有线程获得了锁。共享模式下,不同子类实现代表的含义不同,比如读写锁中高16位表示获得读锁的线程以及重入的次数,低16位表示写锁线程的重入次数。在CountDownLatch中state作为计数器,只有当计数器为0时,所有获取共享锁的线程才能继续执行,state大于0时,不允许获取共享锁入队等待。在Semphore中state作为票据,只有state大于0才允许获取共享锁,释放锁时state加一,获取锁时state减一。
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer,表示独占模式下,获得了锁的线程
对同步状态的修改是基于CAS来保证原子性,而同步状态本身声明是volatile的,这就保证了同步状态state是线程安全的。
AQS通过模板方法定义了线程获取同步状态的流程,而具体获取同步状态的方法通过钩子函数来给子类实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jycEM1pr-1692550109005)(…\Pictures\blog\AQS_change_state_method.png)]
独占模式下aquire()方法的执行流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xBh5u4vS-1692550109006)(…\Pictures\blog\AQS_aquire.png)]
共享模式下aquireShared()方法的执行流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tUx5w0dU-1692550109006)(…\Pictures\blog\AQS_aquireShared.png)]
4.2 同步队列的维护
4.2.1 队列的节点
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ho8bFBJt-1692550109007)(…\Pictures\blog\AQS_sync_queue.png)]
-
AQS中同步队列(FIFO,双向链表)中定义的头结点和尾结点:
private transient volatile Node head;// 头结点,不代表任何线程,哨兵节点,逻辑上可以认为是获得了锁的线程 private transient volatile Node tail;// 尾节点,获取锁失败的线程会封装为Node加入队尾
-
需要入队的线程会被封装为Node节点,Node节点的属性:
static final Node SHARED = new Node(); //用于标记某个节点在共享模式下等待 static final Node EXCLUSIVE = null;//用于标记某个节点在独占模式下等待 volatile Thread thread;// 节点所代表的线程 volatile Node prev;//前驱节点 volatile Node next;//后继节点 Node nextWaiter;//在Condition中表示条件队列中等待的后继节点,在同步队列中表示是独占还是共享模式 volatile int waitStatus;// 线程在队列中等待锁时的状态,默认是0 static final int CANCELLED = 1;//表示线程因为超时或中断或被强制干掉 static final int SIGNAL = -1; //由后继节点设置,表示当前节点获得锁后再释放时需要唤醒后继节点 static final int CONDITION = -2;//当前线程在条件队列中 static final int PROPAGATE = -3;//共享模式向将唤醒后继线程的行为传递下去,在一个节点获取锁成功之前是不会跳跃到该状态的
4.2.2 独占锁演示
-
入队
private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); //将当前线程包装成Node Node pred = tail; // 如果队列不为空, 则用CAS方式将当前节点设为尾节点 if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } //如果队列为空或者上面的CAS插入队尾失败,则调用enq方法不断自旋尝试加入队尾,直到成功为止 enq(node); return node; //返回新入队的节点 }
private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 队列初始化 if (compareAndSetHead(new Node())) tail = head; } else { //队列不为空,不断尝试加入队尾 node.prev = t; //入队的操作是先设置prev指针,然后设置tail指针,设置tail成功后再设置next指针 if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
-
入队后设置前驱节点的waitStatus
只有当前驱节点的状态signal时,入队的节点才能去阻塞,相当于设定了一个闹钟
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) // 前驱节点状态是signal,可以阻塞 return true; if (ws > 0) { // 跳过取消状态的前驱节点,从尾部向前找不为取消状态的节点,直到找到为止。也就是要排在等待锁的节点的后面 do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { compareAndSetWaitStatus(pred, ws, Node.SIGNAL); // CAS设置前驱节点状态为signal } return false; }
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VPeOqNAc-1692550109007)(…\Pictures\blog\AQS.gif)]
-
节点取消
cancelAcquire方法是在线程被唤醒后发现中断信号为true并响应中断或者超时后执行的。执行该方法时,线程一定是在队列中的,并且持有锁的线程不是该线程。
private void cancelAcquire(Node node) { if (node == null) // node为空,返回 return; node.thread = null;//第一步:节点thread设置为null Node pred = node.prev;// 寻找最靠近node并且状态不等于取消的前驱节点 while (pred.waitStatus > 0) node.prev = pred = pred.prev; Node predNext = pred.next; //predNext表示pred的后继节点 node.waitStatus = Node.CANCELLED; // 第二步:设置node节点的状态为CANCELLED if (node == tail && compareAndSetTail(node, pred)) { //1、若node为尾节点,尝试设置pred成为尾节点,如果成功再尝试设置tail.next=null compareAndSetNext(pred, predNext, null); } else { // 2、node节点不为尾节点 int ws; //如果pred节点不是头结点并且pred的thread不为空并且pred的状态是signal或者通过CAS成功设置pred的状态为signal if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null) { Node next = node.next; if (next != null && next.waitStatus <= 0) // 后继不为空并且后继的状态小于等于0,也就是将node的前驱节点指向node的后继节点 compareAndSetNext(pred, predNext, next); } else { unparkSuccessor(node); // 3、表明node是head的后继,则唤醒node的后继 } node.next = node; //第三步:node节点的next指针指向自身 } }
该方法执行的结果有很多种,但是可以确定的是取消节点的thread=null、waitStatus=CANCELLED并且node.next=next。其它可能导致引用或者状态发生变化的情况不会对队列的入队和唤醒操作有任何影响,因为prev指针是一直连续的,不会断掉。这也是为什么唤醒时遇到取消的节点要从尾部来查找的原因。入队时遇到取消的节点也会把prev指针指向有效的前驱节点。
-
出队
private void unparkSuccessor(Node node) { // 唤醒node节点的后继 int ws = node.waitStatus; if (ws < 0) //尝试将head结点状态设置为0 compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { //如果后继节点为取消状态,从尾部向前找距离head最近的有效后继节点 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); //唤醒有效后继节点线程 }
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AHBPt7j0-1692550109007)(…\Pictures\blog\AQS_release.gif)]
4.3 线程阻塞和唤醒
需要明白的是,线程被唤醒意味着当前线程节点是head的后继节点,但并不意味着线程能够获得锁,非公平锁的情况就有可能唤醒后被其它线程抢先占有了锁。
AQS中线程的阻塞与唤醒是借助 LockSupport来实现的
4.3.1 LockSupport
- LockSupport是一个线程阻塞工具类,可以让线程在任意位置阻塞,它是以线程为单位来阻塞和唤醒线程的。底层调用的是UnSafe的方法。
- 调用park方法需要消耗一个permit,如果没有凭证,线程将会阻塞等待permit可用,unpark方法会增加一个凭证,但是凭证最多只能有1个,多次调用该方法并不会累加凭证。
- wait/notify是和同步代码块绑定在一起的,并且只能是先wait在notify,wait使线程进入Blocked状态
- park/unpark以thread为操作对象,可以先调用unpark再调用park,语义更直观,操作更精准灵活,park使线程进入Waiting状态。
4.4 ReentrantLock
4.5 ReentrantReadWriteLock
-
同步状态state的高16位表示读锁的重入次数(所有读线程),低16位表示写锁的重入次数
-
写锁通过独占锁实现
-
读锁通过共享锁实现,获得读锁的线程通过ThreadLocal保存本线程重入的次数
-
锁有公平与非公平之分,公平意味着需要按照同步队列中的排队顺序获得锁,也就是获取锁时判断同步队列中是否有线程排队
获取锁的方法:
- 读读
- 写写
- 读写
- 写锁降级
释放锁的方法
写线程饥饿问题?怎么感觉不存在啊
如果有线程在读,写线程无法获取写锁,是一种悲观锁的策略
Java8引入了StampedLock对RRELock进行增强,使得读写锁之间可以相互转换,因此可以更细粒度的控制并发。
StampedLock最初是用做一个内部工具类,用来辅助开发其它的线程安全组件,用不好容易产生死锁,甚至莫名其妙的问题,并且不支持重入,应用场景非常受限。所有获取锁的方法都会返回一个邮戳。所有释放锁的方法都需要一个邮戳。方法不支持重入。不支持Conditon。有三种访问方式都读模式、写模式、乐观读模式、
5、Sync Container
同步容器通过synchronized关键字实现线程安全的容器,性能低
-
Vector 是List接口的线程安全实现 ArrayList是线程不安全的
-
Stack 是Vector的子类,入栈和出栈都是同步的
-
Hashtable Map接口的线程安全实现,HashMap是线程不安全的
-
Collections.synchronizedXXX() 工具类中同步方法创建的容器也是同步容器
6、Concurrent Container
并发容器是允许多线程同时使用并且保证线程安全的容器
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fwL5wkiT-1692550109007)(…\Pictures\blog\blockingqueue_2.png)]
6.1 COW技术
6.1.1 CopyOnWriteArrayList
-
旧数组支撑读请求,写请求复制原来的数组并写入,写入完成后旧数组指向新数组 (lock)
-
每次写的时候都会copy一份数据,当数据量大的时候,比较耗费内存
-
只能保证数据的最终一致性,不能保证实时一致性。
-
适用于读多写少的场景
6.1.2 CopyOnWriteArraySet
基于CopyOnWriteArrayList实现,添加了去重的功能
6.2 ConcurrentMap接口
6.2.1 ConcurrentHashMap
6.2.2 ConcurrentSkipListMap
- 基于跳表实现
- key是有序的
- key和value不能为null
- 插入操作会比较耗性能,适用于读多写少的场景
- 查询时间复杂度是log(n)
6.3 BlockingQueue接口
6.3.1 阻塞队列的特点
- 不能包含Null元素
- 实现类都是线程安全的
6.3.2 阻塞队列的方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NdmasWmq-1692550109008)(…\Pictures\blog\blockingqueue_1.png)]
- 抛异常:队列满时,再插入元素时,抛出IllegalStateException(“Queue full”)异常,队列为空时,再获取元素时,抛出NoSuchElementException异常
- 返回特殊值:插入成功返回true,失败返回false;移除时,如果成功返回被移除的元素,如果没有元素返回null
- 阻塞:队列满时阻塞生产者,直到消费者唤醒或者响应中断退出,同样的队列空时阻塞消费者,直到生产者唤醒或响应中断退出
- 超时退出:被阻塞的线程会等待一段时间,如果超时,则会退出
6.3.3 阻塞队列的实现类
6.3.3.1 ArrayBlockingQueue
- 数组实现,putIndex takeIndex (lock notFull notEmpty)
- 队列容量必须在创建时指定,之后不能修改,有界
- 支持公平/非公平策略,默认非公平
- 只有一把锁,插入的时候不能删除,因此高并发下,性能会有影响
6.3.3.2 LinkedBlockingQueue
- 链表实现 (putLock notFull) (takeLock notEmpty)
- 可指定边界,最大Integer.max
- 不支持公平非公平的特性
- 有两把锁,一把控制入队,一把控制出队,因此并发性能高于ArrayBlockingQueue
6.3.3.3 PriorityBlockingQueue
PriorityBlockingQueue是PriorityQueue的线程安全版本
- 小根堆(数组)实现 (lock notEmpty 无界所以没有notFull)
- 可以指定容量,默认容量11,可以自动扩容
- 无界阻塞队列,因此,put方法不会阻塞
- 并不是FIFO,优先级最高(小根堆堆顶)的元素先出队
- 优先级根据元素的compareTo()方法比较的结果确定(元素实现)
- 生产者添加元素需要扩容时,会先释放锁,然后使用CAS操作确保只有一个线程进行扩容,这样不会由于只有一把锁而影响到消费者线程消费元素,保证扩容不会阻塞take操作
6.3.3.4 DelayedQueue
- 小根堆(数组)实现,根据延迟时间排序 (lock avaliable)
- 无界
- 放入DelayedQueue中的元素,必须实现Delayed接口,以确定元素的延迟时间和延迟的比较规则
- 并不是FIFO,延迟时间最小的先出队
- 生产者添加元素时,当添加的元素为堆顶的元素时,会唤醒消费者线程,如果添加的不是堆顶,则不需要唤醒消费者,因为之前还有排队的
6.3.3.5 SynchronousQueue
- 不存储元素的阻塞队列
- 生产者必须等待消费者从队列中取走元素
- 适用于任务需要被快速处理、任务执行时间很短
- 公平模式基于链表实现,非公平模式基于栈实现
6.3.3.6 LinkedTransferQueue
- 链表实现,相当于SynchronousQueue和LinkedBlockingQueue的结合体
- 无界
- 实现了TransferQueue接口
- transfer()方法,在消费者阻塞时,生产者调用该方法不会将元素入队,而是直接传递给消费者。如果生产者调用该方法没有在阻塞的消费者线程,则会将元素入队,然后阻塞,直到有一个消费者来消费该元素。
6.3.3.7 LinkedBlockingDeque
- 链表实现的双向阻塞队列 (lock notFull notEmpty)
- 实现了BlockingDeque接口,而BlockingDeque又实现了BlockingQueue接口,因此可以FIFO也可以FILO
- 容量可选,,最大为Integer.max
6.4 ConcurrentLinkedQueue
- 基于单向链表实现
- CAS+自旋的方式
- 无界非阻塞队列
6.5 ConcurrentLinkedDeque
- 基于双向链表实现
- CAS+自旋的方式
- 具备FIFO和FILO
- 不具备实时的数据一致性
- 无界非阻塞队列
6.6 同步工具类
6.6.1 CountDownLatch
-
允许一个或者多个线程去等待其他线程完成操作
-
基于AQS的共享模式实现,每次调用countDown()方法,会将同步状态state的值减一,只有state=0时调用await()方法的线程才能继续执行
-
同步状态减为0时,不能复位
6.6.2 CyclicBarrier
多个线程满足条件后再继续向下运行,可以重复使用
基于ReentrantLock和条件队列实现,当同步条件未满足时,进入条件队列等待,条件满足时向下执行,等待中如果被打断,意味着栅栏损坏,需要通知其它等待的线程。
6.6.3 Semaphare
- 不是一个锁, 用来控制同时访问特定资源的线程数量
- 基于AQS的共享模式实现,同步状态state表示线程数量,当state大于0时,表示有资源可用,可以获取锁获取资源,当state=0时,需要阻塞,当某个获得锁的线程释放锁时,state会加一,并唤醒阻塞的线程
6.6.4 Exchanger
- 用于线程间协作的工具类
- 提供一个同步点,在这个同步点两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据
7、CAS
CAS是Jdk提供的非阻塞原子性操作,调用的是UnSafe类提供的方法,底层调用的是cmpxchg指令,cmpxchg指令在执行的时候,会判断系统是否是多核系统,如果是就给总线加锁,保证只有一个线程对总线加锁成功,加锁成功后执行CAS操作,也就是说CAS的原子性实际上是CPU实现独占的,CAS的非阻塞是基于乐观锁的思想,乐观的认为别的线程不会修改共享变量,就算修改了,自身在重试几次。
7.1 CAS的缺点
- 如果CAS一直都失败,则会一直自旋,造成CPU资源浪费,JUC中的BlockingQueue和SynchronousQueue就限制了自旋的次数。
- ABA问题(基本数据类型的可以不加版本号)
- 只能保证一个共享变量的原子性操作,如果有多个共享变量,就必须使用锁了。
7.2 Unsafe
7.3 原子类
7.3.1 基本数据类型的原子操作类
AtomicInteger
AtomicLong
AtomicBoolean
7.3.1 引用类型的原子操作类
AtomicReference
7.3.1 数组类型的原子操作类
AtomicIntegerArray
AtomicLongArray
AtomicReferenceArray
7.3.1 修改属性字段的原子操作类
AtomicIntegerFieldUpdater
AtomicLongFieldUpdater
AtomicReferenceFieldUpdater
7.3.1 带版本的引用类型的原子操作类
AtomicStampedReference 改了几次
AtomicMarkableReference 有没有改
7.3.1 增强的基本数据类型的原子操作类
LongAdder
DoubleAdder
LongAccumulator
DoubleAccumulator
8、Immutable Class
8.1 不可变
-
不可变类指的是类的实例一经创建,其属性或者所引用的字段对象都不能被修改
-
基本类型的包装类和String类都是不可变类
-
不可变指类的实例不可变,而不是指向实例的引用的不可变
-
对于不可变类的对象,都是通过新创建一个对象并将引用指向新对象来实现变化的
-
final修饰的基本类型、基本类型的包装类、String类的字段必须在初始化时就确定其值,而final修饰的引用类型表示引用的地址不会发生变化,并不代表引用所引用的对象就是不可变的,因此不可变类中的引用类型字段也必须是不可变的
-
不可变类一定是线程安全的,可变对象并不一定是不安全的
注意:servlet不是线程安全的,因为多个请求可以对应一个serclet
8.2 不可变类的设计策略
- 成员变量使用private和final修饰
- 不提供setter方法,也就是不提供改变成员变量的方法
- 不允许子类覆盖方法,最简单的就是使用final修饰类,或者将类的构造方法设置为private并且提供获取实例的工厂方法
- 如果成员变量为引用类型(有可能为可变类),不允许该引用类型能被修改,可以通过下面三种方法实现:
- 所引用的对象可以不提供修改的方法
- 或者将所引用的对象安全发布,也就是调用者无法获取这些对象的引用,进而保证这些对象不被修改
- 或者每次获取该引用类型字段的对象时,通过深拷贝复制一个新对象来确保类的不可变
- 如果成员变量中有可变类时,需要重写hashcode和equals方法(默认Object中的equals方法是通过hash值来判断对象是否相同,而不同对象的hashcode有可能相同,因此需要重写这两个方法来确保hashcode相同的情况下,通过equals来比较其内容是否相同)
8.3 不可变类的优缺点
优点:线程安全。对不可变对象的复制仅仅只需要复制地址就行,效率很高,另外,不变性保证了hashCode的唯一性,因此hashCode可以缓存而不用每次重新计算,比如在HashMap中将hashCode缓存可以提高key为不可变类时的性能
缺点:不可变类的每一次”变化“都会产生新对象,因此可能会导致垃圾变多
9、ThreadPool
9.1 线程池继承体系
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-obW7V6Gm-1692550109008)(…\Pictures\blog\threadpool_extend_relation.png)]
9.1.1 接口说明
-
Executor
将任务本身和任务的执行分开,任务执行可以是同步,可以是异步,可以通过线程池来执行。
-
ExecutorService
增强Executor接口,任务可以使用线程池来执行,提供了对线程池声明周期的管理,提供了关闭执行器、监视执行器状态、异步任务的支持、批处理任务的支持。
-
ScheduledExecutorService
提供对任务延时执行、周期性执行的支持
-
Executors
- 创建固定线程数线程池
- 创建可缓存线程池
- 创建可延时、周期执行线程池
- 创建单个线程线程池
- 创建Fork/Join线程池
9.1.2 线程池的优点
-
减少因为频繁创建和销毁线程所带来的的开销
-
提高程序的响应速度(不需创建线程,直接执行)
-
自动管理线程,调度任务的执行,调用方只用关注任务的创建
9.2 ThreadPoolExecutor
9.2.1 ThreadPoolExecutor的状态
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VHCJQzow-1692550109008)(…\Pictures\blog\threadpool_state.png)]
状态 | 高3位 | 是否接受新任务 | 是否处理阻塞队列中的任务 | 说明 |
---|---|---|---|---|
RUNNING | 111 | Y | Y | |
SHUTDOWN | 000 | N | Y | 调用shutdown方法,不会接受新任务,但会执行阻塞队列中的任务 |
STOP | 001 | N | N | 调用stop方法,不会接受新任务,抛弃阻塞队列中的任务,尝试中断正在执行的任务 |
TIDYING | 010 | - | - | 任务全部执行完毕,活动线程数为0,即将进入终结状态 |
TERMINATED | 011 | - | terminated方法执行完成 |
9.2.2 ThreadPoolExecutor的构造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler){
//省略
}
- corePoolSize 核心线程数,表示线程池最多保留的线程数
- maximumPoolSize 最大线程数,最大线程数减去核心线程数代表救急线程数
- keepAliveTime 救急线程的生存时间
- unit 救急线程生存时间的单位
- workQueue 阻塞队列
- threadFactory 线程工厂,可以自定义线程名称
- handler 拒绝策略
9.2.3 ThreadPoolExecutor的执行流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PwtkRIqo-1692550109008)(…\Pictures\blog\threadpool_execute_step.png)]
addWorker()
- 采用CAS操作将线程数加一
- 新建一个线程并启动
9.2.4 任务的异常处理
- 主动catch异常
- 通过Future的get()方法来处理
execute和submit
-
execute和submit的调用者都会立即返回,都是异步执行的,execute没有返回结果,而submit是有返回结果的
-
execute只能提交Runnable类型的任务,而submit既能提交Runnable类型也能提交Callable类型的任务
-
execute直接抛出任务执行时的异常,submit如果没有通过Future的get方法将任务执行时的异常重新抛出,则会吃掉异常
9.2.5 Executors工具类创建线程池
-
newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } //1、核心线程数=最大线程数,也就是没有救急线程 //2、阻塞队列无界 //3、对外暴露的是ThreadPoolExecutor对象,可以强转后调用setCorePoolSize方法修改核心线程数 //3、适用于任务量已知,相对耗时的任务
-
newCachedThreadPool
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), threadFactory); } //1、核心线程数为0,最大线程数Integer的最大值,也就是全部都是救急线程,且救急线程存活时间为60s //2、适合任务数比较多但任务执行时间很短的情况
-
newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); } //1、只有一个线程的线程池,任务数大于1时,会放入无界队列中排队 //2、采用装饰器模式,对外暴露的是ExecutorService接口,而ThreadPoolExecutor中的方法被隐藏了 //3、适用于多个任务排队执行
阿里巴巴编码规范
【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
- FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
- CachedThreadPool 和 ScheduledThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
9.2.6 Future接口
9.3 SchduledThreadPoolExecutor
该线程池在ThreadPoolExecutor上进行了增强,提供了以固定间隔执行任务和以固定延迟执行任务的方法
- 固定速率
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, //要执行的任务
long initialDelay, //第一次执行时延迟的时间
long period, //执行间隔
TimeUnit unit) {
//略
}
//如果任务执行时间小于period,那么相当于每隔period执行一次
//如果任务执行时间大于period,那么相当于每隔任务执行的时间执行一次
- 固定延迟
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay, //第一次执行时的延迟时间
long delay, //每次执行完成后延迟的时间
TimeUnit unit){
//略
}
//相当于延迟时间+任务执行的时间=执行的间隔
9.4 ForkJoinPool
Fork/Join 是 JDK 1.7 加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型 运算 所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计 算,如归并排序、斐波那契数列、都可以用分治思想进行求解 Fork/Join 在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运 算效率 Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
每个线程都有自己的任务队列,自己的任务队列时FIFO的,而当某个线程执行完自己的任务时,为了提高效率,会从其它线程的任务队列中窃取(工作窃取算法)任务来执行,这个过程是LIFO的,因此ForkJoinPool中的任务队列是双向的。
如果一个操作是在ForkJoinWorkerThread线程中运行的,则是内部操作(大任务拆封成的小任务),否则为外部操作,比如通过submit、execute、invoke等方法提交给线程池的任务在执行时都是外部操作。
- invoke方法是一个同步调用
- execute异步调用,无返回结果
- submit异步调用,有返回结果,通过future.get()获取
-
ForkJoinPool
- 接受外部任务的提交
- 接受Fork出来的子任务的提交
- 负责任务队列数组workQueue[]的初始化和管理
- 负责工作线程的创建和管理
-
ForkJoinTask 要执行的任务,有三个子类
- RecursiveAction 无返回结果的ForkJoin任务
- RecursiveTask 有返回结果的ForkJoin任务
- CountedCompleter 用于操作完成后需要触发其它操作的ForkJoin任务
-
ForkJoinWorkerThread 执行线程
每个执行线程都有各自的WorkQueue
-
WorkQueue 任务队列,
Fork/Join框架中是一个数组,数组大小是2的幂次方,当从外部提交一个任务时,会在偶数位创建任务队列接受外部任务,并且在奇数位上创建执行线程以及该执行线程的任务队列,然后执行线程窃取任务到自己的任务队列中来执行
- 有工作线程绑定的任务队列:奇数位,里面的任务是由工作线程Fork出来的子任务
- 没有工作线程绑定的任务队列:偶数位,里面的任务通常是由其它线程提交的
同步队列的维护?
- 唤醒时,跳过取消的节点,找距离head最近的signal节点
- 线程被强制打断或者可打断模式下的出队
- Condition条件队列的入队
- Condition条件队列的出队
- 共享模式下的释放锁时唤醒头结点后,头结点得到锁并且将共享模式传播到后续节点
AQS->阻塞队列->线程池
Thread->Runnable->Callable->Future->FutureTask->CompetableFutureTask
Runnable
FutureTask
CompletableFuture
池实现,它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型 运算 所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计 算,如归并排序、斐波那契数列、都可以用分治思想进行求解 Fork/Join 在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运 算效率 Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
每个线程都有自己的任务队列,自己的任务队列时FIFO的,而当某个线程执行完自己的任务时,为了提高效率,会从其它线程的任务队列中窃取(工作窃取算法)任务来执行,这个过程是LIFO的,因此ForkJoinPool中的任务队列是双向的。
如果一个操作是在ForkJoinWorkerThread线程中运行的,则是内部操作(大任务拆封成的小任务),否则为外部操作,比如通过submit、execute、invoke等方法提交给线程池的任务在执行时都是外部操作。
- invoke方法是一个同步调用
- execute异步调用,无返回结果
- submit异步调用,有返回结果,通过future.get()获取
-
ForkJoinPool
- 接受外部任务的提交
- 接受Fork出来的子任务的提交
- 负责任务队列数组workQueue[]的初始化和管理
- 负责工作线程的创建和管理
-
ForkJoinTask 要执行的任务,有三个子类
- RecursiveAction 无返回结果的ForkJoin任务
- RecursiveTask 有返回结果的ForkJoin任务
- CountedCompleter 用于操作完成后需要触发其它操作的ForkJoin任务
-
ForkJoinWorkerThread 执行线程
每个执行线程都有各自的WorkQueue
-
WorkQueue 任务队列,
Fork/Join框架中是一个数组,数组大小是2的幂次方,当从外部提交一个任务时,会在偶数位创建任务队列接受外部任务,并且在奇数位上创建执行线程以及该执行线程的任务队列,然后执行线程窃取任务到自己的任务队列中来执行
- 有工作线程绑定的任务队列:奇数位,里面的任务是由工作线程Fork出来的子任务
- 没有工作线程绑定的任务队列:偶数位,里面的任务通常是由其它线程提交的
同步队列的维护?
- 唤醒时,跳过取消的节点,找距离head最近的signal节点
- 线程被强制打断或者可打断模式下的出队
- Condition条件队列的入队
- Condition条件队列的出队
- 共享模式下的释放锁时唤醒头结点后,头结点得到锁并且将共享模式传播到后续节点
AQS->阻塞队列->线程池
Thread->Runnable->Callable->Future->FutureTask->CompetableFutureTask
Runnable
FutureTask
CompletableFuture
ThreadLocal