(JVM)在JVM中,类是如何被加载的呢?本篇文章就带你认识类加载的一套流程!

🏛️ 28365365体育 ⏳ 2025-10-15 09:52:05 👤 admin 👁️ 473 💎 382
(JVM)在JVM中,类是如何被加载的呢?本篇文章就带你认识类加载的一套流程!

在讲类加载前,需要先了解一下方法区、堆和直接内存三块内存区域的运行模式

1. 方法区JVM中的方法去是所有线程中共享的一块区域

它存储了跟类相关的信息

方法区 会在虚拟机被启动时创建。它逻辑上是堆的组成部分

它在不同的jvm厂商中存在的位置可能会不同,有些会放在堆区中,有些会放在本地存储中

如果方法区在申请内存空间不足时,也会抛出:内存溢出问题

在这里插入图片描述1.1 溢出场景:常用:spring、mybiats由于两者框架底层生产的类都用的时cglib(动态代理),cglib会创建多个类来实现,所以内存被就会频繁占用,在1.8以前溢出场景非常多(永久代空间),在1.8以后由于使用的时本地存储,类文件都存储在元空间里,所以不那么容易溢出了

1.2 运行时常量池给指令集提供常量符号,通过常量符号进行查表,查到后就可以执行命令了

代码语言:javascript代码运行次数:0运行复制Classfile /E:/Java/学习案例/2024.5.8 总复习/第一天:JAVA基本操作/basic/target/test-classes/two/JVM/test.class

Last modified 2024年9月2日; size 531 bytes

MD5 checksum 2aeac6e727155f8d343cdb28e189275d

Compiled from "test.java"

public class two.JVM.test

minor version: 0

major version: 61

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #21 // two/JVM/test

super_class: #2 // java/lang/Object

interfaces: 0, fields: 0, methods: 2, attributes: 1

常量池:------------------------------------------------------

Constant pool:

#1 = Methodref #2.#3 // java/lang/Object."":()V

#2 = Class #4 // java/lang/Object

#3 = NameAndType #5:#6 // "":()V

#4 = Utf8 java/lang/Object

#5 = Utf8

#6 = Utf8 ()V

#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;

#8 = Class #10 // java/lang/System

#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;

#10 = Utf8 java/lang/System

#11 = Utf8 out

#12 = Utf8 Ljava/io/PrintStream;

#13 = String #14 // Hello,world

#14 = Utf8 Hello,world

#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V

#16 = Class #18 // java/io/PrintStream

#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V

#18 = Utf8 java/io/PrintStream

#19 = Utf8 println

#20 = Utf8 (Ljava/lang/String;)V

#21 = Class #22 // two/JVM/test

#22 = Utf8 two/JVM/test

#23 = Utf8 Code

#24 = Utf8 LineNumberTable

#25 = Utf8 LocalVariableTable

#26 = Utf8 this

#27 = Utf8 Ltwo/JVM/test;

#28 = Utf8 main

#29 = Utf8 ([Ljava/lang/String;)V

#30 = Utf8 args

#31 = Utf8 [Ljava/lang/String;

#32 = Utf8 SourceFile

#33 = Utf8 test.java

常量池:------------------------------------------------------

{

public two.JVM.test();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: invokespecial #1 // Method java/lang/Object."":()V

4: return

LineNumberTable:

line 3: 0

LocalVariableTable:

Start Length Slot Name Signature

0 5 0 this Ltwo/JVM/test;

public static void main(java.lang.String[]);

descriptor: ([Ljava/lang/String;)V

flags: (0x0009) ACC_PUBLIC, ACC_STATIC

Code:

stack=2, locals=1, args_size=1

主要看以下代码:#7 代表这一行代码需要去常量池中找到的地址

0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;

3: ldc #13 // String Hello,world

5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

8: return

LineNumberTable:

line 5: 0

line 6: 8

LocalVariableTable:

Start Length Slot Name Signature

0 9 0 args [Ljava/lang/String;

}

SourceFile: "test.java"常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

运行时常量池 ,常量池实 *.class 文件中的,当该类被加载,它的常量池就会放入运行时常量池(内存),并把里面的符号地址(类似于 # 1、#2)变为真实地址

1.1 小题目代码语言:javascript代码运行次数:0运行复制/** 反编译后的执行顺序

* 0: ldc #7 // String a

* 2: astore_1

* 3: ldc #9 // String b

* 5: astore_2

* 6: ldc #11 // String ab

*

* 8: astore_3

* 9: aload_1

* 10: aload_2

* 11: invokedynamic #13, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

* 16: astore 4

* 18: return

*/

// stringTable[] 这是一个hashtable结构,不能扩容,当存在同一种数值就不允许重复了

public class Pool {

// 常量池中的信息,都会加载到运行时常量池中,不过都还是常量池中的符号,只有在使用时才会把符号变为对象

// 例如: ldc #2 这种就调用了这个符号,那么对应符号代表的信息就会被转为对象

public static void main(String[] args) {

String s1 = "a";

String s2 = "b";

String s3 = "ab";

// 如上直接声明的字符串对象是会被存入stringTable中的。

String s4 = s1+s2;// 会存储在堆中但不存在于stringTable里

// 这种有字符串变量拼接的对象会调用makeConcatWithConstants方法,在java8版本会使用stringBuilder()进行.append()拼接。

/** makeConcatWithConstants方法说明

* 代码生成器有三种不同的方式来处理字符串连接表达式中的常量字符串操作数S。

* 首先,S可以具体化为引用(使用ldc),并作为普通参数(recipe '\1')传递。

* 或者,S可以存储在常量池中并作为常量(配方'\2')传递。

* 最后,如果S不包含配方标签字符('\1','\2'),则可以将S插入到配方本身中,从而将其字符插入到结果中。

*/

String s5 = "a"+"b";// 底层指令会直接在常量池中寻找对应的符号,如果有相符的会直接使用对应地址创建一个新的对象

/**

* 6: ldc #11 // String ab

* 8: astore_3

* 9: aload_1

* 10: aload_2

* 11: invokedynamic #13, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

* 16: astore 4

* 18: ldc #11 // String ab

*/

// System.out.println(s3==s4);// 两者存储地址不一样一个是内存,一个是存储中,所以为false

System.out.println(s3==s5);// 因为两个对象本质都存储使用的同一个stringTable里的符号地址,所以为true

}

}1.3 StringTable 特性常量池中的字符串仅是符号,第一次用到时才会转为对象利用串池的机制,来避免重复创建字符串对象字符串变量拼接原理在1.8是StringBuilder,后面改为了makeConcatWithConstants字符串常量拼接的原理是编译器优化空要使用intern方法,主动讲串池中还没有转为对象的字符串放入串池 1.8 及以后,将某个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回1.8 以前(例如1.6),将这个对象尝试放串池,如果存在就不会放入,如果没有就会把此对象复制一份,将复制出来的对象放入串池,然后将串池中的对象返回代码语言:javascript代码运行次数:0运行复制public class Pool2 {

public static void main(String[] args) {

String x = "ab";

/**

* 1. new String 在stringTable中添加了 "a" "b"

* 2. 通过拼接后转为了 s String对象(堆中)

*/

String s = new String("a")+new String("b");// 经过了拼接后,s还处于堆中

String s2 = s.intern();// intern 方法,将在堆中的对象尝试放入串池,如果串池中有则把串池中的对象返回

// 经过转换 s2 = 若s存在于串池中,那么就等于串池中的字符串,如果没有则会将s放入串池中

/**

* intern():如果s字符串对象已经存在于串池中,那么会返回串池中的字符串,而s字符串对象不变动

* 如果s字符串对象不存在于串池,那么就会将s字符串对象放进串池中,并返回这个字符串

*/

System.out.println(s=="ab");

System.out.println(s2=="ab");

}

}

/**

面试题

*/

public class pool3 {

public static void main(String[] args) {

String s1 = "a";

String s2 = "b";

String s3 = "a"+"b";

String s4 = s1+s2;

String s5 = "ab";

String s6 = s4.intern();

System.out.println(s3==s4);// false

System.out.println(s3==s5);// true

System.out.println(s3==s6);// true

String x = new String("c")+new String("d");

String x2 = "cd";

String x3 = x.intern();

System.out.println(x==x2);// false

System.out.println(x2==x3);// true

// 如果 17行 和 18行 调换一下位置会输出什么?输出true、true。

}

}1.4 StringTable 位置在这里插入图片描述在1.8以前,StringTable的位置都放置在永久代中,而1.8大改后就放到了Heap堆中。

1.8 以前,使用 -XX MaxPermSize=10m 设置VM的参数

1.8 使用 -Xmx10m 设置VM参数

代码语言:javascript代码运行次数:0运行复制import java.util.ArrayList;

/***

* StringTable 位置

* 测试永久代的或堆的内存溢出

*/

public class StringTablePosition {

public static void main(String[] args) {

ArrayList list = new ArrayList<>();

int j = 0;

try

{

for (int i = 0; i < 260000; i++) {

list.add(String.valueOf(i).intern());

j++;

}

}catch (Throwable e){

e.printStackTrace();

}finally {

System.out.println(j);

}

}

}报错结果:

代码语言:javascript代码运行次数:0运行复制java.lang.OutOfMemoryError: Java heap space

at java.lang.Integer.toString(Integer.java:401)

at java.lang.String.valueOf(String.java:3099)

at StringTablePosition.main(StringTablePosition.java:13)1.5 StringTable 垃圾回收当串池中存在过多字符串,会触发一次或多次的GC来清除

代码:代码语言:javascript代码运行次数:0运行复制int i = 0;

try {

}catch (Throwable e){

e.printStackTrace();

}finally {

System.out.println(i);

}可以看到,这里什么都没有做,Java已经创建了1854个对象。这是因为Java的运行需要创建这么多对象,而往里面循环创建字符串加入串池那这个数会变成多少呢?

在这里插入图片描述代码:代码语言:javascript代码运行次数:0运行复制int i = 0;

try {

for (int j=0;j<10000;j++){

String.valueOf(j).intern();

i++;

}

}catch (Throwable e){

e.printStackTrace();

}finally {

System.out.println(i);

}当讲10000个字符串放进串池中时,实际上存在的对象并没有这么多。

那是因为GC已经清除了一次对象

在这里插入图片描述这里就说明了GC已经被调用了,它将一部分无用的对象进行了清理。

在这里插入图片描述1.6 性能调优1.6.1 调整StringTable的桶个数根据 -XX:StringTableSize=桶个数 这个参数,就可以设置StringTable的容量,越大的话,迭代就越快,运行速度也就随之变快。当然,它的范围是在1009~2305843009213693951之间。

若是小于或超出这个数值会报错:非法数值

代码语言:javascript代码运行次数:0运行复制/**

* StringTable性能调优

* -XX:StringTableSize=2000 -XX:+PrintStringTableStatistics

*/

try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("../demo.txt"), "utf-8"))){

String line = null;

long start = System.nanoTime();

while (true){

line = reader.readLine();

if (line == null){

break;

}

line.intern();

}

System.out.println("cost:"+(System.nanoTime()-start)/1000000+"ms");

}1.6.2 字符串对象是否入池考虑下,字符串对象是否能够入池来节省运行速度?

1.6.2.1 不入池在这里插入图片描述代码语言:javascript代码运行次数:0运行复制public void AdjustOptimize2() throws Exception {

ArrayList address = new ArrayList<>();

System.in.read();

for (int i = 0; i < 10; i++) {

try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("../demo.txt"), "utf-8"))){

String line = null;

long start = System.nanoTime();

while (true){

line = reader.readLine();

if (line == null){

break;

}

// 不放入StringTable放进直接堆中

address.add(line);

System.out.println("cost:"+(System.nanoTime()-start)/1000000+"ms");

}

}

System.in.read();

}1.6.2.2 入池在这里插入图片描述代码语言:javascript代码运行次数:0运行复制public void AdjustOptimize2() throws Exception {

ArrayList address = new ArrayList<>();

System.in.read();

for (int i = 0; i < 10; i++) {

try(BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("E:/Java/学习案例/JVM/JVM/src/main/resources/demo.txt"), "utf-8"))){

String line = null;

long start = System.nanoTime();

while (true){

line = reader.readLine();

if (line == null){

break;

}

// 加进StringTable池

address.add(line.intern());

}

System.out.println("cost:"+(System.nanoTime()-start)/1000000+"ms");

}

}

System.in.read();

}2. 直接内存直接内存并不是值 JVM的内存,而是系统内存。

2.2 定义Direct Memory

常见于NIO操作时,用于数据缓冲区分配回收成功较高,但读写性能高不受JVM GC管理这里分别测试 IO和使用直接内存的ByteBuffer 复制文件的用时时间

代码语言:javascript代码运行次数:0运行复制import java.io.FileInputStream;

import java.io.FileOutputStream;

import java.io.IOException;

import java.nio.ByteBuffer;

import java.nio.channels.FileChannel;

/**

* 直接内存

* ByteBuffer

*/

public class DreictMemory {

static final String FROM = "E:\\Java\\学习案例\\JVM\\JVM\\src\\main\\resources\\b.mp4";

static final String TO = "E:\\Java\\学习案例\\JVM\\JVM\\src\\main\\resources\\out\\a.mp4";

static final int _1Mb = 1024 * 1024;

public static void main(String[] args) {

io(); // io 用时:1535.586957 1766.963399 1359.240226

directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592

}

private static void directBuffer() {

long start = System.nanoTime();

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();

}

long end = System.nanoTime();

System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);

}

private static void io() {

long start = System.nanoTime();

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();

}

long end = System.nanoTime();

System.out.println("io 用时:" + (end - start) / 1000_000.0);

}

} 其结果:

io 用时:37.349

directBuffer 用时:19.8012

使用直接内存进行读写,快了几乎两倍,至少读写效率是高了很多。

2.3 原理在这里插入图片描述这是读写的底层原理。

Java并不具备与底层直接交互,需要依赖C才能访问CPU中的内核态完成读写。

因而在系统内存之外还创建有一个Java堆内存。

数据读取首先要通过系统内存的系统缓存区,才能进入Java堆内存。

只有数据存在于Java堆内存的Java缓冲区时,java才可以读写

在这里插入图片描述使用直接内存后,会调用ByteBuffer.allocateDirect(_1Mb); 方法,来在系统内存和Java堆内存之间创建一块指定内存大小的缓冲区, 这样子Java代码就可以直接访问,系统也可以直接使用。

2.4 内存溢出代码语言:javascript代码运行次数:0运行复制static final int _100Mb = 1024 * 1024 * 100;

/**

* 内存溢出

*/

@Test

public void test1(){

ArrayList list = new ArrayList<>();

int i = 0;

try {

while (true) {

ByteBuffer allocate = ByteBuffer.allocateDirect(_100Mb);

list.add(allocate);

i++;

}

}finally {

System.out.println(i);

}

} tips:不论是堆内存还是直接内存,其实都会有内存溢出的风险。

2.5 分配与释放代码语言:javascript代码运行次数:0运行复制static final int _1Gb = 1024 * 1024 * 1000;

/**

* 分配与释放

*/

@Test

public void test2() throws IOException {

ByteBuffer allocate = ByteBuffer.allocateDirect(_1Gb);

System.out.println("分配完毕..");

System.in.read();

System.out.println("开始释放");

allocate = null;

System.gc();

}开始后,会创建一个系统内存

第一次回车进行后,会进行释放操作

在这里插入图片描述 第二次回车开始释放对象

在这里插入图片描述 为什么GC会将直接内存清除掉呢?不是不会清除的吗?

其实释放内存的并不是GC,而是JVM底层使用了unsafe手动将系统内存清除了

这就是直接内存分配的底层原理,都是使用的unsafe

代码语言:javascript代码运行次数:0运行复制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();

// 释放内存

unsafe.freeMemory(base);

System.in.read();

}

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);

}

} Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等。

这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。

在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

该类还会在 JUC 多次出现。

2.5.1 小结使用了Unsafe对象完成直接内存的分配调用,并且回收需要主动调用freeMemory方法ByteBuffer的实现类内部,使用了Cleaner(虚引用)来检测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存代码语言:javascript代码运行次数:0运行复制ByteBuffer allocate = ByteBuffer.allocateDirect(_2Gb);

↓↓↓↓

public static ByteBuffer allocateDirect(int capacity) {

return new DirectByteBuffer(capacity);

}

↓↓↓↓

DirectByteBuffer(int cap) { // package-private

super(-1, 0, cap, cap, null);

boolean pa = VM.isDirectMemoryPageAligned();

int ps = Bits.pageSize();

long size = Math.max(1L, (long)cap + (pa ? ps : 0));

Bits.reserveMemory(size, cap);

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 = Cleaner.create(this, new Deallocator(base, size, cap));

att = null;

}

↓↓↓ 经过不同的方法,最后使用unsafe.freeMemory方法释放内存

public void run() {

if (address == 0) {

// Paranoia

return;

}

// freeMemory方法释放内存

UNSAFE.freeMemory(address);

address = 0;

Bits.unreserveMemory(size, capacity);

} 如果禁用显式回收(GC),那么其实对直接内存来说可能释放的不会那么的及时(可以使用unsafe手动回收,或者等到真正的GC来自动回收释放),但对于其他类不会有太大的影响

3. 🎉类文件结构在这里插入图片描述一个简单的demo1.java

代码语言:javascript代码运行次数:0运行复制package Class;

public class demo1 {

public static void main(String[] args) {

System.out.println("Hello World");

}

}执行 javac -parameters -d . demo1.java

编译后进制文件是这个样子的

在这里插入图片描述根据 JVM 规范,类文件结构如下:

在这里插入图片描述3.1 魔术 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

0~3字节,表示它是否是【class】类型的文件

3.2 版本 0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

4~7字节,表示类的版本 00 34(52)表示是java 8

3.3 常量池ConstantType

Value

CONSTANT_Class

7

CONSTANT_Fieldref

9

CONSTENT_Methodref

10

CONSTENT_InterfaceMethodref

11

CONSTENT_String

8

CONSTENT_Integer

3

CONSTENT_Float

4

CONSTENT_Long

5

CONSTENT_Double

6

CONSTENT_NameAndType

12

CONSTENT_Utf8

1

CONSTENT_MethodHandle

15

CONSTENT_MethodType

16

CONSTENT_InvokeDynamic

18

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

8~9字节,表示常量池长度,0023(35)表示#1 ~ #34项,注意#0项不计入,也没有值

0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09

第#1项 0a 表示一个Method信息,00 06 和 00 15(21)表示它引用了常量池中 #6 和 #21 项来获得这个方法的【所属类】和【方法名】

二进制文件中排列的数据是非常紧凑的

里面不仅仅包含了所属类和方法名,还有访问标识、继承信息、Field信息、附加属性和方法信息

3.字节码指令两组字节码指令

public cn.itcast.jvm.t5.HelloWorld(); 构造方法的字节码指令public static void main(java.lang.Styring[]); 主方法的字节码指令public cn.itcast.jvm.t5.HelloWorld();

2a b7 00 01 b1

2a=> aload_0 加载 slot 0的局部变量,即 this,做为下面的 invokespecial 构造方法调用的参数b7 => invokespecial 预备调用构造方法,哪个方法呢?00 01 引用常量池中 #1 项,即【Method java/lang/Object.“”: ()v】b1 表示返回public static void main(java.lang.Styring[]);

b2 00 02 12 03 b6 00 04 b1

b2 => getstatic 用来加载静态变量,哪个静态变量呢?00 02 引用常量池中 #2 项,即【Field java/lang/System.out;Ljava/io/PrintStream;】12=>ldc加载参数,哪个参数呢?03 引用常量池中#3项,即【String hello world】b6=>invokevirtual 预备调用成员方法,哪个方法呢?00 04 引用常量池中 #4 项,即【Method java/io/PrintStream.println:(Ljava/lang/String;)V】b1 表示返回3.1 javap 工具自己分析类文件结构太麻烦了,Oracle提供了javap工具来反编译工具

代码语言:javascript代码运行次数:0运行复制Classfile /E:/Java/test.class

Last modified 2024年10月28日; size 411 bytes #// 最后更改日期

MD5 checksum 1f24a99621f4aba89f176c75a621d56e #// 加密哈希值

Compiled from "test.java" #// 编译来源

public class test #// 类名

minor version: 0

major version: 55

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #5 // test

super_class: #6 // java/lang/Object

interfaces: 0, fields: 0, methods: 2, attributes: 1

Constant pool:

#1 = Methodref #6.#15 // java/lang/Object."":()V

#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;

#3 = String #18 // 娴嬭瘯 》》 指的是“测试”字面量,字符码不是utf-8

#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V

#5 = Class #21 // test

#6 = Class #22 // java/lang/Object

#7 = Utf8

#8 = Utf8 ()V

#9 = Utf8 Code

#10 = Utf8 LineNumberTable

#11 = Utf8 main

#12 = Utf8 ([Ljava/lang/String;)V

#13 = Utf8 SourceFile

#14 = Utf8 test.java

#15 = NameAndType #7:#8 // "":()V

#16 = Class #23 // java/lang/System

#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;

#18 = Utf8 娴嬭瘯

#19 = Class #26 // java/io/PrintStream

#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V

#21 = Utf8 test

#22 = Utf8 java/lang/Object

#23 = Utf8 java/lang/System

#24 = Utf8 out

#25 = Utf8 Ljava/io/PrintStream;

#26 = Utf8 java/io/PrintStream

#27 = Utf8 println

#28 = Utf8 (Ljava/lang/String;)V

{

public test();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

# // stack 栈的深度;locals 局部变量表的长度;args_size 参数的数量

stack=1, locals=1, args_size=1

0: aload_0

1: invokespecial #1 // Method java/lang/Object."":()V

4: return

LineNumberTable:

line 1: 0

public static void main(java.lang.String[]);

descriptor: ([Ljava/lang/String;)V

flags: (0x0009) ACC_PUBLIC, ACC_STATIC # 作用区域

Code:

stack=2, locals=1, args_size=1

0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;

3: ldc #3 // String 娴嬭瘯

5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

8: return

LineNumberTable:

line 3: 0

line 4: 8

}

SourceFile: "test.java"3.2 图解方法执行流程3.2.1 原始java代码代码语言:javascript代码运行次数:0运行复制package Class;

/**

* 演示 字节码指令 和 操作数栈、常量池的关系

*/

public class demo2 {

public static void main(String[] args) {

int a = 10;

int b = Short.MAX_VALUE+1;

int c = a+b;

System.out.println(c);

}

} 当一个变量被创建出来后,基本数据类型会被放入栈中,而当一个类型的最大值突破了,那么该变量会被放进运行时常量池中

3.2.2 编译后的字节码文件代码语言:javascript代码运行次数:0运行复制Classfile /E:/Java/demo2.class

Last modified 2024年10月28日; size 434 bytes

MD5 checksum 949fd83375d8625cebadfbc1cf1e19b5

Compiled from "demo2.java"

public class Class.demo2

minor version: 0

major version: 55

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #6 // Class/demo2

super_class: #7 // java/lang/Object

interfaces: 0, fields: 0, methods: 2, attributes: 1

Constant pool:

#1 = Methodref #7.#16 // java/lang/Object."":()V

#2 = Class #17 // java/lang/Short

#3 = Integer 32768

#4 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream;

#5 = Methodref #20.#21 // java/io/PrintStream.println:(I)V

#6 = Class #22 // Class/demo2

#7 = Class #23 // java/lang/Object

#8 = Utf8

#9 = Utf8 ()V

#10 = Utf8 Code

#11 = Utf8 LineNumberTable

#12 = Utf8 main

#13 = Utf8 ([Ljava/lang/String;)V

#14 = Utf8 SourceFile

#15 = Utf8 demo2.java

#16 = NameAndType #8:#9 // "":()V

#17 = Utf8 java/lang/Short

#18 = Class #24 // java/lang/System

#19 = NameAndType #25:#26 // out:Ljava/io/PrintStream;

#20 = Class #27 // java/io/PrintStream

#21 = NameAndType #28:#29 // println:(I)V

#22 = Utf8 Class/demo2

#23 = Utf8 java/lang/Object

#24 = Utf8 java/lang/System

#25 = Utf8 out

#26 = Utf8 Ljava/io/PrintStream;

#27 = Utf8 java/io/PrintStream

#28 = Utf8 println

#29 = Utf8 (I)V

{

public Class.demo2();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: invokespecial #1 // Method java/lang/Object."":()V

4: return

LineNumberTable:

line 6: 0

public static void main(java.lang.String[]);

descriptor: ([Ljava/lang/String;)V

flags: (0x0009) ACC_PUBLIC, ACC_STATIC

Code:

stack=2, locals=4, args_size=1

0: bipush 10

2: istore_1

3: ldc #3 // int 32768

5: istore_2

6: iload_1

7: iload_2

8: iadd

9: istore_3

10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;

13: iload_3

14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V

17: return

LineNumberTable:

line 8: 0

line 9: 3

line 10: 6

line 11: 10

line 12: 17

}

SourceFile: "demo2.java"3.2.3 常量池载入运行时常量池在这里插入图片描述3.2.4 方法字节码载入方法区在这里插入图片描述3.2.5 main线程开始运行,分配栈帧内存(stack=2,locals=4)

在这里插入图片描述 当栈的深度为2时,那么就会分配为一个深度为2的栈(蓝色)

当帧为4时,那么会分配为一个长度为4的阵(绿色)

3.2.6 执行引擎开始执行字节码bipush 10 将一个byte压入操作数栈(其长度会补齐4个字节),类似的指令还有sipush 将一个short压入操作数栈(其长度会补齐4个字节)ldc 将一个int压入操作数栈ldc2_w 将一个long 压入操作数栈(分两次压入,因为long是8个字节)这里小的数字都是和字节码指令存在一起,超过short范围的数字存入了常量池在这里插入图片描述istore_1 将操作数栈顶数据弹出,存入局部变量表的 slot 1

在这里插入图片描述在这里插入图片描述 ldc #3 从常量池加载 #3 数据到操作数栈

注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE+1实际是在编译期间计算好的

在这里插入图片描述istore_2 从操作数栈栈顶弹出至局部变量在这里插入图片描述在这里插入图片描述iload_1 重新将1项压入操作数栈在这里插入图片描述iload_2 重新将2项压入操作数栈在这里插入图片描述在这里插入图片描述…

到目前为止,

istore:可以看作是把对象放入局部变量中

iload:可以看作是把局部变量放入操作数栈中

getstatic #4

在常量池中找到一个成员变量(该对象在堆中),获得到它的引用 。

然后会把它的引用地址放入操作数栈中

在这里插入图片描述在这里插入图片描述​

iload_3

将局部变量3项存放进操作数栈中

在这里插入图片描述在这里插入图片描述invokevirtual #5

找到常量池 #5 项定位到方法区 java/io/PrintStream.println:(I)V 方法生成新的栈帧(分配locals、stack等)传递参数,执行新栈帧中的字节码在这里插入图片描述执行完毕,弹出栈帧清除main操作数栈内容

在这里插入图片描述最后 return

完成main方法调用,弹出main栈帧程序结束3.3 分析 i++代码语言:javascript代码运行次数:0运行复制/**

* 从字节码角度分析a++相关题目

*/

@Test

public void test1(){

int a = 10;

int b = a++ + ++ a + a--;

System.out.println(a);

System.out.println(b);

}字节码:

代码语言:javascript代码运行次数:0运行复制Classfile /E:/Java/学习案例/JVM/JVM/src/test/java/Class/demo3.class

Last modified 2024年10月28日; size 423 bytes

MD5 checksum de9c94aef90c89bfe8a02cec9b7f6a9b

Compiled from "demo3.java"

public class Class.demo3

minor version: 0

major version: 55

flags: (0x0021) ACC_PUBLIC, ACC_SUPER

this_class: #4 // Class/demo3

super_class: #5 // java/lang/Object

interfaces: 0, fields: 0, methods: 2, attributes: 1

Constant pool:

#1 = Methodref #5.#14 // java/lang/Object."":()V

#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;

#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V

#4 = Class #19 // Class/demo3

#5 = Class #20 // java/lang/Object

#6 = Utf8

#7 = Utf8 ()V

#8 = Utf8 Code

#9 = Utf8 LineNumberTable

#10 = Utf8 main

#11 = Utf8 ([Ljava/lang/String;)V

#12 = Utf8 SourceFile

#13 = Utf8 demo3.java

#14 = NameAndType #6:#7 // "":()V

#15 = Class #21 // java/lang/System

#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;

#17 = Class #24 // java/io/PrintStream

#18 = NameAndType #25:#26 // println:(I)V

#19 = Utf8 Class/demo3

#20 = Utf8 java/lang/Object

#21 = Utf8 java/lang/System

#22 = Utf8 out

#23 = Utf8 Ljava/io/PrintStream;

#24 = Utf8 java/io/PrintStream

#25 = Utf8 println

#26 = Utf8 (I)V

{

public Class.demo3();

descriptor: ()V

flags: (0x0001) ACC_PUBLIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: invokespecial #1 // Method java/lang/Object."":()V

4: return

LineNumberTable:

line 3: 0

public static void main(java.lang.String[]);

descriptor: ([Ljava/lang/String;)V

flags: (0x0009) ACC_PUBLIC, ACC_STATIC

Code:

stack=2, locals=3, args_size=1

0: bipush 10

2: istore_1

3: iload_1

4: iinc 1, 1

7: iinc 1, 1

10: iload_1

11: iadd

12: iload_1

13: iinc 1, -1

16: iadd

17: istore_2

18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;

21: iload_1

22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V

25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;

28: iload_2

29: invokevirtual #3 // Method java/io/PrintStream.println:(I)V

32: return

LineNumberTable:

line 5: 0

line 6: 3

line 7: 18

line 8: 25

line 9: 32

}

SourceFile: "demo3.java"分析:

iinc 指令是直接在局部变量slot上进行运算a++和++a 的区别是先执行iload还是先iinc a++ 先 iload 再 iinc++a 先iinc 再 iload3.4 条件判断指令指令

助记符

含义

0x99

ifeq

判断是否==0

0x9a

ifne

判断是否!=0

0x9b

iflt

判断是否<0

0x9c

ifge

判断是否>=0

0x9d

ifgt

判断是否>0

0x9e

ifle

判断是否<=0

0x9f

if_icmpeq

两罐int是否 ==

0xa0

if_icmpne

两个int是否!=

0xa1

if_icmplt

两个int是否<

0xa2

if_icmpage

两个int是否>=

0xa3

if_icmpgt

两个int是否>

0xa4

if_icmple

两个int是否<=

0xa5

if_acmpeq

两个引用是否==

0xa6

if_acmpne

两个引用是否!=

0xc7

ifnonnull

判断是否!=null

几点说明:

byte、short、char都会按int比较,因为操作数栈都是4字节goto用来进行跳转到指定行号的字节码3.5 小结:当执行invokevirtual指令时

先通过栈帧中的对象引用找到对象分析对象头,找到对象的实际classclass结构中有vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了差表得到方法的具体地址执行方法的字节码4. 类加载阶段4.1 加载将类的字节码载入方法区,内部采用C++的instanceKlass描述java类,它的重要field有:

含义

_java_mirror

java的类镜像,例如对String来说就是String.class。作用:把klass暴露给java使用

_super

父类

_fields

成员变量

_methods

方法

_constants

常量池

_class_loader

类加载器

_vtable

虚方法表

_itable

接口方法表

如果这个类还有父类没有加载,那么会优先加载父类

加载和链接可能时交替运行的

instanceKlass 这样的【元数据】是存储在方法区(1.8后的元空间内),但**_java_mirror是存储在堆中的**

在这里插入图片描述_java_mirror地址中的会同步交换映射给在堆中的instanceKlass地址。而创建的类的地址也会存进Klass中,而Klass又会与_java_mirror同步映射。这样java就可以通过_java_mirror来操控对象了

4.2 链接验证阶段:验证类是否符合JVM规范,安全性检查准备:为staic变量分配空间,设置默认值 static 变量在JDK7 之前存储于 instanceKlass末尾,从 JDK 7 开始,存储与_java_mirror末尾static分配空间喝赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成如果static变量是final的基本类型,那么编译阶段值就确定了,赋值在准备阶段完成如果static变量时final的,但属于引用类型,那么赋值也会在初始化阶段完成。解析,将常量池中的符号引用解析为直接引用

代码语言:javascript代码运行次数:0运行复制public class demo1 {

public static void main(String[] args) throws ClassNotFoundException, IOException {

ClassLoader classLoader = demo1.class.getClassLoader();

Class c = classLoader.loadClass("ClassLoad.C"); // LoadClass 它只是加载了C类,但是并没有触发解析C类中的方法,因此并不会加载D类

new C();// 而 new 会导致C的加载并且触发解析

System.in.read();

}

}

class C{

D d = new D();

}

class D{

}4.3 初始化4.3.1 ()V方法初始化,即调用()V,虚拟机会保证这个类的【构造方法】的线程安全

4.3.2 发生的时机概括的说,类初始化是 【懒惰的】

main方法所在的类,总是会被首先初始化首次访问这个类的静态变量或静态方法时子类初始化,如果父类还没初始化会触发子类访问父类的静态变量,只会触发父类的初始化Class.forNamenew 会导致初始化而不会导致类初始化的情况:

访问类的static final 静态常量(基本类型和字符串)不会触发初始化类的对象.class不会触发初始化创建该类的数组不会触发初始化类加载器的loadClass方法Class.forName的参数2为false时实验:

代码语言:javascript代码运行次数:0运行复制package ClassLoad;

public class demo2 {

static {

System.out.println("main init");// main方法所在的类,总是会被首先初始化

}

public static void main(String[] args) throws ClassNotFoundException {

// 访问类的static final 静态常量(基本类型和字符串)不会触发初始化

System.out.println(B.b);

// 类的对象.class不会触发初始化

System.out.println(B.class);

// 创建该类的数组不会触发初始化

System.out.println(new B[0]);

// 不会初始化类B,但会加载 B、A

ClassLoader c1 = Thread.currentThread().getContextClassLoader();

c1.loadClass("ClassLoad.B");

// 不会初始化类B,但会加载 B、A

ClassLoader c2 = Thread.currentThread().getContextClassLoader();

Class.forName("ClassLoad.B",false,c2);

// 首次访问这个类的静态变量或静态方法时

System.out.println(A.a);

// 子类初始化,如果父类还没初始化会触发

System.out.println(B.c);

// 子类访问父类的静态变量,只会触发父类的初始化

Class.forName("ClassLoad.B");

}

}

class A{

static int a = 0;

static {

System.out.println("a init");

}

}

class B extends A{

final static double b = 5.0;

static boolean c = false;

static {

System.out.println("b init");

}

}4.3.3 练习1从字节码分析,使用 a、b、c 这三个常量是否会导致E初始化

代码语言:javascript代码运行次数:0运行复制public class demo4{

public static void main(String[] args){

System.out.println(E.a);

System.out.println(E.b);

/**

E.a 和 E.b 都不会引起E类初始化,因为他们都是已经确定的值

而 E.c 使用的是Integer包装类,他在编译期中会有 Integer.valueOf(obj);这个操作

所以他会导致 E 类初始化

*/

System.out.println(E.c);

}

}

class E{

public static final int a = 10;

public static final String b = "hello";

public static final Integer c = 20;

}4.3.4 练习2代码语言:javascript代码运行次数:0运行复制public final class Singleton{

private Sinleton(){}

// 内部类中保留单例

private static class LazyHolder{

static final Singleton INSTANCE = new Singleton();

}

// 第一次调用 getInstance方法,才会导致内部类加载和初始化其静态成员

public static Sinleton getInstance(){

return LazyHolder.INSTANCE;

}

}5. 类加载器以JDK8为例:

名称

加载哪里的类

说明

Bootstrap ClassLoader

JAVA_HOME/jre/lib

无法直接访问

Extension ClassLoader

JAVA_HOME/jre/lib/ext

上级为Bootstrap,显示为null

Application ClassLoader

classpath

上级为Extension

自定义类加载

自定义

上级为Application

这几种类加载器都会管理不同包下的类

5.1 启动类加载器代码语言:javascript代码运行次数:0运行复制public class demo3 {

public static void main(String[] args) throws ClassNotFoundException {

Class aClass = Class.forName("ClassLoad.F");

System.out.println(aClass.getClassLoader());

}

}代码语言:javascript代码运行次数:0运行复制package ClassLoad;

public class F {

static {

System.out.println("bootstrap F init");

}

}输出:

代码语言:javascript代码运行次数:0运行复制java -Xbootclasspath/a:. ClassLoad.demo3

bootstrap F init

nullXbootclasspth 表示设置 bootclasspath其中 /a:. 表示将当前目录追加至 bootcalsspath 之后可以用这个办法替换核心类 java -Xbootclasspath:java -Xbootclasspath/a: <追加路径>java -Xbootclasspath/p: <追加路径>5.2 应用程序加载器和扩展器加载器代码语言:javascript代码运行次数:0运行复制public class demo3 {

public static void main(String[] args) throws ClassNotFoundException {

Class aClass = Class.forName("ClassLoad.F");

System.out.println(aClass.getClassLoader());

}

}代码语言:javascript代码运行次数:0运行复制package ClassLoad;

public class F {

static {

System.out.println("bootstrap F init");

}

}输出:

代码语言:javascript代码运行次数:0运行复制bootstrap F init

jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b开始》结束顺序,加载器的运行

启动类加载器如果启动类加载器中有这个类,那么使用应用程序加载器如果应用程序加载器中存在这个类,那么使用扩展器加载器5.3 双亲委派模式指:调用类加载器的loadClass方法,查找类的规则

这里的双亲,翻译为上级更为合适,因为它们并没有继承关系

代码语言:javascript代码运行次数:0运行复制protected Class loadClass(String name, boolean resolve)

throws ClassNotFoundException

{

synchronized (getClassLoadingLock(name)) {

// 首先,检查该类是否已经加载完毕

Class c = findLoadedClass(name);

if (c == null) {

long t0 = System.nanoTime();

try {

if (parent != null) {

// 如果有上级,委派上级LoadClass (ExtClassLoader)

c = parent.loadClass(name, false);

} else {

// 没有上级(ExtClassLoader),则委派 BootstrapClassLoader

c = findBootstrapClassOrNull(name);

}

} catch (ClassNotFoundException e) {

// 类没有找到,则从非空父类装入器

// ClassNotFoundException thrown if class not found

// from the non-null parent class loader

}

if (c == null) {

// 如果仍然没有找到,则反射findClass方法,找到这个类的加载器自己扩展

// If still not found, then invoke findClass in order

// to find the class.

long t1 = System.nanoTime();

c = findClass(name);

// 这是定义类装入器;记录统计数据

// this is the defining class loader; record the stats

PerfCounter.getParentDelegationTime().addTime(t1 - t0);

PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);

PerfCounter.getFindClasses().increment();

}

}

if (resolve) {

resolveClass(c);

}

return c;

}

}5.4 线程上下文类加载器我们在使用JDBC时都需要加载Driver驱动,当我们不写

代码语言:javascript代码运行次数:0运行复制Class.forName("com.mysql.jdbc.Driver")也是可以让 com.mysql.jdbc.Driver正确加载的

追踪一下源码看看:

代码语言:javascript代码运行次数:0运行复制public class DriverManager {

private static final CopyOnWriteArrayList registeredDrivers = new CopyOnWriteArrayList<>();

static{

loadinitialDrivers();

}

}Drivermanager的类加载器:

代码语言:javascript代码运行次数:0运行复制System.out.println(DriverManager.class.getClassLoader());在jdk11中,它使用的加载器是:PlatformClassLoader 是一个平台类加载器

扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。

整个JDK都基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,因为分成了更小颗粒,可以对 moudle 进行组合,而并非都是固定某个 jar,那自然无须再保留\lib\ext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。

类似地,在新版的JDK中也取消了\jre目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个“JRE”:jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

而在 jdk9以前,Drivermanager的类加载器还是 Bootstrap Classloader,会在JAVA_HOME/jre/lib 下搜索类

jdk 8 时,拓展类加载器和用户类加载都是继承的 UrlClassLoader,jdk 11 之后,三个默认的类加载器都继承了 BuiltinClassLoaderBuiltinClassLoader 和 UrlClassLoader 对比 原理差不多,都是基于 UrlClassPath 来实现查找的。但 BuiltinClassLoader 支持从 moudle 加载 class。还有和通常的双亲委派不同,如果一个 class 属于某个 moudle 那么会直接调用该 moudle 的类加载器去加载,而不是说直接用当前类加载器的双亲委派模型去加载。但是找到这个 class 对应的类加载器后,还是会按照双亲委派去加载。5.5 自定义类加载器什么时候需要自定义类加载器

加载非classpath随意路径中的类文件都是通过接口来使用实现,希望解耦时,常用在框架设计这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于tomcat容器步骤:

继承ClassLoader父类要遵从双亲委派机制,重写findClass方法 不是重写loadClass方法,否则不会走双亲委派机制读取类文件的字节码调用父类的defineClass方法来加载类使用者调用该类加载器的loadClass方法代码语言:javascript代码运行次数:0运行复制package ClassLoad;

import java.io.ByteArrayOutputStream;

import java.io.File;

import java.io.IOException;

import java.nio.file.Files;

import java.nio.file.Paths;

public class LoadTest {

}

class MyClassLoader extends ClassLoader {

public static void main(String[] args) throws ClassNotFoundException {

MyClassLoader cl = new MyClassLoader();

/**

* 在同一个类加载器中,加载的类并不会被重新加载(c2)

*/

Class a1 = cl.loadClass("demo1");

Class a2 = cl.loadClass("demo1");

System.out.println(a1 == a2);

/**

* 而在不同类加载器中,哪怕读取的是同一个类,他们由于类加载器的不同,内存的地址也是不同的。

*/

MyClassLoader cl2 = new MyClassLoader();

Class a3 = cl2.loadClass("demo1");

System.out.println(a1==a3);

}

@Override

protected Class findClass(String name) throws ClassNotFoundException {

String path = "E:\\Java\\学习案例\\JVM\\"+name+".class";

try {

ByteArrayOutputStream os = new ByteArrayOutputStream();

Files.copy(Paths.get(path),os);

byte[] bytes = os.toByteArray();

return defineClass(name, bytes, 0, bytes.length);

} catch (IOException e) {

// throw new RuntimeException(e);

e.printStackTrace();

throw new ClassNotFoundException("类文件未找到:",e);

}

}

}6. 😊👉前篇知识点深入JAVA底层 JVM(Java 虚拟机)!带你认识JVM、程序计数器、JVM栈和方法栈还有堆内存!7. 💕好文相推还不了解Git分布式版本控制器?本文将带你全面了解并掌握带你认识依赖、继承和聚合都是什么!有什么用?2-3树思想与红黑树的实现与基本原理 !全网最全! ElasticSearch8.7 搭配 SpringDataElasticSearch5.1 的使用

相关掠夺

28365365体育
如何扫描 QR 码

如何扫描 QR 码

🗓️ 07-02 👁️ 9055