Java基础系列-最全面的JVM核心解析

JVM

JVM 就是Java虚拟机(Java Runtime Machine),能够识别.class后缀的文件,解析他的指令,最终调用操作系统上的函数,完成我们需要的操作。

JVM、JRE、JDK

JVM(Java Runtime Machine)

JVM是用来处理.class文件的,.class就是JVM的输入原料,.class从何而来?它需要一个基本的类库,比如怎么操作文件、怎么连接网络等,这些JVM 运行所需的类库就是通过JRE提供。

JRE(Java Runtime Environment): 类库+JVM

JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境---JRE(Java Runtime Environment)。有了 JRE 之后,Java 程序便可以运行了。

JDK(Java Development Kit):JRE+工具

JDK包含JRE,除此之外还提供了一些非常好用的小工具,比如 javac、java、jar 等,是 Java 开发的核心

Java 代码是如何被运行起来的

一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到元数据区,执行引擎将会通过混合模式执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的黑盒存在,输入字节码,调用操作系统函数。

java编译器三种模式

jvm中的编译器有三种编译模式:解释执行模式、编译执行模式、混合执行模式。(编译执行模式效率远远高于解释执行模式)

混合执行模式

jvm默认都是mixed mode 表示是混合执行模式。也可以通过命令切换为混合模式:java -Xmixed -version,该模式是混合使用解析模式和编译模式,执行时会判断执行函数调用频率,若频率高则是热点代码,使用编译模式执行,否则则使用解析模式执行。

java 复制代码
C:\Users\Administrator>java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) Client VM (build 25.144-b01, mixed mode, sharing)

可以通过命令切换为编译执行模式:java -Xcomp -version。该模式会将函数体编译成机器码,每次函数执行只执行编译好的机器码,效率很高。

编译执行模式

可以通过命令切换为编译执行模式:java -Xcomp -version。该模式会将函数体编译成机器码,每次函数执行只执行编译好的机器码,效率很高。

编译执行的缺点是不加筛选的将全部代码编译成机器码而不考虑其执行频率是否有编译的价值,在程序有响应时间的限制下,编译器没法采用耗时较高的优化技术(因为JIT的编译是首次运行或启动的时候进行的),所以纯编译模式下Java程序的执行效率和c c++相比仍有差距。

java 复制代码
C:\Users\Administrator>java -Xcomp -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) Client VM (build 25.144-b01, compiled mode, sharing)

解释执行模式

通过命令切换为解析模式:java -Xint -version,该模式直接由解释器解释执行所有字节码,效率低于编译模式。

java 复制代码
C:\Users\Administrator>java -Xint -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) Client VM (build 25.144-b01, interpreted mode, sharing)

JVM 内存区域划分

JVM内存分为这几个区域:

1.java虚拟机栈

Java 虚拟机栈是基于线程的。栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,也就是一个方法对应一个栈帧,栈帧创建后会入栈,一旦完成相应的调用,则出栈。Java程序的运行就是不断的有栈帧入栈和出栈的过程,所有的栈帧都出栈后,线程也就结束了。

栈帧

每个栈帧包含局部变量表,操作数栈,动态连接,返回地址四个区域。

局部变量表

一个方法对应一个栈帧,局部变量表用于存放方法中定义的局部变量。JavaStack类中,局部变量表中存储的首先是this,也就是当前对象,然后是Object类型tech13和基本数据类型zhifubao,weixin,对于Object类型的tech13,要知道栈中存储的只是引用,对象是在堆中的。

操作数栈

操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区,通过标准的入栈和出栈操作来完成一次数据访问。它是数据运算的地方,大多数指令都在操作数栈弹栈运算,然后结果压栈.

动态连接

指的就是多态相关的东西

返回地址

方法的返回指令

2.本地方法栈

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,这并不影响我们对 JVM 的了解。

3.程序计数器

指的就是代码反编译之后,操作码前边的数字,用于记录当前线程执行到的位置。为什么需要程序计数器?因为线程在获取 CPU 时间片上是不可预知的,需要有一个地方,对线程正在运行的点位进行缓冲记录,以便在获取 CPU 时间片时能够快速恢复。程序计数器指向当前线程正在执行的字节码指令的地址(行号),就是记录执行的位置,当线程切换时等再切换回来才能从正确的点继续执行。

yaml 复制代码
public static void main(java.lang.String[]);
    Code:
       0: new           #19                 // class TestJavaStack
       3: dup
       4: invokespecial #20                 // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #21                 // Method method1:()I
      12: pop
      13: return

4.堆

对大多数应用而言,Java 堆是虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一作用就是存放对象实例,几乎所有的对象实例都是在这里分配的(不绝对,在虚拟机的优化策略下,也会存在栈上分配、标量替换的情况,后面的章节会详细介绍)。Java 堆是 GC 回收的主要区域,因此很多时候也被称为 GC 堆。从内存回收的角度看,Java 堆还可以被细分为新生代和老年代;再细一点新生代还可以被划分为 Eden Space、From Survivor Space、To Survivor Space。从内存回收的角度看,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

5.元空间

"为什么有 Metaspace 区域?它有什么问题?"

说到这里,你应该回想一下类与对象的区别。对象是一个活生生的个体,可以参与到程序的运行中;类更像是一个模版,定义了一系列属性和操作。那么你可以设想一下。我们前面生成的 A.class,是放在 JVM 的哪个区域的?在 Java 8 之前,这些类的信息是放在一个叫 Perm 区(永久代,在堆中)的内存里面的。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。

Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。关于它们的对比,可以看下这张图。

元空间的好处也是它的坏处。使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。

方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。现在只需要了解到,这个区域存储的内容,包括:类的信息、常量池、方法数据、方法代码就可以了。

类加载过程

类加载可以分为如下几个过程:

加载

从jar包或war包找到并加载.class文件的二进制数据到Java的方法区。

验证

验证在类的加载过程中占了很大一部分,主要是为了防止恶意攻击,因为并非所有的.class都能被加载,不符合规范的将会抛出java.lang.VeriFyError错误。一些低版本的 JVM,是无法加载一些高版本的类库,就是在这个阶段验证完成的。

准备

为变量分配内存,并初始化为默认值。此时实例对象还没有分配到内存,所以这些动作是在方法区进行的。

下面两段代码,code1 将会输出 0,而 code2 将无法通过编译。原因是code1中类变量a虽然没有显式的赋值,但是类变量有两次赋初始值的过程,一次是在准备阶段,赋予初始值(也可以是指定值),一次是在初始化阶段,赋予程序员定义的值。因此类变量不设置初始值也没关系,而局部变量没有准备阶段,不赋初始值是不能使用的

arduino 复制代码
code1:
     public class A {
         static int a ;
         public static void main(String[] args) {
             System.out.println(a);
         }
     }
 code2:
 public class A {
     public static void main(String[] args) {
         int a ;
         System.out.println(a);
     }
 }

解析

解析是将符号引用替换为直接引用的过程。符号是一种定义,可以是任何字面的含义,而直接引用是直接指向目标的指针,相对偏移量,直接引用的对象都存在于内存中。 解析阶段负责把整个类激活,串成一个可以找到彼此的网,这个阶段主要做了以下工作:

1.类或接口的解析

2.类方法解析

3.接口方法解析

4.字段的解析

我们来看几个经常发生的异常,就与这个阶段有关。

java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。
java.lang.NoSuchMethodError 找不到相关方法时的错误。

初始化

初始化成员变量

接下来是另一道面试题,你可以猜想一下,下面的代码,会输出什么?

使用

卸载

垃圾定位算法

可达性分析法(根搜索算法,GC Roots)

从 GC Roots 向下追溯、搜索,会产生一个叫作 Reference Chain 的链条。当一个对象不能和任何一个 GC Root 产生关系时,就会被无情的诛杀掉。

如图所示,Obj5、Obj6、Obj7,由于不能和 GC Root 产生关联,发生 GC 时,就会被摧毁。

GC Roots可以分为三大类,下面这种说法更加好记一些:

  1. 活动线程相关的各种引用。(虚拟机栈(栈帧中的本地变量表)中引用的对象。)
  2. JNI 引用。(本地方法栈中JNI(即一般说的Native方法)引用的对象。)
  3. 类的静态变量的引用。(方法区中类静态属性引用的对象。方法区中常量引用的对象。)

引用计数法

因为有循环依赖的硬伤,现在主流的 JVM,没有一个是采用引用计数法来实现 GC 的,所以我们大体了解一下就可以。引用计数法是在对象头里维护一个 counter 计数器,被引用一次数量 +1,引用失效记数 -1。计数器为 0 时,就被认为无效。

面试题:能够找到 Reference Chain 的对象,就一定会存活么? 不一定,看引用类型

引用类型

强引用 Strong references

即使程序会异常终止,这种对象也不会被回收的。

软引用 Soft references

软引用用于维护一些可有可无的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,这种特性非常适合用在缓存技术上。比如网页缓存、图片缓存等。

弱引用 Weak references

当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。弱引用拥有更短的生命周期 ,它的应用场景和软引用类似,可以在一些对内存更加敏感的系统里采用。

虚引用 Phantom References

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

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象之前,把这个虚引用加入到与之关联的引用队列中。

典型 OOM 场景

垃圾回收算法

按照语义上的意思,垃圾回收,首先就需要找到这些垃圾,然后回收掉。但是 GC 过程正好相反,它是先找到活跃的对象,然后把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。

标记清除算法

根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。

如图所示,圆圈代表的是对象。绿色的代表 GC Roots,红色的代表可以追溯到的对象。可以看到标记之后,仍然有多个灰色的圆圈,它们都是被回收的对象。 清除阶段就是把未被标记的对象回收掉。这种简单的清除方式,有一个明显的弊端,那就是内存碎片问题。导致内存不连续。

复制算法

解决碎片问题只有进行内存整理。比较好的思路可以完成这个整理过程,就是提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。复制算法是非常有效的,但是它的弊端也非常明显,浪费了几乎一半的内存空间来做这个事情。

整理算法

不用分配一个对等的额外空间,也是可以完成内存的整理工作。可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,继续使用这个数组就可以实现。它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。从效率上来说,一般整理算法是要低于复制算法的。

css 复制代码
复制算法(Copy)复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费。
标记-清除(Mark-Sweep)效率一般,缺点是会造成内存碎片问题。
标记-整理(Mark-Compact)效率比前两者要差,但没有空间浪费,也消除了内存碎片问题。

分代收集算法

内存中的对象有这么一个特点:大部分对象的生命周期都很短;其他对象则很可能会存活很长时间。也就是说大部分对象是朝生夕灭的,其他的则活的很久。现在的垃圾回收器,都会在物理上或者逻辑上,把这两类对象进行区分。我们把死的快的对象所占的区域,叫作年轻代(Young generation)。把其他活的长的对象所占的区域,叫作老年代(Old generation)。

年轻代

年轻代使用的垃圾回收算法是复制算法。因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。我们前面也了解到复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。

如图所示,年轻代分为:一个伊甸园空间(Eden ),两个幸存者空间(Survivor )。当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GC(Minor GC),具体过程如下:

  1. 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称from);
  2. Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;接下来,只需要清空 from 区就可以了。

所以在这个过程中,总会有一个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费。这个比例,是由参数 -XX:SurvivorRatio进行配置的(默认为 8)。

对象内存分配

一般情况下,我们只需要了解到这一层面就 OK 了。但是在平常的面试中,还有一个点会经常提到,虽然频率不太高,它就是 TLAB,我们在这里也简单介绍一下。

这个道理和 Java 语言中的 ThreadLocal 类似,避免了对公共区的操作,以及一些锁竞争。

TLAB 的全称是 Thread Local Allocation Buffer,JVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配。这个 buffer 就放在 Eden 区中。

对象的分配优先在 TLAB上 分配,但 TLAB 通常都很小,所以对象相对比较大的时候,会在 Eden 区的共享区域进行分配。

TLAB 是一种优化技术,类似的优化还有对象的栈上分配(这可以引出逃逸分析的话题,默认开启)。这属于非常细节的优化,不做过多介绍,但偶尔面试也会被问到。

栈上分配

将线程私有的对象打散分配在栈上,优点是可以在函数调用结束后自行销毁对象,不需要垃圾回收器的介入,并且栈上分配速度快,提高系统性能,但缺点是栈空间小,对于大对象无法实现栈上分配。栈上分配的前提条件是开启了逃逸分析(-XX:+DoEscapeAnalysis):判断对象的作用域是否超出函数体,只有作用域没有超出函数体的对象才能栈上分配。如下,user的作用域超出了函数setUser的范围,是逃逸对象,不能进行栈上分配。栈上分配还有一个前提是开启标量替换 (-XX:-EliminateAllocations),逃逸分析和标量替换都是jvm默认开启的。

csharp 复制代码
private User user;
public void setUser(){
    user = new User();
    user.setId(1);
    user.setName("blueStarWei");
}

标量替换:简单地说,就是用标量替换聚合量。标量是指不可分割的量,如java中基本数据类型和reference类型,相对的一个数据可以继续分解,称为聚合量;如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换;如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量;

TLAB 分配

Thread Local Allocation Buffer, 线程本地分配缓存。一块线程专用的内存分配区域。TLAB占用的是eden区的空间(注意是堆上)。在TLAB启用的情况下(默认开启),JVM会为每一个线程分配一块TLAB区域。

优点是可以加速对象的分配。因为TLAB是线程专有区域,会减少线程同步操作,使分配的效率提高。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。

老年代

老年代一般使用"标记-清除"、"标记-整理"算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。

那么,对象是怎么进入老年代的呢?有多种途径。

1.提升(Promotion)

如果对象够老,会通过"提升"进入老年代。

关于对象老不老,是通过它的年龄(age)来判断的。每当发生一次 Minor GC,存活下来的对象年龄都会加 1。直到达到一定的阈值,就会把这些"老顽固"给提升到老年代

这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉。

这个阈值,可以通过参数 ‐XX:+MaxTenuringThreshold 进行配置,最大值是 15,因为它是用 4bit 存储的(所以网络上那些要把这个值调的很大的文章,是没有什么根据的)。

2.分配担保

看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。

3.大对象直接在老年代分配

超出某个大小的对象将直接在老年代分配。这个值是通过参数-XX:PretenureSizeThreshold进行配置的。默认为 0,意思是全部首选 Eden 区进行分配。

4.动态对象年龄判定

有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。

卡片标记(card marking)

你可以看到,对象的引用关系是一个巨大的网状。有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?

老年代是被分成众多的卡页(card page)的(一般数量是 2 的次幂)。

卡表(Card Table)就是用于标记卡页状态的一个集合,每个卡表项对应一个卡页。

如果年轻代有对象分配,而且老年代有对象指向这个新对象, 那么这个老年代对象所对应内存的卡页,就会标识为 dirty,卡表只需要非常小的存储空间就可以保留这些状态。垃圾回收时,就可以先读这个卡表,进行快速判断。

STW(Stop the world)

你有没有想过,如果在垃圾回收的时候(不管是标记还是整理复制),又有新的对象进入怎么办?

为了保证程序不会乱套,最好的办法就是暂停用户的一切线程。也就是在这段时间,你是不能 new 对象的,只能等待。表现在 JVM 上就是短暂的卡顿,什么都干不了。这个头疼的现象,就叫作 Stop the world。简称 STW。

标记阶段,大多数是要 STW 的。如果不暂停用户进程,在标记对象的时候,有可能有其他用户线程会产生一些新的对象和引用,造成混乱。

现在的垃圾回收器,都会尽量去减少这个过程。但即使是最先进的 ZGC,也会有短暂的 STW 过程。我们要做的就是在现有基础设施上,尽量减少 GC 停顿。

某个高并发服务的峰值流量是 10 万次/秒,后面有 10 台负载均衡的机器,那么每台机器平均下来需要 1w/s。假如某台机器在这段时间内发生了 STW,持续了 1 秒,那么本来需要 10ms 就可以返回的 1 万个请求,需要至少等待 1 秒钟。

在用户那里的表现,就是系统发生了卡顿。如果我们的 GC 非常的频繁,这种卡顿就会特别的明显,严重影响用户体验。

虽然说 Java 为我们提供了非常棒的自动内存管理机制,但也不能滥用,因为它是有 STW 硬伤的。

相关推荐
不太会写37 分钟前
dhtmlxGantt 甘特图 一行展示多条任务类型
android
中式代码美式咖啡40 分钟前
在Spring Boot中实现多环境配置
android·spring boot·后端
吾爱星辰2 小时前
Kotlin 抛出和捕获异常(十一)
android·java·开发语言·jvm·kotlin
alexhilton2 小时前
搞定在Jetpack Compose中优雅地申请运行时权限
android·kotlin·android jetpack
GEEKVIP5 小时前
摆脱困境并在iPhone手机上取回删除照片的所有解决方案
android·macos·ios·智能手机·电脑·笔记本电脑·iphone
呆萌小新@渊洁9 小时前
后端接收数组,集合类数据
android·java·开发语言
ByteSaid10 小时前
Android 内核开发之—— repo 使用教程
android·git
Tom哈哈11 小时前
Android 系统WIFI AP模式
android
ShawnRacine11 小时前
Android注册广播
android
麦克尔.马14 小时前
一个安卓鸿蒙化工具
android·华为·harmonyos