JAVA内存管理

Java内存管理

     这篇文章是在详细读完《深入理解Java虚拟机》这本书并且阅读了大量网络博文之后总结而成的。限于时间和作者水平,如有错误,欢迎指正。整体包含3个部分:
(1)理解Java虚拟机的组成部分
(2)Java虚拟机的垃圾收集算法
(3)分代收集器的详细机制
     整个文章是按照虚拟机的不断发展而逐步展开的。先说明虚拟机内存组成,说明各个部分内存的管理方式,第二部分则是管理方法的不断演变,而第三部分则是现在比较成熟的管理方式。在学习Java内存分配原理的时候一定要牢记这一切都是在JVM中进行的,JVM是内存分配原理的基础与前提。常说的Java内存管理实质上就是JVM的内存管理。


JAVA内存管理小述:

按照数据用途的不同,JAVA大致可以分为Stack和heap和Method Area,

(1)Stack中存储基本类型变量和对象引用;生命周期与线程相同

(2)Method Area中主要用于存储Ojbect中的对象类型数据(如对象类型(类名),访问修饰符,常量池,父类,实现的接口,方法等)。一般不需要做内存回收。

(2)heap中存储的是各种Object的属性数据和在方法区方法地址的引用。JAVA虚拟机会争对这些Object做自动的垃圾回收。

     垃圾回收的原理是确定仍活着的(有用的)Object,然后清除剩下的,确定仍然活着的Object方法,最早采用的方法是引用计数法,但是这种方法无法解决无用Object之间的循环引用而导致计数器用于无法清零的问题。所以现在的采用的都是跟踪收集器,跟踪收集器从一些gc root对象(主要为虚拟机栈中的内容)开始,依次向下遍历记录所有的可达的Object,其他不可达到的区域就全部是可以回收的部分。在标记有用对象后 ,需要清除无用Object以回收内存,清除算法也演进了很多代,最早的是标记-清除算法,但是它会导致很多的内存碎片。为解决内存碎片问题,于是出现了复制算法,为了减少复制算法的内存浪费,一次出现了复制收集器(2个区域),增量收集器(多个区域),分代收集器。


1》理解Java虚拟机中的内存组成

     在《深入理解java虚拟机》一书中,对java的内存管理机制有非常深入的理解。需要说明的是该书所介绍的是《Java虚拟机规范》中所定义的虚拟机机制。实际上各个厂家(sun,IBM)在实际实现时会根据需求和具体情况自己定义内部情况。Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和消失。

生命周期与用户线程的数据区:
     程序计数器:每条线程都需要有一个独立的程序计数器。
     Stack(栈)主要存储基本类型变量(int count)和对象引用。生命周期与线程相同。每个方法被被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态连接,方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。Stack的速度较快,管理简单,但每次操作的数据或者指令字节长度是已知的和确定的。

生命周期与JVM相同的数据区:
     Heap(堆)被所有线程共享,在虚拟机启动时创建。主要用于存储Object(对象实例)中的属性(属性的类型和属性值)。在Heap 中分配一定的内存保存对象实例和对象的序列化比较类似。而对象实例(例如数组,StudentBean)在Heap 中分配好以后,需要在Stack中保存一个4字节(32位系统)的Heap 内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例。(堆上还需要有对象类型数据在方法区中的地址,某个类的方法信息不应该每个对象实例都复制一份,因为对于不同对象来说,它们都是相同的)。当前主流的虚拟机都是按照可扩展来实现的(通过-Xms和-Xmx控制)。如果队中没有内存完成实例分配,且对也无法再扩展时,将会抛出OutOfMemoryError异常。
     Method Area(方法区)被所有线程共享,在虚拟机启动时创建。主要用于存储Ojbect中的对象类型数据(如对象类型(类名),访问修饰符,常量池,父类,实现的接口,方法等)。方法区在虚拟机启动时创建。尽管方法区在逻辑上时heap的一部分,简单的实现仍然可以选择对它既不回收也不压缩。

各个数据区的回收机制:

(1)Stack的内存管理是随着线程的生命周期自动管理的;

(2)Heap 则是随机分配内存,不定长度,存在内存分配和回收的问题

(3)Method Area则主要回收两部分内容:废弃常量和无用的类。一般回收效率很低,只有在大量使用反射,CGLib等bytecode框架的场景,以及动态生成JSP和OSGi这类频繁自定义ClassLoader的场景才需要虚拟机具备类卸载的功能,以保证方法区(永久代)不会溢出。

运行时数据区域(如图)

Image(1)

非静态方法和静态方法的区别:

  非静态方法有一个和静态方法很重大的不同:非静态方法有一个隐含的传入参数,该参数是JVM给它的,和我们怎么写代码无关,这个隐含的参数就是对象实例在Stack中的地址指针。因此非静态方法(在Stack中的指令代码)总是可以找到自己的专用数据(在Heap 中的对象属性值)。当然非静态方法也必须获得该隐含参数,因此非静态方法在调用前,必须先new一个对象实例,获得Stack中的地址指针,否则JVM将无法将隐含参数传给非静态方法。

     静态方法无此隐含参数,因此也不需要new对象,只要class文件被ClassLoader load进入JVM的Stack,该静态方法即可被调用。当然此时静态方法是存取不到Heap 中的对象属性的。

     总结一下该过程:当一个class文件被ClassLoader load进入JVM后,方法指令保存在Stack中,此时Heap 区没有数据。然后程序技术器开始执行指令,如果是静态方法,直接依次执行指令代码,当然此时指令代码是不能访问Heap 数据区的;如果是非静态方法,由于隐含参数没有值,会报错。因此在非静态方法执行前,要先new对象,在Heap 中分配数据,并把Stack中的地址指针交给非静态方法,这样程序技术器依次执行指令,而指令代码此时能够访问到Heap 数据区了。

静态属性和动态属性:

  在JVM中,静态属性保存在Stack中,动态属性保存在Heap中。


2》Java中垃圾回收机制   

垃圾收集GC(Garbage Collection):

     垃圾收集的目的在于清除不再使用的对象。GC通过确定对象是否被活动对象引用来确定是否收集该对象。两种常用的方法是引用计数和对象引用遍历。

引用计数收集器

     引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象(不是引用)都有一个引用计数。当一个对象被创建时,且将该对象分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象+1),但当一个对象的某个引用超过了生命周期或者被设置为一个新值时,对象的引用计数减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

     缺点: 无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0(如下图中的Object6,7,8).

跟踪收集器

     早期的JVM使用引用计数,现在大多数JVM采用对象引用遍历。对象引用遍历从一组对象开始,沿着整个对象图上的每条链接,递归确定可到达(reachable)的对象。如果某对象不能从这些根对象的一个(至少一个)到达,则将它作为垃圾收集。在对象遍历阶段,GC必须记住哪些对象可以到达,以便删除不可到达的对象,这称为标记(marking)对象。

     跟踪收集器采用的为集中式的管理方式,全局记录对象之间的引用状态,执行时从一些列GC  Roots的对象做为起点,从这些节点向下开始进行搜索所有的引用链,当一个对象到GC  Roots 没有任何引用链时,则证明此对象是不可用的。
     下图中,对象Object6、Object7、Object8虽然互相引用,但他们的GC Roots是不可到达的,所以它们将会被判定为是可回收的对象。

Image(2)

可作为GC Roots 的对象包括:

虚拟机栈(栈帧中的本地变量表)中的引用对象。

方法区中的类静态属性引用的对象

方法区中的常量引用的对象

本地方法栈中JNI的引用对象。

在找到仍然有用的点之后,GC要删除不可到达的对象。关于如何删除,历史上有下来算法 :

常用的垃圾收集算法

(1)标记-清除算法

     这种算法首先遍历对象图并标记可到达的对象,然后扫描堆栈以寻找未标记对象并释放它们的内存。这种收集器一般使用单线程工作并停止其他操作。并且,由于它只是清除了那些未标记的对象,而并没有对标记对象进行压缩,导致会产生大量内存碎片,从而浪费内存。

(2)标记-压缩算法

     有时也叫标记-清除-压缩算法,与标记-清除收集器有相同的标记阶段。在第二阶段,则把标记对象复制到堆栈的新域中以便压缩堆栈。这种算法也停止其他操作。

(3)复制算法

     这种算法将堆栈分为两个域,常称为半空间。每次仅使用一半的空间,JVM生成的新对象则放在另一半空间中。GC运行时,它把可到达对象复制到另一半空间,从而压缩了堆栈。这种方法适用于短生存期的对象,持续复制长生存期的对象则导致效率降低。并且对于指定大小堆来说,需要两倍大小的内存,因为任何时候都只使用其中的一半。

(4) 增量算法

     增量算法把堆栈分为多个域,每次仅从一个域收集垃圾,也可理解为把堆栈分成一小块一小块,每次仅对某一个块进行垃圾收集。这会造成较小的应用程序中断时间,使得用户一般不能觉察到垃圾收集器正在工作。

(5)分代算法

     复制算法的缺点是:每次收集时,所有的标记对象都要被拷贝,从而导致一些生命周期很长的对象被来回拷贝多次,消耗大量的时间。而分代算法则可解决这个问题,分代算法把堆栈分为两个或多个域,用以存放不同寿命的对象。JVM生成的新对象一般放在其中的某个域中。过一段时间,继续存在的对象(非短命对象)将获得使用期并转入更长寿命的域中。分代算法对不同的域使用不同的算法以优化性能。

使用垃圾收集器要注意的地方

(1)Java语言允许程序员为任何方法添加finalize( )方法,该方法会在垃圾收集器交换回收对象之前被调用。但不要过分依赖该方法对系统资源进行回收和再利用,因为该方法调用后的执行结果是不可预知的。

(2)垃圾收集器不可以被强制执行,但程序员可以通过调研System.gc方法来建议执行垃圾收集。记住,只是建议。一般不建议自己写System.gc,因为会加大垃圾收集工作量。


3》分代收集器的详细机制

为什么要分代

     分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

     在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

     试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

如何分代

如图所示:

Image

    

     虚拟机中的共划分为三个代:年轻代(Young Generation)、年老代(Old Generation)和持久代(Permanent Generation)。其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。

年轻代:

     所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代:

     在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

持久代:

     用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=<N>进行设置。

什么情况下触发垃圾回收

     由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC和Full GC。一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。而Full GC则是对整个堆进行整理,包括Young、Tenured和Perm。

     Full GC因为需要对整个块进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC:

     · 年老代(Tenured)被写满

     · 持久代(Perm)被写满

     · System.gc()被显示调用

     ·上一次GC之后Heap的各域分配策略动态变化


在整个介绍完之后,我们在回头做一下综合理解:

按照数据用途的不同,JAVA大致可以分为Stack和heap和Method Area,

(1)Stack中存储基本类型变量和对象引用;生命周期与线程相同

(2)Method Area中主要用于存储Ojbect中的对象类型数据(如对象类型(类名),访问修饰符,常量池,父类,实现的接口,方法等)。一般不需要做内存回收。

(2)heap中存储的是各种Object的属性数据和在方法区方法地址的引用。JAVA虚拟机会争对这些Object做自动的垃圾回收。

     垃圾回收的原理是确定仍活着的(有用的)Object,然后清除剩下的,确定仍然活着的Object方法,最早采用的方法是引用计数法,但是这种方法无法解决无用Object之间的循环引用而导致计数器用于无法清零的问题。所以现在的采用的都是跟踪收集器,跟踪收集器从一些gc root对象(主要为虚拟机栈中的内容)开始,依次向下遍历记录所有的可达的Object,其他不可达到的区域就全部是可以回收的部分。在标记有用对象后 ,需要清除无用Object以回收内存,清除算法也演进了很多代,最早的是标记-清除算法,但是它会导致很多的内存碎片。为解决内存碎片问题,于是出现了复制算法,为了减少复制算法的内存浪费,一次出现了复制收集器(2个区域),增量收集器(多个区域),分代收集器。



链接知识:

(1)参考字符串对象的优化:

     Java虚拟机中Stack的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。栈中主要存放一些基本类型的变量(,int, short, long, byte, float, double, boolean, char)和对象句柄。
栈有一个很重要的特殊性,就是存在栈中的数据可以共享。假设我们同时定义:
     int a = 3;
     int b = 3;
     编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b = 3;在创建完b的引用变量后,因为在栈中已经有3这个值,便将b直接指向3。这样,就出现了a与b同时均指向3的情况。这时,如果再令a=4;那么编译器会重新搜索栈中是否有4值,如果没有,则将4存放进来,并令a指向4;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。要注意这种数据的共享与两个对象的引用同时指向一个对象的这种共享是不同的,因为这种情况a的修改并不会影响到b, 它是由编译器完成的,它有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量 (这个特性在需要处理大量String时很有用)

不积跬步无以至千里,不积小流无以成江海。业精于勤而荒于嬉,行成于思而毁于随
原文地址:https://www.cnblogs.com/weilf/p/3425689.html