Java基础——JVM

Java 是高级语言的其中一种,要把高级语言转换为计算机可以识别的机器码需要经过解释或者编译处理。编译的过程就是通过编译器把高级语言代码翻译成可以被机器执行的机器码(如C语言),解释的过程就是通过解释器直接解释执行(如PHP、Javascript),不需要编译成机器语言。但是现代高级语言很难简单的用“编译型”、“解释型”来区分,代码执行过程中并不是只有其中一种行为。

在 Java 中,为了实现跨平台以及提升运行速度,需要先将源代码使用 javac 编译成 Java 字节码,这个字节码并不能被计算机直接识别,需要使用 Java 虚拟机 JVM 来解释执行。JVM 在解释执行代码的过程中,如果发现某个方法或代码块运行频繁,会认为这是热点代码(Hot Spot Code),被标记为热点代码的部分在运行时被动态地编译程序(JIT)转换为本地机器码,以便直接在CPU上执行,而无需再次解释执行。除了 JIT 之外, JDK17 支持的 AOT 编译也可以直接将 Java 代码编译为计算机可以直接识别执行的机器码。

JVM运行时内存区域

不同版本的 JDK 的运行时内存区域是不一样的,目前主流的实现为 Java 虚拟机栈、本地方法栈、Java 堆、程序计数器、方法区(永久代或元空间,另外还包含运行时常量)组成。在 JDK 1.8 时,永久代被替换为元空间。

  • 程序计数器

一个只读的存储器,用于记录 Java 虚拟机正在执行的字节码指令的地址。它是线程私有的,为每个线程维护一个独立的程序计数器,用于指示下一条将要被执行的字节码指令的位置。它保证线程执行一个字节码指令以后,才会去执行下一个字节码指令。

  • Java虚拟机栈

一种线程私有的存储器,用于存储Java中的局部变量。根据Java虚拟机规范,每次方法调用都会创建一个栈帧,该栈帧用于存储局部变量,操作数栈,动态链接,方法出口等信息。当方法执行完毕之后,这个栈帧就会被弹出,变量作用域就会结束,数据就会从栈中消失。

  • 本地方法栈

本地方法栈是一种特殊的栈,它与Java虚拟机栈有着相同的功能,但是它支持本地代码(Native Code)的执行。本地方法栈中存放本地方法(Native Code)的参数和局部变量,以及其他一些附加信息。这些本地方法一般是用C等本地语言实现的,虚拟机在执行这些方法时就会通过本地方法栈来调用这些本地方法。

  • Java堆

是存储对象实例的运行时内存区域。它是虚拟机运行时的内存总体的最大的一块,也一直占据着虚拟机内存总量的一大部分。Java堆由Java虚拟机管理,用于存放对象实例,除堆外内存之外几乎所有的对象实例都要在上面分配内存。此外,Java堆还用于垃圾回收,虚拟机发现没有被引用的对象时,就会对堆中对象进行垃圾回收,以释放内存空间。

  • 方法区

用于存储已被加载的类信息、常量、静态变量、即时编译后的代码等数据的内存区域。每加载一个类,方法区就会分配一定的内存空间,用于存储该类的相关信息,这部分空间随着需要而动态变化。方法区的具体实现形式可以有多种,比如堆、永久代、元空间等。

堆和栈的区别

  1. 存储位置不同,堆是在JVM堆内存中分配空间,而栈是在JVM的栈内存中分配空间。
  2. 存储内容不同,堆中主要存储对象,栈中主要存储本地变量。
  3. 堆是线程共享的,栈是线程独享的。
  4. 堆是垃圾回收的主要区域,当对象不再被引用时,垃圾回收机制会自动回收该对象。而栈的内存使用是一种先进后出的机制,栈中的变量会在程序执行完毕后自动释放。
  5. 栈的空间大小比堆小,一般只有几百到几千字节。
  6. 栈的存储速度比堆快,代码执行效率高。
  7. 栈溢出会抛出 StackOverflow,通常发生在程序调用栈过深的情况(如递归)。堆溢出会发生 OutOfMemoryError,通常发生在分配内存的时超出可用内存限制,可能是由于内存不足或内存泄漏。

一般来说Java程序会在堆上为对象分配内存,但在 JIT 优化进行逃逸分析后发现某一个局部对象没有逃逸到线程和方法外的话,那么这个对象就可能不会在堆上分配内存,而是进行栈上分配。

堆的分代

Java 的堆内存分代是指将不同生命周期的堆内存对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为“代”。这样做可以为不同的”代”设置不同的回收策略,有助于提升垃圾回收的效率。一般来说,Java 中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。因为如果把这两部分对象放到一起分析和回收的效率过低,通过将不同时期的对象存储在不同的内存池中,可以节省宝贵的时间和空间,从而改善垃圾回收的性能。

Java的堆由新生代(Young Generation)和老年代(Old Generation)组成。新生代存放新分配的对象,老年代存放长期存在的对象。

  1. 新生代(Young):由年轻区(Eden)、Survivor区组成(分为S0和S1两个区,分别对应 From Survivor、To Survivor,只有一个区有数据,另一个是空的,因为未达到老年代阈值的对象会被从From复制到To,复制完成后From和To会交换、清空)。默认情况下,新生代的Eden区和Survivor区的空间大小比例是8:2,可以通过 -XX:SurvivorRatio=n 参数调整(n默认为8)。大多数对象都会出现在 Eden 区,当 Eden 区的内存容量用完的时候,就会发起垃圾回收,没有被引用的非存活对象会被标记为死亡,存活的对象被移动到Survivor区。
  2. 如果Survivor的内存容量也被用完了,那么存活对象会被移动到老年代。老年代(Old)是对象存活时间最长的部分,它由单一存活区(Tenured)组成,并且把经历过若干轮GC回收还存活下来的对象移动而来。在老年代中,大部分对象都是存活了很久的,所以GC回收它们会很慢。

对象在堆分代中的晋升

一般情况下,对象将在新生代进行分配,首先会尝试在 Eden 区分配对象,当 Eden 内存耗尽,无法满足新的对象分配请求时,将触发新生代的GC(Young GC、MinorGC),在新生代的GC过程中,没有被回收的对象会从 Eden 区被搬运到 Survivor 区,这个过程称为”晋升”。同样的,对象也可能会晋升到老年代,触发条件主要看对象的大小和年龄。对象进入老年代的条件有三个,满足一个就会进入到老年代:

  1. 躲过15次GC:每次垃圾回收后,存活的对象的年龄就会加1,累计加到15次(JDK8默认),即某个对象躲过了15次垃圾回收,那么JVM就认为这个是经常被使用的对象,就会被移动到老年代。具体的次数可以通过 -XX:MaxTenuringThreshold 设置在躲过多少次垃圾回收后移动到老年代。
  2. 动态对象年龄判断:如果在 Survivor 空间中小于等于某个年龄的所有对象大小的总和大于 Survivor 空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代。
  3. 大对象直接进入老年代:可以通过 -XX:PretenureSizeThreshold=<byte size> 参数来设置大对象的临界值,大于该值的就被认为是大对象,就会直接进入老年代。(该参数默认是0,默认情况下对象不会提前进入老年代,而是直接在新生代分配。然后根据 GC 次数和基于动态年龄判断来进入老年代。)