(二)JVM的内存结构

一、前言

内存结构就是 整体结构 的内存那一层

在这里插入图片描述

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区

二、Program Counter Register 程序计数器(寄存器-各自线程独享)

  • 作用,是记住下一条jvm指令的执行地址
  • 特点
    • 是线程私有的
    • 不会存在内存溢出

2.1、图示说明

先看一个流程,如下图,左边是二进制字节码,也就是 JVM 指令,右侧是Java源代码,其编译后就是右侧的内容
在这里插入图片描述

其对应关系如框中所画:

右侧第一个框对应的二进制字节码就是左侧的第一个框第一个对应右侧第二个框对应的二进制字节码就是左侧的第二个框
第二个对应

2.2、提出程序计数器

而编译成二进制字节码后,CPU还是无法调用的,所以还需要转化到CPU可以执行的指令,即将一行行的二进制字节码通过解释器(也是Java的一个组件(在执行引擎那部分)),转变为机器码(后面你就能看到了,在学习解释器那一章,就是一堆十六进制,什么ca fa 的),这样CPU就可以执行这些机器码了。

好了说了那么多了,那么这个程序计数器的作用是什么呢,我们发现在上图中,最最左侧有一些数字下标,而解释器在执行的时候,需要知道下一条二进制字节码的位置,那这个下标就是位置,程序计数器就是记录这个位置的,比如

解释器 第一次执行是0号,那么在执行的时候,会把下一条的二进制字节码的位置保存在程序计数器中,也就是3保存进去,等到执行到3了,再把4保存进入,以此类推

在这里插入图片描述

而程序计数器是Java的称呼,其实底部(也就是物理上)使用的是计算机的内部组件 寄存器(这个在我们学习计算机组成原理的时候有说明,不懂的可以上网收索),而Java的程序计数器就是用寄存器对上述逻辑的封装

2.3、程序计数器特点

2.3.1、线程私有

每个线程都会有自己的程序计数器,因为对于CPU来说,它是按照时间片来执行的,比如下图
在这里插入图片描述

如果CPU第一次执行到线程一的 10 号位置,时间片用完了去执行线程2了,那么线程1的程序计数器就会记住11的位置,当线程2的时间片也用用完了,回到线程1的时候,从线程1的程序计数器中读取到位置11,则接着11的位置开始执行,就不会丢失了。

2.3.2、不会存在内存溢出(OOM)

对于JVM的其它内存结构来说都会存在内存溢出,比如栈,堆等等,而程序计数器在JVM的声明中就表明它不存在内存溢出,所以实现的厂商们不需要考虑它的内存溢出的问题。

二、Java Virtual Machine Stacks (Java 虚拟机栈-各自线程独享)

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

在这里插入图片描述

2.1、定义

2.1.1、图示说明

一个栈里面存在多个栈帧,栈帧是每一个方法运行时需要的内存,那栈帧的这个内存用来 存储什么呢,相信大家也都知道,比如方法里面的参数,局部变量,以及返回地址等。
在这里插入图片描述

2.1.2、代码示例

1、如下代码,可以看到我们在main这个主线程中调用了test1()方法,而test1会调用test2(),接着test2又会调用test3()。所有等下我们在test3方法哪里打个断点就可以看到main这个线程栈中会有这三个方法,而且是顺序的。

public class Test {

    public static void main(String[] args) {
        test1();
        Date t = new Date();
        System.out.println(t);
        System.out.println("hello world");
    }


    public static void test1() {
        String a = "1";
        System.out.println(a);
        test2();
    }

    public static void test2() {
        String b = "2";
        System.out.println(b);
        test3();
    }

    public static void test3() {
        String c = "3";
        // 断点位置
        System.out.println(c);
    }
}

2、打上断点,执行main方法,可以看到在main()方法这个栈帧中有对于的3个方法,而且右侧有显示变量c=3,这个就是存储在栈帧内存中的。
在这里插入图片描述
3、而当我们test3执行完成后,test3方法占用的内存就会释放,离开栈帧,如下图
在这里插入图片描述
4、而且idea提供了我们选择线程栈的方式,只要点击线程栈的位置,我们就可以调试指定的栈,现在我们执行点击了main这个栈,我们也可以点击其它的栈区调试,而且通过这个我们可以很清晰的看到一个线程方法的调用,所以说线程的名称很重要,一定不要顺便乱起,或者默认。
在这里插入图片描述

2.2、问题辨析

2.2.1、垃圾回收是否涉及栈内存?

不涉及。因为对于栈内存来说其内部都是栈帧,而当方法执行结束后,会自动弹出栈帧,也就会被回收,不需要使用垃圾回收。

2.2.2、 栈内存分配越大越好吗?

大家也肯定想的到,并不是越大越好,如果你设置的过大,那么会影响你的线程数量,因为我们的物理总内存是一定的,比如你启动一个项目给它设置内存为400M,那么你设置每个线程栈的内存大小为20M,那么你就最多只有20个线程。我们使用默认的大小就好,这都是调试好的,默认值如下图:
在这里插入图片描述

可以看到除了window系统其它都是默认1M,window系统会根据你设置的总内存而动态调整,你也可以手动指定固定数值,通过在启动时设置VM参数 -Xss

2.2.3、方法内的局部变量是否线程安全?

2.2.3.1、第一种情况:如果方法内局部变量没有逃离方法的作用访问,它是线程安全的

代码如下,方法calculate 内部的变量 x 作用只是在方法内部,所以当多个线程栈的时候,并不会影响。

public class Test02 {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        executorService.execute(Test02::calculate);
        executorService.execute(Test02::calculate);
        System.out.println("end...");
    }

    public static void calculate() {
        int x = 0;
        for (int i = 0; i < 1000; i++) {
            x++;
        }
        System.out.println(Thread.currentThread().getName() + ": " + x);
    }
}

在这里插入图片描述

而且注意变量x,没有使用static,如果使用static就会变成下面这种情况了,线程也就不安全了,需要你自己区处理线程安全的措施。
在这里插入图片描述

2.2.3.2、第二种情况:如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

这种情况下,你得使用逻辑方法保证你的线程安全了。示例代码如下

public static void m1() {
        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list);
    }

    public static void m2(ArrayList<Integer> list) {
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list);
    }

    public static ArrayList<Integer> m3() {
        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        return list;
    }

对于m2方法和m3方法都不能保证 变量ArrayList list的安全(m1是可以的),举个列子对于m2方法,比如现在有2个线程,第一个线程运行的是值包含100的list,如果在m2的方法中阻塞线程(生产中是业务执行慢),那么第一个线程会很慢,那么此时已经执行到 第二个线程,而第二个线程运行的是值包含101的list,它已经直接改变了 ArrayList<Integer> a的地址的值,导致第一个线程的值会随之改变,因为第一个线程用的也是外部的引用,2个线程用的同一个list地址,其中一个线程改变其值,会影响到另一个线程对他的使用。

 public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        ArrayList<Integer> a = new ArrayList<>();
        a.add(100);
        executorService.execute(() -> {
            m2(a);
        });

        
        a.clear();
        a.add(101);
        executorService.execute(() -> {
            m2(a);
        });
    }

    public static void m2(ArrayList<Integer> list) {
        list.add(1);
        list.add(2);
        list.add(3);
        try {
        // 假设这个地方业务执行了2秒
            Thread.sleep(2 * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(list);
    }

结果,这个结果的值是什么没什么意义的,重要的是验证了这个线程不安全的问题,至于m3方法,理论都是一样的,就不再举例了。

[101, 1, 2, 3, 1, 2, 3]
[101, 1, 2, 3, 1, 2, 3]

2.3、栈内存溢出情况

2.3.1、栈帧过多导致栈内存溢出

1、这种情况就很明显了(类似于方法的递归调用,没有终止条件),一直入栈,但是没有栈帧出栈,肯定是会满的,导致栈内存溢出

注意 内存溢出 和 内存泄漏不是一回事,下面这种情况属于内存溢出。

在这里插入图片描述

2.3.1.1、代码示例一(自己导致)

代码示例,下面这个main方法执行,会递归执行m1方法,而m1方法是没有终止条件的,最终栈内存溢出

public class Test04 {

    private static final AtomicLong ATOMIC_LONG = new AtomicLong(0);

    public static void main(String[] args) {
        try {
            // 使用没有终止条件的递归
            m1();
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(ATOMIC_LONG.get());
        }


    }

    public static void m1() {
        ATOMIC_LONG.incrementAndGet();
        m1();
    }
}

在这里插入图片描述

当然为了方便快速显示,我们设置栈的内存大小为256k在这里插入图片描述

2.3.1.2、代码示例二(第三方jar,不适当使用导致)

我们使用jackson的序列化来说明一个案例,下面这个案例会导致,jackson在序列化的时候,出现死循环

public class Test05 {

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

        Dept development = new Dept();
        development.setName("研发");

        Staff zhangSan = new Staff("张三",development);
        Staff liSi = new Staff("李四",development);

        development.setStaffList(Arrays.asList(zhangSan,liSi));

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.writeValueAsString(development);
    }
}

/**
 * 部门
 */
class Dept {
    private String name;
    /**
     * 当前部门其下所有员工
     */
    private List<Staff> staffList;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Staff> getStaffList() {
        return staffList;
    }

    public void setStaffList(List<Staff> staffList) {
        this.staffList = staffList;
    }

    public Dept(String name, List<Staff> staffList) {
        this.name = name;
        this.staffList = staffList;
    }

    public Dept() {
    }
}

/**
 * 员工
 */
class Staff {
    private String name;
    /**
     * 属于那个部门
     */
    private Dept dept;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Dept getDept() {
        return dept;
    }

    public void setDept(Dept dept) {
        this.dept = dept;
    }

    public Staff() {
    }

    public Staff(String name, Dept dept) {
        this.name = name;
        this.dept = dept;
    }
}

在这里插入图片描述

出现死循环的原因如下,在序列化到staffList时,每一个staff又需要dept,就这样一直陷入了死循环

{"name":"研发","staffList":[{"name":"张三","dept":{"name":"研发","staffList":[{"name":"...."}]}},{"name":"李四"}]}

解决上面的问题,你可以自己修改代码的业务逻辑,或者,在序列化的时候,将员工的dept不给予序列化如下,加上 @JsonIgnore注解即可。

 /**
     * 属于那个部门
     */
    @JsonIgnore
    private Dept dept;

2.3.2、栈帧过大

不过这种情况一半不会出现,你可以想一下,假设我们现在使用默认的栈内存大小 1M,则1M = 1024KB = 1048576(1024*1024)B = 8388608 b,而我们的一个int 才占用 4B ,所以下面这种情况 几乎不可能发生,除非你自定义了栈内存大小,设置的太小了。

注意 1M对于栈的存储来说肯定是足够的,就存储一个int ,string,对象地址等等,因为具体的数据存储还是在堆中。

在这里插入图片描述

2.4、线程运行诊断

2.4.1、cpu 占用过多

1、后台运行一个项目
在这里插入图片描述
2、使用top查看到CPU使用率很高

在这里插入图片描述
3、看看这个进程里面是那个线程占用率高
其中 -H:显示进程的层次,-e:命令之后显示环境,-o:用户自定义格式。后面就是我们需要展示的内容了,最后使用grep过滤其它进程信息,只看32655的进程的线程信息。最后我们可以看到是线程32665 使用的CPU很高
在这里插入图片描述

4、使用jstack确认问题
先使用jstack 32655(这个是进程id),可以看到这个进程里面有很多线程,
在这里插入图片描述
那有那么多线程,我们这么确认是那个线程有问题呢,这个时候我们就要用到第三步骤种查出的线程Id,只不过那是十进制的,我们转化一下,看到十六进制后是 0x7F99,所以,我们就定位到问题了
在这里插入图片描述
而且也就定位到具体的代码位置了,然后我们看下源代码即可。

在这里插入图片描述

5、总结

  • 用top定位哪个进程对cpu的占用过高

  • ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)

  • jstack 进程id(查看java进程的具体信息,看是那个线程有问题,根据上一步查出的线程号)

    • 再根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号

但是在实际生产中,这种方式可能就不是那么实用了,因为那个飙高线程的id可能一瞬间就执行完成了,还没等你定位到问题,有换成了另一个线程id继续飙高了。

2.4.2、程序运行很长时间没有结果(可能是死锁了)

1、运行一个程序
在这里插入图片描述
2、使用 jstack 32752 命令查看到最后面,看到它显示有一个死锁,是因为线程一和线程二互相等待锁导致的,分别在Demo1_3的29行和Demo1_3的21行。

在这里插入图片描述
3、查看对应的代码,至于为什么是死锁,我就不解释了,相信大家都能看懂。
在这里插入图片描述

三、本地方法栈(各自线程独享)

定义:JVM调用一些本地方法时,提供的内存

因为Java本身的限制,不能直接调用一些操作系统的方法,只能通过C,C++来调用,然后Java再去调用C,C++的方法,间接去调用操作系统。而那些用C,C++写好的方法,在Java中称作本地方法,用 native 修饰符修饰。
在这里插入图片描述
举一个列子,比如 java中的 Object 类,里面很多都是本地方法
在这里插入图片描述

四、堆(所有线程共享)

在这里插入图片描述

4.1、定义

  • Heap 堆
    • 通过 new 关键字,创建对象都会使用堆内存
  • 特点
    • 它是线程共享的,堆中对象都需要考虑线程安全的问题
    • 有垃圾回收机制

4.1.1、代码示例

对于下面2个线程来说变量a都是在各自的线程栈内存中,但是 new ArrayList(2);会在堆内存中,而a会使用其地址,那堆又说是线程不安全的,但是下面这个代码我们之前在栈那一章节已经证明过了,是线程安全的,是栈内存独有的,这是为什么呢,请看第二张图

public class Test06 {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        executorService.execute(Test06::m1);
        executorService.execute(Test06::m1);
    }

    public static void m1() {
        List<Integer> a = new ArrayList(2);
        a.add(1);
    }
}

这张图就解释了上面的问题,虽然 new ArrayList(2);会在堆内存中,但是这2个线程使用的并不是同一个,而是重新创建,各自使用各自的。所以前面在栈那章的线程安全问题哪里的验证都是成立的,如果此时你把这个list提取到方法参数让外部传入,还是一样的线程不安全,理论是一样的,其实主要就是大家是不是各自使用各自的。
在这里插入图片描述

4.2、堆内存溢出

因为 new ArrayList<>()在堆中,这里一直在死循环的添加元素,最后会抛出 java.lang.OutOfMemoryError: Java heap space,注意这里是OOM,而前面的栈那一章节是 StackOverFlowError,一个是堆内存溢出,一个是栈内存溢出错误。

所以我们就说1M对于栈的存储来说肯定是足够的,就存储一个int ,string,对象地址等等,具体的数据存储还是在堆中。

public class Test07 {
    public static void main(String[] args) {
        int i = 0;
        String a = "hello";
        List<String> list = new ArrayList<>();
        try {
            while (true) {
                i++;
                list.add(a);
                a = a + a;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }

    }
}

在这里插入图片描述
在上面的实验中,我们也可以手动调整堆内存大小,通过调整VM参数

-Xms 设置最小
-Xmx 设置最大

4.3、堆内存诊断

1、jps 工具

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

2、jmap 工具

查看堆内存占用情况 jmap - heap 进程id

3、jconsole 工具

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

这些命令都在jdk的bin目录下,记得要配置到环境变量中,不然不方便使用。

4.3.1、jps和jmap工具使用示例

1、示例代码如下

public class Test08 {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("1....");
        Thread.sleep(30 * 1000);

        // 10M
        byte[] array = new byte[1024 * 1024 * 10];
        System.out.println("2....");
        Thread.sleep(30 * 1000);

        array = null;
        System.gc();
        System.out.println("3....");
        Thread.sleep(30000 * 1000);
    }
}

2、运行后,在控制台输出 1 后,我们执行jps,查看这个示例的进程id,可以看到是 27896 Test08这个。
在这里插入图片描述
3、在控制台没有输出 2 时,我们接着执行 jmap -heap 27896查看进程id是27896的堆内存情况,输出结果如下,因为此刻还没有到2哪里,

 // 10M
        byte[] array = new byte[1024 * 1024 * 10];

就还没有分配 这个10M的内存,所以,下面这个堆的信息就是初始化的时候。

Attaching to process ID 27896, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.181-b13

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

Heap Configuration:    //堆内存配置
   MinHeapFreeRatio         = 0    // 最小堆内存比例
   MaxHeapFreeRatio         = 100  // 最大堆内存比例
   MaxHeapSize              = 3980394496 (3796.0MB)    // 最大堆内存,这里是3796.0MB
   NewSize                  = 82837504 (79.0MB)        // 堆的新生代初始化为79.0MB
   MaxNewSize               = 1326448640 (1265.0MB)    // 堆的新生代最大为 1265.0MB
   OldSize                  = 166723584 (159.0MB)      // 堆的老年代为 159.0MB
   NewRatio                 = 2						   // 堆的新生代中form和to区的占比内存 0.2
   SurvivorRatio            = 8						   // 堆的新生代中eden区的占比内存 0.8
   MetaspaceSize            = 21807104 (20.796875MB)   // 堆的初始元数据空间内存 20.796875MB
   CompressedClassSpaceSize = 1073741824 (1024.0MB)    // 不了解,请上网搜索
   MaxMetaspaceSize         = 17592186044415 MB		   // 堆的最大元数据空间内存 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)				   // 不了解,请上网搜索



Heap Usage:											   //堆的使用情况
PS Young Generation									   // 新生代
Eden Space:											   //新生代中的 伊甸园
   capacity = 62914560 (60.0MB)						   //伊甸园 初始 (60.0MB)
   used     = 8859904 (8.449462890625MB)			   //伊甸园 已经使用的 (8.449462890625MB)
   free     = 54054656 (51.550537109375MB)             //伊甸园 空闲的 (51.550537109375MB)
   14.082438151041666% used							   //伊甸园内存使用占比



From Space:											   //新生代中的 from区
   capacity = 9961472 (9.5MB)						   //新生代中的 from区 初始化内存大小(9.5MB)
   used     = 0 (0.0MB)							       //新生代中的 from区已经使用的 
   free     = 9961472 (9.5MB)                          //新生代中的 from区空闲的 (9.5MB)
   0.0% used										   //新生代中的 from区 内存使用占比

To Space:											   //新生代中的 to 区
   capacity = 9961472 (9.5MB)						   //新生代中的 to 区 初始化内存大小
   used     = 0 (0.0MB) 							   //新生代中的 to 区已经使用的 
   free     = 9961472 (9.5MB)						   //新生代中的 to 区空闲的 (9.5MB)
   0.0% used										   //新生代中的 to 区 内存使用占比


PS Old Generation									   //老年代
   capacity = 166723584 (159.0MB)				       //老年代 初始化内存大小
   used     = 0 (0.0MB)								   //老年代已经使用的 
   free     = 166723584 (159.0MB)					   //老年代 空闲的内存
   0.0% used										   //老年代 内存使用占比

3181 interned Strings occupying 260688 bytes.

4、等控制台输出2后,我们再执行 jmap -heap 27896查看进程id是27896的堆内存情况,输出结果如下,此刻还没有输出3,所以那10M的内存还在堆中占用着,可以看到 Eden Space 中的使用的区域增加了10M,变成了 used = 19345680 (18.449478149414062MB),说明那个10M的byte数组内存分配到了 伊甸园。

Attaching to process ID 27896, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.181-b13

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

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 3980394496 (3796.0MB)
   NewSize                  = 82837504 (79.0MB)
   MaxNewSize               = 1326448640 (1265.0MB)
   OldSize                  = 166723584 (159.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 62914560 (60.0MB)
   used     = 19345680 (18.449478149414062MB)
   free     = 43568880 (41.55052185058594MB)
   30.749130249023438% used
From Space:
   capacity = 9961472 (9.5MB)
   used     = 0 (0.0MB)
   free     = 9961472 (9.5MB)
   0.0% used
To Space:
   capacity = 9961472 (9.5MB)
   used     = 0 (0.0MB)
   free     = 9961472 (9.5MB)
   0.0% used
PS Old Generation
   capacity = 166723584 (159.0MB)
   used     = 0 (0.0MB)
   free     = 166723584 (159.0MB)
   0.0% used

3182 interned Strings occupying 260744 bytes.

5、等控制台输出3后,我们再执行 jmap -heap 27896查看进程id是27896的堆内存情况,输出结果如下,此刻已经手动将byte 数组赋值为 null,并手动执行了 GC,所以那10M的内存不会在堆中占用着,可以看到 Eden Space 中的使用的区域又减少了10M,变成了 used = 1258304 (1.20001220703125MB)

好像不止10M,哈哈哈,反正就是那个意思。

Attaching to process ID 27896, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.181-b13

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

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 3980394496 (3796.0MB)
   NewSize                  = 82837504 (79.0MB)
   MaxNewSize               = 1326448640 (1265.0MB)
   OldSize                  = 166723584 (159.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 62914560 (60.0MB)
   used     = 1258304 (1.20001220703125MB)
   free     = 61656256 (58.79998779296875MB)
   2.0000203450520835% used
From Space:
   capacity = 9961472 (9.5MB)
   used     = 0 (0.0MB)
   free     = 9961472 (9.5MB)
   0.0% used
To Space:
   capacity = 9961472 (9.5MB)
   used     = 0 (0.0MB)
   free     = 9961472 (9.5MB)
   0.0% used
PS Old Generation
   capacity = 166723584 (159.0MB)
   used     = 1038488 (0.9903793334960938MB)
   free     = 165685096 (158.0096206665039MB)
   0.6228800839598074% used

3168 interned Strings occupying 259760 bytes.

4.3.2、 jconsole 工具使用示例

1、代码还是 4.3.1 节的示例代码,这个工具能,是连续检测的,比起上面我们使用 jmap 只能查看某一刻的状态要好很多。当然也是在java的bin目录下的。

如下我们看到堆内存先一下子拔高,就是我们分配内存了,然后一下子降低,说明到了执行gc哪里。

在这里插入图片描述

2、并且这个工具也可以替代 jstack 查看线程和检测死锁等等,你可以点击线程查看具体的详情在这里插入图片描述
3、而且在内存详情的页面还有一个执行GC的按钮,也就是说我们可以点击它来完成手动执行GC。

其实这个工具还是不错的,而且还有一些收费的观察JVM的工具和不收费的观察工具。比如 JProfile,我觉得就很不错。当然这些后面都会说到。

4.3.3、案例-jvisualvm

1、现在有一个珠海的平台,内存占用一直很高(但是之前的版本没有那么高的内存占用,所以是这次更新的问题),而且你发现Java已经自己做过了几次GC但是,效果还是很不理想,于是你使用 jvisualvm(也是在Java的bin目录下) 链接进去查看,点击链接后,右侧会出现这个项目的一些信息,感觉和 jconsole 差不多,但是也有一些它自己的特点。
在这里插入图片描述

2、现在已经连接到你的珠海的项目了Test09这个项目,于是你手动执行下GC,想看看效果,发现并没有减少
在这里插入图片描述
3、所以你现在想看看堆里面都有些啥,于是你把到此刻的堆信息给保存为快照,慢慢分析,点击了右侧的 堆Dump
在这里插入图片描述
4、那现在已经把之前的堆的数据信息保存为快照了,接下来就是分析了,你点击右侧的查找,想去找到堆中最消耗内存的前20个对象
在这里插入图片描述
5、发现有很多的VehicleIo 对象和 一个list对象,分别占用1M和200M,于是你点击list查看,里面是什么存储了200M,而且GC一直无法回收。点进去查看后发现,这个list的大小是200,里面的元素就说VehicleIo,那么就有200M了,
在这里插入图片描述
6、根据第五步骤,已经确认是这个list里面存储了太多的vehilceIo对象,而这个vehicleIo对象一个竟要1M,所以你应该能想到你项目哪里用到了这个,问题代码如下

public class Test09 {
    private static final int COUNT = 200;

    public static void main(String[] args) throws InterruptedException {
        List<VehicleIo> list = new ArrayList<>(COUNT);
        for (int i = 0; i < COUNT; i++) {
            list.add(new VehicleIo());
        }

        // 阻塞线程 模拟线上正在运行的环境
        Thread.sleep(1000 * 1000 * 1000);
    }
}

/**
 * 车辆io 对象
 */
class VehicleIo {
    /**
     * 假设每一个 车辆io 用到的报文都说 1M
     */
    private byte[] message = new byte[1024 * 1024];
}

7、这个工具还是很不错的,你可以多多探索,还有一个和它一样的工具 ,JProfiler 。

但是在window上有一个问题,就是它无法获取到变成win服务的tomcat,解决方案网上有很多。

jdk自带VisualVM无法监控到注册成服务的tomcat

五、方法区(所有线程共享)

在这里插入图片描述

5.1、定义

官方在Java8的文档中定义 Chapter 2. The Structure of the Java Virtual Machine 这个可以去看看,里面有很多的JVM的定义,都说官方发布的,比较的权威。
在这里插入图片描述
翻译内容如下

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法(第 2.9 节,一般指的是类的构造器)。

方法区是在虚拟机启动时创建的。尽管方法区在逻辑上是堆的一部分,但简单的实现可能会选择不进行垃圾收集或压缩它。本规范不要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算需要扩大,如果不需要更大的方法区域,可以缩小。方法区的内存不需要是连续的。

Java 虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,以及在方法区域大小可变的情况下,对最大和最小方法区域大小的控制。

以下异常情况与方法区相关:
如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出 OutOfMemoryError。

从官方的定义中我们可以了解到:方法区是规范

  • Oracle的HotSpot虚拟机对其实现有:永久代(1.8之前)和元数据空间(1.8之后)
  • Eclipse (IBM)的虚拟机 J9 好像没有实现吧,em…不清楚,自己上网可以搜索一下

至于其它的厂家实现的JVM,中方法区的实现这里就不再多说了。

5.2 组成

5.2.1、Oracle的HotSpot在1.6中方法区

在这里插入图片描述
可以看到其实现叫做永久代,里面存储了常量池,class的属性,以及class的信息,和类加载器,而且要注意一定,此时的方法区的内存占用还是在JVM的内存中,也就是我们给JVM分配的内存大小中,1.8之后就不再是了

5.2.1、Oracle的HotSpot在1.8中方法区

在这里插入图片描述
1、和1.6很大的不同就是它的内存不在JVM中,而是用的本地内存,这个本地内存就是指你的操作系统,也就说说你给JVM分配了4个G的大小,这个方法区是不会占用这个4G内存的,而是使用你操作系统的内存。

2、第二点就是方法区的实现换成了元空间,里面存储了class的属性,以及class的信息,和类加载器,但是和1.6不同,它将常量池放到了堆中。

3、第三点,这个方法区内存还会使用启动一些其它进程。

4、串池的位置也从方法去换到到堆(Heap)中

5.3 方法区内存溢出

1、我们将很多的类装配至一个类中,再限制一下方法区大小,这样在加载的时候就会出现方法区溢出了 示例代码如下

public class Test10 extends ClassLoader {


    /**
     * 记得方法区大小,不然,不容易测试出来
     * 1.8设置元空间大小 -XX:MaxMetaspaceSize=8m
     * 1.8之前设置永久代大小  -XX:MaxPermSize=8m
     * @param args
     */
    public static void main(String[] args) {
        int j = 0;
        try {
            Test10 test10 = new Test10();
            for (int i = 0; i < 100000; i++, j++) {
                // ClassWriter 的作用是生产类的二进制字节码
                ClassWriter classWriter = new ClassWriter(0);
                // 定义一个类 版本(1.8,1.7),修饰符(public),类名(Class0,Class1...),包名(null),父类(Object),接口(null)
                classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 将上面生产的类转成二进制字节码
                byte[] bytes = classWriter.toByteArray();
                // 装配到test10这个对象中,这样test10这个对象中就会有很多的上面生成的类(Class0,Class1...),都会保存在方法区中
                test10.defineClass("Class" + i, bytes, 0, bytes.length);
            }
        } catch (Throwable e) {
            System.out.println(j);
            e.printStackTrace();
        }

    }
}

2、因为方法区默认情况下使用的是系统内存,所以下面的两个实验要增加对应的参数,限制方法区大小

    1.8设置元空间大小 -XX:MaxMetaspaceSize=10m
    1.8之前设置永久代大小  -XX:MaxPermSize=10m

5.3.1、1.8 之前会导致永久代内存溢出

运行结果,也可以看出是 PermGen space 永久代实现的方法区。
在这里插入图片描述

5.3.2、1.8 之后会导致元空间内存溢出

运行结果
在这里插入图片描述

5.3.3、场景

  • spring

    spring 中用到了 cglib 区处理aop

  • mybatis

    mybatis中的mapper接口,需要用到cglib生成代理,关联对应的xml文件,区帮助我们生成对应的mapper的实现类。

但是cglib 其实底层也是使用我们前面示例中的 ClassWriter 类来动态生成二进制字节码的。但是现在方法区已经换成的系统内存(如果你不设置的话),可能这种情况就很少出现了,如果出现了,可以考虑下是不是框架使用的不合理。

5.4、运行时常量池

5.4.1、常量池

1、想要知道什么是运行时常量池,就要知道什么是常量池,示例代码如下,可以看到就是一个简单的输出

 */
public class Test11 {
    public static void main(String[] args) {
        System.out.println("hello JVM");
    }
}

2、我们找到这个类的二进制字节码文件,使用 javap命令来反编译,这样我们就能稍微看懂些了。执行命令javap -v .\Test01.class,反编译结果如下

后面的那些 注释的java代码是 javap 帮我们加上的,实际是没有的


// 类基本信息
Classfile /D:/code/exercise/jvm/target/classes/cn/gxm/test01/Test11.class
  Last modified 2022-3-16; size 547 bytes
  MD5 checksum 3d0cb910aad16d9d908ff774fa15f10b
  Compiled from "Test11.java"
public class cn.gxm.test01.Test11
  minor version: 0    // java的最小版本(不是我们说的jdk1.7,jdk1.8,它这是内部的版本)
  major version: 52   // java的最大版本  这个52其实就说对应我们的jdk1.8
  flags: ACC_PUBLIC, ACC_SUPER



// 常量池(这就是常量池了)  后面有很多注释,其实这都是javap 帮助我们生成的,方便我们不用一个地址一个地址的找,比如
//  #1 = Methodref          #6.#20    //你要知道 #1 就得区找#15 和 #20 ,一步一步的找,直到最后找到信息
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 JVM
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // cn/gxm/test01/Test11
   #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               Lcn/gxm/test01/Test11;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Test11.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello JVM
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               cn/gxm/test01/Test11
  #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 cn.gxm.test01.Test11();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1           // 一个栈,一个变量,参数数量一个
         0: aload_0							   // 将卡槽中的0号位置(就是this,可以看LocalVariableTable),放入到栈中去(这里看不懂后面会说的)
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V  调用方法
         4: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/gxm/test01/Test11;

  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    // 一个栈,一个变量,参数数量一个(就是args,虽然没有值)
         // 获取Out 变量,这么找到的 你可以到常量池中的 #2,一步一步的找,就会发现是Out变量
         0: getstatic     #2                  // Field    java/lang/System.out:Ljava/io/PrintStream;  

		 // 加载字符串 hello JVM  (你可以到常量池中的 #3 ,一步一步的找)
         3: ldc           #3                  // String hello JVM

		 // 调用我们的out.println 参数据就是 hello JVM  (你可以到常量池中的 #4  ,一步一步的找)
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 11: 0
        line 12: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "Test11.java"

3、我们见到上面的反编译结果后,初步可以分为三个部分

  • 类基本信息
  • 常量池
  • 类方法定义
  • 虚拟机指令

其中 Constant pool 部分就是我们所说的常量池

5.4.2、运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息 (上面5.4.1已经说明了)

    字面量:比如前面的 hello JVM就是,如果你有一些int的数值,那么也是字面量信息

  • 运行时常量池,常量池是 *.class 文件中的常量池,当该类被加载,它的常量池信息就会放入内存中存贮,变成运行时常量池,并把里面的符号地址变为真实地址

    上面的5.4.1中是把java代码转变为了二进制字节码,区分出了常量池,那么我们在运行的时候,会把那个区分出来的常量池加载到内存中存贮,并且这只是一个文件,如果有多个文件中都有 hello JVM这样的字面量,在内存就只有一份,这就叫运行时常量池。

    并且我们会把里面的那些 #1,#6.#20 这些下标,转化为对应的内存地址,因为已经将这些数据加载到内存中了,直接记住内存地址即可。

5.5、stringTable(串池)

用到的运行时常量池的对象,会被保存到stringTable(串池)中。

只有被用到才会加载到其中(比如 String s1 = “a”,此时的字符串a 才会从运行时常量池中加载到串池中。),懒惰式的。

5.5.1、举例说明一

1、示例代码

public class Test12 {
	public static void main(String[] args) {
	        String s1 = "a";
	        String s2 = "b";
	        String s3 = "c";
	    }
}

2、首先我们使用 javac 编译这个文件,得出二进制字节码文件,我们再使用 javap命令查看得出如下内容

Classfile /D:/code/exercise/jvm/target/classes/cn/gxm/test01/Test12.class
  Last modified 2022-3-16; size 497 bytes
  MD5 checksum a55c3bfcf0e23f9498589c410a8964d2
  Compiled from "Test12.java"
public class cn.gxm.test01.Test12
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#24         // java/lang/Object."<init>":()V
   #2 = String             #25            // a
   #3 = String             #26            // b
   #4 = String             #27            // c
   #5 = Class              #28            // cn/gxm/test01/Test12
   #6 = Class              #29            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/gxm/test01/Test12;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               s1
  #19 = Utf8               Ljava/lang/String;
  #20 = Utf8               s2
  #21 = Utf8               s3
  #22 = Utf8               SourceFile
  #23 = Utf8               Test12.java
  #24 = NameAndType        #7:#8          // "<init>":()V
  #25 = Utf8               a
  #26 = Utf8               b
  #27 = Utf8               c
  #28 = Utf8               cn/gxm/test01/Test12
  #29 = Utf8               java/lang/Object
{
  public cn.gxm.test01.Test12();
    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 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/gxm/test01/Test12;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String c
         8: astore_3
         9: return
      LineNumberTable:
        line 18: 0
        line 19: 3
        line 20: 6
        line 21: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;
}
SourceFile: "Test12.java"

3、此时我们得出 字符串 “a”,“b”,"ab"都已经在常量池中的了

  #2 = String             #25            // a
   #3 = String             #26            // b
   #4 = String             #27            // c

4、当我们运行这个程序的时候,大家也都知道运行的是这个二进制字节码,会把上述的常量池加载到内存中存储,并把 #2,#25 .... 这类的下标替换为内存地址,成为运行时常量池。然后我们开始读取main代码,就是从上面截取下来的。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String c
         8: astore_3
         9: return
      LineNumberTable:
        line 18: 0
        line 19: 3
        line 20: 6
        line 21: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;

5、此时对于整个运行的项目会有一个空白的串池(stringTable),其内部实现是 hashTable(key 就是对于的字符串),且不能扩容。它里面存储的就是你项目用到的运行时常量池对象。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String a   # 加载字符串a,并加入到 串池(stringTable)中,因为此时串池(stringTable)中没有a,所以可以直接把a加进去
         2: astore_1						  // 存储到卡槽的1号位置的变量中(下面的LocalVariableTable 里面有卡槽的下边,可以看到1号卡槽对应的是s1变量)


         3: ldc           #3                  // String b  # 加载字符串b,并加入到 串池
         5: astore_2						  // 存储到卡槽的2号位置的变量中(下面的LocalVariableTable 里面有卡槽的下边,可以看到2号卡槽对应的是s2变量)


         6: ldc           #4                  // String c  # 加载字符串c,并加入到 串池
         8: astore_3						   // 存储到卡槽的3号位置的变量中(下面的LocalVariableTable 里面有卡槽的下边,可以看到3号卡槽对应的是s3变量)


         9: return
      LineNumberTable:
        line 18: 0
        line 19: 3
        line 20: 6
        line 21: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1    s1   Ljava/lang/String;
            6       4     2    s2   Ljava/lang/String;
            9       1     3    s3   Ljava/lang/String;

最后 空白的串池(stringTable)已经不再是空白的了,里面存有 【“a”,“b”,“c”】

5.5.2、举例说明二

1、我们在5.5.1节的基础上,再次说明,和上面的唯一区别就是加了2行不同的,但是根据前面的实验我们已经知道如果执行到s3的位置时,此时串池(stringTable)中肯定会有 【“a”,“b”,“ab”】

public static void main(String[] args) {
        // 懒惰加载到串池中
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
    }

2、现在比较迷惑的就是s4地方的代码,我们先编译成二进制字节码后,再使用javap 来反编译,这里只截取main方法的内存,其它的意义不大,如下

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
	// 执行到这里串池(stringTable)中肯定会有 【"a","b","ab"】,



   // 至于下面这一部分,大家估计也能看懂就是 调用 StringBuilder.append("a").append("b").toString()  并赋值给卡槽的4号位置就是变量s4
   
         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



        29: return
      LineNumberTable:
        line 16: 0
        line 17: 3
        line 18: 6
        line 19: 9
        line 20: 29
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      30     0  args   [Ljava/lang/String;
            3      27     1    s1   Ljava/lang/String;
            6      24     2    s2   Ljava/lang/String;
            9      21     3    s3   Ljava/lang/String;
           29       1     4    s4   Ljava/lang/String;

3、根据上面的解释我们了解了 s4 = StringBuilder.append("a").append("b").toString(),而我们发现
StringBuilder的toString方法是new 一个字符串
在这里插入图片描述
5、所以现在第一个问题,是ture,还是false,很明显答案是false,因为 == 默认比较的是内存地址,而s3的内存地址在串池中,s4指向一个新的内存地址(堆中),因为它使用的是new出来的字符串。

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

5.5.3、举例说明三

1、我们在5.5.1节的基础上,加一个示例,和上面的唯一区别就是加了1行不同的,但是根据前面的实验我们已经知道如果执行到s3的位置时,此时串池(stringTable)中肯定会有 【“a”,“b”,“ab”】

public static void main(String[] args) {

        // 懒惰加载到串池中
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";

        String s5 = "a" + "b";
        System.out.println(s5 == s3);
    }

2、现在比较迷惑的就是s5地方的代码,我们先编译成二进制字节码后,再使用javap 来反编译,这里只截取main方法的内存,其它的意义不大,如下

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=5, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
		// 执行到这里串池(stringTable)中肯定会有 【"a","b","ab"】,


         // 重要的是这9,10两步,你发现他是直接 拿到了字符串 ab 将其赋值给变量s5(存储到4号卡槽,就是变量s5)
         9: ldc           #4                  // String ab
        11: astore        4


        //接下来就是比较了,这里重点不在这,暂时不说了。相信大家也能看懂
        13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: aload         4
        18: aload_3
        19: if_acmpne     26
        22: iconst_1
        23: goto          27
        26: iconst_0
        27: invokevirtual #6                  // Method java/io/PrintStream.println:(Z)V
        30: return
      LineNumberTable:
        line 16: 0
        line 17: 3
        line 18: 6
        line 20: 9
        line 21: 13
        line 22: 30
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      31     0  args   [Ljava/lang/String;
            3      28     1    s1   Ljava/lang/String;
            6      25     2    s2   Ljava/lang/String;
            9      22     3    s3   Ljava/lang/String;
           13      18     4    s5   Ljava/lang/String;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 26
          locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String, class java/lang/String, class java/lang/String ]
          stack = [ class java/io/PrintStream ]
        frame_type = 255 /* full_frame */
          offset_delta = 0
          locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String, class java/lang/String, class java/lang/String ]
          stack = [ class java/io/PrintStream, int ]
}
SourceFile: "Test12.java"

3、根据上面的解释我们了解了 s5 = “ab”),而我们发现 9,10两步,你发现他是直接 拿到了字符串 ab 将其赋值给变量s5(存储到4号卡槽,就是变量s5),所以结果,是ture,还是false?,很明显答案是true,因为 == 默认比较的是内存地址,而s3和s4的地址都是串池中字符串“ab”

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

4、其实这个和 举例说明2中 不一样的原因是 示例2中是使用变量拼接,java在编译的时候并不知道具体的值,只能先用对象去接收,其结果是在堆中的,但是示例3中,不同,它直接使用字符串常量去拼接,所以java在编译期间就知道最后的值,而当准备放进串池的时候,发现串池中已经有了,那么就拿来直接用就好。

5.5.4、举例说明四

示例代码如下,我们证明一下如果串池中已经存在的字符串常量,那么再次加载的时候,用的还是串池的那一份

public class Test13 {

    public static void main(String[] args) {
        System.out.println("start...");

        System.out.println("0");
        System.out.println("1");
        System.out.println("2");
        System.out.println("3");
        System.out.println("4");
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");

        System.out.println("0");
        System.out.println("1");
        System.out.println("2");
        System.out.println("3");
        System.out.println("4");
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");
    }
}

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

5.5.5、手动将字符串入串池

1、我们可以使用字符串的 intern()方法手动将字符串**尝试放入串池中。注意尝试**两个字:

s.intern();方法将这个字符串尝试放入串池,如果串池中已经有了则不会放入,如果没有则放入串池,并且无论最后有没有成功放入进去,一定会把串池中的对象地址给返回

public class Test14 {

    public static void main(String[] args) {
        // 因为这里使用new,所以, new String("a") 和 new String("b") 都是在堆中的,当然变量s是在栈中的
        // 而字符串常量 “a”,“b” 会在常量池,运行时常量池,以及串池中都有一份
        String s = new String("a") + new String("b");

		// 此时调用intern,因为现在串池中还没有 "ab",所以会将自己堆内存的"ab"放入串池中,并返回内存地址,并修改s的引用地址为串池中的"ab"地址
        String s1 = s.intern();
		
		// true  因为此刻我们拿来比较的"ab"就是串池中的地址
        System.out.println(s == "ab");
        // true  因为此刻我们拿来比较的"ab"就是串池中的地址
        System.out.println(s1 == "ab");
    }
}

2、如果我们把字符串 “ab” 提前一步呢,如下

public class Test14 {

    public static void main(String[] args) {
    	// 直接放入串池中
        String x = "ab";
        // 但是此时的s用的是堆中的"ab"地址
        String s = new String("a") + new String("b");
        // 想放进去串池中,但是放不了,因为里面已经有 "ab"了,并把串池中的 "ab" 地址返回了
        // 因为没有放进去,所以s 还是用的是堆中的"ab"地址
        String s1 = s.intern();
	
		// true
        System.out.println(s1 == x);
        // false 
        System.out.println(s == x);
    }
}

3、判断结果
如下代码在jdk1.8的情况下的结果是什么

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);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢(1.6先不了解,容易把自己搞晕)
System.out.println(x1 == x2);

String x3 = new String("a") + new String("b");
System.out.println(x3 == s4); 

如下解释,都在代码里,各位好汉请看

public class Test14 {

    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";

        // 编译期优化,直接入串池(因为都是常量不会变),所以用的串池中的 "ab" 地址
        String s3 = "a" + "b";

        // 用的是stringBuilder().appned().toString 而toString() 是new string()所以s4用的是在堆中的"ab"的地址
        String s4 = s1 + s2;
        // 用的串池中的 "ab" 地址
        String s5 = "ab";

        // 想将s4堆中的"ab"的地址放入串池 但是失败了,因为串池里面已经有了,并返回了串池的“ab”地址(注意s4并没有变)
        // 所以s6用的是串池的 "ab"的地址
        String s6 = s4.intern();


        // 串池"ab"地址 == 堆中的"ab"的地址 ( false)
        System.out.println(s3 == s4);

        // 串池"ab"地址 == 串池"ab"地址 ( true)
        System.out.println(s3 == s5);

        // 串池"ab"地址 == 串池"ab"地址 ( true)
        System.out.println(s3 == s6);

        // 用的是 stringBuilder().appned().toString 而toString() 是new string()所以 x2 用的是在堆中的"cd"的地址
        String x2 = new String("c") + new String("d");
        // 串池中存有 "cd" 地址
        String x1 = "cd";
        // 想将x2堆中的"cd"的地址放入串池 但是失败了,因为串池里面已经有了,并返回了串池的“cd”地址(注意x2并没有变)
        x2.intern();
        // false
        System.out.println(x1 == x2);
        // 问,如果调换了【最后两行代码 String x1 = "cd"; x2.intern();】的位置呢,如果是jdk1.6呢(1.6先不了解,容易把自己搞晕)
        // 如果互换的话 结果就是 true



        // 用的是 stringBuilder().appned().toString 而toString() 是new string()所以 x3 用的是在堆中的"ab"的地址(新new的),但是不是和s4的同一个地址
        String x3 = new String("a") + new String("b");
        System.out.println(x3 == s4); //false
    }
}

5.5.6、StringTable的位置

其实前面已经有过介绍了,不同的jdk版本存放的位置也不同,如果你想要实验,记得根据不同jdk版本设置一个较小的内存来测试。
在这里插入图片描述

5.5.7、StringTable 垃圾回收

这里需要说明一点 串池也是会被垃圾回收的,我们接下来做一个实验证明这一点,当然我们使用的是JDK1.8,所以串池是在堆内存的,为了方便实验,表示堆内存不够的时,串池也是会被回收的,那么我们就先限制堆内存的大小,这样一旦我们实验中加入过多的字符串入串池,就能看出来是否进行了串池的垃圾回收了,添加VM参数如下:

/**
 * -Xmx10m 限制堆内存最大为100M
 * -XX:+PrintStringTableStatistics  将串池信息打印出来,方便我们查看
 * -XX:+PrintGCDetails  将GC执行时的信息打印出来,这样我们就知道有没有执行GC了
 * -verbose:gc 在程序运行的时候有多少gc 被加载 具体使用可以参考:https://www.cnblogs.com/ezgod/p/14538591.html
*/
-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

1、我们先运行一份干净的代码,看看初始的版本是什么样子

public class Test15 {

    public static void main(String[] args) {
        int i = 0;
        try {

        } catch (Throwable e) {
            e.printStackTrace();
        }
        System.out.println(i);
    }
}

结果

0  // 输出的i

// 堆信息
Heap
 PSYoungGen      total 2560K, used 1951K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 95% used [0x00000000ffd00000,0x00000000ffee7fc0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
 Metaspace       used 3360K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 369K, capacity 388K, committed 512K, reserved 1048576K

// 这一千多个字符串常量(13967 ),是我们的类名属性名之类的,
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13967 =    335208 bytes, avg  24.000
Number of literals      :     13967 =    596368 bytes, avg  42.698
Total footprint         :           =   1091664 bytes
Average bucket size     :     0.698
Variance of bucket size :     0.702
Std. dev. of bucket size:     0.838
Maximum bucket size     :         6

// 这一部分就是我们的串池信息可以看到现在是 1762 个,我们主要观察这个
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      1762 =     42288 bytes, avg  24.000
Number of literals      :      1762 =    157904 bytes, avg  89.616
Total footprint         :           =    680296 bytes
Average bucket size     :     0.029
Variance of bucket size :     0.029
Std. dev. of bucket size:     0.172
Maximum bucket size     :         2

2、我们增加串池的数量(增加100个)

public class Test15 {

    public static void main(String[] args) {
        int i = 0;
        try {
            for (int j = 0; j < 100; j++) {
                // 调用 intern 手动使其入串池  注意我们这里只是创建了,但是没有引用偶
                // 所以说如果我们推理正确的话(StringTable 是可以被垃圾回收的),那么内存不足时它是可以被回收的
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }
        System.out.println(i);
    }
}

结果,这里就是展示 串池的信息了,发现是1862 = (1762 + 100),证明这100个是加入到串池中了,但是现在并没有发现回收的信息,所以接下来我们加大数量

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      1862 =     44712 bytes, avg  24.000
Number of literals      :      1862 =    162776 bytes, avg  87.373
Total footprint         :           =    687592 bytes
Average bucket size     :     0.031
Variance of bucket size :     0.031
Std. dev. of bucket size:     0.176
Maximum bucket size     :         2

Process finished with exit code 0

3、我们增加串池的数量(增加10000个)

public class Test15 {

    public static void main(String[] args) {
        int i = 0;
        try {
            for (int j = 0; j < 10000; j++) {
                // 调用 intern 手动使其入串池  注意我们这里只是创建了,但是没有引用偶,所以说如果我们推理正确的话(StringTable 是可以被垃圾回收),内存不足时它是可以被回收的
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }
        System.out.println(i);
    }
}

结果发生了mingr GC,而且发现现在的数量是8857 ,所以StringTable 是可以被垃圾回收的

// 发生了一次GC
[GC (Allocation Failure) [PSYoungGen: 2048K->496K(2560K)] 2048K->722K(9728K), 0.0022356 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       :      8857 =    212568 bytes, avg  24.000
Number of literals      :      8857 =    498808 bytes, avg  56.318
Total footprint         :           =   1191480 bytes
Average bucket size     :     0.148
Variance of bucket size :     0.159
Std. dev. of bucket size:     0.399
Maximum bucket size     :         3

5.5.8、StringTable 性能调优

  • 调整 -XX:StringTableSize=桶个数
  • 考虑将字符串对象是否入池
5.5.8.1、调整 -XX:StringTableSize=桶个数

1、StringTable (串池)是底层是一个hash表,桶的个数越多,hash碰撞的几率就越小,后续链表的长度就越短,所以我们只需要根据我们的项目的实际情况调整桶的个数即可。

2、示例代码如下,我们读取一文件这个文件有40多万行,每一行就一个单词,我们把他们读取出来后,都让其入串池。最后打印耗费的毫秒值。
在这里插入图片描述
3、在我们添加下列参数,将桶个数设置为 200000 后再运行一次,就发现快很多,大概快200毫秒左右。

-XX:StringTablesize=200000 -XX:+PrintstringTablestatistics

4、打印的结果信息 Number of buckets就是我们设置的通个数的值。

// 串池信息
StringTable statistics:
Number of buckets       :     200000 =    480104 bytes, avg   8.000
Number of entries       :      8857 =    212568 bytes, avg  24.000
Number of literals      :      8857 =    498808 bytes, avg  56.318
Total footprint         :           =   1191480 bytes
Average bucket size     :     0.148
Variance of bucket size :     0.159
Std. dev. of bucket size:     0.399
Maximum bucket size     :         3
5.5.8.2、考虑将字符串对象是否入池

下面案例我们都是设置了VM参数,设置了堆内存大小的,方便看出效果。

-Xmx500m

1、如下案例,我们模拟 10 * 40万 个地址,假设这个400万个地址,我们都需要加载到内存中,这消耗会很大,字符串内存占用几乎达到90%

linux.word文件有40万行,一行一个单词
在这里插入图片描述

在这里插入图片描述

2、但是这个地址有很多都是重复的,我们可以通过 串池的 inter()方法来过滤那些重复的数据,修改完成后可以看到字符串只占用了30%多的内存

address.add(line.inter())

在这里插入图片描述

5.5.9、总结

  • 常量池中的字符串仅是符号,第一次用到时才变为对象

  • 利用串池的机制,来避免重复创建字符串对象

  • 字符串**变量**拼接的原理是 StringBuilder (1.8)(s1 = s2+s3)

  • 字符串**常量**拼接的原理是编译期优化 (s1 = “a”+“b”)

  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池

    • 1.8 将这个字符串对象尝试放入串池,如果有则不会放入,如果没有则放入串池, 最后无论是否放入成功都会把串池中的对象地址返回

    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,
      放入串池, 会把串池中的对象返回

只要是变量的字符串拼接用的一定是 StringBuilder.append().toString(),例如

// 这其实也是用StringBuilder
String x2 = new String("c") + new String("d");
// 这其实也是用StringBuilder
String s4 = s1 + s2;

5.6、常量池、运行时常量池、串池的总结

  • 关于 常量池,运行时常量池,以及串池我这里有一些自己的见解,可能不是很正确,我们在编写代码时,用到了一个常字符串 “ab”,那么经过 javac 命令编译成二进制字节码,这个“abc”字符串就会在一个叫做 constant pool的区域部分,此时还是在一个*.class文件中,*我们称此时的 .class文件中的 constant pool的区域部分叫做 常量池

  • 后一步就是java解释器将这些二进制字节码转为cpu可以运行的机器码

  • 再一步,运行这些机器码后,我们刚刚的字符串“ab”,就会保存到内存中(1.8是元数据使用的是系统内存),那么此时我们称这个保存字符串“ab”的区域为运行时常量池

  • 那么一个程序总会有一个入口,比如我们运行的main方法,假设现在有一行代码要用到字符串"ab",比如

    String s1 = "ab";
    
  • 那么此时先去运行时常量池找到字符串 “ab”,再保存到串池中,如果串池中有了,就直接拿来用,没有就先保存进去,再拿来用。

  • 所以只有被用到才会加载到其中(比如 String s1 = “ab”,此时的字符串 “ab” 才会从运行时常量池中加载到串池中。),懒惰式的。

六、直接内存

6.1、定义

不是JVM的内存,而是操作系统的内存

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理,但是当调用system.gc时如果引用的对象已经不再使用,那么这个直接内存也会被回收。(因为用的系统内存,肯定不受它管理,至于为system.gc可以回收不使用的直接内存后面会说的)

6.2、jvm内存与直接内存对比

1、如下案例,我们对同一个视频文件(大约800M)读取,写入到另一个文件中,但是我们缓冲区一个 使用普通 IO 的 byte[1M] 和直接内存 ByteBuffer.allocateDirect(_1Mb);,缓冲区都是1M,可以看到3次的耗时

类型第一次耗时第二次耗时第三次耗时
IO 的 byte[1M]1535.5869571766.9633991359.240226
直接内存 ByteBuffer.allocateDirect(_1Mb);479.295165702.291454562.56592
public class Test16 {

    static final String FROM = "E:\\code\\vide\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
    static final String TO = "E:\\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);
    }
}

2、那么为什么快呢,先看使用IO的,因为java不能直接调用系统的函数,也就是通过native方法来调用,这时java会从用户态转为内核态,而读取磁盘文件不是一下子全部读取完的,是一部分一部分的读取,避免撑爆内存,它首先会将1M的数据放入系统缓存区,因为Java不能使用系统的,所以它会再堆内存中开辟一块 Java缓冲区byte[],来存放那个系统缓存区的1M数据,这样Java才可以操作,这样一份数据要流转2次保存2次,显然是很慢的。
在这里插入图片描述
2、再看使用直接内存的,唯一的不同是,Java堆内存和系统内存都可以直接访问 这个直接内存,这样,1M的数据只需要流转1次,保存1次即可。
在这里插入图片描述

6.3、直接内存溢出

1、直接内存也是会导致内存溢出的OOM,但是提示信息会打印 Direct buffer memory

public class Test17 {

    private static final int _100M = 100 * 1024 * 1024;

    public static void main(String[] args) {
        int i = 0;
        List<ByteBuffer> list = new ArrayList<>();
        try {
            while (true) {
                // 将每次分配的100M的直接内存放到list中引用,避免被回收
                list.add(ByteBuffer.allocateDirect(_100M));
                i++;
            }
        } catch (Throwable e) {
            System.out.println(i);
            e.printStackTrace();
        }
    }
}

2、结果

33
java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at cn.gxm.one.Test17.main(Test17.java:22)

6.4、分配和回收原理

6.4.1、java中如何分配直接内存(系统内存)

1、其实java中是通过 unsafe.allocateMemory(_1Gb);来分配直接内存,再通过unsafe.freeMemory(base);释放直接内存的

public class Test18 {

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

    /**
     * 通过反射获取 Unsafe 类
     *
     * @return unsafe
     */
    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            // 注意我们在通过反射获取静态属性的时候,或者激活津泰方法的时候,只需要将参数中的调用对象改为null即可,因为static本就是所有实例对象共享的
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

2、我们没有运行main方法,看下直接内存,当然我们不能通过之前的jmap,jconsole等等工具来看,因为那些工具只能看到JVM管理的,对于直接内存因为使用的是系统内存,所以我们可以直接看win的任务管理,可以看到没有运行main时占用了3G左右
在这里插入图片描述
3、分配成功
在这里插入图片描述
4、释放成功
在这里插入图片描述
5、所以我们可以得出,java是通过unsafe类来操作系统函数再来操作直接内存的。

6.4.2、为什么通过system.gc可以回收直接内存?

1、效果我就不再演示了,我们通过调用 System.gc();就会回收没有使用的直接内存。

public class Test19 {

    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;

        // 显式的垃圾回收,Full GC
        System.gc();
        System.in.read();
    }
}

2、我们点击进入ByteBuffer.allocateDirect(_1Gb);的方法内部查看,果然还是通过 unsafe来分配内存,那么肯定也是通过它来释放内存,那么会在哪里呢?
在这里插入图片描述
3、创建直接内存的时候,可以看到一个Cleaner
在这里插入图片描述
4、这个类我们点进去看,它是一个实现了虚引用的类
在这里插入图片描述
5、我们看到它的clear方法,里面就是调用传入参数的一个线程任务
在这里插入图片描述
6、而这个任务其实就是调用 unsafe的清除内存的方法
在这里插入图片描述
在这里插入图片描述

7、所以我们可以得出,一旦使用直接内存的对象,被垃圾回收掉了,那么就会一个实现了虚引用的Cleaner类的clear()方法,而clear方法里面会调用传入的参数的一个名为Deallocator的任务,而Deallocator的里面实现就是调用 unsafe.freeMemory(address);来清除直接内存。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值