多线程 笔记
2024-07-01 15:38:16 7 举报
AI智能生成
这是一份关于多线程的笔记,涵盖了核心概念、优缺点以及如何在Java语言中进行实现。多线程允许多个任务同时执行,提高系统资源的利用率。然而,线程间的竞争可能导致死锁、数据不一致等问题,需要合理控制。在Java中,可以通过Thread类、Runnable接口以及Executor框架来实现多线程编程。这些工具提供了灵活的线程创建、管理和监控方式,可以构建出高效、稳定的多线程应用程序。
作者其他创作
大纲/内容
jmm
并发/并行
并发--同一时刻只有一条指令运行
并发三大特性
可见性
针对多核CPU执行的的时候,在A线程中,内存中的数据 M被读取到CPU寄存器中,还没有开始计算,这个时候发生cpu上下文切换。 又有B线程过来读取该数据M,先读取到CPU寄存器,然后计算,写到内存 M1,此时CPU上下文切换,切换到A线程,A执行计算,然后写到内存数据M2. 这个地方就是会产生可见性问题, 也就是同一个数据在线程之间不可见
原子性
最小执行单元, 因为CPU会有上下文切换,所以多线程会出现原子性问题
有序性
内部优化进行指令重排
解决并发BUG
volatile
1. 禁止CPU寄存器内存使用
解决了可见性问题
2. 禁止指令重排
解决了有序性问题
锁 synchrozied, lock
解决了原子性问题
并行: 同一时刻,多条指令,在多个处理器上运行
线程
为什么需要线程
为什么要上下文切换
为什么要上下文切换
CPU执行的最小到位是线程, 一个进程中包含很多的线程。
coupon服务运行到机器上,coupon就是一个进程,里面包含很多请求,很多线程
再一次请求中,或则在一个线程中,大部分时间都不是Cpu在处理,而是等到IO, 所以会造成CPU资源的浪费
上下文切换,就会更高效的使用CPU资源。 随之而来的就是会面临CPU高效执行所带来的的问题,比如共享变量可见性, 原子性,可见性
线程组成
1. 线程ID
2. 线程名称
3. 线程优先级 :表示跳读优先级,优先级越高,获得CPU的执行机会就越大
4. 线程状态: 新建,就绪,运行,阻塞,结束等
5. 其他信息: 是否为守护线程等
线程结构
1. 在线程的栈空间中,程序计数器,记录这下一条指令的代码段内存地址,
这个也就是在CPU时间切换发生的时候,当前执行A 线程,假设执行到200行了,此时CPU 发生上下文切换,执行B线程。
此时就需要把A线程的执行位置,记录到程序计数器中,当CPU再一次调度A 线程的时候,读取位置
这个也就是在CPU时间切换发生的时候,当前执行A 线程,假设执行到200行了,此时CPU 发生上下文切换,执行B线程。
此时就需要把A线程的执行位置,记录到程序计数器中,当CPU再一次调度A 线程的时候,读取位置
线程和进程的区别
1. 线程是CPU调度最小单位, 线程的出现就是为了让CPU高效运转
线程创建
1. 继承Thread
2. 实现Callable
3. 实现Runable
4. 通过线程池
1. 线程池通过 Executors.newFixThreadPool(3) 等方式创建
2. 线程池创建完成后,就要提交任务,执行任务了
execute(new Runable(){}) 没有返回值
submit(new Runable(){}) 没有返回值
submit(new Callable(){}) 有返回值
线程的核心原理
线程切换不需要java 本身去关注,而是依靠操作系统调度进程完成
时间片。 就是CPU上下文切换的时间,cpu时间片的长短是以来操作系统和CPU。
一个2GHZ的CPU, 时间片20ms。
则在该时间片上可以初次多少次计算呢?
则在该时间片上可以初次多少次计算呢?
20亿/(1000/20) = 4000万次
线程优先级
1. 先说一下系统层面的线程调度
1. 分时调度模型; 轮流占用CPU。
2. 抢占式调度模型: 等待的线程处于就绪状态,等待CPU随机调度
2. java 层面线程调度
由于java 的线程切换是依赖操作系统,和CPU。
目前市场上主流的是抢占式调度模型, 所以java线程调度也是抢占式
3. Java 层面上,可以给线程分配优先级 private int priority
线程生命周期
1. NEW 状态
创建好,还没有开始执行,没有执行start
2. RUNABLE 状态
1. 在java 层面只有Runable状态, 在操作系统层面上,分为就绪和执行。
2. 执行了start之后的状态, 没有抢占到CPU资源的时候,是就绪状态,抢到之后是运行状态
由于时间切片,所以这两种状态是切换的
由于时间切片,所以这两种状态是切换的
3. BLOCKED 阻塞
1. 阻塞就是在执行同步代码块的时候,前一个还没有执行完。
2. 也就是CPU 时间片轮到 该线程执行的时候,该线程依然要等待,因为此时是阻塞,
3. 阻塞状态只有等,等拿到锁,才会进入到就绪状态,
4. synchrozied ,lock可以让线程进入到阻塞
4. WAITING 等待
1.. 等待就是等待CPU执行权,等CPU时间片轮到的时候就会执行
2. 调用 wait(). join(). park() 等方法会进入到等待状态
3. 等待和阻塞的区别就是:简单理解
1. 阻塞线程,就是当CPU时间片轮到 该线程执行的时候,也不会执行,需要等待锁
2. 等待状态,就是CPU时间片轮到 该线程的时候,就会执行
5. TIMED_WAITING 超时等待
1. 和WAITING 基本差不多,就是会有时长限制
6. TERMINATED 终止
线程执行完成,线程终止结束
线程分析
使用jstack -pid
可以看到线程的详细信息,线程id,name, 状态等。 可以分析是否存在死锁等问题
线程基本操作
线程中断
首先线程中断并不是一个线程的状态,线程的几个状态前面讲过,这个中断只是一种叫法,中断这个应该是 RUNABLE这个状态中
为什么中断线程呢,如果有一个线程A,调用了A.sleep(),或则wait(),join().park()等方法,其实这些线程就是进入了阻塞状态,这些线程可能永远都不会被唤醒,但这样的线程也没有意义啊, 所以就有了中断的操作,我们调用A.interrupt() 方法的时候,就可以执行线程中断操作
线程的interrupt() 方法只是改变线程的中断状态,有两个作用
1. 当前线程已经是阻塞状态了,调用了wait()方法, 执行interrupt()方法后,刚才wait()的地方就会抛下 InterruptException异常,在异常捕获的地方就可以继续使用该线程了
2. 当前线程运行中,执行interrupt() 方法, 线程不收任何影响,继续运行。此时线程的中断标记改为 true. 若果后续该线程调用wait()等方法的时候,会直接抛异常 InterruptException
3. 总结一下,调用interrupt() 方法就会把线程的中断标记 改为 true,默认false.. 为true的时候,就默认需要中断了,而中断的体现就是抛异常InterruptException. 中断触发的条件就是阻塞, 只有阻塞才会中断
Thread.interrupted() 线程复位 把线程中断标识改为 false,这样下次阻塞的时候不会抛异常
t1.isInterrupted() 获取线程的中断标识
一个异常 InterruptedException ,这个异常就是配合 无限期阻塞,(sleep, wait,park等)和 interrupte()方法使用。
在使用这些会产生阻塞的方法时候,都需要主动抛出这个异常,也是为了方便中断
线程中断的思考,线程中断一直都不是很好理解,个人感觉,有无限期阻塞等待的线程,不能一直等下去啊。这个阻塞线程就需要关闭,调用close()方法,但是这样不友好啊,线程可能还有事情,也可能自己不想关闭啊,所以给出了一种优雅的方式,给线程打标 一个线程中断的符号,让阻塞的线程直接进入InterruptException异常,在异常处理中,就可以随心所欲了。 一般在异常处理中,可以把线程 中断复位继续使用
join操作
join()可以理解为线程合并,在 线程A执行的时候,需要依赖线程B的结果, 则会调用 b.join() 方法。 线程B开始执行,线程A 处于WATING或则TIMED_WAITING状态
yield() 操作
就是让出CPU时间片,下一次获得CPU时间片的时间是未知的,等待CPU调度
守护线程 daemon 操作
线程可以分为用户线程 和 守护线程
数据库连接中就有一个守护线程,
线程池原理
线程池就是负责创建线程,管理线程的组件。 主要为了提升性能,提前创建好,使用完后放入到线程池中,避免创建,回收等造成的资源损耗,另外,更方便管理需要创建的线程,完成统一调度
ThreadPoolExecutor 就是线程池的实现类
线程池创建(系统提供)
1. Exectors.newSingletonThreadExecutor()
创建只有一个线程的 线程池,不够用的话,排队
这个等待队列是无界队列,大量任务提交的时候,会阻塞。然后OOM
2. Executors.newCacheThreadPool()
创建不限制线程数量的线程池,空闲线程会被回收
这个是可以无限制创建线程,所以不涉及阻塞队列和拒绝策略。
这个就是来一个任务,创建一个线程,可以快速处理突发性时间,
这个创建线程的数量也是依赖 操作系统或则 JVM, 线程创建过多也会有资源问题
3. Executors.newFixedThreadPool(100)
创建固定线程数量的线程池
阻塞队列也是无界的,也会OOM
4. Executors.newScheduledThreadPool()
创建定时或则延期的线程池
里面的队列使用的是DelayBlockingQueue, 延迟阻塞队列
线程池核心参数
核心线程数量 corePoolSize
线程池创建,A任务提交的时候,创建新的线程 a,再有任务 B 提交的时候,就创建新线程 b. 不管a 线程是否使用完,都会创建
核心线程空闲状态也不会回收的
最大线程 maximumPoolSize
最大线程数量是配合核心线程使用的
当线程数量达到核心线程数量的时候,再来新的任务会放入到阻塞队列
阻塞队列无界, 最大线程参数无效
阻塞队列有界,放满了以后,开启新线程,上限就是最大线程数量
阻塞队列 BlockQueue
核心线程数量达到之后,会放到阻塞队列,分为有界队列,无界队列
ArrayBlockingQueue 有界队列
LinkedBlockingQueue 无界队列
priorityBlockingQueue 存在优先级的无界队列
DelayQueue 延迟队列,每个元素都有出队时间,基于 priorityBlockingQueue实现
同步队列
空闲线程存活时间 keepAliveTime 和时间单位
非核心线程空闲时间超过这个就回收
拒绝策略/饱和策略
1. 阻塞队列是有界队列才会生效
2. 生效条件就是 有界队列满了以后,并且最大线程数满了以后,饱和策略才会有效
3. 饱和策略分类
1. 直接拒绝,抛异常
2. 丢弃队列头元素
3. 丢弃队尾元素
4. 使用主线程执行任务,若主线程已经销毁,抛异常
5. 最重要的就是可以自定义饱和策略,线程满了以后,在重写的方法中抛出来,记录,或则重新放到新线程池中
线程工厂 threadFactory
就是创建线程的工厂,一般不考
如何设置核心线程数大小
1. 在考虑多线程问题的时候,要有一个思维就是 1秒内能做多少次事情,做一次事情需要多长时间
2. 确定核心线程数大小,就是基于CPU核数,和业务类型
CPU核数是硬件设施
业务类型: 一个任务进来后,需要在CPU上执行多长时间,IO等待又是多长时间。
IO 密集型业务
设置线程数量是2N。 比如netty
CPU计算型业务
设置线程数为 N+1
混合型业务
需要计算CPU执行时间, IO等待时间。 比如CPU执行时间是 100ms. IO等待时间是200ms。 则核心线程计算是
(200 + 100)/ 100 = 3个。 就是一个RT时间内 CPU可以执行3个线程任务。
(200 + 100)/ 100 = 3个。 就是一个RT时间内 CPU可以执行3个线程任务。
现实场景中,我们要适可而止,设置线程,比如你设置2N。 但是项目中还有其他业务,其他需要占用线程的地方。这都需要考虑的
线程池任务提交
execute()
没有返回值
submit()
返回feature数据,通过get获取结果
调度器钩子方法
ThreadPoolExector 线程池调度器。 7大核心参数就在这里面
调度器中提供的构子方法
1. beforeExector()
需要重写,任务执行前,执行; 使用当前线程
2. afterExecutor()
需要重写,任务执行后,执行。 使用的就是当前线程
3. terminated()
在 Exector 关闭时候执行
线程池如何让优雅的关闭
线程的状态我们已经看过了哈,但是线程池这个对象 ThreadPoolExector 本身的状态
1. RUNNING 运行
线程池创建完成后,线程池就进入了这个状态
2. SHUTDOWN 关闭
该状态下,线程池就不再接受新的任务了,但是队列中的任务还是会执行完成的
3. STOP 关闭
该状态下,线程池不在接受新的任务,并且队列中的任务也会中断不再执行, 调用shutDownNow()方法会返回中断的线程
4. TIDYING [ˈtaɪdiɪŋ] 整理
所有的任务终止或则执行完成,接下来就会执行 terminated() 构子方法
5. TERMINETED 终止
执行完线程池的 terminated() 钩子方法后的状态
上面是线程池的状态,现在看一下关闭线程池的方法
1. shutDown()
调用后线程池状态从 RUNNING ---> SHUTDOWN. 继续执行队列中线程
2. shutDownNow()
调用后线程池状态从 RUNNING---> STOP。 拒绝新任务,中断队列线程,并且返回
3. awaitTermination() 等待关闭/等待终止
上面的两个方法是异步的,执行关闭后,不会一直等待直到线程池完成关闭,
如果需要等待线程池完全关闭,需要执行awaitTermination()
如何优雅关闭
1. 执行 shutDown()
拒绝新任务,执行队列任务
2. 执行 awaitTermination(long timeout)
指定超时时间,判断是否关闭了所有任务。
特定时间返回false, 没有完全关闭,执行 shutDownNow(). 关闭
也可以轮训调用 awaitTermination(long timeout). 多次调用,尽可能的去执行队列中的任务,返回true的时候就可以了
ThreadLocal
1. 本地线程,存放一个本地副本,线程隔离
2. 使用场景
1. 线程隔离
2. 跨函数数据传递,线程内数据携带方便
3. 内部结构
这个要分两部分描述
1. ThreadLocal
在ThreadLocal中 有一个内部类是 ThreadLocalMap。
2. Thread
在Thread 类中,一个属性是
ThreadLocal.ThreadLocalMap threadLocals
这个属性的就是存放当前线程中所有的 ThreadLocal信息 是一个map
ThreadLocal.ThreadLocalMap threadLocals
这个属性的就是存放当前线程中所有的 ThreadLocal信息 是一个map
3. 这个是面试回答 理解一下哈, 也就是每个线程都会有一个ThreadLocalMap,这个map只和当前线程有关系,这个map中存放的key是不同的
threadLocal, 每次保存不同的全局数据时候,都需要new ThreadLocal(). 也就是map中的entry其实就是不同的threadLocal和值
threadLocal, 每次保存不同的全局数据时候,都需要new ThreadLocal(). 也就是map中的entry其实就是不同的threadLocal和值
4。 面试问题: 为什么不使用线程id 作为map的key呢? 那样的话map智能存放一个该线程键值对,不满足
4. 代码流程
1.set() api
1. 根据当前线程信息,获取Thread中的 threadLocals 属性。 这是一个map
2. 如果map存在,就把threadLocal 作为key, 具体的值作为value 放到map中
5. 内存泄漏相关问题
1. 什么是内存泄漏呢? 就是堆空间数据,经过多次GC还不能完成回收,但是这些对象已经不会被使用。
2. ThreadLocal 为什么会存在内存泄漏
1. ThreadLocalMap中 key是 弱引用
弱引用的GC规则是,下一次GC 会被回收
上面的列子,如果手动把threadLocal引用指为null。 则堆中的ThreadLocal对象就会被回收
此时Entry中的 key 执行就是null了。 但是下面的ThreadLocalMap和entry是强引用。并且是所属当前线程的。
如果当前线程一直在使用,ThreadLocalMap就不能回收,entry也不能回收,但是entry的key 引用已经执行null了,里面的value
不可能在被用到了,也回收不了。 就造成了内存泄漏。
不可能在被用到了,也回收不了。 就造成了内存泄漏。
3. ThreadLocalMap中的key为什么要设置成弱引用呢
1. 如果key是强引用,则ThreadLocal对象永远不可能回收,因为Thread短时间不回收,即使当前的ThreadLocal不在使用了,也不会回收,会造成内存泄漏
3. ThreadLocal 内部怎么解决内存泄漏的。
在set()., get() 方法中,都会调用一下remove, 把key是null的entry 移除掉
4。 面试回答
threadLocalMap是属于当前线程的一个变量,是随着当前线程引用进行GC的,长时间执行的线程,threadLocalMap是不会回收的,里面的entry也不会回收,但是entry的key 引用的是ThreadLocal对象,这个地方是个弱引用,当threadLocal 变量置为null的时候 entry中key就没意义了,value同样,但是还不能回收
ThreadLocal创建的时候要使用static, final
static 是只创建一次,节省资源,空间
final 是为了防止当前threadLocal指引其他对象,这样也会造成内存泄漏哦
如何实现跨线程的数据共享
可以实现,有一个 InherritableThradLocal 可以实现父子线程数据共享
java 内置锁
JAVA对象结构
主要结构
对象头
mark work 标记字
存储GC标志位, 哈希吗, 锁状态
Class pointer 类对象指针
存储对象元数据地址,虚拟机通过地址确定这个对象是哪个类的实例
Array length 数组长度
这个字段只针对数组有用,记录数组的长度
对象体
包含实例变量(成员变量)
对齐字节
也叫做填充对齐, 保证java 对象在所占内存字节数是8的倍数。 JVM寻址就是8的倍数,如果对象不够 8 的倍数,补齐
Mark Word结构信息
mark word的长度就是 JVM中一个word 长度。 32/64位
不同锁状态下mark word 的结构是不一样的
这里只说一下 64位的
这里只说一下 64位的
无锁状态
前面 25位没有用
然后 31 位 标识对象的hashCode
然后1 位没用
后面 4位 分代年龄 这是GC使用
1 位偏向锁标识 默认值 0
后面两位是 锁状态 ,无锁是 01
偏向锁
前面 54位是线程ID
2位 是 epoch 这个应该是年龄,在nacos, zookeeper中应该是有的
1位没用
4位 分代年龄
1 位 偏向锁标识 默认 1
锁状态 偏向锁 01
轻量级锁
62 位 是指向方法栈帧 中的锁记录指针
后面 2位锁状态 默认 00
重量级锁
62位 是 指向重量级锁监视器 monitor的指针
2位锁状态 默认10
GC 标记
前面62位null
后两位锁状态 默认 11
2. java 内置锁的几种状态
在JDK 1.6之前 所有内置锁都是重量级锁
在JDK 1.6之前 所有内置锁都是重量级锁
1.无锁状态
这个就是java 对象刚创建,没有任何线程来竞争。
2. 偏向锁状态
有个线程来访问了,就直接升级位偏向锁,偏向锁就是偏爱一个线程。偏向锁在竞争不激烈的时候很高效
偏向锁记录自己喜欢线程的ID, 把偏向锁标识改为1
为什么需要偏向锁呢?
1. 因为JVM认为一个锁大部分情况只有一个线程来访问,当第二次该线程访问的时候,直接匹配线程ID和标志位,就能访问了
2. 如果该线程在重入该锁的时候,还需要阻塞,就会涉及用户态---> 内核态的转变
偏向锁的撤销和膨胀
1. 偏向锁资源发生竞争的时候,就需要先撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁
2. 偏向锁的撤销(满足其一)
1. 多个线程竞争偏向锁
2. 调用了偏向锁的hashCode方法
3. 重点:偏向锁加锁/撤销/膨胀流程
1. 项目启动后,线程A 访问临界区 (同步代码),锁对象头中mark word 会记录A thread_id。 后续A 线程再次访问临界区,只需要判断thread_id 和 锁状态就行了。不需要再次CAS
2. 此时有 B 线程进入临界区,发现锁对象头的 Mark word 偏向A 线程。通过CAS操作替换 锁对象头中的thread_id。
1. CAS替换成功,说明线程A已经执行完临界区(同步代码块)。 此时锁对象偏向 线程B
2. CAS 失败,这时候就出现了锁竞争,应为目前 锁对象头 mark word还是偏向锁的结构和数据,
所以存在可能要撤销偏向锁,膨胀到轻量级锁,应为轻量级锁存放的hashCode, 这也是要撤销偏向锁的原因
所以存在可能要撤销偏向锁,膨胀到轻量级锁,应为轻量级锁存放的hashCode, 这也是要撤销偏向锁的原因
1. 撤销偏向锁: 这个步骤比较耗时,需要等到 全局安全点,也就是STW, 取出所有占有该锁的线程,暂停线程执行,
检查偏向锁对象头中线程 A是否执行完临界区,
检查偏向锁对象头中线程 A是否执行完临界区,
1. A已经执行完临界区, 则B 线程就通过CAS 替换线程ID。 还是偏向锁。
然后唤醒被暂停的A线程继续执行
然后唤醒被暂停的A线程继续执行
2. A 还没有执行完临界区, 则调用锁对象的hashCode方法,把 哈希码写到对象头中,这是偏向锁就升级到了轻量级锁。
然后唤醒拥有锁的A线程继续执行
然后唤醒拥有锁的A线程继续执行
4. 注意: 在锁对象竞争激烈的场景下,是要禁止偏向锁的,因为偏向锁撤销的时候,需要等到全局安全点才会操作, 这样很损耗系统性能。
3. 轻量级锁状态
引入轻量级锁的目的就是在多线程竞争不激烈的情况下,通过CAS机制竞争锁减少重量级锁产生的性能损耗,重量级锁是要是线程挂起,阻塞的,会涉及到用户态和和心态的频繁切换
基于的场景就是,很多对象锁的锁状态只会持续很短的一段时间,也是根据常识或则实际经验得到的。
轻量级锁就是一种自旋锁, JVM本身就是一个应用,在JVM层面自旋,依旧是用户态操作。轻量级锁的 mark word中存放的就是持有锁的线程的栈帧中的锁记录
两个线程竞争锁资源的时候,就会升级成轻量级锁, 线程通过自旋的方式获取锁,不会阻塞抢锁线程
在轻量级锁状态的时候,线程抢夺资源是通多自旋的方式。为什么要用自旋的方式呢?
重要: 说白了JVM 仍然是一个用户系统,自旋主要是规避了线程阻塞挂起是内核态和用户态切换带来的性能损耗
阻塞了就要先从用户态---> 内核态----> 用户态
阻塞了就要先从用户态---> 内核态----> 用户态
为什么用户态---> 内核态---> 用户态 会有性能损耗呢?
这里涉及到数据在两个态 之间要进行复制,就是从这边读取,那边写入
自旋就是在用户态上执行,效率高, 但是会消耗一点CPU资源
重点: 轻量级锁的加锁流程/升级
1. 当偏向锁撤销后,锁对象中的mark word先变成无锁状态
2. 线程A 开始窒息性抢占锁资源,在开始抢占的时候,线程A 会在抢锁的栈帧中创建一个锁记录,就是lock record。 这个锁记录包含两块
一个是owner,加获得所成功后,这块指向 锁对象
另一个是 Displaced Mark word。 在通过CAS 抢占锁成功后,
锁对象头中的hashCode指向的就是线程A的 Lock record (锁记录指针)。
同时,在线程A的堆栈中,Displaced Mard word会保存锁对象头中原有的指针记录。这个适用于解锁的使用
锁对象头中的hashCode指向的就是线程A的 Lock record (锁记录指针)。
同时,在线程A的堆栈中,Displaced Mard word会保存锁对象头中原有的指针记录。这个适用于解锁的使用
理解一下,就是线程A 栈帧中开辟一个空间
A 栈帧空间包含两部分
1. 指向锁对象
2. 保存锁对象中原有线程信息
还有一个就是锁对象头 mark word。 值通过CAS 替换成占有线程的 锁记录指针
4. 重量级锁状态
获取轻量级锁。自旋多次后,还是获取不到锁,就要升级重量级锁了
重量级锁会让其他线程之间进入阻塞, 重量级锁也叫做同步锁,会创建一个监视器对象
JVM 中,每一个对象都关联一个监视器,这个对象可以是 Object, 也可以是class.
监视器是一个同步工具,许可证,拿到许可证的线程可以进入临界区操作,没有拿到的阻塞,等待
重量级锁就是通过监视器 minitor 的方式,保证任何一个时间只允许一个线程访问
3. syncharozied 的加锁流程
1. 线程A访问临界区代码,就是加了syncharonzied的代码块,通过 CAS 完成加锁,此时是偏向锁,当该线程 A 再次访问临界区,比较线程ID,和锁状态就直接执行临界区代码。
2. 此时有一个线程 B 需要执行临界区,发现锁对象处于偏向状态。并且线程ID并不是抢锁线程的ID, 则通过CAS操作竞争锁,竞争成功的话,将锁对象头的mark word的线程ID替换成抢锁线程,然后执行临界区代码,此时的锁依然是偏向锁
3. 在第二步中,如果线程B CAS抢锁失败,则说明锁发生竞争,就会撤销偏向锁,然后升级成轻量级锁。
升级轻量级锁的时候,先在抢占线程B的栈帧中搞一个 Lock Record 锁记录,然后通过CAS 替换锁对象中的锁记录指针,替换成功,则线程B获得锁,执行临界区,此时线程B的占中的Displace mark word 保存 锁对象头中旧数据, Owner指向锁记录,
升级轻量级锁的时候,先在抢占线程B的栈帧中搞一个 Lock Record 锁记录,然后通过CAS 替换锁对象中的锁记录指针,替换成功,则线程B获得锁,执行临界区,此时线程B的占中的Displace mark word 保存 锁对象头中旧数据, Owner指向锁记录,
4. 上一步中,如果线程B CAS替换锁 对象头中mark word的 锁记录指针, 则标识还有线程正在竞争, 该线程B 开始自旋,时刻准备获取锁对象
5. 线程B 自旋一会发现不行,升级重量级锁,后面等待的线程全部阻塞,则锁对象头的mark word 指向 Minitor。 然后执行后开始随机唤醒阻塞线程。
6. synchrozied是一个非公平锁, 就是在重量级锁的唤醒是随机的
线程间的通信
1. 低效的线程轮询
线程之间的通信可以理解为消费者,生产者。 如果使用轮询的方式,当生产者没有数据产生,就会使用CPU资源空转,浪费资源
2. wait()/ notify()
1. 这是Object类中的方法
2. 工作要求: wait()/ notify() 必须在同步代码块中执行,其实现依赖 minitor 对象监视器中entryList和waitSet
2. 工作原理
wait()
1. 调用同步锁的wait()方法后,JVM 将当前线程加到锁对象监视器的WaitSet 等待集
2. 当前线程需要释放对象监视器的Owner权利。让其他对象去抢占锁对象的监视器
3. 当前线程状态就变成了waiting
notify()
1. 调用notify()之后,唤醒waitSet中的第一个等待线程
2. 如果调用的是notifyAll(), 唤醒waitSet等待集合中的所有线程
3. 等待线程唤醒后,会从waitSet 中移动到entryList中,线程具备了排队抢夺监视器Owner权利的资格, 线程状态从waiting变成了 Blocked
4. EntryList中的线程抢夺到监视器Owner权利之后,线程状态从blocked,变成Runable, 具备重新执行的资格
CAS原理和JUC原子类
CAS
cas主要就是依赖使用内存偏移量就行新值替换老值。
cas 会有一个期望新值和一个旧值, 旧值满足才会改成新值
cas 配合 volitile 使用,保证获取到的值最新
cas 会出现ABA问题
具体的方法是 compareAndSwap,等等
CAS优点: 1. 无锁编程, 不存在唤醒,阻塞等重量级操作
2. 不存在用户态和内核态之间进行切换
2. 不存在用户态和内核态之间进行切换
CAS 弊端
1. ABA问题
2. CAS 主要是使用无锁保证原子性,但是只能保障一个共享变量的原子性,如果两个共享变量是一组数据,就不好弄了
解决办法,把两个对象封装成 一个reference,再操作
3. 无效CAS 会带来开销问题
4. 部分CPU会总线风暴
CAS性能提升
1. 分散操作热点 ,使用LongAdder
2. 队列消峰,将将发生CAS操作的线程加入一个队列中排队,降低CAS争用的并发。
想想AQS。 他就是这么做的。
想想AQS。 他就是这么做的。
cas 在jdk中运用广泛,包括current下面的,AQS中的队尾,队首替换都是CAS
JUC
Atomic 原子操作包
atomic 翻译成不可中断的
实现原理
1. 基础原子类主要是通过 CAS自旋 + volatile 相结合的方式实现
CAS自旋可以保证原子性, volatile保证其可见性,每次拿到的都是最新的值。
二者尝尝结合使用 for(;;)
CAS自旋可以保证原子性, volatile保证其可见性,每次拿到的都是最新的值。
二者尝尝结合使用 for(;;)
ABA问题
解决ABA问题的方法
1. 使用版本号,乐观锁原理。 每次更新比较版本号
2. AtomicStampedReference 也是使用版本号,内部封装类了
3. AtomicMarkableReference 这个就是发生ABA了 标记一下。 但是如果反复ABA, 这个也解决不了
大量CAS 自旋导致CPU 资源损耗问题
使用 LongAdder 代替AtomicInterge等
内部使用一个 base变量,当一个线程访问的时候,就是操作的base。 他就是要操作的对象
两个线程访问共享变量的时候,拆分成多个 cell 单元数组,每个线程线程操作一个cell数组中的元素,最后执行sum操作,得到新值
当大量的线程 都对一个AtomicInterge进行累加的时候,一次只有一个线程可以执行,会造成大量的线程进行空转,造成CPU资源损耗
可见性和有序性原理
1. cpu物理缓存结构
1. CPU包含三层高速缓存,包括L1, L2, L3。 其实在最上面还有一层就是 寄存器,执行速度更快
2. CPU查询数据的时候,先在L1 找,以此类推,找不到再去主内存中查找, 越靠近CPU, 越快
3. 百分之95的数据走CPU 高速缓存,L1 大小是32/64kb L2大小是256KB, L3是12M
4. 单核具有L1,L2高速缓存。 单个CPU 共享L3 高速缓存
5. CPU为什么设置高速缓存,是为了解决CPU执行读取速度和内存读取速度的差别提出的。
6. CPU高速缓存中的存储其实是一行一行的叫做 cache line, 就是缓存行,不管缓存中有没有数据,都是称作缓存行。
加载数据的时候,就直接把数据一级一级的加载到缓存行中
加载数据的时候,就直接把数据一级一级的加载到缓存行中
2. 并发编程的三大问题
1. 原子性问题
2. 可见性问题
由于CPU 寄存器的原因,导致写入主内存数据不准确。
就是多线程之间的数据不可见
就是多线程之间的数据不可见
3. 有序性问题
CPU 指令重排,单例模式的体现
3. 硬件层面的MESI协议原理
1. 硬件层面的MESI协议主要解决的问题就是内存可见性。 还是CPU高速缓存导致的问题
2. 为了解决硬件层面内存可见性问题,有两种方案:
1. 总线锁
1.总线锁就是cpu总线锁,在所有的CPU和内存之间有一个层叫做CPU总线锁。
2. 当多核心CPU要访问L3中的数据,就会通过线程总线来读取,在执行操作的时候,会在线程总线上加一把lock。 这样其他的CPU
就不能操作缓存了,从而就会阻塞其他CPU内核, 刚才执行操作的CPU就会独享内存
就不能操作缓存了,从而就会阻塞其他CPU内核, 刚才执行操作的CPU就会独享内存
3. CPU总线效率低下,一般不使用。 控制的颗粒度太大
2. 缓存锁
1. 缓存锁的颗粒度就是加到了缓存行上面。
2. 各个CPU在访问缓存的时候要遵循一些协议,比如MESI协议,缓存一致性协议
3. 缓存锁和 MESI有什么关系呢? 就是我们现在锁的颗粒度减小了,在每一个缓存行上面。 但是CPU多核心操作数据的时候,怎么存取呢。
缓存锁只是一个概念。 具体的实施还是要遵照MESI协议
缓存锁只是一个概念。 具体的实施还是要遵照MESI协议
4. 每一个缓存行更新了以后,都是要写会主存, CPU写入主存的方式有两种
1. Write-through 直写模式
数据更新后,写入低一级的高速缓存和主内存中。
操作简单,所有的数据都会更新主内存,其他CPU读取最新值
缺点就是慢, 每次修改都要写
2. Write-back 回写模式
1. 不会立即写到主内存中。只是先写入高级缓存
2. 当该数据被替换出高级缓存,(就是淘汰),或则数据变成共享状态的时候,如果数据变动,是要更新到主内存
3. 就是效率高,不用每次写主内存
MESI 协议以及 RFO 请求
1. MESI协议上的状态是针对高速缓存上 cache line
还有一点多核CPU之间是有通信的
还有一点多核CPU之间是有通信的
2. 4 中状态
1. M 被修改 (Modified)。 这个状态的 cache line . 只会在本CPU有缓存,并且和主内存数据不一致
2. E 独享的(Exclusive) 这个状态的缓存行,只在本CPU中有缓存, 数据和主内存一致
3. S 共享的(shared)这个状态的cache line 在多个CPU中存在, 和主内存一致
4. I 无效的 (Invalid) 某个cpu中 cache line 失效了, 是其他的CPU中的缓存行发生了更改导致
3. MESI 协议状态是根据读取,数据更新等变动的。 大致可以理解为每个CPu的高速缓存上都有 很多的cache line . 数据从主内存读取到缓存行的时候,根据CPU对缓存行的操作,以及统一数据是否在多核中存在来变换 缓存行的状态
4. MESI是有性能问题的。
RFO 请求需要ack。 导致阻塞
RFO 请求需要ack。 导致阻塞
1. 各个CPU中都有自己的 cache line。 并且各个CPU之间都有通信,发送通知消息进行状态同步。 比如RFO请求 (Request for owner)就是典型的通知消息
2. RFO 请求就是失效请求,通知其他CPU中的 cache line 失效。
3. 某个CPU更新数据后,通过RFO 通知其他CPU的时候,是需要受到其他CPU的ack的。 假设CPU过多,或则一个CPU调度时间过长。 就导致操作数据的CPU收不到ack. 那么 这个CPU内核,就会处于阻塞状态
4. 解决MESI 协议因为RFO ACK问题导致的阻塞
1. 这个思想就和普遍了,MESI要求的是数据强一致性。 强一致性就会造成性能问题
2. 使用弱一致性就能解决这个问题,也就是最终一致性
3. 解决思路。
1. 在消息发送方,搞一个队列。 接入后,不阻塞CPU
2. 消息接收方, 搞一个队列接收消息,接收到就ACK。 然后异步处理。
3. 说白了就是使用 MQ的思想
4. 解决步骤
1. CPU 内核A 修改了 cache line
2. 把修改的内容放到一个队列 M 中。 然后发送RFO。 本地内核 A 可以执行其他任务,不阻塞
3. 守到其他内核的ACK后,再把 队列 M中的这个数据更新到本地 cache line 上
4. 其他内核接收到 RFO 之后,也会把数据存放到一个 队列 N 中, 放入队列N中成功后, 就直接发送ACK
5. 在队列N 中的数据就是失效的缓存行,再去获取的时候,就只能去主内存中查询了
5. Store Buffer 就是发送方存储更新数据的地方。 Invalidate Queue 就是失效队列,接收方上面存放RFO的
5. volatile 关键字
1作用
1. 禁止CPU 指令重排
2. 禁止使用CPU高速缓存, CPU数据更新后,直接写入主内存
2. 重点: 为什么有MESI 协议了, 还需要volatile 关键字呢
1. MESI 是硬件层面保存缓存一直性的。 volatile 是JMM层面上保持缓存一致性的
2. MESI 协议一般会配合Store Buffer 和 Invalidate Queue 使用,不能完全保证。 volatile 可以
3. MESI 只是保证了数据一致性, 不能保证顺序一致性。 但是volatile可以
3. volatile的内存屏障
写操作内存屏障
在变量写 之后,插入一个写屏障, 写完直接刷到主内存。
在变量后面的指令 不会指令重排。 会按照顺序执行
读操作屏障
在变量读取之后,插入读屏障,禁止后面的普通读,普通写和前面的volatile读操作发生重排序
有序性和内存屏障
重排序问题
编译重排序
CPU重排序
内存屏障的理解
你更新一个数据,在更新数据指令后面插入了一个写屏障,这个写屏障 负责把数据写到主内存中。
这是两个指令,一个是写数据,一个写内存屏障。 这两个指令的顺序是固定的。 不需要先写数据,再执行写屏障
这是两个指令,一个是写数据,一个写内存屏障。 这两个指令的顺序是固定的。 不需要先写数据,再执行写屏障
你在操作高速缓存中的数据的时候,在操作之前读取之前,插入一个读屏障,在读取高速缓存之前,先从主内存读取数据
,然后再操作。 两条指令,读指令,读内存屏障。 必须是顺序的。 限制性内存屏障,在读取数据
,然后再操作。 两条指令,读指令,读内存屏障。 必须是顺序的。 限制性内存屏障,在读取数据
所谓内存屏障,就是和主内存打交道的的。 就是保证数据是最新的, 而且数据操作要有顺序
内存屏障是插在两条指令中的一条指令。
硬件层面的内存屏障
1. 写屏障
1. 让寄存器,高速缓存中的最新数据写会到主内存中
2. 写屏障之前的写指令必须 先执行, 也就是先进行写操作, 再通过写屏障写到主内存
2. 读屏障
1. 高速缓存中数据失效,从主内存读取
2. 就是读屏障发生后, 才能执行后面的读操作。 保证数据干净
3. 全屏障
硬件层内存屏障作用
1. 阻止屏障两侧的指令重排序。 屏障两侧怎么理解呢?
两条指令 A 和 B。 在A 和 B之间插入一个内存屏障。 这两条数据顺序不能乱
2. 强制让新数据写会到主内存,并且让高速缓存中的数据失效。
内存屏障 和 MESI 对比
MESI 主要保障的是缓存一致性。 就是各个CPU上的 cache line要保证一致性, 弱一致性
内存屏障,在两侧禁止指令重排。 强制写会主内存。
重要理解 内存屏障是硬件层面的一个技术,需要给硬件下达指令,硬件才会使用内存屏障的哦。 使用Java 关键字 volatile就能使用这个内存屏障
JMM
JMM是Java的这一种规范和规则
JMM属于概念和规范维度的模型,是一个参考性模型,
记住这段话,我觉得够用了, JMM属于语言级别的内存模型。 确保了在不同编译器和不同的cpu平台上位java程序员提供一致额内存可见性来保证指令并发执行的有序性
比如,volatile这个关键字,我们想达到的效果是 保证可见性,有序性。 但是不同的CPU。不同的操作系统,处理的指令是不一样的。 JMM就是提供了指令的转换
Happen-Before
JUC现实锁的原理和实战
Javad的锁包含 java 内置锁, 显示锁。内置锁不够灵活。 显式锁是java语言开发,更灵活
为什么需要显式锁
1. java 内置锁有会有一些问题,所以出现了显式锁
2. 任何一个java 对象都可以作为内置锁使用, java的对象所使用简单。
3. java 内置锁面临缺点:
1. 限时抢锁
2. 可中断抢锁。 抢锁线程抢锁阻塞的时候,外部线程给抢锁线程发送一个中断信号。 就能唤醒等待锁的线程。 并且终止抢线程
3. 性能问题,很多线程竞争对象锁的时候,会升级成重量级锁,会进行用户态和内核态之间频繁的切换,导致效率低下
Java 显式锁的小优势
Java 显式锁的 接口 Lock
1. void lock()
抢锁,抢不到锁就会阻塞,这个和java 内置锁一样的
2. void lockInterrupttibly()
可中断抢锁, 可以响应中断信号,抛出中断异常
这个内置锁不行啊,内置锁只会阻塞等待
这个内置锁不行啊,内置锁只会阻塞等待
3. boolean tryLock()
尝试抢锁, 不阻塞, 这个内置锁也没有哦
4. boolean tryLock(long time)
限时抢锁, 可响应中断 内置锁没有
5. void unlock()
释放锁
6. Condition newCondition()
获取和显式锁绑定Condition 对象, 用于等待--通知线程间通信
ReentrantLock
1. 这个实现了Lock接口,底层使用了AQS 同步器,获取锁使用的都是Lock接口的模板方法
2. 支持公平锁和非公平锁, 公平锁就是后面来的线程放到同步器 队尾,唤醒的时候,逐个唤醒。
非公平锁,就是在唤醒同步器队列中的线程时,可能会有新的线程抢占锁。
公平不公平就体现在最新抢锁的线程有没有机会获取到锁
非公平锁,就是在唤醒同步器队列中的线程时,可能会有新的线程抢占锁。
公平不公平就体现在最新抢锁的线程有没有机会获取到锁
显式锁的等待--通知 condition
显式线程之间的通信
显式线程之间的通信
java 内置锁的通知等待机制,就是线程通信机制使用的object类中的 wait()、notify() 使用的是monitor监视器内的 waitSet和 entryList
java显式锁的通知等待机制, 就是线程之间通信,使用的Condition
Condition 通信类方法, 后面的方法执行对象
都是Condition, 这个对象是Lock 接口中提供创建
都是Condition, 这个对象是Lock 接口中提供创建
1. await() 等待
加入到等待队列中,并且在线程中释放当前锁。 当其他线程调用 signal(), 等待队列唤醒,重新抢锁。 抢锁的这个阶段又可以分为是公平锁和非公平锁
2. signal() 唤醒
唤醒等待线程
3. signalAll() 唤醒全部
4. boolean await(long time) 限时等待
限时等待,如果没有被唤醒,则终止等待
这个Condition 不能独立创建,必须依赖锁
LockSupport
1. 这个是线程阻塞和唤醒的工具类。 这个工具类可以让线程在任何位置阻塞,唤醒。 内部方法都是静态的
2. 内部方法
1. static void park()
阻塞当前线程, 就是挂起当前线程,让出CPU
2. static void unpark(Thread thread)
唤醒某个挂起的线程, 恢复。
3. static void parkNanos(long nanos)
挂起当前线程, 有挂起时间限制
4. static void parkUntil(long deadline)
挂起当前线程,直到某个时间
3. LockSupport.park() 和 Thread.sleep() 区别
1. sleep 没办法外部唤醒,只能自己醒来
park 可以通过unpark 唤醒
park 可以通过unpark 唤醒
2. sleep() 声明了interruptedException异常,中断后需要自己捕获。
park()不需要自己捕获异常
park()不需要自己捕获异常
3. 执行Thread.intecept() 后, sleep() 响应中断信号抛出异常。 park() 标记中断标识,不会抛异常
4. park() 更灵活的唤醒指定线程
5. sleep() 是一个 native 原生方法,
4. LockSupport.park() 和 Objects.wait()
1. wait() 必须在syncharozied块中执行,锁里面的线程之间通信
park() 可以在线程的任意地方执行
park() 可以在线程的任意地方执行
2. 执行Thread.intecepte() 中断时。 wait() 响应中断信号,抛异常。 park 标记中断标识不操作
显式锁的分类
1. 重入维度
1. 可重入锁
2. 不可重入锁
2. 锁机制
1. 悲观锁
写多读少的场景
synchrozied 重量级锁就是悲观锁
2. 乐观锁
CAS就是乐观锁
synchrozied 轻量级锁就是乐观锁
3. 是否公平
1. 公平锁
申请锁的顺序和获得锁的顺序一样
2. 非公平锁
申请锁的顺序和抢到锁的顺序可能是不一样的
优点就是吞吐量大
缺点就是没有顺序,线程优先级不能体现
4. 中断
1. 可中断锁
2. 不可中断锁
synchrozied
5. 锁使用对象
1. 独占锁
每次只有一个线程使用锁, 悲观的加锁策略 ReentrantLock
2. 共享锁
ReentrantReadWriteLock 也叫做读写锁。
1. 互斥场景
1. 读读不互斥
2. 读写互斥
3.写写互斥
2. 在ReentrantWriteReadLock中有两把锁,一把共享锁,读锁。 一把独占锁,写锁。
还有两个记录独占锁,和共享锁 加锁数量的变量 excludesiveCount, sharedCount
还有两个记录独占锁,和共享锁 加锁数量的变量 excludesiveCount, sharedCount
1. 读锁加锁逻辑
1. readLock.lock() 加锁的时候,也是使用的AQS模板中的钩子方法 tryAcquireShared()获取锁,先判断当前独占锁 加锁数量, 大于 1 的话,就调用AQS中模板方法 doAcquireShared() 当前线程 addWaiter() 添加到同步队列中,然后park().
2. 如果当前没有写锁,也就是写锁的线程数量 sharedCount 等于 0. 则直接获取锁,调用
2. 读锁释放锁
唤醒AQS 等待队列中 所有的读锁
3. 独占锁加锁
1. 调用钩子方法 tryAcquire() 加锁,加锁失败就添加到node 双向链表中l,和独占锁逻辑一样了
2. semaphore [ˈseməfɔːr] 信号量
1. 作用: 控制同一时间访问共享资源的线程数量。 维护了一组虚拟许可,数量构造方法可以指定
访问共享资源之前要先获得许可,使用acquire()。 许可数量位0的时候,就会一直阻塞。
该线程访问完成后,使用release()方法释放许可。 所以这个semaphore 就是一个许可管理器
访问共享资源之前要先获得许可,使用acquire()。 许可数量位0的时候,就会一直阻塞。
该线程访问完成后,使用release()方法释放许可。 所以这个semaphore 就是一个许可管理器
2. semaphore的源码也是使用了AQS中的同步队列模版。大致源码流程如下
1. 在new Semapora()的时候创建 permit 令牌个数,然后赋值给AQS中的state
2. 执行acquire()的时候,源码中使用AQS的钩子方法 tryAcquieShare(), Semaphora自己实现这个钩子方法
。 AQS中state 递减,大于等于0的时候标识获取到了许可
。 AQS中state 递减,大于等于0的时候标识获取到了许可
3. state计算后 小于0的话,就是没有获取到许可。 调用AQS中的addWaiter() 方法,没有双向链表的话,就通过enq() 自旋创建。
4. 添加到双向链表后,再次尝试获取许可 tryAcquireShare(). 获取不到,就调用 LockSupport.park() 进行挂起。 这些都是AQS的模版方法实现的
5. 等释放许可证的时候,调用realse()。 然后执行步骤是和互斥锁一样。 当前线程 unpark()唤醒AQS 队列中的下一个节点, 然后移除当前节点。
3. CountDownLatch 共享锁
作用就是等待所有线程执行完毕在执行。
指定多少个任务执行完毕,可以继续执行。 提交任务后,自己阻塞 await()。
每个任务提交后调用countdown() 减1 。
指定多少个任务执行完毕,可以继续执行。 提交任务后,自己阻塞 await()。
每个任务提交后调用countdown() 减1 。
2. CountDownLatch 共享锁底层也是使用的AQS 共享锁, 源码流程
1. new CountDownLatch(10). 10个线程全部执行完后,主线程才会继续执行。 也就是主线程会阻塞。
这个构造方法就是赋值 AQS中state的值。
这个构造方法就是赋值 AQS中state的值。
2. await() 方法就是挂起主线程, 把主线程addWaiter()放到AQS中的node双向链表中,并且park() 主线程
3. countDown() 方法就是递减 AQS中的state值。 当等于0 的时候,就unpark() AQS同步队列中挂起的主线程
3. CountDownLatch 强调的是 主线程挂起阻塞, 等待小兵们执行完了。 就像孩子们的妈妈等待孩子们吃完,才吃饭。 主线程挂起,等到state = 0 的时候唤醒双向链表下一个节点,就是主线程
4. CyclicBarrier 循环栅栏
作用: 等待所有的线程都准备好了才执行同步代码
这个叫做循环栅栏, 强调的是所有子线程全部挂起,等到达到一定数量之后才会放开。 就比如,所有的孩子吃完饭,上床睡觉,如果有没吃完的,其他人要等着。
循环的意思是支持 reset。 当设置的等待线程是0的时候,可以重新设置。
初始化线程数量之后,赋值AQS中的state值, 等到 子线程 await() 方法的时候,state -1. 然后加到双向链表,挂起。 等state = 0 的时候,notifyAll(). 执行unpark() 唤醒AQS队列中的全部线程
6. 自旋锁 CLH
1. 自旋锁CLH 就是一个队列,本质是一个单向链表。
2. 具体的流程
1. 线程A 需要执行同步代码块, 发现被锁状态。然后把自己 使用CAS加到链表的tail
2. 然后线程A 就在前驱节点自旋, 等到前驱节点释放锁后, 自己就拿到了锁
3. 自旋锁只会在加锁的时候使用CAS操作,抢锁线程通过简单的自旋就可以, 不需要在自旋获得锁。 这种可以规避CPU总线风暴
7. 死锁
死锁条件
1. 互斥
2. 占有且等待
3. 不可抢占
4. 循环等待
死锁检测和中断
JVM管理工厂 ManagementFactory
上面类调用个静态方法,可以获得类 ThreadMXBean 类,这个类就可以搞死锁。 这个类在Jconsole中可以见到
提供了两个方法可以检测死锁,
1. 检查显式锁 和 内置锁的放大 findDeadLockedThreads
2. 检查java 内置锁 findMinitorDeadLockedThreads
如果是可中断的抢占锁, 检测到死锁之后,中断其中一个。 但这不是长久之久,因为下次请求还会有问题
AQS原理
AQS 是同步器类,java的各种显式锁,ReenTrantLocak, writeAndReadLock, Condition等底层都是根绝AQS来实现的。
AQS类中属性
1. Node head: 双向链表头指针
2. Node tail : 双向链表尾节点指针
3. int state: 加锁的标识, 重入锁的话会 +1., 通过volatile 和 cas进行操作
4. 内部类Node:
1. shared / exclusive: 区分独占锁还是共享锁
2. int waitStatus
取消状态是 1
排队等待状态是 -1. 末节点是0
Condition 条件队列中使用的是 -2
还有一个 -3,没做研究
3. Node prev: 前直接点
4. Node next: 后置指针
5. Thread thread: 当前线程
6. nextWaiter
AQS面试回答: AQS是一个模版了,提供了一些钩子方法,tryAcquire(), tryLease()等。 AQS在模版中提供了加锁,释放锁的模板化流程, 具体加锁成功的条件,解锁成功的条件交给钩子方法实现。 加锁流程:尝试调用钩子方法中的实现,加锁,加锁成功,则直接获取锁。 和AQS没有交互。 加锁失败的时候,就会创建一个双向链表,把线程信息存放到Node中,初始状态是0. 然后校验前置节点状态 如果是大于0 ,则循环去掉节点,已经失效了。 如果前置节点是-1. 返回。 前置节点是0的话,就替换成-1. 就是最后一个节点进到队列的时候,会把前直接点状态改为-1. 然后执行LocakSupport.park() 挂起线程。
等其他线程执行 unLock()方法的时候,就通过tryReasele()钩子方法进行释放锁, 释放成功后。 就会通过unpark()唤醒下一个节点。
等其他线程执行 unLock()方法的时候,就通过tryReasele()钩子方法进行释放锁, 释放成功后。 就会通过unpark()唤醒下一个节点。
AQS 实现是非常依赖 CAS的, state状态, 尾节点替换,头结点创建,都需要使用CAS。 volatile也是非常依赖的。 保证了关键属性state的可见性等。 AQS中最重要的理论基础就是 CAS 和 volatile
Condition:
这是条件队列,这个类也是在AQS内部。每个锁中都可以创建多个条件队列。功能就是等待/唤醒,API就是await()/ single()等。
等待/唤醒只能在锁内部操作。
等待/唤醒只能在锁内部操作。
流程就是,假设线程A执行过程中,加锁后,执行了Await()方法,让出CPU执行权, 会创建一个Node节点的单项链表,把当前线程放入到队列中
,AQS等待队列中就要通过unpark唤醒下一个节点。 大致await()方法执行的时候,就会把当前线程从AQS队列的头部(当前线程的节点),移动到Condition
条件队列的尾部。 等执行 single() 唤醒的时候,就会把Condition条件队列中的头节点移动到AQS尾部节点上。 谨记,并且要理解
,AQS等待队列中就要通过unpark唤醒下一个节点。 大致await()方法执行的时候,就会把当前线程从AQS队列的头部(当前线程的节点),移动到Condition
条件队列的尾部。 等执行 single() 唤醒的时候,就会把Condition条件队列中的头节点移动到AQS尾部节点上。 谨记,并且要理解
JUC容器类
基础容器
List set queue MAP
基础容器面临问题就是线程不安全
解决的话,在Collect中提供了对基础容器类的包装, 使用synchrozied关键字,这个性能在重量级锁的时候比较低。
比如HashTable, Vector都是这样的被包装的
比如HashTable, Vector都是这样的被包装的
所以就需要设计支持高并发的容器
高并发容器
1. 实现理论: 高并发容器主要是通过CAS + volatile 组合实现。 CAS保证原子性, volatile保证可见性。 实现了无锁的方式保证线程安全
无锁的有点就是不需要在内核态 和用户态之间来回切换
读写不互斥, 写操作需要CAS 乐观锁。 读不需要。
2. CopyOnWriteArrayList
这是一个很重要的 高并发容器,很多源码中都有使用。 使用的是 写时复制 思想。 是ArrayList的升级。 CopyOnWrite 就是写时复制,简称 COW
原理就是 读线程的时候,和原始ArrayList一样。 写操作的时候,会创建一个内存副本, 在新的内存副本上做操作。 并且原始数据对其他线程依然可见。等到在新的内存副本写完数据以后, 再将原来栈中的指针指向新内存,原来的内存会被回收。底层的数据是通过volatile实现的。 保证了可见性。 适用于读多写少的场景。
流程: add() 操作的时候, 使用ReentrantLock加锁,加的是不可中断锁, 获取原来数据, 复制新的副本,在副本上完成操作, 再把原来的指针 array 指到新的内存上。 解锁
COWArrayList 可以实现循环遍历的时候,往集合中添加元素。 因为 写和读 不在一个内存地址上
COWArrayList 和 ReentrantReadWriteLock 区别
后面的 RRWL 是读写锁, 读读不互斥,其他都互斥
前面的COWArrayList 只有写写 是互斥。
3. BlockingQueue
1. 阻塞队列,在很多源码中也有, 只要用于通信这块了, 数据传递。 可以阻塞读,阻塞写
2. API 比较多
1. add() 添加元素,队列满了就抛出异常
2. offer() 非阻塞添加元素,队列满了,返回,
3. put() 阻塞添加元素,队列满了就等待
4. take() 阻塞删除(获取).队列没有元素就等待
5. poll() 非阻塞获取。 没有元素就返回null
6. remove() 移除,成功true, 失败false
3. 常见的BlockingQueue
1. ArrayBlockingQueue
1. 有界,需要指定原始数组大小
2. 源码实现。 添加元素的时候,都需要加锁,使用 ReentranLock。
3. 在put() / take() 这组阻塞方法中, 使用了两个条件队列
1. notFull() 添加元素满的时候, 需要notFull.await()
2. notEmpty() 删除/获取元素的时候,队列中没有内容, 要执行 notEmpty.await()
1. put() 操作添加元素
1. 添加成功。 notEmpty.single()
2. 队列满了 notFull.await()
2. take() 删除元素
1. 删除成功,唤醒添加 notFull.single()
2. 删除时对列为null。 notEmpty.await()
2. LinkedBlockingQueue
无界的,容易OOM。 也可以指定有界
实现原理和 ArrayBlockingQueue 基本一样的
3. DelayQueue
延迟队列,设置指定时间
4. PriorityBlockingQueue
优先级阻塞队列
5. SynchronousQueue
同步队列
4. ConCurrentHashMap
1. HashMap为什么线程不安全
1. 在JDK7中 在进行扩容的时候,会造成死循环。大概原因就是扩容复制的时候,是找到 每一个桶中的链表头结点,然后倒排插入到新map桶中。
这样;两个线程rehash之后,就会造成这种死循环。
这样;两个线程rehash之后,就会造成这种死循环。
2. 在JDK8 中扩容会造成数据替换, 也是扩容的时候,假设A, B线程扩容 rehash之后的结果一样,数据是需要放到一个桶中的。
当时间片切换的时候,第一个线程A rehash后挂起, B线程完成操作。 A获得时间片后,插入数据,会把B线程数据覆盖
当时间片切换的时候,第一个线程A rehash后挂起, B线程完成操作。 A获得时间片后,插入数据,会把B线程数据覆盖
2. HashTable 为什么线程安全
每个方法上加了Syncharozied, 实例锁。 当一个实例执行get方法的时候,其他所有的操作也要暂停。 效率很低
3. ConcurrentHashMap
1 JDK 7
segment分段锁。 每个分段就是一个Entry. 也就是在一个链表上加锁。
在ConcurrentHashMap中会有16个分段锁, 每个分段锁负责所有的桶的 1/16
使用的是Synchrozied 支持的分段锁。 可以支持16个线程同时写入(最理想情况)
2. JDK 8
1. 抛弃了 7中的分段锁
2. 扩容的原理就是,先把桶的个数扩容到64. 到64之后,并且链表中数量到8之后,就会在链表上更改数据结构位红黑树
3. 使用了 CAS
保证线程安全的设计模式
1. 单例模式
1. 饿汉式 单例模式
1. 饿汉式就是默认这个单例就会被使用。存在的问题就是可能创建后的单例不会被使用,造成GC不能回收。代码备注
2. 静态变量在创建这个类的时候就会创建
2. 懒汉式 单例模式
1. 懒汉式就是用的时候再创建,代码备注
2. 懒汉式面临的问题就是线程不安全了。
3. 加锁的 懒汉式 单例模式
添加synchrozied 关键字
面临问题就是每次进来获取锁都要排队,可能升级重量级锁,效率低
4.
通过synchrozied 并且双重检查 创建
为什么要双重检查null, 第一个null就是正常检查,没有数据的时候去创建,第二个锁的意义是。
比如 A 获取单例,第一步check 是null. 准备进入同步代码,此时线程B执行,也check是null. 然后加锁创建对象,此时A 执行进到阻塞队列中
当B 线程执行完后创建号单例释放锁之后,线程A 唤醒,又会创建一遍单例。导致不是单例了
比如 A 获取单例,第一步check 是null. 准备进入同步代码,此时线程B执行,也check是null. 然后加锁创建对象,此时A 执行进到阻塞队列中
当B 线程执行完后创建号单例释放锁之后,线程A 唤醒,又会创建一遍单例。导致不是单例了
双重检查锁面临的问题就是指令重排
1. 在 singletonObject = new SingletonObject()这一步中,CPU的指令正常是
1. 申请一块内存M。
2. 在内存M 上初始化实例
3. M 的地址 赋值给instance 变量
2. 但是由于指令重排,会出现这个顺序
1. 申请一块内存M
2. M 的地址赋值给instance 变量
3. 在 M上实例化
3. 在第一个线程A 创建单例的过程中,如果B 线程访问获取,可能拿到没有实例化的 instance, 然后执行的时候就是空指针
5. 双重检查锁 + volatile
1. 在 instance 上加上 volatile 禁止指令重排
2. 但是他们说这样写,比较麻烦。确实比较麻烦啊
6. 静态内部类 实例懒汉单例模式
1. 需要直到的前提是 静态变量是在类初始化执行。 但是静态方法,静态内部类是在需要的时候才会执行、、
2. 在静态内部类中创建静态变量。 这样只会在使用静态内部类的时候,单例才会创建。代码如下
3. 加final 保证初始化的时候线程安全
4. 静态内部类也会面临问题,就是使用字节码侵入后,通过反射创建出来的不是单例
7. 使用枚举实现单例模式, 枚举是不支持反射的,源码中有限定。 枚举是天然的单例模式
2. Master-Worker 模式
1. Master-Worker模式是一种常见的高并发设计模式, 核心思想就是任务的调度和处理是分开的,有一个Master任务负责接受任务,分配任务,处理worker返回结果集。 然后有很多个Worker线程,主要负责执行任务。
2. Master-Worker是一种归并算法, 核心思想是分而治之
2. 假设不采用Master-Worker这种模式,就好比每个线程都要接受任务,执行任务 。 大部分场景下,执行任务是比较耗时的,执行过程中,线程不会释放。就会导致新任务进不来。
采用Master-Worker模式,有一个或多个线程负责接受任务,分配任务,这个效率是比较高的, 接收到的任务可以放到阻塞队列中, 让worker慢慢执行。 同步时不会造成任务丢失的情况
采用Master-Worker模式,有一个或多个线程负责接受任务,分配任务,这个效率是比较高的, 接收到的任务可以放到阻塞队列中, 让worker慢慢执行。 同步时不会造成任务丢失的情况
3。 Netty 和 TCP连接的时候就是用的Master-worker模式支持高平发场景
3. ForkJoin 模式
子主题
4. Future 模式
Future 是阻塞获取线程执行结果的, 有get() 方法,当一个线程的返回值Future 调用get() 方法的时候,如果当前线程还没有执行完成,就会阻塞等待。
Future有一个子类是FutureTask, 这个FutureTask 类就记录了当前线程执行到的步骤,状态集的流转,执行中,执行完成,失败,异常等。
FutureTask中还有一个属性是 outcome, 等该线程执行完成之后,返回值写到outcome中,并且唤醒等待队列中主线程。 让主线程直接获取 outcome中的值
Future有一个子类是FutureTask, 这个FutureTask 类就记录了当前线程执行到的步骤,状态集的流转,执行中,执行完成,失败,异常等。
FutureTask中还有一个属性是 outcome, 等该线程执行完成之后,返回值写到outcome中,并且唤醒等待队列中主线程。 让主线程直接获取 outcome中的值
异步阻塞调用和异步回调
1. JUC 下的包使用FutureTask提交任务之后,智能通过get()获取结果,但是这个操作时会阻塞的。 主线程阻塞调用各个任务返回的结果。
2. Guava 下的多线程封装了JUC。 可以实现异步回调
3. Java8 之后 JUC下的CompletableFuture 也是支持异步回调。 这个类让用户关注宏观流程,而不再关心具体的线程创建,执行,销毁等
0 条评论
下一页