JVM01_JVM的基本结构

一、JVM 概述

(一)Java 生态圈

​ JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

​ Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够 "一次编译,到处运行" 的原因。

​ ⏬Java虚拟机根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。Java不是最强大的语言,但是JVM是最强大的虚拟机。

image-20230709103349629

(二)JVM 的历史沿革

​ 2000年,JDK 1.3发布,Java HotSpot Virtual Machine正式发布,成为Java的默认虚拟机。

​ 2002年,JDK 1.4发布,古老的Classic虚拟机退出历史舞台

​ 2003年年底,Java平台的Scala正式发布,同年Groovy也加入了 Java阵营

​ 2004年,JDK 1.5发布。同时JDK 1.5改名为JavaSE 5.0

​ 2006年,JDK 6发布。同年,Java开源并建立了 OpenJDK。顺理成章,Hotspot虚拟机也成为了 OpenJDK中的默认虚拟机

​ 2007年,Java平台迎来了新伙伴Clojure

​ 2008 年,Oracle 收购了 BEA,得到了 JRockit 虚拟机

​ 2010年,Oracle收购了Sun,获得Java商标和最具价值的HotSpot虚拟机。此时,Oracle拥有市场占用率最高的两款虚拟机 HotSpot 和JRockit,并计划在未来对它们进行整合:HotRockit。JCP组织管理:Java语言。

​ 2011年,JDK7 发布。在 JDK 1.7u4 中,正式启用了新的垃圾回收器 G1

​ 2017年,JDK9 发布。将 G1 设置为默认 GC,替代 CMS。同年,IBM的J9开源,形成了现在的Open J9社区。

​ 2018年,JDK11发布,LTS 版本的 JDK,发布革命性的 ZGC,调整JDK授权许可。

(三)有关面试题

1、什么是Java虚拟机(JVM),为什么要使用?

​ 虚拟机:指以软件的方式模拟具有完整硬件系统功能、运行在一个完全隔离环境中的完整计算机系统 ,是物理机的软件实现。常用的虚拟机有VMWare,Visual Box,Java Virtual Machine(Java虚拟机,简称JVM)。

2、说说Java虚拟机的生命周期及体系结构。

​ 虚拟机的启动

​ Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。

​ 虚拟机的退出 - 有如下的几种情况:

​ ①某线程调用Runtime类或System类的exit方法,或 Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。

​ ②程序正常执行结束

​ ③程序在执行过程中遇到了异常或错误而异常终止

​ ④由于操作系统出现错误而导致Java虚拟机进程终止

3、JVM 的组成

image-20230709103410811

image-20230709104005666

​ 最上层:javac 编译器将编译好的字节码class文件,通过 java 类装载器执行机制,把对象或 class 文件存放在 jvm 划分内存区域。

​ 中间层:称为 Runtime Data Area,主要是在 Java 代码运行时用于存放数据的,从左至右为方法区(永久代、元数据区)、堆(共享,GC 回收对象区域)、栈、程序计数器、寄存器、本地方法栈(私有)。

​ 最下层:解释器、JIT(just in time)编译器和 GC(Garbage Collection,垃圾回收器)

二、JVM 的内存结构

(一)程序计数器(PC)

​ 程序计数器会记住下一条 JVM 执行指令的执行地址。

1、java文件到机器码

graph LR java源代码 --javac--> 字节码文件 --解释器--> 机器码 --> CPU

​ 使用插件 JClassLib 即可看到一个简单程序编译成 class 字节码的形式。

image-20230709110328493

2、程序计数器的特点

​ (1)线程私有

​ 每个线程都有自己的程序计数器,为何这样设计捏😄,相信学过操作系统的同学都知道,单核多线程的实现是 CPU 资源的轮流占有,也就是说当程序 A 的时间片被用完释放 CPU 之后,程序 B 才能抢占到 CPU 资源;但是当再一次轮到 A 的时候,A 需要 "恢复现场",也就是紧接着自己原来的进度执行,所有每一个线程都应该有自己的 PC 就很有必要了。

​ (2)不存在内存溢出

​ PC 部分是唯一一个不会出现内存溢出的内存区域。程序计数器仅仅只是一个运行指示器,它所需要存储的内容仅仅就是下一个需要待执行的命令的地址,无论代码有多少,最坏情况下死循环也不会让这块内存区域超限,因为程序计算器所维护的就是下一条待执行的命令的地址,所以不存在OutOfMemoryError。

(二)虚拟机栈(JVM Stack)

1、栈和栈帧

​ 虚拟机栈即线程运行所需要的内存空间,每个栈有多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。每一个线程只能有一个活动栈帧,对应的就是当前正在执行的方法。

​ 局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了。局部变量应该和方法共生死。此时你应该会想到调用栈的栈帧,调用栈的栈帧就是和方法同生共死的,所以局部变量放到调用栈里那儿是相当的合理。事实上,的确是这样的,局部变量就是放到了调用栈里

image-20230709114130278

​ 通过一段简单的调试就清晰的看到 m、m1、m2 方法的出入过程。

image-20230709112918511

2、栈内存的问题

(1)GC 是否涉及到栈内存。

​ 栈内存负责方法的调用,当程序结束之后所有的 Frame 应该依次出栈,GC 只会负责堆内存。

(2)栈内存分配越大越好吗

​ 栈内存可以通过 -Xss size进行指定,栈内存分配的越多反而会导致线程数的减少,内存固定的情况下栈占用的内存增加必然导致线程整体变大,内存中所能承载的线程数就会减小。栈内存越大导致 SOF 的概率越小,即对递归调用有更强的容忍性。

(3)方法内的局部变量是否为线程安全。

​ 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的。

​ 如果是局部变量引用了对象,并逃离方法的作用方法,需要考虑线程安全。

3、逃逸分析

​ 逃逸分析是JVM优化技术,它不是直接优化手段,而是为其它优化手段提供依据,主要就是分析对象的动态作用域。

逃逸有两种:方法逃逸和线程逃逸。

​ 方法逃逸(对象逃出当前方法):当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中。

​ 线程逃逸((对象逃出当前线程):这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

​ 我们都知道 Java中StringBuilder 在动态拼接字符串时,它的方法没有被设计成线程安全的,在多线程环境下使用StringBuilder 可能会导致竞争条件和不确定的行为。应该使用线程安全的StringBuffer。StringBuffer的方法都是同步的,因此多个线程可以安全地同时修改同一个StringBuffer实例。

​ (1)下面这段代码定义了成员变量 StringBuilder sb ,由于是单线程所以不存在线程安全问题。

public static void main(String[]args){
    //线程安全
	public static void m1(){
		StringBuilder sb = new StringBuilder();
		sb.append(1);
		sb.append(2);
		sb.append(3);
		System.out.println(sb.tostring());
       	}

​ (2)StringBuilder 对象作为参数传给 m2() 方法进行拼接操作,同时在 main 线程中同样对该对象进行了操作。

​ 多线程操作 StringBuilder 可能造成以下结果:

​ 🔄多个线程同时调用append方法,导致字符串被部分覆盖或者串联出现错误的结果。

​ 🔄多个线程同时调用insert方法,可能导致插入的字符串交错,结果不一致。

​ 🔄多个线程同时调用delete或replace方法,可能会导致字符串被删除或替换出现错误的结果。

​ 所以下面这段代码是线程不安全的。

public static void m2(StringBuilder sb){
	sb.append(1);
	sb.append(2);
	sb.append(3);
	System.out.println(sb.tostring());
   	 }
public static void main(string[] args){
	StringBuilder sb = new StringBuilder();
	sb.append(4);
	sb.append(5);
	sb.append(6);
	new Thread(()->{
		m2(sb);
	})start();
}

​ (3)StringBuilder 是在方法的内部变量,而此时它被直接返回,这样 StringBuilder 就有可能被其他地方的方法或参数所改变,这样它的作用域就不只是 m3 了,虽然它是一个局部变量,但其发生了 “逃逸”。所以下面的代码也不是线程安全的。

public static StringBuilder m3(){
	StringBuilder sb new StringBuilder();
	sb.append(1);
	sb.append(2);
	sb.append(3);
	return sb;
   }

4、栈溢出

(1)栈帧过多导致SOF

​ 最常见的就是递归调用或者死循环,如果条件不正确 Frame 不停的入栈,栈内存在多也会最终溢出。

(2)栈帧过大

​ 当一个栈帧特别大,可能进了几个就直接 SOF 了,可能全局变量是否过多、数组、List、Map数据过大等,不过这种情况比较少见,因为 Linux、MacOS的默认栈内存大小是 1024kb ,Windows 会根据分配给 JVM 的内存来分配,在这个 16G 内存起步的时代栈帧太大这种情况防意外不防手贱。

5、线程诊断

(1)CPU 占用过高

​ 在 Linux 中:

​ 使用 top 定位到哪个进程对 CPU 的占用高;

ps H -eo pid,tid,%cpu | grep 进程 id :使用 PS 命令进一步定位是哪个线程引起的 CPU 占用;

jstack 进程id :根据线程 id 找到有问题的线程。,定位到问题代码的具体位置。

(2)程序运行时间过长

​ 和上面的操作是一样的

(三)本地方法栈(Native Method Stack)

​ Java 虚拟机栈管理Java方法的调用,而本地方法栈管理本地方法的调用。本地方法栈,也是线程私有的。允许被实现成固定或者是可动态扩展的内存大小。

​ Java 作为跨平台的语言和底层操作系统的交互自然不如 C/C++ 这些底层语言。简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。

image-20230709172957989

(四)堆(Heap)

1、堆内存

​ Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例 。堆内存用来存放由new创建的对象实例和数组,在 IDEA 中同样可以使用-Xmx 参数设置堆内存的空间。Java 堆是垃圾收集器管理的主要区域,堆内存中不再被引用的对象会被回收

2、堆内存溢出

​ 虽然堆内存中有垃圾回收机制,但是被回收的对象是被 JVM 判定不再被使用的对象,如果一直创建对象还是会导致堆内存溢出。

    @Test
    public void m1() {
        int i = 0;
        try {
            ArrayList arrayList = new ArrayList();
            String a = "hello";
            while (true) {
                arrayList.add(a);
                a = a + a;
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }
    }

image-20230709174851584

3、堆内存诊断

(1)控制台查看堆内存情况

jps 查看当前系统中有哪些Java进程

jmap --heap pid查看堆内存占用情况, JDK11 则是jhsdb jmap --heap --pid pid查看堆内存占用情况

jconsole工具 图形界面多功能的检测工具,可以连续检测

    @Test
    public void m1() throws InterruptedException{
        System.out.println("1...");
        Thread.sleep(30000);
        //10MB
        byte[] array = new byte[1024 * 1024 * 10];
        System.out.println("2...");
        //30s 后数组可被回收
        Thread.sleep(30000);
        array = null;
        System.gc();
        System.out.println("3...");
        Thread.sleep(1000000L);
    }
}

​ 查看 Eden Space: 中的参数信息:程序开始执行,array 对象未创建,Eden 中使用空间约为 6MB;使用 new 创建 10M 的对象,此时 Eden 中占用约为 16M;array = null;失去引用,对象被回收,此时占用空间约为 1M。

//常看当前内部的所有进程 当前进程号为 5924
PS D:\MyCodeWorkSpace\JVM_heima> jps
3456 
9844 Jps
18652 HeapMereDiagnosis
19004 Launcher
//运行界面输出 1.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jmap -heap 18652
Attaching to process ID 18652, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.372-b07

using thread-local object allocation.
Parallel GC with 8 thread(s)

//省略Heap Configuration:

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 6663720 (6.355018615722656MB)
   free     = 59920856 (57.144981384277344MB)
   10.007903331846702% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 177733632 (169.5MB)
   used     = 0 (0.0MB)
   free     = 177733632 (169.5MB)
   0.0% used

3185 interned Strings occupying 260624 bytes.
//运行界面输出 2.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jmap -heap 18652
Attaching to process ID 18652, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.372-b07

using thread-local object allocation.
Parallel GC with 8 thread(s)

//省略Heap Configuration:
    
Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 17149496 (16.35503387451172MB)
   free     = 49435080 (47.14496612548828MB)
   25.75595885749877% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 177733632 (169.5MB)
   used     = 0 (0.0MB)
   free     = 177733632 (169.5MB)
   0.0% used

3186 interned Strings occupying 260672 bytes.

 //运行界面输出 3.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jmap -heap 18652
Attaching to process ID 18652, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.372-b07

using thread-local object allocation.
Parallel GC with 8 thread(s)

//省略Heap Configuration:
Heap Usage:
PS Young Generation
Eden Space:
   capacity = 66584576 (63.5MB)
   used     = 1331712 (1.27001953125MB)
   free     = 65252864 (62.22998046875MB)
   2.0000307578740157% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 177733632 (169.5MB)
   used     = 1101352 (1.0503311157226562MB)
   free     = 176632280 (168.44966888427734MB)
   0.6196643750576143% used

3171 interned Strings occupying 259592 bytes.

​ 下面是JDK11的策略,和8不同,array 对象直接被放入了 OLD。

//常看当前内部的所有进程 当前进程号为 5924
PS D:\MyCodeWorkSpace\JVM_heima> jps                    
17920 Launcher
5924 HeapMereDiagnosis
18616 
3464 Jps
//运行界面输出 1.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jhsdb jmap --heap --pid 5924
Heap Usage:
G1 Heap:
   regions  = 4042
   capacity = 4238344192 (4042.0MB)
   used     = 4194304 (4.0MB)
   free     = 4234149888 (4038.0MB)
   0.09896091044037605% used
G1 Young Generation:
Eden Space:
   regions  = 4
   capacity = 27262976 (26.0MB)
   used     = 4194304 (4.0MB)
   free     = 23068672 (22.0MB)
   15.384615384615385% used
Survivor Space:
   regions  = 0
   capacity = 0 (0.0MB)
   used     = 0 (0.0MB)
   free     = 0 (0.0MB)
   0.0% used
G1 Old Generation:
   regions  = 0
   capacity = 239075328 (228.0MB)
   used     = 0 (0.0MB)
   free     = 239075328 (228.0MB)
   0.0% used
//运行界面输出 2.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jhsdb jmap --heap --pid 5924
//省略 Heap Configuration:
Heap Usage:
G1 Heap:
   regions  = 4042
   capacity = 4238344192 (4042.0MB)
   used     = 15728640 (15.0MB)
   free     = 4222615552 (4027.0MB)
   0.3711034141514102% used
G1 Young Generation:
Eden Space:
   regions  = 5
   capacity = 27262976 (26.0MB)
   used     = 5242880 (5.0MB)
   free     = 22020096 (21.0MB)
   19.23076923076923% used
Survivor Space:
   regions  = 0
   capacity = 0 (0.0MB)
   used     = 0 (0.0MB)
   free     = 0 (0.0MB)
   0.0% used
G1 Old Generation:
   regions  = 11
   capacity = 239075328 (228.0MB)
   used     = 10485760 (10.0MB)
   free     = 228589568 (218.0MB)
   4.385964912280702% used
       
//运行界面输出 3.. 查看堆内存信息
PS D:\MyCodeWorkSpace\JVM_heima> jhsdb jmap --heap --pid 5924
//省略 Heap Configuration
Heap Usage:
G1 Heap:
   regions  = 4042
   capacity = 4238344192 (4042.0MB)
   used     = 1816920 (1.7327499389648438MB)
   free     = 4236527272 (4040.267250061035MB)
   0.04286862788136674% used
G1 Young Generation:
Eden Space:
   regions  = 0
   capacity = 6291456 (6.0MB)
   used     = 0 (0.0MB)
   free     = 6291456 (6.0MB)
   0.0% used
Survivor Space:
   regions  = 0
   capacity = 0 (0.0MB)
   used     = 0 (0.0MB)
   free     = 0 (0.0MB)
   0.0% used
G1 Old Generation:
   regions  = 5
   capacity = 11534336 (11.0MB)
   used     = 1816920 (1.7327499389648438MB)
   free     = 9717416 (9.267250061035156MB)
   15.75227217240767% used

(2)jconsole

image-20230710170823579

(3)jvisualvm

​ jvisualvm 是随 jdk 一同发布的 jvm 诊断工具,通过插件可以扩展很多功能,插件扩展也是 jvisualvm 的精华所在。jvisualvm使用教程 如下。

​ GC 之后我们发现这个程序的堆内存减少了但是没有完全减少。

image-20230710180743413

image-20230710180928603

​ 通过 Heap Dump 进入到 Size 的界面,我们能看到有一堆 byte[] 占用的内存最大,进一步确认是 Studen 对象的问题。

image-20230710180724850

image-20230710180653382

​ 查看源码,发现该对象一直处于内存中,没有被回收。

package com.purearc.heap;

public class Student {
    private byte[] big = new byte[1024*1024];
}

package com.purearc.heap;
import java.util.ArrayList;
public class HeapVisual {
    public static void main(String[] args) throws InterruptedException {
        ArrayList<Student> students = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            students.add(new Student());
        }
        Thread.sleep(10000000L);
    }
}

(五 )方法区(Method Area)

1、方法区概述

​ 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储被加载的类的信息(It stores per-class structures such as the run-time constant pool,field andmethod data,and the code for methods and constructors,including the special methods used in class and instance initialization and interface initialization.)。

​ 方法区是一种概念上的东西,在逻辑上也是堆的一部分,不过对于不同的VM也有不同的实现(比如HotSpot的永久代或者元空间)。

​ JDK1.6用永久代实现方法区

image-20230710182117405

​ JDK1.8用元空间实现方法区,方法区被转移到本地内存中转移。

image-20230710182126226

2、方法区OOM

​ 记得测试要设置 VM参数 -XX:MaxMetaspaceSize=8m 大小设置的小一点。

package com.purearc.methodarea;
/**
 * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
 * -XX:MaxMetaspaceSize=8m
 */
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test = new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length); // Class 对象
            }
        } finally {
            System.out.println(j);
        }
    }
}

(1)1.8之前永久代内存溢出

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

(2)1.8及之后元空间内存溢出

Exception in thread "main" java.lang.OutOfMemoryError: Meta space

​ 当使用 Spring 、Mybatis 会通过动态代理生成代理对象时也可能会导致 Class 过多而 OOM。

3、常量池

📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖

​ ]ava 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。

📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖📖

​ 所谓常量池,就是一张表虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。常量池的作用就是将指令对应到常量符号(#1、2),虚拟指令根据常量池中的符号标记就可以找到指令。

​ 通过 JClasslib 工具或者 javap 都可以获得类的 class 文件结构。

image-20230711184303524

(1)静态常量池和演示分析

​ 静态常量池也可以称为Class常量池,也就是每个.java文件经过编译后生成的.class文件,每个.class文件里面都包含了一个常量池,因为这个常量池是在Class文件里面定义的,也就是.java文件编译后就不会在变了,也不能修改,所以称之为静态常量池。

​ ⏬在 Jclasslib 中的 HelloWorld 程序的常量池。

image-20230710191434567

​ 在程序运行的时候,JVM会把 HelloWorld.class 加载进内存,然后在解析阶段会把 Class 中的符号引用转化成直接引用。这些直接引用就是在内存的运行时常量池中,运行时常量池除了包含类的相关信息外,还有字符串常量池等,运行时常量池与静态常量池最大的区别就在于运行时常量池里面的数据会随着程序的运行有所变动。

​ 下面是编译之后的代码,方便起见使用了 javap -v来展示。

​ 阅读 mian 方法中的代码,根据句意我们可以得到第一行的意思是 获得某个类的静态成员变量,而后边的参数就是 #2(FiledRef),在常量池中我们找到 #2 ,可以看到 #2中引用了#21.#22,找进去 #21 = Class #28 // java/lang/System #22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;#21 即说明程序需要找到一个 Class 即 java.lang.System#22 引用到了 #29:#30,内容是找到 java/io/PrintStream类型的 out 这个常量。

​ 下面的注释部分大体说明了执行过程:

Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/purearc/constant/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/purearc/constant/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/purearc/constant/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public com.purearc.constant.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 4: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/purearc/constant/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
      //获得 #2 的static常量
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      //加载 #3 的引用(找到 helloworld 作为参数传入)
         3: ldc           #3                  // String hello world
      //虚方法调用 找到了java/io/PrintStream.println
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

(2)运行时常量池

​ 常量池是 *.clss 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。在运行的时候编号由原来的 #1、2、3 就变成了真正的内存中的地址。

4、常量池中的StringTable

​ StringTable也叫串池,听名字就可以知道它和String的存储有关。在1.6,它是存在于永久代中的,到了1.7之后,StringTable被放在了堆中。

(1)编译期优化

//源代码cn\itcast\jvm\t1\stringtable
// StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容
public class Demo1_22 {
    
    /**
   	 	 0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
     */
    /** 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
     ldc #2 会把 a 符号变为 "a" 字符串对象
     ldc #3 会把 b 符号变为 "b" 字符串对象
    ldc #4 会把 ab 符号变为 "ab" 字符串对象
    */
    public static void main(String[] args) {
        String s1 = "a"; // 懒惰的
        String s2 = "b";
        String s3 = "ab";
        
        /**
        9: new #5                         // class java/lang/StringBuilder
        12: dup
        13: invokespecial #6             // Method java/lang/StringBuilder."<init>":()V
        16: aload_1
        17: invokevirtual #7                 // Method java/lang/StringBuilder.append:
        (Ljava/lang/String;)Ljava/lang/StringBuilder;
        20: aload_2
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        27: astore        4
        */
        String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString()  new String("ab")
        System.out.println(s3 == s4); //false
        
        
        /**
        29: ldc           #4                  // String ab
        31: astore        5
        
        33: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
        36: aload_3
        37: aload         5
        39: if_acmpne     46
        42: iconst_1
        43: goto          47
        46: iconst_0
        47: invokevirtual #10                 // Method java/io/PrintStream.println:(Z)V
        */
        String s5 = "a" + "b";  // javac 在编译期间的优化,结果已经在编译期确定为ab
        System.out.println(s3 == s5); //true
    }
}

(2)字符串延迟加载

​ JVM(Java虚拟机)中的字符串延迟实例化是指在某些情况下,字符串对象的创建和初始化被推迟到需要使用它们的时候。这种延迟实例化的机制可以提高性能和节省内存。

​ 在JVM中,字符串常量池是用于存储字符串字面值的特殊区域。当编译器遇到字符串字面值时,它会首先检查字符串常量池中是否已经存在相同内容的字符串对象。如果存在,则直接返回该对象的引用;如果不存在,则在常量池中创建一个新的字符串对象,并返回其引用。

​ 延迟实例化的概念与字符串常量池密切相关。当使用字符串连接操作符(+)连接多个字符串时,JVM会将这些字符串连接操作转换为StringBuilder 或 StringBuffer 的操作,以提高效率。只有在最终需要使用连接后的字符串时,JVM才会将StringBuilder或StringBuffer对象转换为字符串对象,并将其添加到字符串常量池中。

String str = "Hello";
str += " World";
System.out.println(str);

​ 在这个例子中,初始的字符串"Hello"被放入字符串常量池中。当执行str += " World"时,JVM会使用StringBuilder或StringBuffer来进行字符串连接操作。只有在执行System.out.println(str)时,JVM才会将连接后的字符串 "Hello World" 实例化并添加到字符串常量池中。

​ 下面展示JVM得内存情况来证实这点,在 Debug 控制台打开内存的显示区域。

image-20230711212236536

​ 对以下代码进行debug操作。

	    System.out.print("1");//2404
        System.out.print("2");//2405
        System.out.print("3");
        System.out.print("4");
        System.out.print("5");
        System.out.print("6");
        System.out.print("7");
        System.out.print("8");
        System.out.print("9");//2412
//字符串已经在串池中,直接返回其引用,数量不会变化。
        System.out.print("0");//2412

image-20230712163847805

image-20230712164020228

​ 可以看到当执行到含有字符串的语句时对象被放堆入内存,而不是在编译完成后就把所有对象放入。

(3)StringTable_intern_jdk1.8

​ 调用字符串对象的intern()方法,可以将字符串添加到常量池中并返回常量池中的引用。如果字符串已经存在于常量池中,则直接返回常量池中的引用。

        String str1 = "Hello"; // 字符串常量池中创建一个"Hello"对象
        String str2 = new String("Hello"); // 在堆中创建一个新的"Hello"对象
        String str3 = str2.intern(); // 将str2添加到字符串常量池中,并返回常量池中的引用

        System.out.println(str1 == str2); // false,因为str1和str2引用的是不同的对象
        System.out.println(str1 == str3); // true,因为str3引用的是常量池中的对象

​ 在上述示例中,str1str2引用的是不同的对象,因为new String("Hello")会在堆中创建一个新的字符串对象。但是,通过调用str2.intern()str2添加到字符串常量池中,并返回常量池中的引用,所以str3引用的是常量池中的对象。因此,str1 == str3的比较结果为true

(4)StringTable_intern_jdk1.6

​ 在 JDK1.6 中字符串调用 intern()方法,如果有则会返回常量池中的引用,如果常量池中不存在则会将此对象复制一份放入串池,并返回串池中的对象。

public static void main(String[] args) {
        //s 通过拼接创建,堆内存中 a、b、ab 中三个对象
        String s = new String("a")+new String("b");
        String s2 = s.intern();
        //x 直接创建在串池中
        String x = "ab";
        /**
         * TRUE
         * JDK1.8 和 JDK1.6 中调用
         * intern 方法返回的都是常量池中的对象
         */
        System.out.println(s2 == x);

        /**
         * JDK1.6:FALSE JDK1.8 TURE
         * JDK1.8 直接将s移动到了串池当中,s 调用之后就成为了串池之中的 s
         * JDK1.6 s 的副本放入串池,s 还是外部堆内存之中的那个 s
         */
        System.out.println(s == x); 
    }

(5)StringTable 由永久代转移到堆中

​ 在Java 8之前的版本中,字符串常量池(String Pool)是存储在永久代(PermGen)中的。然而,由于永久代的大小是有限的,并且不容易进行动态调整,这可能导致一些问题。

​ 首先,将字符串常量池放在永久代中可能会导致内存溢出。如果应用程序中使用大量的字符串,特别是动态生成的字符串,它们会被存储在永久代中,,这可能导致永久代空间不足的情况。

​ 其次,永久代的垃圾回收机制与堆中的垃圾回收机制不同。在永久代中,垃圾回收主要针对无效的类和类加载器,而不是字符串常量。仅当调用 FUllGC 的时候 StringTable 中的不在引用的对象才会被回收,这意味着即使字符串不再被引用,它们仍然会留在永久代中,无法被垃圾回收,从而造成内存泄漏。

​ 为了解决这些问题,从Java 7开始,字符串常量池被移出永久代,转移到了堆中。这样做的好处是:

​ 1)堆的大小可以通过-Xmx和-Xms等参数进行动态调整,从而更好地适应应用程序的需求。

​ 2)字符串常量可以像其他对象一样进行垃圾回收,当字符串不再被引用时,它们可以被垃圾回收器自动清理,避免内存泄漏问题。

​ 3)字符串常量池的移动还简化了Java虚拟机的实现,使得开发和维护更加容易。

(6)StringTable 的垃圾回收

在 JDK1.8 StringTable 在堆中自然也会收到垃圾回收机制的管理。为虚拟机设置运行时参数并把信息打印到控制台`-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc`。

​ 下面的 Java 程序依次增加内存中对象的数量(StringTable 的实现类似 hashtable,即数组加列表,literals 代表字符串常量的数量)。

public class Demo1_7 {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        try {
            for (int j = 0; j < 100000; j++) { // j=100, j=1000,j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }

    }
}

​ 当对象个数从 100 增加到 100000,字符串对象并没有接近 100000,同时打印出 GC 的信息,即 Allocation Failure

//参数:-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
//i为100
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      1836 =     44064 bytes, avg  24.000
Number of literals      :      1836 =    160400 bytes, avg  87.364
Total footprint         :           =    684568 bytes
//将i改成1000
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      2675 =     64200 bytes, avg  24.000
//增加的对象的个数 2675-1836 部分已经在StringTable中所以不足1000个新增
Number of literals      :      2675 =    200672 bytes, avg  75.018
Total footprint         :           =    744976 bytes
//100000
[GC (Allocation Failure) [PSYoungGen: 2048K->504K(2560K)] 2048K->800K(9728K), 0.0008433 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2552K->488K(2560K)] 2848K->816K(9728K), 0.0017539 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2536K->488K(2560K)] 2864K->816K(9728K), 0.0020333 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :     24164 =    579936 bytes, avg  24.000
Number of literals      :     24164 =   1412000 bytes, avg  58.434
Total footprint         :           =   2472040 bytes

(7)StringTable 调优

1)增加 buckets 的数量 -XX:StringTableSize=buckets 的个数

​ 减少哈希冲突:StringTable 使用哈希函数将字符串映射到不同的 buckets 中。如果 buckets 的数量较少,可能会导致多个字符串哈希到同一个 bucket 中,这就产生了哈希冲突。增加 buckets 的数量可以减少哈希冲突的可能性,从而提高查找和插入操作的效率。

​ 增加散列性:当 buckets 的数量足够大时,哈希函数可以更均匀地分配字符串到不同的 buckets 中,提高了散列性。散列性的改善意味着字符串在 StringTable 中的存储更加均匀和分散,减少了链式查找的长度,提高了访问字符串的速度。

​ 并发性能提升:增加 buckets 的数量可以提高并发性能。在多线程环境下,每个线程可能需要同时访问不同的 buckets。如果 buckets 的数量有限,则多个线程可能会争用同一个 bucket,导致锁竞争和性能下降。增加 buckets 的数量可以提供更多的并行度,减少线程之间的竞争,从而提高并发性能。

2)考虑字符串对象是否入池

​ 从Java 8开始,字符串常量池被移至堆内存中,并且没有固定大小限制。这意味着在现代的Java版本中,字符串常量池的性能表现并不像以前那样容易受到影响。如果一个字符串对象被多次使用,每次都通过创建新的对象来表示,会导致额外的内存开销。

​ 在Java中,声明的字符串字面量默认就处于字符串常量池中(如 String str = "hello"),无需显式调用任何方法。而对于通过new关键字创建的字符串对象,则会在堆内存中创建一个新的实例,不会自动添加到字符串常量池中,但是我们都知道可以使用字符串对象的 intern 方法进行入池。

​ 将字符串对象入池后,可以共享同一个实例,减少内存占用。在实际开发中,如果存在大量重复的字符串对象,并且内存使用情况较为紧张,可以考虑适当使用字符串常量池来优化内存利用率。

(六)直接内存(DirectMemory)

​ JVM(Java虚拟机)的直接内存指的是在JVM运行时分配的堆外内存,也就运行 JVM 虚拟机设备的内存,也称为非堆内存。

​ 直接内存的分配和释放不受Java堆内存管理系统的约束,因此在频繁的I/O操作中,直接使用直接内存能够提高性能并减少内存复制的开销。

​ 直接内存的分配和释放是由操作系统负责的,而不是由JVM的垃圾回收器管理。故而分配成本比较高,但是胜在读写性能高。

(1)直接内存在NIO中的应用

​ ⏬下面的代码是使用 NIO 和传统 IO 读取文件的两种方式。

    /**
    * _1MB:分配个缓存的常量
    * FROM 和 TO :分别表示文件的起始位置和目标位置 
    */
//用时479.295165
	private static void directBuffer() {
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
            while (true) {
                int len = from.read(bb);
                if (len == -1) {
                    break;
                }
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
//用时1535.586957 
    private static void io() {
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1Mb];
            while (true) {
                int len = from.read(buf);
                if (len == -1) {
                    break;
                }
                to.write(buf, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

​ 可见 NIO 比传统 IO 快了不少,一方面是因为 NIO 是异步非阻塞式的IO,另一方面 NIO 直接与直接内存进行交互。直接内存允许将数据从磁盘直接传输到应用程序的内存空间,避免了数据在用户空间和内核空间之间的复制。即文件可以直接通过直接内存共享到 JVM 的内存中,而不需要先从系统缓冲区到 JVM 的缓存区中。

​ ⏬传统阻塞式IO的做法

image-20230719214218585

​ ⏬NIO的处理

image-20230719214248241

(2)直接内存的内存溢出

java.lang.OutOfMemoryError: Birect buffer memory

​ 当使用Direct Memory时,如果没有正确管理和释放直接内存,就有可能导致内存溢出。这可能发生在以下情况下:

​ 1)分配过多的直接内存:如果程序分配了大量的直接内存,但没有及时释放,就会导致内存溢出。

​ 2)内存泄漏:如果程序中存在内存泄漏的情况,即分配的直接内存没有及时释放,就会导致内存溢出。

​ 3)进程无法处理大量直接内存:如果程序需要处理大量的直接内存,但进程的可用内存不足以支持这些操作,就会导致内存溢出。

(3)直接内存释放

public class Demo1_26 {
    static int _1Gb = 1024 * 1024 * 1024;
    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null;
        /**
        通过 -XX:+DisableExplicitGC 禁用显式的GC
        */
        System.gc(); // 显式的垃圾回收,Full GC
        System.in.read();
    }
}

image-20230721110505642

image-20230721110805272

image-20230721110653428

(4)内存释放的原理1

Unsafe类的作用

Unsafe类是Java中的一个特殊类,提供了一些底层操作方法,可以绕过Java语言的限制,直接操作内存和执行其他与安全性相关的操作。直接内存是通过ByteBuffer类的allocateDirect()方法分配的,它不受Java堆内存的限制,通常用于处理大量数据或需要与本地代码进行交互的情况。

​ 在Java中,Unsafe类提供了一些底层操作,包括直接内存的分配和释放。:

  • allocateMemory(long size)`:分配指定大小的直接内存。
  • freeMemory(long address):释放直接内存。
  • copyMemory(Object src, long srcOffset, Object dest, long destOffset, long length):将内存块从源地址复制到目标地址。
  • setMemory(Object obj, long offset, long size, byte value):将指定内存块设置为指定的值。
public class Demo1_27 {
    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();
        // 分配内存
        long base = unsafe.allocateMemory(_1Gb);
        unsafe.setMemory(base, _1Gb, (byte) 0);
        System.in.read();

        // 释放内存,打开任务管理器可见占用的1GB消失
        unsafe.freeMemory(base);
        System.in.read();
    }

    //通过反射获得 Unsafe 需要的内容
    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

(5)直接内存释放的原理2

​ 源码中通过 allocateMemory(size) 实现了对内存的分配,而内存的释放必须通过freeMemory(long address)来进行。

//1-26
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);

//allocateDirect方法
public static ByteBuffer allocateDirect(int capacity) {
     return new DirectByteBuffer(capacity);
}

//DirectByteBuffer类中对直接内存的分配和释放

        long base = 0;
        try {
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;

​ Cleaner 对象是 Java 中的虚引用类型,当期所关联的对象(this,此处也就是 bytebuffer)被回收时间,也会触发虚引用对象的 clean 方法,该方法在守护线程中进行。

    /**
     * Runs this cleaner, if it has not been run before.
     */
    public void clean() {
        if (!remove(this))
            return;
        try {
            thunk.run();
        } catch (final Throwable x) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                    public Void run() {
                        if (System.err != null)
                            new Error("Cleaner terminated abnormally", x)
                                .printStackTrace();
                        System.exit(1);
                        return null;
                    }});
        }
    }

posted @ 2023-07-21 19:26  Purearc  阅读(74)  评论(0编辑  收藏  举报