JVM知识点总结

一. 概述

1.什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?

1)Java虚拟机是一个可以执行Java字节码的虚拟机进程
2)因为JVM识别的是字节码文件,只需将Java源文件编译成class字节码文件后即可被各个平台下的JVM所执行,做到“一次编译,处处运行”。

1.5. Java文件是如何被运行的

执行main方法的步骤如下:

  1. 编译好 App.java 后得到 App.class 后,执行 App.class,系统会启动一个 JVM 进程,从 classpath 路径中找到一个名为 App.class 的二进制文件,将 App 的类信息加载到运行时数据区的方法区内,这个过程叫做 App 类的加载
  2. JVM 找到 App 的主程序入口,执行main方法
    这个main中的第一条语句为 Student student = new Student(“tellUrDream”) ,就是让 JVM 创建一个Student对象,但是这个时候方法区中是没有 Student 类的信息的,所以 JVM 马上加载 Student 类,把 Student 类的信息放到方法区中
  3. 加载完 Student 类后,JVM 在堆中为一个新的 Student 实例分配内存,然后调用构造函数初始化 Student 实例,这个 Student 实例持有 指向方法区中的 Student 类的类型信息 的引用
  4. 执行student.sayName();时,JVM 根据 student 的引用找到 student 对象,然后根据 student 对象持有的引用定位到方法区中 student 类的类型信息的方法表,获得 sayName() 的字节码地址
  5. 执行sayName()

二. 内存管理

¥2.Java内存模型
  • Java堆(Heap):存放对象实例,
  • 方法区(Method Area):也被叫做永久代,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。(JDK1.8后被替换为元空间,元空间使用直接内存)
  • 运行时常量池。运行时常量池是方法区的一部分。(JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace))
  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器。

作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • JVM栈(JVM Stacks):虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

  • 本地方法栈(Native Method Stacks):虚拟机使用到的Native方法的栈。
  • 直接内存

方法区和对是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。

2.5 JVM堆和栈为什么要分开放
  1. 从软件设计的角度来看,栈代表了处理逻辑,而堆代表了数据,这样分离使得处理逻辑更为清晰。这种隔离、模块化的思想在软件设计的方方面面都有体现。
  2. 堆与栈的分离,使得堆中的内容可以被多个栈共享。这种共享有很多好处,一方面提供了一种有效的数据交互方式(如内存共享),另一方面,节省了内存空间。
  3. 栈因为运行时的需要(如保存系统运行的上下文),需要进行址段的划分。由于栈只能向上增长,因此会限制住栈存储内容的能力。而堆不同,堆的大小可以根据需要动态增长。因此,堆与栈的分离,使得动态增长成为可能,相应栈中只需要记录堆中的一个地址即可。
2.6 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致
OutOfMemoryError 异常出现,所以我们放到这里一起讲解。  在 JDK 1.4 中新加入了 NIO(New
Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native
函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据
 显然,本机直接内存的分配不会受到 Java
堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置
-Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

直接内存常用于NIO操作,用于数据缓冲区。
直接内存分配回收成本较高,但读写性能高
直接内存不受JVM内存回收管理

直接内存详解:https://blog.csdn.net/weixin_51146329/article/details/128770701

3.Java对象创建过程
  1. JVM遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用(是否被加载过),没有则加载这个类
  2. 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,线程安全的解决:“本地线程缓冲分配(TLAB)”或CAS+重试
  3. 将除对象头外的对象内存空间初始化为零值(这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用)
  4. 对象头进行必要设置

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

  1. 执行<init>方法
3.5 对象内存分配方法

分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
在这里插入图片描述
内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
3.6 <init>方法和 <clinit>方法

<init>是对象构造器方法,在程序执行new一个对象调用该类的构造方法时会调用 <init>方法。<clinit>方法是类构造器方法,在JVM进行类加载的初始化阶段会调用<clinit>方法

4.Java对象结构

Java对象由三个部分组成:对象头、实例数据、对齐填充。

  • 对象头:由两部分组成,第一部分存储对象自身的运行时数据哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。

  • 实例数据:用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

  • 对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)

4.5 对象的访问定位
  • 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
  • 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改(修改方便)。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

$5.如何判断对象可以被回收?

判断对象是否存活一般有两种方式:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
  • 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。

判断是否进行回收:

1.该对象没有与GC Roots相连

2.该对象没有重写finalize()方法或finalize()已经被执行过则直接回收(第一次标记)、否则将对象加入到F-Queue队列中(优先级很低的队列)在这里finalize()方法被执行,之后进行第二次标记,如果对象仍然应该被GC则GC,否则移除队列。 (在finalize方法中,对象很可能和其他 GC Roots中的某一个对象建立了关联,finalize方法只会被调用一次,且不推荐使用finalize方法)

5.5 有哪些是可以作为GCroots

虚拟机栈引用对象,类静态属性引用对象,常量引用对象,本地方法栈引用对象

6.方法区的回收

方法区回收价值很低,主要回收废弃的常量和无用的类。

如何判断无用的类:

  1. 该类所有实例都被回收(Java堆中没有该类的对象)
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方利用反射访问该类
¥7.垃圾收集算法及其特点
  • 标记清除算法:标记出所有需要回收的对象,然后清除可回收的对象。效率较低,并且因为在清除后没有重新整理可用的内存空间,如果内存中可被回收的小对象居多,会引起内存碎片化问题。
  • 复制算法:将可用内存分为区域1和区域2,将新生成的对象放在区域1,在区域1满后对区域1进行一次标记,将标记后仍然存活的对象复制到区域2,然后清除区域1。效率较高并且易于实现,解决了内存碎片化的问题,缺点是浪费了大量内存,同时在系统中存在长生命周期对象时会在两区域间来回复制影响系统效率。
  • 标记整理算法:结合了标记清除算法和复制算法的优点,标记过程和标记清除算法一样,标记后将存活的对象移动到一端,清理另一端。
  • 分代收集算法:根据对象不同类型把内存划分为不同区域,把堆划分为新生代和老年代。由于新生代的对象生命周期较短,主要采用复制算法。将新生代划分为一块较大的Eden区和两块较小的Survivor区,Servivor区又分为ServivorTo和ServivorFrom区。JVM在运行过程中主要使用Eden和SurvivorFrom区,进行垃圾回收时将这个两个区域存活的对象复制到SurvivorTo区并清除这两个区域。老年代主要存储长生命周期的大对象,因此采用标记清除或标记整理算法。
7.5 HotSpot 为什么要分为新生代和老年代?

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

¥8.内存回收与回收策略
  • 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  • 大对象直接进入老年代,避免在Eden区和两个Survivor区之间发生大量的内存拷贝。
  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,年龄达到阀值对象进入老年区。
  • 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
  • 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC。
9.新生代的垃圾回收机制

新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:

  1. 把在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,如果某对象的年龄达到老年代的标准,则将其复制到老年代,同时把这些对象的年龄加1。如果ServivorTo区的内存空间不够,则也直接将其复制到老年代。如果对象属于大对象,则也直接复制到老年代。
  2. 清空Eden区和ServivorFrom区中的对象。
  3. 将ServivorFrom区和ServivorTo区互换,原来的ServivorTo区成为下一次GC时的ServivorFrom区。
10.老年代的垃圾回收机制
  • 老年代主要存放有长生命周期的对象和大对象,老年代的GC叫MajorGC。
  • 在老年代,对象比较稳定,MajorGC不会频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,过后仍然出现老年代空间不足或无法找到足够大的连续内存空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
  • MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。因为要先扫描老年代的所有对象再回收,所以MajorGC的时间较长。容易产生内存碎片,在老年代没有内存空间可分配时,会出现内存溢出异常。
10.5 哪些情况会触发FullGC
  1. System.gc()方法的调用
    此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。
  2. 老年代空间不足
    老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出如下错误:
    java.lang.OutOfMemoryError: Java heap space
    为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
  3. 永生区空间不足
    JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,Permanet Generation中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息:
    java.lang.OutOfMemoryError: PermGen space
    为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。
  4. CMS GC时出现promotion failed和concurrent mode failure
    对于采用CMS进行老年代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。
    promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)。
    对措施为:增大survivor space、老年代空间或调低触发并发GC的比率
  5. 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间
  6. 堆中分配很大的对象。所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。
    为了解决这个问题,CMS垃圾收集器提供了一个可配置的参数,即-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费赠送一个碎片整理的过程,内存整理的过程无法并发的,空间碎片问题没有了,但提顿时间不得不变长了,JVM设计者们还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不压缩的Full GC后,跟着来一次带压缩的。
¥11.垃圾回收器
  • Serial:单线程,基于复制算法,JVM运行在Client时默认的新生代垃圾收集器
  • ParNew:Serial的多线程实现,基于复制算法,JVM运行在Server时默认 的新生代垃圾收集器。
  • Paraller Scavenge:多线程,基于复制算法,以吞吐量最大化为目标,允许较长时间的STW换取吞吐量。
  • Serial Old:单线程,基于标记整理算法,是JVM运行在Client模式下默认的老年代垃圾回收器,可和Serial搭配使用。
  • Parall Old:多线程,基于标记整理算法,优先考虑系统的吞吐量。
  • CMS:多线程,基于标记清除算法,为老年代设计,追求最短停顿时间。主要有四个步骤:初始标记、并发标记、重新标记、并发清除。
  • G1:将堆内存分为几个大小固定的独立区域,在后台维护了一个优先列表,根据允许的收集时间回收垃圾收集价值最大的区域。相比CMS不会产生内存碎片,并且可精确控制停顿时间。分为四个阶段:初始标记、并发标记、最终标记、筛选回收。
11.2 关于G1垃圾回收器

G1回收特点:并不是回收全部垃圾,而是尽可能在用户规定得停顿时间内尽可能回收垃圾多的region

  • G1如何判断哪些region的回收率高(即垃圾多)?
    G1收集器会跟踪每个region里面垃圾堆积的价值(即回收该region所获的空间和所需时间的价值),然后再后台维护一个优先级列表,每次根据该优先级列表进行回收(优先处理优先级高的region),这也是Garbage First的由来。

G1会根据用户规定的停顿时间,最大的回收region,将需要被回收的region复制到空白region中,再将原region全部回收(不会产生内存碎片)。该步骤也需要STW,因为涉及到了对象的移动(一个region到另一个region)。

11.25 G1垃圾收集器的回收过程
  1. 初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
  2. 并发标记(Concurrent Marking):同CMS的并发标记
  3. 最终标记(Remark,STW):同CMS的重新标记
  4. 筛选回收(Cleanup,STW)这一步和CMS的回收过程不同,CMS回收是并发的:筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了ZGC,Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)
11.3 跨代引用,如何解决

跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用,如下图所示:
在这里插入图片描述
YGC时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费。因为跨代引用是极少的,为了找出那么一点点跨代引用,却得遍历整个老年代!

解决方案:记忆集(RemberSet)
  记忆集就是用来记录跨代引用的表,通过引入记忆集避免遍历老年代。以YGC为例说明,要回收年轻代,只需要引用年轻代对象的GC ROOT+记忆集,就可以判断出Young区对象是否存活,不必再遍历老年代。

缺点:具有“滞后性”,浪费一定的空间;如下图所示,YGC时实际上对象E可以被回收,但是由于没发生FGC,老年代中的对象D仍存在对对象E的引用,导致E无法被回收。
在这里插入图片描述
G1是对每个region维护了一个rset,记忆集中维护了指向自己的region的指针,并且标记指针分别在那些卡页的范围之内。

虚拟机发现对引用数据类型进行写操作时,会产生一个中断,判断引用对象是否处于不同Region之中,如果是,则把相关引用信息记录到被引用对象所属Region的RememberSet之中

11.5 CMS收集过程
  • 初始标记(Stop-the-world): 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记(并发): 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记(Stop-the-world): 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除(并发): 开启用户线程,同时 GC 线程开始对未标记的区域做清扫
11.6 OopMap

在进行初始标记时,对GC Roots 枚举的过程中,是需要暂停用户线程的,对栈进行扫描,找到哪些地方存储了对象的引用。然而,栈存储的数据不止是对象的引用,因此对整个栈进行全量扫描,显然是很耗费时间,影响性能的。
因此,在 HotSpot 中采取了空间换时间的方法,使用 OopMap 来存储栈上的对象引用的信息。在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap,通过 OopMap 存储的信息,快捷地找到 GC Roots
在这里插入图片描述

11.7 Safe Point

然而,在程序执行的过程中,对象之间的引用关系随时都会发生改变,这意味着对应的 OopMap 需要同步进行更新。如果每一条指令的执行,都生成(或更新)对应的OopMap,那么将会占用大量的内存空间,增加了 GC 的空间成本。

因此,针对这个问题,JVM 引入了 Safe Point 的概念,只有在 Safe Point 才会生成(或更新)对应的 OopMap

Safe Point 就是一个安全点,可以理解为用户线程执行过程中的一些特殊位置。线程执行到 Safe Point 的时候,OopMap 保存了当前线程的上下文,当线程执行到这些位置的时候,说明线程当前的状态是确定的,线程有哪些对象、使用了哪些内存。

适合放置 Safe Point的地方:

  • 所有的非计数循环的末尾 (防止循环体的执行时间太长,一直进入不了 Safe Point)
  • 所有方法返回之前
  • 每条 Java 编译后的字节码的边界

当所有线程都到达Safe Point,有两种方法中断线程:

  • 抢占式中断(Preemptive Suspension) JVM会中断所有线程,然后依次检查每个线程中断的位置是否为Safe
    Point,如果不是则恢复用户线程,让它执行至 Safe Point 再阻塞。
  • 主动式中断(Voluntary Suspension) 大部分 JVM
    实现都是采用主动式中断,需要阻塞用户线程的时候,首先做一个标志,用户线程会主动轮询这个标志位,如果标志位处于就绪状态,就自行中断。
11.8 Safe Region

然而,实际情况中 Safe Point 仍然存在缺陷,例如:线程处于 Sleep 状态或者 Blocked 状态,那么线程就无法达到 Safe Point。

因此,针对这个问题,JVM 引入了 Safe Region 的概念。Safe Region 是一片区域,在这个区域的代码片段,引用关系不会发生变化,因此,在 Safe Region 中任意地方开始垃圾收集都是安全的。

可以理解为 Safe Region 就是 Safe Point 的扩展,点动成线。

线程执行到 Safe Region 时,首先标记线程已经进入 Safe Region,当线程将要离开 Safe Region 时,线程需要检查 JVM 是否已经完成 GC Roots 枚举。如果尚未完成,则需要一直等待,直到 GC Roots 枚举完成。

参考文献:https://zhuanlan.zhihu.com/p/441867302

12. 有什么办法可以主动通知虚拟机进行垃圾回收呢?

程序可以使用system.gc回收,但是回不回收看jvm

13.finalize()方法什么时候被调用?析构函数(finalization)的目的是什么?

调用时机:当垃圾回收器要宣告一个对象死亡时,至少要经过两次标记过程:如果对象在进行可达性分析后发现没有和GC Roots相连接的引用链,就会被第一次标记,并且判断是否执行finalizer( )方法,如果对象覆盖finalizer( )方法且未被虚拟机调用过,那么这个对象会被放置在F-Queue队列中,并在稍后由一个虚拟机自动建立的低优先级的Finalizer线程区执行触发finalizer( )方法,但不承诺等待其运行结束。

finalization的目的:对象逃脱死亡的最后一次机会。(只要重新与引用链上的任何一个对象建立关联即可。)但是不建议使用,运行代价高昂,不确定性大,且无法保证各个对象的调用顺序。可用try-finally或其他替代。

14.如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?

不会立即释放对象占用的内存。 如果对象的引用被置为null,只是断开了当前线程栈帧中对该对象的引用关系,而 垃圾收集器是运行在后台的线程,只有当用户线程运行到安全点(safe point)或者安全区域才会扫描对象引用关系,扫描到对象没有被引用则会标记对象,这时候仍然不会立即释放该对象内存,因为有些对象是可恢复的(在 finalize方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。

¥15. java中会存在内存泄漏吗,如何排查。

java中内存泄露只被该被回收的对象没有被正确回收

内存泄漏的场景:

  1. 静态集合类内部对象没被及时释放
  2. 外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
  3. 当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。

排查:jmap获得堆转储快照,再利用快照分析工具进行分析(如Visual VM)

¥16Java中4种引用类型?
  • 强引用,最常见的引用类型,把一个对象指向一个引用变量时就是强引用。强引用的对象一定为可达性状态,所以不会被垃圾回收,是内存泄漏的主要原因。
  • 软引用,通过SoftReference实现,如果一个对象只有软引用,当内存空间不足时将被回收。软引用可用来实现内存敏感的高速缓存
  • 弱引用,通过WeakReference实现,如果一个对象只有弱引用,在垃圾回收过程中一定会被回收
  • 虚引用,通过PhantomReference实现,虚引用和引用队列联合使用,主要用来跟踪对象的垃圾回收过程。

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

16.5 虚引用与软引用和弱引用的区别

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

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

17.永久代与元空间
  • 永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。
  • 永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会出现内存溢出异常,比如Tomcat引用jar文件过多导致JVM内存不足而无法启动。
  • 在JDK1.8中,永久代已经被元数据区取代。元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此元空间的大小不受JVM内存的限制,只和操作系统的内存有关
  • 在JDK1.8中,JVM将类的元数据放入本地内存中,将常量池和类的静态常量放入Java堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存空间决定,而由操作系统的实际可用内存空间决定。
17.5 为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

17.6 JVM调优策略
  1. 根据需求选择合适的垃圾收集器
  2. 调整新生代和老年代的比值,将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
  3. 合理调整Survivor区和Eden区的比值,避免Survivor区不够用。-XX:SurvivorRatio(幸存代)— 设置两个Survivor区和eden的比值
  4. 大对象进入老年代,大对象如果首次在新生代分配可能会出现空间不足导致很多年龄不够的小对象被分配的老年代,破坏新生代的对象结构,可能会出现频繁的 full gc。因此,对于大对象,可以设置直接进入老年代。-XX:PretenureSizeThreshold 可以设置直接进入老年代的对象大小。
  5. 合理设置进入老年代对象的年龄,-XX:MaxTenuringThreshold 设置对象进入老年代的年龄大小,减少老年代的内存占用,降低 full gc 发生的频率。
  6. 设置稳定的堆大小,堆大小设置有两个参数:-Xms 初始化堆大小,-Xmx 最大堆大小。开发过程中,通常会将 -Xms 与 -Xmx两个参数配置成相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。
  7. 合理设置栈大小,避免栈空间不足溢出。可以通过-Xss:调整每个线程栈空间的大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右

二. 执行子系统

¥18.Java类的加载过程(Java类的生命周期)

加载、连接(验证,准备,解析)、初始化、使用和卸载

  • 加载,查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建

  • 连接, 1)验证,文件格式、元数据、字节码、符号引用验证; 2)准备,为类的静态变量分配内存,并将其初始化为默认值; 3)解析,把类中的符号引用转换为直接引用
  • 初始化,为类的静态变量赋予正确的初始值
  • 使用,new出对象程序中使用
  • 卸载,执行垃圾回收
19.哪些情况下类会初始化?哪些情况不会

初始化场景:①创建类的实例。②访问某个类或接口的静态变量,或对该静态变量赋值。③调用类的静态方法。④初始化一个类的子类时(初始化子类,父类必须先初始化)。⑤JVM启动时被标为启动类的类。⑥MethodHandle

不会初始化场景:除了上述都不会发生初始化,如①常量在编译时会存放在使用该常量的类的常量池,该过程不要调用常量所在的类,不会初始化。②子类引用父类的静态变量时,子类不会初始化,只有父类会初始化。③定义对象数组,不会触发该类的初始化。④在使用类名获取Class对象时不会触发类的初始化。⑤在使用Class.forName()加载指定的类时,可以通过initialize参数设置是否需要初始化。⑥在使用ClassLoader默认的loadClass方法加载类时不会触发该类的初始化。

¥20.类加载器
  • 启动类加载器:Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库
  • 扩展类加载器:Extension ClassLoader,它负责加载DK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器
¥21.双亲委派机制,好处是什么

1)顶层的启动类加载器外其余的类加载器都应当有自己的父类加载器。一个类收到类加载请求后会层层找父类加载器去尝试加载,因此所有的加载请求最终都会被传送到顶层的启动类加载器,只有当父类加载器反馈自己无法完成加载时子加载器才会尝试自己去加载。

2)双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

22. 如何自定义类加载器,如何打破双亲委派模型
  • 自定义加载器的话,需要继承 ClassLoader
  • 如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
  • 但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

双亲委派的源码,实际就是递归调用父类加载器去加载,加载不了再调用findClass找到类

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
    {
    	// 同步上锁
        synchronized (getClassLoadingLock(name)) {
            // 先查看这个类是不是已经加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                	// 递归,双亲委派的实现,先获取父类加载器,不为空则交给父类加载器
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    // 前面提到,bootstrap classloader的类加载器为null,通过find方法来获得
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }

                if (c == null) {
                    // 如果还是没有获得该类,调用findClass找到类
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // jvm统计
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            // 连接类
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
23. 打破双亲委派的场景
  1. 双亲委派出现之前,ClassLoader只有loadClass() 方法方法,之后才加的findClass()方法
  2. JNDI服务父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。

具体实现:通过线程上下文类加载器去进行类的加载(可以通过Thread类的setContextClassLoader()去设置,如果没有设置则默认为应用程序类加载器)。

  1. 代码热替换。每一个程序模块(Bundle)都有自己的类加载器,每当需要更换,就把Bundle连同类加载器一起换掉以实现代码的热替换。
24. JVM方法内联

就是把调用方函数代码"复制"到调用方函数中,如:

private int add2(int x1 , int x2 , int x3 , int x4) {
return add1(x1 , x2) + add1(x3,x4);
}
  
private int add1(int x1 , int x2) {
return x1 + x2;
}

经内联后:

private int add2(int x1 , int x2 , int x3 , int x4) {
//return add1(x1 , x2) + add1(x3,x4);
return x1 + x2 + x3 + x4;
}

JVM会自动的识别热点方法,并对它们使用方法内联优化。那么一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置:

  • 使用client编译器时,默认为1500;
  • 使用server编译器时,默认为10000;

但是一个方法就算被JVM标注成为热点方法,JVM仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况:

  1. 如果方法是经常执行的,默认情况下,方法大小小于325字节的都会进行内联(可以通过 -XX:MaxFreqInlineSize=N来设置这个大小)
  2. 如果方法不是经常执行的,默认情况下,方法大小小于35字节才会进行内联(可以通过 -XX:MaxInlineSize=N 来设置这个大小)

我们可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值