Java中的堆和栈(转)

事实上,堆和栈都是内存中的一部分,有着不同的作用,而且一个程序需要在这片区域上分配内存。众所周知,所有的Java程序都运行在JVM虚拟机内部,我们这里介绍的自然是JVM(虚拟)内存中的堆和栈。

 

一,区别

1,各司其职:

最主要的区别就是栈内存用来存储局部变量和方法调用。

而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。

2,独有还是共享:

栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。

而堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

3,异常错误:

如果栈内存没有可用的空间存储方法调用和局部变量,JVM会抛出java.lang.StackOverFlowError。

而如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。

4,空间大小:

栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。如果递归没有及时跳出,很可能发生StackOverFlowError问题。

你可以通过-Xss选项设置栈内存的大小。-Xms选项可以设置堆的开始时的大小,-Xmx选项可以设置堆的最大值。

 

二,深入了解堆和栈

1,Java内存管理简介:

        内存管理在Java语言中是JVM自动操作的,当JVM发现某些对象不再需要的时候,就会对该对象占用的内存进行重分配(释放)操作,而且使得分配出来的内存能够提供给所需要的对象。一般情况下在Java程序开发过程手动内存管理称为显示内存管理

        显示内存管理经常发生的一个情况就是引用悬挂——也就是说有可能在重新分配过程释放掉了一个被某个对象引用正在使用的内存空间,释放掉该空间后,该引用就处于悬挂状态。如果这个被悬挂引用指向的对象试图进行原来对象(因为这个时候该对象有可能已经不存在了)进行操作的时候,由于该对象本身的内存空间已经被手动释放掉了,这个结果是不可预知的。

        显示内存管理另外一个常见的情况是内存泄漏——当某些引用不再引用该内存对象的时候,而该对象原本占用的内存并没有被释放,这种情况简称为内存泄漏。比如,如果指针对某个链表进行了内存分配,而因为手动分配不当,仅仅让引用指向了某个元素所处的内存空间,这样就使得其他链表中的元素不能再被引用而且使得这些元素所处的内存让程序处于不可达状态并且这些对象所占有的内存也不能够被再使用,这个时候就发生了内存泄漏。而这种情况一旦在程序中发生,就会一直消耗系统的可用内存直到可用内存耗尽,而针对计算机而言内存泄漏的严重程度大了会使得本来正常运行的程序直接因为内存不足而中断,并不是Java程序里面出现Exception那么轻量级。

       在以前的编程过程中,手动内存管理带给计算机程序不可避免的错误,而且这种错误对计算机程序是毁灭性的,所以内存管理就成为了一个很重要的话题,但是针对大多数纯面向对象语言而言,比如Java,提供了语言本身具有的内存特性:自动化内存管理,这种语言提供了一个程序垃圾回收器Garbage Collector(GC),自动内存管理提供了一个抽象的接口以及更加可靠的代码使得内存能够在程序里面进行合理的分配。最常见的情况就是垃圾回收器避免了内存泄漏的问题,因为一旦这些对象没有被任何引用“可达”的时候,也就是这些对象在JVM的内存池里面成为了不可引用对象,该垃圾回收器会直接回收掉这些对象占用的内存,当然这些对象必须满足垃圾回收器回收的某些对象规则,而垃圾回收器在回收的时候会自动释放掉这些内存。不仅仅如此,垃圾回收器同样会解决悬挂引用的问题。

 

2,详解堆和栈:

1)通用简介(程序运行时有三种内存分配策略)

        静态存储——是指在编译时就能够确定每个数据目标在运行时的存储空间需求,因而在编译时就可以给它们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间。

        栈式存储——该分配可成为动态存储分配,是由一个类似于堆栈的运行栈来实现的,和静态存储的分配方式相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到了运行的时候才能知道,但是规定在运行中进入一个程序模块的时候,必须知道该程序模块所需要的数据区的大小才能分配其内存。和我们在数据结构中所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。

        堆式存储——堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例,堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放

(对比C++语言程序占用的内存分为下边几个部分

        栈区——由编译器自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。我们在程序中定义的局部变量就是存放在栈里,当局部变量的生命周期结束的时候,它所占的内存会被自动释放。

        堆区——一般由程序员分配和释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。我们在程序中使用C++中new或者C中的malloc申请的一块内存,就是在heap上申请的,在使用完毕后,是需要我们自己动手释放的,否则就会产生“内存泄露”的问题

        全局区Static——全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和静态变量在相邻的另一块区域。程序结束后由系统释放。

        文字常量区——常量字符串就是放在这里的,程序结束后由系统释放。在Java中对应有一个字符串常量池。

        程序代码区——存放函数体的二进制代码!)

2)JVM结构(堆、栈解析)

        在Java虚拟机规范中,一个虚拟机实例的行为主要描述为:子系统、内存区域、数据类型和指令,这些组件在描述了抽象的JVM内部的一个抽象结构。与其说这些组成部分的目的是进行JVM内部结构的一种支配,更多的是提供一种严格定义实现的外部行为,该规范定义了这些抽象组成部分以及相互作用的任何Java虚拟机执行所需要的行为。下图描述了JVM内部的一个结构,其中主要包括主要的子系统、内存区域。(Java虚拟机有一个类加载器作为JVM的子系统,类加载器针对Class进行检测以鉴定完全合格的类接口,而JVM内部也有一个执行引擎!)

        当JVM运行一个程序的时候,它的内存需要用来存储很多内容,包括字节码、从类文件中提取出来的一些附加信息、程序中实例化的对象、方法参数、返回值、局部变量以及计算的中间结果。JVM的内存组织需要在不同的运行时数据区进行以上的几个操作,下边针对上图出现的几个运行时数据区进行详细解析:

(一些运行时数据区共享给所有应用程序线程和其他特有的单个线程,每个JVM实例有一个方法区和一个内存堆,这些是共享给虚拟机内运行的线程。在Java程序里面,每个新的线程启动过后,它就会被JVM在内部分配自己的PC寄存器PC registers,也叫程序计数器,和Java栈Java stacks。若该线程正在执行一个非本地Java方法,在PC寄存器的值指示下一条指令执行,该线程在Java内存栈中保存了非本地Java方法调用状态,其状态包括局部变量、被调用的参数、它的返回值、以及中间计算结果。而本地方法调用的状态则是存储在独立的本地方法内存栈native method stacks里面,这种情况下使得这些本地方法尽可能保证和其他(抽象的)内存运行时数据区的内容独立,而且该方法的调用更靠近操作系统,这些方法执行的字节码有可能根据操作系统环境的不同使得其编译出来的本地字节码的结构也有一定的差异。JVM中的内存栈是一个栈帧的组合,一个栈帧包含了某个Java方法调用的状态,当某个线程调用方法的时候,JVM就会将一个新的帧压入到Java内存栈,当方法调用完成过后,JVM将会从内存栈中移除该栈帧。JVM里面不存在一个可以存放中间计算数据结果值的寄存器,其内部指令集使用Java栈空间来存储中间计算的数据结果值,这种做法的设计是为了保持Java虚拟机的指令集紧凑,使得与寄存器原理能够紧密结合并且进行操作!)

        方法区Method Area——在JVM实例中,对装载的类型信息是存储在一个逻辑方法内存区中的,当Java虚拟机加载了一个类型的时候,它会跟着这个Class的类型去路径里面查找对应的Class文件,类加载器读取类文件(线性二进制数据),然后将该文件传递给Java虚拟机,JVM从二进制数据中提取信息并且将这些信息存储在方法区,而类中声明的(静态)变量就是来自于方法区中存储的信息。在JVM里面用什么样的方式存储该信息是由JVM设计的时候决定的,例如:当数据进入方法区的时候,多类文件字节的存储量以Big-Endian(第一次最重要的字节)的顺序存储,尽管如此,一个虚拟机可以用任何方式针对这些数据进行存储操作,若它存储在一个Little-Endian处理器上,设计的时候就有可能将多文件字节的值按照Little-Endian顺序存储。(......)(JVM虚拟机将搜索和使用类型的一些信息也存储在方法区中以方便应用程序加载读取该数据。设计者在设计过程中也考虑到要方便JVM进行Java应用程序的快速执行,而这种取舍主要是为了程序在运行过程中内存不足的情况能够通过一定的取舍去弥补内存不足的情况。在JVM内部,所有的线程共享相同的方法区,因此,访问方法区的数据结构必须是线程安全的,如果两个线程都试图去调用一个名为Lava的类,比如Lava还没有被加载,只有一个线程可以加载该类而另外的线程只能够等待。方法区的大小在分配过程中是不固定的,随着Java应用程序的运行,JVM可以调整其大小,需要注意一点,方法区的内存不需要是连续的,因为方法区内存可以分配在内存堆中,即使是虚拟机JVM实例对象自己所在的内存堆也是可行的,而在实现过程是允许程序员自身来指定方法区的初始化大小的同样的,因为Java本身的自动内存管理,方法区也会被垃圾回收的,Java程序可以通过类扩展动态加载器对象,类可以成为“未引用”向垃圾回收器进行申请,如果一个类是“未引用”的,则该类就可能被卸载,而方法区针对具体的语言特性有几种信息是存储在方法区内的!)

+(类型信息)在JVM和类文件名的内部,类型名一般都是完全限定名java.lang.String格式,在Java源文件里面,完全限定名必须加入包前缀,而不是我们在开发过程写的简单类名,而在方法上,只要是符合Java语言规范的类的完全限定名都可以,而JVM可能直接进行解析,比如:java.lang.String在JVM内部名称为java/lang/String,这就是我们在异常捕捉的时候经常看到的ClassNotFoundException的异常里面类信息的名称格式。(除此之外,还必须为每一种加载过的类型在JVM内进行存储,下边的信息有不存储在方法区内的!)

+(常量池)针对类型加载的类型信息,JVM将这些存储在常量池里,常量池是一个根据类型定义的常量的有序常量集,包括字面量(String、Integer、Float常量)以及符号引用(类型、字段、方法),整个常量池会被JVM的一个索引引用,如同数组里面的元素集合按照索引访问一样,JVM针对这些常量池里面存储的信息也是按照索引方式进行。实际上常量池在Java程序的动态链接过程起到了一个至关重要的作用

+(字段信息)针对字段的类型信息,信息是存储在方法区里面的有字段名、字段类型、字段修饰符(public,private,protected,static,final,volatile,transient)

+(方法信息)针对方法信息,信息存储在方法区上的有方法名、方法的返回类型(包括void)、方法参数的类型和数目以及顺序、方法修饰符(public,private,protected,static,final,synchronized,native,abstract);针对非本地方法,还有些附加方法信息需要存储在方法区内的是方法字节码、方法中局部变量区的大小和方法栈帧、异常表。

+(类变量)类变量在一个类的多个实例之间共享,这些变量直接和类相关,而不是和类的实例相关,针对类变量,其逻辑部分就是存储在方法区内的。在JVM使用这些类之前,JVM先要在方法区里面为定义的non-final变量分配内存空间;常量(定义为final)则在JVM内部不是以同样的方式来进行存储的,尽管针对常量而言,一个final的类变量是拥有它自己的常量池,作为常量池里面的存储某部分,类常量是存储在方法区内的,而其逻辑部分则不是按照上边的类变量的方式来进行内存分配的。虽然non-final类变量是作为这些类型声明中存储数据的某一部分,final变量存储为任何使用它类型的一部分的数据格式进行简单存储。

+(ClassLoader引用)对于每种类型的加载,JVM必须检测其类型是否符合了JVM的语言规范,对于通过类加载器加载的对象类型,JVM必须存储对类的引用,而这些针对类加载器的引用是作为方法区里面的类型数据部分进行存储的。(程序中就可以随意用这个引用了!)

+(类Class的引用)JVM在加载了任何一个类型过后会创建一个java.lang.Class的实例,虚拟机必须通过一定的途径来引用该类型对应的一个Class实例,并且将其存储在方法区内

+(方法表)为了提高访问效率,必须仔细地设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还添加了一些其他的数据结构,如方法表。

        内存栈Stack——当一个新线程启动的时候,JVM会为Java线程创建每个线程的独立内存栈,如前所言Java的内存栈是由栈帧构成,栈帧本身处于游离状态,在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法一般称为当前线程方法,而该方法的栈帧就称为当前帧,而在该方法内定义的类称为当前类,常量池也称为当前常量池。当执行一个方法如此的时候,JVM保留当前类和当前常量池的跟踪,当虚拟机遇到了存储在栈帧中的数据上的操作指令的时候,它就执行当前帧的操作,当一个线程调用某个Java方法时,虚拟机创建并且将一个新帧压入到内存堆栈中,而这个压入到内存栈中的帧成为当前栈帧,当该方法执行的时候,JVM使用内存栈来存储参数、局部变量、中间计算结果以及其他相关数据。方法在执行过程有可能因为两种方式而结束:如果一个方法返回完成就属于方法执行的正常结束,如果在这个过程抛出异常而结束,可以称为非正常结束,不论是正常结束还是异常结束,JVM都会弹出或者丢弃该栈帧,则上一帧的方法就成为了当前帧。(在JVM中,Java线程的栈数据是属于某个线程独有的,其他的线程不能够修改或者通过其他方式来访问该线程的栈帧,正因为如此这种情况不用担心多线程同步访问Java的局部变量,当一个线程调用某个方法的时候,方法的局部变量是在方法内部进行的Java栈帧的存储,只有当前线程可以访问该局部变量,而其他线程不能随便访问该内存栈里面存储的数据。内存栈内的栈帧数据和方法区以及内存堆一样,Java栈的栈帧不需要分配在连续的堆栈内,或者说它们可能是在堆,或者两者组合分配实际数据用于表示Java堆栈和栈帧结构是JVM本身的设计结构决定的,而且在编程过程可以允许程序员指定一个用于Java堆栈的初始化大小以及最大、最小尺寸!)

        内存堆Heap——当一个Java应用程序在运行的时候在程序中创建一个对象或者一个数组的时候,JVM会针对该对象和数组分配一个新的内存堆空间。但是在JVM实例内部,只存在一个内存堆实例,所有的依赖该JVM的Java应用程序都需要共享该堆实例,而Java应用程序本身在运行的时候它自己包含了一个由JVM虚拟机实例分配的自己的堆空间,而在应用程序启动的时候,任何一个Java应用程序都会得到JVM分配的堆空间,而且针对每一个Java应用程序,这些运行Java应用程序的堆空间都是相互独立的。这里所提及到的共享堆实例是指JVM在初始化运行的时候整体堆空间只有一个,这个是Java语言平台直接从操作系统上能够拿到的整体堆空间,所有的依赖该JVM的程序都可以得到这些内存空间,但是针对每一个独立的Java应用程序而言,这些堆空间是相互独立的,每一个Java应用程序在运行最初都是依靠JVM来进行堆空间的分配的。在开发多线程程序的时候,针对同一个Java应用程序必须考虑线程安全问题,因为在一个Java进程里面所有线程是可以共享这个进程拿到的堆空间的数据的。但是Java内存堆有一个特性,就是JVM拥有针对新的对象分配内存的指令,但是它却不包含释放该内存空间的指令,当然开发过程可以在Java源代码中显示释放内存或者说在JVM字节码中进行显示的内存释放,但是JVM仅仅只是检测堆空间中是否有引用不可达(不可以引用)的对象,然后将接下来的操作交给垃圾回收器来处理。

+(对象表示)对象表示可以理解为独立的一种Java对象在内存中的存储结构,该结构是由设计最初考虑的。针对一个创建的类实例而言,它内部定义的实例变量以及它的超类以及一些相关的核心数据,是必须通过一定的途径进行该对象内部存储以及表示的。当开发过程给定了一个对象引用的时候,JVM必须能够通过这个引用快速从对象堆空间中去拿到该对象能够访问的数据内容,也就是说,堆空间内对象的存储结构必须为外围对象引用提供一种可以访问该对象以及控制该对象的接口使得引用能够顺利地调用该对象以及相关操作。因此,针对堆空间的对象,分配的内存中往往也包含了一些指向方法区的指针,因为从整体存储结构上讲,方法区似乎存储了很多原子级别的内容,包括方法区内最原始最单一的一些变量:比如类字段、字段数据、类型数据等等。而JVM本身针对堆空间的管理存在两种设计结构。

  • (设计一)堆空间的设计可以划分为两个部分:一个处理池和一个对象池,一个对象的引用可以拿到处理池的一个本地指针,而处理池主要分为两个部分:一个指向对象池里面的指针以及一个指向方法区的指针。这种结构的优势在于JVM在处理对象的时候,更加能够方便地组合堆碎片以使得所有的数据被更加方便地进行调用。当JVM需要将一个对象移动到对象池的时候,它仅仅需要更新该对象的指针到一个新的对象池的内存地址中就可以完成了,然后在处理池中针对该对象的内部结构进行相对应的处理工作。不过这样的方法也会出现一个缺点就是在处理一个对象的时候针对对象的访问需要提供两个不同的指针,其实可以这样讲,真正在对象处理过程存在一个根据时间戳有区别的对象状态(一个引用指向过不同对象?),而对象在移动、更新以及创建的整个过程中,它的处理池里面总是包含了两个指针,一个指针是指向对象内容本身,一个指针是指向了方法区,因为一个完整的对外的对象是依靠这两部分被引用指针引用到的,而我们开发过程是不能够操作处理池的两个指针的,只有引用指针我们可以通过外围编程拿到。这里的引用指针就是平时提及到的“Java的引用”,只是JVM在引用指针还做了一定的封装,这种封装的规则是JVM本身设计的时候做的,它就通过这种结构在外围进行一次封装,比如Java引用不具备直接操作内存地址的能力就是该封装的一种限制规则。
  • (设计二)另外一种堆空间设计就是使用对象引用拿到的本地指针,将该指针直接指向绑定好的对象的实例数据,这些数据里面仅仅包含了一个指向方法区原子级别的数据去拿到该实例相关数据,这种情况下只需要引用一个指针来访问对象实例数据,但是这样的情况使得对象的移动以及对象的数据更新变得更加复杂。当JVM需要移动这些数据以及进行堆内存碎片的整理的时候,就必须直接更新该对象所有运行时的数据区

+(需要对象引用的原因)当一个程序试图将一个对象的引用转换成为另外一个类型的时候,JVM就会检查两个引用指向的对象是否存在父子类关系,并且检查两个对象是否能够进行类型转换,而且所有这种类型的转换必须执行同样的一个操作:instanceof操作,在上边两种情况下,JVM都必须要去分析引用指向的对象内部的数据,当一个程序调用了一个实例方法的时候,JVM就必须进行动态绑定操作,它必须选择调用方法的引用类型,是一个基于类的方法调用还是一个基于对象的方法调用,要做到这一点,它又要获取该对象的唯一引用才可以。不管对象的实现是使用什么方式来进行对象描述,都是在针对内存中关于该对象的方法表进行操作,因为使用这样的方式加快了实例针对方法的调用,而且在JVM内部实现的时候这样的机制使得其运行表现比较良好,所以方法表的设计在JVM整体结构中发挥了极其重要的作用。关于方法表的存在与否,在JVM规范里面没有严格说明,也有可能真正在实现过程只是一个抽象概念,物理层它根本不存在,它本身具有不太高的内存需要,如果该实现里面使用了方法表,则对象的方法表应该是可以很快被外围引用访问到

(有一种办法就是通过对象引用连接到方法表。在每个指针指向一个对象的时候,实际上是使用了一个特殊的数据结构,这些特殊的结构包括几个部分:一个指向该对象类所有数据的指针;该对象的方法表,方法表就是一个指针数组,它的每一个元素包含了一个指针,针对每个对象的方法都可以直接通过该指针在方法区中找到匹配的数据进行相关调用。而这些方法表需要包括的内容如下:方法内存堆栈段空间中操作栈的大小以及局部变量;方法字节码;一个方法的异常表。这些信息使得JVM足够针对该方法进行调用,在调用过程,这种结构也能够方便子类对象的方法直接通过指针引用到父类的一些方法定义,也就是说指针在内存空间之内通过JVM本身的调用使得父类的一些方法表也可以同样的方式被调用,当然这种调用过程避免不了两个对象之间的类型检查,但是这样的方式就使得继承的实现变得更加简单,而且方法表提供的这些数据足够引用对对象进行带有任何OO特征的对象操作?)

+(对象的锁——也是从逻辑上讲内存堆中的对象的真实数据结构)这一点可能需要关联到JMM模型中讲的进行理解。JVM中的每一个对象都是和一个锁(互斥)相关联的,这种结构使得该对象可以很容易支持多线程访问,而且该对象的对象锁一次只能被一个线程访问。当一个线程在运行的时候具有某个对象的锁的时候,仅仅只有这个线程可以访问该对象的实例变量,其他线程如果需要访问该实例的实例变量就必须等待这个线程将它占有的对象锁释放过后才能够正常访问,如果一个线程请求了一个被其他线程占有的对象锁,这个请求线程也必须等到该锁被释放过后才能拿到这个对象的对象锁。一旦这个线程拥有了一个对象锁以后,它自己可以多次向同一个锁发送对象的锁请求,但是如果它要使得被该线程锁住的对象可以被其他锁访问到的话就需要同样的释放锁的次数,比如线程A请求了对象B的对象锁三次,那么A将会一直占有B对象的对象锁,直到它将该对象锁释放了三次。(很多对象也可能在整个生命周期都没有被对象锁锁住过,在这样的情况下对象锁相关的数据是不需要对象内部实现的,除非有线程向该对象请求了对象锁,否则这个对象就没有该对象锁的存储结构。所以很多实现不包括指向对象锁的“锁数据”,锁数据的实现必须要等待某个线程向该对象发送了对象锁请求过后,而且是在第一次锁请求过后才会被实现。这个结构中,JVM却能够间接地通过一些办法针对对象的锁进行管理,比如把对象锁放在基于对象地址的搜索树上。实现了锁结构的对象中,每一个Java对象逻辑上都在内存中成为了一个等待集,这样就使得所有的线程在锁结构里面针对对象内部数据可以独立操作,等待集就使得每个线程能够独立于其他线程去完成一个共同的设计目标以及程序执行的最终结果,这样就使得多线程的线程独享数据以及线程共享数据机制很容易实现!)

+(堆结构里存在着堆内对象的一个类似拷贝的镜像机制)该镜像的主要目的是提供给垃圾回收器进行监控操作,垃圾回收器是通过对象的状态来判断该对象是否被应用,同样它需要针对堆内的对象进行监控。而当监控过程垃圾回收器收到对象回收的事件触发的时候,不论使用什么算法都需要通过独有的机制来判断对象目前处于哪种状态,然后根据对象状态进行操作。Java引用中的虚引用、弱引用(引用设置成为了null)就是使得Java引用在显示提交可回收状态的情况下对内存堆中的对象进行的反向监控,这些引用可以监视到垃圾回收器回收该对象的过程。垃圾回收器本身的实现也是需要内存堆中的对象能够提供相对应的数据的。

三,本机内存

        有些时候即使Java堆空间没有满也可能抛出错误,这种情况下需要了解的就是JRE内部到底发生了什么。Java本身的运行宿主环境并不是操作系统而是Java虚拟机,虚拟机本身是用C编写的本机程序,自然它会调用到本机资源,最常见的就是针对本机内存的调用。本机内存是可以用于运行时进程的,它和Java应用程序使用的Java堆内存不一样,每一种虚拟化资源都必须存储在本机内存里面,包括虚拟机本身运行的数据

事实上,堆和栈都是内存中的一部分,有着不同的作用,而且一个程序需要在这片区域上分配内存。众所周知,所有的Java程序都运行在JVM虚拟机内部,我们这里介绍的自然是JVM(虚拟)内存中的堆和栈。   一,区别 1,各司其职: 最主要的区别就是栈内存用来存储局部变量和方法调用。 而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。 2,独有还是共享: 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。 而堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。 3,异常错误: 如果栈内存没有可用的空间存储方法调用和局部变量,JVM会抛出java.lang.StackOverFlowError。 而如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。 4,空间大小: 栈的内存要远远小于堆内存,如果你使用递归的话,那么你的栈很快就会充满。如果递归没有及时跳出,很可能发生StackOverFlowError问题。 你可以通过-Xss选项设置栈内存的大小。-Xms选项可以设置堆的开始时的大小,-Xmx选项可以设置堆的最大值。   二,深入了解堆和栈 1,Java内存管理简介:         内存管理在Java语言中是JVM自动操作的,当JVM发现某些对象不再需要的时候,就会对该对象占用的内存进行重分配(释放)操作,而且使得分配出来的内存能够提供给所需要的对象。一般情况下在Java程序开发过程 手动内存管理称为显示内存管理。         显示内存管理经常发生的一个情况就是 引用悬挂——也就是说有可能在重新分配过程释放掉了一个被某个对象引用正在使用的内存空间,释放掉该空间后,该引用就处于悬挂状态。如果这个被悬挂引用指向的对象试图进行原来对象(因为这个时候该对象有可能已经不存在了)进行操作的时候,由于该对象本身的内存空间已经被手动释放掉了,这个结果是不可预知的。         显示内存管理另外一个常见的情况是 内存泄漏——当某些引用不再引用该内存对象的时候,而该对象原本占用的内存并没有被释放,这种情况简称为内存泄漏。比如,如果指针对某个链表进行了内存分配,而因为手动分配不当,仅仅让引用指向了某个元素所处的内存空间,这样就使得其他链表中的元素不能再被引用而且使得这些元素所处的内存让程序处于不可达状态并且这些对象所占有的内存也不能够被再使用,这个时候就发生了内存泄漏。而这种情况一旦在程序中发生,就会 一直消耗系统的可用内存直到可用内存耗尽,而针对计算机而言内存泄漏的严重程度大了会使得本来正常运行的程序直接因为内存不足而中断,并不是Java程序里面出现Exception那么轻量级。        在以前的编程过程中,手动内存管理带给计算机程序不可避免的错误,而且这种错误对计算机程序是毁灭性的,所以内存管理就成为了一个很重要的话题,但是针对大多数纯面向对象语言而言,比如Java,提供了语言本身具有的内存特性: 自动化内存管理,这种语言提供了一个程序 垃圾回收器Garbage Collector(GC),自动内存管理提供了一个抽象的接口以及更加可靠的代码使得内存能够在程序里面进行合理的分配。最常见的情况就是垃圾回收器避免了内存泄漏的问题,因为一旦这些对象没有被任何引用“可达”的时候,也就是这些对象在JVM的内存池里面成为了不可引用对象,该垃圾回收器会直接回收掉这些对象占用的内存,当然这些对象必须满足垃圾回收器回收的某些对象规则,而垃圾回收器在回收的时候会自动释放掉这些内存。不仅仅如此,垃圾回收器同样会解决悬挂引用的问题。   2,详解堆和栈: 1)通用简介(程序运行时有三种内存分配策略)         静态存储——是指在编译时就能够确定每个数据目标在运行时的存储空间需求,因而在编译时就可以给它们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间。         栈式存储——该分配可成为动态存储分配,是由一个类似于堆栈的运行栈来实现的,和静态存储的分配方式相反,在 栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到了运行的时候才能知道,但是规定在运行中进入一个程序模块的时候,必须知道该程序模块所需要的数据区的大小才能分配其内存。和我们在数据结构中所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。         堆式存储——堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例, 堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。 (对比C++语言程序占用的内存分为下边几个部分         栈区——由编译器自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。我们在程序中定义的局部变量就是存放在栈里,当局部变量的生命周期结束的时候,它所占的内存会被自动释放。         堆区——一般由程序员分配和释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。我们在程序中使用C++中new或者C中的malloc申请的一块内存,就是在heap上申请的,在使用完毕后,是需要我们自己动手释放的,否则就会产生“内存泄露”的问题         全局区Static——全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和静态变量在相邻的另一块区域。程序结束后由系统释放。         文字常量区——常量字符串就是放在这里的,程序结束后由系统释放。在Java中对应有一个字符串常量池。         程序代码区——存放函数体的二进制代码!) 2)JVM结构(堆、栈解析)         在Java虚拟机规范中,一个 虚拟机实例的行为主要描述为:子系统、内存区域、数据类型和指令,这些组件在描述了抽象的JVM内部的一个抽象结构。与其说这些组成部分的目的是进行JVM内部结构的一种支配,更多的是提供一种严格定义实现的外部行为,该规范定义了这些抽象组成部分以及相互作用的任何Java虚拟机执行所需要的行为。下图描述了JVM内部的一个结构,其中主要包括主要的子系统、内存区域。( Java虚拟机有一个类加载器作为JVM的子系统,类加载器针对Class进行检测以鉴定完全合格的类接口,而JVM内部也有一个执行引擎!)         当JVM运行一个程序的时候, 它的内存需要用来存储很多内容,包括字节码、从类文件中提取出来的一些附加信息、程序中实例化的对象、方法参数、返回值、局部变量以及计算的中间结果。JVM的内存组织需要在不同的运行时数据区进行以上的几个操作,下边针对上图出现的几个运行时数据区进行详细解析: (一些运行时数据区共享给所有应用程序线程和其他特有的单个线程, 每个JVM实例有一个方法区和一个内存堆,这些是共享给虚拟机内运行的线程。在Java程序里面, 每个新的线程启动过后,它就会被JVM在内部分配自己的PC寄存器PC registers,也叫程序计数器,和Java栈Java stacks。若该线程正在执行一个非本地Java方法,在PC寄存器的值指示下一条指令执行,该线程在Java内存栈中保存了非本地Java方法调用状态,其状态包括局部变量、被调用的参数、它的返回值、以及中间计算结果。而本地方法调用的状态则是存储在独立的本地方法内存栈native method stacks里面,这种情况下使得这些本地方法尽可能保证和其他(抽象的)内存运行时数据区的 内容独立,而且该方法的调用更靠近操作系统,这些方法执行的字节码有可能根据操作系统环境的不同使得其编译出来的本地字节码的结构也有一定的差异。JVM中的内存栈是一个栈帧的组合,一个栈帧包含了某个Java方法调用的状态,当某个线程调用方法的时候,JVM就会将一个新的帧压入到Java内存栈, 当方法调用完成过后,JVM将会从内存栈中移除该栈帧。JVM里面不存在一个可以存放中间计算数据结果值的寄存器,其内部指令集使用Java栈空间来存储中间计算的数据结果值,这种做法的设计是为了保持Java虚拟机的指令集紧凑,使得与寄存器原理能够紧密结合并且进行操作!)         方法区Method Area——在JVM实例中,对装载的类型信息是存储在一个逻辑方法内存区中的,当Java虚拟机加载了一个类型的时候,它会跟着这个Class的类型去路径里面查找对应的Class文件,类加载器读取类文件(线性二进制数据),然后将该文件传递给Java虚拟机, JVM从二进制数据中提取信息并且将这些信息存储在方法区,而类中声明的(静态)变量就是来自于方法区中存储的信息。在JVM里面用什么样的方式存储该信息是由JVM设计的时候决定的,例如:当数据进入方法区的时候,多类文件字节的存储量以Big-Endian(第一次最重要的字节)的顺序存储,尽管如此,一个虚拟机可以用任何方式针对这些数据进行存储操作,若它存储在一个Little-Endian处理器上,设计的时候就有可能将多文件字节的值按照Little-Endian顺序存储。(......)(JVM虚拟机将搜索和使用类型的一些信息也存储在方法区中以方便应用程序加载读取该数据。设计者在设计过程中也考虑到要方便JVM进行Java应用程序的快速执行,而这种取舍主要是为了程序在运行过程中内存不足的情况能够通过一定的取舍去弥补内存不足的情况。在 JVM内部,所有的线程共享相同的方法区,因此,访问方法区的数据结构必须是线程安全的,如果两个线程都试图去调用一个名为Lava的类,比如Lava还没有被加载,只有一个线程可以加载该类而另外的线程只能够等待。方法区的大小在分配过程中是不固定的,随着Java应用程序的运行, JVM可以调整其大小,需要注意一点,方法区的内存不需要是连续的,因为方法区内存可以分配在内存堆中,即使是虚拟机JVM实例对象自己所在的内存堆也是可行的,而在实现过程是允许程序员自身来指定方法区的初始化大小的 同样的,因为Java本身的自动内存管理, 方法区也会被垃圾回收的,Java程序可以通过类扩展动态加载器对象,类可以成为“未引用”向垃圾回收器进行申请,如果一个类是“未引用”的,则该类就可能被卸载,而方法区针对具体的语言特性有几种信息是存储在方法区内的!) +(类型信息)在JVM和类文件名的内部,类型名一般都是完全限定名java.lang.String格式,在Java源文件里面,完全限定名必须加入包前缀,而不是我们在开发过程写的简单类名,而在方法上,只要是符合Java语言规范的类的完全限定名都可以,而JVM可能直接进行解析,比如:java.lang.String在JVM内部名称为java/lang/String,这就是我们在异常捕捉的时候经常看到的ClassNotFoundException的异常里面类信息的名称格式。(除此之外,还必须为每一种加载过的类型在JVM内进行存储,下边的信息有不存储在方法区内的!) +(常量池)针对 类型加载的类型信息,JVM将这些存储在常量池里,常量池是一个根据类型定义的常量的有序常量集,包括字面量(String、Integer、Float常量)以及符号引用(类型、字段、方法),整个常量池会被JVM的一个索引引用,如同数组里面的元素集合按照索引访问一样, JVM针对这些常量池里面存储的信息也是按照索引方式进行。实际上常量池在Java程序的动态链接过程起到了一个至关重要的作用。 +(字段信息)针对字段的类型信息,信息是存储在方法区里面的有字段名、字段类型、字段修饰符(public,private,protected,static,final,volatile,transient) +(方法信息)针对方法信息,信息存储在方法区上的有方法名、方法的返回类型(包括void)、方法参数的类型和数目以及顺序、方法修饰符(public,private,protected,static,final,synchronized,native,abstract);针对非本地方法,还有些附加方法信息需要存储在方法区内的是方法字节码、方法中局部变量区的大小和方法栈帧、异常表。 +(类变量)类变量在一个类的多个实例之间共享,这些变量直接和类相关,而不是和类的实例相关,针对类变量,其 逻辑部分就是存储在方法区内的。在JVM使用这些类之前,JVM先要在方法区里面为定义的non-final变量分配内存空间;常量(定义为final)则在JVM内部不是以同样的方式来进行存储的,尽管针对常量而言,一个final的类变量是拥有它自己的常量池,作为常量池里面的存储某部分, 类常量是存储在方法区内的,而其逻辑部分则不是按照上边的类变量的方式来进行内存分配的。虽然non-final类变量是作为这些类型声明中存储数据的某一部分,final变量存储为任何使用它类型的一部分的数据格式进行简单存储。 +(ClassLoader引用)对于每种类型的加载,JVM必须检测其类型是否符合了JVM的语言规范, 对于通过类加载器加载的对象类型,JVM必须存储对类的引用,而这些针对类加载器的引用是作为方法区里面的类型数据部分进行存储的。(程序中就可以随意用这个引用了!) +(类Class的引用)JVM在加载了任何一个类型过后会创建一个java.lang.Class的实例, 虚拟机必须通过一定的途径来引用该类型对应的一个Class实例,并且将其存储在方法区内。 +(方法表)为了提高访问效率,必须仔细地设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还添加了一些其他的数据结构,如方法表。         内存栈Stack——当一个新线程启动的时候, JVM会为Java线程创建每个线程的独立内存栈,如前所言Java的内存栈是由栈帧构成,栈帧本身处于游离状态,在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法一般称为当前线程方法,而该方法的栈帧就称为当前帧,而 在该方法内定义的类称为当前类,常量池也称为当前常量池。当执行一个方法如此的时候,JVM保留当前类和当前常量池的跟踪,当虚拟机遇到了存储在栈帧中的数据上的操作指令的时候,它就执行当前帧的操作,当一个线程调用某个Java方法时,虚拟机创建并且将一个新帧压入到内存堆栈中,而这个压入到内存栈中的帧成为当前栈帧,当该方法执行的时候,JVM使用内存栈来存储参数、局部变量、中间计算结果以及其他相关数据。方法在执行过程有可能因为两种方式而结束:如果一个方法返回完成就属于方法执行的正常结束,如果在这个过程抛出异常而结束,可以称为非正常结束, 不论是正常结束还是异常结束,JVM都会弹出或者丢弃该栈帧,则上一帧的方法就成为了当前帧。(在JVM中,Java线程的栈数据是属于某个线程独有的,其他的线程不能够修改或者通过其他方式来访问该线程的栈帧,正因为如此这种情况不用担心多线程同步访问Java的局部变量, 当一个线程调用某个方法的时候,方法的局部变量是在方法内部进行的Java栈帧的存储,只有当前线程可以访问该局部变量,而其他线程不能随便访问该内存栈里面存储的数据。内存栈内的栈帧数据和方法区以及内存堆一样, Java栈的栈帧不需要分配在连续的堆栈内,或者说它们可能是在堆,或者两者组合分配 实际数据用于表示Java堆栈和栈帧结构是JVM本身的设计结构决定的,而且在编程过程可以 允许程序员指定一个用于Java堆栈的初始化大小以及最大、最小尺寸!)         内存堆Heap——当一个Java应用程序在运行的时候在程序中创建一个对象或者一个数组的时候,JVM会针对该对象和数组分配一个新的内存堆空间。但是在JVM实例内部,只存在一个内存堆实例,所有的依赖该JVM的Java应用程序都需要共享该堆实例,而Java应用程序本身在运行的时候它自己包含了一个由JVM虚拟机实例分配的自己的堆空间,而在应用程序启动的时候,任何一个Java应用程序都会得到JVM分配的堆空间,而且针对每一个Java应用程序, 这些运行Java应用程序的堆空间都是相互独立的。这里所提及到的共享堆实例是指JVM在初始化运行的时候整体堆空间只有一个,这个是Java语言平台直接从操作系统上能够拿到的整体堆空间,所有的依赖该JVM的程序都可以得到这些内存空间,但是针对每一个独立的Java应用程序而言,这些堆空间是相互独立的,每一个Java应用程序在运行最初都是依靠JVM来进行堆空间的分配的。在开发多线程程序的时候,针对同一个Java应用程序必须考虑线程安全问题,因为在一个Java进程里面所有线程是可以共享这个进程拿到的堆空间的数据的。但是Java内存堆有一个特性,就是JVM 拥有针对新的对象分配内存的指令,但是它却不包含释放该内存空间的指令,当然开发过程可以在Java源代码中显示释放内存或者说在JVM字节码中进行显示的内存释放,但是JVM仅仅只是检测堆空间中是否有引用不可达(不可以引用)的对象,然后将接下来的操作交给垃圾回收器来处理。 +(对象表示)对象表示可以理解为独立的一种Java对象在内存中的存储结构,该结构是由设计最初考虑的。针对一个创建的 类实例而言,它内部定义的实例变量以及它的超类以及一些相关的核心数据,是必须通过一定的途径进行该对象内部存储以及表示的。当开发过程给定了一个对象引用的时候,JVM必须能够通过这个引用快速从对象堆空间中去拿到该对象能够访问的数据内容,也就是说,堆空间内对象的存储结构必须为外围对象引用提供一种可以访问该对象以及控制该对象的接口使得引用能够顺利地调用该对象以及相关操作。因此, 针对堆空间的对象,分配的内存中往往也包含了一些指向方法区的指针,因为从整体存储结构上讲,方法区似乎存储了很多原子级别的内容,包括方法区内最原始最单一的一些变量:比如类字段、字段数据、类型数据等等。而JVM本身针对堆空间的管理存在两种设计结构。
  • (设计一)堆空间的设计可以划分为两个部分:一个处理池和一个对象池,一个对象的引用可以拿到处理池的一个本地指针,而处理池主要分为两个部分:一个指向对象池里面的指针以及一个指向方法区的指针。这种结构的优势在于JVM在处理对象的时候,更加能够方便地组合堆碎片以使得所有的数据被更加方便地进行调用。当JVM需要将一个对象移动到对象池的时候,它仅仅需要更新该对象的指针到一个新的对象池的内存地址中就可以完成了,然后在处理池中针对该对象的内部结构进行相对应的处理工作。不过这样的方法也会出现一个缺点就是在处理一个对象的时候针对对象的访问需要提供两个不同的指针,其实可以这样讲,真正在对象处理过程存在一个根据时间戳有区别的对象状态(一个引用指向过不同对象?),而对象在移动、更新以及创建的整个过程中,它的处理池里面总是包含了两个指针,一个指针是指向对象内容本身,一个指针是指向了方法区,因为一个完整的对外的对象是依靠这两部分被引用指针引用到的,而我们开发过程是不能够操作处理池的两个指针的,只有引用指针我们可以通过外围编程拿到。这里的引用指针就是平时提及到的“Java的引用”,只是JVM在引用指针还做了一定的封装,这种封装的规则是JVM本身设计的时候做的,它就通过这种结构在外围进行一次封装,比如Java引用不具备直接操作内存地址的能力就是该封装的一种限制规则。
  • (设计二)另外一种堆空间设计就是使用对象引用拿到的本地指针,将该指针直接指向绑定好的对象的实例数据,这些数据里面仅仅包含了一个指向方法区原子级别的数据去拿到该实例相关数据,这种情况下只需要引用一个指针来访问对象实例数据,但是这样的情况使得对象的移动以及对象的数据更新变得更加复杂。当JVM需要移动这些数据以及进行堆内存碎片的整理的时候,就必须直接更新该对象所有运行时的数据区

猜你喜欢

转载自coyotestark.iteye.com/blog/2293730