JUC 串讲
说一说 synchronized
底层实现流程如下:
当你对类中方法使用 synchronized 修饰的时候,其实在代码执行过程中锁定的是执行方法对象,当你对类中的静态方法使用 synchronized 修饰的时候,在代码执行过程中锁定的其实是当前类的字节码文件。
如何停止或者中断运行中的线程?
这里简单说一下,想要深入了解的同学可以下去查一下资料:
- 首先可以通过 interrupt 方法进行线程的中断,不过调用interrupt 方法后,线程并不一定会立即停止,因为本质上 interrupt 方法其实是一种协商机制,虽然你发出了停止的协商信号,但是正在执行的线程并不一定会立即响应
- 其次可以通过诸如 AtomicBoolean 等原子变量来实现
LockSupport
一种用来创建锁和其他同步类的基本线程阻塞原语
主要方法就是 park() 和 unpark() ,park 方法的含义是除非许可证可用,否则禁用当前线程以进行线程调度,unpark 方法含义是如果给定线程不可用,则为其提供许可
三种阻塞唤醒线程的方式:
- 使用 Object 类中的 wait() 方法让线程等待,使用 Object 类中的 notify() 方法唤醒线程
- 使用 JUC 包中 Condition 的 await() 方法让线程等待,使用 signal() 方法进行唤醒
- LockSuppor 类中可以阻塞当前线程以及唤醒指定被阻塞的线程
该类与使用它的每个线程关联一个许可证(同 Semaphore 类的意义相同)。如果许可证可用,将立即返回 park,并在此过程中消费,否则可能会阻止。如果尚未提供许可,则致电 unpark 获得许可。(与 Semaphore 不同,通过 LockSupport 获取的许可证不会累加,最多只有一个)可靠的使用需要使用 volatile (或原子)变量来控制何时停放或取消停放。对于易失性变量访问保持对这些方法的调用的顺讯,但是不一定是非易失性变量用。在这个意义上,park 可以作为 “忙碌等待” 的优化,不会浪费太多时间旋转,但必须与 unpark 配对才能生效。
JMM 入门
为什么要有 JMM ?
CPU 有多级缓存(因为 cpu 和物理内存的速度不一致),CPU 的运行并不是直接操作内存而是先把内存里边的数据读到缓存中,而内存的读和写操作的时候就会造成不一致的问题,JVM 规范中试图定义一种 Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。 所以 JMM (Java Memory Model) 应运而生。
也就是说当一个线程去修改共享内存中的变量的时候,它会首先把共享内存的变量读到自己线程私有的内存中,然后进行修改,修改过后再将新值设置到共享内存中,以此来保证保证共享内存中的变量是最新值
JMM 三大特性
- 原子性:指一个操作不能被打断,即多线程环境下,操作不能被其他线程干扰
- 可见性:指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更, JMM 规定了所有变量都存储在主内存中
- 有序性:对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重排序。Java 规范规定 JVM 线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
重排序的优缺点:
JVM 能根据处理器特性(CPU 多级缓存系统、多核处理器等)适当的对机器指令进行重排序,是机器指令能够更符合 CPU 的执行特性,最大限度的发挥机器性能。但是,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生 "脏读"现象 )。简单来说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺讯会被优化。
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
并且处理器在进行重排序时必须要考虑指令之间的数据依赖性。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
happens-before 8 条原则:
- 次序规则:一个线程内,按照代码顺序,写在前面的操作现行发生于写在后面的操作
- 锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则:对一个 volatile 变量的写操作现行发生于对这个变量的读操作(时间先后)
- 传递规则:如果 A 先行发生于 B,B 先行发生于 C,那么 A 也一定先行发生于 C
- 线程启动规则:Thread 对象的 start 操作现行发生于此线程的每一个操作
- 线程中断规则:对线程 interrupt 方法的调用先行发生于被中断线程的代码检测到中断事件发生,具体的解释就是你要先调用过 interrupt 方法设置过中断标志,我才能检测到中断发送
- 线程终止规则:线程中的所有操作都先行发生于队此线程的终止检测
- 对象终结规则:一个对象的初始化完成(构造函数执行结束),先行发生于它的 finalize 方法,即必须先创建再回收
Happens-before 原则从语义上来说本质上是一种可见性
volatile 语义:
- 当写一个 volatile 变量的时候,JMM 会把线程对应的本地内存中的共享变量值立即刷新会主存中
- 当读一个 volatile 变量的时候,JMM 会把线程对应的本地内存中的共享变量值设置为无效,然后立即去主内存中读取最新共享变量的值
所以,volatile 写内内存语义是直接刷新到主内存中,读内存语义是直接去主内存中读取,volatile 只能保证有序性和可见性,不能保证原子性!!!!!!!!!!
那 volatile 为什么可以保证可见性和有序性呢? 答案就是我们的终极大杀器——内存屏障 Memory Barrier
那么内存屏障是什么呢?
作为内存屏障,是一类同步屏障指令,是 CPU 或编译器对内存访问的过程中的一个同步点,是得此点之前的所有操作都执行后才可以执行此点之后的操作,避免代码重排序。 内存屏障其实就是一种 JVM 指令,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令是插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了Java 内存模型中的可见性和有序性,但 volatile 无法保证原子性。
内存屏障之前的所有写操作都要写入到主内存,内存屏障之后的所有读操作都可以或者内存屏障之前的所有写操作的最新结果
内存屏障的分类:
写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存中的数据全部都同步到主内存,也就是说看到写屏障之后就必须把此屏障之前的所有操作都执行完才能继续向下进行
读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。也就是说在 Load 屏障指令之后就能够保证后面的读取数据指令一定能都读取到最新数据
volatile 变量规则:
CAS
CAS 的两个问题:
**循环开销大:**一直循环判断所以开销比较大
**引出 ABA 问题:**举个栗子,当前比如说有个整型变量值为100,第一个线程请求修改,为110,但是因为某些原因阻塞了,此时中间有其他线程先将值改为200后又修改为100了,此时第一个线程经过比较发现原值为100,进行修改,将变量值修改为了110,但此100非彼100。中间经过了迭代,举个不太恰当的例子,如果在银行系统中这样就意味着虽然你的银行卡虽然成功接收了一笔钱,余额也和预期的一样,但是中间的 N 多转账记录丢失,那么是不是就给不法分子可乘之机来洗钱了呢?这是多么大的问题啊
那我们怎么解决呢?
可以使用版本号的机制去解决这个问题,在每次修改变量值的时候不仅对比期望值还对比版本号就可以啦。
Threadlocal :
ThreadlocalMap
强软弱虚四大引用
对象内存布局:
类在方法区,引用在栈,new 出来的对象在堆
对象实例构成:对象头、实例数据、对齐填充
对象头:由对象标记(Mark Word)、类元信息(类型指针 Class Pointer)
对象标记:哈希码、GC 标记、GC 次数、同步锁信息、偏向锁持有者
类元信息:存储的是指向该对象类元数据的首地址
实例数据:对象中的实际数据
对齐填充:保证8个字节的倍数,因为虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
可以通过引入 JOL-core 依赖的方式进行使用 JOL 工具, 通过 VM.current.details() 进行查看对象的相关详细信息。其他使用方法建议去 JOL 官网查看
Synchronized 与锁升级
Java 对象底层有个 ObjectMonitor 对象,Monitor 可以被理解为一种同步工具,也可以理解为一种同步机制,常常被描述为一个 Java 对象。 Java 对象是一个天生的 Monitor ,每一个 Java 对象都有成为 Monitor 的潜力,因为在 Java 的设计中,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁。
Monitor 的本质是依赖于操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要需要从用户态到内核态的转换,成本非常高。所以 Synchronized 是 Java 中的一个重量级操作
JVM 中的同步就是基于进入和退出管程对象实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现,如下所示:
Monitor 与 Java 对象以及线程是如何关联?
- 如果一个Java对象被某个线程锁住,则该 Java 对象的 Mark Word 字段中 LockWord 指向 monitor 的起始地址
- Monitor 的 Owner 字段会存放拥有相关联对象锁的线程 id
Synchronized 使用的锁是存在对象头中的 Mark Word 中,锁升级主要依赖于 MarkWord 中锁标志位和释放偏向锁标志位
关于锁的指向:
- 偏向锁:MarkWord 存储的是偏向的线程 ID
- 轻量锁:MarkWord 存储的是指向线程栈中 Lock Record 的指针
- 重量锁:MarkWord 存储的是指向堆中的 Monitor 对象的指针
六十四位标记图如下
在你 new 出一个对象之后直接使用 JOL 工具去查看它的详细信息你会发现它的 hashCode 全部为 0 ,这是因为 hashCode 方法是一个 native 方法,在你调用它之前是不会主动生成的,只有你调用过后,才会生成具体的 hashCode 值
偏向锁的主要作用:当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要出发同步。也即偏向锁在资源没有竞争的情况下消除了同步语句,甚至连 CAS 操作都不做了,直接提高程序性能
重要参数说明:
偏向锁撤销:
当有另外线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为轻量级锁,竞争线程尝试 CAS 更新对象头失败,会等待到 全局安全点(此时不会执行任何代码)时撤销偏向锁
Java 15之后逐渐废弃偏向锁!!!!!
轻量级锁
在这里插入图片描述
自适应自旋锁的原理:
重量级锁
锁升级为重量锁之后 hashCode 和 GC 年龄等信息被移到哪去了呢?
各种锁的优缺点的对比
偏向锁:适用于单线程使用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似),存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用 CPU 资源但是相对比使用重量级锁还是更有效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
锁消除
锁粗化
假如方法中首尾相接,前后相邻的都是同一个锁对象,那 JIT 编译器就会把这几个 Synchronized 块合并成一个大块,加粗加大锁的范围,以此申请锁使用即可,避免次次的申请和释放锁,提升了性能
AQS
什么是 AQS ?
是用来实现锁或者其他同步器组件的公共基础部分的抽象实现,是重量级锁基础框架及整个 JUC 体系的基石,主要用于解决锁分配给 “谁” 的问题
整体就是一个抽象的 FIFO 队列来完成资源获取线程的排队工作,并通过一个 int 类变量表示持有锁的状态
和 AQS 有关的类:
锁是面向使用使用者的,定义了程序员和锁交互的使用层 API ,隐藏了实现细节,调用即可。
同步器是面向锁的实现者的,Java 大神 DougLee 提出统一规范并简化了锁的实现,将其抽象出来屏蔽了同步状态管理、同步队列的管理和维护、阻塞线程排队和通知、唤醒机制等,是一切锁和同步组件实现的 ----- 公共基础部分
AQS 能干嘛?
首先涉及到锁就会有阻塞,有阻塞的话必然有队列,涉及等待和抢占:
源码说明:
AQS 的基本结构:
公平锁和非公平锁的差异:
读写锁
无锁 独占锁 读写锁 邮戳锁
读写锁:它只允许读读共存,而读写和写写依然是互斥的,大多实际场景是 "读/读"线程间并不存在互斥关系,只有 "读/写"或 "写/写"线程间的操作需要互斥。因而引入读写锁,一个 ReentrantReadWriteLock 同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁,也即一个资源可以被一个写操作或多个读操作同时访问,但两者不能同时进行。
只有在读多写少的情况下,读写锁才具有较高的性能体现。
读写锁之锁降级:
StampedLock (邮戳锁、票据锁)
里面有个 long 类型的 stamp 变量,代表了锁的状态,当 stamp 返回零时,表示线程获取锁失败。并且,当释放锁或者转换锁的时候,都要传入最初获取的 stamp 值
特点:
-
所有获取锁的方法,都返回一个邮戳(stamp),stamp 为零表示获取失败,其余都表示成功
-
所有释放锁的方法,都需要一个邮戳(stamp),这个 stamp 必须是和成功获取锁时得到的 stamp 一直
-
StampedLock 是不可重入的(如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁)
-
StampedLock 有三种访问模式:
- Reading(读模式悲观):功能和 ReentrantReadWriteLock 的读锁类似
- Writing(写模式):功能和 ReenTrantReadWriteLock 的写锁类似
- Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观任务读取时没人修改,假如被修改再实现升级为悲观读模式
缺点:不支持可重入,悲观读锁和写锁都不支持条件变量,使用过程中不要调用中断操作会发生不可预料的情况
目前还不完善,有些内容没有基础可能不知道在说什么,后续会慢慢完善的