java-基础知识
2024-09-12 16:43:21 8 举报
AI智能生成
主要java知识的整理,对于框架、数据库、中间件、算法在其他的思维导图体现
作者其他创作
大纲/内容
面向对象
面向对象
面向对象是将构成问题的事物分解成一个个的对象,使用对象去描述事物在解决问题过程中的行为,而不是专注于使用对象去完成一个步骤
面向过程
面向过程简而言之就是将一个问题的解决划分为多个步骤,使用函数实现一个个的步骤,然后再按照顺序进行调用
区别
两者在解决问题时,专注的角度不同,面向对象正如这个名称而言,它更关注问题中设计到了那些对象,有什么属性,涉及到什么行为,去将这样的对象一个个实例化,再通过对象之间的行为去解决一个问题。而面向过程是,关注点在于第一步要做什么、第二部要做什么,这样一个循序渐进的过程。
类加载
类的声明周期
加载、(验证、准备、解析)、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接<br>
双亲委派
双亲委派模型
即在加载一个类时,会先由父类加载器尝试加载该类,如果父类加载器无法加载,则交给子类加载器去尝试加载。这样可以确保Java类的唯一性,同时也可以防止Java类库被恶意篡改。
破坏双亲委派例子
tomcat 自定义类加载亲,可以让一个Tomcat可以运行多个服务
SPI(jdbc、Dubbo、Eleasticsearch)
Thread.currentThread.setContextClassloader(mysql驱动)
类加载
加载
读取文件,二进制流
连接
验证
文件格式验证(Class 文件格式检查)<br><br>元数据验证(字节码语义检查)<br><br>字节码验证(程序语义检查)<br><br>符号引用验证(类的正确性检查)
准备
为类的<font color="#ed77b6">静态变量</font>分配内存,并将其初始化为默认值<br>数据类型默认的零值(如0、0L、null、false等)<br>final 修饰直接初始化为对应的值
解析
虚拟机将常量池内的符号引用替换为直接引用的过程<br>
初始化
JVM对类的初始化是一个延迟机制,当一个类在首次使用的时候才会被初始化,<br>在同一个运行时package下,一个Class只会被初始化一次。<br><font color="#a23c73">此时对静态变量赋值</font>
通过new关键字会导致类的初始化<br><br>访问类的静态变量,包括读取和更新会导致类的初始化<br><br>访问类的静态方法,也会导致类初始化<br><br>初始化子类会导致父类被初始化<br><br>对某个类进行反射操作。会导致类被初始化<br><br>启动类,就是执行main函数所在的类会导致该类被初始化
类加载器
启动类加载器(Bootstrap ClassLoader)
是用本地代码实现的类加载器,主要用于加载JRE/lib目录下的核心Java类库,如rt.jar、charsets.jar等。启动类加载器是Java虚拟机最顶层的类加载器,它没有父类加载器。 启动类加载器使用C++编写,因此无法在Java代码中获取到它的引用。启动类加载器加载的类不受限于类路径的限制,因为它在Java虚拟机启动时就已经被加载了。<br><i> JDK17 没有rt.jar </i>
扩展类加载器(Extension ClassLoader)
是由Java代码实现的类加载器,它的父类加载器是启动类加载器。扩展类加载器用于加载Java扩展API,这些API通常存放在JRE/lib/ext目录下,如jconsole.jar、jmxremote.jar等。扩展类加载器在Java虚拟机启动时会被创建,它的类加载路径可以通过系统属性java.ext.dirs来指定。如果要使用自定义的扩展类加载器,也可以通过设置java.system.class.loader系统属性来指定<br>
应用程序类加载器(Application ClassLoader)
当Java虚拟机需要加载一个类时,会先让应用程序类加载器尝试加载该类,如果应用程序类加载器无法加载,则会依次由其父类加载器进行加载,直到启动类加载器为止。
自定义类加载器(User ClassLoader)
获取方法
public class TestClassLoader {<br> public static void main(String[] args) {<br> System.out.println(TestClassLoader.class.getClassLoader()); //AppClassLoader<br> System.out.println(TestClassLoader.class.getClassLoader().getParent()); //PlatformClassLoader<br> System.out.println(TestClassLoader.class.getClassLoader().getParent().getParent());//null<br> }<br>}<br>
常用方法
Class.forName("").getDeclaredConstructor().newInstance();<br>Test.getClass().getClassLoader().loadClass("").getDeclaredConstructor().newInstance();
loadClass是抽象类ClassLoader中实现的方法,从源码来看,当调用ClassLoader.loadClass()方法时,调用的是loadClass(name,false),注意到第二个参数false,可以得知loadClass只是加载类,不会对类进行解析和初始化。<br><font color="#f19594">--懒加载实现</font>
参考地址
https://blog.csdn.net/qq_38411796/article/details/139226495
对象创建
对象大小
对象头(Header) 12
markword 8
用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁等。<br>这部分数据长度在32位机器和64位机器虚拟机中分别为4字节和8字节<br>(64位的JVM为了节约内存可以使用选项+UseCompressedOops开启指针压缩,开启该选项后,占用字节数降为4字节);<br>
matedata类型指针<br>4
即对象指向它的类元数据(保存在方法区)的指针,虚拟机通过这个指针来确定<br>这个对象属于哪个类的实例,指针占用4个字节(64位机器占8个字节);<br>
数组长度
只有数组对象才有:如果是 Java 数组,对象头必须有一块用于记录数组长度的数据,用4个字节int来记录数组长度;
查看方法
<dependency><br> <groupId>org.openjdk.jol</groupId><br> <artifactId>jol-core</artifactId><br> <version>0.9</version><br></dependency>
public class ObjectHeaderTest {<br> public static void main(String[] args) {<br> System.out.println("=========打印Object对象的大小========");<br> ClassLayout layout = ClassLayout.parseInstance(new Object());<br> System.out.println(layout.toPrintable());<br> System.out.println("========打印数组对象的大小=========");<br> ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});<br> System.out.println(layout1.toPrintable());<br> System.out.println("========打印有成员变量的对象大小=========");<br> ClassLayout layout2 = ClassLayout.parseInstance(new ArtisanTest());<br> System.out.println(layout2.toPrintable());<br> }<br><br> /**<br> * ‐XX:+UseCompressedOops 表示开启压缩普通对象指针<br> * ‐XX:+UseCompressedClassPointers 表示开启压缩类指针<br> *<br> */<br> public static class ArtisanTest {<br><br> int id; //4B<br> String name; //4B<br> byte b; //1B<br> Object o; //4B<br> }<br>}<br>
实例数据(Instance Data)
实例数据中主要包括对象的各种成员变量,包括基本类型和引用类型;static类型的变量会放到java/lang/Class中,而不会放到实例数据中;<br><br>对于引用类型的成员(包括string),存储的指针;对于基本类型,直接存储内容;通常会将基本类型存储在一起,引用类型存储在一起;
对齐填充(Padding)
Java对象采用的是8字节对齐。对象大小必须是8的倍数,不足需要补齐。比如,计算一个对象只需要20字节,那么实际占用24字节
创建过程
检查类是否加载(非必然步骤,如果没有就执行类的加载)<br>
当需要创建一个类的实例对象时,比如通过new xxx()方式,虚拟机首先会去检查这个类是否在常量池中能定位到一个类的符号引用,并且检查这个符号引用代表的类是已经被加载、解析和初始化,如果没有,那么必须先执行类的加载流程;如果已经加载过了,就不会再次加载。<br>Q:为什么在对象创建时,需要有这一个检查判断?<br>A:主要原因在于:类的加载,通常都是懒加载,只有当使用类的时候才会加载,所以先要有这个判断流程。<br>
分配内存
类加载成功后,虚拟机就能够确定对象的大小了,<br>此时虚拟机会在堆内存中划分一块对象大小的内存空间出来,分配给新生对象。<br>
分配方法
指针碰撞法<br>
Serial、ParNew<br>内存是规整的
空闲列表法<br>cms
安全问题
CAS+重试机制
TLAB (thread local Allocation buffer):也称为本地线程分配缓冲,这个处理方式思想很简单,就是当线程开启时,虚拟机会为每个线程分配一块较大的空间,然后线程内部创建对象的时候,就从自己的空间分配,这样就不会有并发问题了,当线程自己的空间用完了才会从堆中分配内存,之后会转为通过 CAS+重试机制来解决并发问题<br>
初始化零值<br>
初始化零值,顾名思义,就是对分配的这一块内存(实例数据区)初始化零值,也就是给对象的实例成员变量赋于零值,比如 int 类型赋值为 0,引用类型为null等操作。这样对象就可以在没有赋值情况下使用了,只不过访问对象的成员变量都是零值。
设置头对象<br>
初始化零值完成之后,虚拟机就会对对象进行必要的设置,比如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息,这些信息都会存放在对象头中。这部分数据,官方称它为“Mark Word”。
执行<init>方法(该方法由实例成员变量声明、实例初始化块和构造方法组成)
<init>方法Java在编译的时候生成的,该方法包含这个类中的实例成员变量声明、实例初始化块和构造方法,作用是给对象执行初始化操作。类中有多少个构造方法就有多少个<init>方法。创建对象时使用哪个构造方法,就执行对应的<init>方法
顺序
父类的实例成员变量声明和实例初始化块(按照声明顺序)<br> 父类构造方法<br> 子类的实例成员变量声明和实例初始化块(按照声明顺序)<br> 子类的构造方法
详情
https://blog.csdn.net/qq_53070263/article/details/137514717
对象引用
强引用
是 Java 的默认引用实现,是使用最普遍的引用
垃圾回收器绝不会回收它,它会尽可能长时间的存活于 JVM 内
软引用
软引用( SoftReference) 会尽可能长的保留引用直到 JVM 内存不足时才会被回收(虚拟机保证), <br>这一特性使得 SoftReference 非常适合缓存应用<br>
软引用可用来实现内存敏感的高速缓存。<br>软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,<br>Java虚拟机就会把这个软引用加入到与之关联的引用队列中<br>
SoftReference<Object> softRef = new SoftReference<>(new Object());Object obj = softRef.get();<br>
弱引用
弱引用也用于描述还有用但并非必需的对象。与软引用不同的是,<br>垃圾回收器只要发现了就会回收具有弱引用的对象,而不管内存是否足够。<br>
ThreadLocal<br>Entry {threadLocal value}<br>entry 是弱引用,threadLocal不使用可以回收,entry弱引用也可以回收<br>value 不会被回收,所以用完要用remove
JDK中的ThreadLocalMap又做了一次精彩的表演,它没有继承java.util.Map类,而是自己实现了一套专门用来定时清理无效资源的字典结构。其内部存储实体结构Entry<ThreadLocal, T>继承自java.lan.ref.WeakReference,这样当ThreadLocal不再被引用时,因为弱引用机制原因,当jvm发现内存不足时,会自动回收弱引用指向的实例内存,即其线程内部的ThreadLocalMap会释放其对ThreadLocal的引用从而让jvm回收ThreadLocal对象。这里是重点强调下,是回收对ThreadLocal对象,而非整个Entry,所以线程变量中的值T对象还是在内存中存在的,所以内存泄漏的问题还没有完全解决。
WeakReference<Object> weakRef = new WeakReference<>(new Object());Object obj = weakRef.get();<br>
虚引用
虚引用也被成为幽灵引用,它的存在主要是为了跟踪对象被垃圾回收器回收的活动。虚引用不能单独使用,必须和引用队列
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();<br>PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue);<br>Object obj = phantomRef.get();<br>
序列化
Serializable
可以序列化的标记接口
ObjectOutputStream:用于将对象序列化为字节流的输出流。<br>ObjectInputStream:用于将字节流反序列化为对象的输入流。<br>writeObject():将对象序列化为字节流并写入输出流中的方法。<br>readObject():从输入流中读取字节流并反序列化为对象的方法。<br>Serializable接口:通过实现该接口,可以指示一个类是可序列化的。
对象持久化:通过将对象序列化为字节流,可以将对象保存到文件系统或数据库中,以便在后续时间点重新加载和使用。<br>网络传输:通过将对象序列化为字节流,可以在网络上传输对象数据,例如在客户端和服务器之间进行远程调用。<br>缓存管理:将对象序列化为字节流后,可以将其存储在缓存中,以提高系统性能和响应速度。<br>深拷贝:通过将对象序列化为字节流并再次反序列化,可以实现深拷贝,即创建一个与原始对象完全独立的副本。<br>对象克隆:Serializable接口通常与clone()方法结合使用,以实现对象的克隆操作。
一个实体类User实现Serializable接口,并且定义了serialVersionUID变量。<br>serialVersionUID是用来辅助对象的序列化与反序列化的,原则上序列化后的数据当中的serialVersionUID与当前类当中的serialVersionUID一致,那么该对象才能被反序列化成功。<br>工作机制:在序列化的时候系统将serialVersionUID写入到序列化的文件中去,当反序列化的时候系统会先去检测文件中的serialVersionUID是否跟当前的文件的serialVersionUID是否一致,如果一直则反序列化成功,否则就说明当前类跟序列化后的类发生了变化。
Externalizable
继承 Serializable
可以序列化 下面的标识的属性<br>transient<br>static<br>
数组
集合和数组的区别<br>长度区别:数组的长度固定,而集合长度可变<br>内容区别:数组可以是基本数据类型,也可以是引用数据类型(类、接口、数组);二集合只能是引用数据类型<br>元素内容:数组只能存储同一数据类型,集合可以存储不同数据类型(集合一般存储的也是同一数据类型)<br>
集合
一般集合
Collection
list
代表有序、可重复的集合
线程安全
Vector(淘汰)
ArrayList实现类的对比:<br><br>联系:底层都是数组的扩容<br><br>区别:ArrayList底层扩容长度时原数组的1.5倍,线程不安全 -->效率高<br>Vector底层扩容长度时原数组的2倍,线程安全–> 效率低(已淘汰)
替代品<br>// Your standard, unsynchronized list<br>List data = new ArrayList();<br>// Use this to put it into a synchronization wrapper<br>List syncedData = Collections.synchronizedList(data); <br><br>//CopyOnWriteArrayList
CopyOnWriteArrayList
CopyOnWriteArrayList 是 List 接口的一个线程安全实现,适用于需要保证线程安全频繁读取和偶尔修改的场景。其基本工作原理是,当对列表进行写操作(如添加、删除、更新元素)时,它会创建一个底层数组的副本,然后在新数组上执行写操作。这种“写时复制”的机制确保了在进行写操作时,不会影响正在进行的读操作,从而实现了线程安全。
优点<br>高效的读取:读取操作不需要加锁,可以并发执行,性能非常高。<br>线程安全:由于写操作是在副本上进行,不会影响其他线程的读操作,天然地实现了线程安全。<br>迭代安全:迭代器遍历的是数据的快照,因此在遍历期间对数据的修改不会影响迭代器的遍历。<br><br>缺点<br>写操作开销大:每次写操作都需要复制数据,内存消耗较大,且写操作相对较慢。<br>内存使用高:频繁的写操作会导致大量内存占用。<br><br>适用场景<br>CopyOnWriteArrayList 特别适用于读操作频繁而写操作较少的场景,例如缓存、配置管理、白名单和黑名单等。在这些场景中,读取操作占主导地位,而写操作相对较少,因此可以充分利用 Copy-On-Write 技术的优点。
线程不安全
ArrayList和LinkedList的区别<br>ArrayList:数据结构<br>物理结构:紧密结构<br>逻辑结构:线性表(数组)<br>LinkedList:数据结构<br>物理结构:跳转结构<br>逻辑结构:线性表(链表,双向链表)
set
代表无序、不可重复的集合
queue
代表一种队列集合实现
map
Map则代表具有映射关系的集合
hashmap
HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。<br>数组+链表改成了数组+链表或红黑树<br>链表的插入方式从头插法改成了尾插法<br>扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;<br>在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容; <br>
面试:<br>你知道hash的实现吗?为什么要这样实现<br>JDK1.8中,是通过hashCode()的高16位异或低16位实现的:(h=k.hashCode())^(h>>>16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。<br>大于8变成红黑树,小于6退位链表<br>HashMap会导致CPU 100%?<br>HashMap 发生死循环的一个重要原因是 JDK 1.7 时链表的插入是首部倒序插入的,而 JDK 1.8 时已经变成了尾部插入,有人把这个死循环的问题反馈给了 Sun 公司,但它们认为这不是一个问题,因为 HashMap 本身就是非线程安全的,如果要在多线程使用建议使用 ConcurrentHashMap 替代 HashMap,但面试中这个问题被问的频率比较高,所以在这里就特殊说明一下。1.7头插入,A扩容后,B线程也执行扩容导致<br><br>
:HashMap会导致CPU 100%?
ConcurrentHashMap
默认并发级别(可以在构造函数中传入,默认16)个数<br>ConcurrentHashMap在JDK1.8中做了进一步优化,基于CAS+synchronized做了“桶”锁实现的线程安全:<br>CAS:在没有hash冲突时(Node要放在数组上时);<br>synchronized:在出现hash冲突时(Node存放的位置已经有数据了);【尾插法】 <br>
并发集合
ConcurrentHashMap
JDK 1.7 实现:<br>分段锁(Segment):ConcurrentHashMap 将整个 HashMap 分成多个 Segment,每个 Segment 类似一个小的 HashMap,并且用锁来保护。因此,多个线程可以同时访问不同的 Segment,从而提高并发性能。<br>分段操作:通过对键的哈希值取不同的高位来确定数据存放的 Segment。每个 Segment 独立的加锁操作,减少了锁的粒度,提高了并发性能。<br>JDK 1.8 实现:<br>CAS 和 synchronized:ConcurrentHashMap 移除了分段锁机制,转而使用 CAS 操作和 synchronized 锁来实现操作原子性。<br>哈希桶数组(Node 数组):每个桶存放一个链表(或红黑树,在冲突严重时转换)。<br>volatile 关键字:使用 volatile 关键字保证变量的可见性,避免脏数据。<br>简单优先策略:使用 CAS 尝试更新(简单优先),失败时使用 synchronized 锁保护较复杂的操作。 <br><br>ConcurrentHashMap 的大小(size)方法是如何实现的? 数组对象统计 对象使用volatile<br>为什么 ConcurrentHashMap 不允许使用 null 作为键或值?避免 NullPointerException 降低复杂度 一贯性<br>
volatile
CopyOnWriteArrayList
ConcurrentLinkedQueue
ConcurrentSkipListMap
LinkedBlockingQueue
ArrayBlockingQueue
PriorityBlockingQueue
多线程
基本概念
线程状态<br>VM.toThreadState<br>
转换图
详情
NEW
新创建了一个线程对象,但还没有调用start()方法。
RUNNABLE
Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
BLOCKED
表示线程阻塞于锁。
阻塞的情况分三种:<br><br>(1) 等待阻塞:运行的线程执行wait()方法,该线程进入等待池中 <br><br>(2) 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则该线程进入锁池中<br><br>(3) 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
WAITING
进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
TIMED_WAITING
该状态不同于WAITING,它可以在指定的时间后自行返回。
TERMINATED
表示该线程已经执行完毕
实现方式
Thread <br>实现Runnable接口<br>
Thread thread1 = new Thread(){<br> @Override<br> public void run() {<br> System.out.println("run2");<br> }<br> };
Runnable+Thread
Thread thread = new Thread(new Runnable() {<br> @Override<br> public void run() {<br> System.out.println("run");<br> }<br> });
FutureTask+Callable/runnable
class MyCallable implements Callable<String> {<br> public String call() {<br> return "Thread is running...";<br> }<br>}<br> Callable<String> callable = new MyCallable();<br> <br> // 创建FutureTask对象,用于包装Callable对象<br> FutureTask<String> futureTask = new FutureTask<>(callable);<br> <br> // 创建线程并启动<br> Thread thread = new Thread(futureTask);<br> thread.start();<br> <br> try {<br> // 获取线程执行结果<br> String result = futureTask.get();<br> System.out.println(result);<br> } catch (Exception e) {<br> e.printStackTrace();<br> }<br>
Excutor
Executors.newFixedThreadPool(int nThreads)<br>固定大小线程池<br>
创建一个固定大小的线程池,线程池中的线程数量为 nThreads<br>
Executors.newCachedThreadPool()<br>缓存线程池<br>
创建一个缓存线程池,根据需要创建新线程,但会复用空闲线程<br>
Executors.newSingleThreadExecutor()<br>单例线程池
创建一个单线程池,所有任务按顺序执行<br>
Executors.newScheduledThreadPool(int corePoolSize)<br>调度线程池<br>
创建一个调度线程池,可以执行定时或周期性任务<br>
参数
corePoolSize: 线程池核心线程数最大值<br>maximumPoolSize: 线程池最大线程数大小<br>keepAliveTime: 线程池中非核心线程空闲的存活时间大小<br>unit: 线程空闲存活时间单位<br>workQueue: 存放任务的阻塞队列<br> ArrayBlockingQueue<br> LinkedBlockingQueue<br> DelayQueue<br> PriorityBlockingQueue<br>SynchronousQueue<br>threadFactory: 用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。<br>handler: 线城池的饱和策略事件,主要有四种类型。另外可以自定义 <br> AbortPolicy(抛出一个异常,默认的)<br> DiscardPolicy(直接丢弃任务)<br> DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)<br> CallerRunsPolicy(交给线程池调用所在的线程进行处理)<br>
异常处理
代码里面try catch处理
执行futureTask get获取异常内容
重写ThreadPoolExecutor的afterExecute方法,处理传递的异常引用
为工作者线程设置UncaughtExceptionHandler,在uncaughtException方法中处理异常
线程池状态
RUNNING: 线程池的初始化状态,可以添加待执行的任务。<br>SHUTDOWN:线程池处于待关闭状态,不接收新任务仅处理已经接收的任务。<br>STOP:线程池立即关闭,不接收新的任务,放弃缓存队列中的任务并且中断正在处理的任务。<br>TIDYING:线程池自主整理状态,调用 terminated() 方法进行线程池整理。<br>TERMINATED:线程池终止状态。
面试题
Runnable接口和Callable接口的区别<br>Runnable和Callable都是用于创建线程任务的接口,它们之间的主要区别在于以下几点:<br><b>返回值<br></b>Runnable接口的run()方法没有返回值,因此线程任务无法返回执行结果。<br>Callable接口的call()方法可以返回执行结果,并且可以抛出受检查的异常。<br><b>异常处理<br></b>Runnable接口的run()方法不能抛出受检查的异常,只能抛出非受检查的RuntimeException。<br>Callable接口的call()方法可以抛出任何类型的异常,包括受检查的异常。<br><b>兼容性<br></b>Callable接口是在Java 5中引入的新接口,而Runnable接口是在Java 1.0中就存在的。<br>Callable接口提供了更多的灵活性和功能,但Runnable接口仍然是使用较多的接口之一,因为它的简单性和兼容性。<br><b>并发集合<br></b>Callable接口通常与ExecutorService和Future配合使用,以支持异步任务执行和获取结果。<br>Runnable接口通常与Thread类或者Executor框架一起使用,用于执行简单的线程任务。<br>总的来说,如果需要线程执行任务并返回结果,以及处理受检查的异常,那么可以使用Callable接口;如果只是需要执行简单的线程任务,而不需要返回结果或处理异常,那么可以使用Runnable接口。<br>
submit()/execute():执行线程池<br>shutdown()/shutdownNow():终止线程池<br>isShutdown():判断线程是否终止<br>getActiveCount():正在运行的线程数<br>getCorePoolSize():获取核心线程数<br>getMaximumPoolSize():获取最大线程数<br>getQueue():获取线程池中的任务队列<br>allowCoreThreadTimeOut(boolean):设置空闲时是否回收核心线程
线程池中核心线程数量大小怎么设置?<br>CPU密集型任务<br>尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。<br>IO密集型任务<br>可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。<br>混合型任务<br>可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。<br>以上只是理论值,实际项目中建议在本地或者测试环境进行多次调优,找到相对理想的值大小。
什么是阻塞队列?说说常用的阻塞队列有哪些?<br>阻塞队列 BlockingQueue 继承 Queue,是我们熟悉的基本数据结构队列的一种特殊类型。<br>当从阻塞队列中获取数据时,如果队列为空,则等待直到队列有元素存入。当向阻塞队列中存入元素时,如果队列已满,则等待直到队列中有元素被移除。提供 offer()、put()、take()、poll() 等常用方法。
JDK 提供的阻塞队列的实现有以下几种:<br>1)ArrayBlockingQueue:由数组实现的有界阻塞队列,该队列按照 FIFO 对元素进行排序。维护两个整形变量,标识队列头尾在数组中的位置,在生产者放入和消费者获取数据共用一个锁对象,意味着两者无法真正的并行运行,性能较低。<br>2)LinkedBlockingQueue:由链表组成的有界阻塞队列,如果不指定大小,默认使用 Integer.MAX_VALUE 作为队列大小,该队列按照 FIFO 对元素进行排序,对生产者和消费者分别维护了独立的锁来控制数据同步,意味着该队列有着更高的并发性能。<br>3)SynchronousQueue:不存储元素的阻塞队列,无容量,可以设置公平或非公平模式,插入操作必须等待获取操作移除元素,反之亦然。<br>4)PriorityBlockingQueue:支持优先级排序的无界阻塞队列,默认情况下根据自然序排序,也可以指定 Comparator。<br>5)DelayQueue:支持延时获取元素的无界阻塞队列,创建元素时可以指定多久之后才能从队列中获取元素,常用于缓存系统或定时任务调度系统。<br>6)LinkedTransferQueue:一个由链表结构组成的无界阻塞队列,与LinkedBlockingQueue相比多了transfer和tryTranfer方法,该方法在有消费者等待接收元素时会立即将元素传递给消费者。<br>7)LinkedBlockingDeque:一个由链表结构组成的双端阻塞队列,可以从队列的两端插入和删除元素。
你们线程池是咋监控的?<br>因为线程池的运行相对而言是个黑盒,它的运行我们感知不到,该问题主要考察怎么感知线程池的运行情况。<br>可以这样回答:<br>我们自己对线程池 ThreadPoolExecutor 做了一些增强,做了一个线程池管理框架。主要功能有监控告警、动态调参。主要利用了 ThreadPoolExecutor 类提供<br>的一些 set、get方法以及一些钩子函数。<br>动态调参是基于配置中心实现的,核心参数配置在配置中心,可以随时调整、实时生效,利用了线程池提供的 set 方法。<br>监控,主要就是利用线程池提供的一些 get 方法来获取一些指标数据,然后采集数据上报到监控系统进行大盘展示。也提供了 Endpoint 实时查看线程池指标数据。<br>同时定义了5种告警规则。<br>线程池活跃度告警。活跃度 = activeCount / maximumPoolSize,当活跃度达到配置的阈值时,会进行事前告警。<br>队列容量告警。容量使用率 = queueSize / queueCapacity,当队列容量达到配置的阈值时,会进行事前告警。<br>拒绝策略告警。当触发拒绝策略时,会进行告警。<br>任务执行超时告警。重写 ThreadPoolExecutor 的 afterExecute() 和 beforeExecute(),根据当前时间和开始时间的差值算出任务执行时长,超过配置的阈值会触发告警。<br>任务排队超时告警。重写 ThreadPoolExecutor 的 beforeExecute(),记录提交任务时时间,根据当前时间和提交时间的差值算出任务排队时长,超过配置的阈值会触发告警<br>通过监控+告警可以让我们及时感知到我们业务线程池的执行负载情况,第一时间做出调整,防止事故的发生。
你在使用线程池的过程中遇到过哪些坑或者需要注意的地方?<br>这个问题其实也是在考察你对一些细节的掌握程度,就全甩锅给年轻刚毕业没经验的自己就行。可以适当多说些,也证明自己对线程池有着丰富的使用经验。<br>1)OOM 问题。刚开始使用线程都是通过 Executors 创建的,前面说了,这种方式创建的线程池会有发生 OOM 的风险。<br>2)任务执行异常丢失问题。可以通过下述4种方式解决<br>在任务代码中增加 try、catch 异常处理<br>如果使用的 Future 方式,则可通过 Future 对象的 get 方法接收抛出的异常<br>为工作线程设置<br>setUncaughtExceptionHandler,在 uncaughtException 方法中处理异常<br>可以重写 afterExecute(Runnable r, Throwable t) 方法,拿到异常 t<br>3)共享线程池问题。整个服务共享一个全局线程池,导致任务相互影响,耗时长的任务占满资源,短耗时任务得不到执行。同时父子线程间会导致死锁的发生,今儿导致 OOM<br>4)跟 ThreadLocal 配合使用,导致脏数据问题。我们知道 Tomcat 利用线程池来处理收到的请求,会复用线程,如果我们代码中用到了 ThreadLocal,在请求处理完后没有去 remove,那每个请求就有可能获取到之前请求遗留的脏值。<br>5)ThreadLocal 在线程池场景下会失效,可以考虑用阿里开源的 Ttl 来解决
execute和submit的区别<br>提交任务的类型:<br>execute和submit都属于线程池的方法,execute只能提交Runnable类型的任务<br>submit既能提交Runnable类型任务也能提交Callable类型任务。<br>异常:<br>execute会直接抛出任务执行时的异常,可以用try、catch来捕获,和普通线程的处理方式完全一致<br>submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。<br>返回值:<br>execute()没有返回值<br>submit有返回值,所以需要返回值的时候必须使用submit<br>注:execute 执行futureTask 可以通过futureTask.get 拿到返回值
线程操作
sleep
本地方法(native method),它会调用操作系统提供的功能来使当前线程休眠指定的毫秒数。<br><br>当调用 sleep() 方法时,当前线程会进入 TIMED_WAITING 状态,直到指定的时间到达或者被中断(InterruptedException)。<br><font color="#e74f4c">不会释放锁</font>
join
join()方法要做的事就是,当有新的线程加入时,主线程会进入等待状态,一直到调用join()方法的线程执行结束为止。<br>底层isAlive Thread.wait实现,循环
yield
yield() 方法尝试减少当前线程的运行时间片,从而给予同优先级的其他线程执行的机会。当一个线程调用 yield() 方法时,它表明自己愿意暂停执行,让出CPU给具有相同优先级的线程。然而,这种行为并不是强制性的,取决于操作系统的线程调度器是否接受这个建议
interrupt
interrupt()是给线程设置中断标志:底层interrupt0;(设置线程中断后,线程内调用 wait()、join()、slepp() 方法中的一种,都会抛出 InterruptedException 异常,且中断标志位被清除,重新设置为 false;当线程被阻塞,比如调用了上述三个方法之一,那么此时调用它的 interrupt() 方法,也会产生一个 InterruptedException 异常。因为没有占有 CPU 的线程是无法给自己设置中断状态位置的;阻塞方法park响应中断,即t3被LockSupport.park()阻塞,然后主线程调用t3.interrupt(),park()方法就响应中断,结束阻塞,并且不会抛出异常,t3线程继续向下执行,同时不会清除中断标记位,仍为true。)<br>interrupted()是检测中断并清除中断状态(<font color="#e74f4c">interrupted()作用于当前线程,要在线程内使用,是个坑,已经被废弃</font>);<br>isInterrupted()只检测中断。 interrupt()和isInterrupted()作用于此线程,即代码中调用此方法的实例所代表的线程。 <br>
stop
已被废弃<br>checkAccess stop0 <br>
死锁
死锁原因
互斥条件
即当资源被一个线程使用(占有)时,别的线程不能使用
持有并等待
即当资源请求者在请求其他的资源的同时保持对原有资源的占有
不可抢占条件
资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
循环等待
即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
解决办法
如避免嵌套锁、使用可重入锁、资源分配策略等<br>锁顺序、锁超时
a) 运行Java应用程序:java <应用程序名><br><br>b) 在不同窗口中打开一个终端<br><br>c) 输入 jps 命令,将显示正在运行的 Java 应用程序的进程 ID。<br><br>d) 输入 jstack 命令并指定 Java 应用程序的进程 ID: jstack <Java 进程 ID><br><br>e) 可能需要几秒钟才能运行此命令,但它会输出应用程序的详细信息和堆栈跟踪,包括死锁或潜在死锁的提示。<br>
synchronized
作用
原子性
所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
可见性
可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。 <br>synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。<br>
有序性
有序性值程序执行的顺序按照代码先后执行。 synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
实现方法
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。<br>当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。<br>在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。<br>在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。<br>
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。<br>另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
用法
修饰实例方法
锁定方法作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁<br>synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。<br>
修饰静态方法
也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。<br>synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁<br>
修饰代码块
指定加锁对象,对给定对象/类加锁。
优化
锁优化-锁升级
偏向锁
3.1.1 偏向锁的获取过程<br>(1)访问Mark Word中偏向锁的标识是否设置成“1”,锁标志位是否为“01”——确认为可偏向状态。<br>(2)如果为可偏向状态,判断线程ID是否指向当前线程,如果是进入步骤(5),否则进入步骤(3)。<br>(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)<br>(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点10(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。<br>(5)执行同步代码。<br>3.1.2 偏向锁的释放<br>偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量锁
1)在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。<br>(2)拷贝对象头中的Mark Word复制到锁记录中。<br>(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。<br>(4)如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。<br>(5)如果这个更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。<br><br>3.2.2 轻量级锁的解锁过程<br><br>(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。<br>(2)如果替换成功,整个同步过程完成。<br>(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
重量级锁
那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。<br><br>与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。<br><br>也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):<br>ObjectMonitor中有两个队列,<font color="#e74f4c">_WaitSet </font>和 <font color="#e74f4c">_EntryList</font>,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:<br><br>首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;<br>若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;<br>若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);<br>同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。<br>
编译优化
锁消除
当JVM在JIT(Just-In-Time)编译时,在Synchronized修饰的代码中的锁不可能被其他线程访问到即不存在操作临界资源竞争情况,那么JVM在编译时会将这个锁清除。这样可以避免无谓的锁操作,提升执行效率。即便写了Synchronized也不会触发。
锁膨胀
当JVM检测到一段代码中有多次加锁和解锁的操作时,若这些锁针对同一个对象,并且加锁和解锁操作频繁但加锁范围小,JVM会将锁的范围扩展到更大区域,从而减少锁的获取和释放次数,降低锁的开销。
通信
wait
线程会放到等待池_WaitSet当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll()后等待池的线程才会(_EntryList)开始去竞争锁
(1)必须在synchronized 修饰的同步代码块中调用<br>(2)会释放cpu资源和释放同步锁(类锁和对象锁)<br>(3)调用wait()后必须调用notify()或notifyAll()后线程才会从等待池进入到锁池,当我们的线程竞争得到同步锁后就会重新进入绪状态等待cpu资源分配<br>(4)是Object类的方法
wait(long)
线程会放到等待池_WaitSet当中, 如果经过long(毫秒)时间后没有收到notify或者notifyAll的通知,自动从等待队列转入锁池队列 _EntryList
notify
随机唤醒一个处在等待状态的线程。
方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。<br>如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)<br><font color="#e74f4c">在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁 </font>
notifyAll
唤醒所有处在等待状态的线程。
Synchronized 和 ReenTrantLock 的对比
① 两者都是可重入锁<br><br>两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。<br><br>② synchronized依赖于JVM而ReenTrantLock依赖于API<br><br>synchronized是依赖于JVM实现的,前面我们也讲到了 虚拟机团队在JDK1.6为synchronized关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock是JDK层面实现的(也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。<br><br>③ ReenTrantLock比synchronized增加了一些高级功能 :①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
JUC<br>java.util.concurrent.atomic<br>
CAS
概念
传统的并发控制手段,如使用synchronized关键字或者ReentrantLock等互斥锁机制,虽然能够有效防止资源的竞争冲突,但也可能带来额外的性能开销,如上下文切换、锁竞争导致的线程阻塞等。而此时就出现了一种乐观锁的策略,以其非阻塞、轻量级的特点,在某些场合下能更好地提升并发性能,其中最为关键的技术便是Compare And Swap(简称CAS)。<br>CAS是一种无锁算法,它在硬件级别提供了原子性的条件更新操作,允许线程在不加锁的情况下实现对共享变量的修改。<br> CAS是原子指令,一种基于锁的操作,而且是乐观锁,又称无锁机制。CAS操作包含三个基本操作数:内存位置、期望值和新值。<br>
实现原理
在执行CAS操作时,计算机会检查内存位置当前是否存放着期望值,如果是,则将内存位置的值更新为新值;若不是,则不做任何修改,保持原有值不变,并返回当前内存位置的实际值。<br>CAS操作通过一条CPU的原子指令,保证了比较和更新的原子性。在执行CAS操作时,CPU会判断当前系统是否为多核系统,如果是,则会给总线加锁,确保只有一个线程能够执行CAS操作。这种独占式的原子性实现方式,比起使用synchronized等重量级锁,具有更短的排他时间,因此在多线程情况下性能更佳。<br>
实现
在Java中,CAS机制被封装在jdk.internal.misc.Unsafe类中,尽管这个类并不建议在普通应用程序中直接使用,但它是构建更高层次并发工具的基础,例如java.util.concurrent.atomic包下的原子类如AtomicInteger、AtomicLong等。这些原子类通过JNI调用底层硬件提供的CAS指令,从而在Java层面上实现了无锁并发操作。<br>Java的标准库中,特别是jdk.internal.misc.Unsafe类提供了一系列compareAndSwapXXX方法,这些方法底层确实是通过C++编写的内联汇编来调用对应CPU架构的cmpxchg指令,从而实现原子性的比较和交换操作。 <br>Unsafe中的compareAndSetInt使用了@HotSpotIntrinsicCandidate注解修饰,@HotSpotIntrinsicCandidate注解是Java HotSpot虚拟机(JVM)的一个特性注解,它表明标注的方法有可能会被HotSpot JVM识别为“内联候选”,当JVM发现有方法被标记为内联候选时,会尝试利用底层硬件提供的原子指令(比如cmpxchg指令)直接替换掉原本的Java方法调用,从而在运行时获得更好的性能。 <br>CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。<br>
分类
基本原子类
AtomicInteger:整型原子类。<br>AtomicLong:长整型原子类。 <br>AtomicBoolean :布尔型原子类。
数组原子类
AtomicIntegerArray:整型数组原子类。<br>AtomicLongArray:长整型数组原子类。<br>AtomicReferenceArray :引用类型数组原子类。
引用原子类
AtomicReference:引用类型原子类。<br>AtomicMarkableReference :带有更新标记位的原子引用类型。<br>AtomicStampedReference :带有更新版本号的原子引用类型。<br><font color="#a23c73">AtomicStampedReference通过引入“版本”的概念,来解决ABA的问题。</font><br>
字段更新原子类
AtomicIntegerFieldUpdater:原子更新整型字段的更新器。 <br>AtomicLongFieldUpdater:原子更新长整型字段的更新器。<br> AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
CAS应用场景<br>通常用于实现乐观锁和无锁算法<br>
线程安全计数器:由于CAS操作是原子性的,因此CAS可以用来实现一个线程安全的计数器;<br>队列: 在并发编程中,队列经常用于多线程之间的数据交换。使用CAS可以实现无锁的非阻塞队列(Lock-Free Queue);<br>数据库并发控制: 乐观锁就是通过CAS实现的,它可以在数据库并发控制中保证多个事务同时访问同一数据时的一致性;<br>自旋锁: 自旋锁是一种非阻塞锁,当线程尝试获取锁时,如果锁已经被其他线程占用,则线程不会进入休眠,而是一直在自旋等待锁的释放。自旋锁的实现可以使用CAS操作;<br>线程池: 在多线程编程中,线程池可以提高线程的使用效率。使用CAS操作可以避免对线程池的加锁,从而提高线程池的并发性能。<br>
CAS的缺点
ABA问题
AtomicStampedReference、AtomicMarkableReference 来解决 ABA 问题
只能保证一个共享变量的原子操作
一个比较简单的规避方法为:把多个共享变量合并成一个共享变量来操作。 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个 AtomicReference 实例后再进行 CAS 操作。比如有两个共享变量 i=1、j=2,可以将二者合并成一个对象,然后用 CAS 来操作该合并对象的 AtomicReference 引用。
循环时间长开销大
高并发下N多线程同时去操作一个变量,会造成大量线程CAS失败,然后处于自旋状态,导致严重浪费CPU资源,降低了并发性。<br>解决办法:<br>分散操作热点,使用 LongAdder 替代基础原子类 AtomicLong。<br>使用队列削峰,将发生 CAS 争用的线程加入一个队列中排队,降低 CAS 争用的激烈程度。JUC 中非常重要的基础类 AQS(抽象队列同步器)就是这么做的。<br>
优点
CAS是一种无锁算法,它在硬件级别提供了原子性的条件更新操作,允许线程在不加锁的情况下实现对共享变量的修改。<br> CAS是原子指令,一种基于锁的操作,而且是乐观锁,又称无锁机制
lock
实现原理
同步原语
java中Lock的实现主要依赖于底层的同步原语,如CAS(Compare and Swap)操作、volatile变量、原子变量等。CAS操作是一种无锁的同步操作,通过比较并交换的方式来保证数据的一致性。Lock实现中通常会使用CAS操作来进行线程的加锁和释放锁操作。
线程调度
Lock的实现还依赖于Java线程调度机制,包括线程的状态转换、线程的阻塞和唤醒等。在使用Lock时,会通过线程调度来实现线程的等待和唤醒机制,以保证线程的正确执行顺序。
原理概括
Lock的实现原理可以简单概括为:通过同步原语来实现线程的加锁和释放锁操作,并通过线程调度来保证线程的正确执行顺序。在具体的Lock实现中,可能会采用不同的同步原语和线程调度机制,以满足不同的需求和性能要求。例如,常用的Lock实现类ReentrantLock就是基于AQS(AbstractQueuedSynchronizer)同步器和Condition条件队列来实现的。<br>LockSupport.park LockSupport.unpark <br>
主要问题
如何保证互斥性--共享资源state 0-无锁 1-有锁
被拒绝访问的线程如何挂起--lockSupport.park
被拒绝的线程如何保存恢复执行--内部维护链表保存挂起线程,lockSupport.unpark 唤醒链表挂起线程
同一个线程重复访问如何处理--可重入性保存抢锁线程ID
Condition
介绍
它是Lock接口的一部分,用于实现更细粒度的线程同步。Condition提供了类似于Object监视器方法的功能,但使用更加灵活。
对比
实现
子主题
步骤
等待队列是一个FIFO的队列,在队列中的每个节点都包含了一个线程引用,该线程就是在Condition对象上等待的线程,<br>如果一个线程调用了Condition.await方法,那么该线程将会释放锁,构造成节点并加入等待队列进入等待状态。<br><br>调用Condition的await方法,会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。从await方法返回时,当前线程一定获取了Condition相关联的锁。<br>如果从队列(同步队列和等待队列)的角度来看await方法,调用await方法时,相当于<font color="#e74f4c">同步队列的首节点</font>移动到了<font color="#e74f4c">等待队列中</font>。<br><br>通过调用Condition.singal方法,将会唤醒在等待队列中等待时间最长的节点(<font color="#e74f4c">首节点</font>),在唤醒节点之前,会将节点移到<font color="#e74f4c">同步队列中</font>。<br><br>Condition的singalAll方法,相当于对等待队列的每个节点均执行一次singal方法,效果就是将等待队列中所有节点全部移动到同步队列,并唤醒每个节点的线程。<br>
使用方式
Condition调用await方法后,当前线程会释放锁并在此等待,而其他线程调用Condition对象的singal方法,通知当前线程,当前线程才从await方法返回,并且在返回前已经获取了锁。<br><br>
ReentrantLock
公平非公平
非公平<br>如果发现c==0,则通过CAS设置该状态值为acquires,acquires的初始调用值为1,每次线程重入该锁都会+1,每次unlock都会-1,但为0时释放锁。如果CAS设置成功,则可以预计其他任何线程调用CAS都不会再成功,也就认为当前线程得到了该锁,也作为Running线程,很显然这个Running线程并未进入等待队列。<br>如果c !=0 但发现自己已经拥有锁,只是简单地++acquires,并修改status值,但因为没有竞争,所以通过setStatus修改,而非CAS,也就是说这段代码实现了偏向锁的功能,并且实现的非常漂亮。<br>跟NonfairSync大体一致, 区别 无抢锁过程 tryAcquire之前判断队列是否为空<br>
可重入
ReentrantReadWriteLock
(1)公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。<br>(2)重进入:读锁和写锁都支持线程重进入。<br>(3)锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁。
1 一个变量保存 2 个状态 和 线程池里类似<br>2 读锁的可重入使用 ThreadLocal 进行存储<br>3 写锁可以重入<br>4 写锁降级(没释放锁时候获取读锁,保证数据的一致性)
写饥饿
ReentrantReadWriteLock实现了读写分离,想要获取读锁就必须确保当前没有其他任何读写锁了,但是一旦读操作比较多的时候,想要获取写锁就变得比较困难了,因为当前有可能会一直存在读锁。而无法获得写锁。
公平锁
StampedLock
StampedLock是由Java8时引入的一个性能更好的读写锁
缺点
虽然StampedLock性能更好,但是!不可重入且不支持条件变量 Condition,且并没有直接实现Lock或者ReadWriteLock接口,而是与AQS类似的采用CLH(Craig, Landin, and Hagersten locks)作为底层实现。
写锁: 独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。<br>读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。<br>乐观读 :允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。<br><br>StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。此外,在官网给的示例中我们也看到了,StampedLock 还支持这3种锁的转换:<br>long tryConvertToWriteLock(long stamp){}<br>long tryConvertToReadLock(long stamp){}<br>long tryConvertToOptimisticRead(long stamp){}<br>
注意事项
StampedLock 是不可重入锁,使用过程中一定要注意;<br>悲观读、写锁都不支持条件变量 Conditon ,当需要这个特性的时候需要注意;<br>如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。
线程关键字
volatile
1)保证可见性<br>2)不保证原子性<br>3)禁止指令重排
JMM
(Java内存模型,Java Memory Model)本身是一种抽象的概念,并不真实存在。它描述的是一组规则或规范,通过这组规范,定了程序中各个变量的访问方法。JMM关于同步的规定:<br>1)线程解锁前,必须把共享变量的值刷新回主内存;<br>2)线程加锁前,必须读取主内存的最新值到自己的工作内存;<br>3)加锁解锁是同一把锁;
可见性
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。<br>内存屏障又称内存栅栏,是一个 CPU 指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止 + 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序<br>指令是LOCK
如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过探在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自已缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。这一步确保了其他线程获得的声明了volatile变里都是从主内存中获取最新的。
有序性
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会<br>影响到单线程程序的执行,但是会影响到多线程并发执行的正确性。<br>volatile 关键字可以禁止指令重新排序,可以保证一定的有序性。 <br><font color="#e74f4c">单例 防止指令重排</font>
volatile 修饰的变量的有序性有两层含义:<br>所有在 volatile 修饰的变量写操作之前的写操作,将会对随后该 volatile 修饰的变量读操作之后的语句可见。 happen-before<br>禁止 JVM 重排序:volatile 修饰的变量的读写指令不能和其前后的任何指令重排序,其前后的指令可能会被重排序。<br>
也是内存屏障<br>Lock前缀指令实际上相当丁一个内存屏障(也成内存档),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。<br>
happen-before 关系是用来判断是否存在数据竞争、线程是否安全的主要依据,也是指令重排序的依据,保证了多线程下的可见性。<br>volatile 修饰的变量在读写时会建立 happen-before 关系。
无原子性
只保证单次读/写操作的原子性,对于多步操作,volatile 不能保证原子性,
这是因为 count++ 是一个复合操作,包括三个部分:<br>读取 count 的值;<br>对 count 加 1;<br>将 count 的值写回内存;<br>volatile 对于这三步操作是无法保证原子性的,所以会出现上述运行结果。<br>
场景
状态标志
一次性安全发布
dcl 缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。这就是造成著名的双重检查锁定【double-checked-locking】问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。
独立观察
安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
32位 long 或者 double 赋值不是原子性
因为对于 64 位的这两个类型是分为两个 32 位操作的,因为是两次单独的写入,所以就可能导致原子性破坏。
使用 volatile 修饰 long 和 double 类型, 就可以保证值的读取和写入始终是原子的了。
CountDownLatch
描述
一个线程(或者多个), 等待另外N个线程完成某个事情之后才能执行
实现原理
CountDownLatch 的实现原理比较简单,它主要依赖于 AQS(AbstractQueuedSynchronizer)框架来实现线程的同步。<br> CountDownLatch 内部维护了一个计数器,该计数器初始值为 N,代表需要等待的线程数目,当一个线程完成了需要等待的任务后,就会调用 countDown() 方法将计数器减 1,当计数器的值为 0 时,等待的线程就会开始执行。<br> 构造函数初始化state=n<br>countDown方法实现state=state-1,并且如果state=0唤醒await阻塞在AQS队列中线程<br>await 挂起当前线程,并且加入到AQS队列<br>自循环阻塞latch线程 当state=0时候退出循环<br>
Semaphore
描述
是一个用于控制同时访问特定资源的线程数量的同步工具。它通过维护一个许可集来管理对资源的访问。线程在访问资源之前必须从信号量中获取许可,访问完成后释放许可。如果没有可用的许可,线程将被阻塞,直到有可用的许可为止。
主要方法
Semaphore semaphore = new Semaphore(3); // 创建一个具有3个许可的信号量<br>acquire():获取一个许可,如果没有可用许可,则等待。<br>release():释放一个许可,增加可用许可数。<br>tryAcquire():尝试获取一个许可,如果成功则返回 true,否则返回 false。<br>availablePermits():返回当前可用的许可数。<br>
场景
限制数据库连接数<br>控制并发执行的任务数量<br>控制资源池的访问<br>
总结
优点<br>简单易用:通过许可数量,轻松控制并发线程数。<br>高效:避免了不必要的线程创建和销毁,提高了系统吞吐量。<br>灵活:支持公平和非公平策略,可根据需求选择。<br>缺点<br>不保证公平性:默认策略下,先到的线程不一定先获得许可。<br>可能导致死锁:使用不当(如在持有Semaphore时执行其他阻塞操作)时,可能会引发死锁。
CyclicBarrier
描述
允许一组线程互相等待,直到所有线程都到达某个屏障(barrier)点,然后这些线程可以继续执行后续的任务,这个屏障是可以循环使用的,也就是说,当所有线程都达到屏障点后,屏障会自动重置,等待下一轮的线程到来
场景
线程同步:当多个线程需要同时进行某些操作,而这些操作需要在所有线程都准备好之后才能开始时,CyclicBarrier可以用来同步这些线程,它可以让一组线程在某个点上等待,直到所有线程都达到这个点,然后这些线程才可以继续执行。<br>资源分解与任务划分:在处理大量数据或执行复杂任务时,通常会将任务分解成多个子任务,由不同的线程并行处理,CyclicBarrier可以确保在所有子任务完成之前,不会有线程提前进入下一个处理阶段,从而保证了数据的一致性和任务的顺序性。<br>循环使用:与CountDownLatch不同,CyclicBarrier是可以重复使用的,一旦所有线程都达到了屏障点,屏障会自动重置,这样就可以用于多轮的任务同步。<br>异常处理:CyclicBarrier还提供了一个特性,即当线程在屏障点等待时,如果某个线程因为异常而中断,那么它可以传播这个异常给其他正在等待的线程,这样可以让所有线程都对异常情况作出响应。<br>线程间的协作:在某些场景中,线程之间需要紧密协作,比如生产者-消费者模式中的多个消费者线程需要等待所有生产者线程完成生产后才能开始消费,CyclicBarrier可以提供一个集中的同步点,简化线程间的协作逻辑。
主要方法
CyclicBarrier(int parties),构造方法,创建一个新的CyclicBarrier实例,并设置需要等待的线程数(即参与方数量),parties表示需要等待的线程数,当这么多线程调用await()方法后,屏障才会打开,允许线程继续执行。<br>CyclicBarrier(int parties, Runnable barrierAction),构造方法,除了设置需要等待的线程数外,还指定了一个当所有线程都达到屏障点时执行的任务(即屏障操作),barrierAction是一个Runnable对象,它的run()方法会在所有线程都到达屏障点后被一个线程调用,barrierAction只会在当前屏障点运行一次,如果屏障被重置,下次所有线程到达时不会再次执行该操作。<br>int await() throws InterruptedException, BrokenBarrierException,此方法用于让当前线程在屏障点等待,直到所有线程都达到这个屏障点,如果当前线程不是最后一个到达屏障点的线程,那么它会被阻塞,直到所有线程都到达,如果当前线程是最后一个到达的,并且构造方法中指定了barrierAction,那么该操作会由当前线程或另一个线程执行(具体取决于实现),如果在等待过程中线程被中断,或者屏障被其他线程破坏(通过调用reset()方法),那么此方法会抛出异常,返回值是到达屏障点的当前线程的到达顺序,但是这个特性在实际应用中很少使用。<br>int await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException,这个方法与上一个await()方法类似,但是允许指定一个最大等待时间,如果在指定的时间内所有线程都到达了屏障点,那么行为与await()相同,如果超过了指定的时间还没有所有线程到达,那么这个方法会抛出TimeoutException。<br>int getParties(),返回在CyclicBarrier中需要等待的线程数。<br>int getNumberWaiting(),返回当前在屏障点等待的线程数。<br>boolean isBroken(),如果屏障被破坏(可能是因为某个线程在等待时被中断,或者调用了breakBarrier()方法),那么这个方法返回true。<br>void reset(),将屏障重置为初始状态。这会导致所有当前在屏障点等待的线程抛出BrokenBarrierException,并且屏障可以被重新使用。
与CountDownLatch区别
1.CyclicBarrier基于Lock+condition实现的,CountDownLatch继承AQS实现的
2.CyclicBarrier可以重复使用;而CountDownLatch是一次性的,不可重复使用
3.CyclicBarrier中操作计数和阻塞的是同一个线程,调用方法只有一个await方法;<br>而CountDownLatch中操作计数和阻塞等待是N个线程,控制计数调用方法countDown<br>
总结
优点:<br>它可以重复使用,非常适合多轮任务同步。<br>提供了线程间的协作机制,确保任务分阶段完成。<br>可以指定屏障点操作,当所有线程到达时自动执行。<br>缺点:<br>如果线程在等待时被中断或取消,可能会导致BrokenBarrierException。<br>不适合用于同步访问共享资源,更多是用于任务划分和同步点控制。
锁
IO
内存模型
自由主题
自由主题
0 条评论
下一页