【Java面试八股文】JVM

2021/5/9 20:25:15

本文主要是介绍【Java面试八股文】JVM,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

1. 讲一下JVM内存模型(运行时数据区)

JVM内存模型分为两部分:线程共享和线程私有

image-20210424225209906

JDK1.8之后方法区被元空间Metaspace替代。

  • 程序计数器PC:代码流程的控制和多线程上下文切换恢复现场

  • 虚拟机栈:也就是我们常说的栈内存。Java中线程执行代码其实都是在执行一个个方法,每执行一个方法,该线程的栈空间就会被压入一个栈帧,因此虚拟机栈是由一个个栈帧组成的。每个栈帧中都拥有该方法执行过程中产生的局部变量表、操作数栈、动态链接以及方法出口信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    栈内存随线程的创建而创建,死亡而死亡

  • 本地方法栈:虚拟机栈提供Java方法服务,本地方法提供Native方法服务,也是由一个个栈帧组成。

  • 堆:虚拟机所管理的内存中最大的一块,所有线程共享,存放几乎所有的对象实例(new出来的东西都放在,但是引用是局部变量,放在栈内存)和数组

    Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存

    堆也叫做GC堆(Garbage Collected Heap)

  • 方法区:又叫非堆,用于存储类信息,常量、静态变量以及即时编译器编译后的代码缓存等数据。运行时常量池是方法区的一部分,保存的信息有类的版本、字段、方法、接口等描述信息以及常量池表(Final,String等)。

2. 对象创建的过程

  • 类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  • 为新生对象分配内存

    对象所需要的内存大小在类加载完成后便可确定,分配内存即把堆中的一块等同大小的内存划分出来给对象。但是因为Java堆中空闲内存和已被分配的内存有两种不同的情况:

    • 规整:已分配的内存在一边,空闲的内存在一边,中间放着一个指针作为指示器。分配内存的时候仅仅需要指针向空闲方向移动对象大小相同的距离。这种分配方式叫做“指针碰撞”
    • 不规整:已分配的内存和空闲的内存相互交错在一起。这种情况下虚拟机必须维护一个列表来记录哪些内存是可用的,分配的时候从列表中找出一块足够大的内存分配给对象。这种分配方式叫做“空闲列表”。

    堆是否规整取决于垃圾回收器是否带有空间压缩整理的能力。当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

    分配内存如何解决线程安全的问题?

    对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

    • CAS乐观锁+失败重试,保证更新操作的原子性
    • TLAB(本地线程分配缓冲):为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  • 初始化零值

    虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值

  • 设置对象头

    虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  • 执行init方法(有点类似于依赖注入,由程序员控制注入什么)

    在上面工作都完成之后,从虚拟机的视⻆来看,一个新的对象已经产生了,但从 Java 程序的视⻆来看,对象创建才刚开始,方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

3. 对象的内存布局

  • 对象头

    • 运行时数据:hashcode、GC分代年龄、锁状态、持有的锁
    • 类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
  • 实例数据

    字段内存、继承下来的字段内存

  • 对齐填充

4. 栈内存中的reference如何访问堆内存中的变量

  • 句柄访问
  • 直接指针

5. JVM垃圾回收概述

哪些内存要进行垃圾回收

线程私有的内存空间是不需要进行垃圾回收的,因为当方法结束或者线程终止,内存自然会跟随着回收。垃圾回收的主战场是堆,主目标就是堆中分配的对象。这部分的内存分配和回收是动态的。

什么对象需要被回收?

死亡的对象需要被回收,判定对象是否存活都和“引用”离不开关系。也就是说,没有被引用,没有指针指向的对象将被回收。

Java中的引用详解

以前我对引用的认识是:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义没有错,但是不足以应付我们复杂的业务逻辑。比如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。这个时候就引入扩充引用这个概念了。

JDK1.2之后,Java对引用进行了扩充,将引用分为:(强度注解降低)

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

强引用:即类似“Objectobj=newObject()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(肯定不回收)

软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。(满了就先回收你)

弱引用:也是描述一些还有用,但非必须的对象,但强度更低,只能生存到下一次垃圾收集为止。(不用等满,下一次就回收你)

虚引用:相当于没有引用,完全不会对其生存时间构成影响。唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

JVM如何判断对象已死(没有引用)

  • 引用计数法

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

    缺点:无法解决相互循环引用的问题

  • 可达性分析算法

    通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(ReferenceChain),如果某个对象到GCRoots间没有任何引用链相连则证明此对象是不可能再被使用的。简单来说,GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。那么通过这些引用延伸到的对象,自然也是存活的。

    引用,GC Root是引用的集合。这个引用集合由以下组成:

    • 当前所有正在被调用的方法的引用类型的参数/局部变量/临时值
    • JVM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。

6. 方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类信息。

如何判断一个常量为废弃常量

假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池。

如何判断一个类为无用的类

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

7. 垃圾回收算法

三个假说以及堆为什么要分代

根据大多数程序运行实际情况的经验准则,我们发现堆中的对象有以下特点:

  • 绝大多数对象撑不过第一轮垃圾回收
  • 越是熬过多次垃圾回收过程的对象越是难以消亡
  • 跨代引用相对于同代引用来说仅占极少数,也就是说同一个方法中引用的对象一般都是同代的

根据这三条假说,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。不同代的堆采用不同的回收算法获得最大效率,以此来提高垃圾回收的效率。

在新生代中,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。简单来说就是新生代都死得很快,我们只需要关注那些没死的。

在老生代中,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。简单来说就是老生代就死不了,因此很久才回收一次。

这就是堆为什么要分代的原因:选择最合适的GC算法。

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,在标记完成后,统一回收掉未被标记的对象。也可以反过来。标记过程就是对象是否属于垃圾的判定过程。

最基础的收集算法,后续收集算法都是基于其改进的。

缺点:

  • 执行效率不稳定;
  • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记-复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把所有已使用过的内存空间一次清理掉。

这个算法基于假说1:绝大部分的对象都撑不过第一轮垃圾收集。因此复制只是少量操作,回收就完事。

image-20210425164828591

缺点:

  • 对象存活率高时效率较低
  • 将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

标记-整理算法

标记-整理算法就是一种“移动式”的标记-清除算法,先把存活的对象移动到内存的一侧,再清空端边界以外的内存。

image-20210425165541585

其实这个算法也有缺点,对于老生代区域来说,对象存活率较高,因此移动的代码也很高。但是不移动就会造成内存碎片的问题。不难两全其美。

还有一种“和稀泥”的方式,先用标记-清除算法回收垃圾,等内存碎片真的很多的时候再使用标记-整理算法处理内存碎片的问题。

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。这些我们上面都说过了。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

8. 常见的垃圾回收器

垃圾回收器一般分为四大类:

  • 串行-Serial-单个垃圾回收线程

  • 并行-Parallel-多个垃圾回收线程

  • 并发-CMS

  • G1(JDK8后)

  • ZGC(JDK11后才有)

JVM中并行和并发的概念

  • 并行:并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
  • 并发:并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行

图片描述

HotSpot实现了很多垃圾回收器,可以自行搭配使用,一般为新生代选一个回收器,老生代选一个回收器。连线表示这两个回收器可以适配。

如何查看默认垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20210425231112945

默认使用Parallel Scavnge + Parallel Old

Minor GC、Major CG和Full GC

  • Minor GC:针对整个新生代
  • Major GC:针对整个老年代
  • Full GC:针对针对堆

Serial收集器

  • 单线程串行
  • 新生代-标记复制算法

Serial Old

  • 单线程串行
  • 老年代-标记整理算法

ParNew(Parallel New)

  • 多线程并行
  • 新生代-标记复制算法

Parallel Scavenge

  • 多线程并行

  • 相比ParNew提供了参数设置和自适应调节策略以提高吞吐量

  • 新生代-标记复制算法

Parallel Old

  • 多线程并行
  • 老年代-标记整理
  • 同样提供了参数设置和自适应调节策略

CMS

  • 多线程并发(第一款并发老年代收集器)

  • 老年代-改进的标记-清除算法

    CMS的垃圾回收算法(基于标记-清除,标记可达的对象,清除所有不可达的对象):

    • 初始标记(Stop the world)

      🍕标记一下GC Roots能直接关联到的对象,虽然需要Stop the world,但是速度很快

    • 并发标记

      🍕从GCRoots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

    • 重新标记(Stop the world)

      🍕修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,不会标记新产生的对象,只会改变此前的标记状态。时间较短

    • 并发清除

      🍕清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

    可以看到回收算法总共被分为四个阶段,其中1,3这两个时间段的需要停止用户线程,2,4这两个时间长的不需要停止用户线程。总体看起来就是并发的。

    因此CMS也被叫做“并发低停顿收集器”

  • 如果CMS回收失败,虚拟机会使用Serial Old垃圾收集器对老年代进行回收(Full gc),此时所有的工作进程都要停止,会产生一段长时间的停止。那么问题来了,什么时候CMS回收会失败呢?

    在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(ConcurrentModeFailure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用SerialOld收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

    我的总结:并发标记和并发清除过程中用户线程还在持续运行,这个过程中产生的对象此次CMS垃圾回收是无法清除的,因此必须预留出一部分空间。但是如何预留的空间不够用,在CMS垃圾回收过程中内存爆了,此次CMS回收并发失败,虚拟机将使用Full GC对整个堆进行垃圾回收。

  • 缺点:

    • 对处理器资源敏感
    • 无法处理“浮动垃圾”
    • 标记清除算法的通病–内存碎片

G1

微观上还是分代思想,内存宏观上不再是分区了,化整为零

在G1收集器出现之前的所有其他收集器,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。回收衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。JVM会在在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。

我的总结:其实G1收集器本质上还是分代回收算法,但是回收的单位不是整个新生代或者老年代了,而是一个个Region。具体先回收哪个Region由该Region的权重决定,优先回收所获得的空间大以及回收所需时间少的Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

  • 回收是以Region为单位的,一个Region可以存储多个对象,但是大对象是不存放在Region中的,使用Humongous专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的HumongousRegion之中,G1的大多数行为都把HumongousRegion作为老年代的一部分来进行看待,也就是说大对象会直接进入老年区

  • 一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

    并不!每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。

9. JVM的内存分配策略

  • 对象优先在新生代的Eden区分配。

    • 当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
    • 当新生代没有多余的空间时,对象会通过分配担保机制将新生代内存转移到老年代
  • 大对象直接进入老年代

    • 因为新生代使用标记-复制算法,如果大对象没死的话,就要在在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

      为什么JVM要有两个Survicor(from to)?

      假设只有一个survicor区:

      首次分配对象到Eden区,垃圾回收时把存活的对象复制到survivor区,清空eden。第二次垃圾回收的时候,eden区和survivor区都有死亡的对象,那怎么办?总不能把survivor区死了的对象移动到eden区吧。所以这时候再需要一个survivor区了,eden区和from区存活的对象复制到to区,清空eden区和from区,交换from和to的指针。

      这样看起来就很完美了,对象都是从eden,from -> to区,to区里面永远是死不了的对象。

      eden区和survivor区的内存空间默认是八二开,为了提交内存利用率。

    • 容易出发垃圾回收机制,尽管新生代还有不少的空间

    • HotSpot虚拟机提供了-XX:PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配

  • 长期存活的对象将进入老年代

    • 对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15),就会被晋升到老年代中。
    • 对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。
  • 动态年龄判定

    为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX: MaxTenuringThreshold中要求的年龄

作者: Virtuals
原文出处:https://www.cnblogs.com/sang-bit/p/14725733.html



这篇关于【Java面试八股文】JVM的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程