关于jvm:Java开发者需要明白的最小JVM知识点

7次阅读

共计 4955 个字符,预计需要花费 13 分钟才能阅读完成。

先说点废话

咱们好多 java 开发者,代码编写了很多年,然而要么始终是在做 CRUD 的活儿,要么就是不停的学习各种框架怎么应用,对于 java 最最根底的 JVM 局部,除了在最开始学习 java 的时候有过一些隐约的印象。

晓得 JVM 是 java 编译后可能跨平台运行的根底,也晓得能够应用 -Xms 和 -Xmx 配置堆内存大小,然而对于什么是堆内存,jvm 就只有堆内存吗?jvm 外部各个区域的构造是怎么样的,为什么要这么设计?代码在 jvm 外部是怎么执行的,并没有一个清晰的概念。

对于一般开发者来说,如果始终是在编写业务代码的,仿佛不理解这些也并不影响,可能也确实没啥影响。

不过我想,但但凡对技术有点谋求的,还是须要去理解一下 jvm 的内部结构的。包含咱们去面试一些 java 岗位的时候,jvm 都是必问的。如果面试的时候一家公司连 jvm 都不问,那基本上能够必定这家公司的 java 技术团队是挺拉跨的。

对于 jvm 的学习,其实和 java 源码、框架原理的学习有点相似。

很多时候咱们会有一种感觉,不往下深刻学习,也不影响把货色做进去。

然而真的如此吗?大家能够尝试一下深刻学习一门技术,而后再回过头去看看先前对该常识的利用,必定会有不一样的领会。

JDK、JRE 与 JVM

这是一张 Java 世界中比拟有名的图了,

位于最底层的,是各类操作系统,往上一层,就是咱们的 JVM 了,这里大家可能会有个疑难,JVM 怎么还辨别 Client 和 Server?

这其实是 JVM 的两种运行模式,Client 模式启动速度较快,Server 模式启动较慢;然而启动进入稳定期长期运行之后 Server 模式的程序运行速度比 Client 要快很多。这是因为 Server 模式启动的 JVM 采纳的是重量级的虚拟机,对程序采纳了更多的优化;而 Client 模式启动的 JVM 采纳的是轻量级的虚拟机。所以 Server 启动慢,但稳固后速度比 Client 远远要快。

要想查看以后 jvm 是以何种模式运行的,咱们的 jvm 默认都是以 server 模式运行的

 mac@MACs-iMac-2  ~  java -version
java version "11.0.10" 2021-01-19 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.10+8-LTS-162)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.10+8-LTS-162, mixed mode)

JVM 和其余 java 程序运行所需的根底类库,组成了 JRE,即 Java Runtime Environment

JRE 联合开发应用的工具包,则造成了整个 JDK

JDK 加上其余所有基于 Java 开发的各种类库、框架,就造成了咱们的 Java 生态

当然,这里只是从语言层面上比拟不那么谨严的这么一说,真正的 Java 生态,要简单的多。

根本堆栈构造

JVM 在启动后,会将内存分为两大类,
一类是跟着线程存在而存在的内存空间,包含程序计数器、虚拟机、本地办法栈
一类是所有线程共享的区域,包含堆、办法区。

第一类空间,存储的是以后正在执行的线程的局部变量、办法参数,会随着线程的终止而开释
堆空间,存储 new 进去的对象是 JVM 中占用空间最大的一个区域,
办法区,存储的是类信息、常量、动态变量等等

而咱们对 JVM 的调优,次要就是设置各项存储空间的大小、比例,以及前面会讲到的各项 GC 参数

聊聊线程隔离区

其实对于 jvm 调优来说,线程隔离区不是重点,须要配置的参数也不多,不过因为内容不多,所以还是简略的来介绍一下。

线程隔离去所有的内存空间占用,都会随着线程的完结而开释

程序计数器

存储的是 以后线程所执行的字节码的行号指示器。字节码解释器通过线程的程序计数器的值,晓得以后执行到哪一行,下一步执行哪一步。不过不同于咱们的 java 代码,jvm 层面的一行和 java 代码的一行往往不是一个概念,如下代码

package com.zhangln;

/**
 * @author sherry
 * @date 2021 年 08 月 11 日 8:00 下午
 */
public class HelloWorld {public static void main(String[] args) {
        int a = 1;
        int b = 2;
        System.out.println(a + b);
    }
}

当我把它编译后,再应用 javap 命令进行反编译

  master ●✚  javap -c HelloWorld.class
Compiled from "HelloWorld.java"
public class com.zhangln.HelloWorld {public com.zhangln.HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: iload_1
       8: iload_2
       9: iadd
      10: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      13: return
}

这才是 jvm 层面上理论执行的内容

具体的每一行代表什么意思,咱们须要联合 jvm 指令手册来进行浏览,我这里就不赘述了。

每个线程都有本人的程序计数器,其理论记录的是正在执行的虚拟机字节码指令的地址。
如果正在执行的是本地(Native)办法,这个计数器值则应为空(Undefined)。此内存区域是惟一一个在《Java 虚拟机标准》中没有规定任何 OutOfMemoryError 状况的区域。

  • 为什么须要程序计数器呢?

咱们举一个单核多线程的例子就明确了,如果没有程序计数器,当 CPU 去执行其余线程的时候,当操作系统将 CPU 的执行权限再次给到了以后线程,程序是不晓得从哪里开始执行的,总不能从头开始执行吧。

本地办法栈

本地办法栈的作用和虚拟机栈是相似的,不同之处在于本地办法栈中的内存开销,服务的是 native 办法。个别都是 c /c++ 编写的。

虚拟机栈

虚拟机栈,也能够称为线程栈,它的生命周期与线程雷同。存储的是各种局部变量、操作数等等。
不同的存储内存,存储在不同的栈帧中。

栈帧:在线程栈外部,每执行一个办法,则开拓一个内存空间,遵循 FILO 准则。局部变量就是放在一个个办法栈帧中的。

栈帧用于存储局部变量表、操作数栈、动静连贯、办法进口等信息
每一个办法被调用直至执行结束的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表寄存了编译器可知的各种 Java 根本类型和对象援用类型

如果线程申请的栈深度大于虚拟机所容许的深度,将派出 StackOverfowError
如果虚拟机栈容量能够动静扩大,当栈扩大时无奈申请到足够的内容就会抛出 OOM

每个线程栈的大小,通过 -Xss 设置,如 -Xss128k。JDK5 当前默认 1M,之前是 256k。

雷同物理内存下,缩小这个值,可能生成更多的线程,不过操作系统还是会限度一个过程可能产生的线程数的,也不是可能有限生成的

办法区

办法区:在 jdk8 之前,叫永恒代,jdk8 开始,改名叫元空间(不过他们俩并不是等价的)。存储的内容包含 常量、动态变量、类信息

运行时常量池:是办法区的一部分,Class 文件中除了有类的版本、字段、办法、接口等形容信息外,还有一项信息是常量池表,用于寄存编译期生成的各种字面量与符号援用
这部分内容将在类加载后寄存到办法区的运行时常量池中

当常量池无奈申请到内存时,也会抛出 OOM

对于 64 位 JVM 来说,默认初始大小为 20.75M,默认最大值是有限的。

通过 -XX:MetaspaceSize= N 和 -XX:MaxMetaspaceSize= N 调整初始值和最大值,个别设置成一样大,因为元空间的调整须要 full gc,这个是十分低廉的操作

堆内存

以后大多数商业虚拟机的垃圾收集器,都遵循分代收集的实践进行设计

弱分代假说:绝大多数对象都是朝生夕灭的
强分代假说:熬过越屡次垃圾收集过程的对象就越难以沦亡

所以咱们罕用的垃圾收集器,都有一个共性:将 Java 堆分出不同的区域,根据回收对象的年龄,调配到不同的区域中进行存储。不同区域的对象对应不同的分代年龄,同时也对应不同的垃圾收集频率

在 Java 堆划分出不同的区域之后,垃圾收集器才能够每次只回收其中某一个或者某些局部的区域——因此才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分

也才可能针对不同的区域安顿与外面存储对象存亡特色相匹配的垃圾收集算法——因此倒退出了“标记 - 复制算法”“标记 - 革除算法”“标记 - 整顿算法”等针对性的垃圾收集算法

1、堆内存分为老年代和年老代,默认占比为 2:1

2、年老代中分为 Eden 和 s0、s1,默认占比为 8:1:1

3、新 new 进去的对象,先进入 eden

4、eden 满了后,触发 minor gc 后,采纳可达性算法,无援用的间接革除

5、minor gc 时,在 eden 标记的非垃圾对象,从 eden 区转移到 s0 区

6、minor gc 时,在 s0 区找到的非垃圾对象,从 s0 转移到 s1 区,垃圾间接革除

相当于每一次 gc,年老代中的对象,要么被垃圾回收,要么在 eden、s0、s1 之间进行一次区域转移,留神,程序只可能是 eden—>s0—>s1。每个对象会被标记以后曾经经验了几次 gc

7、当对象经验过 15 次 gc 还没被干掉,则转移到老年代

至此,咱们应该对堆内存有了一个大略的印象了。

堆内存调优实战

案例 1:s0 空间过小导致的老年代堆空间问题

通过接口状况,计算出每秒钟新生成的对象大略有多少,咱们以每个对象 1kb 进行计算,假如算下来每秒钟生成 60MB 的堆内存对象

如果咱们只设置了堆内存大小(-Xms -Xmx),那么就会依照老年代:年老代 2:1,edge:s0:s1 8:1:1 的比例进行堆内存的调配

景象:频繁的触发 full gc,即老年代老是不够用

起因:当一次 minor gc 的时候,局部对象逃过了被革除的命运,失常是应该从 eden 到 s0 去的,然而,如果这部分内存的大小超过了 s0 大小的一半,就会被间接挪到老年代。这就会导致老年代不够用,几轮 minor gc 后就触发 full gc

解决办法:将 JVM 配置改为

将老年代的内存空间放大,空间给年老代。
留神:eden 和 s0、s1 之间的空间大小比值,是通过大量计算后得出的最优值,个别不会进行调整,所以这里就调整了年老代和老年代的比值,就达到了成果
这里的解决思路,就是让那些长期对象,尽可能在年老代中就被 gc 革除,而不要让他们因为某些起因,被转移到老年代中

案例 2:eden 过大导致的垃圾回收不及时问题

在案例 1 中,除了咱们通过调配年老代和老年代之间的内存大小,来达到让垃圾内存在年老代就被革除掉的目标,其实还能够间接通过扩充堆内存的形式来进行,而后依照默认比例,各个堆内存空间同比放大了。

可是内存难道就是越大越好吗?

所谓物极必反,如果内存空间很大,那么势必 eden 空间也很大,长期都无奈触发 minor gc,一旦达到 gc 条件了,一次性要革除的对象就会十分多。这就导致 STW 的工夫变长,升高 gc 时刻零碎的可用性 / 吞吐量

那么该怎么解决大内存下的 gc 问题呢?
思路:不要等到 eden 满了再去回收,能够设置触发 gc 的时候,只回收局部区域的内存。这样就能保障一次 gc 的时候 SWT 的工夫不会太长

可能实现这种垃圾回收形式的,就是 G1 垃圾回收器,实用于大内存的场景

怎么配置呢?-XX:UseG1GC -XX:MaxGCPauseMillis=100,这里的意思是每次 gc 的时候最大进展工夫为 100 毫秒,默认为 200 毫秒

java-Xms64G-Xmx64G-Xss1M-XX:+UseG1GC-XX:MaxGCPauseMillis=100 -XX:MetaspaceSize=512M
 -XX:MaxMetaspaceSize=512M -jar micro service-eureka-server.jar
正文完
 0