【Java虚拟机】第二章、深入理解JVM-------内存模型

目录

   虚拟机栈

程序计数器

本地方法栈

java堆

方法区

常量池

直接内存

总结


public static void main(String[] args){
    String str = "Hello World!";
    System.out.println(str);
    System.out.println("Hello World!");
}

我们知道了上面这段代码,当右键运行时操作系统会寻找java.exe,叭叭叭,就创建了虚拟机,那这段代码怎么在虚拟机上执行呢?System是啥呢,out是啥呢,Hello World!怎么输出到控制台的?

System是工具包jdk的java.lang.System里面的,输出和IO流又有关系,所以此地暂不解决System是啥的问题,我们先扒开工具包看深处,str这个对象为什么能够被java.lang.String申明,申明之后的这个对象为什么能够被java.lang.System输出,上面输出的变量和下面直接输出字符串有什么区别吗?我们先了解以下几个概念。

   虚拟机栈

  1. 操作数栈
  2. 局部变量表
  3. 动态链接
  4. 方法出口(返回地址)

虚拟机栈是线程私有的,它的生命周期和线程相同,Java虚拟机栈描述的是方法执行时的内存模型。每个方法被执行的时候都会创建一个栈帧。栈帧是虚拟机栈的一个栈元素。栈帧的特性是后进先出,它入栈是从最顶端进行的,也称压栈,出栈也是先从最顶端出栈,所以是后进先出。

  当一个方法被创建时同时会创建栈帧,执行方法需要哪些,栈帧就包含哪些,比如这个方法中申明的变量(基本数据类型,对象实例的引用),那么栈帧中就在局部变量表中存放了这个对象,方法运行需要对一些数据进行计算,那么就需要栈帧中的操作数栈,方法可能需要调用本地的函数库,那么就需要动态链接,方法有返回值,所以栈帧中就出现了方法出口(方法返回地址)。总的来说,一个栈帧包含了局部变量表,操作数栈,动态链接,方法出口等信息。

    局部变量表

    局部变量表存储的数据包含方法参数和方法内的变量。在JAVA文件编译为CLASS文件时,在方法的CODE属性中max_locals数据项内确定了该方法所需要分配的局部变量表的最大容量。

    局部变量表容量的最小单位是变量槽(Variable slot)。虚拟机规范中没有明确指出1个slot所需内存分配空间的大小,但是指向性的说明了至少能存放char,int,short,byte,float,boolean,reference(一个对象实例的引用,java虚拟机中没有明确规定reference的长度,它的长度与虚拟机是32位还是64位有关,如果是64为的虚拟机,那么还要看他是否开启了某些对象指针压缩的优化有关),reference address这些类型的数据,这些数据都可以用32位或者更小的空间来存放。但这种描述与明确指出“每个slot占用内存都是32位”,还是有差异的,它允许slot长度随着处理器、操作系统或虚拟机的改变而改变。如果系统是64位的话,只要保证即使在64位的虚拟机中也利用了64位的物理内存空间去实现一个slot,如果是这种方式的话,虚拟机也还是需要对齐和补白的方式使slot在外观上与在32位虚拟机中的是一致的。

    对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的slot空间。java语言中明确规定了只有两种类型是64位的,long、double,reference类型可32,也可64。

  虚拟机通过索引定位的方式使用局部变量表,索引的范围从0到局部变量表的最大的slot数量。如果访问的是32位的数据类型的变量,索引N就代表使用了第N个slot,如果访问的是64位的数据类型的变量,则说明会同时使用N,N+1两个slot。对于两个相邻的共同存放1个64位的数据的两个slot,不允许以任何方式的被单独访问其中一个。

   以下代码可以看出栈帧深度。

	static int count = 0;
	public static void main(String[] args) {
		{
			try {
				test(1, 2);
			} catch (StackOverflowError e) {
				System.out.print("深度:" + count);
			}
		}
	}
	public static void test(int a ,int b){
		count++;
		String s = "123";
		long c = 123L;
		test(a,b);
	}

    为了尽可能节省栈帧空间,局部变量表的slot是可以重用的。当一个程序计数器的值超出了某个变量的作用域,那么这个变量的slot是可以被释放的,也可以被其他的变量所使用,如下代码手动释放slot空间。

	public static void main(String[] args) {
		byte[] a = new byte[60 * 1024 * 1024];
		System.gc();
	}

内存日志:61448K->63477K(86336K)] 63646K->63477K(124096K)

	public static void main(String[] args) {
		{
			byte[] a = new byte[60*1024*1024];
		}
		int a = 0;
		System.gc();
	}

内存日志:61448K->2037K(86336K)] 63580K->2037K(124096K)

为什么上面的GC没有被释放呢?其实它是被释放了,但是此时slot还存在,没有被其他变量覆盖,所以当下面int a =0就覆盖了之前的slot。或者也可以用byte[] a = null;有时会有奇效。

另外说一点对于实际编码有用的东西,int类型或其他boolean的值不一定都有默认值,成员变量是经过两次初始化的,一次是系统默认值操作,另外一次是工程师自定义值赋值操作。但是局部变量只有一次初始化,只有当我们自定义值的时候,才会被初始化。

   操作数栈

先看这个图,其实很好理解

  1. 两个局部变量100,98
  2. 把100压入栈中
  3. 把98压入栈中
  4. 在栈中把100和98加起来,并返回结果
  5. 把返回结果通过指令获取到局部变量2中

   动态链接

  要完全理解动态链接,我看了一些文章,可能还是得先从静态链接开始讲起,啥是静态链接,C/C++里的概念就是,在编译期间把所有的类加载,并找到他们的引用,无论是否使用到就叫静态链接。JAVA中,我们知道每个java文件会被编译成class文件,虽然看上去每个文件都是独立的,但是他们之间通过接口(harbor)符号互相联系,或与java api的class文件相联系。

https://blog.csdn.net/xmd415606062/article/details/88525102类加载的步骤,其中解析和调用就是静态链接。在类加载的过程中把符号引用替换成直接引用称为静态链接。在jvm运行期间,把符号引用替换为直接引用为动态链接。

   方法出口(返回地址)

    简单的讲,一个是正常返回,一个是非正常返回(报异常等)。正常返回会在程序计数器中记录返回地址,被调用方调用(到另一个栈帧中),非正常返回不会在程序计数器中记录地址,而需要在异常处理器中确认地址。

    方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
  注意:

  1. StackOverflowError:当线程请求的栈深度大于虚拟机栈允许的最大深度,会报此错误。
  2. OutOfMemoryError:如果虚拟机栈可以动态扩展(大部分都支持),当扩展到无法申请足够的内存时,会报此错误。

程序计数器

   程序计数器简单来说就是记录每行代码的偏移地址,保证每个线程被挂起之后可以继续执行。当java文件编译成字节码文件后,每行代码的开头都有个数字,字节码解释器就是依靠这些指令的偏移地址进行解析的。而每个线程都有自己独立拥有的计数器,当线程被创建时,程序计数器也同时被创建,销毁时同时销毁。程序计数器也是不可能会有内存溢出的情况出现的,因为程序计数器只会存在当前线程执行到哪一步字节码的偏移地址,当执行下一步时,程序计数器中的标记会被新的地址覆盖。也有例外,当执行本地native方法时,程序计数器为空。因为本地native方法不会产生字节码。

本地方法栈

这个和虚拟机栈作用类似,区别在于虚拟机栈是虚拟机为执行java方法(字节码)准备的,本地方法栈是为虚拟机执行native方法准备的。虚拟机并没有规定本地方法栈使用何种语言, 使用方式,数据结构,因此虚拟机可以自由实现它。甚至有的虚拟机(sun hotspot虚拟机)把虚拟机栈和本地方法栈合二为一。与虚拟机栈一样,本地方法也会抛出StackOverflowError和OutOfMemoryError。

java堆

对于大多数应用来说,Java堆是虚拟机所管理的都内存中最大的一块。Java堆是被所有线程共享的一块区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,但是随着JIT编译器的产生和逃逸分析的出现,这个说法就不是这么绝对了。当JIT编译器在运行期编译时,如果该方法被判断为非逃逸,那么会进行标量替换,栈上分配,如果判断为对象逃逸,那么垃圾收集器不会马上去收集,这就造成了大量的堆内存消耗。逃逸分析还有很多好处,可以参考【Java虚拟机】第四章、JIT编译器和逃逸分析所以不是所有的对象都会被分配到堆内存上

Java堆是垃圾收集器管理的主要区域,因此很多时候都被称为GC堆。如果从内存回收角度来看,由于现在收集器基本采用分代收集算法,所以堆也可以分为以下几个部分。

如上图所示,整个堆有两部分组成

  1. Young Generation(新生代),占用了堆1/3的空间,其中包括占用新生代8/10空间的Eden区,占用1/10空间的From Survivor区和占用1/10的To Survivor区。可以使用-Xmn分配新生代的大小,也可以分配Eden去和Suviver区的大小,使用-XX:SurvivorRatio分配,这个配置的是比值,默认是8。
  2. Old Generation(老年代),占用了2/3的堆空间。新生代多次没有被回收的时候会被放到老年代,以及一些大对象(缓存,这里缓存指的是弱引用),这些大对象可以不进入新生代,直接进入老年代(1.防止新生代有大量剩余空间,而大对象创建导致提前GC;2.防止在eden区和suviver区的大对象复制造成性能问题),这个可以通过-XX:PretenureSizeThreshold参数设置,表示单个对象超过了这个值就进入老年代(默认为0,意思是不管多大都是先在eden中分配内存)。并且大的数组对象也会分配到老年代,如List,ArrayList(底层用数组实现),因为数组需要连续的空间存储数据。

还可以分配新生代和老年代的配置比例。-XX:NewRatio这个分配的比例仅仅是新生代和老年代的比例,不包含永久代(<JDK1.8)。如果堆内存被设置为100M,这个比值设置为7的,如果永久代占用了10M内存,那么年轻代就占用了90/(7+1)*7,老年代就占用了90/(7+1)*1。

可能有些人很奇怪,上图怎么没有永久代吗?在JDK1.8版本之后已经把永久代取消了,换成了元数据区,这块区域后面会讲。

如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(Thread LocalAllocation Buffer,TLAB)。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储的都是内存的实例。而进一步划分是为了更好的进行回收内存,或者更快的分配内存。

根据Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

最小堆内存在JVM中默认占用物理内存的1/64,最大堆内存默认占用物理内存的1/4,且建议最大堆内存不超过4G,并且设置-Xms=-Xmx,这样的话避免每次GC回收后,调整堆的大小,减少系统内存的分配开销。

JDK1.8和JDK1.7相比去掉了永久代区域和新增了元数据区。

永久代

永久代(PermGen)只有HotSpot有,只有JDK1.7之前有,用来存储class和method元信息,大小配置和项目规模以及类和方法的数量有关,一般配置128M就够了,设置原则是预留30%空间,他可以通过-XX:PermSize和-XX:MaxPermSize设置。永久代也可能会被GC回收,如果永久代里的常量池没有被引用以及一些无用的类信息和类的Class对象也会被回收。

元数据区

如上图所示,JDK1.8之后的内存区域如此划分。

为什么移除永久代?

  • 它的大小是启动时固定好的,很难验证并且调优。-XX:MaxPermSize
  • HotSpot的内部类型也是java对象:它可能会在Full GC中被移动,非强类型,难以跟踪调式,需要存储元数据的元数据信息
  • 简化垃圾回收:对每一个回收集使用专门的元数据迭代器
  • 可以在GC不进行暂停的情况下并发地释放类数据。
  • 使得原来受限于永久代的一些改进未来有可能实现

什么是元空间?

元空间和永久代类似,都是对JVM规范中方法区的实现。但是永久代删除后,原本存储在永久代中的静态变量和字符串常量池(Interned String)都被存储到jvm堆中了,只有class元数据才存储在元空间。永久代和元空间最大的区别是元空间并不在虚拟机中,而是存储在本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来设置。

    -XX:MetaSpaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaSpaceSize时,适当提高该值。

    -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。 
    除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性: 

    -XX:MinMetaSpaceFreeRatio,在GC之后,最小的MetaSpace剩余空间容量的百分比,减少为分配空间导致的垃圾收集

    -XX:MaxMetaSpaceFreeRatio,在GC之后,最大的MetaSpace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

方法区

JDK1.8之后只是取消了永久代部分,并没有取消方法区!!谁要讲方法区被取消了就喷他!

这块看了好多文章,挺抽象的,说是啥永久代的规范啥的,找了好多文档,接下来详细说一下,这个是咋回事。如果有不准确的,麻烦指正,3q。

先看下classloader是怎么加载class文件和存储文件信息的。

当一个classloader启动的时候,classloader的生存地点在JVM的堆,然后它会去主机硬盘上将A.class装载到jvm的方法区,方法区中的这个字节文件会被虚拟机拿来new A字节码(),然后在堆内存生成一个A字节码对象,然后A字节码这个字节文件有两个引用,一个指向A的class对象,一个指向加载自己的classloader,那么方法区中的字节码内存块除了记录一个class自己的class对象引用和一个加载自己的classloader引用之外,还记录了什么信息呢?见下图

方法区关键信息介绍

    1.类信息:修饰符(public final)

                 是类还是接口(class,interface)

                 类的全限定名(Test/ClassStruts.class)

                 直接父类的全限定名(java/lang/Object.class)

                 直接父接口的权限定名数组(java/io/Serializable)

    也就是public final class ClassStrut extends object impletemts Serializable这段描述的信息提取

    2.字段信息:修饰符(pirvate)

                     字段类型(java/lang/String.class)

                     字段名(name)

    也就是类似private String name;这段描述信息的提取

    3.方法信息:修饰符(public static final)

                   方法返回值(java/lang/String.class)

                    方法名(getStatic_str)

                    参数需要用到的局部变量的大小还有操作数栈大小(操作数栈我们后面会讲)

                    方法体的字节码(就是花括号里的内容)

                     异常表(throws Exception)

    也就是对方法public static final String getStatic_str ()throws Exception的字节码的提取
    4.常量池:

        4.1.直接常量:

                    1.1CONSTANT_INGETER_INFO整型直接常量池public final int CONST_INT=0;

                    1.2CONSTANT_String_info字符串直接常量池   public final String CONST_STR="CONST_STR";

                     1.3CONSTANT_DOUBLE_INFO浮点型直接常量池

                      等等各种基本数据类型基础常量池(待会我们会反编译一个类,来查看它的常量池等。)

        4.2.方法名、方法描述符、类名、字段名,字段描述符的符号引用

        也就是所有编译器能够被确定,能够被快速查找的内容都存放在这里,它像数组一样通过索引访问,就是专门用来做查找的。

        编译时就能确定数值的常量类型都会复制它的所有常量到自己的常量池中,或者嵌入到它的字节码流中。作为常量池或者字节码流的一部分,编译时常量保存在方法区中,就和一般的类变量一样。但是当一般的类变量作为他们的类型的一部分数据而保存的时候,编译时常量作为使用它们的类型的一部分而保存

    5.类变量:

                  就是静态字段( public static String static_str="static_str";)

                  虚拟机在使用某个类之前,必须在方法区为这些类变量分配空间。

    6.一个到classLoader的引用,通过this.getClass().getClassLoader()来取得为什么要先经过class呢?思考一下,看一下上面的图,再回来思考。(class A 对象拥有A字节码和加载它的加载器地址引用)

    7.一个到class对象的引用,这个对象存储了所有这个字节码内存块的相关信息。所有你能够看到的区域,比如:类信息,你可以通过this.getClass().getName()取得

          所有的方法信息,可以通过this.getClass().getDeclaredMethods(),字段信息可以通过this.getClass().getDeclaredFields(),等等,所有在字节码中你想得到的,调用的,通过class这个引用基本都能够帮你完成。因为他就是字节码在内存块在堆中的一个对象

    8.方法表,如果学习c++的人应该都知道c++的对象内存模型有一个叫虚表的东西,java本来的名字就叫c++- -,它的方法表其实说白了就是c++的虚表,它的内容就是这个类的所有实例可能被调用的所有实例方法的直接引用。也是为了动态绑定的快速定位而做的一个类似缓存的查找表,它以数组的形式存在于内存中。不过这个表不是必须存在的,取决于虚拟机的设计者,以及运行虚拟机的机器是否有足够的内存。

jvm虚拟机规范对这个区域管理的非常宽松,除了和java堆一样不需要连续的内存和可以选择固定大小和可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是很少出现的,但并非数据进入方法区就和永久代一样永久存在了,这个区域在1.7之前主要是对常量池和类型的卸载,1.7之后(>=1.8)由于字符串常量池迁移到了堆中,所以主要还是对类型的卸载,条件相当苛刻,但是这部分的回收确实是有必要的。在Sun公司的bug列表中,曾出现过若干个严重的bug,就是由于低版本的hotspot虚拟机对此区域未完全回收而导致内存泄漏。

根据java虚拟机规范的规定,当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError异常。

下图是个完整的方法区和堆和栈的关系图,这就非常直观了。

常量池

1.全局字符串池(string pool也有叫做string literal pool)  1.7之后迁到堆

都知道JDK1.8之后变了,我来简单说下吧,也是我看到的,有待验证:符号表SymbolTable引用的Symbol移动到了native memory,而StringTable引用的java.lang.String实例则从PermGen移动到了普通Java heap。

全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到字符串池。(string pool中存的是引用值而不是具体的对象实例,对象实例是在堆中开辟的一块空间存储的)。在hotspot VM里实现string pool功能的一个string table类,它是一个hash表,里面存的是驻留字符串(也就是我们用双引号申明的字符串)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个String Table引用之后就等同赋予了“驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

2.class文件常量池(静态常量池)(class constant pool)   存放在堆中

我们都知道,class文件中除了包括类的版本,字段,方法接口等描述信息外,还有一项信息是常量池(constant pool table),用于存放编译器生成的各种字面量和符号引用。字面量就是我们所说的常量概念,如文本字符串,被申明为final的常量值等。符号引用是一组符号来描述所引用的目标,符号是任何形式的字面量,只要使用时能无歧义的定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是能简介定位到目标的句柄)。

3.运行时常量池(runtime constant pool)  1.8之后在元空间

当java文件被编译成class文件时,会产生上面所说的class文件常量池,那么运行时常量池何时产生呢?

当jvm执行某个类的时候,必定经过以下几个过程,加载,验证,准备,解析,初始化。而当类加载到内存中后,jvm会将class常量池中的内容存放到运行时常量池,由此可知,运行时常量池也是每个类都有的。在上面也说了,class常量池中存储的都是字面量和符号引用,当jvm执行解析的过程后,会吧符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的stringtable,以保证运行时常量池中引用的字符串和全局字符串池所用的是一致的。

 

全局字符串池在每个VM中只有一份,存放的是字符串常量的引用

class常量池在编译的时候每个class都有,在编译阶段,存放的是常量的符号引用

运行时常量池是在类加载完成后,将每个class常量池中的符号引用值转存到运行时常量池中,待类在解析时,把符号引用都替换成直接引用,与全局字符串池的引用保持一致。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但是这部分区域也被频繁使用,同时也可能会触发OutOfMemoryError异常出现,所以我们放在这里一起讲。

在JDK1.4之后加入了NIO类,引用了一种基于通道(channel)与缓冲区(buffer)的I/O方式,他可以直接运用native函数库方式直接分配堆外内存,然后通过一个存储在java堆里面的directByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在java堆和native堆中来回复制数据。

显然,本机直接内存的分配不会收到java堆大小的限制。但是既然是内存,还是会受到本机总内存的大小及处理器寻址空间的限制。服务器管理员配置参数时,一般会根据实际内存设置-xms等参数信息,但经常会忽略掉直接内存,使得各个内存总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError。

总结

上面是整个流程的贯穿,不包括方法执行步骤,方法执行步骤包括入栈出栈,操作数栈已经讲的很详细,自己理解吧。

知道了对象的内存布局,那如何对对象进行定位呢? java程序是通过栈上的reference数据来操作堆上的具体对象。jvm规范并未定义reference这个也能用应该通过何种方式去定位堆中的对象。所以对象的访问方式取决于虚拟机的具体实现:

1. 句柄:java堆中会划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息 

2. 直接指针:reference中直接存储对象的地址 

两种方式各有优劣:句柄的好处是对象移动(GC很多)时只会改变句柄中的实例数据指针,而reference本身不用修改;但是直接指针的访问速度更快,一次定位,句柄需要二次。HotSpot是采用直接指针的方式对对象定位的。

 

 

 

 

参考内容:https://www.cnblogs.com/dingyingsi/p/3760447.html     JVM内存模型

https://blog.csdn.net/TuGeLe/article/details/78886522     局部变量表

https://blog.csdn.net/youngyouth/article/details/79868299   程序计数器

https://denverj.iteye.com/blog/1218359   操作数栈

https://www.cnblogs.com/straybirds/p/8529924.html  TLAB堆内存运行时缓冲区

https://blog.csdn.net/qq_20641565/article/details/60332593   堆和GC

https://www.cnblogs.com/duanxz/p/3728737.html  方法区

https://blog.csdn.net/qq_26222859/article/details/73135660  常量池

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值