前言
以下内容是经过我记忆并添加自己理解所写,可能会出现概念上的错误或者用词不当,请各位大佬批评指正。
什么是JVM
JVM是Java Virtual Machine,译为java虚拟机,是一台虚构出来的计算机,是一种规范。所以这也意味着JVM不止一种,只要满足JVM规范,任何企业,组织和个人都可以开发自己专属的JVM。
tips:如今最常用的是HotSpot 虚拟机,如果下文没有特殊提及,所讲解的基本都是这种。
大家可以简单理解为项目在启动时,又多出了一台小型电脑运行在咱们本机的操作系统环境下,从软件层面屏蔽了底层硬件、指令层面的细节让他兼容各种系统,由他直接和操作系统进行交互,获取项目运行的一系列资源。
大家都知道Java语言具有平台无关性的特点,这其实就是依靠了JVM和字节码文件来实现的,JVM可以运行在不同平台(Windows,Linux,Mac),只与字节码文件相关联(他只认识字节码文件,其他的他不认识),而字节码文件是一种特殊的二进制文件,他只面向于JVM,是由不同种类语言对应的编译器编译得到的(例如我们写一个java程序 *.java,那么他经过javac编译器 编译后就能得到 .class 字节码文件了)。
JVM的运行流程
JVM整体由以下四个部分组成:
- 类加载器(ClassLoader)
- 运行时数据区(Runtime Data Area)
- 执行引擎(Execution Engine)
- 本地库接口(NativeInterface)
程序在执行之前先要把 java 代码转换成字节码(class 文件),jvm 首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中的运行时数据区(Runtime Data Area) ,而字节码文件是 jvm 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU 去执行,而这个过程中需要调用其他语言的接口 本地库接口(NativeInterface) 来实现整个程序的功能,这就是这 4 个主要组成部分的职责与功能。
而我们通常所说的 JVM 组成指的是 运行时数据区(Runtime Data Area) ,因为通常需要程序员调试分析的区域就是“运行时数据区”,或者更具体的来说就是“运行时数据区”里面的 Heap(堆)模块。
JVM运行时数据区域
JVM在执行java程序时,会将它管理的内存划分为若干个不同的数据区域,每个区域都有自己的职责。由于JDK1.8和之前版本略有不同(HotSopt VM和JRockit VM融合,因为后者没有永久代,所以移除了),接下来将以JDK1.7和JDK1.8为例介绍。
JDK1.6与JDK1.7对比:
JDK1.7以前,字符串常量池和静态变量都是存放在永久代中,而永久代是方法区的具体实现,是实际的内存结构(用于存储类的元数据、常量池、静态变量和方法信息),方法区只是一种抽象概念,属于JVM内存模型的一个逻辑区域。JDK1.7将字符串常量池和静态变量移动到堆中。
JDK1.7和JDK1.8对比:
JDK1.8版本以后,使用了元数据区(元空间)实现了方法区,代替了永久代,元空间不在虚拟机中,而是使用本地内存(运行时常量池也就移动到了本地内存中),并且大小可以自动增长,减少了OOM(内存溢出)的几率
从JDK1.7开始就在为了移除永久代做准备,1.8正式完成。
tips:后文可能出现JDK7,JDK8这种版本描述,请大家不要混乱,这其实是最开始java版本命名的问题,JDK1.7就对应着JDK7,以此类推。不过1.8以后就没有这种描述了。
从两张图中可以看出:
线程私有的:
- 虚拟机栈
- 本地方法栈
- 程序计数器
线程共享的:
- 堆
- 方法区
- 直接内存(非运行时数据区的一部分)
Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。
程序计数器(私有)
程序计数器主要有两个作用:
- 作为当前线程执行的字节码文件的行号指示器,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如顺序执行,循环,异常处理等;
- 在多线程情况下,在进行线程切换后,记录当前线程执行位置,当切换回来后,能够知道该线程上次执行哪里了从而继续执行(上下文切换);
线程在执行过程中会有自己的运行条件和状态(也称上下文),线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
特备注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
现在有这么一个问题:程序计数器为什么是线程私有的?
你该如何回答呢,其实答案就在上边,你可以回答主要是为了线程切换后恢复到正确的执行位置
虚拟机栈(私有)
虚拟机栈(后文简称为栈)的生命周期和线程一样,它随着线程的创建而创建,随着线程的死亡而死亡。
栈算得上是JVM运行时数据区的一个核心,除了一些Native方法需要通过本地方法栈来实现,其他的所有Java方法都是通过栈来实现的(当然也需要其他核心项目配合,如程序计数器)
方法调用的数据通过栈来传递,每有一个方法被调用就会有一个对应的栈帧被压入栈内,每有一个方法被执行完毕就有一个栈帧被弹出。
这里的虚拟机栈与数据结构上的栈类似,两者都是先进后出的数据结构,都只支持入栈和出栈两种操作。
栈是由一个个栈帧组成的,每个栈帧中都用拥有:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
局部变量表
主要存放编译器可知的各种数据类型(8种基础数据类型),和对象引用(reference类型,与对象本身不同,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置,后边再详细说)
操作数栈
主要作为方法调用的中转站,用于存放方法执行过程中的中间结果,计算过程中创建的临时变量也会存放在这里。
动态链接
主要用于一个方法调用另一个方法的场景下,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
符号引用这个概念大家简单理解下就行,简单来说符号引用是源代码编译成字节码的过程中产生的,编译器会将类、方法和字段等的名称转换为符号引用,可以被看作是一个代替直接引用的符号,JVM负责将其转换为可以直接使用的内存地址。
返回地址
就是方法执行完毕后,最终结果要返回的地点,原先哪里调用的该方法。
java方法有两种返回方式,其一是return语句正常返回,其二是运行过程中抛出异常,提前终止,但是不管是哪种返回方式,都会导致栈帧被弹出,也就是说,栈帧会随着方法的调用而创建,随着方法的结束而销毁,无论方法是正常结束还是异常终止都算作方法的结束。
简单总结下运行中虚拟机栈可能会出现的两种报错:
- StackOverFlowError:如果栈的内存不允许动态扩展,那么当线程请求栈的深度超过当前虚拟机栈的最大深度时,就会抛出StackOverFlowError错误
- OutOfMemoryError:如果栈的内存允许动态扩展,如果虚拟机在动态扩展栈时无法申请到足够的内存空间,就会抛出OutOfMemoryError 错误。
本地方法栈(私有)
本地方法栈的功能与虚拟机栈的十分相似,区别在于:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机栈使用到的Native方法(本地方法)服务,在 HotSpot 虚拟机中,两栈合二为一。
本地方法被执行时,也会在本地方法栈中创建一个栈帧,用于存放该本地方法的局部变量表,操作数,动态链接,出口信息,结构和功能与虚拟机栈中的栈帧相差不多。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError 和OutOfMemoryError 两种错误
堆(共享)
这里是JVM所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建,此区域的唯一作用存放对象实例,几乎所有的对象实例和数组都是在这里分配内存。
这里使用“几乎”这个词是因为:有些对象是可以直接在虚拟机栈上分配内存,从JDK1.7开始默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)。
在JDK7版本和JDK7版本以前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代内存(Old Generation)
- 永久代内存(Permanent Generation)
新生代内存又可以进一步划分为Eden区和Survivor区(伊甸园区和存活者区)
在JDK8版本以后,永久代(Permanent)区被元空间(Metaspace)取代,元空间使用的是本地内存(后边再介绍)。
所以在JDK8版本及以后,堆内存变为了:
- 新生代内存
- 老生代内存
新生代的细分和原来相同
JDK8 以后堆内存结构划分
新生代是由一个Eden+两个Survivor组成,他们内存大小划分的比例为8:1:1
tips:进一步分代分区的目的是为了将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及 GC 频率(更好地回收内存,或者更快地分配内存)
新生代是专门存放新创建对象的内存区域,当我们使用new关键字创建对象时,绝大多数的对象都会被分配到Eden区(伊甸园),在经历第一次新生代垃圾回收后(Minor GC,当伊甸园区的内存用尽时,JVM会触发一次Minor GC,清理未被引用的对象),未被引用的对象会被回收,存活下来的对象就会放入Survivor区(幸存者区),首次一般都是到S0,前提是new出对象前,三个区全是空的。从Eden 区->Survivor 区后对象的初始年龄变为 1,然后每进行一次Minor GC,就会将Eden区和当前Survivor区的对象移动到另一个Survivor区(至少时刻确保其中一个Survivor区为空),并且年龄+1,当它的年龄增加到一定程度(默认为 15 岁),就会晋升到老生代中,晋升老生代的年龄阈值可以通过-XX:MaxTenuringThreshold=<N>来设置,不过最大值为15(这是因为在对象头中,是用4位比特来对年龄进行存储的,这四位所能表达的最大二进制数是1111,转换为十进制就是15),在老年代,相对悠闲,当老年代内存不足时,则会出发Major GC ,进行老年代的内存清理,若执行后依然无法进行对象保存,就会产生 OOM (OutOfMemory)异常(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置)。
Java.lang.OutOfMemoryError:Java heap space
方法区(共享)
方法区属于是 JVM 运行时数据区域的一块逻辑区域,只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的(这也就呼应了我们前边说到的JDK1.7和1.8版本的方法区的不同实现)。
当JVM要使用一个类的时候,首先会读取并解析Class文件获取相关信息,然后把相关信息存入到方法区,方法去会存储已经被JVM加载的信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区和永久代以及元空间的关系可以这么理解,他们的关系就像Java中的接口与类关系,接口中定义方法(规范),但具体代码由实现类去完成(具体实现),这里接口就可以看作是方法区,永生代和元空间就是具体的实现类。
问:为什么要将永生代替换为元空间呢?
答:
- 前文提到的,在JDK8版本时,HotSpot VM与JRockit VM融合时,后者从来没有永生代这个东西,合并后也就没有必要额外设置这么个永久代的东西了
- 永生代有一个 JVM 本身设置的固定大小上限,无法进行动态调整(也就是受到 JVM 启动时内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
- 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由
MaxPermSize
控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了 - 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
记得时候可以这么记,换肯定是因为后者更好,哪里更好呢,要针对前者的缺点去讲,前者首先是堆内存中的区域 ,堆内存总会进行GC,换掉了说明前者效率低需要换,对应第四点,其次要注意JDK版本,8以后换掉了,咱们要注意8版本的改动,VM融合了,对应第一点。然后再对比两个东西的作用与本身所在位置,前者在堆内存中,堆内存是属于JVM内,所以收到JVM的内存限制,后者处于本地内存中,本地内存比较大,资源足,对应第二点,然后是用途,元空间存放类的元数据,区域大了,能加载的类就变多了,对应第三点。
方法区的常用参数:
-XX:PermSize=N | 方法区 (永久代) 初始大小 |
-XX:MaxPermSize=N | 方法区 (永久代) 最大大小 |
-XX:MetaspaceSize=N | 设置 Metaspace 的初始(和最小大小) |
-XX:MaxMetaspaceSize=N | 设置 Metaspace 的最大大小 |
字符串常量池(共享)
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。(这些池都是一样的,像是数据库连接池,http连接池,线程池等都是为了减少资源的重复创建,提高效率设置的)
1.都使用字符串字面量直接赋值,指向的同一个对象
//在字符串常量池中创建字符串对象“abc”
//将字符串对象“abc”的引用赋值给 a
String a = "abc";
//将字符串对象“abc”的引用赋值给 b
String b = "abc";
System.out.println(a==b); //true
2.都使用new String方法,每次使用都会在堆中重新生成一个对象
// 在堆内存中创建一个新的字符串对象 "abc"
// 将该字符串对象的引用赋值给 a
String a = new String("abc");
// 在堆内存中再次创建一个新的字符串对象 "abc"
// 将该字符串对象的引用赋值给 b
String b = new String("abc");
// 因为它们指向堆中的不同对象
System.out.println(a == b); // false
3. 两种方式都有,指向的是不同对象
// 在字符串常量池中创建一个新的字符串对象 "abc"
// 将该字符串对象的引用赋值给 a
String a = "abc";
// 在堆内存中再次创建一个新的字符串对象 "abc"
// 将该字符串对象的引用赋值给 b
String b = new String("abc");
System.out.println(a == b); // false
4.使用了intern方法
// 在堆内存中创建一个新的字符串对象 "abc"
// 将该字符串对象的引用赋值给 a
String a = new String("abc");
// 将字符串 "abc" 的引用从字符串常量池中赋值给 b
String b = a.intern();
// 比较 a 和 b 的引用是否相同,结果是 false,因为 a 指向堆中的对象,而 b 指向常量池中的对象
System.out.println(a == b); // false
图中展示的这些都是在JDK1.7版本及其以后的,因为在该版本中,字符串常量池和静态变量的位置移动到了堆中。
总结:
- 使用字面量创建字符串对象时(例如 String str = "abc" 时),JVM 会首先检查字符串常量池。如果常量池中已有相同的字符串对象,直接返回该对象的引用;如果没有,则在常量池中创建一个新的字符串对象并返回其引用。
- 使用new String("abc")方法创建字符串对象时,JVM会保证字符串常量池和堆中都有这个对象。首先检查字符串常量池中是否存在相同的字符串,如果不存在,先在常量池中创建字符串对象,然后在堆中创建一个新的字符串对象。如果存在,直接在堆中创建一个新的字符串对象,常量池中的对象不会被影响。最后返回堆内存中创建的新对象的引用。
- String中的intern方法是一个 native 的方法,当调用 intern方法时,JVM 会检查常量池中是否已存在一个等于当前
String
对象的字符串(通过 equals() 方法判断)。如果存在,则返回常量池中的字符串引用;如果不存在,则将当前字符串对象添加到常量池,并返回池中字符串的引用。
HotSpot 虚拟机中字符串常量池的实现是StringTable,可以简单理解为一个固定大小的 HashTable,
容量为 StringTableSize(可以通过 -XX:StringTableSize
参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
直接内存(共享)
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError
错误出现。
直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
总结
没想到这点东西就写了一个下午加一个晚上,看来还得提升自己的效率啊,好了第一期JVM内容先到这里吧,后续再继续写,希望大家能多多点赞支持啊,非常感谢,你的点赞就是我继续的动力!
标签:对象,虚拟机,学习,内存,JVM,字符串,方法 From: https://blog.csdn.net/qq_65754164/article/details/142622420