@Spring解决循环依赖

目录

经典面试题引入:

JVM在垃圾回收中应用的是啥算法?

引用计数算法

可达性分析算法

GC Roots

可达性分析算法的优缺点

为啥一定要STW(Stop The World)

注意点:不可达的对象并非"非死不可"

可达性分析算法与整个垃圾回收阶段的关系

JVM的执行过程

垃圾收集器

CMS垃圾回收器

G1垃圾回收器

垃圾回收器的使用

再谈引用

1.强引用(StrongReference)

2.软引用(SoftReference)

3.弱引用(WeakReference)

4.虚引用(PhantomReference)

虚引用与软引用和弱引用的一个区别在于:

Spring是如何解决循环依赖的?

什么是循环依赖

Bean的生命周期回顾

循环依赖的问题

Spring支持的循环依赖

Spring解决循环依赖

为什么要使用三级缓存  

AOP和循环依赖的那些事?

面试题及回答


经典面试题引入:

面试官:GC垃圾回收机制如何判断垃圾对象?

回答:引用计数法和可达性分析算法...........(准备介绍概念中

面试官:(打断我)如果有这样一个场景,new了一个线程,什么也不做,那么它会被回收吗?回收的话是谁引用了它?如果是可达性分析算法,那么在springboot中,serviceA注入了serviceB,serviceB注入了serviceA,那么他们会被回收吗?如果会被回收,那么单例不是每次都要创建吗?你如果是springboot的开发者,你怎么处理它?

面试题分析:这道面试题就很有深度了,简单的GC垃圾回收算法谁都知道是咋莫回事,现在结合常用的SpringBoot框架,就成为一道很有深度的面试题,下面深入探究一下!

JVM在垃圾回收中应用的是啥算法?

垃圾回收通常会分为两个阶段:垃圾标记阶段和垃圾清除阶段。而引用计数算法和可达性分析算法作用的是垃圾标记阶段;有了如何判断对象存活的基础,接下来的问题就是进行垃圾收集GC,而现在商用的虚拟机基本上都是分代收集的实现。

引用计数算法

在对象中添加一个引用计数器,每当一个地方引用它时,计数器就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

但是引用计数算法有一个问题,就是不能解决循环依赖的问题。

Object 1和Object 2其实都可以被回收,但是它们之间还有相互引用,所以它们各自的计数器为1,则还是不会被回收。所以,Java虚拟机没有采用引用计数法,而采用的是可达性分析算法。

可达性分析算法

从根集合(Root Set)里面的每一个GC Roots的引用关系遍历GC堆的对象图,遍历的路径称为“引用链”,如果GC堆里面某个对象到Root Set没有引用链,就称为该对象不可达,对象已经死亡。类似如下图:

GC Roots

GC Roots是根可达算法的基本,可以被作为GC Roots的对象有如下四大类:

  • ①虚拟机栈中每个栈帧中局部变量表里面的引用对象,如方法的入参,局部变量等
  • ②元空间中类静态属性引用的对象
  • ③元空间运行时常量池中常量引用的对象,如:字符串常量池引用的对象
  • ④本地方法栈中JNI(native方法)中引用的对象

除开上述中的四大类对象可以被作为根节点外,也包括被synchronized持有的对象、JVM内部的对象、异常对象(NullPointerException、OutOfMemoryError),基本数据类型的 Class 对象,系统类加载器以及反应 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等都可以作为根节点对象。由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那么它就可以被看作为一个根节点。

可达性分析算法的优缺点

优点:
   1. 可以解决环状引用;
   2. 占用对象空间少,标记过程只需要在对象头设置标记位;
缺点:
  1. STW(Stop The World),这是一个很严重的问题,目前来说,所有的追踪过程必须要STW;

为啥一定要STW(Stop The World)

如果要使用可达性分析算法来进行垃圾标记,那么就必须保证在整个可达性分析过程当中,系统必须处于一个能够保障一致性的内存快照中进行。什么意思呢?就是在可达性分析过程中,不能有用户线程更新对象间的引用关系,否则可达性分析算法的分析结果的准确性就无法保证了。 因此在可达性分析算法的工作当中,会暂停所有的用户线程,也就是”Stop The World“,简称 STW。即使是号称几乎不会发生停顿的并发收集器中,枚举根节点也是必须要停顿的。

注意点:不可达的对象并非"非死不可"

即使在可达性分析法中不可达的对象,也并非是"非死不可"的,这时候它们暂时处于"缓刑阶段",要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize() 方法。当对象没有覆盖 finalize() 方法,或 finalize() 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。(但是不建议使用finalize()方法)

其实可以发现要想真正理解可达性分析算法,还是要结合整个垃圾回收阶段去学习的,接下来就结合整个垃圾回收阶段进行学习!

可达性分析算法与整个垃圾回收阶段的关系

从判断对象是否死亡的角度出发,垃圾收集算法可以分为"引用计数式垃圾收集"和"追踪式垃圾收集",而“追踪式垃圾收集”中有标记-清除(Mark-Sweep)、标记-复制(Mark-Copy)、标记-整理(Mark-Compact),这三种垃圾收集算法虽然处理方式各不相同,但是处理的第一阶段却是一样,追踪(trace)阶段,此阶段正是使用的可达性分析算法进行遍历和标记(Mark)。

JVM的执行过程

在JVM中判断一个对象是否存活,依旧要经历下面几个过程:

  1. 第一次标记:
      当该对象进行可达性分析后,发现该对象不可达,将会对该对象进行第一次标记。
  2. 条件筛选:
      在进行完第一次标记后,会对对象进行筛选,筛选条件是有没有必要执行finalize()方法,如果该对象没有覆盖finalize()方法,或者该对象的finalize()方法被执行过(任何一个对象的finalize()方法都只会被系统自动调用一次),则认为该对象可以直接回收。
  3. 加入队列:
      在条件筛选完成之后,被认为不能直接回收的对象将会被加载到一个F-Queue的队列中。
  4. 执行方法:
      在加入F-Queue之后,会有一条虚拟机自己建立的、低调度优先级的Finalizer线程去执行(触发)这些对象的finalize()方法,在finalize()方法中是这些对象存活的最后机会,只要和任意一条引用链连接上,都不会被回收。
  5. 第二次标记:
      在执行完finalize()方法之后,收集器将会对F-Queue中没有与引用链关联上的对象进行二次标记。
  6. 对象回收:
      对二次标记的对象或者在条件筛选判定为可以直接回收的对象进行回收。

垃圾收集器

CMS垃圾回收器

MS收集器是一种老年代区域的垃圾收集器,往往配合ParNew 收集器来使用。
它适合在注重用户体验的应用上使用,实现了GC线程和用户线程并发进行(部分步骤)。
采用了标记-清除算法。回收过程大致分为4个步骤:

  1. 初始标记暂停其他线程(STW),标记GC roots 直接引用的对象。过程很快
  2. 并发标记:从GC roots 直接引用的对象出发向下查找所有引用的对象。这个过程最耗时,和用户线程并发执行不停顿。【这个过程可能出现的问题:用户线程执行中可能产生新的垃圾(浮动垃圾),无法被标记。】
  3. 重新标记:为修正 并发标记 中产生变动的对象标识,主要用三色标记中的增量更新算法来进行标记。这个过程会暂停其他进程(STW);时间比初始时间要长一点。
  4. 并发清除:清理掉标记阶段标记的死亡的对象,和用户线程并发执行。【*这个过程也会产生新垃圾对象(浮动垃圾),这些对象将在下次GC时回收】。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

由于CMS以上特性,缺点也是比较明显的:

  • Mark Sweep算法会导致内存碎片比较多
  • CMS的并发能力依赖于CPU资源,所以在CPU数少和CPU资源紧张的情况下,性能较差
  • 并发清除阶段,用户线程依然在运行,所以依然会产生新的垃圾,此阶段的垃圾并不会再本次GC中回收,而放到下次。所以GC不能等待内存耗尽的时候才进行GC,这样的话会导致并发清除的时候,用户线程可以了利用的空间不足。所以这里会浪费一些内存空间给用户线程预留。

G1垃圾回收器

G1,Garbage First,在JDK 1.7版本正式启用,是当时最前沿的垃圾收集器。G1可以说是CMS的终极改进版,解决了CMS内存碎片、更多的内存空间登问题。虽然流程与CMS比较相似,但底层的原理已是完全不同。

高效益优先。G1会预测垃圾回收的停顿时间,原理是计算老年代对象的效益率,优先回收最大效益的对象。

堆内存结构的不同。以前的收集器分代是划分新生代、老年代、持久代等。

G1则是把内存分为多个大小相同的区域Region,每个Region拥有各自的分代属性,但这些分代不需要连续。

这样的分区可以有效避免内存碎片化问题。

但是这样同样会引申一个新的问题,就是分代的内存不连续,导致在GC搜索垃圾对象的时候需要全盘扫描找出引用内存所在。

为了解决这个问题,G1对于每个Region都维护一个Remembered Set,用于记录对象引用的情况。当GC发生的时候根据Remembered Set的引用情况去搜索。

两种GC模式

  • Young GC,关注于所有年轻代的Region,通过控制收集年轻代的Region个数,从而控制GC的回收时间。
  • Mixed GC,关注于所有年轻代的Region,并且加上通过预测计算最大收益的若干个老年代Region。

整体的执行流程:

  • 初始标记(initial mark):标记了从GC Root开始直接关联可达的对象。STW执行。
  • 并发标记(concurrent marking):和用户线程并发执行,从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。
  • 最终标记(Remark):STW执行,标记再并发标记过程中产生的垃圾。
  • 筛选回收(Live Data Counting And Evacuation):制定回收计划,选择多个Region 构成回收集,把回收集中Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。需要STW。

相比CMS,G1的优点有很多,可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集。

只从内存的角度来看,与CMS的"标记 - 清除"算法不同,G1从整体来看是基于"标记 - 整理"算法实现的收集器,但从局部(两个Region 之间)上看又是基于"标记 - 复制"算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。

G1主要解决了内存碎片过多的问题。

垃圾回收器的使用

JDK1.8默认:新生代用的Parallel Scavenge收集器,老年代用的是Parallel Old 收集器。

线上一般采用了设计比较优秀的G1垃圾收集器,因为它不仅满足低停顿的要求,而且解决了CMS的浮动垃圾问题、内存碎片问题。

再谈引用

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

1.强引用(StrongReference)

这是使用最普遍的引用;如果一个对象具有强引用,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

2.软引用(SoftReference)

一个对象只具有软引用,如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,此时内存空间不足引起的垃圾回收就会回收这些对象的内存。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。

内存不够用导致OOM?软引用和弱引用来助你一臂之力 - 掘金---软引用的应用(找的最好的了)

3.弱引用(WeakReference)

如果一个对象只具有弱引用,在垃圾回收器线程扫描内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动

虚引用与软引用和弱引用的一个区别在于:

虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

Spring是如何解决循环依赖的?

回到最初的面试题,答案是Spring的三级缓存。

什么是循环依赖

简单说,就是 A 对象依赖 B 对象, B 对象又依赖 A 对象,类似的代码如下:
@Component
public class A{
    @Autowired
    private B b;
}
@Component
public class B{
    @Autowired
    private A a;
}
其他还有很多种方式,如 A 依赖 B B 依赖 C C 依赖 A ,或是 A 依赖 A 自己,只要产生了依赖关系的闭环,即造成了循环依赖。
那么,循环依赖会引发什么问题呢?理解这个问题先得理解 Bean 的生命周期,以下先回顾下

Bean的生命周期回顾

1. 启动容器:加载 Bean
2. 实例化 Bean 对象
3. 依赖注入:装配 Bean 的属性
4. 初始化 Bean :执行 aware 接口方法、预初始化方法、初始化方法、后初始化方法
5. 关闭容器:销毁 Bean
在以上第四个步骤执行完毕,才算一个初始化完成的 Bean ,也即 Spring 容器中完整的 Bean 对象。

循环依赖的问题

Spring 容器保存 Bean 的方式,是采取缓存的方式:使用 Map<String, Object> 的结构, key Bean
的名称, value Bean 对象。需要使用时直接从缓存获取。
假如 A B 互相依赖(循环依赖):
1. 容器中没有 A 对象,实例化 A 对象
2. 装配 A 中的 B 对象,发现 B 在容器中没有,需要先实例化 B
3. 实例化 B 对象
4. 装配 B 中的 A 对象,发现 A 在容器中没有,需要先实例化 A
5. 重复第一个步骤
这就套娃了 , 你猜是先 StackOverflow 还是 OutOfMemory
Spring 怕你不好猜,就先抛出了 BeanCurrentlyInCreationException

注:

Bean 会依赖某些注入的 Bean 来完成初始化工作
由于 Spring 支持构造方法注入,属性 /Setter 注入的方式,所以不能简单的先把所有对象全部实例
化,放到缓存中,再全部执行初始化。原因很简单,此时所有对象的引用都可以获取到,但属性都
null ,执行初始化甚至构造方法都可能出现空指针异常。
那么我们说 Spring 能解决循环依赖,也不是所有的情况都可以解决,只有以下情况才支持。

Spring支持的循环依赖

在Spring容器中注册循环依赖的Bean,必须是单例模式,且依赖注入的方式为属性(setter)注入。
原型模式及构造方法注入的方式,不支持循环依赖。以下为说明:
原型模式( prototype )的 Bean :原因很好理解,创建新的 A 时,发现要注入原型字段 B ,又创建新
B 发现要注入原型字段 A... ;还是典型的套娃行为...
基于构造器的循环依赖,就更不用说了,官方文档都摊牌了,你想让构造器注入支持循环依赖,是
不存在的,不如把代码改了。
那么默认单例的属性注入场景, Spring 是如何支持循环依赖的?

Spring解决循环依赖

Spring 是使用三级缓存的机制来解决循环依赖问题,以下为三级缓存的定义:
三级缓存的源码见 DefaultSingletonBeanRegistry

说明:

三级缓存 singletonFactories 中保存的是 ObjectFactory 对象( Bean 工厂),其中包含了
    BeanName Bean 对象, RootBeanDefinition ,该工厂可以生成 Bean 对象。
>  由于 Bean 可能被代理,此时注入到其他 Bean 属性中的也应该是代理 Bean

单例模式的AB循环依赖执行流程如下:  

为什么要使用三级缓存  

依照以上三级缓存的流程,其实使用二级缓存也能满足循环依赖的注入:
        普通的IoC 容器使用一级缓存即可,但无法解决循环依赖问题。
        >  解决循环依赖问题:使用二级缓存即可。一级缓存保存完整Bean,二级缓存保存提前曝光
            的不完整的Bean
       >   需要AOP 代理 Bean 时,有两种实现思路:
                (1)再加一级缓存
                (2)只使用二级缓存,其中二级缓存保存Bean的代理对象,代理对象中引用不完整的
                         原始对象即可。
       >   Spring 使用三级缓存保存 ObjectFactory Bean 工厂,在代码的层次设计及扩展性上都会更              好。
ps ObjectFactory 内部可以根据 SmartInstantiationAwareBeanPostProcessor 这样的后置处
理器获取提前曝光的对象。

AOP和循环依赖的那些事?

大家可能会遇到过或者听说过Spring的循环依赖的问题。Spring使用了"三级缓存"来解决Bean的循环依赖,但可能很多人不知道为什么要使用三级缓存,其实这个也跟AOP有关。
如果没有AOP,其实Spring使用二级缓存就可以解决循环依赖的问题。若使用二级缓存,在AOP情形下,注入到其他Bean的,不是最终的代理对象,而是原始目标对象。
因为Spring对Bean有一个生命周期的定义,而代理对象是在Bean初始化完成后,执行后置处理器的时候生成的。所以不能在二级缓存的时候就直接生成代理对象,放进缓存。

面试题及回答

题目:

如果有这样一个场景,new了一个线程,什么也不做,那么它会被回收吗?回收的话是谁引用了它?如果是可达性分析算法,那么在springboot中,serviceA注入了serviceB,serviceB注入了serviceA,那么他们会被回收吗?如果会被回收,那么单例不是每次都要创建吗?你如果是springboot的开发者,你怎么处理它?

回答:

构建的这个对象没有被任何变量所引用,按照java里垃圾回收机制,thread对象会被马上进行回收,但是事实上构建的对象并没有被及时回收,这是为什么呢?因为每一个新建的thread对象都只有离开了run方法后才能把它清理掉,换句话说只要thread的run方法还在执行时候不管这个thread对象处在什么状态下,thread对象都不会被回收,因为Thread的run()方法的局部变量this保持了对线程对象Thread的引用。

第二个问题就是循环依赖问题了,仔细阅读本篇文章,会得到答案的!
 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值