Java并发
2023-04-28 16:58:42 5 举报
AI智能生成
JUC
作者其他创作
大纲/内容
多线程基础
基础概念
操作系统级别
进程
进程是计算机中的基本执行单元,是程序在操作系统上运行时分配资源的基本单位。每个进程都有独立的地址空间、代码、数据和系统资源。进程之间通过进程间通信机制来共享数据和信息。操作系统可以通过进程调度算法控制进程的运行。
内存分配:进程之间的地址空间和资源是相互独立的
一个进程崩溃后,在保护模式下不会对其他进程产生影响
每个独立的进程有程序运行的入口. 顺序执行序列和程序出口,可并发执行
线程
线程是进程中的一个执行流程,可以看做是轻量级的进程。一个进程可以有多个线程,线程之间共享进程的地址空间和系统资源。线程的执行可以独立于其他线程,也可以协作执行。线程的调度和管理通常由操作系统内核完成。
内存分配:同一进程的线程共享本进程的地址空间和资源
一个线程崩溃整个进程都死掉
线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,可并发执行
<b>守护线程</b>
守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。<br>在 Java 中垃圾回收线程就是特殊的守护线程。
编程语言级别
协程
协程是一种用户态线程,也称为轻量级线程。协程可以看做是函数的执行流程,不同于线程,协程在同一时间内只有一个协程在执行。协程之间的切换不需要内核介入,而是由用户自行管理。协程的优点是轻量级、快速切换、低开销,适合处理高并发和IO密集型的任务。
管程
管程是一种用于协调多个进程或线程之间的共享资源的机制。管程提供了一组操作共享资源的接口,同时对这些操作进行了互斥和同步的管理。通过管程,多个进程或线程可以安全地访问共享资源,避免了竞争条件和死锁等问题。管程通常由高级语言的库或操作系统内核提供。
<font color="#e74f4c"><b>管程则是一种协调共享资源的机制,可以在进程和线程中使用</b></font>
创建线程方式
Runnable接口
重新run()
任务没有返回值
run()无法抛异常
Callable接口
重写call()
任务执行后可返回值
call()可以抛异常
运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果
Thread类(继承了Runable)
为什么使用多线程?
提速
提高CPU使用率,充分利用系统资源
线程间的切换和调度的成本远远小于进程
支持高并发场景
计算机层面
单核机器
让CPU和IO可以同时进行
多核机器
让多个CPU核心都可以被利用到
线程的生命周期
新建状态(New)
当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
就绪状态(Runable)
当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行
运行状态(Running)
当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中
阻塞状态(Blocked)
处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种
<b>等待阻塞</b>:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态
<b>同步阻塞</b> :线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态
<b>其他阻塞</b>:通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时. join()等待线程终止或者超时. 或者I/O处理完毕时,线程重新转入就绪状态
等待状态(Wating)
当线程等待某个特定的时间或者其他线程发出的通知时,线程就会进入等待状态,此时线程不会占用 CPU 资源
超时等待状态(Timed Wating)
当线程等待的时间超过了指定的等待时间时,线程就会进入超时等待状态,此时线程不会占用 CPU 资源
终止状态(Terminated)
线程执行完了或者因异常退出了run()方法,该线程结束生命周期
<b>正常结束</b>:run()或者call()方法执行结束<br>
<b>异常结束</b>:线程抛出未捕获Exception或者Error错误<br>
<b>调用stop()方法</b>:一般不推荐使用该种方式,因为该方法通常容易导致死锁<br>
拓展:<b>中断(优雅的关闭线程)</b>
在Java中没有办法立即停止一条线程,然而停止线程却显得尤为重要,如取消一个耗时操作。<br>因此,Java提供了一种用于停止线程的机制——中断<br>
当线程在 sleep()、wait() 或 join() 方法上等待时,中断线程会唤醒线程并抛出 InterruptedException 异常
当线程在 java.nio.channels.InterruptibleChannel 上等待时,中断线程会关闭通道并唤醒线程,并抛出 ClosedByInterruptException 异常<br>
当线程在 Selector 上等待时,中断线程会使 Selector 立即返回,并抛出 ClosedSelectorException 异常<br>
当线程处于其他状态时,调用中断方法只是设置线程的中断状态为 true,需要在程序中检查中断状态并决定是否终止线程的执行<br>
pubulic interrupt()
将调用者线程的中断状态设为true
public bool isInterrupted()<br>
判断调用者线程的中断状态<br>
public static boolean interrupted()
只能通过Thread.interrupted()调用<br>
<ol><li>返回当前线程的中断状态;</li><li>将当前线程的中断状态设为false</li></ol>
线程常见关键字、方法及对比
Runable VS Callable
Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Callable 接口可以返回结果或抛出检查异常
Runnable 接口不会返回结果或抛出检查异常<br>
如果任务不需要返回结果或抛出异常推荐使用 Runnable接口,这样代码看起来会更加简洁<br>
工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule))<br>
shutdown() VS shutdownNow()<br>
关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。 shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止<br>
isTerminated() VS isShutdown()<br>
isShutDown 当调用 shutdown() 方法后返回为 true<br>
isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true<br>
sleep() VS wait()
sleep()
是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态
当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来<br>
睡眠不释放锁<br>
wait()
是Object的方法,必须与synchronized关键字一起使用<br>
线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞
只有重新占用互斥锁之后才会进入可运行状态
睡眠时,会释放互斥锁
sleep 通常被用于暂停执行Wait 通常被用于线程间交互/通信<br>两者都可以暂停线程的执行<br>
start() VS run()
<b>调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行</b>
JVM执行start方法,会另起一条线程执行thread的run方法,这才起到多线程的效果
new 一个 Thread,线程进入了新建状态; 调用start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,(调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。)这是真正的多线程工作<br>
yield()
Yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行<br>
基本没有业务场景用到
Fork/Join框架
算法
<b>分而治之</b>
<b>工作窃取法</b>
例子
Arrays工具类提供的并行排序parallelSort
CAS(Compare and swap)
即比较并交换,它是一条 CPU 同步原语。是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。<br>
是一种无锁的非阻塞算法的实现
操作数<br>
需要读写的内存值<b>V</b>
旧的预期值<b>A</b>
要修改的更新值<b>B</b>
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的 值,否则不会执行任何操作(他的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的
拓展
CAS 并发原语体现在 Java 语言中的 sum.misc.Unsafe 类中的各个方法。调用 Unsafe 类中的 CAS 方法, JVM 会帮助我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于 CAS是一种系统原语,<b><font color="#e74f4c">原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断</font></b>,CAS 是一条 CPU 的原子指令,不会造成数据不一致问题
缺陷
ABA问题
使用版本号解决
类:AtomicStampedReference
循环时间长开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销
设置自旋次数,避免一直循环
只能保证一个变量的原子操作
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的
<ol><li>使用互斥锁来保证原子性</li><li>将多个变量封装成对象,通过AtomicReference来保证原子性</li></ol>
ThreadLocal
概念
ThreadLocal,即<b>线程本地变量</b>。<br>如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题<br>
Demo
//创建一个ThreadLocal变量<br>static ThreadLocal<String> localVariable = new ThreadLocal<>();
应用场景
数据库连接池
会话管理
实现原理
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap
ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值
拓展
<b><font color="#e74f4c">在Java中键值对的存储方式不建议使用对象为key</font></b>
在使用对象作为key值时,<b><font color="#e74f4c">由于对象的引用地址是不稳定的,可能会随着垃圾回收而发生变化</font></b>,因此在HashMap中使用对象作为key值时需要进行计算hash值,并对hash值进行处理,以便可以快速的查找对应的value值
<b><font color="#e74f4c">对象的hashcode计算较慢</font>。</b>如果对象的hashcode计算比较复杂或者需要访问对象的实例变量,就会导致hash值的计算比较慢,从而影响HashMap的性能
<b><font color="#e74f4c">对象的equals方法比较较慢</font>。</b>HashMap在查找key值时需要进行equals比较,如果equals方法实现比较复杂或者需要访问对象的实例变量,也会影响HashMap的性能
<b style="font-size: inherit;"><font color="#e74f4c">对象作为key值需要占用内存</font></b><span style="font-size: inherit;">。使用对象作为key值时,每个key值都需要占用一定的内存空间,如果存储的key值较多,就会</span><b style="font-size: inherit;"><font color="#e74f4c">占用较多的内存空间</font></b><span style="font-size: inherit;">,从而影响性能</span><br>
如果必须使用对象作为key值,可以考虑重写hashcode和equals方法,尽量减少计算量
每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离
ThreadLocal内存结构图
内存泄漏
ThreadLocalMap是一个弱引用对象,GC的时候将会被回收<br><b>线程的重新启动或者线程的回收</b>会导致ThreadLocalMap的<b><font color="#e74f4c">地址引用发生变化<br></font></b>从而导致Entry数组中,value存在key不存在,引发<b><font color="#e74f4c">内存泄漏</font></b>
解决方案:使用完ThreadLocal后,及时调用remove()方法释放内存空间
防并发
死锁
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止<br><font color="#e74f4c">即两个线程互相等待对方释放资源</font>
死锁条件
互斥条件:该资源任意一个时刻只由一个线程占用
请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
避免死锁
破环死锁四大条件的任一
<ul><li>破坏互斥条件 这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)</li><li>破坏请求与保持条件 一次性申请所有的资源。</li><li>破坏不剥夺条件 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。</li><li>破坏循环等待条件 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。</li><li>锁排序法:(必须回答出来的点) 指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁? 通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。</li><li>使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁</li></ul>
synchronize
作用域
修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁<br>synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
特别注意
如果一个线程A调用一个实例对象的<b>非静态 synchronized 方法</b>,而线程B需要调用这个实例对象<b>所属类的静态 synchronized 方法</b>,<br>是允许的,<b>不会发生互斥</b>现象,因为访问静态 synchronized 方法占用的锁是当前类的锁<br><b>一个是对象锁,一个是类锁,不是同一个锁</b>
尽量不要使用 synchronized(String s) ,因为JVM中,字符串常量池具有<b><font color="#e74f4c">缓存</font></b>功能<br>如果多个线程使用相同的字符串常量作为锁对象<br>
锁升级
在Java中,synchronized是一个重要的同步机制,它的实现依赖于对象头的标记位(Mark Word)和对象的监视器(Monitor)。在Java 6及之前的版本中,synchronized的实现比较简单,存在锁膨胀和锁粗化的优化策略,但锁升级机制并不明显。在Java 7中,synchronized进行了升级,引入了轻量级锁和偏向锁,使得锁升级机制更加明显。<br>
下面是synchronized锁升级的过程:<br><ol><li><b>无锁</b></li><li><b>偏向锁</b>:当一个线程访问一个没有被锁定的对象时,虚拟机会将对象头的Mark Word设置为指向当前线程的Thread ID,表示该对象被该线程偏向了。如果同一个线程再次访问该对象,它可以直接获取到锁,无需进行加锁操作。这种偏向锁的升级过程是非常快速的,因为只有一条线程访问该对象。</li><li><b>轻量级锁</b>:当两个或多个线程访问同一个对象时,偏向锁就失效了。此时虚拟机会尝试使用轻量级锁,来避免线程阻塞。轻量级锁的实现原理是,虚拟机会将对象头中的Mark Word复制到线程栈的锁记录(Lock Record)中,然后使用CAS操作尝试将对象头的Mark Word替换成指向锁记录的指针。如果这个CAS操作成功了,说明该线程已经获取到了锁;如果失败了,则说明其他线程正在竞争该锁,此时虚拟机会退化为重量级锁。</li><li><b>重量级锁</b>:如果轻量级锁的CAS操作失败了,那么虚拟机会使用重量级锁。重量级锁的实现原理是,虚拟机会让等待锁的线程进入到一个Entry Set队列中,然后让线程进入阻塞状态。当锁被释放时,虚拟机会从Entry Set队列中选择一个线程唤醒,然后让该线程进入到Wait Set队列中,等待重新获取锁。</li></ol>
拓展
<b>自适应自旋锁</b>:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点
总之,synchronized的锁升级过程是一个逐级升级的过程,当线程访问的对象不是被自己偏向的对象时,会尝试使用轻量级锁;当轻量级锁的CAS操作失败了,会退化为重量级锁。锁升级的过程是为了在保证多线程并发执行的情况下,尽可能地减少线程竞争和阻塞,提高程序的执行效率。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系
锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了
作用
原子性
确保线程互斥的访问同步代码
可见性
保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 <br>“<b>对一个变量unlock操作之前,必须要同步到主内存中</b>;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,<br><b>在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值</b>” 来保证的
有序性
有效解决重排序问题,即happens-before锁原则(一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作)
底层原理
前置知识
对象结构
概念
对象头
Mark Word
存储哈希码、GC信息、锁信息等
一般是4个字节或者8个字节,取决于硬件,64位操作系统是8个字节
Kalss Point
它指向一个对象的类元数据,这个空间的大小在不同的虚拟机实现中可能不同,通常为4个字节或8个字节
实例变量
存放这个实例的一些属性信息,比如有的属性是<b>基本类型</b>,那就<b>直接存储值</b>;如果是<b>对象类型</b>,存放的就是一个<b>指向对象的内存地址</b>
对齐补充
主要是补齐作用,<b>JVM对象的大小比如是8字节的整数倍</b>,如果 (对象头 + 实例变量 )不是8的整数倍,则通过对齐填充来补齐
举例
Monitor
synchronize关键字升级到重量级锁的时候,MarkWord中前30比特会存储Monitor对象的引用
参考:https://blog.csdn.net/chenzengnian123/article/details/122683264
非公平锁
当持有锁的线程释放锁时,该线程会执行以下两个重要操作
1.先将锁的持有者 owner 属性赋值为 null
2.唤醒等待链表中的一个线程(假定继承者)
在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁
当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒
volatile
<b>volatile是Java虚拟机提供的轻量级同步机制,<font color="#e74f4c">并不是一个锁</font></b>
作用
防止指令重排序(有序性)
不保证原子性<br>
保证可见性
非volatile变量
读写时每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中<br>
volatile变量
<b>JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步</b><br>
对 volatile 变量进行写操作时,会在写操作后加一条 store 屏障指令,将工作内存中的共享变量刷新回主内存<br>
对 volatile 变量进行读操作时,会在写操作后加一条 load 屏障指令,从主内存中读取共享变量<br>
即happens-before的volatile原则
通过 hsdis 工具获取 JIT 编译器生成的汇编指令来看看对 volatile 进行写操作CPU会做有<b>lock addl指令</b>操作
<br>
<span style="font-size: inherit;">lock指令效果:<br><ol><li><span style="font-size: inherit;">将当前处理器缓存行的数据写回到系统内存</span></li><li><span style="font-size: inherit;">这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效</span></li></ol></span>
性能
volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
ReentrantLock
ReetrantLock是一个可重入的独占锁
支持公平锁和非公平锁
可重入
ReetrantLock实现依赖于AQS(AbstractQueuedSynchronizer)
ReetrantLock主要依靠AQS维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。等待唤醒,重新尝试加锁
ReentrantReadWriteLock
由于ReentrantLock某时候有局限性,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。<br>从而诞生了ReentrantReadWriteLock,即实现了一个读写锁。写入操作是独占的,读取操作是共享的。读写、写写互斥,读读不互斥,从而提升了性能
volatile VS synchronized<br>
<ul><li>volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取</li><li>synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住</li></ul>
<ul><li>volatile 仅能使用在变量级别</li><li>synchronized 则可以使用在 变量. 方法. 和类级别的</li></ul>
<ul><li>volatile 仅能实现变量的修改可见性,不能保证原子性</li><li>synchronized 则可以 保证变量的修改可见性和原子性</li></ul>
<ul><li>volatile 不会造成线程的阻塞</li><li>synchronized 可能会造成线程的阻塞</li></ul>
<ul><li>volatile 标记的变量不会被编译器优化(不会重排序)</li><li>synchronized 标记的变量可以被编译器优化</li></ul>
synchronize VS Lock
<ul><li>synchronized 可以给类. 方法. 代码块加锁</li><li>lock 只能给代码块加锁</li></ul>
<ul><li>synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁</li><li>lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁</li></ul>
<ul><li>通过 Lock 可以知道有没有成功获取锁</li><li>synchronized 却无法办到</li></ul>
synchronize VS ReentrantLock
两者都是可重入锁
拓展<br>可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁<br>比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增<b>1</b>,所以要等到锁的计数器下降为<b>0</b>时才能释放锁<br>
synchronized 依赖于 JVM <br>ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
ReentrantLock 比 synchronized灵活
<span style="font-size: inherit;">ReentrantLock</span>增加了一些高级功能<br><ol><li><span style="font-size: inherit;">等待可中断</span></li><li><span style="font-size: inherit;">可实现公平锁</span></li><li><span style="font-size: inherit;">可实现选择性通知(锁可以绑定多个条件)</span></li></ol>
等待可中断.通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情
ReentrantLock可以指定是公平锁还是非公平锁<br><b>synchronized只能是非公平锁,ReentrantLock默认情况是非公平的</b>。可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的
拓展:所谓的公平锁就是先等待的线程先获得锁
ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,<b>用ReentrantLock类结合Condition实例可以实现“选择性通知”</b>
<div>使用建议</div>
<ul><li>除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized</li><li>synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放</li></ul>
线程池
为什么使用线程池?
线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
优点
<b>降低资源消耗</b>。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
<b>提高响应速度</b>。 当任务到达时,任务可以不需要的等到线程创建就能立即执行
<b>提高线程的可管理性</b>。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
池化是一种思想,使用他的场景还有很多,比如常量池、数据库连接池等
execute() VS submit()
<b>execute() 方法用于提交<font color="#e74f4c">不需要返回值的任务</font>,所以无法判断任务是否被线程池执行成功与否</b>
<b>submit()方法用于提交<font color="#e74f4c">需要返回值的任务</font>。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功</b>,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候<font color="#e74f4c">有可能任务没有执行完</font>。
可以在执行完所有任务后调用shutdown()方法来关闭线程池,然后再调用awaitTermination()方法等待所有线程执行完成
<b>shutdown()</b>方法会禁止向线程池中添加新的任务,并且等待已经提交的任务执行完成
<b>awaitTermination()</b>方法则会阻塞调用线程,直到线程池中的所有任务都已经执行完成,或者超时等待时间到达。
Executor VS Executors
Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求
Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。<br>
使用ThreadPoolExecutor 可以创建自定义线程池。Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用get()方法获取计算的结果<br>
线程池参数
核心参数
int corePoolSize
核心线程数
int maximumPoolSize
最大线程数
long keepAliveTime
当线程数大于核心数时,这是多余的空闲线程在终止之前等待新任务的最长时间
BlockingQueue<Runnable> workQueue
用于在执行任务之前保留任务的队列。此队列将仅 Runnable 保存该方法提交
defaultHandler
饱和策略。ThreadPoolExecutor类中一共有4种饱和策略。通过实现RejectedExecutionHandler接口
AbortPolicy
线程任务丢弃报错。默认饱和策略
DiscardPolicy
线程任务直接丢弃不报错
DiscardOldestPolicy
将workQueue队首任务丢弃,将最新线程任务重新加入队列执行
CallerRunsPolicy
线程池之外的线程直接调用run方法执行
ThreadFactory<br>
执行器创建新线程时使用的工厂
allowCoreThreadTimeOut
是否允许核心线程超时
queueCapacity
任务队列容量
TimeUnit
时间单位
执行流程
常用Java线程池
newCachedThreadPool
创建一个可缓存线程池
如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE),这也意味着要控制任务数量,否容易造成OOM
如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止
newFixedThreadPool
创建一个指定工作线程数量的线程池
每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中
FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即<b>线程池中没有可运行任务时,它不会释放工作线程</b>,还会占用一定的系统资源
newSingleThreadExecutor
创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务
所有任务按照指定顺序(FIFO, LIFO, 优先级)执行
优点:<br><ul><li>可以通过一个统一的接口来管理所有任务的执行,例如对任务的优先级、超时时间、任务的取消等进行管理,而且能够更好地利用计算机的资源</li><li>当你需要扩展应用程序以支持并发执行时,可以更容易地将现有的代码改造为多线程的实现</li></ul>
newScheduleThreadPool
创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行
WorkStealingPool
工作窃取线程池,适用于大量计算密集型任务的场景,可以自动调整线程数以提高CPU利用率
线程池常用的阻塞队列
LinkedBlockingQueue
是一个基于链表实现的阻塞队列,可用于生产者-消费者模式
最大容量可以指定也可以不指定,如果不指定,则容量大小默认为 Integer.MAX_VALUE
当任务队列满时,新的任务会被阻塞,直到队列有空闲位置才会被添加进来
SynchronousQueue
是一个不存储元素的阻塞队列
SynchronousQueue是一种特殊的队列,其内部并不存储任何元素,插入操作和删除操作必须同时发生,因此也被称为“<b><font color="#e74f4c">同步移交队列</font></b>”
用于任务的提交和执行之间的协调。提交任务时,必须等待线程池中的某个线程来执行该任务,才能返回执行结果。如果线程池中没有空闲线程,提交任务的线程会被阻塞,等待线程池中的某个线程空闲下来后再执行该任务。<br>因此,SynchronousQueue可以让提交任务的线程和执行任务的线程保持同步,<b>避免线程池中的线程被过度占用,提高了系统的并发性能</b>
DelayedWorkQueue
是一个基于优先级队列实现的延迟阻塞队列
该队列中的每个元素都有一个过期时间,当获取队列元素时,只有过期时间到达的元素才会被出队
在线程池中,一般用于实现延迟任务调度,例如定时器等
为什么线程池中大量使用软引用和弱引用?
优点:<br><ul><li><b>线程池里面使用软引用或弱引用主要是为了避免线程池中的任务长时间占用内存,从而导致系统内存耗尽或OOM问题的发生。</b>这是因为线程池中的任务一旦被执行,就会在内存中占用一定的资源,如果任务执行时间很长,那么这些资源就会被长时间占用,从而导致系统内存不足的问题。</li></ul>
缺点:<br><ul><li>使用软引用或弱引用虽然可以解决内存问题,但也<b>可能会导致线程池中的任务被意外地回收</b>,从而影响线程池的正常执行</li></ul>
源码中线程池是怎么复用线程的<br>
ThreadPoolExecutor中的复用线程是通过线程池中的worker对象实现的。worker对象通过自循环,从workQueue中取出任务并执行。当worker执行完任务后,如果workQueue不为空,就会继续执行下一个任务;如果workQueue为空,则调用getTask()方法获取下一个任务。getTask()方法会一直阻塞直到有任务到来或者线程池被关闭
<br>
核心线程数怎么设置
美团文档:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html
AQS
什么是AQS
AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现
比如我们在 lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得锁,<br>对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,<br>这就是 lock 时锁的内部机制,封装的很好,又暴露出子类锁需要扩展的地方
<b style="color: rgb(231, 79, 76);">AQS 底层是由同步队列 + 条件队列联手组成</b><font color="#000000">,<br></font>同步队列管理着获取不到锁的线程的排队和释放,条件队列是在一定场景下,对同步队列的补充
比如获得锁的线程从空队列中拿数据,肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞
AQS 围绕两个队列,提供了四大场景,分别对应着 AQS 架构图中的四种颜色的线的走向
<ul><li>获得锁</li><li>释放锁</li><li>条件队列的阻塞</li><li>条件队列的唤醒</li></ul>
AQS使用了哪些设计模式?
AQS同步器的设计是基于模板方法模式的
如果需要自定义同步器一般的方式是这样?
<ol><li>使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)</li><li>将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。</li></ol>
自定义同步器时需要重写下面几个AQS提供的模板方法
<ul><li>isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。</li><li>tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。</li><li>tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。</li><li>tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。</li><li>tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。</li></ul>
设计模式参考文档:https://mp.weixin.qq.com/s?__biz=MzAwNjQwNzU2NQ==&mid=2650352483&idx=1&sn=9031bf1c36672983ff470ecc168fcfbe&chksm=83005f81b477d697ca6a84246c8892198e125e98d47ebaa0eaf00ce54e5a204ae483b4f74170&mpshare=1&scene=1&srcid=0816lBVcv3MUnGqFT2vaSR26&sharer_sharetime=1629083156388&sharer_shareid=95ecfad5690a6c3ffaaf379265a01d5a&version=4.1.3.6008&platform=win#rd
队列
同步队列:当一个线程获取到锁时,它就会从同步队列中移除掉,同时唤醒下一个等待的线程
<ul><li>当前线程获取同步状态失败,同步器将当前线程机等待状态等信息构造成一个Node节点加入队列,放在队尾,同步器重新设置尾节点</li><li>加入队列后,会阻塞当前线程</li><li>同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中第一个节点,让其再次获取同步状态</li></ul>
条件队列:当某个条件不满足时,线程就会进入条件队列等待。当条件满足时,会从条件队列中唤醒一个等待线程,将其从条件队列中移除,并将其加入到同步队列中去,等待获取锁
资源共享方式
Exclusive(独占),例:ReentrantLock
公平锁
按队列顺序执行
非公平锁
线程竞争,谁抢到谁先执行,无序
Share(共享),例:Semaphore/CountDownLatch/ReentrantReadWriteLock
总结:不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了
AQS组件
Semaphore(信号量)-允许多个线程同时访问
synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源
CountDownLatch (倒计时器)
CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行
CyclicBarrier(循环栅栏)
CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
Atomic 原子类
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
所以,所谓原子类说简单点就是具有原子 / 原子操作特征的类<br>并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下<br>
AtomicStampedReference
该类将整型数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题
主要是通过CAS算法 + volatile + native本地方式实现效果
0 条评论
下一页