JAVA面经
- JAVA题目
- String、StringBuffer、StringBuilder的区别
- 怎样声明一个类不会被继承,什么时候使用
- 自定义异常在生产中如何应用
- ABA问题
- Class初始化问题是什么
- ConCurrentHashMap底层原理
- CG如何判断对象可以被回收
- Happens-Before规则是什么
- Java类加载器有哪些
- JVM8为什么增加元空间
- JVM内存模型如何分配
- JVM性能调优
- JVM有哪些垃圾回收器,实际中如何选择
- ThreadLocal的原理和适用场景
- volatile的可见性和禁止指令是如何实现的
- 程序开多少线程合适
- 创建线程有哪些方式
- 介绍线程的生命周期和状态
- 描述一下线程安全活跃态问题已经竞态条件
- 内存溢出的原因有哪些,如何排查线上问题
- 如何解决线上gc频繁的问题
- 如何预防死锁
- 什么是守护线程
- 什么是字节码以及它的组成
- 双亲委派机制是什么
- 为什么要使用线程池
- 线程池线程复用的原理是什么
- 线程的sleep.wait、 join、yield如何使用
- 有哪些垃圾回收算法
- JAVA面向对象有哪些特征
- ArrayList和LinkedList的区别
- 高并发中的集合有哪些问题
- JDK1.8的新特性有哪些
- JAVA中抽象了和接口的区别
- JVM题目
- 多线程与并发面试题
- MySQL
- MySQL索引调优
- Spring部分
JAVA题目
- String:
- String 是不可变的,一旦创建就不能被修改。
- 每次对 String 类型的操作都会创建一个新的 String 对象,因此在频繁修改字符串的情况下会产生大量的临时对象,影响性能。
- 适用于不需要频繁修改的情况,例如存储常量、字符串拼接较少的场景。
- StringBuffer:
- StringBuffer 是可变的,线程安全的,所有的方法都是同步的。
- 适用于多线程环境下的字符串操作,例如在 Servlet 中处理请求。
- StringBuilder:
- StringBuilder 是可变的,线程不安全的,所有的方法都是非同步的。
- 适用于单线程环境下的字符串操作,例如在方法内部拼接字符串。
类不必有子类,所有方法都不需要重写的情况下,使用final修饰。eg:工具类Math
Java本身包含丰富的异常类,需要自定义异常一般有两种应用,第一种是符合java语法打不符合业务逻辑;第二种是在分层的软件结构中,在表现层对其它层的异常进行统一的捕获处理。
-
定义
ABA问题是一种在并发编程中常见的问题,尤其是在使用CAS操作时。它发生在一个线程读取了某个变量的值A,准备基于这个值执行操作。与此同时,另一个线程将该变量的值改为B然后又改回A。原线程在检查变量时,仍然发现其值为A,误认为变量未发生变化,从而基于错误的假设继续执行其操作。以银行转账举例。 -
影响
ABA问题可能导致数据不一致和逻辑错误,尤其在需要高度数据一致性的系统中(如金融系统),可能导致严重的安全和正确性问题。 -
解决策略
解决ABA问题的一种方法是引入版本号。每次变量修改时,除了更新值外,还需要更新一个版本号。这样即使变量值被还原,版本号的变化也能表明变量状态的变化,从而避免错误的操作。CAS提供了一种无锁并发控制的实现,但是会导致以下问题: -
CAS无法感知变量在比较和交换期间的中间变化,这可能导致错误的业务逻辑判断。
-
循环时间长:CAS常常需要在操作失败时重试,如果竞争激烈,这种循环可能会非常耗时。
-
只能保证单个变量的原子性:如果操作涉及跨多个变量的原子性,单个CAS操作无法处理。
-
Class初始化问题是什么
- 类初始化的触发条件,类的初始化通常被以下几种情况触发:
- 实例化:首次创建类的实例时。
- 访问静态字段:首次访问类的静态字段(除了常量字段)时。
- 调用静态方法:首次调用类的静态方法时。
- 反射:通过反射方式操作类时,如Class.forName(“com.example.MyClass”)。
- 初始化子类:初始化一个类的子类,首先会触发父类的初始化。
- Java虚拟机启动时:定义为启动类的主类(包含main方法的那个类)将被初始化。
- 类的初始化过程主要包括以下步骤:
- 加载:类加载器将类的.class文件加载到JVM中,分析类中的字节码,创建一个Class对象。
- 链接
- 验证:检查加载的类是否有正确的内部结构,并和Java语言规范兼容。
- 准备:为类变量(静态变量)分配内存,并初始化为默认值,如int为0,object引用为null等。
- 解析:将类、方法、属性等的符号引用转换为直接引用。
- 初始化:
- 静态变量赋值:根据类中的静态变量声明和静态初始化块(static块)的编写顺序,执行静态变量赋值和静态初始化块。
- 执行静态代码块:如果类中包含静态代码块,则按照它们在类中出现的顺序执行。
- 初始化过程
-
获取初始化锁LC
- 每个类都有一个初始化锁LC。
- 线程获取LC,这个操作会导致当前线程一直等待,直到获取到LC锁。
-
等待其他线程完成初始化
- 如果C正在被其他线程初始化,当前线程会释放LC锁进入阻塞状态,并等待C初始化完成。此时,当前线程需要重试这一过程。执行初始化过程时,线程的中断状态不受影响。
-
递归初始化
- 如果C正在被本线程初始化(即递归初始化),释放LC并且正常返回。
-
已完成初始化
- 如果C已经被初始化完成,释放LC并且正常返回。
-
错误状态处理
- 如果C处于错误状态,表明不可能再完成初始化,释放LC并抛出
NoClassDefFoundError
异常。
- 如果C处于错误状态,表明不可能再完成初始化,释放LC并抛出
-
标记初始化状态
- 否则,将C标记为正在被本线程初始化,释放LC;然后,初始化那些final且为基础类型的类成员变量。
-
父类和接口的初始化
- 如果C是类而不是接口,且C的父类Super Class (SC) 和各个接口SI_n(按照implements子句中的顺序来)还没有初始化,那么就在SC上面递归地进行完整的初始化过程,如果有必要,需要先验证和准备SC。
- 如果SC或SI_n初始化过程中抛出异常,则获取LC,将C标记为错误状态,并通知所有正在等待的线程,然后释放LC,然后再抛出同样的异常。
-
断言机制
- 从C的classloader处获取assertion断言机制是否被打开。
-
执行静态初始化
- 接下来,按照文本顺序执行类变量初始化和静态代码块,或接口的字段初始化,把它们当作是一个个单独的代码块。
-
完成初始化
- 如果执行正常,那就获取LC,标记C对象为已初始化,并通知所有正在等待的线程,然后释放LC,正常退出整个过程。
-
异常处理
- 否则,如果抛出了异常E那么会中断退出。若E不是Error,则以E为参数创建新的异常
ExceptionInInitializerError
作为E。如果因为OutOfMemoryError
导致无法创建ExceptionInInitializerError
,则将OutOfMemoryError
作为E。 - 获取LC,将C标记为错误状态,通知所有等待的线程,释放LC,并抛出异常E。
- 否则,如果抛出了异常E那么会中断退出。若E不是Error,则以E为参数创建新的异常
在Java中,HashMap本身不是线程安全的,这意味着如果多个线程同时修改同一个HashMap实例而没有进行适当的同步,可能会导致数据的不一致性甚至导致HashMap内部结构的破坏。ConcurrentHashMap是Java中的一个线程安全的哈希表,用于在多线程环境中高效地处理数据。它是Java java.util.concurrent 包的一部分,提供了比Hashtable和同步的HashMap更好的并发性能。
ConcurrentHashMap的核心设计理念是分段锁。在早期的Java版本中,它通过将内部数据结构分为多个段(Segment),每个段独立锁定,实现高并发。这意味着多个更新操作可以同时进行,只要它们不是落在同一个段上。每个段其实就是一个小的哈希表,带有自己的锁。当进行插入、删除或访问操作时,只需要锁定包含特定键的段,而不是整个哈希表。这减少了锁竞争,从而提高了性能。
在JDK 8中,ConcurrentHashMap的实现有了重大改进。分段锁的概念被弱化,转而使用了一种节点锁技术和红黑树来进一步优化性能。主要的变化包括:
- Node-based locking:而不是对整个段使用锁,JDK 8的ConcurrentHashMap在更细的粒度上实现锁定。每个桶的头节点使用synchronized锁进行同步。
- 红黑树:当链表长度超过一定阈值时,链表会转换成红黑树,这极大地改善了最坏情况下的搜索时间。
- 引用计数法
- 每个对象有一个引用计数器,当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1。任何时刻计数器为零的对象就是不可能再被使用的。
- 最大的问题是很难处理循环引用的情况。如果两个对象相互引用,它们的引用计数永远不会是零,即使它们已经不再被其他对象引用。
- 可达性分析
-
这是Java垃圾收集器最常用的方法。可达性分析的基本思想是通过一系列的称为“GC Roots”的对象作为起点进行搜索,如果一个对象到GC Roots没有任何引用链(即从GC Roots到这个对象不能通过引用被访问到),那么这个对象是不可达的,因此可以判断为可回收的。
-
常见的GC Roots包括:
- 在虚拟机栈(Stack Frames)中引用的对象。
- 方法区中的类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
-
- 定义
- happens-before规则简单来说,就是如果一个操作A happens-before另一个操作B,则操作A产生的内存效果对操作B可见,并且A的执行顺序在B之前。这种逻辑上的关系保证了并发环境中线程间的一致性和互斥行为。
- 主要的happens-before规则(8个)
- 程序顺序规则:在一个线程中,按照程序代码顺序,书写在前面的操作happens-before于书写在后面的操作。
- 监视器锁规则:对一个锁的解锁happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile字段的写入happens-before于任何后续对这个volatile字段的读取。这是保证volatile变量的读/写操作具有内存可见性的关键。
- 传递性:如果操作A happens-before操作B,且操作B happens-before操作C,则操作A happens-before操作C。
- 线程启动规则:在一个线程中调用另一个线程的start()方法happens-before于后者线程中的任何操作。
- 线程终止规则:一个线程中的所有操作都happens-before于其他线程检测到这个线程已经终止的操作,或者从其他线程的join()方法成功返回的操作。
- 线程中断规则:对线程interrupt()的调用happens-before于被中断线程检测到中断事件的发生。
- 对象终结规则:一个对象的构造函数的结束happens-before于这个对象finalize()方法的开始。
类加载器 | 描述 |
---|---|
启动类加载器 (Bootstrap Class Loader) | 职责:引导类加载器是最顶层的加载器,负责加载JVM基础核心类库,如rt.jar 、resources.jar 、charsets.jar 等,这些库位于<JAVA_HOME>/jre/lib 目录。实现:它是由**C++**实现,是JVM自带的类加载器,不继承自java.lang.ClassLoader ,因此在Java应用中无法获得它的引用。特点:由于其加载范围的特殊性,它不继承任何类加载器。 |
扩展类加载器 (Extension Class Loader) | 职责:扩展类加载器负责加载<JAVA_HOME>/jre/lib/ext 目录下或者由系统属性java.ext.dirs 指定路径下的类库。实现:这个类加载器在Java中实现为sun.misc.Launcher$ExtClassLoader 类。特点:它是是ClassLoader 的子类,其父加载器为引导类加载器。 |
应用程序类加载器 (Application Class Loader) | 职责:系统类加载器负责加载环境变量classpath 或系统属性java.class.path 指定路径下的类库,这是默认的类加载器,通常用来加载应用的主函数类。实现:这个类加载器在Java中实现为sun.misc.Launcher$AppClassLoader 类。特点:其父加载器是扩展类加载器 |
自定义类加载器 | 开发人员可以通过继承 java.lang.ClassLoader 类实现自己的类加载器,用于特定的加载需求。 |
- JVM8之前使用持久代,用来存储以下内容:
- 类的元数据:包括类的结构(如字段、方法、接口、超类等)、方法的字节码、运行时常量池等。
- 内部字符串池:例如,由String.intern()方法生成的字符串。
- 静态变量:属于类的静态变量,这些变量与类一同加载,并在持久代中分配空间。
- 持久代的弊端
- 固定大小:持久代的大小是在JVM启动时预设的,并且不会根据运行时的需求动态调整。这种固定的内存分配方式使得它在类加载非常频繁的应用中容易发生内存溢出错误(OutOfMemoryError)。
- 垃圾收集问题:持久代的垃圾收集通常涉及完整的GC周期,包括“标记-清理”(Mark-Sweep)阶段。因为持久代主要存储类的元数据和常量,这些通常生命周期较长,不易回收,导致GC效率低下。
- 内存泄漏:在长时间运行的应用中,尤其是那些动态生成或载入大量类的应用(如某些Web服务器和应用服务器),持久代更容易发生内存泄漏。
- 为什么使用元空间,元空间是在本地内存中实现的,与堆内存隔离。它主要用于存储来自Java类的元数据。引入元空间后,带来了以下优势:
- 动态扩展:元空间不再受JVM内存的限制,其扩展仅受限于系统的实际可用内存。这意味着元空间可以根据应用的需要动态调整大小,减少了内存溢出的风险。
- 改善性能:由于元空间可以动态扩展,开发者不需要在JVM启动时精确地指定其大小,从而减少了因配置不当导致的性能问题。
- 简化垃圾收集:将元数据从虚拟机内存中分离出来后,简化了垃圾收集过程。不再需要对永久代进行专门的垃圾回收,这可以降低Full GC的发生频率,提高垃圾收集器的效率。
虚拟机栈(Java Virtual Machine Stack)
每个线程在Java虚拟机中都有一个私有的栈,这个栈与线程同时创建。栈中存储的是栈帧(Stack Frame),每个方法执行时都会创建一个栈帧。栈帧中包括局部变量表、操作数栈、方法出口等信息。栈的大小可以固定也可以动态扩展。
1.局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变
2.Java 虚拟机栈也是线程私有,随着线程创建而创建,随着线程的结束而销毁
3.特别需要注意的是,栈调用深度过大时会抛出 StackOverflowError
;如果栈可以动态扩展,但内存不足以支持这种扩展时,会抛出 OutOfMemoryError
。
本地方法栈(Native Method Stack)
本地方法栈为JVM运行Native方法准备的空间,由于很多Native方法都是用C语言实现的,所以它通常又叫C栈。其功能和Java虚拟机栈相似,但专门用于支持Native方法的执行,本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。方法执行结束后,相应的栈帧也会出栈,并释放内存空间。本地方法栈同样可能抛出 StackOverFlowError
和 OutOfMemoryError
异常。
程序计数器(Program Counter Register)
每个线程都有一个程序计数器,这是当前线程所执行的字节码的行号指示器。如果执行的是Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,则计数器为空。
堆(Heap)
堆是JVM所有线程共享的内存区域,主要用于存储对象实例和数组。这是垃圾收集器管理的主要区域,分为新生代和老年代。堆在虚拟机启动时创建,是垃圾回收的主要场所。如果堆中没有足够的内存完成实例分配,并且堆也不能进一步扩展,将会抛出 OutOfMemoryError
。
方法区(Method Area)
java虚拟机规范中定义为堆的一个逻辑部分,方法区同样是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量及即时编译器编译后的代码。在Java 8中,传统的永久代已被元空间(Metaspace)所替代,后者使用本地内存,不再占用JVM堆内存。
直接内存(Direct Memory)
直接内存并非JVM运行时数据区的一部分,但它可以被Java程序通过NIO类库显式使用,通常用于大规模数据处理。直接内存的分配和回收成本高于堆内存,但是直接内存读取IO的性能高于堆内存(减少复制,没有垃圾回收),且其大小不受Java虚拟机控制,但受制于机器的物理内存限制。使用不当时也可能抛出 OutOfMemoryError
。
- 调优原则
- 许多Java应用在服务器上无需进行特别的GC优化,因为JVM已经内置了多种优化机制以确保应用的稳定运行。不当的调优可能适得其反,所以在进行GC优化前,应确保不是为了调优而调优。
- 在应用上线前,应首先将机器的内存管理(MM)参数(如
-Xmx
和-Xms
)设置得当,以确保环境适合应用运行。 - 确认在考虑GC优化前,应用的架构和代码层已优化至最佳状态。不应期望通过GC优化来弥补系统架构的缺陷或代码层的不足。
- GC优化是一项系统而复杂的任务,需要深入理解不同垃圾回收器的工作原理。只有在充分理解的基础上,调优工作才能有效果。
- 处理吞吐量和延迟问题时,增加可用于垃圾回收的Java堆空间通常能改善垃圾收集效果并使应用运行更流畅,这符合GC内存最大化原则。
- 在吞吐量、延迟和内存这三个属性中,通常需要在两者之间做出选择进行优化,这种方法称为“GC优化三选二”。
- 需要调优的情况
-
堆内存问题:
- 堆内存(特别是老年代)持续增长并达到设定的最大内存值。
- 应用出现
OutOfMemoryError
等内存异常。
-
直接内存问题:
- 应用出现
OutOfDirectMemoryError
等内存异常,示例错误信息:failed to allocate 16777216 byte(s) of direct memory (used: 1056964615, max: 1073741824)
。
- 应用出现
-
本地缓存使用:
- 应用中使用的本地缓存占用大量内存空间。 -
GC性能问题:
- Full GC次数频繁。
- GC停顿时间过长(超过1秒,具体阈值应根据应用场景设定)。
- Full GC次数频繁。
-
CPU和内存使用:
- 应用的CPU占用率异常高,持续不下降。
- 内存占用过高,持续不下降。
-
系统吞吐量和响应性能:
- 系统吞吐量和响应性能不高或有所下降。
-
- 调优指标
-
吞吐量:
吞吐量是评价垃圾收集器能力的重要指标之一,它表示为:用户代码时间/(用户代码执行时间+垃圾回收时间)。它不考虑垃圾收集引起的停顿时间或内存消耗,反映了垃圾收集器能支撑应用程序达到的最高性能指标。吞吐量越高,算法越好。 -
低延迟:
STIW(Stop the World)越短,响应时间越好。这是评价垃圾收集器能力的另一个重要指标,它度量标准是缩短由于垃圾收集引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用程序运行时发生抖动。暂停时间越短,算法越好。
-
- 调优策略
-
目标平衡:
在设计(或使用)GC算法时,我们必须确定目标:一个GC算法只可能针对两个目标之一(即只专注于最大吞吐量或最小暂停时间),或尝试找到二者的折衷。 -
MinorGC原则:
MinorGC尽可能多的收集垃圾对象。我们把这个称作MinorGC原则,遵守这一原则可以降低应用程序FullGC的发生频率。FullGC较耗时,是应用程序无法达到延迟要求或吞吐量的主要障碍。 -
堆大小调整:
统计Minor GC持续时间、Minor GC的次数、Full GC的最长持续时间、最差情况下Full GC频率,这些是分析和着手调整堆大小的主要指标。我们应该根据业务系统对延迟和吞吐量的需求,结合这些分析指标,进行各个区域大小的调整。
-
- JVM常用参数
-
Heap内存大小设置
-Xms<size>
: 设置JVM启动时堆的初始内存大小。例如:-Xms512m
设置JVM启动时的堆大小为512MB。-Xmx<size>
: 设置JVM可以使用的堆的最大内存大小。例如:-Xmx2048m
设置JVM最大堆大小为2048MB。
-
新生代大小调整
-Xmn<size>
: 设置新生代的大小。此值也可以通过-XX:NewSize
和-XX:MaxNewSize
更精细地控制。
-
垃圾回收器选择
-XX:+UseG1GC
: 启用G1垃圾回收器。-XX:+UseParallelGC
: 启用并行垃圾回收器。-XX:+UseConcMarkSweepGC
: 启用CMS(并发标记扫描)垃圾回收器。
-
垃圾回收日志
-Xloggc:<file-path>
: 将垃圾回收的详细日志记录到文件中。-XX:+PrintGCDetails
: 打印垃圾回收详细信息。-XX:+PrintGCDateStamps
: 在垃圾回收日志中添加时间戳。
-
性能调优
-XX:SurvivorRatio=<ratio>
: 设置Eden区与一个Survivor区的大小比例。例如,-XX:SurvivorRatio=8
表示Eden区是Survivor区的8倍大。-XX:NewRatio=<ratio>
: 设置老年代与新生代的比例。例如,-XX:NewRatio=3
表示老年代是新生代的3倍大。
-
其他高级选项
-XX:MaxPermSize=<size>
: (仅在Java 8之前的版本有效)设置永久代的最大大小。-XX:MetaspaceSize=<size>
: (Java 8及以上版本)设置Metaspace的初始大小。-XX:+UseStringDeduplication
: 开启G1垃圾回收器的字符串去重功能,减少内存占用。
-
系统属性和性能监控
-Dcom.sun.management.jmxremote
: 启用JMX远程管理功能。-XX:+UnlockExperimentalVMOptions
: 允许使用实验性的VM选项。
-
新生代收集器(全部的都是复制算法) : Serial、ParNew、Parallel Scavenge
老年代收集器:CMS(标记-清理)、Serial Old(标记-整理)、Parallel Old(标记整理)整堆收集器:G1 (一个Region中是标记-清除算法,2个Region之间是复制算法)
- Serial GC:单线程,最基本,运行时所有线程等待。适用于client模式下的虚拟机
- ParNew:多线程版本的Serial
- Parallel Scavenge:吞吐量优先收集器,年轻代使用,采用复制算法
- Serial Old:Serial的老年版本,使用标记-整理算法
- Parallel Old:Parallel Scavenge的老年版本
- CMS:主要用于老年代,目标是减少垃圾回收时的停顿时间,标记-清除算法,拉长垃圾回收阶段,某些阶段可以与用户线程并行。缺点在于对CPU资源敏感,无法处理浮动垃圾(回收过程中新生成的垃圾),会产生内存碎片。
- G1:改进CMS,Garbage First,兼顾高吞吐量和低延迟,将整个Java堆分割成多个大小相等的独立区域(Region),每个区域可以是Eden区、Survivor区或Old区。这种划分使得G1能够更灵活地进行垃圾回收,不必每次都处理整个Java堆,从而减少单次垃圾回收的延迟。
垃圾回收过程
G1的垃圾回收过程包括几个步骤:
初始标记(Initial Marking):这个阶段标记从GC Roots直接可达的对象。此阶段发生在应用线程停顿时。
1. 并发标记(Concurrent Marking):G1在此阶段遍历堆中的对象图,标记所有可达的对象。这个过程主要是并发执行的,不会导致应用线程停顿。
2. 最终标记(Final Marking):处理在并发标记阶段因程序运行而产生的变动。此阶段使用一种叫做“记忆集”的数据结构来跟踪引用变动,可能会引起短暂的停顿。
3. 筛选回收(Evacuation):在此阶段,G1将回收价值最大的区域中的存活对象复制到空闲区域,然后清理掉整个区域。这个阶段是G1回收中唯一需要应用线程停顿的部分。
优点
4. 可预测的停顿时间:通过目标停顿时间模型,G1允许用户指定所期望的最大GC停顿时间,使得
14. ## synchronized和lock的区别
特性 | synchronized | Lock |
---|---|---|
实现方式 | Java 关键字,内置语言实现 | 接口,位于 java.util.concurrent.locks 包下 |
异常处理 | 发生异常时会自动释放锁 | 发生异常时不会主动释放锁,需手动调用 unlock() 方法释放锁 |
中断响应 | 无法响应中断 | 可以使用 interrupt() 方法中断等待 |
获取锁信息 | 无法直接得知是否成功获取到锁 | 可以通过 tryLock() 方法尝试获取锁 |
读操作效率 | 无法实现读写分离,读写效率一样 | 可以通过 ReentrantReadWriteLock 实现读写分离,提高读操作效率 |
性能差异 | 竞争资源激烈时性能较差 | 高并发时性能优于 synchronized |
调度机制 | 使用对象本身的 wait(), notify(), notifyAll() 调度机制 | 使用 Condition 进行线程之间的调度,提供更灵活的等待/通知机制 |
原理:
ThreadLocal 是 Java 中的一个线程级别的变量,它为每个线程都创建了一个独立的变量副本,各个线程之间互不影响。ThreadLocal 内部通过一个 ThreadLocalMap 来实现,类似一个哈希表,ThreadLocalMap 中的 key 是 ThreadLocal 对象,value 是对应线程的变量副本。每个线程持有一个 ThreadLocalMap,用来存储线程独享的变量副本。
使用场景:
- 解决线程安全问题:将不安全的对象存储在 ThreadLocal 中,确保每个线程都有自己的对象副本,避免多线程并发访问时的数据竞争问题。
- 管理事务上下文:在一个事务中的多个地方都需要使用同一个 Connection 对象,可以将 Connection 对象存储在 ThreadLocal 中,保证在同一个线程中始终使用同一个 Connection 对象。
- 简化参数传递:在多个方法中传递同一个对象,可以将该对象存储在 ThreadLocal 中,避免在方法参数中反复传递。
- 避免复杂的对象传递:有些对象在整个应用中都可能用到,但是传递起来比较复杂,可以将这些对象存储在 ThreadLocal 中,在需要的时候直接从 ThreadLocal 中获取,避免传递的复杂性。
读汇编指令可知,volatile关键字加入后,会多出一个lock前缀指令。
这个lock指令是一个内存屏障,具体来说有如下三个作用:
- 防止指令重排序,确保内存屏障之后的指令不会重排到内存屏障之前,内存屏障之前的指令不会重排到内存屏障之后
- 强制将对缓存的修改操作立即写入主存,确保了内存的可见性
- 如果lock修饰的指令是写操作,内存屏障会使其他CPU中对应的缓存行无效,换言之,其他线程对该变量的写操作不生效。
可见性:
-
volatile的功能就是被修饰的变量在被修改后可以立即同步到主内存,被修饰的变量在每次使用之前都从主内存刷新。本质也是通过内存屏障来实现可见性。
-
写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于StoreBuffer和Invalidate Queue的非实时性带来的问题。
禁止指令重排序:
-
volatile是通过内存屏障来禁止指令重排序。
- JMM内存屏障的策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个 StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad 屏障。
- 在每个volatile读操作的后面插入一个LoadStore 屏障。
- JMM内存屏障的策略:
表示B的写不会重排序到A的写之前
CPU密集型程序:
- 单核CPU: 适合单线程处理,因为单核CPU处理CPU密集型程序不太适合使用多线程。
- 多核CPU: 最佳线程数 = CPU核数(逻辑) + 1 (经验值),可以最大化利用CPU核心数,提高效率。计算(CPU)密集型的线程恰好在某时因为发生一个页错误或其他原因而暂停,刚好有一个"额外"的线程,可以确保在这种情况下CPU周期不会中断工作。
I/O密集型程序:
类似于核心数/cpu利用率- 如果几乎全是IO耗时,那么CPU耗时就无限趋近于0,所以纯理论上可以说是2N(N=CPU核数),一般说2N + 1,其中1是备用线程。
- 一般建议使用2N + 1个线程。
继承Thread类创建线程:
- 继承Thread类,重写run()方法,然后创建线程对象并调用start()方法启动线程。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程正在运行 - 继承Thread类");
}
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
}
}
实现Runnable接口创建线程:
- 实现Runnable接口,实现run()方法,然后创建Thread对象,将实现了Runnable接口的对象作为参数传递给Thread对象,并调用start()方法启动线程。
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程正在运行 - 实现Runnable接口");
}
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
使用Callable和Future创建线程:
- 实现Callable接口,重写call()方法,然后创建FutureTask对象,将Callable对象作为参数传递给FutureTask对象,再创建Thread对象,将FutureTask对象作为参数传递给Thread对象,并调用start()方法启动线程。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class CallableExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> task = () -> {
return "线程正在运行 - 使用Callable返回结果";
};
Future<String> future = executor.submit(task);
try {
// 获取结果
String result = future.get();
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
executor.shutdown();
}
}
使用线程池(例如使用Executor框架):
- 使用java.util.concurrent.Executors类中的静态方法创建线程池,然后将实现Runnable接口或Callable接口的任务提交给线程池执行。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(4); // 创建包含4个线程的线程池
Runnable task = () -> {
System.out.println("线程正在运行 - 使用线程池");
};
// 提交任务到线程池
executor.execute(task);
executor.execute(task);
executor.execute(task);
executor.execute(task);
// 关闭线程池
executor.shutdown();
}
}
当一个线程调用 yield() 方法时,它实际上是在告诉线程调度器(Thread Scheduler)它愿意放弃其当前的时间片,允许其他线程获得执行机会。
join() 方法用于让一个线程等待另一个线程完成其任务。当在某个线程上调用 join() 方法时,调用它的线程将被阻塞,直到被 join() 的线程结束运行。
线程安全活跃态问题(Thread Safety Liveness Issues):
- 死锁(Deadlock): 死锁指的是多个线程因争夺资源而相互等待,导致彼此都无法继续执行下去的情况。这种情况下,每个线程都在等待其他线程释放它需要的资源,从而导致所有线程都无法继续执行。
- 例如,线程A持有资源1,等待资源2;线程B持有资源2,等待资源1。这样,线程A无法释放资源1,直到获得资源2,而线程B也无法释放资源2,直到获得资源1。这种相互等待的状态就是死锁。
- 饥饿(Starvation): 饥饿指的是某个线程因为无法获取到所需的资源而无法继续执行,而其他线程却一直占用着这些资源,导致该线程无法被调度执行。这种情况下,线程虽然“活跃”,但却无法得到执行的机会。
- 例如,某个线程总是被其他优先级较高的线程抢占资源,导致它无法获得执行的机会,这种情况就是饥饿。
- 活锁(Livelock): 活锁指的是多个线程在争夺资源时,由于某种原因导致它们不断地改变自己的状态,但最终无法取得进展,导致线程无法继续向前推进。与死锁不同的是,线程在活锁中是“活跃”的,但却无法完成实际的工作。
- 例如,两个线程试图通过礼让彼此来避免冲突,但每次都在检查对方是否礼让时都发现对方也在等待,于是它们都放弃了礼让而重新开始。这样就形成了一个循环,导致线程都无法取得进展,这种情况就是活锁。
竞态条件
竞态条件(Race Condition)是指在多线程编程中,由于线程执行顺序的不确定性或并发操作的交错执行,导致程序的行为与预期不符的一种情况。同大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序。
最常见的竞态条件为:
- 先检测后执行。执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题,可以对文件加锁解决。
eg:对于main线程,如果文件a不存在,则创建文件a,但是在判断文件a不存在之后,Task线程创建了文件a,这时候先前的判断结果已经失效,(main线程的执行依赖了一个错误的判断结果)此时文件a已经存在了,但是main线程还是会继续创建文件a,导致Task线程创建的文件a被覆盖、文件中的内容丢失等等问题。 - 延迟初始化(最典型即为单例)
-
第一类内存溢出错误:
java.lang.OutOfMemoryError: ....java heap space.....
- 当你看到 heap 相关的时候就肯定是堆栈溢出了。解决方法是适当调整
-Xmx
和-Xms
参数,前提是代码没有问题。可能的原因包括代码存在问题、访问量过多、访问时间过长或数据量过大等。 - 可能出现的关键字:
java.lang.OutOfMemoryError: GC overhead limit exceeded
。这种情况是系统处于高频的 GC 状态但 GC 效果不佳,可能由于产生了大量无法释放的对象。
- 当你看到 heap 相关的时候就肯定是堆栈溢出了。解决方法是适当调整
-
第二类内存溢出:
java.lang.OutOfMemoryError: PermGen space
- PermGen 空间溢出,主要由于常量池膨胀导致。解决方法包括避免频繁创建常量、避免使用 intern 方法等。
-
第三类内存溢出:
java.lang.OutOfMemoryError: Direct buffer memory
- 使用 ByteBuffer 中的
allocateDirect
方法但不释放时可能出现此错误。解决方法是及时调用clear
方法释放 ByteBuffer。
- 使用 ByteBuffer 中的
-
第四类内存溢出错误:
java.lang.StackOverflowError
- 主要由于栈空间不足导致。解决方法是增大
-Xss
参数。
- 主要由于栈空间不足导致。解决方法是增大
-
第五类内存溢出错误:
java.lang.OutOfMemoryError: unable to create new native thread
- 线程内存空间不足导致无法为新线程分配内存,可能由于系统剩余内存不足或 heap 空间设置过大。
-
第六类内存溢出:
java.lang.OutOfMemoryError: request byte for fout of swap
- 此错误一般由于地址空间不足导致。
这些内存溢出错误覆盖了 Java 中99%的溢出情况,虽然大多数情况下可以通过调整参数或优化代码来解决,但也可能需要进一步调优系统或修复硬件问题。
死锁发生的四个必要条件:
- 互斥条件:同一时间只能有一个线程获取资源。
- 不可剥夺条件:一个线程已经占有的资源,在释放之前不会被其他线程抢占。
- 请求和保持条件:线程等待过程中不会释放已占有的资源。
- 循环等待条件:多个线程互相等待对方释放资源。
死锁预防:
为了避免死锁,需要破坏这四个必要条件。
- 破坏不可剥夺条件:
- 一个进程不能获得所需的全部资源时,进入等待状态,同时释放已占用的资源,重新加入到系统资源列表中,以便其他进程使用。只有在重新获得原有资源和新申请的资源后,进程才能重新启动执行。
- 破坏请求与保持条件:
- 静态分配: 进程在开始执行时申请所需的全部资源。
- 动态分配: 进程在申请资源时,不占用系统资源。
- 破坏循环等待条件:
- 资源有序分配: 将系统中的所有资源顺序编号,按编号顺序申请资源。进程只有获得较小编号的资源才能申请较大编号的资源。
在Java中有两类线程,用户线程(User Thread)和守护线程(Daemon Thread)
守护线程(Daemon Thread)是一种在程序运行时在后台提供服务的线程,其特点是当所有的非守护线程结束后,守护线程会自动被JVM终止,而不会阻止程序的正常退出。
注意事项:
通过Thread类的setDaemon(true)方法可以将线程设置为守护线程,必须在thread.start()之前设置
守护线程不能持有程序中重要资源的锁,因为它会在程序的任意时刻被终止,而无法保证资源的安全释放
在Daemon线程中产生的新线程也是Daemon的。
字节码(Bytecode)是一种中间代码,它是Java源代码编译后的结果,不是针对特定计算机体系结构的机器代码,而是针对Java虚拟机(JVM)的指令集。字节码可以在任何实现了JVM规范的平台上运行,实现了Java语言的"一次编译,到处运行"的理念。
字节码由一系列以字节为单位的指令组成,每条指令都有特定的操作码(opcode)和操作数(operands)。Java字节码的指令集定义了一系列操作,包括加载、存储、算术运算、类型转换、方法调用等。
Java字节码的组成包括以下几个部分:
-
Magic Number(魔数):字节码文件的前4个字节是一个固定的标识符,称为魔数,它用于识别字节码文件的格式。在Java中,魔数的值为0xCAFEBABE。
-
版本号:紧跟在魔数之后的4个字节表示字节码文件的版本号,分为主版本号和次版本号,用于指示该文件是由哪个版本的Java编译器生成的。
-
常量池:紧跟在版本号之后的部分是常量池(Constant Pool),它是字节码文件中一个重要的结构,用于存储字面量、符号引用和其他常量。常量池中的内容包括类名、字段名、方法名、字符串字面量、接口方法等。
-
访问标志和类信息:常量池之后的部分包括类的访问标志和类的相关信息,如父类、接口等。
-
字段信息:接着是字段信息,包括字段的访问标志、名称、描述符等。
-
方法信息:随后是方法信息,包括方法的访问标志、名称、描述符、字节码等。
-
属性表:最后是属性表,包括类、字段、方法等各种信息的附加属性。
Java字节码的组织结构是固定的,但具体内容会根据源代码的不同而变化。编译器将源代码编译为字节码文件后,JVM会按照字节码文件中的指令序列执行程序逻辑。
双亲委派机制(Delegation Model)是Java类加载器(ClassLoader)的一种工作机制,在双亲委派机制中,类加载器之间形成了一种层次关系,一般情况下,一个类加载器在加载类时会先委托其父类加载器去加载,只有在父类加载器无法完成加载任务时,才由子类加载器自己尝试加载。
双亲委派机制的主要优势在于安全性和可靠性:
安全性:通过双亲委派机制,可以防止恶意类被恶意地加载和执行,因为父类加载器会优先尝试加载类,如果恶意类不在Java核心类库中,那么它们很难被加载执行。
可靠性:双亲委派机制可以保证类加载的一致性,即同一个类在不同的类加载器中只会被加载一次,避免了类的重复加载和冲突。
-
降低资源消耗:
线程的创建和销毁是有开销的,包括内存和CPU。通过线程池,可以重用已创建的线程,避免频繁地创建和销毁线程,从而减少资源的消耗。 -
提高响应速度:
线程池可以预先创建一定数量的线程,并将它们保存在池中待用。当任务到达时,可以立即分配一个空闲线程来处理任务,从而减少任务的等待时间,提高系统的响应速度。 -
提高系统稳定性:
合理配置线程池的大小可以防止系统因为创建过多线程而导致资源耗尽或系统崩溃的情况。通过控制线程池的大小,可以有效地限制系统的并发量,保持系统的稳定性。 -
统一管理和监控:
线程池提供了一种统一的管理和监控机制,可以方便地监控线程池的运行状态、线程的执行情况以及任务的执行情况。这样可以更好地管理系统的资源和调度任务。 -
避免线程创建和销毁带来的性能损耗: 创建和销毁线程是有代价的,线程池可以避免频繁地创建和销毁线程,从而减少性能损耗,提高系统的性能和效率。
sleep(long millis):
使当前线程休眠指定的时间(以毫秒为单位)。
主要用于模拟等待或延迟执行的情况。
try {
Thread.sleep(1000); // 休眠1秒
} catch (InterruptedException e) {
// 处理中断异常
}
wait() 和 notify()/notifyAll():
wait() 用于使当前线程等待,并释放对象的锁,直到其他线程调用相同对象上的 notify() 或 notifyAll() 方法来唤醒该线程。
notify() 唤醒在相同对象上等待的一个线程,notifyAll() 唤醒所有在相同对象上等待的线程。
// 创建一个对象锁
Object lock = new Object();
// 在同步块中使用 wait() 和 notify() 方法
synchronized (lock) {
try {
// 线程等待,释放对象锁
lock.wait();
} catch (InterruptedException e) {
// 处理中断异常
}
}
// 在另一个线程中使用 notify() 方法唤醒等待的线程
synchronized (lock) {
lock.notify(); // 唤醒等待的线程
}
join():
使当前线程等待指定线程完成执行。
通常用于等待其他线程的结果或确保在当前线程继续执行之前,某个线程已经完成了执行。
Thread t3 = new Thread(() -> {
System.out.println("Thread t3 is running...");
try {
t2.join(); // t3 线程等待t2线程执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread t3 is finished.");
});
yield():
暂停当前正在执行的线程,并允许其他线程执行。
通常用于在多线程环境下,帮助调度器在执行任务时选择其他线程。
// 在循环中使用 yield() 方法让出CPU资源
Thread t4 = new Thread(() -> {
System.out.println("Thread t4 is running...");
Thread.yield(); // t4 线程让出CPU执行时间片
System.out.println("Thread t4 is finished.");
});
-
有哪些垃圾回收算法
标记-清除算法: 这是最基本的垃圾回收算法之一。它分为两个阶段:标记和清除。在标记阶段,垃圾回收器标记所有活动对象。在清除阶段,它清除未标记的对象,释放它们所占用的内存。
复制算法:解决了效率问题和内存碎片的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
标记-整理算法:与标记-清除极其类似,但在清除阶段,它会将所有活动对象压缩到内存的一端,以便在清理后保持连续的内存空间。这个算法主要用于老年代的垃圾回收。
4. 分代收集算法:是一种组合算法,结合了复制算法和标记-压缩算法。它将内存划分为不同的代(通常是新生代和老年代),并根据每个代的特性选择合适的垃圾回收算法。新生代通常使用复制算法,因为大部分对象是短暂的,而老年代通常使用标记-压缩算法,因为它包含大量长期存活的对象。
封装:将数据和方法封装在对象内部,对外提供接口进行访问。
继承:子类可以继承父类的属性和方法,实现代码的重用和扩展。
多态:同一操作作用于不同的对象,可以有不同的解释,提高代码的灵活性和可扩展性。
抽象:通过抽象类和接口来定义规范,提供了程序设计的灵活性和可扩展性。
底层数据结构不同:ArrayList基于数组实现,LinkedList基于双向链表实现。
插入和删除操作性能不同:ArrayList在末尾插入和删除元素效率高,但在中间插入和删除元素效率低;LinkedList在任意位置插入和删除元素效率高,但在访问元素时效率低。
随机访问性能不同:ArrayList支持随机访问,通过索引可以快速访问元素;LinkedList不支持随机访问,需要从头或尾开始遍历链表才能访问元素。
线程安全性:在多线程环境下,普通的集合类(如ArrayList、HashMap等)不是线程安全的,需要额外的同步措施来保证线程安全。
内存占用和性能:部分线程安全的集合类在保证线程安全的同时,可能会带来额外的内存消耗和性能损耗。
遍历和修改冲突:在遍历集合的同时进行修改可能会导致ConcurrentModificationException异常。
阻塞和死锁:一些并发集合类在特定的情况下可能会出现阻塞或死锁。
Lambda表达式:简化了匿名内部类的使用,使代码更加简洁和易读。
Stream API:提供了函数式编程风格的集合操作,支持串行和并行操作,简化了集合处理代码。
接口的默认方法和静态方法:允许在接口中定义默认方法和静态方法,方便接口的扩展和升级。
新的日期和时间API:提供了更加强大和易用的日期和时间处理工具,解决了旧的Date和Calendar类的不足。
Optional类:用于处理可能为空的对象,避免了空指针异常的出现
抽象类可以包含成员变量和普通方法的实现,而接口只能包含常量和抽象方法的声明。
类可以实现多个接口,但只能继承一个抽象类。
接口中的方法默认是public和abstract的,而抽象类中的方法可以有各种访问修饰符。
抽象类不能被实例化,只能被继承和扩展,而接口不能包含构造方法,也不能被实例化,只能被实现。
JVM题目
对象头:包括标记字、类型指针等信息。
实例数据:对象的属性值,按照定义顺序排列。
对齐填充:内存对齐,确保对象的起始地址是8字节的倍数。
对象头:包括标记字、类型指针等信息。
实例数据:对象的属性值,按照定义顺序排列。
对齐填充:内存对齐,确保对象的起始地址是8字节的倍数。
对象的内存分配由Java虚拟机的垃圾回收器负责,在堆内存中进行。
对象的内存分配主要包括两个步骤:选择合适的内存区域和分配内存空间。
内存区域的选择通常有新生代和老年代,具体由垃圾回收器根据对象的大小和存活时间进行选择。
分配内存空间时,通常使用指针碰撞或空闲列表等算法来管理堆内存,确保分配的内存空间是连续的。
类加载:加载类的字节码文件,并将类的信息存储在方法区。
内存分配:根据对象的大小和存活时间,在堆内存中分配内存空间。
对象初始化:为对象的实例数据赋予初始值,包括基本类型的默认值和引用类型的null值。
构造函数调用:调用对象的构造函数,进行属性的初始化和其他必要的操作。
对象引用赋值:将对象的引用赋值给引用变量,使其可以被访问和操作。
首先解释什么是DCL,在Java中,DCL(Double-Checked Locking)是一种多线程编程中用来减少同步开销的技术。其基本思想是,在访问资源之前进行两次检查是否已经初始化,其中第二次检查是在同步块内部进行。这种模式主要用于实现懒加载,特别是在创建单例对象时。
DLC实现如下示例代码:
public class Singleton {
// 使用volatile关键字确保多线程环境下的可见性和禁止指令重排序
private static volatile Singleton instance;
private Singleton() {
// 构造函数私有化
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) { // 同步块
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
其中涉及到一个单例模式,意思就是这个类的对象无论创建多少次,创建的都是同一个对象,这就是通过两次check实现的,其中两次check的作用分别如下:
- 第一次检查:在同步块外部检查实例是否已经创建。如果已经创建,则直接返回实例,避免进入同步块,从而提高效率。
- 第二次检查:在同步块内部再次检查实例是否已经创建。这是必须的,因为可能有多个线程同时通过了第一次检查。
第二次检查比较好理解,对于多线程环境,可能有多个线程通过第一次检查,如果没有这个锁和第二次检查的话会导致每次都会对这个对象进行修改;而第一次检查可以避免大量枷锁,如果有一万个线程创建这个对象就会加一万把锁,而这些上锁和释放都会造成额外的资源浪费,而加上第一层检查只需判断是否为空即可排除绝大多数上锁的情况。
volatile使用的原因是JAVA中存在指令重排,在不影响最终执行结果的情况下都是可以指令重排的,这意味着对象创建过程中,如果线程2进来看到的是链接和构造部分发生指令重排的线程1,这就会导致现成2过不了第一层检查,但是读取到的是默认值的对象,因为线程1还没赋值。因此为了避免这种现成重排带来的安全问题需要使用volatile关键字,这个关键字确保对变量的写入不会与其他线程的后续读取发生重排序,即保证写入操作的可见性以及禁止指令重排序,具体来说就是下图:
多线程与并发面试题
public static class Philosoher extends Thread{
private Chopstick left,right;
private int index;
public Philopher(String name,int index,Chopstick left,Chopstick right){
this.setName(name);
this.index = index;
this.left = left;
this.right = right;
}
@override
public void run(){
synchronizd(left){
SleppHelper.sleepSeconds(1+index);
synchronized(right){
SleepHelper.sleepSeconds(1);
}
}
}
}
locks.LuckSupport:
static Thread t1 = null,t2 = null;
public static void main(String[] args) throws Exception{
char[] aI = "1234567".toCharArray();
char[] aC = "ABCDEFG".toCharArray();
t1 = new Thread(()->{
for(char c:aI){
System.out.println(c);
LockSupport.unpark(t2);
LockSupport.park();
}
});
t2 = new Thread(()->{
for (char c:aC){
LockSupport.park();
System.out.println(c);
LockSupport.unpark(t1);
}
});
t1.start();
t2.start();
}
TransferQueue的实现
TransferQueue 是一个扩展了 BlockingQueue 功能的接口,提供了额外的能力,允许生产者线程等待,直到消费者线程接收到元素
import java.util.concurrent.LinkedTransferQueue;
import java.util.concurrent.TransferQueue;
public class TransferQueueExample {
private static TransferQueue<Character> transferQueue = new LinkedTransferQueue<>();
public static void main(String[] args) {
Thread threadLetters = new Thread(() -> {
char[] letters = "abcdefg".toCharArray();
try {
for (char letter : letters) {
// 输出字母后,传递控制给数字线程
System.out.print(letter);
transferQueue.transfer(letter); // 将信号传递给数字线程
transferQueue.take(); // 等待数字线程完成
}
// 通知数字线程结束循环
transferQueue.transfer(' ');
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread threadNumbers = new Thread(() -> {
char[] numbers = "1234567".toCharArray();
try {
for (char number : numbers) {
transferQueue.take(); // 等待字母线程的信号
System.out.print(number);
transferQueue.transfer(number); // 将信号传递给字母线程
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threadLetters.start();
threadNumbers.start();
}
}
共享锁的实现,即notify()和wait();
比较容易懵逼的点在于不是让锁唤醒或者等待,而是使用synchronized将锁绑定在当前线程上,通过修改对象头中的锁信息实现的,因此对同一个lock进行的notify和wait其实是针对不同线程的而不是针对这把锁的。
用专业的话讲,在Java中,每个对象都与一个监视器锁(Monitor Lock)关联,这个锁用于控制对同步代码块或方法的访问。notify()和wait()的调用针对的是特定对象的监视器上的线程队列,而不是锁本身。只有当前线程离开synchronized块或方法后,锁才会被释放。
public class AlternatePrinter {
public static void main(String[] args) {
final Object lock = new Object(); // 共享锁对象
char[] aI = "1234567".toCharArray();
char[] aC = "ABCDEFG".toCharArray();
// 线程1:打印数字
new Thread(() -> {
synchronized (lock) {
for (char c : aI) {
System.out.print(c);
try {
lock.notify(); // 唤醒另一个线程
lock.wait(); // 当前线程挂起
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify(); // 必须,否则另一个线程可能永久等待
}
}, "t1").start();
// 线程2:打印字母
new Thread(() -> {
synchronized (lock) {
for (char c : aC) {
System.out.print(c);
try {
lock.notify(); // 唤醒另一个线程
lock.wait(); // 当前线程挂起
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify(); // 必须,否则第一个线程可能永久等待
}
}, "t2").start();
}
}
生产者-消费者问题是多线程编程中的典型问题,涉及到多个线程(生产者)生成数据并将其放入缓冲区,另外一些线程(消费者)从缓冲区中取出数据进行处理。
阻塞队列的实现
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ArrayBlockingQueue;
public class ProducerConsumerExample {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);
// 生产者线程
Thread producer = new Thread(() -> {
for (int i = 0; i < 20; i++) {
try {
System.out.println("Produced: " + i);
queue.put(i); // 将生产的元素添加到队列中
Thread.sleep(100); // 模拟生产耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// 消费者线程
Thread consumer = new Thread(() -> {
while (true) {
try {
Integer value = queue.take(); // 从队列中取出元素
System.out.println("Consumed: " + value);
Thread.sleep(100); // 模拟消费耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break; // 如果线程被中断,退出循环
}
}
});
// 启动线程
producer.start();
consumer.start();
try {
producer.join(); // 等待生产者线程结束
consumer.interrupt(); // 生产者线程结束后,中断消费者线程
consumer.join(); // 等待消费者线程结束
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
MySQL
1. MySQL的隔离级别
MySQL 支持四种隔离级别,分别是:
-
READ UNCOMMITTED(读取未提交):允许一个事务读取另一个事务未提交的数据。这种隔离级别最低,可能导致脏读、不可重复读和幻读。
-
READ COMMITTED(读取已提交):保证一个事务只能读取到另一个事务已经提交的数据。这种隔离级别避免了脏读,但仍然可能会出现不可重复读和幻读。
-
REPEATABLE READ(可重复读):默认级别,保证在同一事务中多次读取相同记录时,结果始终相同。这种隔离级别避免了脏读和不可重复读,但仍然可能会出现幻读。
-
SERIALIZABLE(串行化):最高的隔离级别,强制事务串行执行,避免了脏读、不可重复读和幻读。但是串行化也会导致并发性能下降,因为它阻止了多个事务同时操作相同的数据。
2. MySQL的复制原理
MySQL 的复制原理是指在 MySQL 数据库中实现主从复制的机制,它允许将一个 MySQL 数据库服务器(称为主服务器)上的更改同步到一个或多个其他 MySQL 服务器(称为从服务器)上。
简单来说,MySQL 复制的原理可以概括为以下几个步骤:
-
主服务器记录二进制日志(Binary Log):主服务器将所有对数据的更改操作都记录在二进制日志中,包括插入、更新和删除操作。
-
从服务器连接主服务器并请求日志:从服务器通过与主服务器建立连接,并请求获取主服务器上的二进制日志。这通常是通过在从服务器上设置主服务器地址和端口来实现的。
-
从服务器读取并执行二进制日志:一旦从服务器连接到主服务器并获取了二进制日志,它会读取并执行这些日志中的操作,以在从服务器上复制主服务器上的更改。
-
从服务器定期轮询主服务器获取新日志:一旦从服务器读取了一段二进制日志,它会定期轮询主服务器以获取新的二进制日志,并将其应用到从服务器上,以保持与主服务器的同步。
这样,主服务器上的更改就会被同步到一个或多个从服务器上,实现了数据的复制和同步。MySQL 复制的实现可以提高数据库的性能、可用性和容错性,同时还可以用于实现读写分离、数据备份等需求。
3. MySQL聚簇索引和非聚簇索引的区别
聚簇索引和非聚簇索引是两种不同的索引存储方式,在 MySQL 中有着重要的作用。
聚簇索引:
- 聚簇索引中,索引的顺序和数据存储的顺序一致,也就是说索引本身就是表的物理顺序,因此聚簇索引只能有一个。
- InnoDB 存储引擎中的主键索引就是一个聚簇索引,它将数据行存储在索引的叶子节点中。
- 当按照聚簇索引进行查询时,可以直接在索引中定位到对应的数据行,因此查询速度较快。
- 由于数据存储的顺序与索引一致,因此插入数据时可能需要重新调整数据的存储位置,可能导致性能上的损失。
非聚簇索引:
- 非聚簇索引中,索引的顺序与数据存储的顺序无关,也就是说索引和实际数据的存储是分开的。
- 比如,InnoDB 存储引擎中的辅助索引就是非聚簇索引。
- 当按照非聚簇索引进行查询时,需要先在索引中找到对应的主键,然后再根据主键定位到实际的数据行,因此查询速度相对较慢。
- 由于索引和数据存储的是分开的,因此插入数据时不需要调整数据的存储位置,插入性能相对较好。
mysql的索引类型跟存储引擎是相关的,innodb存储引擎数据文件跟索引文件全部放在ibd文件中,而myisam的数据文件放在myd文件中,索引放在myi文件中,其实区分聚簇索引和非聚簇索引非常简单,只要判断数据跟索引是否存储在一起就可以了。
innodb存储引擎在进行数据插入的时候,数据必须要跟索引放在一起,如果有主键就使用主键,没有主键就使用唯一键,没有唯一键就使用6字节的rowid,因此跟数据绑定在一起的就是聚簇索引,而为了避免数据冗余存储,其他的索引的叶子节点中存储的都是聚簇索引的key值,因此innodb中既有聚簇索引也有非聚簇索引,而myisam中只有非聚簇索引。
4. MySQL索引的基本原理
首先解释为什么要使用索引,这是因为在一般的应用系统中,读数据的需求量比写数据要大得多;此外数据的插入和更新一般很少出现性能问题,但是在查询操作中需要尽可能加快速度,而使用索引正是解决这一问题的关键。
MySQL 主要使用两种索引数据结构:B+Tree 索引和 Hash 索引。
-
B+Tree 索引:B+Tree 是 MySQL 中使用最频繁的索引数据结构之一,常见于 InnoDB 和 MyISAM 存储引擎模式。相较于 Hash 索引,B+Tree 索引在查找单条记录时速度较慢,但更适合于范围查询和排序等操作。由于 B+Tree 结构的特性,它的层级越多,数据量的指数级增长。因此,尽管在单条记录查询方面性能较 Hash 索引略逊,但由于其更适合于排序等操作,因此更为常见和受欢迎。
-
Hash 索引:Hash 索引将数据以哈希形式组织起来,适用于快速定位单条记录的查询。在 MySQL 中,只有 Memory 存储引擎明确支持 Hash 索引,且它是 Memory 表的默认索引类型。尽管 Memory 表也可以使用 B+Tree 索引,但 Hash 索引更为常见。Hash 索引的特点是每个键只对应一个值,并以散列方式分布。因此,它不支持范围查询和排序等功能,适用于对临时数据的快速查询。
5. MySQL索引结构及其优劣势
MySQL 的索引结构主要有以下几种:B+ 树索引、Hash 索引、全文索引和空间索引。每种索引结构都有其独特的优点和局限性。
B+ 树索引:
- 优点:
- 适用于范围查询和排序操作,查询性能稳定。
- 支持高效的增删改操作,插入和删除数据时,不会造成大量的数据重组。
- 可以有效利用磁盘预读特性,提高查询性能。
- 缺点:
- 随着 B+ 树高度的增加,查询性能可能会有所下降。
- 对于单条记录的查询速度可能较 Hash 索引慢一些。
Hash 索引:
- 优点:
- 对单条记录的查询非常快,查找效率高。
- 不支持范围查询和排序等操作。
- 缺点:
- 不适合范围查询和排序操作,只适用于等值查询。
- 不利于磁盘预读,因为数据在存储时是散列分布的。
全文索引:
- 优点:
- 支持对文本类型的字段进行全文搜索。
- 可以进行模糊匹配和关键词搜索。
- 缺点:
- 查询性能相对较低,特别是对大量文本数据进行搜索时。
空间索引:
- 优点:
- 支持对空间数据进行快速查询,例如地理位置信息。
- 可以进行空间关系查询,如范围搜索和邻近搜索。
- 缺点:
- 对于非空间数据的查询效率较低,不适合一般的数据查询。
综上所述,不同的索引结构适用于不同的查询场景。在选择索引结构时,需要根据具体的业务需求和数据特点进行综合考虑,以达到最佳的查询性能。
6. MySQL锁的类型
基于锁的属性分类:
共享锁(Shared Lock):
- 也称为读锁(Share Lock,简称 S 锁)。
- 允许多个事务同时读取同一行数据,但不允许进行修改操作。
- 读取数据时不支持修改,避免重复读问题的发生。
排他锁(Exclusive Lock):
- 也称为写锁(Exclusive Lock,简称 X 锁)。
- 只允许一个事务对数据进行修改,其他事务需要等待该事务释放锁后才能进行读取或修改操作。
- 防止其他事务同时修改或读取,避免了脏数据和脏读问题的发生。
基于锁的粒度分类:
表级锁(Table Lock):
- 锁定整个表,阻塞其他事务对表的访问。
- 特点:粒度大,加锁简单,容易产生冲突。
行级锁(Row Lock):
- 锁定表中的某一行或多行记录,其他事务只有被锁住的记录不能访问。
- 特点:粒度小,加锁复杂,不容易产生冲突,支持较高的并发访问。
页级锁(Page Lock):
- 锁定数据表中的一页数据,一次锁定相邻的一组记录。
- 特点:锁定粒度介于表级锁和行级锁之间,加锁时间和开销也介于两者之间,可能出现死锁。
基于锁的状态分类:
意向共享锁(Intention Share Lock):表示事务准备给数据行加共享锁。
意向排他锁(Intention Exclusive Lock):表示事务准备给数据行加排他锁。
其他类型的锁:
记录锁(Record Lock):
- 锁定表中的某一条记录,避免重复读和脏读问题的发生。
间隙锁(Gap Lock):
- 锁定索引记录和索引之间的间隙,防止其他事务在间隙中插入新行数据。
临键锁(Next-Key Lock):
- 是记录锁和间隙锁的组合,锁定查询范围内的记录和相邻的间隙空间。
7. MySQL为什么要实现主从同步
MySQL的主从复制是一种数据库复制方式,其中数据从一个MySQL服务器(主服务器)自动复制到一个或多个MySQL服务器(从服务器)。这是一种异步复制方式,主要用于提高数据的可用性、灾难恢复和负载分担。有如下使用场景:
- 数据备份:从服务器可以作为主服务器数据的实时备份,用于灾难恢复。
- 负载均衡:读操作可以分散到一个或多个从服务器上,减轻主服务器的负载。
- 数据分析:在从服务器上进行数据分析和报告的生成,避免影响主服务器的性能。
8. 如何处理MySQL的慢查询
-
开启慢查询日志: 开启慢查询日志可以帮助准确定位到哪个 SQL 语句出现了问题。通过记录执行时间超过阈值的 SQL 语句,可以快速发现性能瓶颈。
-
分析 SQL 语句: 查看是否加载了额外的数据或查询了多余的行,并对语句进行优化。有时可能加载了不必要的列或抛弃了结果集中需要的数据,需要重写 SQL 语句以提高效率。
-
执行计划分析: 通过EXPLAIN或EXPLAIN ANALYZE命令查看MySQL是如何执行你的SQL语句的,分析 SQL 语句的执行计划,特别是它是如何使用索引的。根据执行计划优化 SQL 语句或修改索引,使得查询可以尽可能地命中索引,提高查询性能。
-
考虑数据量和表结构: 如果对 SQL 语句的优化已经无法进行,可以考虑表中的数据量是否过大。如果数据量过大,可以考虑进行横向或纵向的分表,以减轻单表查询的压力,提高查询效率。
9. 索引的设计原则有哪些
-
适合索引的列: 出现在 WHERE 子句或 JOIN 条件中的列是索引的好候选,但应优先考虑那些经常用于过滤或排序的列。检查查询模式并针对最常见的或最耗时的查询优化索引。
-
基数较小的表: 基数是指列中不同值的数量。如果一个列的基数很低(如性别字段,只有“男”和“女”两种可能),则索引可能不会提供查询性能的显著提升,因为数据库可能会选择全表扫描而非使用索引。
-
选择索引列的长度: 对于字符类型的列,考虑使用前缀索引,这可以通过指定前缀长度来实现,如 INDEX(col(10)) 表示只对 col 列的前 10 个字符建立索引。这样做可以减少索引占用的空间,并提高索引的维护效率。
-
索引的数量: 虽然索引可以加速查询,但每个额外的索引都增加了插入、删除和更新操作的成本。只为最关键的列创建索引,并定期审查并移除不常用或无用的索引。
-
外键列的索引:
- 重要性:在含有外键的列上创建索引是很重要的,尤其是在执行连接操作时。外键索引可以加快连接操作和外键约束的检查速度。
-
更新频繁的字段: 频繁更新的字段上的索引会导致高开销,因为每次更新不仅要修改数据,还要更新索引。如日志表的时间戳字段,如果频繁变动,则应避免索引。
-
组合索引的列数: 组合索引应根据查询条件精心设计,但一般不推荐超过三到四个列,因为组合索引会随着列数的增加而迅速增大,查询优化效果的边际递减。
-
大文本、大对象: 避免在大文本或 BLOB 类型的列上创建索引,因为这样做非常低效。对于需要搜索这类数据的情况,考虑使用专门的全文索引或其他数据检索技术,如 Elasticsearch。
MySQL索引调优
1. 为什么要有索引
MySQL数据存储在磁盘中,而MySQL数据查询慢主要就是慢在了IO操作上,使用索引必然可以加快查询,有效使用IO资源和内存,从而降低网络负载,此外索引是一个唯一性标识,有索引支持唯一性检查。
从磁盘读取数据时候的两个概念,磁盘预读和局部性原理。
2. 如何设计索引结构
-
理解数据和查询:
- 数据特征: 了解数据的类型、大小和分布是设计索引的重要基础。不同类型的数据(如文本、数字、日期等)可能需要不同的索引策略。
- 查询模式: 分析应用中最常用的查询模式。查看哪些字段经常出现在 WHERE 子句、JOIN 条件或是 ORDER BY 子句中。这些字段是创建索引的最佳候选。
-
选择索引类型:
- 单列索引: 在一个字段上创建索引,适用于经常需要过滤或排序的列。
- 复合索引: 在多个字段上创建索引,适用于查询条件中经常一起使用的多个列。复合索引的顺序很重要,应根据查询中字段的出现频率和过滤能力来确定顺序。
- 全文索引: 适用于需要对文本内容进行搜索的字段,如 MySQL 的 MyISAM 和 InnoDB 引擎支持全文索引。
- 空间索引: 用于空间数据,如 MySQL 的 GIS 数据类型。
-
考虑索引覆盖:
- 覆盖索引: 如果一个索引包含了查询中需要的所有字段的数据,那么 MySQL 可以直接使用索引来完成查询,而不需要回表查询实际的数据行。这可以大大提高查询性能。
-
使用索引策略:
- 前缀索引: 对于文本字段,如果列的长度非常长,可以使用前缀索引,即只对字段的前 N 个字符创建索引。
- 分区索引: 对于非常大的表,可以考虑使用分区表,每个分区使用不同的索引。
-
监控和调整:
- 性能监控: 使用 EXPLAIN 或其他性能监控工具来分析查询的执行计划和索引的使用情况。
- 定期审查: 随着应用的发展,原有的索引可能不再适用,需要定期审查和调整索引策略。
-
避免常见陷阱:
- 过度索引: 过多的索引会降低写入操作的性能,因为每次数据更新都需要同时更新索引。
- 忽略写性能: 索引提高读取速度的同时,也会对写操作造成影响,应该找到适当的平衡。
- 忽略磁盘空间: 索引虽然可以提高性能,但也会占用额外的磁盘空间。
3. MySQL索引系统使用的数据结构是什么
-
B+树索引: MySQL 主要使用 B+ 树索引来实现数据的快速检索。B+ 树索引适用于范围查询和排序等操作,并且具有平衡性能。
-
B-Tree 索引: 与 B+ 树类似,但在叶子节点上存储数据记录而不是在内部节点,适用于 MyISAM 存储引擎。
-
Hash 索引: 在 MySQL 中少数存储引擎(如 Memory 存储引擎)支持 Hash 索引。Hash 索引适用于等值查询,但不支持范围查询和排序等操作。
-
全文索引: 用于对文本内容进行全文搜索的索引类型,主要用于 MyISAM 和 InnoDB 存储引擎的全文搜索功能。
-
空间索引: 用于处理空间数据(如 GIS 数据类型)的索引类型,主要用于支持空间数据的查询和分析。
-
JSON 索引: MySQL 5.7 及更高版本支持对 JSON 类型数据的索引,可以提高对 JSON 数据的查询性能。
-
R-Tree 索引: 用于处理空间数据的一种索引类型,主要用于支持空间数据的范围查询和空间关系查询。
4. 为什么使用B+树索引
B+ 树索引在数据库中被广泛使用,主要是因为它具有以下优点:
-
平衡性能: B+ 树是一种平衡树结构,保证了检索数据的高效性。在 B+ 树中,从根节点到叶子节点的路径长度是相等的,因此检索性能稳定,不会因为数据量的增加而降低。
-
适用于范围查询和排序: B+ 树的叶子节点形成了一个有序链表,这使得范围查询和排序等操作变得高效。B+ 树的叶子节点存储了实际的数据记录,而非像 B 树那样在内部节点存储数据,这使得 B+ 树更适合范围查询。
-
减少磁盘 I/O: B+ 树的节点通常较大,可以容纳更多的键值对,因此树的高度相对较小,从而减少了磁盘 I/O 操作。这对于数据库的性能提升至关重要,特别是在大规模数据的场景下。
-
支持快速插入和删除: B+ 树的平衡性能和节点分裂合并策略使得插入和删除操作的成本相对较低,使得数据库能够在频繁更新的情况下保持高效率。
5.什么是回表
在使用索引找到了所需的数据行的键值之后,再次查询主数据表以获取完整的数据行内容。具体步骤如下:
- 使用索引定位数据:数据库首先使用索引来快速定位包含所需数据的行。索引结构提供了指向实际数据行的物理地址。
- 访问主表:一旦通过索引找到了数据行的位置,数据库系统需要再次访问主数据表(也称为回表),按照索引给出的指针加载完整的数据行。
- 返回结果:从主表获取完整数据后,结果将返回给用户。
6. 什么是索引覆盖
索引覆盖是指查询语句所需的所有字段都包含在了索引中,因此数据库引擎可以直接使用索引来完成查询,而不需要回表查询实际的数据行。换句话说,索引覆盖可以通过索引本身就提供了查询所需的数据,从而避免了额外的磁盘 I/O 操作,提高查询效率。
7. 什么是最左匹配原则
最左匹配原则(也称为最左前缀原则)是指在使用复合索引进行查询时,查询条件必须从索引的最左列开始,并且按照索引定义的列的顺序使用这些列。同时遇到范围查询(>、<、between、like)就会停止匹配。
有效的查询:
SELECT * FROM table WHERE A = 'foo':这个查询利用了索引的最左列。
SELECT * FROM table WHERE A = 'foo' AND B = 'bar':这个查询使用了索引的前两列。
SELECT * FROM table WHERE A = 'foo' AND B = 'bar' AND C = 'baz':这个查询使用了所有索引列。
效率较低的查询:
SELECT * FROM table WHERE B = 'bar':这个查询没有使用索引的最左列(A),因此索引可能不会被使用,或者使用效率不高。
SELECT * FROM table WHERE B = 'bar' AND C = 'baz':虽然查询使用了索引中的列,但没有从最左列开始,因此索引的效果会受到限制。
部分使用索引:
SELECT * FROM table WHERE A = 'foo' AND C = 'baz':在这种情况下,索引可能仅对列A的条件部分有效,而对列C的条件则可能无法利用索引进行优化。
原理:这是由索引底层实现决定的,索引底层是一棵B+树,符合索引是多个键的B+树,第一个键必然有序,第二个键在第一个键后有序,依次类推,所以需要最左匹配。
8. 什么是索引下推
通常情况下,当数据库执行一个查询时,它会在索引中搜索符合条件的条目。在不使用索引下推的情况下,数据库将索引中找到的所有条目的键值返回给查询优化器,然后在服务器层面上应用其他的过滤条件,以确定哪些条目真正满足查询的要求。
使用索引下推优化后,数据库引擎会在索引查找过程中就应用尽可能多的查询条件。这意味着,引擎会在处理索引条目时直接检查非索引列的条件,只有当索引条目同时满足所有条件时,才会被考虑为有效结果,从而减少了必须进一步处理的数据行数。
9. 如何利用索引进行优化
- 选择合适的列进行索引
高选择性的列:优先为那些具有高选择性的列创建索引。选择性是指列中不同值的比例。例如,一个只有两种状态(如“是”或“否”)的列选择性低,不适合索引;而一个具有大量唯一值的列(如用户ID或电子邮件地址)选择性高,适合索引。
查询条件中的列:为经常出现在WHERE子句中的列建立索引,可以加快这些查询的速度。
排序和分组的列:经常用于ORDER BY、GROUP BY、DISTINCT等操作的列,索引可以显著提高这些操作的性能。 - 使用复合索引
索引多个列:如果查询条件经常涉及多个列的组合,可以创建一个包含这些列的复合索引。复合索引的创建需要根据查询条件中列的使用顺序来决定列在索引中的顺序。
遵循最左前缀原则:在复合索引中,确保查询能够利用索引的最左边的列。 - 考虑索引覆盖
如果一个查询可以完全通过索引来获取所需的数据,则不需要再访问数据表。在可能的情况下,尽量创建覆盖索引,即包含查询中需要的所有字段的索引。 - 适当使用索引提示
在某些情况下,数据库的查询优化器可能不会选择最优的索引。在这种情况下,可以在查询中使用索引提示来强制使用特定的索引。 - 定期维护索引
重建和重新组织索引:随着时间的推移,索引可能会因为数据的增加和删除而变得碎片化。定期重建或重新组织索引可以恢复索引的性能。
监控索引性能:监控索引的使用情况和性能,以便调整策略。未被使用或很少使用的索引可能需要移除,以减少维护成本和提高写操作的性能。 - 避免过度索引
索引虽好,但不宜过多。每一个额外的索引都会增加写操作(INSERT、UPDATE、DELETE)的负担,因为在写入数据时,所有的索引都需要被更新。因此,避免为不常用于查询的列或表创建索引。
Spring部分
1. Spring,Springboot,SpringMVC的区别
-
Spring 和 Spring MVC:
-
Spring: 是一个一站式的轻量级的 Java 开发框架,核心是控制反转 (IOC) 和面向切面 (AOP)。针对于开发的 WEB 层 (Spring MVC)、业务层 (IOC)、持久层 (JdbcTemplate) 等都提供了多种配置解决方案。
-
Spring MVC: 是 Spring 基础之上的一个 MVC 框架,主要处理 web 开发的路径映射和视图渲染,属于 Spring 框架中 WEB 层开发的一部分。
-
Spring MVC 和 Spring Boot:
-
Spring MVC: 属于一个企业 WEB 开发的 MVC 框架,涵盖面包括前端视图开发、文件配置、后台接口逻辑开发等,XML、config 等配置相对比较繁琐复杂。
-
Spring Boot: 框架相对于 Spring MVC 框架来说,更专注于开发微服务后台接口,不开发前端视图,同时遵循默认优于配置,简化了插件配置流程,不需要配置 XML,相对 Spring MVC,大大简化了配置流程。通过提供大量的自动配置(auto-configuration)和启动器(starters),Spring Boot 简化了项目的配置和管理。这使得开发者可以更专注于业务逻辑的实现,而不是花费时间处理框架的配置。
2.SpringMVC工作流程
1、DispatcherServlet表示前置控制器,是整个SpringMVC的控制中心。用户发出请求,DispatcherServlet接收请求并拦截请求。
2、HandlerMapping为处理器映射。DispatcherServlet调用HandlerMapping,HandlerMapping根据请求url查找Handler。
3、返回处理器执行链,根据url查找控制器,并且将解析后的信息传递给DispatcherServlet。
4、HandlerAdapter表示处理器适配器,其按照特定的规则去执行Handler。
5、执行handler找到具体的处理器
6、Controller将具体的执行信息返回给HandlerAdapter,如ModelAndView.
7、HandlerAdapter将视图逻辑名或模型传递给DispatcherServlet。
8、DispatcherServlet调用视图解析器(ViewResolver)来解析HandlerAdapter传递的逻辑视图名。
9、视图解析器将解析的逻辑视图名传给DispatcherServlet。
10、DispatcherServlet根据视图解析器解析的视图结果,调用具体的视图,进行试图渲染
11、将响应数据返回给客户端
3.SpringMVC九大组件
Bean 类型 | 作用 |
---|---|
HandlerMapping | 处理器映射。根据规则将请求映射到具体处理器,支持控制器上添加注解配置请求路径。 |
HandlerAdapter | 处理器适配器。负责调用处理器,屏蔽调用细节,比如解析基于注解配置的控制器。 |
HandlerExceptionResolver | 处理器异常解析器。将捕获的异常映射到不同视图,支持复杂异常处理。 |
RequestToViewNameTranslator | 请求到视图名翻译器。从请求中找到默认视图。 |
ViewResolver | 视图解析器。将逻辑视图名映射到实际视图类型。 |
LocaleResolver、LocaleContextResolver | 地区解析器、地区上下文解析器。解析客户端地区信息,支持国际化视图。 |
ThemeResolver | 主题解析器。解析可用主题,提供个性化定制布局等。 |
MultipartResolver | 多部分解析器。解析多部分传输请求,支持文件上传等。 |
FlashMapManager | FlashMap 管理器。存储并传递请求之间的 FlashMap 对象,用于请求重定向情景。 |
4. Spring的核心
Spring是一个开源框架,是为了简化企业级应用开发而生,是Spring生态的基石。Spring的核心主要是IoC和AOP:
-
IoC涉及到一个重要思想,就是依赖倒置,它将组件的依赖关系从代码内部转移到外部容器,可以减少代码之间的耦合度。
具体来说,在传统的程序设计中,组件间的依赖关系通常由组件自己在内部管理和控制,一个对象负责创建它所依赖的其他对象。以创建一个Car对象为例,汽车依赖车身,车身依赖底盘,底盘依赖轮子,在这个过程中,我需要在Car的代码中完成车身对象的构造,以此类推。
而IoC就是我们用依赖注入(Dependency Injection)这种方式来实现控制反转,将控制权交给外部容器。对于创建对象时依赖的对象,通过IoC容器创建后注入到对象中。
其好处是降低耦合度、提高灵活性、增强模块化。 -
AOP(面向切面编程)可以对业务逻辑的各个部分进行隔离,使得业务逻辑各部分之间的耦合度降低,主要是将主功能逻辑和一些通用业务逻辑分离。
具体来说,任何一个系统都是由不同的组件组成的,每个组件负责一块特定的功能,当然会存在很多组件是跟业务无关的,例如日志、事务、权限等核心服务组件,这些核心服务组件经常融入到具体的业务逻辑中,如果我们为每一个具体业务逻辑操作添加这样的代码,很明显代码冗余太多。
因此我们需要将这些公共的代码逻辑抽象出来变成一个切面,然后注入到目标对象(具体业务)中去,AOP正是基于这样的一个思路实现的,通过动态代理的方式,将需要注入切面的对象进行代理,在进行调用的时候,将公共的逻辑直接添加进去,而不需要修改原有业务的逻辑代码,只需要在原来的业务逻辑基础之上做一些增强功能即可。
一些基本概念如下:- 横切关注点:在多个模块中重复出现的功能,如日志记录、事务管理、安全性检查等。这些功能与核心业务逻辑不直接相关,但又是应用不可或缺的部分。
- 切面(Aspect):封装横切关注点的模块。一个切面可以定义切入点和相应的建议,它可以在不修改代码的情况下增强现有代码的功能。
- 连接点(Join Point):程序执行过程中的某个特定点,如方法调用或变量赋值。在AOP中,一个连接点总是代表一个方法的执行。
- 切入点(Pointcut):一组连接点的集合,通常通过表达式来匹配特定的执行点。这个表达式可以是某些方法名的模式,或者是方法注解等。
- 通知(Advice):在切面的某个特定的切入点上所采取的行动。通知类型包括前置通(Before)、后置通知(After)、环绕通知(Around)、返回后通知(AfterReturning)和异常后通知(After Throwing)。
5.Spring的事务传播机制
- PROPAGATION_REQUIRED(默认):如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行(即没有事务的支持下执行)。 - PROPAGATION_MANDATORY:如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。
PROPAGATION_REQUIRES_NEW:创建一个新事务,如果当前存在事务,则暂停当前事务。 - PROPAGATION_NOT_SUPPORTED:以非事务方式执行,如果当前存在事务,则暂停当前事务。
- PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前事务不存在,则表现如PROPAGATION_REQUIRED。
6.Spring中的单例bean是线程安全的吗
Spring中的Bean对象默认是单例的,框架并没有对bean进行多线程的封装处理
-
如果Bean是有状态的,有状态就是有数据存储的功能,那么就需要开发人员自己来保证线程安全的保证,最简单的办法就是改变bean的作用域把singleton改成prototype,这样每次请求bean对象就相当于是创建新的对象来保证线程的安全
-
如果Bean是无状态的,无状态就是即没有实例变量或只有不可变的实例变量,不会存储数据,比如controller,service和dao本身并不是线程安全的,只是调用里面的方法,而且多线程调用一个实例的方法,会在内存中复制遍历,每次调用都像是第一次调用一样,因此不会发生状态冲突,这是自己线程的工作内存,是最安全的。
因此在进行使用的时候,不要在bean中声明任何有状态的实例变量或者类变量,如果必须如此,使用ThreadLocal把变量变成线程私有,如果bean的实例变量或者类变量需要在多个线程之间共享,那么就只能使用synchronized,lock, cas等这些实现线程同步的方法了。
7. Spring框架中使用了哪些设计模式及应用场景
- 1.工厂模式,在各种BeanFactory以及ApplicationContext创建中都用到了
- ⒉.模版模式,在各种BeanFactory以及ApplicationContext实现中也都用到了
- 3.代理模式,Spring AOP利用了AspectJ AOP实现的! AspectJ AOP的底层用了动态代理
- 4.策略模式,加载资源文件的方式,使用了不同的方法,比如: ClassPathResourece,
FileSystemResource,ServletContextResource,UrlResource但他们都有共同的借口Resource;在Aop的实现中,采用了两种不同的方式,JDK动态代理和CGLIB代理 - 5.单例模式,比如在创建bean的时候。
- 6.观察者模式,spring中的ApplicationEvent,ApplicationListener,ApplicationEventPublisher
- 7.适配器模式,MethodBeforeAdviceAdapter,ThrowsAdviceAdapter,AfterReturningAdapter
- 8.装饰者模式,源码中类型带Wrapper或者Decorator的都是
8. Spring事务的隔离级别
跟数据库事务隔离级别相同,read uncommited、read commited、repeated read、serializable
9.Spring事务的实现原理
关于SPI
https://cloud.tencent.com/developer/article/2340759