文章目录
JVM 整体架构,类加载器,运行时数据区
教程:https://www.bilibili.com/video/BV1PJ411n7xZ
Java 17 文档:https://docs.oracle.com/javase/specs/jvms/se17/html
1. JVM 整体架构
-
java 文件先编译为 class 文件,然后通过类加载器子系统进行加载,连接,初始化。
-
当所需的类加载进来放在内存(运行时数据区),结构如下,其中有:
- 线程共享的方法区,堆内存
- 线程私有的虚拟机栈,本地方法栈,程序计数器
-
执行引擎
- 解释器
- 即时编译器
- 分析器
- 垃圾回收器
2. 类加载器
2.1 类加载器子系统作用
- 负责从文件系统或者网络中加载 class 文件
- ClassLoader 只负责文件加载,文件是否运行由执行引擎决定
- 加载的类信息放在方法区,方法区存放运行时常量池(存放编译期的字符串字面量和数字常量,以及运行时的常量,如
String
类的intern()
方法),还存放已被加载的类的信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
一个在 class 中的类 → \to → ClassLoader → \to → 元数据模板
2.2 类加载过程
2.2.1 加载
- 通过一个类的全类名获取该类的二进制字节流
- 将这个字节流的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
加载 class 文件的方式:
- 本地文件系统
- 网络获取,Web Applet
- 压缩包读取,Jar,War
- 运行时计算生成,动态代理技术
- JSP
- 从加密文件中获取,防止 class 文件被反编译的保护措施
类加载时机:
- new 对象,创建类的实例
- 访问某个类或者接口的静态变量,或者对该静态变量赋值(除了被 final 修饰的变量)
- 调用类的静态方法
- 反射(如
Class.forName("com.mysql.cj.jdbc.Driver")
) - 初始化一个类的子类
- 系统启动执行的带有 main 方法的类
- 一个接口使用 JDK8 中的
default
关键字修饰的接口方法时,当这个接口的实现类被初始化,该接口需要在此之前被加载
2.2.2 链接
- 验证,确保 class 文件的字节流中包含的信息符合当前虚拟机的要求,保证加载类的正确性
- 准备,将类变量,即
static
修饰的变量初始化为 0,null,false。若是static final
修饰,则编译的时候就已经分配值了 - 解析,将常量池中的符号引用转换为直接引用
Note:
我对准备阶段的理解:应该类似之前学习 C++ 的时候,new 的对象因为是内存中直接分配的,所以该对象的值可能是随机的,一般都初始化为 0,false 或者 NULL。
2.2.3 初始化
- 执行类构造器方法
<clinit>()
,该方法不需要定义,由 javac 编译器收集所有类变量的赋值动作和静态代码块的语句合并而来。 - 构造器方法中的指令按照语句在源文件中出现的顺序执行。若代码块在一个变量定义之前,则代码块中可以对变量赋值,但是不能访问。
<clinit>()
和类的构造方法不同,类的构造方法为<init>()
- 若该类有父类,则在子类
<clinit>()
之前先执行父类的<clinit>()
- JVM 必须保证
<clinit>()
方法在多线程下被同步加锁
Note:
补充一个小基础知识,被 final 修饰的变量必须在定义的时候就赋值吗?
答案:不是,在构造方法中赋值也可以。
static { } 静态代码和 static 修饰的变量只会执行一次。
2.3 类加载器分类
从 JVM 的角度来看,只有两种类加载器,一种是 Bootstrap 类加载器,一种就是其他类加载器。
- Bootstrap 类加载器由 C++ 实现,是 JVM 的一部分,
- 而其它类加载器由 Java 实现,全部继承于抽象类
java.lang.ClassLoader
。
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 获取上层, 扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// 试图获取引导启动类加载器 null
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// 获取当前类的类加载器
ClassLoader classLoader = InitTest.class.getClassLoader();
System.out.println(classLoader);
// 加载字符串类型的类加载器, String 也是引导启动类加载器加载的
ClassLoader classLoader2 = String.class.getClassLoader();
System.out.println(classLoader2);
2.3.1 Bootstrap 类加载器
- 加载 Java 的核心库:
JAVA_HOME/jre/lib/rt.jar、resource.jar
或者sun.boot.class.path
路径下的内容,用于提供 JVM 自身需要的类 - 用于加载扩展类加载器
- 出去安全考虑,只加载包名为
java
、javax
、sun
等开头的类
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url);
}
2.3.2 Extension 类加载器
- 派生于 ClassLoader 类
- 上层加载器为 Bootstrap 类加载器
- 加载扩展目录中的内容:
JAVA_HOMEjre/lib/ext
或者java.ext.dirs
系统变量所指定的路径种的所有类库 - 允许用户创建的的 jar 包放在此目录下面,会自动加载
String exts = System.getProperty("java.ext.dirs");
for (String ext : exts.split(";")) {
System.out.println(ext);
}
2.3.3 System 类加载器 AppClassLoader
- 派生于 ClassLoader 类
- 上层加载器为 Extension 类加载器
- 负责加载环境变量
classpath
或者系统属性java.class.path
指定路径下的类库 - 程序默认的类加载器
2.3.4 自定义类加载器
为什么需要自定义类加载器:
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
自定义类加载器实现步骤:
- 继承 ClassLoader 重写 findClass() 方法
- 若没有复杂需求可以直接继承 URLClassLoader 类,这样可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
2.4 获取 ClassLoader 的途径
// 1. class
System.out.println(Class.forName("java.lang.String").getClassLoader());
System.out.println(InitTest.class.getClassLoader());
// 2.
System.out.println(Thread.currentThread().getContextClassLoader());
// 3.
System.out.println(ClassLoader.getSystemClassLoader());
2.5 双亲委派模型
2.5.1 引子
假如我创建一个 String 相同的包和相同的类,然后执行 main() 方法会发生什么?
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("hello world");
}
}
换个类名行不行?
package java.lang;
public class CCC {
public static void main(String[] args) {
System.out.println("hello world");
}
}
2.5.2 工作原理
- 一个类加载器收到类加载请求,他不会自己先去加载,而是把这个请求交给上层去执行,如上层依然有更上层的类加载器,则进一步向上委托,直到到达顶部的 Bootstrap 类加载器。
- 若上层加载器能完成加载就成功返回,若不能则由下层加载器尝试去加载。
因此可以解释,自定义的 String 类已经被上层加载器加载过了,因此该类中找不到 main 方法。第二个错误原因是该包下被 Bootstrap 加载器加载过了,所以下层加载器不能再对其加载,这也是 Java 沙箱安全机制中对于恶意代码所采取的防护措施,防止核心 API 被篡改。
Note:
JVM 两个 class 对象是一个类的必要条件:
- 完整类名相同
- 加载这个类的类加载器必须相同
3. 运行时数据区
3.1 内存结构
红色为线程共享的,灰色是线程私有的。方法区又叫元空间(永久代)。
空间 | 异常 | 垃圾回收 |
---|---|---|
程序计数器 | × | × |
本地方法栈 | √ | × |
虚拟机栈 | √ | × |
堆 | √ | √ |
方法区 | √ | √ |
3.2 线程
一个 JVM 允许有多个线程并行执行。
Hotspot JVM 中,每个线程都与操作系统的本地线程直接映射。
3.3 程序计数器(PC寄存器)
用来存储下一条指令的地址,就是说记录当前线程运行到哪里。
如果执行的是本地方法,指定的地址为未指定值(undefined)。
唯一没有规定 OutOfMemoryError
且不需要 GC 的区域。
Note:
使用javap -v class
路径 可以进行反汇编
面试题
- PC 寄存器为什么要记录当前线程的执行地址呢?
- 因为在多线程并发执行时,CPU 会切换线程执行,因此切换线程的时候,需要直到当前线程运行到哪里了。
- PC 寄存器为什么设置为线程私有?
- 为了准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是每个线程分配一个 PC 寄存器
3.4 虚拟机栈
3.4.1 定义
- 每个线程创建时都会创建一个虚拟机栈,内部保存一个个的栈帧(Stack Frame),对应一次次的 Java 方法调用。
- 是线程私有的。
- 栈中不存在垃圾回收问题
主要作用:
- 主管 Java 程序的运行,保存方法的局部变量,部分结果,并参与方法调用和返回
异常:
- 线程请求的栈的容量大于虚拟机所允许的容量,抛出
StackOverFlowError
异常 - 若虚拟机栈可以动态扩展,当在尝试扩展时无法申请足够的内存,或者创建新线程没有足够的内存去创建对应的虚拟机栈,抛出
OutOfMemoryError
异常
3.4.2 虚拟机栈出现的背景
为了跨平台的特性,Java 的指令都是根据栈来设计的。
因为不同平台的 CPU 架构不同,所以不能基于寄存器实现。
虚拟机栈好处:
- 跨平台
- 指令集小(多地址指令集还需要存放参数)
- 编译器容易实现(不需要考虑空间分配的问题,所需空间都在栈上操作)
虚拟机栈缺点:
- 性能相对于寄存器更低(频繁的栈操作,内存成为瓶颈)
- 实现相同的功能需要更多的指令
使用 1+1
的操作例子:
- 虚拟机栈,将两个常量压入栈,然后将两个值出栈相加放到栈顶,再存入第 0 个 slot:
iconst_1
iconst_1
iadd
istore_0
- 寄存器 mov 把 eax 寄存器设置为 1,add 指令将该值加上 1,结果保存在 eax 寄存器中:
mov eax, 1
add eax, 1
Note:
参考:https://blog.csdn.net/shockang/article/details/116676873
3.4.3 内存中的栈和堆
- 栈是运行时的单位
- 堆是存储的单位
栈解决程序的运行问题,程序如何执行,如何处理数据。
堆解决数据存储问题,数据怎么放,放在哪里。(堆主要负责的是对象存储,基本数据类型和引用类型的局部变量放在栈中)
3.4.4 设置栈内存大小
Note:
参数设置参考:https://docs.oracle.com/en/java/javase/11/tools/java.html
Windows 默认是虚拟内存大小,Linux 默认是 1024 KB
使用 -Xss
选项设置线程的最大栈空间,栈的大小直接决定了函数调用的最大深度。
Idea 可以通过以下操作设置参数:
/**
* count 值:
* 默认情况下: 11232
* 设置 -Xss256K: 2314
*
* */
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
3.4.5 栈帧:栈的存储单位
- 每个线程有自己的栈,栈中的数据都是以栈帧的格式存在。
- 在此线程正在执行的每个方法都各自对应一个栈帧
JVM 对栈的的操作就只有压栈和出栈,在活动线程中,一个时间点上,只有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。若在当前方法中调用其他方法,则对应的新的栈帧会被创建放在栈顶,成为新的当前栈帧。
不同线程所包含的栈帧不允许相互引用。
Java 方法有两种返回函数的方式,会导致栈帧弹出:
- 正常函数返回,
return
- 抛出异常
3.4.6 栈帧的内部结构
每个栈帧包含:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 一些附加信息
3.4.6.1 ★局部变量表
- 存储编译期间可知的各种基本数据类型,对象引用类型以及返回值地址类型
returnAddress
(一条指向字节码指令的地址)。局部变量表所需容量在编译期间已经确定下来,运行期间不会改变。 - 局部变量表中的变量是对象垃圾回收的根节点,只要被局部变量表中直接或者间接引用的对象都不会被回收。
- 重点性能调优最密切的部分
局部变量表最基本的存储单位:Slot
局部变量槽
- 其中每一个 Slot 占 32 bit,只有
double
和long
占两个 Slot,其余类型均占一个 Slot char
,byte
,short
,boolean
在存储之前都被转换为int
通过 javap -v
命令反汇编可以看到 double
和 long
的槽数为 2
:
public static void main(String[] args) {
int a = 10;
int b = 20;
double c = 0D;
long d = 0L;
boolean e = false;
}
其中 Start
为字节码指令起始位置,Length
表示作用范围。
如果当前的栈帧由构造方法或者实例方法创建,那么对象引用 this
会存在索引为 0 的 Slot 处:
栈帧中的 Slot 是可以重复利用的,可以看到变量 c
使用的是 b
之前使用的 Slot:
public void xxx () {
int a = 10;
{
int b = 10;
b += a;
}
int c = 10;
}
Java 局部变量在使用之前必须进行初始化,不像 C 中可以不赋值:
3.4.6.2 ★操作数栈
- 在方法执行过程中,根据字节码指令,向栈中写入数据或者提取数据,即入栈和出栈
- 主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间(类似寄存器?)
- 操作数栈是 JVM 执行引擎的工作区,一个方法刚开始执行时操作数栈是空的
- 若被调用的方法具有返回值,其返回值也会被压入当前栈帧的操作数栈中
3.4.6.3 栈顶缓存技术
又因操作数是存储在内存,每次操作都需要进行入栈出栈操作,必然会影响执行速度。
为了解决这个问题,HotSpot JVM 提出了栈顶缓存:
- 将栈顶元素全部缓存到物理 CPU 的寄存器中,以此来降低对内存的读写次数,提升执行引擎的执行效率。
3.4.6.4 ★动态链接
Java 文件编译成字节码文件时,所有的变量和方法引用都作为符号链接保存在 class 文件的常量池中。
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
- 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
3.4.6.5 方法的调用
静态链接:被调用的方法在编译时可知,且运行期间不变
动态链接:被调用的方法在编译期间无法被确定下来,运行期间才能确定
动态链接例子:多态,接口的实现
Note:
Java 中默认除了 invokestatic 和 invokespecial 指令调用的方法(除了final
修饰的)都是虚方法。
Java 是静态类型的语言,因为定义变量的时候需要指定类型,而动态类型的语言(JS,Python)是只有变量值有类型信息
invokedynamic 指令使用 lambda 表达式可以直接生成
虚方法表用于快速寻找子类未实现的方法
3.4.6.6 ★方法返回地址
存放该方法的 PC 寄存器(程序计数器) 的值。
若异常退出,返回地址需要通过异常表来确定。
Note:
静态代码块也算是一个没有返回值的类构造器
3.4.6.7 附加信息
栈帧中允许携带与 JVM 实现相关的一些附加信息,例如对程序调试提供支持的信息。
面试题
-
栈溢出的情况?
- Linux 默认 1M,可以通过
-Xss<size>
设置,当超出该容量会导致StackOverFlowError
,若整个内存空间不足会导致OutOfMemoryError
- Linux 默认 1M,可以通过
-
调整栈的大小,就能保证不出现溢出吗?
- 不能,若递归一直不终止那么仍然会出现溢出情况。
-
分配的栈内存越大越好吗?
- 不是,如果发生问题,问题发生的时间会延迟。
- 而且整个物理内存空间是有限的,当栈空间设置过大,其他的空间就会变小。
-
垃圾回收是否会涉及到虚拟机栈?
- 不会,代码块执行结束,局部变量直接出栈
-
方法中定义的局部变量是否线程安全?
- 具体问题具体分析
- 若一个局部变量内部产生内部消亡,一般是线程安全
- 若一个局部变量不是内部产生或者内部产生返回给外面,一般是线程不安全的
3.5 本地方法栈
本地方法(Native Method)是一个 Java 调用非 Java 代码的接口。
本地方法由 native
修饰。
为什么用本地方法?
- 需要与 Java 环境外的环境交互,若想要操作底层,只凭 Java 代码做不到
虚拟机栈是用于管理 Java 方法的调用,本地方法栈负责本地方法的调用,一样具有两种异常。
- 当线程调用一个本地方法,该方法进入了一个全新且不受 JVM 限制的空间,和虚拟机具有相同的权限
- HotSpot JVM 直接将本地方法栈和虚拟机栈合二为一了
3.6 堆
3.6.1 堆的核心概念
- 堆是 JVM 管理的内存中最大的一块
- 线程共享
- 《JVM 规范中》规定:
- 物理上可以不连续,但是逻辑上必须连续
- 所有的对象和数组都应该在堆上分配
- 对于大对象,多数虚拟机为了实现简单,存储高效的目的,很可能要求连续的空间
- ★从分配内存角度,线程共享的堆空间还可以划分线程私有的分配缓冲区(TLAB)
- 方法结束后,堆中的对象不会马上释放,需要垃圾收集的时候才能被移除
Visual VM
输入命令:jvisualvm
打开
安装插件 Visual GC 插件参考:https://blog.csdn.net/jushisi/article/details/109655175
3.6.2 堆的内存结构
现代 GC 大部分都是基于分代收集理论设计的,堆空间细分为:
Java 8 以后堆内存逻辑分为:
- 新生代 + 老年代 + 元空间(实际放在方法区)
新生代进一步划分:
- Eden 空间
- From Survivor 空间
- To Survivor 空间(S0 和 S1,谁空谁是 To)
-XX:+PrintGCDetailes
可以查看堆空间的细节,Java17 使用 -Xlog:gc*
选项。
或者 jps
查看 java 进程 id,然后使用 jstat -gc <pid>
查看。
3.6.3 设置堆空间大小
Xms<size>
:表示堆区的初始内存(年轻代+老年代),等价于-XX:InitialHeapSize
,默认物理内存/64
Xmx<size>
:堆区的最大内存(年轻代+老年代),等价于-XX:MaxHeapSize
,默认物理内存/4
查看内存:
Runtime runtime = Runtime.getRuntime();
longinit = runtime.totalMemory() / 1024 / 1024;
long max = runtime.maxMemory() / 1024 / 1024;
log.debug("Xms: {}", init);
log.debug("Xmx: {}", max);
通常会将初始内存和最大内存设置相同的值,目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
当堆区的内存超过最大内存所指定的值将会抛出 OutOfMemoryError
:
public class InitTest {
public static void main(String[] args) throws InterruptedException {
List list = new ArrayList();
while (true) {
list.add(new byte[1024 * 1024]);
}
}
}
3.6.4 新生代和老年代
设置新生代和老年代在堆结构的占比:
-XX:NewRatio=4
表示新生代占 1,老年代占 4
Eden 和两个 Survivor 空间默认比例为:8:1:1
(实际上并不一定是该比例,是自适应的):
-XX:SurvivorRatio=8
手动设置8:1:1
几乎所有的对象都在 Eden 区域被 new 出来。
绝大部分的对象的销毁都在新生代中进行。
-Xmn<size>
设置新生代最大内存,一般默认即可
3.6.5 新生代对象分配和回收过程
首先创建对象在 Eden 区域,当 Eden 区域满时,会进行垃圾回收 YGC/MinorGC(垃圾回收时会将S0,S1 一起回收)。Eden 中还在使用的放入 From Survivor 区,并将对象的 age++(初始为 1)。
一段时间后,若 Eden 区域又满,Eden 中还在使用的对象以及 From Survivor 中的对象一起放入 To Survivor 区,将对象的 age++。
重复此过程,当对象的 age 达到 15 (默认)时会放入老年代。
-XX:MaxTenuringThreshold=<N>
进行设置放入老年代的 age 阈值
Note:
jinfo -flags <pid>
查看虚拟机设置参数
总结和问题
总结:
- 针对 S0,S1 区域,复制之后谁空谁是 To Survivor
- 关于垃圾回收,频繁在新生代,很少在老年代,几乎不在元空间
问题:
- 什么时候 Eden 区域的对象会直接到老年代?
- YGC 的时候,Eden 中的对象 S0,S1 都放不下,那么直接放入老年代
- 超大对象,Eden 放不下,老年代能放下,直接放入老年代
- 如果 S0,S1 满了,即使 age 没到 15,也会放入老年代
3.6.6 Major GC,Major GC,Mixed GC,Full GC
- Minor GC/Young GC:只针对新生代(Eden,S0,S1)的垃圾回收
- Major GC/Old GC:针对老年代的垃圾回收(只有 CMS 收集器有单独收集老年代)
- Mixed GC:针对整个新生代和部分老年代的垃圾回收(只有 G1 收集器有该行为)
- Full GC:针对整个堆和方法区的垃圾回收
Minor GC 触发机制:
- Eden 区域满,S0,S1 区域满不会触发(会直接尝试放入老年代)
- Minor GC 会导致 STW,暂停其他用户线程,等垃圾回收结束,用户线程继续执行,大部分对象是很快死亡的,因此 Major GC 很频繁,回收速度较快
Major GC 触发机制:
- 老年代空间不足,会先尝试触发 Minor GC,若空间还不足,则触发 Major GC
- Major GC 速度比 Minor GC 慢 10 倍以上,STW 时间更长
- 若 Major GC 之后还是内存不足,就会报
OOM
Full GC 触发机制:
- 调用
System.gc()
,系统会建议执行 Full GC,不是一定执行 - 老年代空间不足
- 方法区/元数据空间不足
- 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
- 开发和调优中要尽量避免
3.6.7 为什么要分代?
不分代也可以,分代是为了优化 GC 的性能,如果没有分代,所有的对象都在一起,每次 GC 都要在全部对象中找已经不再使用的对象,效率太低。
3.6.8 内存分配策略
- 优先分配到 Eden
- 大对象直接分配到老年代(尽量避免出现过多大对象)
- 长期存活的对象放进老年代
- 动态对象年龄判断:S 区相同年龄的所有对象大小的总和 > S 区的一半,年龄 >= 该年龄的对象可以直接进入老年代(G1 收集器中单独设置了一个 Humongous 区域存放大对象)
3.6.9 TLAB 线程私有的分配缓冲区
由于堆内存是线程共享的,并发状态下从堆中划分内存空间是线程不安全的,为了避免多个线程操作同一个地址,需要使用加锁等机制,影响性能。
JVM 给每一个线程分配了一个私有缓冲区,包含在 Eden 空间中。
- TLAB 空间只占 Eden 总空间的
1%
-XX:TLABWasteTargetPercent
可以设置 TLAB 所占 Eden 空间的百分比jinfo -flag UseTLAB <pid>
可以查看进程是否使用 TLAB(默认开启)
多个线程同时分配内存时,使用 TLAB 可以避免非线程安全的问题,同时提升内存分配的效率,因此我们将这种内存分配方式称为快速分配策略。
对象分配过程:TLAB
3.6.10 常用参数
-XX:+PrintFlagsInitial
查看 JVM 所有参数的默认值-XX:+PrintFlagsFinal
查看 JVM 所有参数可能进行修改后的最终值jinfo -flag 参数 <pid>
可以查看进程的某个参数的值-XX:PrintGCDetails
垃圾回收详细情况(Java 8)-Xlog:gc*
垃圾回收详细情况(Java 17)-XX:SurvivorRatio=<n>
设置 Eden 区和 Survivor 区比值-XX:NewRatio=<n>
设置老年代和新生代的比值
3.6.11 对象只能在堆中存储吗?
若经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。
这样就不用在堆上分配内存了,也不需要垃圾回收了。
Java 6u23 之后默认已经开启了逃逸分析。
没有发生逃逸的对象,可以分配到栈上,随着方法执行的结束,栈帧也随之被移除:
public void fun() {
List<Object> list = new ArrayList<>();
// ...
list = null;
}
发生逃逸的对象,方法中 new 的对象作为返回值,会放在堆中:
public List fun() {
List<Object> list = new ArrayList<>();
// ...
return list;
}
Note:
简单讲就是如果 new 的对象只在方法内使用,那么就是没有逃逸,即时编译器会进行优化,在栈上分配空间!
3.6.11 JVM 如何对代码进行优化
- 栈上分配
- 同步省略
- 分离对象或者标量替换
3.6.11.1 栈上分配(其本质是标量替换!)
因为栈上的局部变量表存储单位是 Slot,是没办法存对象的,其本质就是标量替换,然后在进行存储!
通过下面的例子可以看到 JVM 这么做的好处:
@Slf4j(topic = "c.InitTest")
public class InitTest {
static void invoke() {
User user = new User("张三", 150);
}
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000_0000; i++) {
invoke();
}
log.debug("用时: {} ms", System.currentTimeMillis() - start);
TimeUnit.SECONDS.sleep(1000);
}
}
class User {
String name;
Integer age;
public User(String name, Integer age) {
this.name = name;
this.age = age;
}
}
使用 -XX:-DoEscapeAnalysis
关闭逃逸分析,执行结果:
打开逃逸分析(不使用任何参数,默认就是打开状态),执行结果:
可以看到速度提升显著!
3.6.11.2 同步省略
若一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
Note:
简单讲就是如果一个对象只能被一个线程访问,就没必要上锁了,即使你上了锁,即时编译阶段会进行优化,把锁去掉。
3.6.11.3 分离对象或标量替换
这里的标量是指无法再分解成更小的数据的数据,比如基本数据类型。
如果一个对象不会被在方法外使用,那么即时编译阶段进行优化后会把整个对象拆成若干个成员变量来使用。
默认开启,可以设置 -XX:-EliminateAllocations
关闭。
Note:
标量替换就是栈上分配的本质!
3.7 方法区
Note:
方法区只是逻辑上的叫法,元数据区和永久代是落地的实现。
3.7.1 栈,堆,方法区的交互
从线程共享与否的角度:
存储的角度:
3.7.2 方法区基本理解
- 方法区的大小决定了系统可以保存多少个类,如果系统中定义了太多的类,导致方法区溢出,虚拟机同样会抛出
OutOfMemoryError
- Tomcat 部署的工程过多;大量动态生成反射类;加载大量的第三方的 jar 包
- 关闭 JVM,该区域就会释放
- Java 7 以前习惯叫永久代,Java 8 开始习惯叫元空间;本质上对于 HotSpot,方法区和元空间并不等价。
3.7.3 设置方法区大小
-XX:MetaspaceSize=<size>
设置元空间初始大小(默认 21M)-XX:MaxMetaspaceSize=<size>
设置最大元空间大小(默认没有限制)
Note:
若初始元空间过小,加载的类信息容量超过该值,会触发 Full GC。为了避免频繁发生 Full GC,建议将-XX:MetaspaceSize=<size>
设置为一个相对较高的值。
出现 OOM 怎么办?
- 首先通过内存映像文件分析工具,如 Java VisualVM,JProfiler 对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是先分清楚到底是出现了内存泄露还是内存溢出
- 内存泄露指的是,对象不再使用,但是一直存在 GC Roots 的一条引用链,导致其无法进行垃圾回收。可以通过分析定位泄露代码的位置
- 内存溢出得情况可以根据物理内存适当调大,代码上检查是否某些对象生命周期过长,可以尝试减少程序运行期间的内存消耗
3.7.4 方法区内部结构
- 类型信息(域信息(成员变量),方法信息)
- 常量
- 静态变量
- 即时编译器编译后的代码缓存
Note:
被处理为static final
的类变量在编译的时候就会被分配值
常量池和运行时常量池
类字节码中的常量池中存放编译期间生成的各种字面量以及符号引用。
当类的字节码被加载后放入方法区,它的常量池信息就会集中放入到一块内存,这块内存就称为运行时常量池,并且把里面的 符号地址
变为 真实地址
。
Note:
为什么用常量池?
- 常量池是为了避免频繁的创建和销毁对象而影响系统的性能,其实现了对象的共享。动态链接的时候会用到运行时常量池。
3.7.5 JDK 8 之后的方法区
- 类型信息,字段,方法,常量保存在本地内存的元空间(本地内存!本地内存!本地内存!)
- 字符串常量、静态变量放在堆中
Note:
为什么要移除永久代?
官方:为了融合 HotSpot 和 JRockit ,JRockit 中没有永久代。
- 永久代空间设置无法确定
- 永久代调优困难(一个类是否不再使用的判断比较麻烦,因此移除永久代,放在直接内存)
字符串常量池(StringTable)为什么为什么放在了堆?
- 永久代垃圾回收效率低,只有在 Full GC 的时候才触发,Full GC 在老年代空间不足,永久代空间不足的时候才触发,因此 StringTable 的回收效率不高,但是在开发中会有大量的字符串被创建,回收效率很低,导致永久代内存不足。放到堆中,能及时回收内存。
3.7.6 方法区的垃圾回收
方法区的垃圾回收主要针对两部分内容:
- 常量池中废弃的常量(常量池中的常量没有被其他地方使用,就可以被回收)
- 不再使用的类型(判断一个类不在使用,条件比较苛刻)
- 该类的所有实例都已经被回收,即 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的加载器已经被回收
- 该类对应的 java.lang.Class 对象没有在任何地方引用,无法在任何地方使用反射访问该类的方法
Note:
在大量使用反射、动态代理、CGLib 等字节码框架,通常需要 JVM 具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
面试题
内存示意图:
- JVM 内存模型,有哪些区?分别的作用?
- JVM 中堆和栈的区别,堆的结构?为什么两个 Survivor 区?
- Eden 和 Survivor 的比例分配?
- 8:1:1
- JVM 内存分区为什么要有新生代和老年代?
- 每次都进行 Full GC 代价太大,而且很多对象都是很快死亡的(使用时间短),没必要放太久
- 什么时候对象会进入老年代?
- JVM 内存为什么要分为新生代,老年代?新生代为什么要分为 Eden 和 Survivor?
- JVM 方法区会进行 GC 吗?什么时候会发生 GC?
3.8 对象实例化内存布局与访问定位
3.8.1 对象实例化
3.8.1.1 创建对象的方式
new
,最常见的方式,若构造私有化,一般使用静态方法获取实例(单例模式),XxxBuilder/XxxFactory 的静态方法获取实例(工厂模式)- Class 的
(因为只能调用空参的构造器),Java 9 弃用,改为newInstance()
getDeclaredConstructor().newInstance()
(可以调空参、带参的构造器,权限没有要求) clone()
,不调用任何构造器,当前类需要实现Cloneable
接口并实现clone()
- 使用反序列化,从文件中、网络中获取一个对象的二进制流
- 第三方库,
Objenesis
3.8.1.2 对象创建的步骤
- 先判断对象对应的类是否加载、链接、初始化,没有的话就加载类元信息
- 为对象分配内存
- 如果内存规整:指针碰撞,类似追加,指针后移
- 如果内存不规整:虚拟机维护一个空闲列表,记录哪些内存是可用的,分配的时候从列表中找一块足够大的划分给实例
- 处理并发安全问题
- 采用 CAS 失败重试,区域加锁保证更新的原子性
- 每个线程预先分配一块 TLAB
- 初始化分配到的空间(属性默认初始化,零值初始化
0,false,null
) - 设置对象的对象头
- 执行 init 方法进行初始化(属性显式初始化,代码块中初始化,构造方法初始化)
3.8.2 对象的内存布局
- 对象头
- 实例数据
- Padding
3.8.3 对象访问定位
JVM 如何通过栈帧中的对象引用,找到其内部的对象实例呢?
- HotSpot 采用的直接指针
- 句柄(好处是标记整理的时候,只需要更改句柄就可以)
面试题
- 对象在 JVM 中是怎么存储的?
- Java 对象头信息里面有哪些东西?
3.9 直接内存
元空间就在直接内存!!!
直接内存是直接向系统申请的内存区间。
基于 NIO,直接申请物理内存空间:
ByteBuffer.allocateDirect(BUFFER_SIZE);
需要大量 IO 操作的业务中可以使用直接内存,性能更高
- 直接内存大小直接取决于 OS 能给出的大小,若超出该大小会抛出
OOM
- 直接内存大小可以通过
-XX:MaxDirectMemorySize=<size>
设置,默认和-Xmx
一样
缺点:
- 不受 JVM 管理
- 分配回收成本高