JVM 内存结构 超详细学习笔记(一)

本文详细介绍了JVM的内存结构,包括程序计数器、虚拟机栈、本地方法栈、堆内存和方法区,特别是重点讨论了堆内存的垃圾回收机制及如何诊断内存溢出问题。此外,还详细阐述了字符串常量池(stringTable)的工作原理、垃圾回收后的内存占用以及直接内存的使用和回收。通过对JVM的学习,有助于提升面试竞争力,理解底层原理并进行性能调优。
摘要由CSDN通过智能技术生成

本篇博客是记录个人学习JVM的笔记,学习的视频是哔哩哔哩中 黑马的jvm教程;

视频链接:jvm学习

下一篇笔记:JVM 垃圾回收 超详细学习笔记(二)_未来很长,别只看眼前的博客-CSDN博客

目录

什么是JVM

学JVM有什么用

学习路线

一、内存结构

1、程序计数器

2、虚拟机栈

问题辨析

栈内存溢出

线程运行诊断

本地方法栈

3、堆

堆内存溢出

堆内存诊断

案例:垃圾回收后内存占用依然高

4、方法区

方法区内存溢出

运行时常量池

stringTable(面试常问的字符串常量池就在这里)

stringTable的延迟加载的证明

stringTable是特性(对串池以及intern方法进行了非常细致的分析)

stringTable面试题

stringTable的位置

stringTable的垃圾回收机制

stringTable的性能调优

5、直接内存

为什么要有直接内存

直接内存释放原理

直接内存的回收机制总结


什么是JVM

定义:Java Virtual Machine (Java虚拟机),Java 程序的运行环境(Java 二进制[字节码]的运行环境)。

好处:

  • 一次编译,处处执行

  • 自动的内存管理,垃圾回收机制

  • 数组下标越界检查

比较:jre jdk jvm

学JVM有什么用

  • 提高面试的竞争力

  • 理解底层的实现原理(有利于长远发展)

  • 中高级程序员的必备技能

学习路线

一、内存结构

1、程序计数器

定义:Program Counter Register 程序计数器(寄存器)

作用:是记录下一条 jvm 指令的执行地址行号。(字节码最右边的哪些数字可以理解为下一条指令的地址行号)

如果没有程序计数器,那么jvm都不知道下一条该执行什么指令。

物理层面上实现程序计数器是通过寄存器来实现的,因为这个程序计数器是需要非常频繁的执行的,刚好寄存器在cpu中可以说是执行指令速度最快的了,所以就用它来实现了;

特点:

  • 是线程私有的(因为这个是负责记录jvm执行的指令的,如果不私有,那就乱套了)

  • 不会存在内存溢出(java中唯一不会内存溢出的区)

一段Java代码的执行流程:

Java代码---> 二进制字节码 ---> 解释器---> 机器码 ---> cpu来执行

0: getstatic #20 // PrintStream out = System.out; 
3: astore_1 // -- 
4: aload_1 // out.println(1); 
5: iconst_1 // -- 
6: invokevirtual #26 // -- 
9: aload_1 // out.println(2); 
10: iconst_2 // -- 
11: invokevirtual #26 // -- 
14: aload_1 // out.println(3); 
15: iconst_3 // -- 
16: invokevirtual #26 // -- 
19: aload_1 // out.println(4); 
20: iconst_4 // -- 
21: invokevirtual #26 // -- 
24: aload_1 // out.println(5); 
25: iconst_5 // -- 
26: invokevirtual #26 // -- 
29: return

2、虚拟机栈

栈:线程运行需要的内存空间,那么一个线程需要一个虚拟机栈,那么多个线程就需要多个虚拟机栈,那么每个栈是由什么组成的呢? 一个栈内是可以看作由多个栈帧组成,那么栈帧又是什么呢?一个栈帧就对应一次方法的调用,我们都知道线程最后是为了去执行代码,而代码又是由一个个的方法组成,所以在线程运行的时候每个方法所需要的内存我们就称为栈帧; 一个方法包括参数,局部变量,返回地址值,所以栈帧中也会存储这些数据;

定义:

  • 每个线程运行需要的内存空间,称为虚拟机栈

  • 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存

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

举例理解:

public class Main {
	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;
	}
}

 在debug模式下中可以看到,主类中的方法在进入虚拟机栈的时候,符合栈的特点(先进后出以及压栈和弹栈)

问题辨析

1.垃圾回收是否涉及栈内存?

不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。

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

不是。因为物理内存是一定的,栈内存越大,虽然可以支持更多的递归调用,但是可执行的线程数就会越少。

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

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

  • 如果是局部变量引用了对象,并逃离了方法的访问(比如static修饰的变量,对象作为参数传递,方法有返回值),那就要考虑线程安全问题。

栈内存溢出

什么情况下会导致栈溢出?

  • 递归调用然后没有合适的退出条件,导致栈帧一直在积压得不到释放,然后就导致栈溢出了;

  • 栈帧过大,直接把栈给挤爆了......

  • 调用第三方库也可能会出现栈溢出(比如双向引用),可能这个库的代码..........

线程运行诊断

案例一:cpu 占用过多 解决方法:Linux 环境下运行某些程序的时候,可能导致 CPU 的占用过高,这时需要定位占用 CPU 过高的线程

top 命令,查看是哪个进程占用 CPU 过高

  • 第一步:使用top命令查看是哪个进程占用 CPU 过高,不过不能精确到哪个具体的线程

  • 第二步:ps H -eo pid(进程id), tid(线程id), %cpu(占用cpu的百分数) | grep 刚才通过top查到的进程号

    • 使用ps命令可以进一步定位是哪个线程引起的cpu占用过高

  • 第三步:jstack 进程id(刚刚查询到的cpu占用过高的进程号)

    • 可以根据第二步查出来的线程id找到有问题的线程,然后结合第三步输出的内容对比,可以找到第二步显示的cpu使用过高的线程以及对应的源代码行号,然后去Java的源代码中查找就行,注意jstack查找出的线程id是16进制的需要转换

案例二:程序运行很长时间都没有得到我们想要的结果(可能是线程发生了死锁)

使用命令:jstack 运行但是没有出结果的程序的进程id

然后看控制台的输出日志;

本地方法栈

一些带有 native 关键字的方法就是需要 JAVA 去调用本地的C或者C++方法,因为 JAVA 有时候没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法。

比如:object中的clone方法,wait方法,synchonized关键字;

3、堆

定义:Heap 堆

  • 通过new关键字创建的对象都会被放在堆内存

特点:

  • 它是线程共享,堆内存中的对象都需要考虑线程安全问题

  • 有垃圾回收机制

堆内存溢出

既然有垃圾回收机制,那么为什么还是会出现堆内存溢出呢?比如:此时堆中已经有很多的对象了,并且这些对象还在被其他团队成员使用,然后还有人在不断的创建对象,垃圾回收机制是不会去回收正在使用的对象的,此时就会导致堆内存溢出;

java.lang.OutofMemoryError :java heap space. 堆内存溢出错误

堆内存诊断

  1. jps 工具 查看当前系统中有哪些 java 进程

  2. jmap 工具 查看堆内存占用情况 jmap - heap 进程id

  3. jconsole 工具 图形界面的,多功能的监测工具,可以连续监测,jdk自带的;

  4. jvisualvm 工具

    以可视化的情况展示虚拟机的情况,也是jdk自带的(jdk11后就不自带了,需要自己额外下载);

    直接在terminal敲下jvisualvm这个命令,然后回车就行;

运行测试程序,使用检测工具查看:

package heap;

/**
 * @author LJM
 * @create 2022/6/21
 * 这个demo是测试在不同时间段检测堆内存的情况
 */
public class Demo_1 {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        Thread.sleep(30000);
        byte[] array = new byte[1024*1024*10]; //10mb
        System.out.println("2...");
        Thread.sleep(30000);
        array = null;
        System.gc(); //进行垃圾回收
        System.out.println("3...");
        Thread.sleep(1000000l);
    }
}

 使用可视化工具,jconsole jdk自带的一个软件,直接在terminal敲就行;

案例:垃圾回收后内存占用依然高

进行垃圾回收后,内存占用仍然很高;

使用jvisualvm 工具 里面的堆 dump功能(里面有一检查功能,通过生成快照来进行定位分析),可以查看实例的一些情况;然后找到哪个实例占用的内存比较多,就可以去源代码进行定位分析了;

4、方法区

定义:(jdk1.8中JVM规范中对方法区的定义)

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化;方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上(因为不同的厂商的实现可能不同)是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!

方法区也会导致这个内存溢出的错误:当方法区发现申请的内存不足的时候,就会让虚拟机抛出这个错误;

下面图片的常量池指的是运行时常量池(图中少打了两个字)

方法区内存溢出

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

    • 使用 -XX:MaxPermSize=8m 指定永久代内存大小

  • 1.8 之后会导致元空间内存溢出(默认的使用的系统的空间,所以这个比较难演示出来,但是我们可以设置元空间的大小)

    • 使用 -XX:MaxMetaspaceSize=8m 指定元空间大小

类加载器的作用:可以用来加载类的二进制字节码

场景:

  • spring ---使用cjlib(在运行期间动态的生成字节码然后动态的完成类加载)动态的加载字节码文件,如果非常频繁并且次数多的话就可能导致永久代方法区内存溢出

  • mybatis ---使用cjlib动态的加载字节码文件

运行时常量池

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

运行时常量池:常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号(比如#1)地址变为真实地址

stringTable(字符串常量池,也称串池)是运行时常量池中的一个重要部分;

先看代码:

//二进制字节码包含:类的基本信息,常量池,类方法定义,虚拟机指令;
public class HelloWord {
    public static void main(String[] args) {
        System.out.println("hello word");
    }
}

 运行上面的程序后,使用然后使用terminal打开刚刚编译生成的字节码文件,然后在该字节码文件路径下进行反编译; javap -v xxx.class

 常量池的作用是为虚拟机解释这些指令的时候给它查询使用的;

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

运行时常量池:常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号(比如#1)地址变为真实地址

stringTable(面试常问的常量池就在这里)

先看代码:

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

对上面的代码生成的字节码进行反编译:

注意:常量池中的信息都会被加载到运行时常量池中,但是此时a ,b ab都还是常量池中的符号,还没有变成Java中的字符串对象,只有当指令执行到ldc #2 这条指令的时候才会把 a 符号变为 “a” 字符串对象(这是一个懒惰的过程,只有用到了才会去), 当变成 a符号对象 后虚拟机会准备好一个空的空间叫做stringTable(数据结构上是一个hash表,不能扩容,【里面存储的是字符串对象的地址引用值】,该引用是指向堆中对应的对象(常量池存的是对象还是对象的地址值这里有点不太确定因为看到了两中不同的说法.....),jdk7之后常量池不仅可以存储对象还可以存储对象的引用地址),然后就会把刚刚生成的字符串对象(或者是对象的地址引用值,看版本)当做key去这个stringTable里面找,看有没有一个值相同的key, 如果没有的话就会把刚刚生成的这个字符串对象放入这个stringTable,如果有点话就直接取出来用就行,就不需要再创建这个对象了

加一行代码再测试:

public class ConstantPool {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1+s2; 
    }
}

 运行后,对其字节码文件进行反编译;

从这个反编译的结果我们可以知道:String s4 = s1+s2;是怎么执行的

new StringBuilder().append("a").append("b").toSrting(); 
new String("ab");

 然后再从源码中找到StringBuilder的tostring方法:

所以请看题:

public class ConstantPool {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1+s2; 
        System.out.println(s3==s4);  
    }
}

答案是false!

s3虽然也是ab但是它是存在常量池中的,而s4是用了new ,明显就是在堆中,二者的地址值就明显不一样了;

(这个我自己还不确定。。。评论区看到的。。)注意:字符串常量池在jdk1.7之后就已经从方法区移到堆里面了,虽然是在堆里面但是所属的区域是不同的

继续加代码:

public class ConstantPool {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        String s4 = s1+s2; //这个代码里面的s1和s2在编译的时候是不确定的,因为它们是变量,所以要在运行的时候才知道s1和s2具体的值,所以就采用了stringBuilder这种【动态的方式】来进行拼接
//      System.out.println(s3==s4); //false
        String s5 = "a" + "b";//下面的图片是该行代码的字节码指令, 这里在虚拟机中为什么会变成了ab?因为javac 在编译器进行了优化,就是这里的a 和 b 【都是常量】是确定的,既然【是确定的】那么我在编译期我就知道你这个具体是什么了,所以编译器就直接对其进行了拼接
//      System.out.println(s3==s5); //ture
 
    }
}

 第一个#4 也是先去常量池中找有没有ab这个字符串对象,没有的话它就直接把自己给放进去了;

第二个#4,也是先从常量池中找,然后发现已经存在了,所以这里就不会去创建新的字符串对象了;

stringTable的延迟加载的证明

前面我们提到过,字符串对象只有在使用的时候或者是加载到创建该字符串对象的代码的时候才会去创建这个字符串对象,就是懒加载

通过使用idea自带的memory(可以查看运行池的实例对象的个数)来测试查看:

stringTable是特性(对串池以及intern方法进行了非常细致的分析)

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象(懒加载)

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

  • 字符串变量拼接的原理是StringBuilder(jdk1.8)

  • 字符串常量拼接的原理是编译器优化

  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中(下面讲的intern方法都是针对jdk1.8的)

    • 1.8将这个字符串对象尝试放进串池,如果有则并不会放入,如果没有则把自己放入到串池,最后都是会把串池中的字符串对象给返回

    • 1.6将这个字符串对象尝试放进串池,如果有则并不会放入,如果没有则会把此对象复制一份,然后把拷贝的对象放入串池,最后都是会把串池中的字符串对象给返回

注意串池中只存放常量的字符串; 动态拼接的字符串并没有放到这个串池中,而是在堆中;

        String s = new String("a") + new String("b"); //串池中(因为a,b是常量):["a","b"],此时ab字符串并不在串池中,因为串池中只能存在常量,而这里是动态拼接得到ab的
//堆中会创建: new String("a") new String("b") new String("ab") ,堆中的new String("ab")可以通过intern()方法把该对象放到串池中
        s.intern();  //尝试将字符串对象放到串池中,如果串池中有那么则不会放入(那么此时该对象还是在堆中),如果串池没有那么就会把自己也放入到串池中,【最后不管有没有都会把【串池中】的对象返回】,  此时串池中的对象由["a","b"]变成了["a","b","ab"]
// 串池中["a","b","ab"]
public static void main(String[] args) {
        String s = new String("a") + new String("b");
        String s2 = s.intern();  //这个s2是串池中的对象了
        System.out.println(s2 == "ab"); //ture, 这里的ab是常量,所以会先去串池中找
    	System.out.println(s == "ab"); //ture ,因为一开始串池并没有ab对象,所以s调用intern的时候是把s自己也放到了串池中
    }
}

// 串池 ["ab","a","b"]
public static void main(String[] args) {
    	String x = "ab";
        String s = new String("a") + new String("b");
        String s2 = s.intern(); //因为堆中已经有ab对象了,所以s对象没放进去(此时s依旧是堆中的对象),但是不管有没有放进去,都是会把串池中的对象当做结果返回的,所以s2是串池中的ab对象
        System.out.println(s2 == x); //ture
    	System.out.println(s == x); //false
    }
}

stringTable面试题

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); //ture
    System.out.println(s3==s6); //true

    String x2 = new String("c") + new String("d");
    String x1 = "cd";
    x2.intern();
    System.out.println(x1 == x2); //false
    
    //如果上面的代码变成
    String x2 = new String("c") + new String("d");
    x2.intern();
    String x1 = "cd";
    System.out.println(x1 == x2); //ture
    
}

如果这几个题写不对或者是想不明白,那么就要再回头看看前面与stringTable有关的知识点!!!

stringTable的位置

jdk1.6 的StringTable 位置是在永久代中,1.8 都StringTable 位置是在堆中。

下面图片的常量池指的是运行时常量池(图中少打了两个字)

可以证明:看stringTable满了后报什么错误,记得把内存调小一些再测试;

使用jdk1.6运行是Perm Space

 使用jdk1.8:(不过要先关闭这个垃圾回收机制,否则报的是下面官方给的错误) (注意改变虚拟机的多个参数,参数之间使用空格分割开就行)报的是Java heap space

stringTable的垃圾回收机制

StringTable在内存紧张时,会发生垃圾回收;

stringTable的性能调优

因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间(因为可以减少比较数据是否重复的次数,hash冲突的概率也变小了);

-XX:StringTableSize=桶个数(最少设置为 1009 以上,否则会抛异常)

  • 考虑是否需要将字符串对象入池(可以通过 intern 方法减少重复入池(从而减少数据对内存的大量占用)

伪代码:

List<String> address = new ArrayList<>();
while(ture){
    ........
    
    String words = xxxxx(通过一些方法获取到string类型的数据);
    
    //address.add(words); //这个是直接添加
 address.add(words.intern());  //这个是入池,可以大大的减少字符串对象的重复,从而减少堆内存空间的使用 
}

5、直接内存

为什么要有直接内存

这个直接内存指的是操作系统内存,并不是Java内存;

  • 常见于NIO操作是,用于数据缓存区;

  • 分配回收成本较高,但读写性能更加强

  • 不受JVM内存回收管理

文件读写的过程:

 使用了DirectBuffer后 :

直接内存释放原理

直接内存的释放不是通过jvm的垃圾回收来管理的,而是通过unsafe类来管理的,通过unsafe中的freeMemory()方法进行释放的;

我们先来了解一下这个unsafe类:

//伪代码

//通过反射获取unsafe对象,这个unsafe对象是不能直接获取的
Unsafe unsafe = xxxxxxx;
Long base = unsafe.allocateMemory(_1Gb);  //分配一个g的直接内存,返回的是这个直接内存的地址值

unsafe.setMemory(base,_1Gb,(byte)0); //使用这个内存

//释放直接内存
unsafe.freeMemory(base);

了解是通过unsafe来释放直接内存后,再来看 allocateDirect()方法是怎么和Java中的unsafe联系的;

1.看allocateDirect()是怎么实现的,下面是它的构造方法:

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

2.看DirectByteBuffer 类

 这里调用了一个 Cleaner,这个类是Java中比较特殊的一个类库,叫做虚引用类型,它的特点是如果这个虚引用对象所关联的对象被回收后,那么Cleaner就会触发Cleaner 的 clean 方法,这个this就是指这个ByteBuffer,ByteBuffer对象是Java对象,所以可以被虚拟机垃圾回收,当ByteBuffer对象被回收后,就会触发 Cleaner 的 clean 方法,并且这个执行clean方法的线程并不是主现场而是有一个专门的线程在后台监测这个虚引用对象;

直接内存的回收机制总结

  • 使用了 Unsafe 类来完成直接内存的分配回收,回收需要主动调用freeMemory 方法(Java中是通过特殊的cleaner虚引用类来实现的,后台有专门的线程监测这个虚引用类)

  • ByteBuffer 的实现内部使用了 Cleaner(虚引用)来检测 ByteBuffer 。一旦ByteBuffer 被垃圾回收,那么会由 ReferenceHandler(守护线程) 来调用 Cleaner 的 clean 方法调用 freeMemory 来释放内存

        // -XX:+DisableExplicitGC 禁用显示的
        private static void method() throws IOException {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1GB);
            System.out.println("分配完毕");
            System.in.read();
            System.out.println("开始释放");
            byteBuffer = null;
            System.gc(); // 显示的进行垃圾回收,手触发点是一次full GC ,不仅会回收新生代,也会回收老年代,是一种比较吃性能的操作,会造成程序执行的时间比较长,所以为了避免一些新手程序员手动的调用System.gc();这个来进行垃圾回收,所以在JVM调优的时候,一般是会在虚拟机加上  -XX:+DisableExplicitGC  这个参数,表示禁用显示的垃圾回收,但是使用了这个参数可能会影响直接内存的回收(就是在代码中我们不能使用System.gc()这个指令来间接的释放直接内存了,只能等到真正的垃圾回收才会一起释放直接内存,这就会导致直接内存的使用过大,那么怎么解决这个问题呢?通过上面讲解直接内存释放 本质我们可以直接通过 unsafe 对象调用 freeMemory 的方式释放直接内存)
            System.in.read();
        }
    

    在实际的JVM调优中经常遇到的情况:

  • 调用System.gc();是显示的进行垃圾回收,触发的是一次full GC ,不仅会回收新生代,也会回收老年代,是一种比较吃性能的操作,会造成程序执行的时间比较长,所以为了避免一些新手程序员手动的调用System.gc();这个来进行垃圾回收,所以在JVM调优的时候,一般是会在虚拟机加上  -XX:+DisableExplicitGC  这个参数,表示禁用显示的垃圾回收,但是使用了这个参数可能会影响直接内存的回收(就是在代码中我们不能使用System.gc()这个指令来间接的释放直接内存了,只能等到真正的垃圾回收才会一起释放直接内存,这就会导致直接内存的使用过大,那么怎么解决这个问题呢?通过上面讲解直接内存释放 本质我们可以直接通过 unsafe 对象调用 freeMemory 的方式释放直接内存

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值