Java内存区域详解
JVM自动内存管理机制,可以使得不像C/C++语言那样,需要手动的申请和释放内存,Java将内存的申请和释放完全交给JVM来管理,所以并不容易出现内存泄漏和内存溢出的问题。但是并不代表不会发生,所以我们必须要熟悉JVM,要熟悉JVM自动内存管理的机制,这样我们在发生内存问题时,才知道该如何下手去解决。
JVM在运行时会把管理的内存划分为不同的数据区域,包括线程共享的堆、方法区,以及线程私有的虚拟机栈、本地方法栈、程序计数器。一共五大部分。但是在JDK 8前后,内存区域的划分出现了变动,在JDK 8之前方法区是在JVM内存模型里面的,但是到了JDK 8 JVM内存模型只是保留了方法区的概念,而方法区的具体实现移到了本地内存中,并且改名为元空间。
首先从PC程序计数器开始说起
程序计数器是一块比较小的内存空间,顾名思义,它是用来存放指向下一条指令的地址,也就是执行引擎即将要读取执行的下一条指令。PC程序计数器是线程私有的,也就是说每一个线程都有自己的PC程序计数器,并且它的生命周期和线程的生命周期保持一致。你可能回想,既然程序计数器是记录下一条指令的地址的,那么在多线程并发的情况下,为什么不直接所有线程共享一个程序计数器,谁用到谁就从程序计数器里面读取数据不就行了?为什么要把程序计数器设置为线程私有的?这是因为所谓的多线程,实际上在一个特定的时间段内只会执行某一个线程,CPU会不停的切换任务,这就涉及到线程之间的切换和恢复,为了能够准确的记录每一个线程当前正在执行的指令地址,以及上一次切换执行到哪个指令了,所以最好的办法就是每个线程分配一个程序计数器,这样各个线程之间相互独立,各自维护自己的程序计数器 。
程序计数器只要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的控制流程,比如顺序执行、循环、跳转、异常处理等等
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,在方法之间调用发生上下文切换时,能够保证当线程切换回来的时候,知道上次线程运行到哪个位置了
由于程序计数器内存空间本来就不大,而且也只是仅仅保存在下一条指令的地址,它的生命周期二者线程的创建二创建,随着线程的结束而消亡,所以程序计数器也是唯一一个不会发生内存溢出的内存区域。
再来说一下虚拟机栈这个内存区域
Java的指令都是根据栈来设计的,不同的平台CPU架构不同,不能基于寄存器来涉及,所以也就出现了虚拟机栈。
我们平时开发程序一提到创建一个对象,很容易就联想到堆、栈这两个内存结构,我们要明确栈是运行时的单位,也就是解决的是程序运行什么、什么时候运行、怎么运行的问题;而堆是存储的单位,也就是解决的是存放什么数据、数据放在哪的问题。
虚拟机栈也是线程私有的,在每个线程创建的时候都会创建一个虚拟机栈,保存着一个个的栈帧,而这一个个的栈帧就对应的一次次的方法调用。栈的生命周期也是和线程生命周期保持一致。涉及到栈的操作只有两个,一个是当方法执行时,栈帧入栈,方法结束时,栈帧出栈,因此也就不存在垃圾回收的问题。虽然不存在垃圾回收的问题,但是会出现栈溢出的问题,我们可以指定固定大小或者动态分配栈的内存大小,当栈帧数量超过了栈的容量时,JVM就会抛出一个栈溢出的异常,此时我们可以进行动态扩展,如果说在尝试扩展的时候无法申请到足够的内存,还会抛出内存溢出的异常。
我们既然说虚拟机栈内部保存的是一个个的栈帧,也就是说栈的基本单位是栈帧。那栈帧又是什么?栈帧里面存放的是什么?其实栈帧就是一个内存区块,里面保存着方法运行时的数据集合。
每个栈帧中保存着:局部变量表、操作数栈、动态链接、方法返回地址、其它附加信息
-
局部变量表
局部变量表其实是一个数组,主要用来存储方法参数和方法体内部定义的局部变量,这些数据类型包括各类基本数据类型、对象引用、返回地址类型。由于局部变量表示线程私有的,属于线程私有数据,所以不存在数据共享的安全问题。这个局部变量的容量大小在编译器就已经确定下来了,并保存在方法的Code属性中,并且在方法运行期间是不会改变局部变量表的大小的。
这个局部变量表的基本存储单元是Slot,称为变量槽,32bit以内的数据类型占用一个slot,64bit的数据类型占用2个slot。需要注意的是,byte、short、char、boolean基本数据类型在存储时会被转换成int数据类型进行存储。而long类型和double类型占用2个slot。这些变量槽是可以重复使用的,从而达到节省资源的目的。
在栈帧中,与性能调优联系最为密切的的部分就是局部变量表,在方法执行时,虚拟机使用局部变量表来完成方法数据的传递,而且局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或者间接引用的对于都不会被回收。
-
操作数栈
操作数栈是在方法执行过程汇总,根据字节码指令,往操作数栈中写入数据或者获取数据。操作数主要用来保存计算过程的中间结果。操作数栈的大小也是在编译器就计算好了,保存在方法的Code属性中。操作数栈中的元素可以是任意java数据类型,32bit的数据类型占用一个栈单位深度,64bit的数据类型占用两个栈单位深度。而局部变量表不同,局部变量表是通过索引访问数据,而操作数栈是通过出栈入栈操作数据的。如果被调用的方法有返回值的话,这个返回值也会被压入当前栈中。操作数栈中的元素的数据类型必须和字节码指令严格匹配,这是编译器在编译期间进行验证,同时在类加载过程汇总的类检验阶段的数据流分析阶段还要再次验证。JVM虚拟机的执行引擎是基于栈的执行引擎,这个栈指的就是操作数栈。
-
动态链接
我们编写的java代码在编译之后会生成class文件,而代码中的所有变量和方法引用都以符号引用的形式保存在class文件的常量池里面。一个方法引用另一个方法就是通过常量池中指向方法的符号引用来表示的,当使用类加载器将class文件加载到内存,程序运行时我们不可能用符号引用来运行方法,符号引用只是一个干巴巴的“符号”,是一种“标记”,一个“表面意义的字符串”,便于指令的识别而已,只是告诉程序该去调用哪个具体的、真正运行的方法。所以这个动态链接的作用就是为了将这些符号引用转为调用方法的直接引用。
这里既然涉及到了符号引用转为调用方法的直接引用,那我就具体来说一下方法的调用。将符号引用转为调用方法的直接引用与方法的绑定机制有关。绑定机制指的是一个字段、方法或者类在符号引用被转为直接引用的过程,仅仅会发生一次。与动态链接相对的是静态链接,当一个字节码文件被加载进JVM内部时,如果被调用的方法在编译器就已经确定下来,并且在运行期间保持不变,我们称这样的方法为非虚方法,比如静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,JVM通过使用invokestatic和invokespecial指令来调用这些非虚方法的,这样的情况会将调用方法的符号引用转为直接引用,由于绑定的时间点比较“早”,在编译器就确定了,所以静态链接对应的绑定机制是早期绑定。如果说被调用的方法在编译器无法确定下来,也就是说,只能在程序运行时将调用方法的符号引用转为直接引用,这些方法称为虚方法,又有会频繁使用到这些虚方法,为了避免每次使用虚方法都在类的方法元数据中搜索这些虚方法,JVM在类的方法区建立了一个虚方法表,使用索引表来代替查找。虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值纸杯完成之后,JCM会把该类的虚方法表也初始化完成。由于具有动态性,绑定的时间点比较“晚”,所以动态链接对应的绑定机制是晚期绑定。
-
方法返回地址
顾名思义,方法返回地址存放调用该方法的PC程序计数器的值。一个方法的要么正常执行完成,要么出现异常退出,但是无论哪种方式退出,在方法退出后都要返回到该方法被调用的位置,一边调用该方法的那个方法继续向下执行。方法如果正常执行完成退出时,执行引擎会读取到字节码指令return,把调用者的PC程序计数器的值作为返回地址,也就是调用该方法的指令的下一条指令的地址。但是如果是方法异常退出的,并且这个倡议没有在方法内部进行处理,就会抛出异常,返回地址是要通过异常表来确定的,不会给上层的调用者产生任何的返回值。栈帧中一般不会保存这部分信息。
本质上方法的退出就是当前栈帧的出栈过程,此时需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC程序计数器等,这样才能让上层方法继续执行下去。
-
其它附加信息
栈帧还保存着与JVM实现相关的一些附加信息,比如对程序调试提供支持的信息
栈帧的出栈入栈以及操作数的出栈入栈,都会频繁的执行内存的读写操作,所以在一定程度上会影响执行速度。为了解决这个问题,使用了栈顶缓存技术。既然频繁操作的都是栈顶元素,那么栈顶缓存技术就是将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的频繁读写,来提升执行引擎的执行效率。
其次是本地方法栈内存区域
与虚拟机栈相似,虚拟机栈用来管理java方法的调用,而本地方法栈用来管理本地方法的调用。本地方法栈也是线程私有的,本地方法栈的容量大小也是可以固定大小或者动态扩展,这一点跟虚拟机栈是一样的。本地方法是使用C语言实现的,本地方法栈会记录本地方法的调用,在执行引擎执行的时加载本地方法库。在Hotspot JVM中直接将本地方法栈和虚拟机栈合二为一了。
本地方法,简单的来说,就是一个java调用非java代码的接口,我们可以使用native标识符标记一个方法是本地方法,但是native不能和abstract一起使用。有可能你想说,为啥非得使用非java代码的本地方法?难道Java这么多方法已经满足不了你了吗?其实之所以使用到本地方法,是因为有些层次的任务使用Java实现起来不容易,或者效率很低,尤其是一些涉及到计算机底层的一些操作。这时候本地方法的作用就发挥出来了,本地方法通过本地方法接口来实现了Java与底层相同之间的友好交互操作。
本地方法栈要想调用本地方法库中的本地方法,这就不得不提到JVM架构中的本地方法接口。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的环境,它和虚拟机拥有同样的权限。这个线程调用的本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,甚至可以直接使用本地处理器中的寄存器,也可以直接从本地内存的堆中分配任意数量的内存。
线程私有的内存区域已经说完了,再来说一下线程共享的内存区域,堆
堆!这块内存区域可以JVM内存模型中的重头戏!分量刚刚的!
堆是java内存管理的核心区域,在JVM启动的时候就会被创建,创建的时候就已经确定好了堆空间的内存大小,但是可以调节堆内存的大小,使用-Xms参数设置堆空间的最小内存,使用-Xmx设置堆空间的最大内存。堆空间是JVM管理的最大的一块内存空间。JVM规范中固定了,堆可以是物理上不连续的内存空间,但是在逻辑上必须是连续的。虽然说堆空间是所有线程共享的内存区域,但是可以在堆空间中设置线程私有的缓冲区TLAB。几乎所有的对象实例以及数组都在堆空间中分配内存,而栈帧中局部变量表保存在只是指向对象实例或者数组的引用,当方法执行完成,栈帧出栈之后,堆中的对象并不会马上被移除,仅仅实在垃圾回收的时候才会被移除,堆就是GC垃圾回收器执行垃圾回收的重点内存区域。
由于堆空间是JVM管理的最大的一块内存区域,所以堆空间又做了进一步更详细的划分。
在JDK 7及以及,堆空间在逻辑上分为三大部分:
- 新生代
- Eden区
- Survivor区
- 老年代
- 永久代
但是到了JDK 8及以后,堆空间在逻辑上划分为:
- 新生代
- Eden区
- Survivor区
- 老年代
- 元空间(永久代改为了元空间,这一点到讲方法区内存区域的时候再详细说明)
之前我们说过可以使用参数-Xms和-Xmx来设置堆空间的起始内存和最大内存,但一般都会把这两个值设置为相同的值,目的是为了能够在java垃圾回收机制清理完堆空间后不需要重新分配计算堆空间,从而提高性能。默认情况下初始内存为电脑内存大小/64,最大内存大小为电脑内存大小/4。一旦堆空间超出设置的最大内存,就会报内存溢出异常。
我们先来说说新生代和老年代
现代垃圾回收器大部分都是基于分代收集理论设计的。根据对象实例生命周期的长短可以将堆空间划分为新生代和老年代,其中新生代又进一步划分为Eden去和Survivor区,而Survivor区包含Survivor0(from区)和Survivor1(to区)两部分。
一般默认情况新生代、老年代、Eden区、Survivor区所占堆空间的比例如下所示
几乎所有的Java对象都是在Eden区被new出来的,而且大多数对象声明周期都很短,在新生代就已经结束生命周期被销毁了。
接下来说一说创建出来的对象是怎么分配内存的
- new出来的对象会放在Eden区
- Eden区大小是有限制的,如果Eden区满了,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中不再被其它对象所引用的对象进行销毁(也就是被GC视为垃圾的对象),然后加载新创建的对象到Eden区
- 然后将Eden区中剩余的对象,也就是那些还被其它对象所引用,没有被GC视为垃圾的那些对象,将他们移动到Survivor0区(from区)
- 如果再次触发垃圾回收,此时如果上次幸存下来的放到Survivor0区的对象还没有被回收,那么就会把Survivor0区的这些对象移动到Survivor1区(to区)
经过一次次的垃圾回收,Survivor区中仍然存活的对象会不断从from区到to区反复移动。当然并不是无限制移动,默认垃圾回收15次,如何对象还存活就会把这些“生命力顽强”的对象移动到老年代。生命周期长的对象毕竟是少数,但是老年区也有可能会内存不足,此时再次触发垃圾回收(Major GC),进行老年代的内存清理。如果说老年代进行垃圾回收之后,还是内存不足无法存放新创建出来的对象,那么就只能报OOM内存溢出异常了。
由于对象生命周期的长度不同,以及各个分代所占堆空间比例的不同,新生代频繁发生垃圾回收,老年代很少发生垃圾回收,永久代或者说是元空间基本不会发生垃圾回收。
总结一下对象内存分配流程图大体如下:
JVM按照回收区域的不同,将GC又分为两大类型:
- 部分收集(只收集堆空间一部分垃圾)
- 新生代收集:Minor GC/Young GC,只是收集新生代的垃圾
- 老年代收集:Major GC/Old GC ,只是收集老年代的垃圾(目前只有CMS垃圾回收器会单独收集老年代垃圾)
- 混合收集:Mixed GC,收集整个新生代和部分老年代的垃圾(目前只有G1垃圾回收器)
- 整堆收集:Full GC,收集好着呢个堆空间和方法区里面的垃圾
当新生代空间不足的时候,就会触发Minor GC,这里说的新生代空间不足指的是Eden区满了,而Survivor去满了并不会触发Minor GC,每次Minor GC会清理Eden去和Survivor区的垃圾。由于大多数对象生命周期都很短,而且新生代所占堆空间内存比例并不大,所以Minor GC发生的会很频繁,但回收操作速度很快。但是Minor GC会引发STW暂停,它会暂停用户的其它线程,等待垃圾回收结束之后,用户线程才恢复继续运行。
当老年代空间不足的时候,就会触发Major GC,但一般会伴随着至少一次的Minor GC(Parallel Scavenge垃圾收集器会直接进行Major GC),也就是说在触发Major GC之前会先尝试触发Minor GC来回收新生代里面的垃圾,如果发生空间还是不足,就会触发Major GC回收老年代里面的垃圾,这时新创建的对象会直接放入到老年代中。Major GC速度比Minor GC要慢很多,STW暂停时间也更长。如果Major GC之后内存还不足,就直接报OOM了。
至于什么时候会触发Full GC来回收整个堆空间和方法区里面的垃圾,大致情况有如下几种:
- 手动调用System.gc()方法时,可以建议系统执行一次Full GC,但是系统根据实际情况来判断到底要不要真的执行Full GC
- 老年代内存不足
- 方法区内存不足
- 通过Minor GC后进入到老年代的对象平均大小大于了老年代可用内存,也就是说从Survivor区超过了设定的阈值而移动到老年代的对象太大,虽然老年代内存还没有满,但是放不下这么大的对象,只能触发一次Full GC
- 从Eden区或者Survivor区的from区向Survivor区的to区移动对象时,有可能对象太大,即使没有超过设置的阈值,由于to区放不下,这个对象会被直接放入到老年代中,如果此时这个对象大小大于老年代可用内存,就会触发Full GC
JVM把堆空间这一块大的内存区域进行了分代,并且垃圾回收也是基于分代的,有可能你想问问什么JVM要把堆分代?难道不分代就不能正常工作了吗?其实不分代也是可以的,只不过出于对GC垃圾回收的性能考虑,才进行了分代。我们知道java中大部分创建出来的对象,其生命周期是很短的,只有少部分的对象生命周期比较长,如果我们不根据生命周期的长短进行分代,而是把这些生命周期不同的对象都存放在一起,那么在垃圾回收的时候,垃圾回收器需要扫描整个堆空间,来判断哪些对象是垃圾。而大多数对象生命周期都很短,如果我们进行分代的话,把这些生命周期短的对象集中放在一个地方,这些垃圾回收时只需要扫描这一块区域就可以了,从而分代提升了垃圾回收的性能。
其实新生代不仅对Survivor做了进一步的划分,而且还对Eden区也做了进一步的划分。JVM为每个线程分配了一个私有缓存区域,即TLAB,它位于Eden区。既然堆空间是线程共享的,那么又跑出了一个线程私有缓存区域TLAB,它是干嘛的?因为堆空间是线程共享区域,任何线程都可以访问到堆空间中的共享数据,但是对象的创建非常频繁,在多线程并发情况下,从堆空间划分内存空间是线程不安全的,为了避免多个线程操作同一个堆空间内存地址,我们可以采取加锁等机制,但是这会影响对象内存分配的速度,此时TLAB线程私有缓存区域就诞生了,在保证多线程安全的情况下,提升了内存分配的吞吐量,因此将这种内存分配方式称为快速分配策略。
对于TLAB有一点需要注意的是,不是说所有对象在分配内存的时候都会成功在TLAB中成功分配,而是说JVM会把TLAB作为内存分配的首选,并不代表这个对象就一定能在TLAB中分配成功。默认情况下,TLAB内存空间很少,仅仅占有Eden区的1%,一旦创建的对象比较大,在TLAB空间分配内存就会失败,此时JVM就会尝试着通过使用加锁机制来确保数据操作的原子性,从而直接在Eden区进行分配内存。
我们一直在说创建的对象是在堆空间中分配内存,但是你有没有注意到,之前我说的是“几乎”所有对象都是在堆空间中分配内存的,为什么要说“几乎”?也就是说,堆空间并不是对象存储的唯一选择!有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆空间中分配内存,也无需进行垃圾回收了,它会随着出栈而销毁。这就是最常见的堆外存储技术。逃逸分析是一种跨函数全局数据流分析的算法,通过逃逸分析,编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆空间中。如果说一个对象在方法中被定义后,这个对象只在方法内部被使用,则认为这个对象没有发生逃逸,如果这个对象被外部方法给引用了,比如给成员变量赋值、作为方法返回值、实例引用传递等等,则认为发生了逃逸。
逃逸分析技术除了可以实现栈上分配,还可以实现同步省略。JIT即时编译器可以记住逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其它其它线程。如果没有发布到其它线程,那么JIT即时编译器在编译和这个同步代码块的时候就会取消对这部分代码的同步,这样就可能大大提高并发,取消同步的过程就叫做同步省略,也叫作锁消除,与并发编程中的可重入锁有异曲同工之妙。
通过逃逸分析还可以实现标量替换。所谓标量,指的是一个无法再分解成更小的数据的数据,像java中8种基本数据类型就是标量,不像是一个对象中包含若干个成员变量。在JIT即使编译阶段,经过逃逸分析后发生一个对象不会被外部访问,那么就会把这个对象拆解成若干个它的成员变量来替代这个对象,这个过程就是标量替换。标量替换可以减少堆空间内存的占用,标量替换是栈上分配的基石。
需要强调的一点就是,逃逸分析看上出非常NB,非常完成,既节省了堆空间的内存有能提升整体性能。实际上逃逸分析技术还很不成熟,其根本原因就是无法保证逃逸分析的它所带来的性能提升一定高于他自身进行分析的性能损耗,也就是说逃逸分析本身就很复杂,并不能保证利大于弊。
再来说一说另一个线程共享区域,方法区
又一个重头戏!和堆空间的分量不相上下!
JVM内存模型中联系最为紧密的可以说是虚拟机栈、堆、方法区这三者的之间的关系了。虚拟机栈是线程私有,而堆和方法区是线程共享,所以它们三者之间的交互构成了多线程协作的基础。
从class字节码文件加载的类的相关信息都是存放在方法区,而创建的变量则是存放在虚拟机栈中,确切的说是虚拟机栈中栈帧的局部变量表中,他保存这指向堆空间中对象实例的引用,而使用new关键字创建出来的对象实例则保存在堆空间中,对象实例还会维护一个指向对象类型数据的指针,方便知道当前这个对象实例是由哪个类模板创建出来的。
说完三者的关系,我们把话题拉回来,具体说一说方法区这个内存区域
Java虚拟机规范中明确说明了『尽管所有的方法区在逻辑上是属于堆的一部分,但是一些简单的实现可能不会选择取进行垃圾回收或者进行压缩』,但是对于Hotspot JVM来说,方法区还有一个别名就是Non-Heap(非堆),目的就是要和堆分开。所以说在概念上,方法区是独立于堆空间的一块内存空间。方法区和堆一样都是线程共享的内存区域,也都是在JVM启动的时候被创建出来,其物理内存也都是可以是不连续的,大小可以指定或者动态扩展。因为方法区存放的是所有类的相关信息,方法区的大小也就决定了保存类的数量,如果类太多就会导致方法区OOM。
实际上随着JDK版本的不断更新,方法区发生了一些变动。
在JDK6及以前,方法区的落地实现称为永久代,而且静态常量、字符串常量池是存放在永久代上的
在JDK 7时,因为逻辑上属于堆,所以方法区也被称为永久代,但是开始准备逐渐去除掉永久代了,此时静态变量、字符串常量池移动到了堆空间中
但是到了JKD 8,JVM内存模型中只保留了方法区这样一个概念,而真正的落地实现移动到了本地内存中,不再占用JVM内存,同时也把永久代改名为元空间。元空间和永久代本质上是相似的,都是对于方法区的具体落地实现,不过二者最大的区别就是,元空间不在虚拟机占用内存,而是使用本地内存。静态变量、字符串常量池仍保留在堆空间中
由此可以看出来JDK8可以算是一个分水岭,一个里程碑式的版本,尽管JDK版本还在不断的更新汇总,但是JDK8是相对成熟也是企业里面使用最多的一个版本。那么在JDK 8 版本中为什么使用元空间来替代了永久代呢?这是因为之前使用永久代,很难设置永久代空间大小,永久代使用的是JVM的内存,动态加载的类一旦过多,就会报出OOM异常。而元空间则是使用的是本地内存,本地内存要大得多,不再受JVM内存的限制,仅受本地内存的限制。还一个原因就是对永久代进行调优也很困难。那为什么静态变量和字符串常量池也要调整到堆空间中呢?这是因为永久代的回收效率很低,在Full GC的时候才会触发永久代的垃圾回收,而字符串是使用最频繁的基本数据类型,这就导致字符串常量池回收效率不高,很容易就导致永久代内存不足,所以从JDK 7开始就已经把静态变量和字符串常量池移动到了堆空间中,这样就能利用堆空间的垃圾回收机制及时的回收垃圾。一般来说垃圾回收一般都是发生在堆空间,其实方法区也会发生垃圾回收,只不过触发方法区进行垃圾回收的条件比较困难,而且回收的效率也不高,但是有必要的,方法区的垃圾回收主要回收常量池中废弃的常量和不再使用的类型。常量池中的常量主要存放两大类常量,一类是字面量,一类是符号引用,只要常量池中的常量没有被任何地方引用就会被回收,这与堆空间的对象回收相似。而回收不再使用的类型比较苛刻,必须保证这个类所有的对象实例都已经被回收,并且加载这个类的类加载器也已经被回收,还有就是这个类对应的Class对象没有在任何被引用,满足这三个条件这个类才允许被回收,注意,是允许,并不代表一定会回收,由此可见方法区想要进行垃圾回收十分困难。
接下来讲一下方法区的内部结构
从class字节码文件加载的与类有关的相关信息都会被存放到方法区中,类型信息、域信息、方法信息、静态变量、运行时常量池、JIT代码缓存都存放在方法区中。
-
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须存储这些类型相关信息
- 这个类型的完整有效名称(包名+类名),也称为全限定名
- 这个类型直接父类的完整有效名称
- 这个类型的修饰符(权限修饰符、abstract、final、static等等)
- 这个类型直接接口的一个有序列表(都实现了哪些直接接口)
-
域信息
方法区中保存了所有域的相关信息(域名称、域类型、域修饰符,如public static int var)以及域的声明顺序
-
方法信息
方法区中保存着方法的相关信息,以及方法的声明顺序
- 方法名称
- 方法的返回值
- 方法参数的数量和类型
- 方法的修饰符
- 方法的字节码、操作数栈信息、局部变量表信息
- 异常表
-
静态变量
静态变量和类关联在一起,随着类的加载而加载,类变量被该类的所有对象实例所共享,即使没有对象实例也可以访问静态变量。
需要注意的是使用final修饰的静态变量称为全局常量,在编译的时候就会被分配了。
我们提到方法区中还存放着运行时常量池,而class字节码文件中也有常量池,它们是一个东西?有什么区别?实际上运行时常量池是针对方法区的,而常量池是针对class字节码文件的,二者之间存在着联系(想想之前的动态链接、符号引用、直接引用)。
class字节码文件保存着的都是一些二进制数据,这些二进制数据按照class文件结构保存着java类的所有相关信息。一个有效的class字节码文件除了包含类的版本信息、字段、方法、接口等相关信息以外,还包含一个常量池表,这张常量池表中包含着各种字面量以及类型、字段、方法的符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池属于方法区的一部分,在加载类和接口到JVM后,就会创建对应的运行时常量池。JVM会为每一个加载的类型都维护一个常量池,池中的数据项像数组项一样,是通过索引访问的。运行时常量池中包含不同的常量,包含从编译器就已经确定的字面量,也包括到运行时解析后才能获得的方法或者字段引用,此时不再是class文件中常量池中的符号引用地址了,而是转换为真实的地址引用了。也就是说运行时常量池能将class字节码文件中的常量池里面的符号引用转换为直接引用。
运行时常量池 VS 字符串常量池 VS class字节码文件常量池
讲到这里千万不要搞混了运行时常量池、字符串常量池、class字节码文件中的常量池这三大概念,接下来我再来说一下字符串常量池。
我们所说的字符串常量池在JDK 7及以前,运行时常量池在逻辑上是包含着字符串常量池的,并存放在方法区,在JDK7和8的时候是将字符串常量池(StringTable)移动到了堆空间中,而不是将整个运行时常量池移动到了堆空间中,运行时常量池中剩下的其它东西仍保留在方法区中,这一点一定要注意!
String数据类型可以说是我们使用最频繁的了,String类型是被final修饰的,因此它不可被继承,同时String还是先了Serializable接口和Comparable接口,因此也支持字符串的序列化和比较大小。在JDK8及以前,String底层使用char[]数组存储字符串数据的,而到了JDK9改为了byte[]数组。String类型具有不可变性,通过字面量的方式给一个字符串赋值,此时这个字符串值声明在字符串常量池中,字符串常量池中是不会存储相同内容的字符串的,它是一个固定大小的Hashtable。
我们之前说过在JDK 6及以前,字符串常量池是存放在永久代,到了JDK7将字符串常量池移动到了堆空间中,此时所有的字符串都保存在堆中,和其他普通对象一样来进行统一管理,JDK8永久代改名为元空间,字符串常量池仍在堆空间中。那为什么要把字符串常量池移动到堆空间中呢?你可以从两点考虑,第一点就是堆空间是一块较大的内存,而且对象的创建和回收比较频繁,而字符串类型的数据是经常被使用,在永久代很难进行垃圾回收,把字符串常量池移动到堆空间,方便统一管理和回收垃圾,第二点就是和字符串和普通对象进行统一管理,提升了垃圾回收效率和内存使用率,以减少了因加载过多类而导致字符串常量池的OOM发生的概率。
平时在操作字符串数据时,经常会进行拼接操作,常量与常量的拼接结果是保存在字符串常量池中,是因为其结果在编译期间就已经确定下来了,字符串常量池不会存放相同内容的字符串常量,但是拼接操作中只有其中一个是变量,那么拼接的结果就会保存在堆空间中,这是因为这种操作底层使用的是StringBuilder,使用append方法进行拼接,最后使用toString方法返回一个字符串对象,此时字符串常量池并没有存放着这个新创建出来的字符串对象。如果说拼接的结果你主动调用intern()方法,并且字符串常量池还没有这个字符串对象,那么就会主动将这个字符串对象放入字符串常量池中,并返回此对象地址。对于字面量字符串对象来说是直接存放在字符串常量池中的,但是其他new出来的字符串对象,我们可以使用这个intern()方法,这个方法会从字符串常量池检查当前字符串是否存在,如果存在则直接返回这个字符串对象的地址,如果不存在就将当前字符串对象放入字符串常量池中,这样是为了确保相同内容的字符串在内存中只有一份,节约内存空间。在JDK 6时,intern方法会尝试将这个字符串放入字符串常量池中,如果说字符串常量池中已经有了,则不会放入,并返回池中已有的那个字符串对象的地址;如果说池子中还没有,就会把这个对象复制一份放入池中,并返回池子中的对象地址。但是到了JDK 7/8之后,如果说字符串常量池中没有这个字符串对象,注意!它是把这个对象的引用地址复制一份,放入池子中,并返回池子中的引用地址!
举个列子,String s = new String("a");
这段代码你说它创建了几个对象?实际上创建了两个对象,一个是new关键字在堆空间中创建的对象,另一个是在字符串常量池中创建的对象“a”。但是再来看看这段代码String s2 = new String("a") + new String("b");
你觉得创建了几个对象?实际上创建了6个对象!这样的字符串拼接会用到第一个对象StringBuilder,然后会在堆空间中new出来存放“a”第二个对象,然后在字符串常量池中存放创建出来的第三个对象“a”,接着在堆空间中new出来存放“b”第四个对象,然后在字符串常量池中存放创建出来的第五个对象“b”,第六个对象就是StringBuilder最后调用toString方法创建的返回对象“ab”。一共创建了6个对象,注意,最后调用toString方法返回的字符串对象并不会在字符串常量池中生成”ab“这个字符串常量。当一个程序中使用大量的字符串时,使用intern方法可以降低内存的消耗,提高性能。String对象使我们使用最大也是最频繁的,在堆空间中存在着重复String对象肯定是一种对内存的浪费,而G1垃圾回收器实现了自动持续对重复的String对象进行去重存在,节约了内存,它底层实现是维护一个hashtable来记录所有的被String对象使用的不重复char数组,每当去重的时候会检查这个hashtable,来看堆空间中是否已经存在一个一模一样的char数组。
对象的创建过程和内存布局
前面讲了这么多,那么一个对象到底是什么创建出来的?在JVM中又是怎么存储的?
先来说一下创建对象的详细过程
-
类加载检查
当虚拟机执行new指令时,首先会检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析、初始化过,如果没有,则必须先执行相应的类加载过程。
-
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从堆空间中划分出来给这个对象。分配方式有指针碰撞和空闲列表两种。至于选择哪种分配方式由堆空间是否规整决定,堆空间是否规整又由所采用的垃圾回收器是否带有压缩整理功能有关。
内存分配方式:
- 指针碰撞
- 适用场合:堆空间规整,也就是没有内存碎片
- 原理:用过的内存全部整合到一边,没有用过的内存整合到另一边,二者之间有一个分界值指针,只需要向没有用过的内存方向移动这个指针即可
- GC回收器:Serial、ParNew
- 空闲列表
- 适用场合:堆空间不规整的情况
- 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给创建的对象使用,然后更新列表记录
- GC回收器:CMS
程序在运行时会创建出大量的对象,那就会带来多线程下内存分配并发问题,JVM 必须保证线程是安全的
-
CAS+失败尝试:
CAS是乐观锁的一种实现方式,假设每次分配内存不会冲突,如果发生冲突就失败重试,直到成功为止。虚拟机采用CAS+失败重试的方式来保证更新操作的原子性。
-
TLAB:
在Eden区给每一个线程都分配一块线程私有缓存区域,在JVM给线程中的对象分配内存时,首先尝试在TLAB中分配,如果对象大于TLAB的内存或者TLAB内存已经用完了,再采取第一种方式分配内存。
- 指针碰撞
-
初始化默认值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
-
设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
-
执行
init
初始化方法在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,
<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行<init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
经历完上述5个步骤,一个真正意义上的对象才被创建出来
创建出来对象之后,再来说一下对象在JVM内存中的布局
-
对象头
-
运行时元数据
用来存储对象自身运行时的数据,包括哈希码、GC分代年龄、锁状态等等
-
类型指针
存储对象指向它所属类的指针
-
如果说对象是数组,还需要记录数组的长度
-
-
实例数据
实例数据部分才是对象真正存储有效信息的区域,包括自身以及从父类继承下来的各种字段信息
-
对齐填充
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
现在对象也创建出来了,也清楚了对象在内存中的布局了,那么当我们想要使用这个创建出来的对象时,该如何去定位到这个对象并进行访问呢?JVM实际上是通过栈帧中的对象引用来访问对象实例,访问方式主要有两种,一种是句柄访问,另一种是直接访问。
-
句柄访问
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
-
直接访问
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。由对象实例中维护一个指向对象类型数据的指针。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
至此,讲的也差不多了,我们围绕着JVM内存模型,讲了JVM内存区域详细划分以及对象的创建、存储、使用。也许讲的还不够,后续将继续补充~~~