JVM 内存模型 —— JVM篇

JVM 内存模型

一、JVM 体系结构

​ Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。JVM所管理的内存将会包括以下几个运行时数据区域。如下图所示:

JVM 内存模型 —— JVM篇_第1张图片

运行时数据区也是Java虚拟机的内存结构,如以下图所示:

JVM 内存模型 —— JVM篇_第2张图片

1.classfile 是字节码文件的部分。

2.classloader 是类加载器。

3.内存结构也就是运行时数据区。

4.执行引擎的部分,由解释器,JIT即时编译器和GC垃圾回收组成。

在整个JVM体系结构中,加载一个类的过程是:

​ 类的对象是存储在方法区中的,堆中是存储对象和成员变量的,而堆中的对象在方法的执行过程中,需要用到虚拟机栈,程序计数器,以及本地方法栈;方法在执行的时候,它的每行代码都是由执行引擎中的解释器逐行解释的,对于热点代码则通过JIT即时编译器进行编译;在执行引擎中还有一个比较重要的模块,垃圾回收GC,它会对堆中的不在引用对象进行垃圾回收,还会有一些java不太方便实现的功能,需要调用底层操作系统的功能,则是通过本地方法接口来进行实现。

二、JVM 系统线程

​ 有很多的线程其实是在后台运行的,这些后台线程与主函数的主线程以及主线程创建的一些其他线程是在一起运行的,HotSpotVM 后台运行的系统线程有以下几个:

线程名称 线程作用
虚拟机线程(VM thread) 这个线程等待JVM到达安全点操作出现。这些操作必须要再独立的线程里执行,因为当堆修改无法进行时,线程都需要JVM位于安全点,这些操作的类型的有 stop-the-world 垃圾回收,线程栈 dump 线程暂停,线程偏向锁(biased locking)接触
周期性任务线程 这个线程负责定时器事件(也就是中断) 用来调度周期性操作的执行
GC线程 这些线程支持JVM 中不同的垃圾回收活动
编译器线程 这些线程在运行时讲字节码动态编译成本地平台相关的机器码
信号分发线程 这个线程接受发送到JVM的信号并调用适当的JVM方法处理

三、程序计数器

​ 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是**当前线程所执行的字节码的行号指示器。**在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

​ 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

​ 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError(内存溢出)情况的区域。

​ 所有的CPU 都有一个PC 每执行一条指令PC都会自增 因此PC存储了指向下一条要被执行的指令。例如以下代码:

package com.openlab;

public class PCRegisterTest {
    public static void main(String[] args) {
        System.out.println(1);
        System.out.println(2);
        System.out.println(3);
        System.out.println(4);
        System.out.println(5);
    }
}

Jclasslib打开:

JVM 内存模型 —— JVM篇_第3张图片

​ 程序计数器在这里记住的是JVM下一条指令的执行地址,当第一条指令执行完,那么解释器就会到程序计数器中去寻找下一条指令。从物理上来讲的话,程序计数器是通过寄存器来实现的。

特点:

  • 线程私有的:

    ​ 多个线程在执行代码的时候,CPU会用一个调度系数,给线程分配时间片,线程在现有的时间片,没有执行完,那么会让它执行一个暂存的指令,然后切换到其他线程,其他线程也是如此。那么线程在切换的过程中,如果想知道下一个指令执行到哪了,会使用程序计数器来进行记录。

    ​ 每条线程里都有自己的程序计数器,来记录自己线程执行到的指令 当线程切换后 又切换到原有的线程那么它就从自己的程序计数器中取出要执行的下一条指令。

  • 程序计数器不存在内存溢出 OOM(OutOfMemoryError)

  • 程序计数器是很小的空间,可以把它看成字节码行号的指示器。

四、虚拟机栈

​ 与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

​ 栈帧: 每个方法在执行的时候需要的内存,方法在调用的时候,需要参数 、局部变量、返回地址、这些都是需要地址,一个虚拟机栈是由多个栈帧组成。先进后出。

JVM 内存模型 —— JVM篇_第4张图片

总结:

  • 每个线程运行时需要的内存 -------虚拟机栈

  • 每个栈都由多个栈帧组成,每一个栈帧又对应着方法调用的内存

  • 每个线程只能有一个活动的栈帧,对应着当前正在执行的方法

例如以下代码进行Debug分析方法进栈的顺序:

package com.openlab;

public class StackTest {
    public static void main(String[] args) {
        method1();
    }

    private static void method1(){
        method2(1,2);
    }

    private static int method2(int a,int b){
        int c = a+b;
        return c;
    }
}

main()主方法:

image-20220313150857919

JVM 内存模型 —— JVM篇_第5张图片

method1()方法:

image-20220313150928632

JVM 内存模型 —— JVM篇_第6张图片

重要的几种问题

1.垃圾回收是否涉及到虚拟机栈?

答:不需要,因为每一层的方法执行完之后都会被弹出栈空间,根本就不需要垃圾回收。

2.栈内存分配越大越好吗?

答:不是的,首先可以通过 -Xss 来设置栈空间的大小,栈的空间越大,并不会提升程序的运行的速度,反而会让程序的线程数量变少,因为物理内存是固定的(如果 jvm 设置的内存过大,就会导致其它程序所占用的内存小)。栈的内存越大是会提升方法进行递归调用的次数。

3.方法内的局部变量是否是线程安全的?

答:这个问题主要看的就是局部变量被多个线程共享的,当如果两个线程中的局部变量互不干扰时,那么局部变量是对线程私有的,不会存在线程的安全问题。如下图所示:

JVM 内存模型 —— JVM篇_第7张图片

但是当这个变量一旦被static关键字修饰时,那么它就不安全了。如下图所示:

JVM 内存模型 —— JVM篇_第8张图片

如以下代码所示:

package com.openlab;

public class Demo02 {

    public static void main(String[] args) {

//        StringBuilder sb = new StringBuilder();
//        sb.append(4);
//        sb.append(5);
//        sb.append(6);
//
//        new Thread(()->{
//           m2(sb);
//        }).start();

    }

    public static void m1(){
        StringBuffer sb = new StringBuffer();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());

    }

    public static void m2(StringBuilder sb){

        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());

    }

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

    }
}

根据上述代码,每个方法的线程安全如下逐个分析:

  • m1方法:是线程安全的,StringBuffer sb 本身就是一个局部变量。
  • m2方法:StringBuffer sb 变成了一个参数,所以它线程不安全,有可能会有其他线程访问到它 sb 对象,不是线程私有的了。
  • m3方法:将 StringBuffer sb 做成了返回值。那么它线程不安全,因为其他线程是可以拿到这个方法的返回值,并且可能会去修改它。

​ 程序员最关注的、与对象内存分配关系最密切的区域是“堆”和“栈”两块。其“栈”通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。

1. 局部变量表

局部变量表存放:

  • 编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)
  • 对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)
  • returnAddress类型(指向了一条字节码指令的地址)

​ 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

​ 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
  • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2. JVM 栈帧

​ JVM栈帧:方法调用的和方法执行的一个数据结构,一个方法从调用到执行完成。

每一个栈帧都包括:

  • 局部变量表

  • 操作数栈

  • 动态链接

  • 方法返回地址

  • 额外的信息

如下图所示:

JVM 内存模型 —— JVM篇_第9张图片

3. 操作数栈

​ 类似局部变量表,操作数栈也是先进后出的;操作数栈的最大深度是class文件中的code属性中的 max_stacks 数据项。

​ 操作数栈可以存储任意类型:

  • 32位的数据类型,栈容量为1
  • 64位的数据类型,栈容量为2
  • 栈容量的单位 —— 字宽:
    • 对32位的虚拟机,一个字宽是 4 个字节
    • 对64位的虚拟机,一个字宽是 8 个字节

在进行算术运算上就是通过操作数栈来进行的:

JVM 内存模型 —— JVM篇_第10张图片

4. 动态连接

​ 每个栈帧都包含一个指向运行时常量池中这个栈帧属性和方法的引用,持有这个引用是为了支持方法调用过程的动态连接。

​ 在class文件的常量池中有大量的符号引用的,方法在调用的时候和调用指令的时候会以符号引用作为参数。

​ 符号引用的一部分是在类的加载阶段或是在第一次使用的时候转化为直接引用称为静态解析。

​ 另一部分在每次运行期间转化的直接引用称为动态链接。

5. 方法的返回地址

方法的退出方式有两种:

  • 正常完成出口:执行引擎遇到return的指令,返回的字节码指令:返回值和返回类型

  • 异常完成出口:方法在执行时遇到了异常,异常没有在方法体内得到处理,一种是虚拟机内部异常,代码中athrow字节码的异常。当出现异常时是不会产生任何返回值的。

​ 只要是方法的退出,都要回到方法的调用位置,这个位置是由程序计数器来记录的可以作为返回的地址,栈帧中会保存这个计数器的值;如果异常退出的话,返回的地址是通过异常处理器来确定的。异常信息是不会保存栈帧中的。

6. 附加信息

​ 栈帧的信息:栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息

五、本地方法栈

​ 本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

​ 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,提供支持的JVM一般都会为每个线程创建本地方法栈。

  • Hotspot虚拟机:直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
  • C—linkage 模型实现 JNI java native Invocation 这个本地栈就被称为C栈,本地方法栈参数的顺序,返回值和C程序相同的。

六、堆

​ Java堆(Java Heap)是虚拟机所管理的内存中最大的一块Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。在《Java虚拟机规范》中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配 ”,而这里写的“几乎”是指从实现角度来看,随着Java语言的发展,说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”,从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空 间”“To Survivor空间”等名词,首先说明一下这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。

​ Java堆是收集器管理的主要区域,GC堆 采用的分代收集的算法。

  • 新生代:经常被分为 Eden空间和Survivor空间

  • 老年代

  • 永久代:1.8 的jdk之后,就不存在永久代了。 被metaspace

在细致的去分

  • Eden空间

  • FromSurvivor空间

  • ToSurvivor 空间

​ 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

​ Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

1. 分代策略

JVM 内存模型 —— JVM篇_第11张图片

永久代的变化:

​ 1.8 的 jdk之后,就不存在永久代了。 被metaspace(元空间)替代了,采用永久代的方式来实现方法区,永久代用于放常量、的信息、静态变量等数据,这些数据和垃圾回收的关系不是很大,和垃圾回收关系比较大的两个区域分别是新生代和老年代。

JVM 内存模型 —— JVM篇_第12张图片

2. 内存管理

​ 所有的对象和数组都会在堆内存中进行分配,并且永远不会显示回收,而是由垃圾回收器自动回收。通常新的对象和数组被创建并放入老年代。

​ Minor 垃圾回收器:回收将发生在新生代的,依旧存活的对象将从eden区域移到Survivor区。

​ Major 垃圾回收:一般会导致应用进程的暂停,它将在三个区内移动对象,仍然存活的对象会被从新生代移动到老年代。

​ 每次老年代回收时也会进行永久代的回收,它们中任何一个变满了,都会进行回收。

2.1 新生代

​ 新生代:新生成的对象优先的放在新生代中,存活率很低。新生代中的常规应用进行一次垃圾回收, 可以回收到70%-95% 效率很高。

​ HotSpot 将新生代划分为三个区:

  • Eden区(伊甸区):一块比较大的
  • Survivor区(幸存者区):有两块较小的
    • FromSurvivor空间
    • ToSurvivor空间

默认比例为:8:1:1,为什么用这个比例?原因是因为 HotSpot 采用的复制算法来回收新生代。

在新生代中,大的对象是不会进入新生代的 而是直接进入老年代。

​ 当Eden区没有足够的空间进行分配,虚拟机将发起一次MinorGC:

  • GC开始的时候,对象会存在Eden区。这个时候FormSurvivor区和ToSurvivor 是空的,作为保留区域。

  • GC进行的时候,Eden区的所有存活的对象都会被复制到ToSurvivor 区中,而在FormSurvivor区的是扔存活的对象,根据他们的年龄值决定去向。年龄值达到年龄的阀值(15 新生代中的对象每熬过一次GC 年龄就+1)的对象会被移动到老年代中,没有达到阀值的对象会被复制的ToSurvivor区,接着FormSurvivor和ToSurvivor 两者交换角色。都要保证ToSurvivor 区在一轮GC后是空的;如果在GC时,当ToSurvivor没有足够的空间存放上一次新生代收集下来的存活对象,需要依赖老年代进行分配担保,将这些对象放入到老年代。

2.2 老年代

​ 老年代:在新生代经历了多次GC(具体要看虚拟机的配置的情况)后仍然存活了下来的对象就会进入老年代,老年代的对象的生命周期长,存活率高;在老年代进行GC的频率比较低,而且回收速度也比较慢。

2.3 永久代 PermanentGeneration — metaspace

​ 永久代:存放类的信息、常量、静态变量、JIT即时编译器编译后的代码等数据。这个区域是不进行垃圾回收的。

3. 非堆内存

​ 在逻辑上是JVM的一部分对象,但实际上都是在堆上创建的。

非堆内存:

  • 永久代
    • 方法区
    • 驻留字符串
  • 代码缓存:Code Cache 用于编译和存储哪些被JIT即时编译器编译的原生代码方法。

4. 栈内存溢出

栈内存溢出情况

  • 栈帧过多的时候容易造成溢出

  • 栈帧过大的时候容易造成溢出

例如以下代码测试:

package com.openlab;
public class StackOverflowTest {
    private  static int count;
    public static void main(String[] args) {
        try{
            method1();
        }catch(Throwable e){
            e.printStackTrace();
            System.out.println(count);
        }
    }
    private static void method1(){
        count++;
        method1();
    }
}

测试结果为栈内存溢出:

JVM 内存模型 —— JVM篇_第13张图片

也可以通过 -Xss256k 去调整栈空间的大小,设置过程如下图所示,也是所以JVM设置参数的流程:

JVM 内存模型 —— JVM篇_第14张图片

JVM 内存模型 —— JVM篇_第15张图片

JVM 内存模型 —— JVM篇_第16张图片

再次测试结果如下:

JVM 内存模型 —— JVM篇_第17张图片

5. 堆内存溢出

测试堆内存溢出代码如下:

package com.openlab;

import java.util.ArrayList;
import java.util.List;

public class HeapOverFlowTest {

    public static void main(String[] args) {

        int i = 0;
     try{
         List list = new ArrayList();
         String a = "hello";
         while(true){
             list.add(a);
             a = a+a;// hellohellohellohello
             i++;
         }
     }catch(Throwable e){
         e.printStackTrace();
         System.out.println(i);
     }
    }
}

测试结果如下:

JVM 内存模型 —— JVM篇_第18张图片

也可以通过虚拟机参数去指定堆空间的大小 -Xmx8m:

JVM 内存模型 —— JVM篇_第19张图片

测试结果如下:

JVM 内存模型 —— JVM篇_第20张图片

6. 堆内存诊断

(1)命令行工具 jps

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

(2)命令行工具 jmap jmap -heap 进程id

​ 查看堆内存中的占用情况

(3) jconsole

​ 图形界面的工具 多功能的检测工具 可以连续监控。

使用以下代码进行测试:

package com.openlab;

public class Demo03 {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        Thread.sleep(30000);
        byte[] array = new byte[1024*1024*10];// 10M
        System.out.println("2...");
        Thread.sleep(30000);
        array = null;

        System.gc();
        System.out.println("3...");
        Thread.sleep(1000000L);
    }
}

命令行窗口的使用:

View ->Tool Windows ->Terminal

JVM 内存模型 —— JVM篇_第21张图片

JVM 内存模型 —— JVM篇_第22张图片

显示为:

JVM 内存模型 —— JVM篇_第23张图片

6.1 jps 指令

输入jps指令:查看系统中的java进程

JVM 内存模型 —— JVM篇_第24张图片

6.2 jmap 指令

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

JVM 内存模型 —— JVM篇_第25张图片

其中Heap Configuration:

JVM 内存模型 —— JVM篇_第26张图片

常用的内存意思:

  • Heap Configuration:堆的配置

  • MaxHeapSize:最大的堆内存空间

  • NewSize:新生代内存

  • MaxNewSize:最大的新生代内存空间

  • OldSize:老年代内存

  • Metaspace:元空间的内存

其中堆的使用情况如下:

JVM 内存模型 —— JVM篇_第27张图片

其中的一些意思为:

  • Heap Usage:表示堆内存的占用

  • Edenspace:表示新生代的Eden区

  • Form:Form Survivor

  • To:To Survivor

  • Old Generation:表示老年代

实例实验流程:

代码:

package com.openlab;

public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        //执行打印1...之后,查看没有声明数组的内存占用情况
        Thread.sleep(30000);
        byte[] array = new byte[1024*1024*10];// 10M
        System.out.println("2...");
        //执行打印2...之后,查看有声明数组的内存占用情况
        Thread.sleep(30000);
        array = null;

        System.gc();
        System.out.println("3...");
        //执行打印3...之后,查看数组被赋值为null 以及调用gc之后的内存使用情况
        Thread.sleep(1000000L);
    }
}

执行打印1…之后,查看没有声明数组的内存占用情况:

JVM 内存模型 —— JVM篇_第28张图片

堆使用情况:

  • 容量:63.5M

  • 使用的容量:6.35M

  • 空闲:57.15M

执行打印2…之后,查看有声明数组的内存占用情况:

JVM 内存模型 —— JVM篇_第29张图片

堆使用情况:

  • 使用的容量:16.35M

  • 空闲的容量:47.15M

执行打印3…之后,查看数组被赋值为null 以及调用gc之后的内存使用情况:

JVM 内存模型 —— JVM篇_第30张图片

堆使用情况:

  • 使用的容量:1.27M

  • 空闲的容量:62.23M

6.3 jconsole 指令

运行程序:然后在命令行输入jconsole:

image-20220313195448253

运行后会弹出如下图所示:

JVM 内存模型 —— JVM篇_第31张图片

然后选择不安全连接:

JVM 内存模型 —— JVM篇_第32张图片

展示监控平台的工具:

JVM 内存模型 —— JVM篇_第33张图片

在这个里面可以检测出内存的占用、线程、类的加载情况等等。

七、方法区

​ 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、JIT即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

​ 说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

​ 而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

下图是JDK1.6和JDK1.8的版本不一样的内存结构,对于方法区的变化:

JVM 内存模型 —— JVM篇_第34张图片

​ **《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。**相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

1. 方法区结构

JVM 内存模型 —— JVM篇_第35张图片

2. JDK1.6,JDK1.7与JDK1.8版本

​ 在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

​ 但是对于其他虚拟机实现,譬如BEAJRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java MissionControl管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。

​ 考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了。

到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

在常量池中,主要分成以下几种:

(1)静态常量池:就是.class文件中的常量池,包含字符串数字这些字面量,还包含类的方法的信息,占用了classfile的大部分空间,这个常量池主要用于放两大类的常量:字面量和符号引用,包含了三种类型的常量、类和接口的全限定名、字段名称和描述符、方法名称和描述符。

(2)运行时常量池:虚拟机会将各个的class文件中常量池载入到运行时常量池中,即编译期间,生成字面量、符号引用,装载class文件。

(3)字符串常量池 StringTable:可以把它理解为运行时常量池分出来的一部分内容,加载时对于class的静态常量池,字符串会被装载到字符串常量池中。

(4)整型常量池:Integer 类似 StringTable 可以把它理解为运行时常量池分出来的一部分内容,在加载时如果是整型的数据会被装到整型的常量池中。

​ 在永久代移出之后,字符串常量池(StringTable)也不再放在永久代了, 也没有放入到元空间里,而是继续留在了堆空间了Heap,这是为什么? 方便回收吗?

​ 而运行时常量池搬到了元空间里,它是装载静态变量、字节码的信息,有它的地方才被称为方法区。

JVM 内存模型 —— JVM篇_第36张图片

JVM 内存模型 —— JVM篇_第37张图片

​ 这里注意:所有的线程共享同一个方法区,因此访问方法区的数据和动态链接的进程必须是线程安全的。(加锁)

在方法区里存在的内容:

  • Classloader 引用:通过this.getClass().getClassLoader()进行获取 。
  • 对象中含有字节码和加载器的地址引用。

类型信息

  • 修饰符(public final)
  • 是类还是接口(class,interface)
  • 类的全限定名(Test/ClassStruct.class)
  • 直接父类的全限定名(java/lang/Object.class)
  • 直接父接口的权限定名数组(java/io/Serializable)

运行时常量池

  • 数值型常量
  • 字段引用
  • 方法引用
  • 属性
  • 字段数据
  • 针对每个字段的信息
  • 字段名
  • 类型
  • 修饰符
  • 属性(Attribute)

方法数据

  • 每个方法
  • 方法名
  • 返回值类型
  • 参数类型(按顺序)
  • 修饰符
  • 属性

方法代码

  • 每个方法
  • 字节码
  • 操作数栈大小
  • 局部变量大小
  • 局部变量表
  • 异常表
  • 每个异常处理器
  • 开始点
  • 结束点
  • 异常处理代码的程序计数器(PC)偏移量
  • 被捕获的异常类对应的常量池下标

常量池

直接常量:

  • CONSTANT_INGETER_INFO称为二路归并

    public final int CONST_INT=0;
    
  • CONSTANT_String_info字符串直接常量池

    public final String CONST_STR="CONST_STR";
    
  • CONSTANT_DOUBLE_INFO浮点型直接常量池

​ 等等各种基本数据类型基础常量池、方法名、方法描述符、类名、字段名,字段描述符的符号引用。

参考博客内容:https://blog.csdn.net/qq_43684005/article/details/119615972

3. 方法区内存溢出

1.8版本以前 导致永久代的内存溢出

1.8版本之后 导致元空间的内存溢出

package com.openlab;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

// 元空间内存溢出
//-XX:MaxMetaspaceSize=8m
public class Demo05  extends  ClassLoader{

    public static void main(String[] args) {
        int j = 0;
      try{
          Demo05 demo = new Demo05();

          for(int i = 0;i<10000;i++,j++){
             // 用代码的方式生成字节码
              ClassWriter cw = new ClassWriter(0);
              // 版本号 public 类的名字  包名 类的父类 接口
              cw.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"Class"+i,null,"java/lang/Object",null);
              byte[] code = cw.toByteArray();
              // 执行了类的加载
              demo.defineClass("Class"+i,code,0,code.length);

          }

      }finally{
           System.out.println(j);
      }

    }
}

​ 1.8jdk 版本之后永久代被移出,由metaspace(元空间)来替代,元空间是没有上限的,所以这段代码在不调整JVM的虚拟机参数的情况下是不会溢出的。

在这里根据上面所述过的设置参数的过程,设置-XX:MaxMetaspaceSize=8m:

JVM 内存模型 —— JVM篇_第38张图片

测试结果如下,会报异常信息:

JVM 内存模型 —— JVM篇_第39张图片

​ 在这里注意如果测试永久代溢出,把jdk版本修改成1.6的版本,并且也需要修改一下虚拟机的参数:-XX:MaxPermSize=8m;正常来讲,这里应该出现的error OutofMemoryError 异常信息。

场景: 在生产环境中,这种动态生成class的方法是非常常用的方式。

Spring Aop:动态代理 ----cglib Aop的核心。

Mybatis:也应用cglib ,比如mapper接口的生成。

4. StringTable 字符串池

​ String类时Java最常用的类,在Java中创建对象会很耗费性能的事,所以再有了StringTable这一特殊的存在后,就可以相对的减少耗费性能,StringTable叫作字符串常量池,是用于存放字符串常量,这样当我们使用相同的字符串对象时,就可以直接从StringTable中获取而不用重新创建对象。

String的不可变性

​ 对于StringTable字符串池,String 的不可变性很重要,是StringTable字符串池的前提。当定义一个字符串时:

String s = "abc";

​ 这时候,“abc” 就被存放在StringTable中,而变量s是一个引用,s指向了StringTable中的“abc”。如下图所示字符串的内部存储情况:

JVM 内存模型 —— JVM篇_第40张图片

​ 然后当再给s附一个字符串时,改变它的值,改成“abcabc”

String s = "abc";
s = "abcabc";

​ 这时候,并不是把原来的s所指向的“abc”的值改变为“abcabc”,而是指向了一个新的字符串,如下图所示:

JVM 内存模型 —— JVM篇_第41张图片

​ 如何去验证是指向了一个新的字符串而不是修改其内容,可以打印一下hash值看看。

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String s = "abc";
        System.out.println(System.identityHashCode(s));
        s = "abcabc";
        System.out.println(s.hashCode());
        s = "abc";
        System.out.println(System.identityHashCode(s));
    }
}

执行结果如下:

JVM 内存模型 —— JVM篇_第42张图片

​ 从以上结果可以看到,第一次和第三次的hash值一样,第二层hash值和其它两次不同,说明确实是指向了一个新的对象而不是修改了String的值。

那么String是怎么实现不可变的呢,需要看一下String类的源码:

JVM 内存模型 —— JVM篇_第43张图片

​ 从源码中我们可以看出,首先String类是final的,说明其不可被继承,就不会被子类改变其不可变的特性;其次,String的底层其实是一个被final修饰的数组,说明这个value在确定值后就不能指向一个新的数组。这里我们要明确一点,被final修饰的数组虽然不能指向一个新的数组,但却是可以修改数组的值的

既然可以被修改,那String怎么是不可变的呢??

因为String类并没有提供任何一个方法去修改数组的值,所以String的不可变性是由于其底层的实现,而不是一个final。

String为什么要设计成不可变的呢??

​ 因为是出于安全性的考虑,可是试想一下,在一个程序中有多个地方同时引用了一个相同的String对象,但是可能只是需要在一个地方修改String的内容,要是String是可变的,导致了所有的String的内容都改变了,如果这个String字符串时账户的一个密码,-那不就会出很大的问题吗,所以说String就被设计成了不可变的。

字符串的拼接

如以下代码:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String a = "abc";
        String b = "def";
        String c = a+b;
    }
}

上述代码的字节码指令如下图:

JVM 内存模型 —— JVM篇_第44张图片

​ 这里用蓝色标注的几行代码,可以看到,字符串拼接其实就是调用StringBuilder的append()方法,然后调用了toString()方法返回一个新的字符串。

StringTable详细讲解

​ StringTable:它的底层数据结构是HashTable,每个元素都是key-value结构,采用了数组+单向链表的实现方式。

字符串什么时候被放入StringTable的???

如下代码所示:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
     -> String a = "abc";
        String b = "def";
        String c = "abcdef";
    }
}

​ 在类加载后,“abc”这些字符串仅仅是当作符号被加载进了运行时常量池中,还没有成为字符串对象,这是因为Java中的字符串采用了延迟加载的机制,就是程序运行到具体某一行的时候再去加载。比如当程序运行到箭头所指向的那一行时,“abc”会从一个符号变成一个字符串对象,然后去StringTable中找有没有相同的字符串对象,如果有的话就返回对应的地址给变量a,如果没有的话就把“abc”放入StringTable中,然后再把地址给变量a。我们来看一下是不是这样:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "abc";
        String s3 = "abc";
        String s4 = "abc";
        System.out.println(System.identityHashCode(s1));
        System.out.println(System.identityHashCode(s2));
        System.out.println(System.identityHashCode(s3));
        System.out.println(System.identityHashCode(s4));
    }
}

执行结果如下:

JVM 内存模型 —— JVM篇_第45张图片

​ 从上图可以看出,四个字符串对象的hash值都一样,说明如果StringTable中已经有了相同的对象就会指向同一个对象而不是指向新的对象。

new String() 的时候都干了什么

​ 当使用new String()去创建一个字符串对象时和直接写String a = "abc"是不一样的。前者保存在堆内存中,后者保存在StringTable中

JVM 内存模型 —— JVM篇_第46张图片

其实StringTable也是在堆中,在后面会详细说明。运行一下代码来验证一下上面的说法:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String a = "abc";
        String b = new String("abc");
        System.out.println(a == b);
    }
}

执行结果如下:

image-20220316185030400

以上结果很显然是false,说明两者确实不是一个对象。而且上面提到指向字符串常量时会先从StringTable中查找,找到就直接返回找到的字符串,但是new String()的时候却不是这样,每 new 一个 String 就会在堆里面创建一个新的 String 对象,即使是相同的内容,也会去创建一个新对象,比如我创建 4个String对象,代码如下:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String s1 = new String("abc");
        String s2 = new String("abc");
        String s3 = new String("abc");
        String s4 = new String("abc");
        System.out.println(System.identityHashCode(s1));
        System.out.println(System.identityHashCode(s2));
        System.out.println(System.identityHashCode(s3));
        System.out.println(System.identityHashCode(s4));
    }
}

这时候在堆里面就会存在4个String对象:

JVM 内存模型 —— JVM篇_第47张图片

再来打印一下hash看看是不是4个对象,执行结果如下:

JVM 内存模型 —— JVM篇_第48张图片

从结果中看出,确实是4个不同的对象。

intern()方法详解

先来看看一下代码:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String s1 = new String("abc");
        String s2 = "abc";
        String s3 = s1.intern();

        System.out.println(s1 == s2);
        System.out.println(s2 == s3);
    }
}

执行结果如下:

image-20220316190120202

intern方法是干什么的呢???

intern方法的作用就是尝试将一个字符串放入StringTable中,如果不存在就放入StringTable并返回StringTable中的地址,如果存在的话就直接返回StringTable中的地址。这是jdk1.8版本中intern方法的作用,jdk1.6版本中有些不同,1.6中intern尝试将字符串对象放入StringTable,如果有则并不会放入,如果没有会把此对象复制一份,放入StringTable, 再把StringTable中的对象返回。不过在这里不讨论1.6版本。

​ 接下来根据上述作用解释一下上面的代码:首先我们在堆中创建了一个"abc"字符串对象,s1指向了这个堆中的对象;然后在StringTable中创建了一个值为"abc"的字符串常量对象,s2指向了这个StringTable中的对象;最后尝试将s1指向的堆中对象放入StringTable中,发现已经有了,所以就返回了StringTable中的字符串对象的地址给了s3。所以s1和s2指向不同一个对象,s2和s3是一个对象。就像下图这样:

JVM 内存模型 —— JVM篇_第49张图片

要是把代码稍微改一下:

String s1 = new String("abc").intern();
String s2 = "abc";

System.out.println(s1 == s2);

​ 这时候结果就是true了。来分析一下:首先使用了new String()在堆中创建了字符串对象,然后调用了其intern()方法,所以就从StringTable中查找有没有同样的字符串,发现没有,就将字符串放入StringTable中,然后将StringTable中的对象的地址给了s1;到第二行的时候,因为没有用new String(),所以就直接从StringTable中查找,发现有,就将StringTable中的对象的地址给了s2;所以s1、s2指向了同一个对象。

JVM 内存模型 —— JVM篇_第50张图片

StringTable的位置

​ 前面已经提到了StringTable在堆中,现在来验证一下。

​ 验证的方式很简单,我们放入大量的字符串导致内存溢出,看看是哪个部分内存溢出就知道StringTable在哪儿了。

package com.openlab;

import java.util.ArrayList;

public class StringDemo {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        String str = "hello";
        for(int i = 0;i < Integer.MAX_VALUE;i++) {
            String s = str + i;
            str = s;
            list.add(s.intern());
        }
    }
}

​ 这里先是调用了intern方法将字符串放入StringTable,再用一个ArrayList去存放字符串,目的是为了避免垃圾回收,因为这样的话每个字符串都会被强引用,就不会被垃圾回收了,垃圾回收了就不会看到我们想要的结果。来看一下结果:

JVM 内存模型 —— JVM篇_第51张图片

​ 很明显,是堆内存发生了内存溢出,这样就可以确定StringTable是存放在堆中的。不过这是从1.7版本开始的,1.7之前保存在永久代中。

为什么要把StringTable放入到堆空间中???

​ 答:在1.6的JDK中永久代,很少去做垃圾回收的,降低了它的效率 FullGC垃圾回收时才会回收StringTable,但是FullGC的触发条件是老年代空间不足,在永久代空间也不足时才会触发FullGC。这样会导致StringTable的回收效率不高。 研发的时候 String变量是经常使用的,所以在1.8版本中放在堆空间里,提升StringTable的回收效率

StringTable的垃圾回收

​ 前面提到了垃圾回收,就来验证一下StringTable会不会发生垃圾回收。还是上面的代码,只不过稍微修改一下:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        String str = "abc";
        for(int i = 0;i < 10000;i++) {
            String s = str + i;
            s.intern();
        }
    }
}

​ 这里没有再将字符串放入ArrayList了,要不然就算是发生了内存溢出也不会垃圾回收。为了看到垃圾回收的过程,所以添加几个虚拟机参数,先不指定堆大小:

-XX:+PrintStringTableStatistics //打印StringTable信息
-XX:+PrintGCDetails //打印垃圾回收信息

JVM 内存模型 —— JVM篇_第52张图片

运行程序,看看打印情况:

JVM 内存模型 —— JVM篇_第53张图片

因为堆内存足够大,所以没有发生垃圾回收,我们现在将堆内存设置的小一点,,来个1m:

-Xmx1m //设置堆内存大小

如下图:

JVM 内存模型 —— JVM篇_第54张图片

再来运行下程序,结果如下:

JVM 内存模型 —— JVM篇_第55张图片

​ 这回因为堆内存不够,发生了多次垃圾回收,所以说,StringTable也会因为内存不足导致垃圾回收

StringTable底层实现以及性能调优

​ 在介绍性能调优之前不得不说一说StringTable的底层实现,前面已经提到了StringTable底层是一个HashTable,HashTable长什么样呢?其实就是数组+链表,每个元素是一个key-value当存入一个元素的时候,就会将其key通过hash函数计算得出数组的下标并存放在对应的位置。

JVM 内存模型 —— JVM篇_第56张图片

​ 比如现在有一个key-value,这个key通过hash函数计算结果为2,那么就把value存放在数组下标为2的位置。但是如果现在又有一个key通过hash函数计算出了相同的结果,比如也是2,但2的位置已经有值了,这种现象就叫做哈希冲突,怎么解决呢?这里采用了链表法:

JVM 内存模型 —— JVM篇_第57张图片

​ **链表法就是将下标一样的元素通过链表的形式串起来,如果数组容量很小但是元素很多,那么发生哈希冲突的概率就会提高。**大家都知道,链表的效率远没有数组那么高,哈希冲突过多会影响性能。所以为了减少哈希冲突的概率,所以可以适当的增加数组的大小。数组的每一格在StringTable中叫做bucket,我们可以增加bucket的数量来提高性能,默认的数量为60013个,来看一个对比,运行一下代码:

package com.openlab;

public class StringDemo {
    public static void main(String[] args) {
        long startTime = System.nanoTime();
        String str = "abc";
        for(int i = 0;i < 500000;i++) {
            String s = str + i;
            s.intern();
        }
        long endTime = System.nanoTime();
        System.out.println("花费的时间为:"+(endTime-startTime)/1000000 + "毫秒");
    }
}

先通过一个虚拟机参数将bucket指定的小一点,先来个2000:

-XX:StringTableSize=2000 //设置bucket(数组)的大小

JVM 内存模型 —— JVM篇_第58张图片

运行一下:

image-20220316194021101

一共花费了1.78秒。再来将bucket的数量增加一点,来个20000个:

-XX:StringTableSize=20000

JVM 内存模型 —— JVM篇_第59张图片

运行一下:

image-20220316194128527

​ 可以看到,这次只花了0.25秒,性能有了明显的提升,说明这样确实可以优化StringTable。

参考文献:

  1. https://zhuanlan.zhihu.com/p/260939453

  2. https://blog.csdn.net/qq_26632895/article/details/105587461

  3. https://blog.csdn.net/qq_25884515/article/details/104187125?spm=1001.2101.3001.6661.1&utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1.pc_relevant_paycolumn_v2&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog

5. 直接内存

​ 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

​ 在JDK 1.4中新加入了NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

​ 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

总结:直接内存:

  • 是系统内存
  • NIO操作用于数据缓冲区比普通的IO性能要高
  • 不受JVM内存的回收管理。
  • 分配回收成本比较高,读写的性能比较强。

执行一下代码:

package com.openlab;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
// 使用IO和NIO哪个效率更高
public class Demo06 {

    static final String FROM = "D:\Desktop\数据结构视频\数据结构30号上.MP4";
    static final String TO = "D:\\a.MP4"";
    static final  int _1MB = 1024*1024;

    public static void main(String[] args) {
      io();
      directBuffer();
    }

    /**
     * 使用NIO的方式去读取文件
     */
    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);
    }

    /**
     * IO的方式
     */
    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);
    }
}

执行结果如下:

image-20220316200858563

为什么 byteBuffer 它的性能会这么高???

​ Java本身其实不具备磁盘读写的能力,如果要使用java语言对磁盘进行读写的情况,需要调用操作系统的函数,比如说本地的方法,这里涉及到状态上的切换。

​ 当使用java语言的IO去读取文件的时候,java的用户态需要切换到系统内核态,先要把文件读取到系统缓存中,java代码没有办法直接去访问系统的缓存,它就会在堆空间分配一个java缓冲区,然后在通过IO到java的堆的缓冲区内读取,读取后在将内核态转换为用户态在内存中会存在两份要读取的文件流,所以浪费内存空间,效率降低。

JVM 内存模型 —— JVM篇_第60张图片

​ 使用NIO调用的过程,通过ByteBuffer bb = ByteBuffer.allocateDirect(_1MB);申请一个直接内存,是在系统内存你和java的堆内存之间的,系统可以使用,java代码也可以使用,成倍的速度的提升。

JVM 内存模型 —— JVM篇_第61张图片

6. 直接内存溢出

运行以下代码:

package com.openlab;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

//直接内存溢出
public class Demo07 {

    static int _100MB = 1024*1024*100;

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

            while(true){

                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100MB);
                list.add(byteBuffer);
                i++;

            }
        }finally{
            System.out.println(i);
        }
    }
}

执行运行结果:

JVM 内存模型 —— JVM篇_第62张图片

7. 直接内存释放原理

JVM 内存模型 —— JVM篇_第63张图片

运行以下代码:

package com.openlab;

import java.io.IOException;
import java.nio.ByteBuffer;

public class Demo08 {
    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;
        System.gc();

    }
}

JVM 内存模型 —— JVM篇_第64张图片

释放内存之后:

JVM 内存模型 —— JVM篇_第65张图片

是不是因为调用垃圾回收器,所以这个直接内存是由垃圾回收器回收?

8. Unsafe

​ Java语言不像C语言或者是C++可以自己申请内存,java依靠Unsafe这个类提供了类似于C++手动管理内存的能力,所以这个类很危险。

JVM 内存模型 —— JVM篇_第66张图片

​ 这个类本身是final ,而且它的构造方法也是private,那么这个对象的创建需要通过反射来完成。Unsafe对象的创建:

public static Unsafe getInstance() throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    return unsafe;
}

JVM 内存模型 —— JVM篇_第67张图片

运行程序:

JVM 内存模型 —— JVM篇_第68张图片

​ 点击回车,查看任务管理器, 进程是没有被关掉的,但是内存也被释放了。

JVM 内存模型 —— JVM篇_第69张图片

查看一下ByteBuffer的源码:

DirectByteBuffer(int cap) {                   // package-private

    super(-1, 0, cap, cap);
    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 {
//这里调用的是unsafe的内存申请
        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;
    }
// Deallocator 这个对象里面调用了释放直接内存
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}
private static class Deallocator
    implements Runnable
{

    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        unsafe.freeMemory(address);
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }

}

​ Cleaner 这个对象是个虚引用,特点是它所关联的对象被引用时,Cleaner就会触发它的clean方法,unsafe调用freeMemory 来进行释放内存,直接内存的实现是接住了java中的虚引用。

禁用显示的垃圾回收:

​ 我们在代码中写的System.gc() 显示的垃圾回收 让System.gc()无效,就在JVM参数中设置:

-XX:+DisableExplicitGC //手段调用让System.gc()无效

​ System.gc() 其实是FullGC :

​ FullGC是一种比较影响性能的垃圾回收,它不光要回收新生代,也要回收老年代,会造成程序的暂停时间比较长,一般在JVM调优过程中会使用这个命令,禁用显示的垃圾回收。

运行以下代码:

package com.openlab;

import java.io.IOException;
import java.nio.ByteBuffer;

public class Demo08 {
    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;
        System.gc();
        System.in.read();
        System.out.print("程序结束");

    }
}

JVM 内存模型 —— JVM篇_第70张图片
tatic Unsafe unsafe = Unsafe.getUnsafe();

private long address;
private long size;
private int capacity;

private Deallocator(long address, long size, int capacity) {
    assert (address != 0);
    this.address = address;
    this.size = size;
    this.capacity = capacity;
}

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

}


​		Cleaner 这个对象是个虚引用,特点是它所关联的对象被引用时,Cleaner就会触发它的clean方法,unsafe调用freeMemory 来进行释放内存,直接内存的实现是接住了java中的虚引用。

禁用显示的垃圾回收:

​		我们在代码中写的System.gc() 显示的垃圾回收 让System.gc()无效,就在JVM参数中设置:

-XX:+DisableExplicitGC //手段调用让System.gc()无效


​		System.gc() 其实是FullGC :

​		FullGC是一种比较影响性能的垃圾回收,它不光要回收新生代,也要回收老年代,会造成程序的暂停时间比较长,一般在JVM调优过程中会使用这个命令,禁用显示的垃圾回收。

运行以下代码:

```Java
package com.openlab;

import java.io.IOException;
import java.nio.ByteBuffer;

public class Demo08 {
    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;
        System.gc();
        System.in.read();
        System.out.print("程序结束");

    }
}

[外链图片转存中…(img-ouD9USmn-1647612976078)]

你可能感兴趣的:(Java并发,java,jvm,后端)