先说点废话
我们好多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."":()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