-
JDK、JRE、JVM,以及三者的关系
1)JDK 指的是 java 开发工具包,它包括编译器、JAVA核心类库、JVM、开发辅助工具(jps、jinfo、jmap、jconsole、jvisualvm)
2)JRE 指的是 JAVA 程序运行环境,主要包括 JAVA 核心类库、JVM
3)JVM 是字节码执行引擎,java 程序运行在 java 虚拟机上,同时负责 java 程序的内存管理、垃圾回收
三者的关系:JDK 包含 JRE,JRE 包含 JVM -
String 和 StringBuffer、StringBuilder 的区别是什么?
主要从两个方面来比较,一个是可变性,一个是线程安全性
1)String 是不可变字符串;StringBuffer、StringBuilder 是可变字符串,可以通过 append() 方法来改变
2)线程安全性:String 因为是对象不可变的,因此它是线程安全的,StringBuffer 的方法度加了 synchronized,它是线程安全的,而 StringBuilder 的方法没有加锁,因此它是线程不安全的 -
String 为什么是不可变的
final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的
1)保存字符串的 char[] 数组被 final 修饰且为私有的,并且 String 类并没有提供修改这个字符串的方法
2)String 类被 final 修饰,因此其不能被继承,进而避免了子类破坏 String 的不可变性。如果 String 类没有被 final 修饰,那就可以通过继承 String 类,然后重写 String 的构造方法,从而导致 String 变成可变的 -
String 为什么要设计成不可变的
1)保证线程安全,因为 String 是一个比较特殊的对象,假如一个字符串在多个地方被使用,如果 String 是可变的,那么一个地方修改就会引起其它地方的同步改动,这样的话会给程序带来很严重的不可靠性
2)字符串常量池的需求,字符串常量池需要 String 不可变。当创建一个 String 对象时,若此字符串已经存在于常量池中,则不会创建一个新的对象,而是引用已经存在的对象。如果字符串变量允许改变,会导致各种逻辑错误,如改变一个对象会影响到另一个独立对象
3)支撑哈希表(HashMap/HashSet)的正常工作:当 String 作为 HashMap 的 Key 时,若后续修改了 String 的字符序列,其 hashCode 会随之改变。此时,原 Key 所在的哈希桶位置与新 hashCode 对应的桶位置不一致,导致后续无法通过 Key 找到对应的 Value,哈希表彻底失效
4)保障 Java 核心 API 的安全性:文件路径(File file = new File(String path)):若 path 可变,创建 File 后若 path 被篡改,可能导致程序访问错误的文件(甚至敏感文件) -
重载和重写
-
接口和抽象类
-
封装、继承、多态
-
ArrayList 为什么是线程不安全的
ArrayList 线程不安全,主要是因为它的底层操作没加锁。比如多个线程同时 add 元素时,都会先拿当前 size 当索引,然后赋值、size+1。这两步如果不同步,就可能两个线程用同一个索引,后加的元素把先加的覆盖了,数据就丢了。还有扩容的时候,多个线程同时复制数组,也可能搞乱数据,甚至抛出数组越界异常。另外,一边遍历一边修改,还会触发并发修改异常,因为它内部有个修改计数器,不一致就报错 -
如何解决ArrayList的线程安全问题
1)用 Vector,它的方法都加了 synchronized,强制线程排队,简单但性能一般;
2)用 Collections.synchronizedList 包装一下 ArrayList,原理和 Vector 类似,也是加锁,适合并发不高的场景;
3)高并发尤其是读多写少的话,用 CopyOnWriteArrayList,写的时候复制一份新数组改,读的时候直接读旧的,不用锁,效率高,但写起来开销大,可能读到老数据 -
HashMap的数据结构
-
HashMap put() 方法的执行过程
1)先算 key 的 hash 值,再用 hash 值和数组长度减一做与运算,确定存到数组的哪个位置(桶);
2)如果这个桶是空的,直接把键值对包装成节点放进去;
3)如果桶里有东西,就比较 key:相同的话就替换旧值;不同的话,看这个桶是链表还是红黑树(JDK8 后),链表的话就往后插,插多了(超过 8 个)会转成红黑树;红黑树就按树的规则插入;
4)最后如果元素数量超过阈值(数组长度 * 负载因子,默认 0.75),就会扩容,数组长度翻倍,把元素重新分配到新数组里。 -
HashMap get() 方法的执行过程
1)先算 key 的 hash 值,找到对应的桶位置;
2)然后遍历这个桶里的元素(链表或红黑树),用 key 的 hash 值和 equals 方法比对,找到匹配的节点,返回它的 value;
3)如果整个桶里都没找到,就返回 null。 -
HashMap 线程不安全的原因
1)并发扩容时导致死循环
JDK 7 中 HashMap 采用「头插法」处理哈希冲突的链表,并发扩容时可能导致链表成环,引发死循环
原理:
· 当 HashMap 元素数量超过阈值(capacity * loadFactor)时,会触发扩容(resize()),新建更大的数组并将旧元素迁移到新数组
· 并发扩容时,多个线程同时迁移同一链表,由于「头插法」会改变链表顺序,可能导致两个节点互相引用,形成环形链表
· 后续查询该链表时,会陷入无限循环(next 指针永远无法指向 null),最终导致 CPU 使用率飙升
(大白话:两个线程在同时进行put操作时,由于元素数量超过阈值而同时进行扩容,多个线程迁移同一链表,头插法会导致形成环形链表,后续查询该链表时,会导致死循环)
2)并发执行 put() 操作时,可能导致后插入的元素覆盖先插入的元素,造成数据丢失
原理:
线程 A 计算出 key 的哈希索引后,判断该位置为空,准备插入新节点
线程 B 同时计算出相同的哈希索引,且也判断该位置为空,先于线程 A 插入新节点
线程 A 恢复运行后,仍认为该位置为空,直接插入新节点,覆盖线程 B 插入的数据
3)get() 可能返回 null
一个线程执行 put() 插入元素后,另一个线程执行 get() 可能无法获取到该元素,返回 null
原理:
HashMap 的 size、table 等字段未被 volatile 修饰,不保证多线程间的内存可见性
线程 A 插入元素后,其修改的 table 或 size 可能未及时刷新到主内存
线程 B 读取时仍使用本地缓存中的旧数据,导致无法感知线程 A 插入的新元素
-
ConcurrentHashMap
ConcurrentHashMap 是线程安全的 HashMap,比 Hashtable 好用,因为它锁得更细,性能高。
JDK7 里是 “分段锁”,整个数组分成几个段,每个段一把锁,不同段的操作可以并发,效率高;
JDK8 之后改成了 “CAS+synchronized”,直接锁每个桶的头节点,粒度更细了,并发度更高。
它支持并发读写,读操作基本不用锁,写操作只锁当前桶,所以多线程用起来又安全又高效,适合高并发场景。 -
线程池
1)构造方法参数含义
· corePoolSize,线程池核心线程数
· maximumPoolSize 线程池最大线程数量
· keepAliveTime 非核心线程存活时间
· unit 非核心线程存活时间单位
· workQueue 工作队列
· ArrayBlockingQueue:基于数组的有界阻塞队列
· LinkedBlockingQuene:基于链表的无界阻塞队列
· SynchronousQuene:一个不缓存任务的阻塞队列
· PriorityBlockingQueue:具有优先级的无界阻塞队列
· threadFactory:线程工厂,创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等
· handler 拒绝策略
· CallerRunsPolicy:在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务
· AbortPolicy:直接丢弃任务,并抛出RejectedExecutionException异常
· DiscardPolicy:直接丢弃任务,什么都不做
· DiscardOldestPolicy:抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列
豆包:
线程池构造方法有几个核心参数,就像给线程池定规矩:
核心线程数:线程池长期保持的线程数量,即使没事干也不删;
最大线程数:线程池最多能创建的线程数,超过核心数的线程是临时的,闲久了会被删;
空闲时间:临时线程(超过核心数的)没事干多久后会被回收;
时间单位:空闲时间的单位,比如秒、毫秒;
工作队列:任务太多时,先存在这个队列里,等线程有空了再取;
线程工厂:用来创建线程的,一般用默认的就行;
拒绝策略:任务太多,队列满了且线程也到最大数了,怎么处理新任务?比如直接抛异常、让提交任务的线程自己执行,等等。
2)工作机制:最开始启用核心线程处理任务,当所有核心线程全部都在运行,并且有新的任务进来的时候,这些任务会放入阻塞队列中,如果阻塞队列满了,就会启动非核心线程处理任务,如果所有的线程都在运行,并且阻塞队列满的时候,会启用拒绝策略,拒绝策略有4种,默认的拒绝策略是直接抛出异常
豆包:
来了新任务,先看核心线程够不够,不够就新建核心线程执行;
核心线程满了,就把任务放到工作队列里,等核心线程干完手头的再去队列里取;
队列也满了,就看有没有到最大线程数,没到就新建临时线程来执行;
临时线程也满了(到最大数了),就触发拒绝策略处理新任务;
临时线程闲太久(超过空闲时间),就会被回收,最后保持在核心线程数。
- synchronized
1)作用:可以保证原子性(一段代码以原子的方式执行,不会执行到一半就中断)、可见性、有序性
2)使用方式:可以修饰方法、修饰代码块
3)底层实现原理:synchronized 属于jvm层面的,如果修饰的是代码块,则会生成两条jvm字节码指令 monitorenter 和 monitorexit,monitorenter指向代码块开始的位置,monitorexit 指向代码块结束的位置;如果修饰的是方法,则会生成一个 ACC_SYNCHRONIZED 标识符,标识这个方法是一个同步方法
4)锁的性质:非公平锁、悲观锁、可重入锁
5)锁升级的过程:在JDK1.6之前,synchronized被认为是重量级锁,在JDK1.6之后,引入了偏向锁和轻量级锁来减少获得锁和释放锁带来的性能消耗。锁的状态有四种,分别是无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,当没有线程持有锁的时候,它是无锁状态,当有一个线程持有锁的时候,它会改成偏向锁状态,如果没有其它线程来争夺这个锁,那么就一直处于偏向锁状态,当有其它线程来争夺锁的时候,这把锁就会改成轻量级锁状态,其它获取不到锁的线程一直在自旋,当自旋的次数达到一定次数还是获取不到锁的话,它就会认为自旋的成本和代价太高昂了,浪费CPU的资源,因此这把锁就会膨胀为重量级锁
6)是如何实现可重入的:每一个锁关联一个线程持有者和计数器,当计数器为0时表示锁是空闲的,当某个线程请求获取锁成功之后,计数器加1,当这个线程再次申请锁时,计数器继续加1,当退出同步代码块时,计数器会递减,当计数器为0时则表示释放锁
-
ReentrantLock
-
CAS
1.什么是 CAS
1)CAS是一种乐观锁机制,指的是比较并交换,有的称比较并设置,意思都一样,它是这样的:现在要去改变一个变量的值,在改变之前先把这个值记录下来,这个值称为旧值,然后进行设置新值的时候,会先判断一下这个旧值有没有改变,如果没有改变的话就可以设置成功,如果发生改变的话就放弃修改
2)CAS的底层实现
CAS的底层实现是基于CPU原语来保证操作的原子性。在JDK层面,提供了Unsafe这个类来进行CAS的操作
在Java层面,CAS相关的接口由sun.misc.Unsafe类提供,其中CAS相关的方法都是native方法
豆包:
CAS 是 “比较并交换”,是种无锁的并发控制方式,比加锁效率高。它有三个参数:内存地址 V、旧的预期值 A、新值 B。意思是 “我认为 V 地址现在的值是 A,如果是的话就改成 B,不是的话就不改,还告诉我现在实际是啥”。比如多个线程抢着改一个变量,每个线程用 CAS 试,只有一个能成功,其他的失败了可以重试或放弃。好处是不用加锁,避免线程切换开销,但有个 “ABA 问题”(值从 A 变 B 又变 A,CAS 以为没变),可以用版本号解决。Java 里的 AtomicInteger 这些原子类就是靠 CAS 实现的。
- volatile
1)前置知识
在理解 volatile 之前,有必要先了解 Java 内存模型和 happens-before 规则
2)java 内存模型:假如有一个线程的共享变量,这个变量是存在于主内存中的,但是为了提高效率,每个线程在自己的工作内存中有一份该变量的拷贝。(主内存映射到硬件可以理解为平常说的内存,而工作内存可以理解为CPU中的高速缓存存储器,CPU从高速缓存存储器中读数据肯定要比从内存中读数据要快得多)
3)happens-before 规则:
4)作用:可以保证可见性、有序性
· 可见性:当一个线程修改了一个 volatile 变量的值,其他线程能够立即看到这个修改
· 有序性:volatile 通过禁止指令重排序来保证代码的顺序执行,即 volatile 变量的读写操作不会被编译器和处理器重排序
5)实现原理
· 可见性:可见性是基于缓存一致性协议实现。当一个线程写入 volatile 变量时,它会强制将线程工作内存中的变量值刷新到主内存。当其他线程读取这个变量时,它们必须从主内存中读取该值,而不是从自己的工作内存中读取可能过时的副本
· 有序性是基于内存屏障(LoadLoad Barriers、LoadStore Barriers、StoreStore Barriers、StoreLoad Barries)实现的
6)使用方式
-
ThreadLocal 是什么
1)ThreadLocal 直译为线程本地,也称为线程本地变量
2)意思是在 ThreadLocal 变量中填充的值只属于当前线程,对其它线程是不可见的,从而起到线程隔离的作用,避免了线程安全问题 -
ThreadLocal 的作用(为什么需要 ThreadLocal)
1)在线程之间隔离数据,对于同一个变量,每个线程都有独立的数据
2)减少锁的使用,如果没有 ThreadLocal,在某些并发场景下需要加锁来解决 -
ThreadLocal 内存泄露问题
1)ThreadLocal 内存泄露是指 ThreadLocalMap 中的 value 没办法被回收
2)内存泄露原因:
ThreadLocal 已经使用结束了(意味着没有强引用指向堆中的 ThreadLocal 对象),而线程还存活着,JVM 在进行垃圾回收后会把只有弱引用指向的 ThreadLocal 对象回收,也就是 Entry 的 key 会被回收,但是此时 value 还在,因此就产生了内存泄露
3)如何避免内
内存泄露:在使用完 ThreadLocal 之后,调用 remove() 方法把 Entry 置空
- AQS
1)AQS 的核心组成部分
· volatile修饰的state变量:当它的值为0的时候,就表示资源是空闲的,当为1或者是其他数值的时候,就表示资源处于锁定状态
· 等待队列:它的底层数据结构是双向链表,那些获取不到资源的线程会被包装成一个Node节点,放到这个队列当中
· CAS:相当于是一种轻量级的并发处理,因为修改属性的时候是多个线程同时去修改的,但是最终只有一个线程能修改成功,修改失败的线程会通过CAS进行重试
2)AQS 的工作原理:假设一个线程来请求资源,这个资源是空闲的,那么请求资源成功的线程会被设定为工作线程,把资源的状态设定为锁定状态,这个时候如果再有线程来请求资源,那么就会请求失败,请求不到资源的线程会加入到同步队列中,当资源被释放之后,同步队列中的线程再次进行资源的争夺
豆包:
AQS 是 “抽象队列同步器”,是 Java 里很多并发工具(比如 ReentrantLock、CountDownLatch)的底层骨架。它核心是一个状态变量(state)和一个等待队列。线程抢锁时,先看 state 是不是 0(没人用),是就拿走(state 设为 1);不是就进等待队列排队,挂起。释放锁时,把 state 改回去,再叫醒队列里的线程来抢。它就像个 “排队管理器”,帮各种并发工具实现了加锁、解锁、排队的基本逻辑,上层工具只需要关心自己的 state 怎么用(比如 ReentrantLock 用 state 记录重入次数)。
-
并发工具类
1)CountDownLatch、CyclicBarrier、Semaphore 可以认为是一种控制并发流程的工具类
2)Exchanger 可以认为是线程间交换数据的工具类 -
CountDownLatch
1)CountDownLatch 的核心思想是通过计数器来控制线程的执行顺序,当计数器的值降为0时,所有等待的线程都会被唤醒,然后开始执行下一步操作
2)CountDownLatch 的使用:test219_thread/demo9/CountDownLatchDemo
3)CountDownLatch 的执行过程
· 在主调用线程中创建 CountDownLatch,并传入 count,count 通常为工作线程数量
· 在工作线程中调用 countDown() 方法,每调用一次 count 就减1
· 在主调用线程中调用 await() 方法来阻塞主调用线程
· count减到为0时,主调用线程被唤醒
4)CountDownLatch 的底层实现:基于 AQS 实现 -
CyclicBarrier
1)CyclicBarrer 的作用是让一组线程达到一个屏障(同步点)时被阻塞,直到所有的线程到达此屏障时,才会唤醒被屏障阻塞的所有线程
2)CyclicBarrer 的使用:test219_thread/demo9/CyclicBarrierDemo、CyclicBarrierDemo2、CyclicBarrierDemo3、CyclicBarrierDemo4
3)CyclicBarrer 的执行过程
· 在主调用线程中创建 CyclicBarrer,并传入 parties,parties 通常为工作线程数量
· 在工作线程中调用 await() 方法,每调用一次,就说明有一个线程抵达屏障,直到有 parties 个线程抵达屏障后,唤醒被屏障阻塞的所有线程
4)CyclicBarrier 的底层实现:基于 AQS 实现 -
Semaphore
1)信号量是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源
2)Semaphore 的使用:test219_thread/demo9/SemaphoreDemo
3)Semaphore 的工作流程
· 在主调用线程中创建 Semaphore,并传入 permits,permits 为许可证数量
· 在工作线程中调用 acquire() 方法,每调用一次,许可证数量就减1
,当许可证数量减到为0时,再调用 acquire() 会被阻塞,直到已经获得许可证的工作线程调用 release() 方法归还许可证,然后被阻塞的线程会获得许可证
4)Semaphore 的底层实现:基于 AQS 实现 -
Exchanger
Exchanger 的底层实现:使用 ThreadLocal 和 ArrayBlockingQueue 等数据结构来实现线程间的配对和数据交换 -
CountDownLatch 和 CyclicBarrier 的区别
· CountDownLatch 阻塞的是主线程,下一步动作的执行者是主线程,不可重复使用
· CyclicBarrier 阻塞的是其它线程,下一步动作的执行者是其它线程,可重复使用 -
原子类
1)原子类的出现背景:当一个线程更新一个变量时,程序如果没有正确的同步,那么这个变量对于其他线程来说是不可见的。我们通常使用 synchronized 或者 volatile 来保证线程安全的更新共享变量。在JDK1.5中,提供了 java.util.concurrent.atomic 包,这个包中的原子操作类提供了一种用法简单,性能高效,线程安全地更新一个变量的方式
2)原子类的应用案例:多个线程进行 i++ 操作,为了线程安全,需要加 synchronized 锁,锁是比较重的,因此可以考虑使用原子类AtomicInteger 来代替 synchronized
3)原子类的底层实现是基于 volatile 和 CAS,因此可以减少锁带来的性能开销
- 介绍下运行时数据区
1)运行时数据区划分为以下几个区域
· 程序计数器PC:记录线程运行的代码位置,每个线程都有一个独有的程序计数器
· 虚拟机栈:存储java方法,每执行一个java方法,就会在虚拟机栈中创建一个栈帧
· 本地方法栈:存储本地方法
· 堆:分为新生代和老年代,新生代细分为 Eden 区和两个 S 区,默认比例是8:1:1
· 方法区:存放元数据信息,主要有类型信息、方法信息、字段信息、运行时常量池
· 直接内存
2)生命周期:堆和方法区是线程共享的区域,生命周期与 jvm 进程一样;其他三个是线程私有的区域,生命周期与线程一样,这三个区域随线程而生,随线程而灭
- 堆内存为什么要进一步划分区域(堆内存为什么要划分新生代老年代?)
主要是为了提升 GC 的效率。对象的生命周期不一样,有的长,有的短,所以可以根据对象的生命周期采用不同的垃圾回收算法,生命周期比较短的放在一个区域,生命周期比较长的放在另外一个区域,然后根据不同的区域采用不同的垃圾回收算法
豆包:
堆划分成新生代、老年代这些区域,主要是为了提高垃圾回收的效率。因为对象的生命周期不一样:大部分对象刚出生就死了(比如临时变量),少数对象能活很久。
新生代(又分 Eden、Survivor)放新创建的对象,这里垃圾回收(Minor GC)频率高,速度快,用复制算法,把活的对象挪到 survivor 区,剩下的直接清掉;
老年代放活了很久的对象,垃圾回收(Major GC)频率低,用标记 - 清除或标记 - 整理算法,避免频繁复制大对象;
这样分区域后,垃圾回收不用扫描整个堆,只针对特定区域,效率大大提高,也能减少回收时的停顿时间。
-
类加载器有哪些
· Bootstrap ClassLoader,启动类加载器或者根类加载器,加载 rt.jar(JDK核心类库)
· Extension ClassLoader,扩展类加载器,加载(参考下马士兵)(JDK扩展类库)
· Application ClassLoader,应用类加载器或者系统类加载器,加载自定义类(自定义类库以及第三方类库)
· 自定义类加载器:加载自定义类 -
什么时候需要自定义类加载器?
1)热部署、代码加密、从特定位置加载类(因为三类自带的类加载器只能从属于它们的位置加载类,如果要从网络中加载类文件,那只能自定义类加载器了)
2)可以通过继承java.lang.ClassLoader类,来自定义自己的类加载器
豆包:
一般是默认的类加载器满足不了需求的时候。比如想加载非标准路径的类(比如从网络上下载的 class 文件),或者对类进行加密解密(防止 class 文件被反编译,加载时先解密),还有像热部署(不停机更新类)也需要自定义加载器,因为默认加载器加载过的类不会再重新加载。简单说就是,想自己控制类的加载逻辑和来源时,就需要自定义。
- 什么是双亲委派模型
当一个类加载器收到加载类的请求时,它首先将这个请求委派给父类加载器去尝试加载。如果父类加载器能够成功加载该类,那么加载过程结束,返回父类加载器加载的类。如果父类加载器无法加载该类,那么子类加载器才会尝试加载
豆包:
就是类加载器加载类的时候,先不自己动手,而是 “向上请示”:先让父加载器试试,父加载器加载不了,再自己来。比如我们自定义的加载器,会先找它的父加载器(应用类加载器),应用类加载器再找扩展类加载器,扩展类加载器再找启动类加载器。只有所有父加载器都找不到这个类,自己才会去加载。就像孩子遇到事儿,先找爸爸,爸爸搞不定找爷爷,爷爷也不行才自己解决。
- 为什么要采用双亲委派模型
1)避免类的重复加载:当一个类需要被加载时,首先会委托给父类加载器进行加载,如果父类加载器已经加载了该类,就不会再次加载,避免了类的重复加载,节省了内存空间
2)确保类的安全性和一致性:Java核心类库由启动类加载器加载,通过双亲委派机制,可以确保核心类库的安全性和一致性,避免了恶意代码替换核心类库的风险(保护java的核心类库)
3)防止类的篡改:如果允许子类加载器加载和父类加载器加载同名的类,就会出现类的篡改问题,通过双亲委派机制,父类加载器加载的类无法被子类加载器替换,保证了类的完整性
4)方便类加载器的扩展和自定义:开发人员可以通过继承ClassLoader类来实现自定义的类加载器,通过双亲委派机制,可以方便地扩展和自定义类加载器的功能,满足不同的加载需求
豆包:
最主要是为了 “安全” 和 “避免重复加载”。比如 Java 核心类(像 java.lang.String),肯定是启动类加载器先加载,如果没有双亲委派,自定义一个同名的 String 类,自己的加载器直接加载了,就会替换掉核心类,那不乱套了?有了双亲委派,核心类只能被最顶层的加载器加载,保证了类的唯一性和安全性。另外,父加载器加载过的类,子加载器就不用再加载了,也节省了资源。
- 如何破坏双亲委派模型
1)自定义类加载器
2)使用 Thread.currentThread().setContextClassLoader 方法
豆包:
双亲委派的核心是加载类时先调父加载器的 loadClass 方法。那破坏它就可以不按这个规矩来:比如重写类加载器的 loadClass 方法,不先委托父加载器,直接自己加载;或者用线程上下文类加载器(比如 JNDI、SPI 这些场景),让父加载器能拿到子加载器加载的类,相当于 “逆向委派”。像 Tomcat 的类加载器也破坏了双亲委派,因为它需要同一个 Web 应用里的类隔离开,各自加载自己的,不被父加载器干扰。
-
如何保持双亲委派模型
-
类加载的过程
1)加载
2)验证
3)准备
4)解析
5)初始化
豆包:
大概分三步:加载、连接、初始化。
加载就是把 class 文件读进内存,生成一个 Class 对象;
连接又分三步:验证(检查 class 文件是不是合法的)、准备(给类的静态变量分配内存,设默认值,比如 int 默认 0)、解析(把符号引用换成直接引用,比如把类名换成内存地址);
初始化就是执行静态代码块和静态变量的赋值,比如static int a = 10;这里才会把 10 赋给 a,这一步是按需触发的(比如 new 对象、调用静态方法时)。
- 对象的分配策略
1)如果满足逃逸分析和标量替换,则直接在栈上分配
2)如果是大对象则直接进入老年代(大对象生命周期一般较长,避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制)
3)在 Eden 区分配
(在极端情况下,如果 Eden 区满了而 Survivor 区有足够的空间,JVM 可能会将对象直接分配到 Survivor 区,但这不是常见的情况)
豆包:
简单说就是对象在哪儿创建,一般遵循 “先年轻代,再老年代” 的原则。大部分新对象先在新生代的 Eden 区分配;如果 Eden 区满了,就触发 Minor GC,把活下来的对象挪到 Survivor 区;如果对象太大(超过设定的阈值),Eden 区放不下,可能直接分配到老年代(大对象直接进老年代,避免在新生代频繁复制);还有些长期存活的对象,在 Survivor 区来回挪几次(默认 15 次),也会进老年代。
- 对象在堆中各个区域的活动过程
1)对象如果不满足进入老年代以及栈上分配的条件,将进入 Eden 区分配内存
2)经过一次 Minor GC 之后,从 Eden 区进入空闲的 s 区
3)后续的 Minor GC 之后,就在 s0 和 s1 两个区域来回移动,当年龄达到15岁(CMS默认是6岁)的时候,进入老年代
(新创建的对象年龄为 0,一次 Minor GC 之后仍然存活,年龄会增加1)
豆包:
新对象先在新生代的 Eden 区 “出生”;Eden 区满了,Minor GC 一清理,活下来的对象搬到 Survivor 的 From 区;下次 Eden 区又满了,再 GC,把 Eden 和 From 区活的对象搬到 To 区,同时年龄 + 1;之后 From 和 To 区互换角色,反复几次;如果对象年龄够大(比如到 15),就会被移到老年代;如果老年代也满了,就会触发 Major GC 甚至 Full GC,清理后还放不下,就 OOM 了。
- 对象在哪些情况下会进入老年代
豆包:
主要几种情况:
年龄够了:在 Survivor 区来回存活次数达到阈值(默认 15),就晋升到老年代;
大对象:超过设定大小的对象,直接进老年代(避免在新生代反复复制);
Survivor 区空间不够:Minor GC 后,Survivor 区放不下存活的对象,多余的会直接进老年代;
动态年龄判断:Survivor 区里同年龄的对象总和超过一半,那么大于等于这个年龄的对象都进老年代。
-
判断对象为垃圾的算法
1.判断对象为垃圾的算法
1)引用计数算法:指的是一个对象被引用一次就加1,当被引用次数为0的时候就认为该对象是垃圾。无法解决循环引用的问题,这样就会导致内存泄露
2)可达性分析算法:也称为根搜索算法,指的是 GC Roots 到这个对象有没有可达路径,如果没有则说明这个对象是垃圾 -
介绍下垃圾回收算法
1)标记-复制:将内存划分为大小相同的两块,每次使用其中一块,当这一块的内存使用完后,就将存活的对象复制到另外一块,然后把这块的内存区域清空。优点:没有内存碎片,缺点:内存利用率低
2)标记-清除:标记存活的对象,统一回收未被标记的对象。优点:实现起来简单,缺点:存在效率问题(标记和清除两个过程的效率都不高)和空间问题(标记清除后会产生大量不连续的碎片)
3)标记-整理:标记过程与标记清除算法一样,但后续过程是把存活的对象向一端移动,然后清理掉端边界以外的内存。优点:不会产生内存碎片、相对于复制算法来说,内存利用率高;缺点:移动对象开销大
4)分代收集:按照年代划分堆内存,不同的年代采用不同的算法,新生代一般采用复制算法,老年代一般采用标记-清除或者标记-整理算法 -
为什么新生代要用标记-复制、老年代要用标记-清除或者标记-整理
豆包:
因为新生代和老年代的对象特性不一样。新生代里的对象大多 “短命”,存活的少,标记 - 复制算法就是把活的对象复制到另一块区域,剩下的直接清掉,速度快,而且不会有内存碎片,适合这种场景;老年代里的对象存活久、数量多,如果用复制算法,要复制大量对象,开销太大。而标记 - 清除(标记要删的,直接清)或标记 - 整理(标记活的,移到一起再清)不用复制大量对象,更适合老年代,虽然标记 - 清除会有碎片,但老年代 GC 频率低,偶尔整理一下就行。
- 什么是 Minor GC、Major GC、Full GC
1)Minor GC(Young GC 新生代垃圾回收)
· 发生区域:主要针对新生代,特别是 Eden 区和其中一个 Survivor 区(From 区或 To 区)
· 触发时机:当新生代空间不足时,通常是因为 Eden 区被新对象填满
2)Major GC(老年代垃圾回收)
· 发生区域:主要针对老年代
· 触发时机:当老年代空间不足
3)Full GC(完整垃圾回收)
· 发生区域:涉及整个堆内存,包括新生代和老年代,有时还包括方法区
· 触发时机:老年代空间不足、方法区空间不足、手动调用 System.gc() 方法
由于Full GC会影响到整个堆内存,因此它会导致应用程序暂停时间显著增加,对响应时间敏感的应用可能造成用户体验下降。因此,优化GC策略以减少Full GC的发生是很重要的
豆包:
Minor GC:只清理新生代(Eden 和 Survivor 区)的 GC,发生频繁,速度快,因为新生代对象存活率低;
Major GC:一般指清理老年代的 GC,偶尔也会连带新生代一起清,速度慢,因为老年代对象存活率高;
Full GC:清理整个堆(新生代 + 老年代),甚至包括方法区,一般是老年代满了或者系统内存不够时触发,耗时最长,会导致程序卡顿,平时要尽量避免。
-
什么情况下会触发 Minor GC
-
什么情况下会触发 Full GC
-
介绍下垃圾回收器
· Serial + Serial Old(均属于单线程,Serial系列现在基本被淘汰)
· Parallel Scavenge + Parallel Old(均属于多线程)
· ParNew + CMS(ParNew是Parallel Scavenge的变种,做了一些增强,可以与CMS结合使用)
· CMS 收集器使用“标记-清除”算法实现,运作过程分为四个步骤:初始标记 -> 并发标记 -> 重新标记 -> 并发清除
CMS可以让垃圾收集线程与用户线程(基本上)同时工作 -
垃圾回收器用的什么算法
1)Serial、ParNew、Parallel Scavenge 使用标记复制算法
2)Serial Old、Parallel Old 使用标记整理算法
3)CMS 使用标记清除算法
4)G1 使用 -
说说你对内存泄漏的理解
5.内存泄露分析如何做?
1.内存泄露
指的是一个对象已经不再被使用了,但是这个对象没有被标记为垃圾,因此垃圾收集器就无法对其进行回收,这样的话一个无用的对象一直在占用内存空间。可能的后果是内存泄露随着日积月累,一些问题会暴露出来,比如频繁 gc 以及 oom
豆包:
内存泄漏就是程序里的对象明明已经没用了(不再被使用),但垃圾回收器就是收不掉它,导致它一直占着内存。时间久了内存越占越多,最后可能引发 OOM。比如 Java 里,长生命周期的对象(像静态集合)持有短生命周期对象的引用,就算短对象没用了,因为被静态集合牵着,GC 也没法回收,这就是典型的泄漏。
- OOM 如何排查和解决
2.内存溢出
指的是堆空间的老年代无法再存放对象了,导致发生 OutOfMemoryError。原理是这样的,一个普通对象一般存放在年轻代(大对象直接进入老年代),当经过多次垃圾回收之后,如果这个对象还存活着,一般是默认达到15岁之后进入老年代,而老年代相当于年轻代的担保空间,如果老年代的空间占满了,那么就会发生 OutOfMemoryError
四、OOM 的排查思路
1.OOM 产生的原因
1)堆内存设置太小,业务代码的运行确实是需要这么大的内存空间,但是物理内存不够或者是堆内存设置的太小,导致发生OOM,这种情况的处理方式就需要调大物理内存或者堆内存的大小
2)内存泄露
3)一次申请太多的对象,比如从数据库查询出大量的数据到内存中
4)内存资源耗尽,比如创建了太多线程,线程执行完之后,没有把资源释放
给出一个大致的思路,然后讲一个项目中的案例
2.定位方式:加一个 jvm 参数 -XX:+HeapDumpOnOutOfMemoryError,当发生OOM的时候会生成一个 hprof结尾的文件,可以根据这个文件来分析是哪些对象占用内存空间比较大以及内存溢出具体的代码位置。通过 jvisualvm 载入这个 dump 文件分析
3.jmap
豆包:
排查的话,首先得让程序抛出 OOM 时自动生成堆转储文件(通过-XX:+HeapDumpOnOutOfMemoryError配置),然后用 MAT、JProfiler 这些工具分析 dump 文件,看看哪些对象占内存最多,是不是有内存泄漏(比如某个集合无限增长)。解决的话,先看是不是泄漏:如果是,就找到泄漏点(比如没释放的引用),改代码;如果不是泄漏,可能是堆内存不够,那就调大堆大小(-Xms、-Xmx);也可能是新生代 / 老年代比例不合理,调整-XX:NewRatio之类的参数。
-
系统CPU经常100%,如何定位和调优(java 服务 CPU 使用率高)
1)找出哪个进程cpu高(top)
2)该进程中的哪个线程cpu高(top -Hp pid)
3)导出该线程的堆栈 (jstack)
4)查找哪个方法(栈帧)消耗时间 (jstack)
5)业务线程占比高 | 垃圾回收线程占比高 -
根搜索算法中的根节点可以是哪些对象(哪些对象可以作为GC Roots对象)
1)线程栈变量引用的对象
2)静态变量引用的对象
3)常量池引用的对象
4)JNI指针引用的对象 -
说说强、软、弱、虚引用?
豆包:
这四个是 Java 里对象引用的类型,主要区别是被 GC 回收的时机:
强引用:最普通的引用(比如Object o = new Object()),只要有强引用,GC 绝对不回收,OOM 了都不回收;
软引用:用SoftReference包装,内存够时不回收,内存不够要 OOM 了才会回收,适合做缓存;
弱引用:WeakReference包装,不管内存够不够,GC 一跑就回收,比如 ThreadLocal 里的 Entry 用了弱引用;
虚引用:PhantomReference,基本不用来存对象,主要用来跟踪对象被 GC 回收的时机,必须和引用队列一起用。
- 三色标记有听过吗?
豆包:
听过,是垃圾回收里标记存活对象的一种算法。把对象分成三种颜色:
白色:还没开始标记,默认都是白色;
灰色:自己被标记了,但它引用的对象还没标记完;
黑色:自己和引用的对象都标记完了,肯定是存活的。
这样 GC 就能高效遍历所有存活对象,避免漏标。不过可能有 “漏标” 问题(并发标记时对象引用变了),一般用 “增量更新” 或 “原始快照” 解决,CMS 和 G1 都用了类似思路。
- 常用的 JVM 配置参数有哪些?
豆包:
最常用的是堆内存设置:-Xms(初始堆大小)、-Xmx(最大堆大小,一般和-Xms设成一样避免频繁扩容);新生代相关:-Xmn(新生代大小)、-XX:SurvivorRatio(Eden 和 Survivor 的比例,比如 8 表示 Eden:S0:S1=8:1:1);垃圾收集器:-XX:+UseG1GC(用 G1)、-XX:+UseParallelGC(并行收集器);还有-XX:MetaspaceSize(元空间初始大小)、-XX:+HeapDumpOnOutOfMemoryError(OOM 时 dump 堆)这些。
- Servlet 的执行流程
豆包:
大概分几步:
浏览器发请求到 Web 服务器(比如 Tomcat),服务器解析请求,找到对应的 Servlet;
如果 Servlet 是第一次被访问,Tomcat 会加载它的类,创建实例,调用init()方法初始化;
然后 Tomcat 创建ServletRequest和ServletResponse对象,调用 Servlet 的service()方法处理请求;
service()里会根据请求方式(GET/POST)调用doGet()或doPost(),处理完把结果写到ServletResponse里;
服务器把响应返回给浏览器。Servlet 实例一般是单例的,多个请求共享一个实例。
-
IOC、DI
1)IOC:指的是控制反转,在没有 IOC 的情况下,对象通常自己负责管理和创建它所依赖的其它对象。有了 IOC 之后,对象的创建以及对象的依赖关系,反转给 Spring 容器来实现
2)DI:指的是依赖注入,是实现控制反转的一种方式
3)它们的好处是降低耦合 -
Spring 依赖注入有哪几种方式
1)构造器注入
2)setter 方法注入
3)字段注入 -
AOP
1)指的是面向切面编程,好处就是在不修改源代码的情况下,可以对类的功能进行增强
2)底层实现方式是动态代理,JDK动态代理以及 CGLIB 代理。AOP的应用方面,比如声明式事务,在开发中我们用的比较多的一个注解@Transactional,它的底层就是通过AOP来实现的
豆包:
AOP 就是面向切面编程,简单说就是把代码里重复的逻辑(比如日志、事务、权限校验)抽出来,单独写成 “切面”,然后在需要的地方(比如某个方法执行前 / 后)自动生效,不用在每个方法里都写一遍。比如想给所有接口加日志,不用每个接口方法里都写System.out,用 AOP 定义一个切面,指定拦截哪些方法,在方法执行时自动打印日志,这样代码更干净,也方便维护。
- Spring 中的 AOP
豆包:
Spring 的 AOP 是基于动态代理实现的:如果目标类有接口,就用 JDK 动态代理;如果没接口,就用 CGLIB 代理(生成子类)。核心概念有:
切面(Aspect):抽出来的公共逻辑(比如日志切面);
通知(Advice):切面里的方法,比如@Before(方法前执行)、@AfterReturning(方法返回后);
切点(Pointcut):指定哪些方法要被拦截(比如execution(* com.xxx.service.*(..)));
连接点(JoinPoint):可能被拦截的地方(比如方法执行、字段赋值),Spring 里主要关注方法执行。
平时用注解@Aspect定义切面,配合@Before、@Around这些注解用起来很方便。
- BeanFactory 和 FactoryBean 的区别
1)BeanFactory 是IOC容器的最顶层接口,定义了 Spring IOC 容器最基本的规范
2)如果 Bean 的创建过程比较复杂,那么就可以通过实现 FactoryBean 来实现
豆包:
BeanFactory是 Spring 的核心接口,是个 “工厂”,负责管理所有 Bean 的创建、初始化、依赖注入,比如ApplicationContext就是它的子类;
FactoryBean是个 “Bean 工厂” 接口,当你想自定义一个 Bean 的创建逻辑时,就实现它,比如 MyBatis 的SqlSessionFactoryBean。它的getObject()方法返回的才是真正的 Bean,Spring 会自动调用这个方法来获取 Bean。
- BeanFactory 和 ApplicationContext 的区别
豆包:
都是容器,ApplicationContext是BeanFactory的子接口,功能更强:
BeanFactory是懒加载,调用getBean()时才创建 Bean;ApplicationContext是启动时就初始化所有单例 Bean(除非设置懒加载);
ApplicationContext多了很多功能:支持国际化、事件发布、AOP 集成、资源访问(比如读文件),而BeanFactory只负责 Bean 的基础管理;
平时开发基本用ApplicationContext(比如ClassPathXmlApplicationContext),BeanFactory更底层,少用。
- ObjectFactory 是什么
豆包:
是 Spring 里的一个工厂接口,就一个getObject()方法,用来延迟获取对象。和FactoryBean有点像,但更简单:FactoryBean是 Spring 管理的 Bean,而ObjectFactory更像个普通的工厂类,常用来在需要的时候才创建对象,避免提前初始化。比如 Spring 在注入原型 Bean 时,可能用它来每次获取新实例。
-
Bean 的生命周期
1)Bean 的实例化
2)填充 Bean 的属性值
3)调用 Bean 生命周期相关的方法(调用初始化相关的方法)
· 调用 Aware 接口相关的方法
· 调用 BeanPostProcessor.before 方法
· 调用 afterPropertiesSet 方法(如果Bean实现了InitializingBean接口)
· 调用 init-method 方法
· 调用 BeanPostProcessor.after 方法
· 调用 Bean 销毁时相关的方法 -
Spring Bean 的作用域
豆包:
默认是单例(singleton),整个容器里就一个实例;还有原型(prototype),每次getBean()都新建一个;Web 环境里有request(每个请求一个实例)、session(每个会话一个)、application(整个 Web 应用一个);还有个globalSession,基本用在 Portlet 环境,现在很少见。
- 如何解决单例 Bean 的线程安全问题
豆包:
单例 Bean 本身是线程不安全的(因为多线程共享一个实例),解决办法看情况:
如果 Bean 是无状态的(没有成员变量,或者成员变量是常量),本身就安全,不用处理;
有状态的话,尽量避免用成员变量存可变数据,改成局部变量(每个线程私有);
必须用成员变量的话,加锁(synchronized或Lock),但会影响性能;
也可以把单例改成原型,但原型每次都新建,可能增加开销。
- Spring Bean 的单例是怎么实现的?
在 Spring 容器初始化的时候,Spring 会根据 Bean 的定义信息进行 Bean 的创建,然后把创建好的 Bean 放入容器中,这个容器一般是一个 Map,要获取单例时候根据 Bean 的名称或者 Bean 的 Class 对象获取就可以。实际上 Spring 的单例模式可以理解为是容器式单例
豆包:
Spring 的单例是 “容器级” 的,一个容器里一个 Bean 对应一个实例。核心是通过BeanFactory里的缓存实现的:创建 Bean 时,先查缓存(比如singletonObjects这个 Map),如果有就直接返回;没有就创建,创建完放到缓存里。具体步骤是:在getBean()方法里,通过getSingleton()方法检查缓存,没命中的话,调用createBean()创建,然后存入缓存。这样后续获取都是同一个实例。
- Spring 循环依赖
1)什么是循环依赖
对象之间发生相互依赖
2)如何解决循环依赖:通过三级缓存解决,在二级缓存中提前暴露对象
一级缓存:Map<String, Object> singletonObjects,存放实例化、初始化都完成的对象
二级缓存:Map<String, Object> earlySingletonObjects,存放实例化完成、初始化未完成的对象
三级缓存:Map<String, Object> singletonFactories,对象工厂
3)以A依赖B,B依赖A为例,循环依赖的情况下创建对象的整个过程
· 创建对象A,实例化的时候把A对象工厂放入三级缓存,A填充属性时,发现依赖B,转而去实例化B
· 同样创建对象B,注入属性时发现依赖A,依次从一级到三级缓存查询A,从三级缓存通过对象工厂拿到A,把A放入二级缓存,同时删除三级缓存中的A,此时,B已经实例化并且初始化完成,把B放入一级缓存
· 接着A继续填充属性,顺利从一级缓存拿到实例化且初始化完成的B对象,A对象创建也完成,删除二级缓存中的A,同时把A放入一级缓存,最后,一级缓存中保存着实例化、初始化都完成的A、B对象
(关键点在于把实例化和初始化两个过程分开了,然后提前把对象暴露。构造器方式的循环依赖无法解决,因为它的实例化和初始化是捆绑在一起的,没有办法分开)
4)为什么要三级缓存?二级不行吗
· 如果单单是为了解决循环依赖,二级缓存是可以的。使用三级缓存主要是为了生成代理对象
· 因为三级缓存中放的是生成具体对象的匿名内部类,获取Object的时候,它可以生 成代理对象,也可以返回普通对象。使用三级缓存主要是为了保证不管什么时候使用户的都是一个对象
· 假设只有二级缓存的情况,往二级缓存中放的显示一个普通的Bean对象,Bean初始化过程中,通过 BeanPostProcessor 去生成代理对象之后,覆盖掉二级缓存中的普通Bean对象,那么可能就导致取到的Bean对象不一致了
5)循环依赖的检测方法:给对象打一个标记,如果递归回来的时候发现这个对象正在创建中,则说明产生了循环依赖
- Spring 事务失效(@Transactional 没起作用)
1)@Transactional 作用于非 public 方法时将不起作用,通过源码可以知道,当 @Transactional 作用于非 public 方法时,将不会创建代理对象或者不会对方法进行代理调用
2)方法自调用导致事务失效,是因为方法自调用是通过 this 对象调用而不是通过代理对象调用,则事务肯定无法发挥作用,因为 Spring 中事务的实现是基于动态代理。而通过从 Spring 容器中获取的对象调用加了事务的方法,事务会生效是因为从 Spring 容器中获取的对象是代理对象,则就会走动态代理的逻辑,因此事务生效。场景描述:a方法调用了加了事务的b方法,b方法事务会失效
3)抛出的异常是非 RunTimeException,因为 Spring 默认回滚 RunTimeException 抛出的异常
4)方法内部的异常被捕获,导致事务无法回滚
- @Transactional 的实现原理
豆包:
基于 AOP 实现的,属于 “声明式事务”。Spring 在启动时,会扫描带有@Transactional的类或方法,生成代理对象;当调用被注解的方法时,代理对象会先开启事务,然后执行原方法;如果方法正常返回,就提交事务;如果抛出异常(非检查异常,默认),就回滚事务。底层用了TransactionInterceptor这个拦截器,里面调用了PlatformTransactionManager(事务管理器)来处理具体的开启、提交、回滚操作。
- Spring 中的事务传播行为
1.Spring事务的传播机制主要是用来处理多个事务方法相互调用时的事务行为。当一个事务方法内部调用另一个事务方法时,Spring框架会根据事务的传播行为来决定如何处理这些事务。主要有以下七种
2.详解
1)Propagation.REQUIRED:表示当前方法必须运行在事务中。如果当前事务存在,方法将会在该事务中运行,否则,会在该方法中启动一个新的事务
2)Propagation.SUPPORTS:表示当前方法不需要事务上下文。但是如果存在当前事务的话,则该方法会在这个事务中运行
3)Propagation.MANDATORY:表示该方法必须在事务中运行。如果当前事务不存在,则会抛出一个异常
4)Propagation.REQUIRES_NEW:表示当前方法必须运行在它自己的事务中。一个新的事务将被启动。如果存在当前事务,在该方法执行期间,当前事务会被挂起。如果使用JTATransactionManager的话,则需要访问TransactionManager
5)Propagation.NOT_SUPPORTED:表示该方法不应该运行在事务中。如果存在当前事务,在该方法运行期间,当前事务将被挂起。如果使用JTATransactionManager的话,则需要访问TransactionManager
6)Propagation.NEVER:表示当前方法不应该运行在事务上下文中。如果当前正有一个事务在运行,则抛出异常
7)Propagation.NESTED:表示如果当前已经存在一个事务,则该方法将会在嵌套事务中运行,嵌套的事务可以独立于当前事务进行单独地提交或回滚;如果当前事务不存在,则其行为与Propagation.REQUIRED一样。注意各厂商对这种传播行为的支持是有所差异的,可以参考资源管理器的文档来确认它们是否支持嵌套事务
- Spring 中的事务隔离级别
Spring 的接口 TransactionDefinition 中定义了表示隔离级别的常量,当然其实主要还是对应数据库的事务隔离级别
1)DEFAULT(默认):使用数据库默认的事务隔离级别。通常为数据库的默认级别,如MySQL默认为REPEATABLE READ,Oracle默认为READ COMMITTED
2)READ_UNCOMMITTED(读未提交):允许读取未提交的数据。事务可以读取其他事务未提交的数据,可能会导致脏读、不可重复读和幻读的问题
3)READ_COMMITTED(读已提交):确保一个事务只能读取到已提交的数据。避免了脏读问题,但可能会出现不可重复读和幻读的问题
4)REPEATABLE_READ(可重复读):确保一个事务在执行期间多次读取同一数据时,数据保持一致。避免了脏读和不可重复读问题,但可能会出现幻读问题
5)SERIALIZABLE(串行化):最高的隔离级别,确保事务串行执行,避免了脏读、不可重复读和幻读问题。但会对并发性能产生较大的影响
-
@Autowired 和 @Resource 的区别
1)@Autowired 只能通过类型进行装配,如果有接口多个实现类,可以结合@Qualifier使用,指定要注入哪个类型的Bean
2)@Resource 有两个属性是比较重要的,分是 name 和 type,Spring 将 @Resource 注解的 name 属性解析为 bean 的名字,type 属性则解析为 bean 的类型
· 如果同时指定了name和type,则从Spring上下文中找到唯一匹配的bean进行装配,找不到则抛出异常
· 如果指定了name,则从上下文中查找名称(id、Bean的唯一标识)匹配的bean进行装配,找不到则抛出异常
· 如果指定了type,则从上下文中找到类型匹配的唯一bean进行装配,找不到或者找到多个,都会抛出异常
· 如果既没有指定name,又没有指定type,则默认按照byName方式进行装配;如果没有匹配,则回退为按照byType方式进行装配 -
Spring AOP 和 AspectJ 有什么区别?
AspectJ 是通过修改字节码的方式实现 AOP,没有生成代理对象;Sprng AOP 是通过动态代理的方式。生成了代理对 -
@Controller 和 @Component 有什么区别
豆包:
都是 Spring 的注解,用来标识 Bean,让 Spring 扫描并管理:
@Component是通用注解,所有组件都能用;
@Controller是@Component的特例,专门用在 MVC 的控制层(处理请求的类),Spring MVC 会识别它,还能配合@RequestMapping等注解处理请求。
简单说,@Controller比@Component多了 MVC 层的特定功能,语义更明确。
-
Spring 依赖注入时如何注入集合属性
-
Spring 中的自动装配有哪些限制
豆包:
Spring 中的自动装配有哪些限制
自动装配(比如@Autowired)虽然方便,但有局限:
只能装配 Spring 管理的 Bean,非 Spring 创建的对象不行;
如果有多个同类型的 Bean,会报歧义错误(需要用@Qualifier指定名字);
基本类型(int、String 等)不能自动装配,得用@Value;
构造方法过多时,自动装配可能分不清用哪个构造器;
循环依赖时,构造器注入的自动装配会出问题(字段注入或 setter 注入可以解决)。
- Spring MVC 有哪些核心组件
1)DispatcherServlet:前端控制器或者中央控制器,负责接收 HTTP 请求并将请求分发给相应的控制器(Controller)进行处理。同时也是 Spring MVC 整个工作流程的控制中心,控制其他组件的执行,进行统一调度,降低组件之间的耦合性,相当于总指挥
2)HandlerMapping:处理器映射器,保存请求和处理器的映射关系。DispatcherServlet 接收到请求之后,通过 HandlerMapping 将请求映射到对应的 Handler,通常是根据请求的URL、请求方法(POST、GET等)等信息,确定请求应该由哪个处理器处理。Spring MVC 提供了多种 HandlerMapping 的实现,如 BeanNameUrlHandlerMapping 和 RequestMappingHandlerMapping
3)Handler:处理器,Handler 是实际处理请求的组件,完成具体的业务逻辑并返回处理结果。Handler 可以是使用 @Controller、@RestController 或 @RequestMapping 注解标记的类,也可以是实现特定接口如 HttpRequestHandler 的类,或者是其他任何可以由 DispatcherServlet 识别并处理请求的对象
4)HandlerAdapter:处理器适配器,HandlerAdapter 负责调用处理器的具体方法,并返回一个 ModelAndView 对象。为什么需要 HandlerAdapter,对于不同的处理器,其实现方式是不一样的,因此需要进行“适配”,不同的处理器类型对应不同的处理器适配器,例如 @Controller、或 @RequestMapping 类型的处理器就使用 RequestMappingHandlerAdapter,HttpRequestHandler 类型的处理器就使用 HttpRequestHandlerAdapter
5)ModelAndView:模型数据和视图信息,Handler 的处理结果封装在 ModelAndView 中
6)ViewResolver:视图解析器,负责将处理结果(ModelAndView)解析为具体的视图。视图解析器的具体实现有 InternalResourceViewResolver、FreeMarkerViewResolver 等(现在基本都是前后端分离,后端返回 json 数据给前端,无需视图解析器的参与)
7)其它的有:Interceptor、ExceptionResolver 等
-
讲一下 SpringMVC 的工作流程
1)请求先到达 DispatcherServlet
2)DispatcherServlet 接收到请求后,根据请求信息从 HandlerMapping 中找到对应的处理器,返回一个执行链
3)DispatcherServlet 将请求交给 HandlerAdapter 执行处理器的业务逻辑
4)处理器执行业务逻辑,处理结果封装到 ModelAndView 对象中,其中包含模型数据和逻辑视图名,并返回给 HandlerAdapter
5)HandlerAdapter 将 ModelAndView 返回给 DispatcherServlet
6)DispatcherServlet 根据 ModelAndView 中的视图信息,通过视图解析器找到对应的视图
7)DispatcherServlet 将 ModelAndView 中的模型数据传递给 View
8)View 将模型数据和视图模板结合,生成最终的响应结果
9)DispatcherServlet 将最终的响应结果返回给客户端 -
SpringMVC 过滤器和拦截器的区别
豆包:
出身不同:过滤器(Filter)是 Servlet 规范里的,依赖 Web 容器;拦截器(Interceptor)是 SpringMVC 自己的,不依赖容器;
执行时机不同:过滤器在请求进入 Servlet 之前执行,拦截器在 DispatcherServlet 之后、Controller 之前执行;
功能范围不同:过滤器能处理所有请求(包括静态资源),拦截器只处理 SpringMVC 的请求(经过 DispatcherServlet 的);
调用次数不同:过滤器在一次请求中只调用一次,拦截器可以在preHandle、postHandle、afterCompletion调用多次。
-
SpringBoot的优点
1)去xml化、内置web容器、自动配置、starter(启动依赖)
2)像传统的ssm项目,需要大量的xml样板式配置,项目相对比较笨重,而Springboot就进行了去xml化
3)内置web容器,不需要再安装tomcat之类的web容器,直接使用main方法来启动jvm进程 -
SpringBoot的启动流程
-
SpringBoot自动配置的原理
SpringBoot启动的时候会遍历类路径下的每个jar文件,通过@EnableAutoConfiguration注解找到META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载,而这些自动配置类都是以AutoConfiguration结尾来命名的,它实际上就是一个JavaConfig形式的Spring容器配置类,它能通过以Properties结尾命名的类中取得在全局配置文件中配置的属性如:server.port,而XxxProperties类是通过@ConfigurationProperties注解与全局配置文件中对应的属性进行绑定的 -
如何编写一个 starter 组件
豆包:
Starter 就是把一组功能封装好,让别人引入依赖就能用,步骤大概:
新建 Maven 项目,引入spring-boot-autoconfigure依赖;
写核心功能类(比如自定义的服务类);
写自动配置类(用@Configuration),在里面定义 Bean,用@Conditional注解控制 Bean 的创建条件(比如类存在才创建);
在src/main/resources/META-INF下建spring.factories文件,配置自动配置类的全路径(让 Spring Boot 能扫描到);
打包发布,别人引入这个依赖,Spring Boot 就会自动配置好相关 Bean。
-
Mybatis #{}和${}的区别是什么?
1)#{}是占位符?,SQL预编译;${}是拼接符,字符串替换(字符串拼接)
2)Mybatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值
3)Mybatis在处理${}时,就是把${}替换成变量的值
4)使用#{}可以有效的防止 SQL 注入,提高系统安全性;${}一般用于传入数据库对象,例如传入表名
5)使用 ${} 时,要注意何时加或不加单引号,即 ${} 和 '${}',例如传入表名时可以不加单引号 -
MySQL 有哪些存储引擎及其区别(至少5点)
1)MyISAM
· 只支持表锁
· 不支持事务
2)InnoDB
· 支持表锁和行锁
· 支持事务
豆包:
常用的有 InnoDB、MyISAM,还有 Memory、CSV、Archive 等,区别:
事务支持:InnoDB 支持事务(ACID),MyISAM 不支持;
锁粒度:InnoDB 支持行级锁,MyISAM 只有表级锁(并发差);
外键:InnoDB 支持外键,MyISAM 不支持;
索引结构:InnoDB 主键是聚簇索引,MyISAM 都是非聚簇索引;
崩溃恢复:InnoDB 有 redo 日志,崩溃后能恢复,MyISAM 恢复困难;
存储文件:InnoDB 有 frm(表结构)、ibd(数据 + 索引),MyISAM 有 frm、MYD(数据)、MYI(索引)。
- 索引的使用经验
1)要选择 where、join(on)、order by、group by 子句后面的字段建立索引
2)过长的字段建前缀索引,即对字段的前面少部分字符进行索引,可以节省空间
CREATE INDEX index_name ON table_name (column_name(length))
3)如果使用多条件查询,可以考虑创建组合索引。而创建组合索引也有一些注意点
· 如果除了根据字段 a 和字段 b 查询,还会根据字段 a 单条件查询,为了满足最左前缀匹配原则,建索引时应该把字段 a 放在前面
· 如果不考虑1),则建索引时应该把区分度高的字段放在前面
4)能使用覆盖索引的就使用覆盖索引,因为覆盖索引可以减少回表的次数
5)避免用无序的值,如身份证号码、UUID,作为主键索引
6)一个表中索引的个数不要过多,要注意控制索引的数量,因为索引需要占用存储空间,并在更新时需要进行索引维护操作
7)数据量较少的表没必要建索引,在这种情况下,使用索引可能不会带来显著的性能提升,并且会占用额外的存储空间
8.哪些字段不适合创建索引
1)区分度底的字段,例如性别字段、布尔类型字段或者枚举字段。因为区分度低,经过索引查找之后可能还是有大量符合条件的数据
2)更新比较频繁的字段,例如记录的状态字段或计数字段,这些字段的频繁更新会伴随的索引结构的更新
3)范围查询频繁的字段 :如果某个字段需要频繁进行范围查询,例如日期范围查询或价格范围查询,添加索引可能无法有效地支持范围查询操作,因为索引是按照值的顺序进行排序的,而范围查询需要跳跃式地访问索引(存疑)
- 索引失效的情况
1)like 查询的前面带 %,可以考虑使用全文索引优化
2)如果使用了 or 关键字,即使它前面和后面的字段都加了索引也可能会导致索引失效。这是因为 OR 操作符的存在会导致数据库无法利用索引的有序性进行快速定位。优化方式:
· 改成 union 或者 union all,分成多个 sql,走各自的索引
· 考虑能不能使用覆盖索引
ALTER TABLE user8 ADD INDEX idx_a_b_c(a, b, c) USING BTREE;
explain select c from user8 where a = 'jack1' or b = 'jack2';(Extra 字段值有 Using index)
3)字段类型不匹配,索引列是字符串类型,查询条件用数字类型。这是因为发生了字段类型的隐式转换(如果索引列是 int 类型,查询条件用字符串类型,则不会失效)
4)对索引列进行运算,如果在查询条件中对索引列进行运算,例如使用 +、-、*、/等运算符,索引可能无法被使用
select * from user where id+1=2;
5)在索引列上使用函数。因为索引是按照列值进行存储和排序的
select * from user where SUBSTR(height, 1, 2)=17;
6)在索引列上使用不等于(!= 或 <>)和 NOT IN 操作符
7)在索引列上使用 IS NULL 或 IS NOT NULL 操作符
8)如果使用了联合索引,但查询条件中的列不是联合索引的第一个列,索引可能无法被有效利用
9)关联字段编码格式不一致 :在使用左连接或右连接进行关联查询时,如果关联字段的编码格式不一致,索引可能无法被使用
10)优化器估计全表扫描更快:数据库优化器会根据统计信息和查询成本估计选择使用索引还是全表扫描,如果优化器认为全表扫描更快,可能选择不使用索引
- 索引是越多越好吗?
索引并不是建得越多越好。建立过多的索引会增加存储空间占用和维护开销,降低数据库的写入性能。此外,过多的索引也会导致查询性能下降。因此,在建立索引时需要综合考虑查询需求和性能优化,选择适当的索引来提高查询效率
豆包:
不是,索引太多反而不好:
索引会占额外的磁盘空间,越多占得越多;
插入、更新、删除数据时,不仅要改数据,还要维护索引(比如 B + 树调整),索引越多,这些操作越慢;
查询时,MySQL 优化器要选哪个索引,索引太多可能导致优化器选错,反而影响查询效率。
所以索引要按需建,只给常用的查询字段(比如 where、order by、join 的字段)建索引。
- mysql 索引的数据结构
1)主要的数据结构有两种 B+Tree 和 HASH(InnoDB 和 MyISAM 都支持使用 B+Tree 和 HASH)
2)为什么MySQL的索引要使用B+树而不是其它树形结构?比如二叉树、红黑树、B树
因为B树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能变低
豆包:
主要用 B + 树,还有哈希索引(Memory 引擎支持)、全文索引等。
B + 树是多路平衡查找树,特点:
叶子节点用链表连起来,方便范围查询(比如between、order by);
非叶子节点只存索引键,不存数据,能存更多索引,树的高度低,查询快;
所有数据都存在叶子节点,查询效率稳定(每次都查到底部)。
哈希索引适合等值查询(=),但不支持范围查询和排序,所以 InnoDB 默认用 B + 树。
- 什么是覆盖索引
指的是我们想要查找的数据刚好在二级索引中有,这样的话就不需要再回到聚集索引中查找数据,相当于少扫描了一颗索引树。ALTER TABLE user3 ADD INDEXindex_name_age(name,age) USING BTREE; explain selectname,age from user3 wherename= 'aaa' and age = 2,通过 explain 查出来的 extra 字段为 Using index,则表示走了覆盖索引
豆包:
查询的字段刚好都在索引里,不用回表查数据。比如给name和age建了联合索引,查询select name, age from user where name='xxx',这时候索引里已经有name和age,直接从索引拿数据,不用去主键索引查整行,速度快。
- 什么是回表
先在二级索引中找到主键索引的键值,再通过主键值回到聚集索引中查找数据,这个过程就是回表
豆包:
InnoDB 里,二级索引(非主键索引)的叶子节点存的是主键值。如果查询的字段不在二级索引里,就需要先查二级索引拿到主键,再用主键去主键索引(聚簇索引)查整行数据,这个过程就是回表。比如给name建了索引,查询select * from user where name='xxx',因为*包含其他字段,所以查完name索引后,还要用主键回表查所有数据。
- 什么是最左匹配原则?
豆包:
针对联合索引的,查询时要从索引的最左边开始匹配,不能跳过左边的字段。比如联合索引(a, b, c),能匹配a、a+b、a+b+c的查询,但b、b+c、a+c这些就用不上这个索引(除非a是常量)。比如where b=1不会走索引,但where a=1 and c=2会用a部分的索引。
- 什么是索引下推?
ICP索引条件下推,指的是在存储引擎层进行数据过滤,而不是在服务层过滤,利用索引现有的数据减少回表的数据
豆包:
MySQL 5.6 + 的功能,用来优化回表操作。在使用二级索引查询时,会把过滤条件(比如where a=1 and b>2中的b>2)下推到存储引擎层,在索引遍历的时候就过滤掉不符合条件的记录,减少回表的次数。比如联合索引(a, b),查询where a=1 and b>2,没有索引下推的话,会先查所有a=1的索引,然后全部回表再过滤b>2;有了下推,在查索引时就先过滤b>2,只回表符合条件的,效率更高。
- 慢 SQL 优化思路
1)慢查询日志记录慢SQL
· show variables like 'slow_query_log%',两个结果字段,slow query log:表示慢查询开启的状态,slow_query_log_file:表示慢查询日志存放的位置
· show variables like 'long_query_time',查看超过多少时间,才记录到慢查询日志
2)explain 分析SQL的执行计划
· 需要关注的字段:type、rows、filtered、extra、key
· type:system > const > eq_ref > ref > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
system:这种类型要求数据库表中只有一条数据,是const类型的一个特例,一般情况下是不会出现的
const:通过一次索引就能找到数据,一般用于主键或唯一索引作为条件,这类扫描效率极高,速度非常快
eq_ref:常用于主键或唯一索引扫描,一般指使用主键的关联查询
ref : 常用于非主键和唯一索引扫描
ref_or_null:这种连接类型类似于ref,区别在于MySQL会额外搜索包含NULL值的行
index_merge:使用了索引合并优化方法,查询使用了两个以上的索引
unique_subquery:类似于eq_ref,条件用了in子查询
index_subquery:区别于unique_subquery,用于非唯一索引,可以返回重复值
range:常用于范围查询,比如:between ... and 或 in 等操作
index:全索引扫描
ALL:全表扫描
· rows:表示MySQL估算要找到我们所需的记录,需要读取的行数。对于InnoDB表,此数字是估计值,并非一定是个准确值
· filtered:该列是一个百分比的值,表里符合条件的记录数的百分比。简单点说,这个字段表示存储引擎返回的数据在经过过滤后,剩下满足条件的记录数量的比例
· extra:该字段包含有关MySQL如何解析查询的其他信息,它一般会出现这几个值:
Using filesort:表示按文件排序,一般是在指定的排序和索引排序不一致的情况才会出现。一般见于order by语句
Using index :表示是否用了覆盖索引
Using temporary: 表示是否使用了临时表,性能特别差,需要重点优化。一般多见于group by语句,或者union语句
Using where : 表示使用了where条件过滤.
Using index condition:MySQL5.6之后新增的索引下推。在存储引擎层进行数据过滤,而不是在服务层过滤,利用索引现有的数据减少回表的数据
· key:该列表示实际用到的索引。一般配合 possible_keys 列一起看
- SQL 优化经验
1)尽量使用 join 代替子查询
因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,临时表的创建和销毁会占用一定的系统资源以及花费一定的时间,同时对于返回结果集比较大的子查询,对性能的影响更大
2)小表驱动大表
关联查询的时候要拿小表驱动大表,因为关联查询的时候,MySQL内部会遍历驱动表,再去连接被驱动表
select name from 小表 left join 大表
3)适当增加冗余字段
减少表的关联查询,这是以空间换时间的优化策略
4)深分页问题(MySQL 深度分页有什么解决思路?)
· 深分页慢的原因:扫描更多的行数
· 优化:记录上一次查询的id,比如上次查询的 id 的末尾值是100000,则下次查询的时候 where 条件带上 id > 100000
-
如果SQl没办法很好优化,可以改用ES的方式
有一些场景确实没办法通过sql层面再去优化,像我们之前有做过一个项目,它前端的查询条件很多,数据库需要连接很多表去查询,并且数据量是比较大的,索引该加的也加了,查询还是很慢,sql本身已经没有优化的空间,这个时候我们设计了另外一种优化方案,用es来辅助查询提高性能。实现方式是这样的,把mysql的数据同步到es,我们同步数据的话呢,也不是把mysql所有的数据都给搬到es,我们只同步查询条件的字段,比如前端界面的查询条件框有10个字段,那么我们就只同步这10个字段和id字段。前端发起查询的时候直接查es,然后从es查询出符合条件的id,再用 id 去 mysql 查询对应的详情信息。
如果同步到es失败,我们会捕获异常,然后把异常信息以及需要同步到es的数据记录到一个异常日志表,然后我们会起一个定时任务定时的扫描这个异常表进行数据同步的重试,那如果重试的次数达到一定的次数之后,这个时候就会通知开发人员介入定位问题,怎么通知开发人员的,发一条消息到工作群,比如说钉钉群,实际上这也是一种运维,属于业务上的运维 -
事务的 ACID 特性(事务的四个特性)
1)原子性:事务里面的操作单元不可切割,要么全部成功,要么全部失败
2)一致性:事务执行的前后数据的完整性保持一致 (比如转账前后money的总数保持不变)
3)隔离性:一个事务执行的过程中,不应该受到其他的事务的干扰
4)持久性:事务一旦提交,数据就永久保持到数据库中
