目录
JVM的学习
/      

JVM的学习

JVM的学习

jvm 专业名词:运行时数据区(Runtime Data Area)、方法区(Method Area)、堆(Heap)、栈(Stack)、本地方法栈(Native)、程序计数器、执行引擎、本地方法接口(Java Native Interface 又称 JNI)。

  • 程序计数器(PC寄存器)

    在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任意时刻,一个CPU的内核只会执行一条线程中的指令,为了能够使得每个线程在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,程序计数器是每个线程是私有的。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出的现象(OutOfMemory)的。

  • Java栈

    Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表、操作数栈、指向当前方法所属的类的运行时常量池的引用、方法返回地址和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。

  • 本地方法栈

    本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是执行本地方法(Native Method)服务的。

  • Java中的堆是用来存储对象本身的以及数组(数组引用是存放在Java栈中的)。堆是被所有线程共享的,在JVM中只有一个堆。

  • 方法区

    与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

    在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

    在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就会被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如 String 的 intern 方法。

    在jdk1.7之后的版本,字符串常量池从方法区中转移到堆内存中。

    运行时常量池和字符串常量池是不一样的。

在Java虚拟机规范中,对这个区域规定了两种异常情况:

1、如果线程请求的栈深度大于虚拟机所允许的深度,将抛出Stack Overflow Error 异常

2、如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

这两种情况存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存大太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

方法区域,又被称为 “ 永久代 ”,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

JVM总结一下

内存区域

jdk 1.6

1、Java虚拟机栈
2、本地方法栈
3、程序计数器
4、堆内存
5、方法区(永久代)-> (运行时常量池 & 字符串池)

jdk 1.7

1、Java虚拟机栈
2、本地方法栈
3、程序计数器
4、堆内存 -> 字符串池
5、方法区(永久代)-> 运行时常量池

jdk 1.8

1、Java虚拟机栈
2、本地方法栈
3、程序计数器
4、堆 -> 字符串常量池
5、直接内存 -> 元空间 -> 运行时常量池

版本更新日志

1.6 -> 1.7
方法区中的字符串池(字符串常量池和静态变量)转移到了堆内存中

1.7 -> 1.8
永久代被移除,取而代之的是元空间,元空间使用的是本地内存

GC垃圾回收

Java的堆由新生代老年代 组成。新生代存放新分配的对象,老年代存放长期存在的对象。新生代和老年代空间大小比例为2:3

  • 新生代Young:由年轻代Eden、Survivor区(From Survivor、To Survivor)组成,新生代的Eden区和Survivor区大小比例为8:2,可以通过参数-XX:SurvivorRatio调整
  • 老年代Old:是对象存活时间最长的部分,它由单一存活区(Tenured)组成,并且把经过若干轮GC回收还存活下来的对象移动而来。在老年代中,大部分对象都是活了很久的,所以GC回收它们很慢。

新生代主要使用的是标记-复制算法进行垃圾回收的。新创建的对象分配在Eden区,如果Eden区快满了就触发垃圾回收,把Eden区中存活对象转移到一块空着的Survivor区,Eden区清空,然后再次分配新对象到Eden区,再触发垃圾回收,就把Eden区存活的和Survivor区存活的转移到另外一块空着的Survivor。
也就是说新生代空间中,只有Eden区和一块Survivor区是在被使用的,而另外一个Survivor区是空着的,所以内存使用率大约90%

对象经过多次 Young GC 仍然存活,达到一定年龄后(默认 15 次),会晋升到老年代(Old Generation)。

如果扫描Eden区和一块Survivor区,存活对象所需要的内存大于另外一块Survivor区怎么办?
空间担保机制就此而生,如果Survivor区域的空间不够,就会晋升到老年代,也就是说,老年代起到了一个兜底的作用。但是老年代可能空间不足的。所以,在这个过程中就需要做一个空间分配担保(CMS)
在每一次执行Young GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
如果大于,那么说明本次Young GC是安全的,否则

  • 当准备要触发一次Young GC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查老年代最大可用的连续空间小于新生代所有对象的总空间,但是HandlePromotionFailure=false,那么就会触发一次 Full GC(HandlePromotionFailure这个配置在JDK7中并不在支持了,这一步骤在该版本已取消)
  • 当准备触发一次Young GC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查老年代最大可用连续空间小于新生代所有对象的总空间,但是HandlePromotionFailure=true,继续检查发现老年代最大可用连续空间小于历次晋升到老年代的对象的平均大小时,会触发一次Full GC

Young GC

触发条件:当年轻代中Eden区分配满的时候就会触发

Full GC

触发条件:

  • 老年代空间不足:
    • 创建一个大对象,超过指定阈值会直接保存在老年代当中,如果老年代空间也不足,会触发Full GC
    • Young GC之后,发现要移动到老年代的对象,老年代存不下的时候,会触发一次Full GC
  • 空间分配担保失败
    • 当准备要触发一次Young GC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查老年代最大可用的连续空间小于新生代所有对象的总空间,但是HandlePromotionFailure=false,那么就会触发一次 Full GC(HandlePromotionFailure这个配置在JDK7中并不在支持了,这一步骤在该版本已取消)
    • 当准备触发一次Young GC时,会进行空间分配担保,在担保过程中,发现虚拟机会检查老年代最大可用连续空间小于新生代所有对象的总空间,但是HandlePromotionFailure=true,继续检查发现老年代最大可用连续空间小于历次晋升到老年代的对象的平均大小时,会触发一次Full GC
  • 永久代空间不足
    • 如果有永久代的话,当在永久代分配空间时没有足够空间的时候,会触发Full GC
  • 代码中执行 System.gc()
    • 代码中执行System.gc()的时候,会触发 Full GC,但是并不保证一定会立即触发。

JIT优化

just in time 即时编译是JVM的一项优化技术,能在运行时将热点代码(执行频率高的代码)编译为机器码,以提高程序性能。

什么是内联优化?

内联优化(Method Inlining) 是JIT编译器最重要的优化技术之一,它能消除方法调用的开销,提高代码的执行效率。

内联优化指的是JIT编译器将一个小方法的代码直接插入到调用方,从而避免方法调用的开销(如方法查找、参数压栈、返回值处理等)。

未优化代码:

public class JITTest {
    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            printMessage();  // 调用方法
        }
    }

    public static void printMessage() {
        System.out.println("Hello, JIT!");
    }
}

在这个例子中,JVM默认会每次调用 printMessage,这意味着需要进行:

  • 方法查找
  • 参数压栈
  • 执行return语句

内联优化的代码如下:

public class JITTest {
    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            System.out.println("Hello, JIT!");  // 直接展开方法代码
        }
    }
}

这样一来,省去了方法调用的开销

JVM可进一步优化代码(如指令重排,寄存器优化)

执行速度大大提升。

JIT何时执行内联优化

JVM不会内联所有的方法,而是根据调用次数和方法大小等条件决定是否内联。

JIT内联优化的触发条件:

  • 方法调用次数 > 某个阈值(默认 10000 次)
  • 方法体足够小(字节码 < 35字节,Hotspot默认)
  • 非虚方法(final或static方法更容易被内联)
  • 无异常处理代码(异常处理会阻止内联)
  • 没有递归调用(递归方法不能完全内联)

查看JVM JIT内联日志

可以使用参数查看哪些方法被内联:

java -jar -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining JITTest

内联优化的实际应用

  • 内联 + 常量传播
  • 内联 + 循环展开
  • 强制内联 @CompilerControl

常见问题收集

1、为什么 JVM 堆内存不能超过 32 GB ?

  • 压缩指针(Compressed Oops)

    • Java 对象在内存中的引用是通过指针来实现的。在 64 位JVM中,如果堆内存超过 32 Gb,JVM 将不能使用压缩指针技术(Compressed Oops)。压缩指针可以将 64 位指针压缩为 32 位,从而减少内存占用并提高访问速度。
      • 压缩指针的优势
        1、减少内存占用:32 位指针比 64 位指针占用更少的内存。
        2、提高内存命中率:更小的指针意味着更多的数据可以加载到 CPU 缓存中,从而提高访问速度。

        当堆内存超过 32 GB 时,JVM 自动禁用压缩指针,导致指针变为 64 位,内存占用增加,性能下降。

  • 垃圾回收器的性能
    大堆内存可能导致垃圾回收(GC)变得非常耗时,特别是对 Full GC 而言。较大的堆内存需要更多的时间来扫描和整理,从而导致更长的停顿时间。

    • GC 停顿时间:
      • 堆内存越大,GC 扫描和整理的时间越长,导致应用响应时间变长。
      • 对于许多实时或近实时应用,长时间的 GC 停顿是不可接受的。
  • 内存碎片化
    较大的堆内存更容易出现内存碎片化问题。内存碎片化会导致内存使用效率降低,并增加垃圾回收的复杂性和停顿时间。

    • 内存碎片化的影响:
      • 增加内存分配和回收的复杂性
      • 导致内存使用效率降低,从而需要更多的内存来满足相同的需求。
  • JVM 内存管理的复杂性
    较大的堆内存增加了内存管理的复杂性,JVM 在管理和分配内存时需要更多的资源和时间。

    • 内存管理复杂性:
      • 较大的堆内存需要更多的元数据来管理内存分配和回收,还增加了内存管理的开销
      • JVM 需要更多的资源来跟踪和管理大量的对象和引用,可能导致性能下降。

标题:JVM的学习
作者:gitsilence
地址:https://blog.lacknb.cn/articles/2022/11/22/1669098095939.html