Java内存区域
划分
在首先,需要注意的是,Java内存区域与Java内存模型是不同的概念:
ava虚拟机在运行程序时会把其自动管理的内存划分为区域,这些区域就被称为 Java内存区域。
而Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
而在Java内存区域与Java内存模型唯一一点相似之处,都存在共享数据区域和私有数据区域, 但两者的内存区域又不能等同,属于不同层级的概念。
在查阅资料的时候,发现有人将这两者混为一谈,让我颇感困惑,特地在这里提醒一下。
而Java内存区域的概念比起JMM来说要浅显易懂。
而Java内存区域则划分为: 方法区,堆(这两者是被所有线程共享的内存区域),虚拟机栈,本地方法栈,程序计数器。(这三者属于线程隔离区域)
但对于不同的虚拟机,实现可能有所区别。
程序计数器
在这里的程序计数器,作用与计算机中的程序计数器十分类似,不过处理的对象有所不同,在计算机中,程序计数器指向的是下一条计算机指令所在的地址,而Java的程序计数器是一块内存空间,可以被看做是当前线程
所执行的字节码文件的行号指示器。
当所执行的方法为 Native时,这个程序计数器的值为空。
对于每一个线程,都需要维护其独有的程序计数器。
Java虚拟机栈
Java虚拟机栈也是线程所私有的,生命周期与线程相同。它描述的是Java方法执行的内存模型:在每个方法执行的同时,都会创建一个栈帧,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈出栈的过程。
通俗意义上讲,就是当一个方法被调用的时候,代表这个方法的栈帧入栈,当一个方法返回的时候,代表这个方法的栈帧出栈。
1 栈帧
栈帧(stack frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
如果线程所请求的栈深度大于虚拟机的最大栈深度,会抛出 StackOverflowError异常,而如果虚拟的栈本身可以动态扩展,在扩展时无法申请到足够内存,则抛出 OutOfMemoryError异常。
查看字节码的命令:
javap -verbose ClassName.class
-
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
局部变量表中的空间基本单位是 slot, 对于32位之内的数据,用一个 slot 来存放,如 int,short 等;对于64位的数据用连续的两个 slot 来存放,如 long,double 等。引用类型的变量 JVM 并没有规定其长度,它可能是 32 位,也有可能是 64 位的,所以既有可能占一个 slot,也有可能占两个 slot。并且需要注意到的是,在这里所提到的数据类型,仅仅是能够与java的数据类型类比,事实上依然分属不同的概念。 JVM并不仅仅只支持java语言,自然数据类型也不可能和java强相关。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问。我们知道,静态方法只需要和类关联即可,实例方法必须和对象相关联。
而之所以在实例方法中可以访问其他实例方法,是因为在实例方法中第一个参数为this,指向当前对象,自然可以调用其他方法,而静态方法没有 this 引用,无法给实例方法提供指向方法接收者的隐含参数,因此不能调用实例方法。
在局部变量表中存储参数(索引从零开始)的顺序为:
this(如果是实例方法)=> 参数(如果有的话)=> 定义的局部变量(如果有的话)。
同时,局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为。
而这一点参考:
-
操作数栈
在这之前,首先需要提到一个概念。
Java虚拟机的解释执行引擎被称为"基于栈的执行引擎"。
Java的一大特点是,一次编译,到处运行,而这里的编译并非编译成机器码,而仅仅是指编译成字节码文件,在不同的机器上都有相应的JVM执行这些字节码文件,以求在不同的机器上会有相同的表现形式。
类装载器装载负责装载编译后的字节码,并加载到运行时数据区(Runtime Data Area),然后执行引擎执行会执行这些通过类装载器装载的字节码,被分配到JVM的运行时数据区的字节码会被执行引擎执行。
这里所说的基于栈执行是区别于基于寄存器执行的。
而这里的执行引擎在我理解来就是读取字节码,解释并执行相应代码的功能。
但这种解释不可避免的会导致效率低下,因此在Java中又使用了,即时编译(JIT),将热点代码编译成本地代码,放入本地缓存中,在使用时直接读取相应的本地代码去执行即可,使得速度非常快。当不再是热点代码,则从缓存中移除,恢复成解释型执行。
在看过Java的执行引擎之后,再来看,对应的操作数栈。
和局部变量区一样,操作数栈也是被组织成一个以slot为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
看下面这段代码,简单的将两个数相加:
begin iload_0 // push the int in local variable 0 onto the stack iload_1 // push the int in local variable 1 onto the stack iadd // pop two ints, add them, push result istore_2 // pop int, store into local variable 2 end
则是分别将操作数压入栈,这里从0开始,是因为静态方法的参数,正是从索引0开始的, 将索引0,1的位置数据分别压入栈,而后弹出两个数,相加之后,再度压入栈,存在索引2的位置。不难理解,在最终,操作数栈存有的便是方法本身的返回值。
-
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池有存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
本地方法栈
本地方法栈是区别于虚拟机方法栈的,其区别不过是虚拟机方法栈是为执行Java方法服务,而本地方法栈则是为 Native方法服务,在HotSpot中,则是采取将两者合二为一。
Java堆
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。这个内存区域唯一的作用就是用来存放Java实例对象,几乎所有的实例对象都要在当前区域分配,数组是一种特殊类型的对象,也被分配在当前区域。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”(Garbage Collected Heap)。
从内存回收的角度来看,由于现在收集器基本上都是采用分代收集算法回收内存,所以Java堆中还可以细分为:新生代和老年代;再细分一点的有:Eden空间,From Survivor空间,To Survivor空间等。
从内存分配的角度看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer ,TLAB),无论如何划分,都与存放内容无关,无论哪个区域,存放的都仍然是对象实例。进一步划分是为了更好地回收内存,更快地分配内存。
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续即可,就像我们的磁盘空间一样。当前主流的虚拟机都是按照可扩展来实现的,如果在堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError异常。
至于更详细的就不在这里多做介绍,放在下一篇博客进行详细介绍。
方法区
参考:
方法区被所有线程所共享,存储已经被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据。
在HotSpot中用永久代的方式来实现方法区,并且将GC的分代收集扩展至方法区。至于其中缺点稍后再谈。
需要注意到的是:
-
方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。同样方法区也不必是连续的。方法区可以在堆(甚至是虚拟机自己的堆)中分配。jvm可以允许用户和程序指定方法区的初始大小,最小和最大尺寸。
-
方法区同样存在垃圾收集,因为通过用户定义的类加载器可以动态扩展Java程序,一些类也会成为垃圾。jvm可以回收一个未被引用类所占的空间,以使方法区的空间最小。
接下来分别看一下,在方法区存储的这几种信息。
-
类信息
对于每个已经被虚拟机加载的类,jvm在方法区中存储以下类型信息:
这个类型的完整有效名称(全限定名 包名+类名)
这个类型的直接父类的完整有效名称(Interface 或者 java.lang.Object 没有父类)
这个类型的修饰符(public abstract final )
这个类型直接接口的一个有序列表
类型的常量池( constant pool)
域(Field)信息
方法(Method)信息
除了常量外的所有静态(static)变量
-
常量池
而常量则是存储在常量池中,jvm为每一个被虚拟机加载的类型维护一个常量池,常量池就是这个类型里用到的常量的一个有序集合:
包括了实际的常量(被final修饰的, 普遍意义上的常量),对类、属性、方法的符号引用。池中数据类似数组项,可以通过索引访问。
这个常量池,和运行时常量池是相同的概念,在运行时,当类被加载之后,相应内存才存放在运行时常量池中。
而就在这里,有一个让我之前混淆的概念问题,就是 import * 与import单个Java类的区别,我一直以为,import * 会引入所有Java类的常量数据,相关的final信息,导致占用不必要的内存,因为这样的原因才导致需要引入特定类。
但事实显然并非如此,import本身只能影响编译,对运行时无能为力,因此只会带来编译时的压力,而不会引起其他问题。
但还是推崇引入单个Java类这种方式:
-
域信息
jvm在方法区中保存类的所有域的相关信息以及声明顺序。
域的相关信息:
域名
域的类型
域的修饰符在Java中域是指:
field,域是一种属性,可以是一个类变量,一个对象变量,一个对象方法变量或者是一个函数的参数。
所以这个类自身的几乎所有信息都能够在方法区中找到。
-
方法信息:
jvm在方法区中保存类的所有方法的相关信息以及声明顺序方法的相关信息:
方法的返回值(或者void)
方法的参数列表
方法的修饰符
方法的字节码操作数栈和局部变量表大小
异常表
Java堆中的对象
在这里主要了解一下,在Java堆中,一个对象是如何被分配,布局,访问,这整个过程的。
当使用一个方法,且在方法中定义一个变量时,这个变量的引用/变量,便会被存储在Java虚拟机栈中的 栈帧 中的 局部变量表中,Java栈是线程私有的,当变量指向一个 new 出来的对象时,会在共享区域,也就是方法区中的常量池中去查找相应类的符号引用,并检测类是否被加载。
而在类被加载之后,需要给新生对象分配相应的内存,如果在堆中,内存划分工整有序,被分为空闲的和已使用的内存,中间通过一个指针来进行分割,那么分配内存就是移动指针即可,而如果内存混乱无序,就需要维护相应的列表,记录内存的使用情况,前者被称为“指针碰撞”,后者被曾为“空闲列表”,而Java虚拟机究竟采取哪种方式的关键因素之一则是: 垃圾收集器是否具有内存压缩功能。
而当内存已经指定,在划分内存区域的时候同样会遇到相应的并发状况,这时候就需要采取CAS的方式, 或是采取将分配动作按照线程划分不同的区域,保证不会冲突。
而在内存分配完毕之后,虚拟机需要将分配到的空间初始化为零值(不包含对象头),这一操作保证了对象的实例字段在Java代码中即使不进行初始化也能够直接使用。
接下来就是需要设置对象头,如对象是哪个类的实例(与多态息息相关),如何能够找到对象的元信息等其他信息。
而这时,以Java虚拟的角度来看,对象的创建已经完成。但从Java程序的角度来看,对象的创建才刚刚开始,进行相应的初始化操作等。
而一个Java对象分为三块区域:对象头,实例数据,对齐填充。
对象头主要分为两部分:其一是对象的运行时数据,如哈希码,GC分代年龄,锁状态,等其他信息。
而另一部分则是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个来确定究竟是哪个类的实例,如果为数组对象,则还需要记录数组长度的数据。
而实例数据则是对象真正存储的有效信息。
对齐填充的真正目的在于因为在hotSpot内存管理中要求对象起始地址必须为8字节的整数倍。
在对象的访问中,主流存在两种方式:
句柄:在Java栈中存储的是对象的句柄,在对象的句柄中包含对象的实例数据,对象类型信息的地址。 对象的实例数据地址再指向堆中的实例数据。对象类型指针则是指向方法区中的对象类型信息。
直接指针:在栈中存储的就是对象地址,对象地址中包含对象的实例数据以及到对象类型数据的指针。
以上就是堆中对象的相关信息。
来源于:《深入理解Java虚拟机》
Java虚拟机中的几种配置信息
-
-Xms -Xmx
用来分配和设置进程对堆内存的最小最大内存大小。
-
-Xmn
-Xmn用来设置堆内新生代的大小。通过这个值我们也可以得到老生代的大小:-Xmx减去-Xmn,在JDK1.8以前,HotSpot中还要减去永久代的大小
-
-Xss
-Xss设置每个线程可使用的内存大小。在相同物理内存下,减小这个值能生成更多的线程。因此对于具有多个线程的应用而言,需要将这个值设置的尽量小,以支持更多的线程。
而关于内存的限制,不得不提到的一个点是:
32位程序的寻址能力是2^32,也就是4G。对于32位程序只能申请到4G的内存。而且这4G内存中,在windows下有2G,linux下有1G是保留给内核态使用,用户态无法访问。故只能分配2G、3G的内存使用(对单个进程而言)。
永久代
参考:
Java8内存模型—永久代(PermGen)和元空间(Metaspace)
之所以要将永久代单独提出来, 是因为在 java8中彻底移除了一个概念,永久代。而其原因除了永久代本身的设计不合理因素之外, 则是Oracle可能要将 HotSpot 与 JRocket进行合并,因此这种特性上的,且设计并非很合理的东西就被删除掉了。
那么永久代的概念是什么呢?
其实在之前已经提到过这个概念了,也就是方法区,不过有所不同的是,方法区是Jvm的一种规范,而永久代则是方法区在HotSpot中的实现方式。而最初采取这种设计方式的目的,则是因为,在方法区同样需要实现类的卸载,运行时常量池的回收工作。(需要了解的是,字符串常量就被存储在永久代中。)
因此就将GC的分代收集扩展至方法区,使用永久代来实现方法区。
java.lang.OutOfMemoryError: PermGen space "这个异常正是由于永久代所导致的。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。因为jsp页面在其所对应的servlet第一次被访问时,就会创建相应的class文件。并且加载到方法区中。
而原先的运行时常量则是存储到元空间。
字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。
对于字符串常量池:
而元空间:
元空间与永久代类似,存放的数据差别不大,最大的区别则在于存放的位置,元空间并不在虚拟机中,而是使用本地内存。
相关配置参数:
-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
-XX:MaxMetaspaceSize,最大空间,默认是没有限制的(仅取决于自身机器内存影响)。
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
因此在无限加载String时, Java Heap Space
而加载大量Java类时: MetaSpace。
JVM内存区域相关的介绍,就到这里。