面试题之JVM内存区域

余生颓废 提交于 2020-03-09 05:15:40

1、Java内存区域(运行时数据区域):

  jdk1.8之前:虚拟机运行内存分栈、堆和方法区这几种。

  1. 栈:虚拟机栈、本地方法栈、程序计数器。(线程私有,每个线程都拥有各自的)
    1. 程序计数器:一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。主要有2个作用:
      1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
      2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪里了。
        注意:唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
    2. Java虚拟机栈:生命周期与线程相同(随着线程创建而创建,随着线程死亡而死亡),描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。Java内存大概就是堆内存和栈内存,栈是虚拟机栈或是虚拟机栈中局部变量表部分。(实际上,Java虚拟机栈是由一个个栈帧组成,每个栈帧中都拥有:局部变量表、操作数、动态链接、方法出口信息)
      局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,不同于对象本身,可能是指向一个代表对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)
      1. 注意:只会出现两种异常:StackOverFlowError和OutOfMemoryError。
      2. 1.StackOverFlowError:若Java虚拟机栈的内存大小不允许动态扩展,那么线程请求的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverMemory异常。
      3. 2.OutOfMemoryError:若Java虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。
      4. Java栈可用类比数据结构中栈,主要存的是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个函数调用完后,都会有一个栈帧被弹出
      5. Java方法有两种返回方式:return语句和抛出异常。
    3. 本地方法栈:和虚拟机栈发挥的作用很相似,虚拟机栈为虚拟机执行Java方法(也就是字节码服务),而本地方法栈则为虚拟机使用到的Native方法服务。
      本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数帧、动态链接、出口信息。也会出现StackOverFlowError异常和OutOfMemoryError异常。
  2. 堆:Java虚拟机管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
    1. 垃圾收集器采用分代垃圾收集算法,所以Java堆可以再细分为:新生代和老年代。再细致一点:Eden空间、From Survivor、To Survivor空间等。更好回收内存或者更快分配内存。
    2. eden

      s0

      s1

      tentired

      edene区、s0区、s1区都属于新生代,tentired区属于老年代。对象首先在eden区分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或s1,并且对象的年龄还会加1(enden区->survivor区后对象的初始年龄变为1),当它年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
  3. 方法区:与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即是编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名较做non-heap,目的就是与Java堆区分开来。jdk1.8时,方法区被彻底移除了,取而代之的是元空间,即直接内存。

      1.运行时常量池:是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符合引          用)。由于受到方法区的限制,当常量池无法再申请到内存时抛出OutOfMemoryError异常。

        jdk1.7及之后的jvm版本已经将运行时常量池从方法区移了出来,而在Java堆中开辟了一块区域存放运行时常量池。

    直接内存:并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但这部分内存也被频繁地使用。也可能导致OutOfMemoryError异常出现。

          JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓存区(Buffer)的I/O方式,它可以直接使用Native函数库直接分配堆外          内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了Java堆和Native堆          之间来回复制数据。不会受到Java堆的限制,但是,会受到本机总内存大小及处理器寻址空间的限制。

2、虚拟机对象:

  1、对象的创建:掌握好Java对象的创建过程,以及每一步在做什么。

    过程:1 类加载检查--》2 分配内存--》3 初始化零值--》4 设置对象头--》5 执行init方法

  1. 类加载检查:虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可,例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。)。直接引用:直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。直接引用可以是: 1:直接指向目标的指针。(个人理解为:指向对象,类变量和类方法的指针)2:相对偏移量(指向实例的变量,方法的指针)3:一个间接定位到对象的句柄。
  2. 分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有“指针碰撞”和“空闲列表”两种,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。1.指针碰撞:堆内存规整(即没有内存碎片)的情况下,原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向没用过的内存方向将该指针移动对象内存大小位置即可,GC收集器:Serial、ParNew。2.空闲列表:堆内存不规整的情况下,原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录,GC收集器:CMS。注意:内存分配并发的问题:在创建对象的时候有一个很重要的问题,就是线程安全,因为实际开发中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常采用两种方式来保证线程安全:1、CAS+失败重试:CAS是乐观锁的一种实现方式。乐观锁就是每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。2、TLAB:为每一个线程预先在Eden区分配一块儿内存,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配。
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头:初始化零值完成之后,虚拟机要对对象头进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行init方法:在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚开始,<init>方法还没有执行,所有的字段都还为零。所以一般执行new指令之后会接着执行<init>方法,把按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

  2、对象的内存布局

    可以分为:对象头、实例数据和对齐填充。

    1、对象头包括两部分信息:第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等),另一部分是类型指针,即对象指向它的类元数据的指      针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

    2、实例数据:是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

    3、对齐填充:不是必然存在的,也没有什么特别的含义,仅仅起占位作用。虚拟机的内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8      字节的整数倍。而对象头部分刚好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

  3、对象的访问定位

    建立对象就是为了使用对象,Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有1使用句柄2直接指    针

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

    2、直接指针:Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的之间就是对象的地址。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!