JVM内存结构详解

JVM学习笔记(一)

1、什么是JVM

JVM: 全称Java Virtual Machine ,Java 程序的运行环境(Java 二进制字节码的运行环境)

特点: 一次编写,到处运行;自动内存管理,垃圾回收功能;多态

扩展:

JDK: Java开发工具包 (Java Development Kit ) 的缩写,包含JRE(JVM+基础类库)和编译工具(bin下面的程序,java、javac等)

JRE: Java运行环境(Java Runtime Environment)的缩写,包含JVM和基础类库

2、JVM内存结构

​ 包括:程序计数器、虚拟机栈、本地方法栈、、方法区

在这里插入图片描述

1、程序计数器(实现为物理寄存器)

作用:记住下一条jvm指令的执行地址

特点:是线程私有的、不会存在内存溢出

理解:

​ 当java程序运行时,首先我们需要把java文件(.java)编译成二进制字节码文件(.class)(如下图),字节码文件包含一条条的JVM指令,对应我们编写的java代码,运行时会通过类加载器(ClassLoader)将class类文件加载到JVM内存里面,然后才可以执行,执行过程如下图。

​ 指令在CPU中是一条一条执行的,程序计数器会保留下一次要执行的那条指令的内存地址(如下图2中的0、3、4等,只能保留一条,即下一条要执行的),由于CPU只能执行机器码,所以中间还需要一个从JVM指令到机器码的过程。解释器通过从程序计数器中保存的指令地址去获取要执行的指令进行解析,解析成机器码后交给后续CPU执行,此时程序计数器会同步存储下一条需要执行的指令地址供解释器获取。然后JVM指令即可循环往复进行执行。

在这里插入图片描述

​ jvm指令(二进制字节码) <—javac— java代码

JVM指令

2、虚拟机栈

作用:每个线程运行时所需要的内存

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

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

​ 栈帧内保存着方法的相关信息,包括局部变量表(包含方法中的局部变量、8种基本类型、对象的引用地址等),操作数栈(可以理解为方法内部的栈,因为方法的执行也是执行方法中的一条条JVM指令),动态链接,返回地址等

理解:

​ 当线程创建时系统会为此线程分配一个虚拟机栈,用于保存当前线程所运行的方法,内部由栈帧组成,一个栈帧对应一个方法,当方法被调用时会创建相应的栈帧来保存方法的相关信息。假如调用场景为方法1调用方法2,方法2又调用方法3。当执行到方法1时,虚拟机就会创建一个栈帧1并入栈,方法1调用到方法2时又会创建一个栈帧2,并入栈,以此类推,栈帧3则对应方法3,此时当方法3运行的时刻,此线程的栈内存结构如下所示,方法1在栈底,方法3在栈顶(也就是此线程当前时刻的活动栈帧)。当方法3执行结束后则会出栈(从栈中移除),此时方法2继续执行内部的逻辑,方法2执行完后也会出栈,直到方法1结束出栈,此时对应线程也就执行完毕了。

注意:

​ 1、栈内存不会被垃圾回收,因为每次当方法执行完成时都会出栈,没必要回收。

​ 2、栈内存也会发生溢出,当单个个方法所需占内存过大时(大于栈内存)或者栈中方法过多时(常见为递归调用、循环调用)占内存不够时则会报错,java.lang.StackOverflowError(栈内存溢出)。

​ 3、栈(单个)的大小可以通过虚拟机参数(-Xss)进行调整,当然,栈的大小并不是越大越好,因为栈内存是对应的线程内存,可以简单理解为栈内存=线程个数(栈个数)*单个栈内存大小 ,当占用物理内存固定时,单个栈内存通过调整变大时,对应的线程数会变少。也就是说系统某时刻并发执行的线程也就变少了。

​ 栈结构与数据(方法1 ----->方法2------>方法3)
虚拟机栈

线程诊断

案例1: cpu 占用过多

定位

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

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

3、 jstack 进程id;可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号

3、本地方法栈

说明:同虚拟机栈,不同点为本地方法栈是调用本地方法时所需要的栈内存。

本地方法:非java代码编写的方法,如C代码、C++代码等第三方语言编写的代码;因为java虚拟机本身就是用C语音写的,所以java内部肯定有很多需要调用到C、C++编写的函数库。这些方法通过native关键字声明,常见的如Object类的getClass()、hashCode()等,Thread类的核心方法start0()(为start()方法的核心)等。

4、堆(Heap)

说明:通过new关键字创建出的对象实例都会在堆内存中。

特点:存在内存溢出;线程共享,堆中的对象都存在线程安全问题;有垃圾回收机制

理解:堆是JVM中空间占用最大的一块内存区域,也是垃圾回收的主要区域。在JVM启动时创建,可以通过参数 -Xms(最小值)和Xmx(最大值)设置其大小。为了方便堆内存的使用和管理,java1.8中堆内部又分为新生代区域(Young)和老年代区域(Old),其中新生代又分成伊甸园区(Eden)、幸存区From、幸存区To;如下图所示。

java堆内存结构

分区解释:因为java中有的对象需要长时间使用,生命周期较长,需要长时间保存在堆中;而有的对象只是短时间使用,用完后其实就可以直接丢弃了。所以就可以针对对象生命周期的不同特点进行分类存储(类似垃圾分类),生命周期较长的放老年代区,这样就可以对老年代回收不那么频繁,生命较短的放新生代,相对于老年代就可以较频繁的进行回收。这样相比遍历整个堆内存对象进行回收则要方便的多,也能大大节省系统资源的开销。具体回收方式和回收算法可以参照JVM的垃圾回收机制。

5、方法区(Method Area)

用于存储已被虚拟机加载的类型信息(类、接口、枚举、注解等类信息)、域(属性)信息、常量、静态变量、即时编译器(JIT)编译后的代码缓存等。
逻辑上是堆的组成部分,方法区只是个概念,不同JVM厂商有自己的具体实现,比如我们常用的HotSpot虚拟机是Oracle公司设计的,在jdk1.8之前,方法区的实现就在堆内存里,叫永久代(PermGen),而1.8之后则换了一种实现,不再占用JVM内存,而是直接占用系统的物理内存,名称为元空间(Metaspace)。

特点:线程共享;存在内存溢出

下图为HotSpot虚拟机的常量池组成:

​ JDK1.6

1.6
​ JDK1.8

在这里插入图片描述

5.1 类型信息(类class、接口interface、枚举enum、注解annotation)

1)这个类型的完整有效名称(全名=包名.类名)

2)这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)

3)这个类型的修饰符(public,abstract,final的某个子集)

4)这个类型直接实现的接口的一个有序列表

5.2 域(Field、属性)信息

包括域(属性)名称、域(属性)类型、域(属性)修饰符(public、private、protected、static、final、volatile、transient的某个子集)

final:常量,编译期值就已确认

static:静态变量,编译期值未确认,类加载初始化时赋值

5.3 方法(Method)信息

1)方法名称

2)方法返回类型(或void)

3)方法参数的数量和类型(按定义顺序)

4)方法的修饰符(public、private、protected、static、final、synchronized、native、abstract的一个子集)

5)方法区的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

6)异常表(abstract和native方法除外)

​ 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

5.4 运行时常量池

常量池概念讲解:以下是以HotSpot java1.8版本进行分析

如下代码执行


public static void main(String[] args){
	System.out.println("hello world");
}
1、常量池(Constant pool)

​ 以下解释是从文件的角度,也就是java的.class文件里的信息,.class文件内容为二进制字节码,可以通过javap来反编译字节码,编译后就可以看到此类文件的的具体信息,下面解释看不懂的可以先了解一下.class的组成和里面大概的内容包括什么数据,大概是什么样子的。

​ 反编译后可以看到,每一个Class类(.class文件)中都包含一个常量池(Constant pool),可以理解为一张常量表,而类的方法(函数)内则是一条条虚拟机指令(对应着方法一行行代码的执行),而这些虚拟机指令则是通过这张常量表找到要执行的类名、方法名、参数类型、字面量(如字符串、基本类型等数据)等信息。如方法中的getstatic #2,getstatic是获取一个静态变量 #2,#2可以理解为一个表示地址的临时变量,而#2地址具体是什么则需要去常量池中查找,找到了Fieldref,而Fieldref又引用#21和#22,此时又在重复上面的操作,在常量池找#21和#22…(截图为.class文件反编译后的可视文件内容)

​ 常量池部分

常量池

​ 方法部分

类信息(方法)

2、运行时常量池

​ 上面的常量池是存在于具体的.class文件中,是磁盘中的数据,而当程序运行时,则需要把class文件中的数据加载到内存中使用,常量池部分也就对应加载到了运行时常量池中,此运行时常量池为内存中的数据。包含所有类的常量池数据。

​ 运行时常量池中的字符串或变量此时还不是真正的对象,当程序加载运行时,运行到那一行代码时,那些变量等数据才会变成真正的对象,才会在堆中分配真正的内存地址。比如上面代码,而当代码运行到main方法时,之前常量池中的#2、#3等才会转换为真实的内存地址。运行到System.out.println(“hello world”)时,"hello world"这个从原本只是符号(并非java中的对象)的东西才会被创建为字符串对象分配到堆内存中,同时此对象的内存地址引用也会被放入字符串常量池(串池)。这样运行时就可以找到内存中真实的那部分数据并使用。

3、字符串常量池(String Table)

​ 作用:缓存java程序运行时显式定义的字符串对象,串池的存在可以提高内存的使用率,避免开辟多余的内存空间来存储相同的字符串。

​ 解释:字符串常量池又叫串池(String Pool),底层数据结构为不可扩容的哈希表,当代码中直接定义字符串时(如下面赋值代码)就会将字符串对象的引用地址放入串池,当下次再使用相同字符串时首先会在串池中寻找,如果能找到,就返回串池中的内存地址,如果没找到,就会在堆中新建一个字符串对象,同时将引用地址放入串池。

其实串池中对象的产生基本有两种方式:

1.显示赋值

2.调用String类的intern()方法

以下几种方式带你分析字符串的创建过程以及和串池的关系:

1、直接赋值字符串
String s = "abc";
String b = "abc";
// s==b为true

​ "abc"字符串会在堆中分配内存,创建对象,并且对象的引用地址返回给s,同时引用地址也会放入串池。后续有String b = “abc"时,则会先从串池中找"abc”,此时串池已存在,直接返回串池中已有的"abc"对象的引用地址,此时s和b的引用地址相同,也就是同一个堆对象。

2、构造方法赋值的字符串
String s = new String("abc");
String b = "abc";
// s==b为false

​ 因为有显式定义"abc",则运行时第一步先去串池中是否有"abc",此时没有,则会在堆中创建"abc"对象,并将引用地址放入串池,我们先暂且定义这个对象叫o1;第二部则执行new String(),将"abc"作为参数传给String类的构造方法,在堆中创建新的对象(因为此时有new关键字,必定会新创建对象),并将内存地址返回给s变量,s也就指向的new创建出来的对象。此时堆中有两个真实的对象,一个是第一步的o1,一个是第二步的new String()出的s,只不过o1的引用地址在串池中,没有被外部变量引用。

​ 后续有String b = “abc"时,则会先从串池中找"abc”,此时串池已存在(也就是o1),直接返回串池中已有的"abc"对象的引用地址(o1)赋值给b(此时b就是o1),但s和b是两个对象。

3、使用+连接字符串常量
String s = "ab" +"c";
String b = "abc";
// s==b为true

​ 因为字符串对象是final类,是不可变对象,所以在第一行"ab"+"c"时,编译器会对字符串常量进行优化,最终编译后会变成String s = “abc”(class文件中有体现),所以结果同上面第一种场景。

4、使用+连接带变量的字符串
String s = "abc";    
String b = "ab";    
String x = b +"c";    
// s==x为false

​ 有了以上经验,第一行会在堆中创建"abc"对象,并放入串池,第二行会在堆中新建"ab"对象,并放入串池。关键是第三行,不同于上面的第三种情况,因为b是个变量,虚拟机并不知道b具体是什么,所以没法做以上的编译期优化。此处的加号会通过new关键字先创建一个StringBuilder对象,并调用其append方法来拼接字符串对象,最后调用Stringbuild的toString方法(toString方法内部调用new String)来在堆中创建一个新的字符串对象赋值给x,所以第三行会创建"c"字符串对象,并放入串池,后面通过StringBuilder再创建一个x对象,所以x并不等于s。

此时上述代码中的对象情况:堆中有s、b、x、"c"共四个对象,其中串池中的对象(引用)有s、b、"c"这三个。其中x是通过Stringbuild的toString方法中的new String()出来的,其他三个是直接赋值创建的。

5、通过String类的intern()方法

作用:主动将串池中还没有的字符串对象放入串池,并返回串池中该字符串的引用地址。

jdk1.8 将这个字符串对象尝试放入串池,如果已经有则不会放入,直接返回串池中的对象;如果没有则放入串池, 同样将串池中的对象返回。

jdk1.6 将这个字符串对象尝试放入串池,如果已经有则不会放入,直接返回串池中的对象;如果没有会把此对象复制(创建新对象)一份,放入串池,并返回串池中的对象。

5.1 intern方法的解释

String s = new String("abc");
System.out.println(s == s.intern());//false

​第1行代码实际解释可以看上面的场景2,在堆中创建了两个"abc"对象,并且其中一个对象(o1)的引用放入了串池,另一个对象的引用给了s1;s.intern()做了什么操作呢?根据intern的网上解释s.intern()就是先判断字符串对象在串池中是否存在,不存在时将s字符串对象尝试放入串池,并返回串池中该对象的引用。

按常规的理解可能会有人有这样的疑问:第一行执行后,s对象是在堆中,并不在串池中,那s.intern()不正好把s(的引用)放入了串池,并返回了串池中s的引用,按这种理解那s == s.intern()应该为true啊?但执行结果却是false。其实当时我也有这种疑惑,最后我悟了,其实矛盾点就是在放入时判断是否存在的地方,此函数判断存在的逻辑对应的是java中的equals(),而非"==",这样的话s.intern()其实是判断串池中有没有"abc"对象,而"abc"对象(o1)在第一行执行后已经在串池中了,所以s.intern()返回的是o1对象的引用,此时也就有了s == s.intern() 为false。

String s = "a";
String s1 = "b";
String s2 = s + s1;
String s3 = s2.intern();
System.out.println(s2 == s3);//true

上面代码前3行可以参考示例4,当第三行执行完时,串池中有"a",“b"两个对象,也就对应着s和s1,s2对象在堆中创建,并且对象值为"ab”,s2.intern()会判断串池中有没有和"abc"值相同的字符串,此时是没有的,这时会把s2对象放入串池,并返回对象引用赋值给s3,那么s2和s3自然是一个对象了。

练习题

String s = new String("abc");
String b = "abc";
String s1 = s.intern();
System.out.println(s == b);//false
System.out.println(s1 == b);//true

有了上面的解释,这几行代码的结果应该能手到擒来吧,如果不行请回放重看哈哈哈哈哈。

3、类的加载过程

过程:加载(装载)-------> 链接(验证 ----> 准备 ----> 解析)-------> 初始化

3.1、加载(Loading)
  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中(方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的分钟数据的访问入口

补充:这个阶段java虚拟机会检查类文件的格式、语义等内容,确保其符合Java规范。加载.class文件有如下几种方式:

  • 从本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从zip压缩包中获取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多给的是动态代理技术
  • 由其它文件生成,典型场景:JSP应用
  • 从专有数据库中提取.class文件,比较少见
  • 从加密文件中提取,典型的防Class文件被反编译的保护措施
3.2、链接(Linking):
  1. 验证(Verify)
  • 目的在于确保Class文件的字节流包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
  • 主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证
  1. 准备(Prepare)
  • 为类变量分配内存并且设置该类变量的默认初始值,即零值

    这里不包含final修饰的static变量,因为final在编译的时候就会分配了,准备阶段会显示初始化值

  • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中

  1. 解析(Resolve)
  • 将常量池内的符号引用转换为直接引用的过程
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再进行
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中,直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 解析动作主要针对类或接口、字段、类方法。接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。
3.3、初始化(Initialization):
  • 初始化阶段就是执行类构造器方法**<clinit>()**的过程

    此方法不需要定义,是javac编译期自动收集类中的所有类变量的赋值动作静态代码块中的语句合并而来。

    构造器方法中指令按语句在源文件中出现的顺序执行。如代码中有类变量(static修饰)赋值,则在此步骤会给此变量赋初始值。(final static除外,因为它已在链接的准备阶段赋值)

  • <clinit>()不同于类构造器(关联:构造器时虚拟机视角下的<init>())

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕

  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

4、总结

这个章节主要是记录了JVM的常规组成元素并程序运行时自己的一些理解。后面章节就是一些面试常问知识点了,比如:JVM中对象的四种引用类型(强、软、弱、虚)还有终结器引用、直接内存、有了前面的铺垫就引出了JVM的垃圾回收,常见的回收算法jdk1.8堆内存的分代划分和分代回收机制(新生代、老年代);垃圾回收器分类以及常见的垃圾回收器JMM(java内存模型) 等。
最后附一张代码执行时JVM内存示意图,大家看看能不能理解,对应代码如下:

public class Test{
    public static void main(String[] args){
        method1(10);
    }
    private static void method1(int x){
        int y = x + 1;
        Object m = method2();
        System.out.prientln(m);
    }
    private static Object method2(){
        Object n = new Object();
        return n;
    }
}

代码对应的JVM信息

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值